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-ambiguous-write-verification.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/installer-progress-lines.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
54 changes: 53 additions & 1 deletion api/tests/unit/tooling/installer-cli.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 87 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,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',
Expand Down Expand Up @@ -138,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;
Expand Down Expand Up @@ -343,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);
});
65 changes: 51 additions & 14 deletions api/tooling/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
/** @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; override?: boolean }} ApplyAssetsOptions */
/** @typedef {{ env?: Record<string, string | undefined>; isTTY?: boolean; ask?: (prompt: string) => Promise<string>; 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 */

Expand Down Expand Up @@ -126,7 +126,7 @@

/** @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), {
Expand All @@ -140,7 +140,16 @@
}
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();
};
}

Expand Down Expand Up @@ -173,17 +182,17 @@
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() {
return request('GET', '/admin/script-variables');
},
/** @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 });
},
};
}
Expand Down Expand Up @@ -223,9 +232,10 @@
}

/** @param {LoadedAsset[]} ordered @param {AdminClient} client @param {ApplyAssetsOptions} [options] @returns {Promise<AssetInstallState[]>} */
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) {
Expand All @@ -237,18 +247,40 @@
return states;
}

/** @param {AssetInstallState[]} states @param {AdminClient} client @returns {Promise<InstallResult>} */
async function writeAssets(states, client) {
/** @param {LoadedAsset} asset @param {AdminClient} client @returns {Promise<string | null>} */
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<InstallResult>} */
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);
} catch (cause) {
// Confirm this outcome before advancing to the next dependency-ordered write.
const verification = await verifyFailedWrite(asset, client);

Check warning on line 277 in api/tooling/installer.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=hypercerts-org_api&issues=AaESJ03FK7f85vLbVN39&open=AaESJ03FK7f85vLbVN39&pullRequest=28
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;
Expand Down Expand Up @@ -346,7 +378,7 @@
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, {
Expand All @@ -356,7 +388,7 @@
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;
Expand Down Expand Up @@ -680,7 +712,12 @@
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));
}

Expand Down
Loading