From ba579e68db8767b9f599d51ef8b6ffb3d58ce95c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 21:20:53 +0600 Subject: [PATCH] installer: add explicit asset conflict override --- .changeset/installer-asset-override.md | 5 ++ CONTRIBUTING.md | 2 +- api/README.md | 4 +- api/tests/unit/tooling/installer-cli.test.js | 29 +++++++++ api/tests/unit/tooling/installer-core.test.js | 60 +++++++++++++++++++ .../unit/tooling/profile-installer.test.js | 14 +++++ api/tooling/installer.js | 34 +++++++---- 7 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 .changeset/installer-asset-override.md diff --git a/.changeset/installer-asset-override.md b/.changeset/installer-asset-override.md new file mode 100644 index 0000000..5221722 --- /dev/null +++ b/.changeset/installer-asset-override.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Operators can pass `--override` to replace conflicting assets declared by the bundle; without it, the installer still refuses conflicts before writing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2523787..5f45f6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,4 +51,4 @@ For the pinned HappyView release, ordinary Lua `error()` exceptions return HTTP ## Operations with external effects -`pnpm install:api` sends admin requests to a HappyView instance and uploads declared assets. Run it only for an explicitly approved target with an approved token. It does not roll back writes if a later asset fails. Review the target and release notes before installing a released bundle; see [api/README.md](api/README.md). +`pnpm install:api` sends admin requests to a HappyView instance and uploads declared assets. By default, conflicting declared assets stop the install before asset writes; `pnpm install:api --override` explicitly replaces only those conflicts. Override does not bypass source/dependency validation, authentication, or profile resolver-setting requirements, and writes are not rolled back if a later asset fails. Run the installer only for an explicitly approved target with an approved token. Review the target and release notes before installing a released bundle; see [api/README.md](api/README.md). diff --git a/api/README.md b/api/README.md index c7e486b..8dfc73f 100644 --- a/api/README.md +++ b/api/README.md @@ -53,7 +53,9 @@ HAPPYVIEW_BASE_URL='https://your-happyview.example' HAPPYVIEW_ADMIN_TOKEN=' { + const script = fileURLToPath(new URL('../../../tooling/installer.js', import.meta.url)); + const child = spawnSync(process.execPath, [script, '--help'], { + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '' }, + }); + + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /Usage: pnpm install:api .*--override/); + assert.match(child.stdout, /Without it, conflicts are refused before any asset writes/); + assert.equal(child.stderr, ''); +}); + +test('CLI accepts --override and --debug before target validation', () => { + const script = fileURLToPath(new URL('../../../tooling/installer.js', import.meta.url)); + const child = spawnSync(process.execPath, [script, '--override', '--debug'], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + HAPPYVIEW_BASE_URL: 'not a URL', + HAPPYVIEW_ADMIN_TOKEN: 'hv_cli-test-token', + }, + }); + + assert.equal(child.status, 1); + assert.equal(child.stdout, ''); + assert.equal(child.stderr, 'HappyView admin URL must be a valid HTTP(S) URL\n'); +}); + test('CLI reports an actionable error for a malformed HappyView admin URL', () => { const script = fileURLToPath(new URL('../../../tooling/installer.js', import.meta.url)); const child = spawnSync(process.execPath, [script], { diff --git a/api/tests/unit/tooling/installer-core.test.js b/api/tests/unit/tooling/installer-core.test.js index a3d261f..58ca7d6 100644 --- a/api/tests/unit/tooling/installer-core.test.js +++ b/api/tests/unit/tooling/installer-core.test.js @@ -26,6 +26,66 @@ test('installer preflights every asset before writing and refuses conflicts', as assert.deepEqual(written, []); }); +test('override replaces differing declared assets only after preflighting the complete bundle', async () => { + const lexicon = { + id: 'org.example.query', + kind: 'lexicon', + config: { backfill: false }, + lexicon_json: { lexicon: 1, id: 'org.example.query', defs: { main: { type: 'query', description: 'incoming' } } }, + }; + const conflictingScript = { id: 'conflicting-script', kind: 'script', config: { script_type: 'lua' }, body: 'incoming script' }; + const unchangedScript = { id: 'unchanged-script', kind: 'script', config: { script_type: 'lua' }, body: 'same script' }; + const missingScript = { id: 'missing-script', kind: 'script', config: { script_type: 'lua' }, body: 'new script' }; + const installed = new Map([ + [lexicon.id, { + config: { backfill: true }, + lexicon_json: { lexicon: 1, id: lexicon.id, defs: { main: { type: 'query', description: 'installed' } } }, + }], + [conflictingScript.id, { config: conflictingScript.config, body: 'installed script' }], + [unchangedScript.id, { config: unchangedScript.config, body: unchangedScript.body }], + ]); + const events = []; + const writes = []; + + const result = await applyAssets([lexicon, conflictingScript, unchangedScript, missingScript], { + read: async ({ id }) => { events.push(`read:${id}`); return installed.get(id) ?? null; }, + write: async (asset) => { events.push(`write:${asset.id}`); writes.push(asset); }, + }, { override: true }); + + assert.deepEqual(events, [ + 'read:org.example.query', + 'read:conflicting-script', + 'read:unchanged-script', + 'read:missing-script', + 'write:org.example.query', + 'write:conflicting-script', + 'write:missing-script', + ]); + assert.equal(writes[0].lexicon_json.defs.main.description, 'incoming'); + assert.equal(writes[1].body, 'incoming script'); + assert.deepEqual(result, { + changed: ['org.example.query', 'conflicting-script', 'missing-script'], + unchanged: ['unchanged-script'], + }); +}); + +test('override does not bypass later asset preflight errors or write partial results', async () => { + const events = []; + await assert.rejects(() => applyAssets([ + { id: 'conflict', kind: 'script', config: {}, body: 'incoming' }, + { id: 'unavailable', kind: 'script', config: {}, body: 'incoming' }, + ], { + read: async ({ id }) => { + events.push(`read:${id}`); + if (id === 'conflict') return { config: {}, body: 'installed' }; + throw new Error('HappyView GET /admin/scripts/unavailable returned HTTP 403'); + }, + write: async ({ id }) => { events.push(`write:${id}`); }, + }, { override: true }), /HTTP 403/); + + assert.deepEqual(events, ['read:conflict', 'read:unavailable']); +}); + test('debug conflict shows differing config and canonical Lexicon paths without writing', async () => { const asset = { id: 'org.example.query', kind: 'lexicon', config: { backfill: false }, diff --git a/api/tests/unit/tooling/profile-installer.test.js b/api/tests/unit/tooling/profile-installer.test.js index ff48bb0..804c2f6 100644 --- a/api/tests/unit/tooling/profile-installer.test.js +++ b/api/tests/unit/tooling/profile-installer.test.js @@ -147,6 +147,20 @@ test('asset conflicts are all preflighted before resolver setting interaction or assert.deepEqual(prompts, []); }); +test('override does not bypass the resolver setting requirement for a conflicting profile handler', async () => { + const admin = fakeAdmin({ conflictId: profileHandlerId }); + await assert.rejects(() => apply(assets([profileHandlerId]), admin, { + override: true, + env: {}, + isTTY: false, + }), /HYPERCERTS_HANDLE_RESOLVER_URL is required/); + + assert.deepEqual(admin.events, [ + ['read', profileHandlerId], + ['list-script-variables'], + ]); +}); + test('resolver setting permission failures explain the required token scopes', async () => { const readAdmin = fakeAdmin({ listFailure: new Error('HappyView GET /admin/script-variables returned HTTP 403') }); await assert.rejects(() => apply(assets([profileHandlerId]), readAdmin, { diff --git a/api/tooling/installer.js b/api/tooling/installer.js index 9d2bd0f..d2e5de9 100644 --- a/api/tooling/installer.js +++ b/api/tooling/installer.js @@ -18,7 +18,7 @@ import { readLexiconSource } from './lexicon-source.js'; /** @typedef {ScriptManifestAsset & { path: string; body: string }} LoadedScriptAsset */ /** @typedef {LoadedLexiconAsset | LoadedScriptAsset} LoadedAsset */ /** @typedef {{ id: string; kind?: 'lexicon' | 'script'; dependsOn?: string[] }} OrderableAsset */ -/** @typedef {'missing' | 'unchanged'} InstallState */ +/** @typedef {'missing' | 'conflict' | 'unchanged'} InstallState */ /** @typedef {{ asset: LoadedAsset; state: InstallState }} AssetInstallState */ /** @typedef {{ config?: AssetConfig | null; lexicon_json?: unknown; body?: unknown }} InstalledAsset */ /** External GET /admin/lexicons/:id response assertion; response.json() is not runtime-validated. @typedef {{ backfill: boolean; target_collection: string | null; action: string | null; token_cost: number | null; lexicon_json: unknown }} LexiconAdminRow */ @@ -26,7 +26,7 @@ import { readLexiconSource } from './lexicon-source.js'; /** @typedef {{ read: (asset: LoadedAsset) => Promise; write: (asset: LoadedAsset) => Promise; listScriptVariables?: () => Promise; createScriptVariable?: (key: string, value: string) => Promise }} AdminClient */ /** @typedef {{ changed: string[]; unchanged: string[] }} InstallResult */ /** @typedef {{ key: string; status: 'exists-unverified' | 'created' }} ResolverSetting */ -/** @typedef {{ env?: Record; isTTY?: boolean; ask?: (prompt: string) => Promise; onNotice?: (message: string) => void; debug?: boolean }} ApplyAssetsOptions */ +/** @typedef {{ env?: Record; isTTY?: boolean; ask?: (prompt: string) => Promise; onNotice?: (message: string) => void; debug?: boolean; override?: boolean }} ApplyAssetsOptions */ /** @typedef {InstallResult & { resolverSetting?: ResolverSetting }} ApplyAssetsResult */ /** @typedef {Error & { completed: string[]; remaining: string[]; resolverSetting?: ResolverSetting }} PartialInstallError */ @@ -223,13 +223,13 @@ export function orderAssets(assets) { } /** @param {LoadedAsset[]} ordered @param {AdminClient} client @param {ApplyAssetsOptions} [options] @returns {Promise} */ -async function preflightAssets(ordered, client, { debug = false } = {}) { +async function preflightAssets(ordered, client, { debug = false, override = false } = {}) { const states = []; for (const asset of ordered) { const installed = await client.read(asset); const state = compareAsset(asset, installed); - if (state === 'conflict') { - const detail = debug ? `\n${describeAssetConflict(asset, /** @type {InstalledAsset} */ (installed))}` : ' (rerun with --debug to see the difference)'; + if (state === 'conflict' && !override) { + const detail = debug ? `\n${describeAssetConflict(asset, /** @type {InstalledAsset} */ (installed))}` : ' (use --override to replace it, or rerun with --debug to see the difference)'; throw new Error(`Refusing ${asset.id}: unexpected installed difference; inspect and resolve manually before retrying${detail}`); } states.push({ asset, state }); @@ -238,7 +238,7 @@ async function preflightAssets(ordered, client, { debug = false } = {}) { } /** @param {AssetInstallState[]} states @param {AdminClient} client @returns {Promise} */ -async function writeMissingAssets(states, client) { +async function writeAssets(states, client) { const changed = []; for (let i = 0; i < states.length; i++) { const { asset, state } = states[i]; @@ -346,7 +346,7 @@ export async function applyAssets(assets, client, options = {}) { const ordered = orderAssets(assets); const states = await preflightAssets(ordered, client, options); if (!ordered.some(({ id }) => PROFILE_LOOKUP_ASSET_IDS.has(id))) { - return writeMissingAssets(states, client); + return writeAssets(states, client); } const resolverSetting = await installResolverSetting(client, { @@ -356,7 +356,7 @@ export async function applyAssets(assets, client, options = {}) { onNotice: options.onNotice ?? ((message) => console.log(message)), }); try { - const result = await writeMissingAssets(states, client); + const result = await writeAssets(states, client); return { ...result, resolverSetting }; } catch (cause) { if (resolverSetting.status !== 'created') throw cause; @@ -659,14 +659,28 @@ export async function resolveInstallConfig({ }; } +const INSTALLER_USAGE = `Usage: pnpm install:api [--override] [--debug] [--help] + +Options: + --override Replace differing installed assets declared by this bundle. + Without it, conflicts are refused before any asset writes. + --debug Include incoming and installed values in conflict errors. + --help Show this help and exit.`; + async function main() { const args = process.argv.slice(2); - if (args.some((arg) => arg !== '--debug')) throw new Error('Unknown installer option; use --debug to print conflicting asset values'); + const unknown = args.find((arg) => !['--debug', '--override', '--help'].includes(arg)); + if (unknown) throw new Error(`Unknown installer option ${unknown}; use --help to see supported options`); const debug = args.includes('--debug'); + const override = args.includes('--override'); + if (args.includes('--help')) { + console.log(INSTALLER_USAGE); + return; + } const { baseUrl: rawBaseUrl, token } = await resolveInstallConfig(); const client = createAdminClient({ baseUrl: rawBaseUrl, token }); const { assets } = await loadAssets(fileURLToPath(new URL('../manifest.json', import.meta.url))); - const result = await applyAssets(assets, client, { debug }); + const result = await applyAssets(assets, client, { debug, override }); console.log(JSON.stringify(result, null, 2)); }