From 93d350213bea703de61d82b0b07ee5576f02e4e5 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 22:07:27 +0600 Subject: [PATCH 1/2] installer: report per-asset install progress --- .changeset/installer-progress-lines.md | 5 ++ api/README.md | 2 +- api/tests/unit/tooling/installer-cli.test.js | 54 ++++++++++++++++++- api/tests/unit/tooling/installer-core.test.js | 28 ++++++++++ api/tooling/installer.js | 26 ++++++--- 5 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 .changeset/installer-progress-lines.md diff --git a/.changeset/installer-progress-lines.md b/.changeset/installer-progress-lines.md new file mode 100644 index 0000000..f848bff --- /dev/null +++ b/.changeset/installer-progress-lines.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +`pnpm install:api` now shows compact progress for asset checks and required installs while keeping its final JSON summary on stdout. diff --git a/api/README.md b/api/README.md index 1ff0a29..316845a 100644 --- a/api/README.md +++ b/api/README.md @@ -152,7 +152,7 @@ The shared module registers the pinned rights-record Lexicon with backfill enabl 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. +`pnpm install:api` prints a short progress line for each asset check and each required install to stderr; its final JSON summary remains on stdout. It contacts a HappyView instance and uploads declared assets, so 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. diff --git a/api/tests/unit/tooling/installer-cli.test.js b/api/tests/unit/tooling/installer-cli.test.js index ec846b2..1a1743a 100644 --- a/api/tests/unit/tooling/installer-cli.test.js +++ b/api/tests/unit/tooling/installer-cli.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -101,6 +102,57 @@ test('importing the installer does not run the CLI or require configuration', () assert.equal(child.stderr, ''); }); +test('CLI writes per-asset progress to stderr and keeps its JSON result on stdout', async () => { + const server = createServer((request, response) => { + if (request.method === 'GET' && request.url === '/admin/script-variables') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('[]'); + } else if (request.method === 'GET') { + response.writeHead(404).end(); + } else { + response.writeHead(204).end(); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + try { + const address = server.address(); + const script = fileURLToPath(new URL('../../../tooling/installer.js', import.meta.url)); + const child = spawn(process.execPath, [script], { + env: { + ...process.env, + HAPPYVIEW_BASE_URL: `http://127.0.0.1:${address.port}`, + HAPPYVIEW_ADMIN_TOKEN: 'hv_cli-test-token', + HYPERCERTS_HANDLE_RESOLVER_URL: 'https://resolver.example', + }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; }); + const { code, signal } = await new Promise((resolve) => { + child.once('close', (exitCode, childSignal) => resolve({ code: exitCode, signal: childSignal })); + }); + + assert.equal(code, 0, `signal: ${signal}; stderr: ${stderr}`); + const result = JSON.parse(stdout); + const progress = stderr.trimEnd().split('\n'); + const checks = progress.filter((line) => line.startsWith('[check ')); + const installs = progress.filter((line) => line.startsWith('[install ')); + const total = result.changed.length + result.unchanged.length; + + assert.equal(checks.length, total); + assert.equal(installs.length, result.changed.length); + assert.match(checks[0], new RegExp(`^\\[check 1/${total}\\] \\S+$`)); + assert.match(installs[0], new RegExp(`^\\[install 1/${result.changed.length}\\] \\S+$`)); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test('CLI main-module detection works when the checkout path contains #', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'happyview#installer-cli-')); try { diff --git a/api/tests/unit/tooling/installer-core.test.js b/api/tests/unit/tooling/installer-core.test.js index 58ca7d6..e305aaa 100644 --- a/api/tests/unit/tooling/installer-core.test.js +++ b/api/tests/unit/tooling/installer-core.test.js @@ -26,6 +26,34 @@ test('installer preflights every asset before writing and refuses conflicts', as assert.deepEqual(written, []); }); +test('installer reports compact per-asset check and install progress', async () => { + const events = []; + const assets = [ + { id: 'existing', kind: 'script', config: {}, body: 'same' }, + { id: 'new', kind: 'script', config: {}, body: 'incoming' }, + ]; + + const result = await applyAssets(assets, { + read: async ({ id }) => { + events.push(`read:${id}`); + return id === 'existing' ? { config: {}, body: 'same' } : null; + }, + write: async ({ id }) => { events.push(`write:${id}`); }, + }, { + onProgress: (message) => events.push(`progress:${message}`), + }); + + assert.deepEqual(events, [ + 'progress:[check 1/2] existing', + 'read:existing', + 'progress:[check 2/2] new', + 'read:new', + 'progress:[install 1/1] new', + 'write:new', + ]); + assert.deepEqual(result, { changed: ['new'], unchanged: ['existing'] }); +}); + test('override replaces differing declared assets only after preflighting the complete bundle', async () => { const lexicon = { id: 'org.example.query', diff --git a/api/tooling/installer.js b/api/tooling/installer.js index d2e5de9..b2c2d54 100644 --- a/api/tooling/installer.js +++ b/api/tooling/installer.js @@ -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; override?: boolean }} ApplyAssetsOptions */ +/** @typedef {{ env?: Record; isTTY?: boolean; ask?: (prompt: string) => Promise; onNotice?: (message: string) => void; onProgress?: (message: string) => void; debug?: boolean; override?: boolean }} ApplyAssetsOptions */ /** @typedef {InstallResult & { resolverSetting?: ResolverSetting }} ApplyAssetsResult */ /** @typedef {Error & { completed: string[]; remaining: string[]; resolverSetting?: ResolverSetting }} PartialInstallError */ @@ -223,9 +223,10 @@ export function orderAssets(assets) { } /** @param {LoadedAsset[]} ordered @param {AdminClient} client @param {ApplyAssetsOptions} [options] @returns {Promise} */ -async function preflightAssets(ordered, client, { debug = false, override = false } = {}) { +async function preflightAssets(ordered, client, { debug = false, override = false, onProgress } = {}) { const states = []; - for (const asset of ordered) { + for (const [index, asset] of ordered.entries()) { + onProgress?.(`[check ${index + 1}/${ordered.length}] ${asset.id}`); const installed = await client.read(asset); const state = compareAsset(asset, installed); if (state === 'conflict' && !override) { @@ -237,12 +238,16 @@ async function preflightAssets(ordered, client, { debug = false, override = fals return states; } -/** @param {AssetInstallState[]} states @param {AdminClient} client @returns {Promise} */ -async function writeAssets(states, client) { +/** @param {AssetInstallState[]} states @param {AdminClient} client @param {(message: string) => void} [onProgress] @returns {Promise} */ +async function writeAssets(states, client, onProgress) { const changed = []; + const total = states.filter(({ state }) => state !== 'unchanged').length; + let progress = 0; for (let i = 0; i < states.length; i++) { const { asset, state } = states[i]; if (state === 'unchanged') continue; + progress++; + onProgress?.(`[install ${progress}/${total}] ${asset.id}`); try { await client.write(asset); changed.push(asset.id); @@ -346,7 +351,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 writeAssets(states, client); + return writeAssets(states, client, options.onProgress); } const resolverSetting = await installResolverSetting(client, { @@ -356,7 +361,7 @@ export async function applyAssets(assets, client, options = {}) { onNotice: options.onNotice ?? ((message) => console.log(message)), }); try { - const result = await writeAssets(states, client); + const result = await writeAssets(states, client, options.onProgress); return { ...result, resolverSetting }; } catch (cause) { if (resolverSetting.status !== 'created') throw cause; @@ -680,7 +685,12 @@ async function main() { 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, override }); + const result = await applyAssets(assets, client, { + debug, + override, + // Keep progress out of the JSON result on stdout. + onProgress: (message) => console.error(message), + }); console.log(JSON.stringify(result, null, 2)); } From e5847ae9f46a611c5000ccc66f70031137c2a4a6 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 22:24:44 +0600 Subject: [PATCH 2/2] installer: verify ambiguous asset writes --- .../installer-ambiguous-write-verification.md | 5 ++ CONTRIBUTING.md | 2 +- api/README.md | 2 +- api/tests/unit/tooling/installer-core.test.js | 59 +++++++++++++++++++ api/tooling/installer.js | 39 ++++++++++-- 5 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 .changeset/installer-ambiguous-write-verification.md diff --git a/.changeset/installer-ambiguous-write-verification.md b/.changeset/installer-ambiguous-write-verification.md new file mode 100644 index 0000000..03108ec --- /dev/null +++ b/.changeset/installer-ambiguous-write-verification.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +The installer now verifies assets after write errors and avoids false failures when a successful response body is interrupted. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2441a0c..75e5bc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,4 +56,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. 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). +`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. If a write request errors, the installer checks the installed asset and treats it as complete only if it matches the bundle. 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 316845a..cf6e831 100644 --- a/api/README.md +++ b/api/README.md @@ -152,7 +152,7 @@ The shared module registers the pinned rights-record Lexicon with backfill enabl 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` prints a short progress line for each asset check and each required install to stderr; its final JSON summary remains on stdout. It contacts a HappyView instance and uploads declared assets, so do not run it without an explicitly approved target and token. Writes are not rolled back if a later asset fails. +`pnpm install:api` prints a short progress line for each asset check and each required install to stderr; its final JSON summary remains on stdout. It contacts a HappyView instance and uploads declared assets, so do not run it without an explicitly approved target and token. If a write request errors, the installer checks the installed asset and treats it as complete only if it matches the bundle. 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. diff --git a/api/tests/unit/tooling/installer-core.test.js b/api/tests/unit/tooling/installer-core.test.js index e305aaa..b382355 100644 --- a/api/tests/unit/tooling/installer-core.test.js +++ b/api/tests/unit/tooling/installer-core.test.js @@ -166,6 +166,46 @@ test('unchanged assets are skipped and partial failures report completed and rem assert.deepEqual(written, ['new']); }); +test('installer confirms a write after its response is lost when read-back matches', async (t) => { + const installed = new Map(); + const id = 'org.example.recovered'; + const asset = { id, kind: 'script', config: { script_type: 'lua' }, body: 'return true' }; + const server = createServer((request, response) => { + if (request.method === 'POST' && request.url === '/admin/scripts') { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')); + installed.set(payload.id, payload); + response.destroy(); + }); + return; + } + + if (request.method === 'GET' && request.url === `/admin/scripts/${id}`) { + const payload = installed.get(id); + if (!payload) { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ script_type: payload.script_type, description: null, body: payload.body })); + return; + } + + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))); + const { port } = server.address(); + const admin = createAdminClient({ baseUrl: `http://127.0.0.1:${port}`, token: 'hv_local-test-token' }); + + const result = await applyAssets([asset], admin); + + assert.deepEqual(result, { changed: [id], unchanged: [] }); + assert.equal(installed.get(id).body, asset.body); +}); + test('apply reports an all-unchanged result without writing', async () => { const asset = { id: 'same', kind: 'script', config: {}, body: 'return true' }; let writes = 0; @@ -371,3 +411,22 @@ test('admin client preserves action and token_cost when writing lexicons', async token_cost: 7, }); }); + +test('admin client accepts a successful write when its unused response body terminates', async () => { + let calls = 0; + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"updated":')); + controller.error(new TypeError('terminated')); + }, + }), { status: 200 }); + const admin = createAdminClient({ + baseUrl: 'http://localhost:8080', + token: 'hv_write-secret', + fetchImpl: async () => { calls++; return response; }, + }); + + await admin.write({ id: 'org.example.script', kind: 'script', config: { script_type: 'lua' }, body: 'return true' }); + + assert.equal(calls, 1); +}); diff --git a/api/tooling/installer.js b/api/tooling/installer.js index b2c2d54..891435e 100644 --- a/api/tooling/installer.js +++ b/api/tooling/installer.js @@ -126,7 +126,7 @@ function validateAdminUrl(baseUrl) { /** @param {URL} target @param {string} token @param {typeof globalThis.fetch} fetchImpl */ function createAdminRequest(target, token, fetchImpl) { - return /** @param {'GET' | 'POST'} method @param {string} route @param {unknown} [body] */ async function request(method, route, body) { + return /** @param {'GET' | 'POST'} method @param {string} route @param {unknown} [body] @param {{ readJson?: boolean }} [options] */ async function request(method, route, body, { readJson = true } = {}) { let response; try { response = await fetchImpl(new URL(route, target), { @@ -140,7 +140,16 @@ function createAdminRequest(target, token, fetchImpl) { } if (method === 'GET' && response.status === 404) return null; if (!response.ok) throw new Error(`HappyView ${method} ${route} returned HTTP ${response.status}`); - return response.status === 204 ? null : response.json(); + if (response.status === 204) return null; + if (!readJson) { + try { + await response.body?.cancel(); + } catch { + // The successful status confirms the write; its response body is unused. + } + return null; + } + return response.json(); }; } @@ -173,9 +182,9 @@ export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch target_collection: asset.config.target_collection, action: asset.config.action, token_cost: asset.config.token_cost, - }); + }, { readJson: false }); } else { - await request('POST', '/admin/scripts', { id: asset.id, script_type: asset.config.script_type, description: asset.config.description, body: asset.body }); + await request('POST', '/admin/scripts', { id: asset.id, script_type: asset.config.script_type, description: asset.config.description, body: asset.body }, { readJson: false }); } }, async listScriptVariables() { @@ -183,7 +192,7 @@ export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch }, /** @param {string} key @param {string} value */ async createScriptVariable(key, value) { - await request('POST', '/admin/script-variables', { key, value }); + await request('POST', '/admin/script-variables', { key, value }, { readJson: false }); }, }; } @@ -238,6 +247,18 @@ async function preflightAssets(ordered, client, { debug = false, override = fals return states; } +/** @param {LoadedAsset} asset @param {AdminClient} client @returns {Promise} */ +async function verifyFailedWrite(asset, client) { + try { + const installed = await client.read(asset); + if (compareAsset(asset, installed) === 'unchanged') return null; + return 'read-back did not confirm the requested asset'; + } catch (error_) { + const detail = error_ instanceof Error ? error_.message : 'unknown read-back failure'; + return `read-back failed: ${detail}`; + } +} + /** @param {AssetInstallState[]} states @param {AdminClient} client @param {(message: string) => void} [onProgress] @returns {Promise} */ async function writeAssets(states, client, onProgress) { const changed = []; @@ -252,8 +273,14 @@ async function writeAssets(states, client, onProgress) { await client.write(asset); changed.push(asset.id); } catch (cause) { + // Confirm this outcome before advancing to the next dependency-ordered write. + const verification = await verifyFailedWrite(asset, client); + if (verification === null) { + changed.push(asset.id); + continue; + } const reason = cause instanceof Error ? cause.message : 'unknown write failure'; - const error = /** @type {PartialInstallError} */ (new Error(`Install partially failed at ${asset.id}: ${reason}; completed: ${changed.join(', ') || '(none)'}; remaining: ${states.slice(i).filter((entry) => entry.state !== 'unchanged').map((entry) => entry.asset.id).join(', ')}`, { cause })); + const error = /** @type {PartialInstallError} */ (new Error(`Install partially failed at ${asset.id}: ${reason}; ${verification}; completed: ${changed.join(', ') || '(none)'}; remaining: ${states.slice(i).filter((entry) => entry.state !== 'unchanged').map((entry) => entry.asset.id).join(', ')}`, { cause })); error.completed = changed; error.remaining = states.slice(i).filter((entry) => entry.state !== 'unchanged').map((entry) => entry.asset.id); throw error;