Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/installer-asset-override.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,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).
4 changes: 3 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ HAPPYVIEW_BASE_URL='https://your-happyview.example' HAPPYVIEW_ADMIN_TOKEN='<scop

Review that release's notes and target only an explicitly approved HappyView instance.

The reusable installer validates every local asset and dependency before making admin requests. `pnpm install:api` contacts a HappyView instance and uploads declared assets; do not run it without an explicitly approved target and token. It does not roll back writes if a later asset fails.
The installer validates all local assets and dependencies before making admin requests, then checks installed versions before writing. By default, any conflicting declared asset stops the install before asset writes. Pass `--override` to replace only conflicting assets declared by this bundle; it does not affect undeclared assets or bypass source/dependency validation, admin authentication, or the profile resolver-setting requirements. Use `--debug` to include incoming and installed values in conflict errors, or `--help` to list the options.

`pnpm install:api` contacts a HappyView instance and uploads declared assets; do not run it without an explicitly approved target and token. Writes are not rolled back if a later asset fails.

Fixture SQL helpers require an explicit disposable loopback database opt-in. Unit tests use local fixture data and fake process/network adapters; they do not seed a database or call an external HappyView service.

Expand Down
29 changes: 29 additions & 0 deletions api/tests/unit/tooling/installer-cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,35 @@ test('CLI requires a nonblank admin token and does not fall back to a session co
}
});

test('CLI help documents the explicit override and safe default without requiring credentials', () => {
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], {
Expand Down
60 changes: 60 additions & 0 deletions api/tests/unit/tooling/installer-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
14 changes: 14 additions & 0 deletions api/tests/unit/tooling/profile-installer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
34 changes: 24 additions & 10 deletions api/tooling/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ 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 */
/** External GET /admin/scripts/:id response assertion; response.json() is not runtime-validated. @typedef {{ script_type: string; description: string | null; body: string }} ScriptAdminRow */
/** @typedef {{ read: (asset: LoadedAsset) => Promise<InstalledAsset | null>; write: (asset: LoadedAsset) => Promise<void>; listScriptVariables?: () => Promise<unknown>; createScriptVariable?: (key: string, value: string) => Promise<void> }} AdminClient */
/** @typedef {{ changed: string[]; unchanged: string[] }} InstallResult */
/** @typedef {{ key: string; status: 'exists-unverified' | 'created' }} ResolverSetting */
/** @typedef {{ env?: Record<string, string | undefined>; isTTY?: boolean; ask?: (prompt: string) => Promise<string>; onNotice?: (message: string) => void; debug?: boolean }} ApplyAssetsOptions */
/** @typedef {{ env?: Record<string, string | undefined>; isTTY?: boolean; ask?: (prompt: string) => Promise<string>; onNotice?: (message: string) => void; debug?: boolean; override?: boolean }} ApplyAssetsOptions */
/** @typedef {InstallResult & { resolverSetting?: ResolverSetting }} ApplyAssetsResult */
/** @typedef {Error & { completed: string[]; remaining: string[]; resolverSetting?: ResolverSetting }} PartialInstallError */

Expand Down Expand Up @@ -223,13 +223,13 @@ export function orderAssets(assets) {
}

/** @param {LoadedAsset[]} ordered @param {AdminClient} client @param {ApplyAssetsOptions} [options] @returns {Promise<AssetInstallState[]>} */
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 });
Expand All @@ -238,7 +238,7 @@ async function preflightAssets(ordered, client, { debug = false } = {}) {
}

/** @param {AssetInstallState[]} states @param {AdminClient} client @returns {Promise<InstallResult>} */
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];
Expand Down Expand Up @@ -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, {
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}

Expand Down
Loading