@yargote/artifacts-cli 0.1.0-beta.5 → 0.1.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { once } from 'node:events';
6
+ import { access, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
7
+ import { constants } from 'node:fs';
8
+ import { createServer } from 'node:http';
9
+ import { createRequire } from 'node:module';
10
+ import { homedir } from 'node:os';
11
+ import { delimiter, join, resolve, sep } from 'node:path';
12
+ import { promisify } from 'node:util';
13
+
14
+ const execute = promisify(execFile);
15
+ const framePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
16
+ const previewPolicy = `${framePolicy}; frame-ancestors 'self'; sandbox allow-scripts`;
17
+ const variants = [375, 1280].flatMap((width) => ['light', 'dark'].map((theme) => ({ width, height: 900, theme })));
18
+ const fields = { text: ['text'], count: ['count'], visible: ['visible'], visibleCount: ['count'], attribute: ['name', 'value'], click: [], press: ['key'], fill: ['value'], check: ['checked'], select: ['values'] };
19
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
20
+ const message = (error) => error instanceof Error ? error.message : String(error);
21
+ const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
22
+ const compatible = (ua) => !/Edg|OPR|SamsungBrowser|CriOS|FxiOS/.test(ua) && Number(/(?:Headless)?Chrome\/(\d+)\./.exec(ua)?.[1]) >= 152;
23
+
24
+ function object(value, required, optional, label) {
25
+ assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
26
+ assert(required.every((key) => Object.hasOwn(value, key)), `${label} has missing fields`);
27
+ assert(Object.keys(value).every((key) => required.includes(key) || optional.includes(key)), `${label} has unknown fields`);
28
+ return value;
29
+ }
30
+
31
+ function parseExpectations(value) {
32
+ object(value, ['expectations'], ['timeoutMs'], 'expectations document');
33
+ assert(Array.isArray(value.expectations) && value.expectations.length <= 100, 'expectations must contain at most 100 steps');
34
+ const timeoutMs = value.timeoutMs ?? 3000;
35
+ assert(Number.isInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 30000, 'timeoutMs must be an integer from 100 to 30000');
36
+ for (const step of value.expectations) {
37
+ assert(step && Object.hasOwn(fields, step.kind), 'Unknown expectation kind');
38
+ object(step, ['kind', 'selector', ...fields[step.kind]], ['when'], 'expectation');
39
+ assert(typeof step.selector === 'string' && step.selector.trim() && step.selector.length <= 8192, 'selector must be a nonempty string');
40
+ if (step.when !== undefined) {
41
+ object(step.when, [], ['width', 'theme'], 'when');
42
+ if (step.when.width !== undefined) assert([375, 1280].includes(step.when.width), 'when.width must be 375 or 1280');
43
+ if (step.when.theme !== undefined) assert(['light', 'dark'].includes(step.when.theme), 'when.theme must be light or dark');
44
+ }
45
+ for (const key of fields[step.kind]) {
46
+ if (['count'].includes(key)) assert(Number.isInteger(step[key]) && step[key] >= 0, 'count must be a nonnegative integer');
47
+ else if (['visible', 'checked'].includes(key)) assert(typeof step[key] === 'boolean', `${key} must be boolean`);
48
+ else if (key === 'values') assert(Array.isArray(step.values) && step.values.length <= 100 && step.values.every((entry) => typeof entry === 'string'), 'values must be strings');
49
+ else if (key === 'value' && step.kind === 'attribute') assert(step.value === null || typeof step.value === 'string', 'attribute value must be a string or null');
50
+ else assert(typeof step[key] === 'string' && (!['key', 'name'].includes(key) || step[key].trim()), `${key} must be a string`);
51
+ }
52
+ }
53
+ return { expectations: value.expectations, timeoutMs };
54
+ }
55
+
56
+ function invocation(args) {
57
+ const options = {};
58
+ let html;
59
+ for (let index = 0; index < args.length; index++) {
60
+ const arg = args[index];
61
+ if (arg.startsWith('--')) {
62
+ assert(['--evidence', '--expectations', '--browser'].includes(arg), `Unknown option ${arg}`);
63
+ assert(!Object.hasOwn(options, arg) && args[index + 1] && !args[index + 1].startsWith('--'), `Supply ${arg} once with a value`);
64
+ options[arg] = args[++index];
65
+ } else { assert(!html, 'Supply one HTML file'); html = arg; }
66
+ }
67
+ assert(html && options['--evidence'], 'Usage: verify-local.mjs HTML --evidence DIRECTORY [--expectations JSON] [--browser EXECUTABLE]');
68
+ return { html: resolve(html), evidence: resolve(options['--evidence']), expectations: options['--expectations'] && resolve(options['--expectations']), browser: options['--browser'] && resolve(options['--browser']) };
69
+ }
70
+
71
+ function playwright() {
72
+ for (const base of [join(process.cwd(), 'package.json'), import.meta.url]) {
73
+ const require = createRequire(base);
74
+ for (const name of ['playwright', 'playwright-core']) {
75
+ try { return require(name).chromium; }
76
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
77
+ }
78
+ }
79
+ throw new Error('No Playwright runtime found. Provide an existing playwright or playwright-core package in the working directory project, then rerun. The helper does not install packages or browsers.');
80
+ }
81
+
82
+ async function cachedExecutables(chromium) {
83
+ const expected = chromium.executablePath();
84
+ const browserDirectory = /[\\/](?:chromium|chromium_headless_shell)-\d+[\\/]/.exec(expected);
85
+ const roots = new Set([browserDirectory && expected.slice(0, browserDirectory.index), process.env.PLAYWRIGHT_BROWSERS_PATH && process.env.PLAYWRIGHT_BROWSERS_PATH !== '0' && resolve(process.env.PLAYWRIGHT_BROWSERS_PATH)].filter(Boolean));
86
+ const executables = [];
87
+ for (const root of roots) {
88
+ let entries;
89
+ try { entries = await readdir(root, { withFileTypes: true }); }
90
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
91
+ for (const entry of entries.filter((item) => item.isDirectory() && /^(?:chromium|chromium_headless_shell)-\d+$/.test(item.name)).sort((a, b) => b.name.localeCompare(a.name, 'en', { numeric: true })).slice(0, 50)) {
92
+ const directory = join(root, entry.name);
93
+ for (const relative of [
94
+ 'chrome-linux/chrome', 'chrome-linux64/chrome', 'chrome-linux/headless_shell', 'chrome-headless-shell-linux64/chrome-headless-shell',
95
+ 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
96
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell', 'chrome-headless-shell-mac-x64/chrome-headless-shell',
97
+ 'chrome-win/chrome.exe', 'chrome-win64/chrome.exe', 'chrome-headless-shell-win64/chrome-headless-shell.exe',
98
+ ]) {
99
+ const candidate = join(directory, relative);
100
+ try { await access(candidate, constants.X_OK); executables.push(candidate); } catch {}
101
+ }
102
+ }
103
+ }
104
+ return executables;
105
+ }
106
+
107
+ async function chooseBrowser(chromium, explicit, diagnostics) {
108
+ const candidates = explicit ? [explicit] : [
109
+ chromium.executablePath(), ...await cachedExecutables(chromium),
110
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium',
111
+ join(homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
112
+ ...['PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA'].filter((key) => process.env[key]).flatMap((key) => [join(process.env[key], 'Google/Chrome/Application/chrome.exe'), join(process.env[key], 'Chromium/Application/chrome.exe')]),
113
+ ...(process.env.PATH ?? '').split(delimiter).flatMap((directory) => ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome'].map((name) => join(directory, name))),
114
+ ];
115
+ for (const candidate of new Set(candidates)) {
116
+ let browser;
117
+ try {
118
+ await access(candidate, constants.X_OK);
119
+ const executable = await realpath(candidate);
120
+ const { stdout, stderr } = await execute(executable, ['--version'], { timeout: 5000, maxBuffer: 65536 });
121
+ const version = `${stdout} ${stderr}`.trim();
122
+ assert(/Chrom(?:e|ium)/i.test(version) && Number(/(\d+)\./.exec(version)?.[1]) >= 152, `Incompatible version ${version}. Chrome or Chromium 152+ is required`);
123
+ browser = await chromium.launch({ executablePath: executable, headless: true, chromiumSandbox: true, timeout: 15000 });
124
+ const page = await browser.newPage();
125
+ const userAgent = await page.evaluate(() => navigator.userAgent);
126
+ assert(compatible(userAgent), `Unsupported launched user agent ${userAgent}`);
127
+ await page.close();
128
+ return { browser, identity: { executable, selectedPath: candidate, version: browser.version(), probeVersion: version, userAgent } };
129
+ } catch (error) {
130
+ await browser?.close();
131
+ diagnostics.push({ executable: candidate, reason: message(error) });
132
+ }
133
+ }
134
+ throw new Error('No compatible browser could launch. Use --browser /absolute/path/to/an/existing/Chrome-or-Chromium-152+ executable. Check browserDiagnostics in report.json. No software was installed and browser sandboxing was not disabled.');
135
+ }
136
+
137
+ async function poll(read, expected, timeout) {
138
+ const deadline = Date.now() + timeout;
139
+ let actual;
140
+ do {
141
+ actual = await read();
142
+ try { assert.deepEqual(actual, expected); return; } catch {}
143
+ await new Promise((done) => setTimeout(done, 50));
144
+ } while (Date.now() < deadline);
145
+ assert.deepEqual(actual, expected);
146
+ }
147
+
148
+ async function expectation(frame, step, timeout) {
149
+ const locator = frame.locator(step.selector);
150
+ const actions = {
151
+ click: () => locator.click(), press: () => locator.press(step.key), fill: () => locator.fill(step.value),
152
+ check: () => locator.setChecked(step.checked), select: () => locator.selectOption(step.values),
153
+ };
154
+ if (Object.hasOwn(actions, step.kind)) { await actions[step.kind](); return; }
155
+ if (step.kind === 'count') return poll(() => locator.count(), step.count, timeout);
156
+ if (step.kind === 'visibleCount') return poll(async () => {
157
+ const items = await locator.all();
158
+ const visible = await Promise.all(items.map((item) => item.isVisible()));
159
+ return visible.filter(Boolean).length;
160
+ }, step.count, timeout);
161
+ if (step.kind === 'visible') return poll(async () => {
162
+ const count = await locator.count();
163
+ return { count, visible: count === 1 ? await locator.isVisible() : null };
164
+ }, { count: 1, visible: step.visible }, timeout);
165
+ if (step.kind === 'text') return poll(() => locator.innerText(), step.text, timeout);
166
+ return poll(() => locator.getAttribute(step.name), step.value, timeout);
167
+ }
168
+
169
+ async function dimensions(frame) {
170
+ return frame.evaluate(() => ({ viewportWidth: innerWidth, documentWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), documentHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0), viewportHeight: innerHeight }));
171
+ }
172
+
173
+ async function capture(page, frame, result, directory, phase) {
174
+ const size = await dimensions(frame);
175
+ const maximum = Math.max(0, size.documentHeight - size.viewportHeight);
176
+ const offsets = [0];
177
+ while (offsets.at(-1) < maximum && offsets.length < 12) offsets.push(Math.min(maximum, offsets.at(-1) + size.viewportHeight));
178
+ const actualOffsets = [];
179
+ for (const offset of offsets) {
180
+ const actual = await frame.evaluate((top) => { scrollTo({ top, left: 0, behavior: 'instant' }); return scrollY; }, offset);
181
+ actualOffsets.push(actual);
182
+ await page.waitForTimeout(50);
183
+ const path = `${result.variant.width}-${result.variant.theme}-${phase}-${offset}.png`;
184
+ await page.screenshot({ path: join(directory, path), timeout: 5000 });
185
+ result.screenshots.push({ path, phase, scrollY: actual, width: result.variant.width, height: result.variant.height });
186
+ }
187
+ const finalSize = await dimensions(frame);
188
+ const capturedThrough = Math.min(finalSize.documentHeight, Math.max(...actualOffsets) + size.viewportHeight);
189
+ const reachedOffsets = actualOffsets.every((actual, index) => Math.abs(actual - offsets[index]) <= 1);
190
+ result.captureCoverage.push({ phase, documentHeight: finalSize.documentHeight, capturedThrough, truncated: !reachedOffsets || capturedThrough < finalSize.documentHeight });
191
+ await frame.evaluate(() => scrollTo({ top: 0, left: 0, behavior: 'instant' }));
192
+ }
193
+
194
+ async function runVariant(browser, origin, config, variant, directory) {
195
+ const deadline = { timeoutMs: Math.min(60000, Math.max(10000, config.timeoutMs * (config.expectations.length + 1))), exceeded: false };
196
+ const result = { variant, status: 'passed', deadline, checks: [], screenshots: [], captureCoverage: [], consoleErrors: [], runtimeErrors: [], externalAttempts: [], errors: [] };
197
+ const context = await browser.newContext({ viewport: { width: variant.width, height: variant.height }, colorScheme: variant.theme, serviceWorkers: 'block' });
198
+ context.setDefaultTimeout(config.timeoutMs);
199
+ let closing;
200
+ const close = () => closing ??= context.close().catch((error) => { result.errors.push(`Browser context cleanup failed. ${message(error)}`); });
201
+ const watchdog = setTimeout(() => {
202
+ deadline.exceeded = true;
203
+ result.errors.push(`Variant deadline exceeded after ${deadline.timeoutMs} ms. Remaining checks, CSP observations, and captures are incomplete.`);
204
+ void close();
205
+ }, deadline.timeoutMs);
206
+ let frame;
207
+ const overflow = async (phase) => {
208
+ const size = await dimensions(frame);
209
+ const passed = size.documentWidth <= size.viewportWidth + 1;
210
+ result.checks.push({ kind: 'overflow', phase, status: passed ? 'passed' : 'failed', ...size });
211
+ };
212
+ try {
213
+ const page = await context.newPage();
214
+ page.on('console', (entry) => { if (entry.type() === 'error') result.consoleErrors.push(entry.text()); });
215
+ page.on('pageerror', (error) => result.runtimeErrors.push(message(error)));
216
+ await context.route('**/*', async (route) => {
217
+ const request = route.request();
218
+ if ([`${origin}/`, `${origin}/content`].includes(request.url()) && request.method() === 'GET' && request.resourceType() === 'document') await route.continue();
219
+ else { result.externalAttempts.push({ source: 'request', url: request.url(), resourceType: request.resourceType() }); await route.abort('blockedbyclient'); }
220
+ });
221
+ await context.addInitScript(() => {
222
+ globalThis.__artifactsLocalViolations = [];
223
+ addEventListener('securitypolicyviolation', (event) => globalThis.__artifactsLocalViolations.push({ source: 'csp', url: event.blockedURI, directive: event.effectiveDirective }));
224
+ });
225
+ const responsePromise = page.waitForResponse((response) => response.url() === `${origin}/content`);
226
+ const [, response] = await Promise.all([page.goto(`${origin}/`, { waitUntil: 'load', timeout: 10000 }), responsePromise]);
227
+ const headers = response.headers();
228
+ result.sandbox = { csp: headers['content-security-policy'], connectionAllowlist: headers['connection-allowlist'], dnsPrefetchControl: headers['x-dns-prefetch-control'], iframeSandbox: await page.locator('iframe').getAttribute('sandbox'), contentSha256: hash(await response.body()) };
229
+ assert.equal(result.sandbox.csp, previewPolicy);
230
+ assert.equal(result.sandbox.connectionAllowlist, '()');
231
+ assert.equal(result.sandbox.dnsPrefetchControl, 'off');
232
+ assert.equal(result.sandbox.iframeSandbox, 'allow-scripts');
233
+ frame = page.frames().find((item) => item.url() === `${origin}/content`);
234
+ assert(frame, 'The local sandboxed content frame must load');
235
+ await frame.evaluate(async (timeout) => {
236
+ await Promise.race([document.fonts.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Fonts readiness timed out')), timeout))]);
237
+ }, config.timeoutMs);
238
+ await page.waitForTimeout(200);
239
+ await overflow('initial');
240
+ await capture(page, frame, result, directory, 'initial');
241
+ for (const [index, step] of config.expectations.entries()) {
242
+ if (step.when && ((step.when.width !== undefined && step.when.width !== variant.width) || (step.when.theme !== undefined && step.when.theme !== variant.theme))) continue;
243
+ try {
244
+ await expectation(frame, step, config.timeoutMs);
245
+ result.checks.push({ kind: step.kind, step: index, status: 'passed' });
246
+ } catch (error) { result.checks.push({ kind: step.kind, step: index, status: 'failed', message: message(error) }); break; }
247
+ }
248
+ assert.equal(page.url(), `${origin}/`, 'The document must not navigate the wrapper');
249
+ assert.equal(frame.url(), `${origin}/content`, 'The document must not replace its content frame');
250
+ await page.waitForTimeout(200);
251
+ await overflow('final');
252
+ await capture(page, frame, result, directory, 'final');
253
+ } catch (error) { result.errors.push(message(error)); }
254
+ finally {
255
+ if (!deadline.exceeded && frame && !frame.isDetached()) {
256
+ try { result.externalAttempts.push(...await frame.evaluate(() => globalThis.__artifactsLocalViolations ?? [])); }
257
+ catch (error) { result.errors.push(`CSP evidence unavailable. ${message(error)}`); }
258
+ }
259
+ await close();
260
+ clearTimeout(watchdog);
261
+ }
262
+ if (result.checks.some((item) => item.status === 'failed') || result.errors.length || result.consoleErrors.length || result.runtimeErrors.length || result.externalAttempts.length) result.status = 'failed';
263
+ return result;
264
+ }
265
+
266
+ async function main() {
267
+ const report = {
268
+ schemaVersion: 1, evidenceKind: 'renderizado local', authenticatedPublication: 'not-verified', status: 'blocked',
269
+ visualReview: { status: 'required', checks: ['Content fidelity and reading order', 'Composition, alignment, legibility, and focus visibility at both widths and themes', 'Inspect screenshot tiles and any truncated capture coverage'] },
270
+ input: null, browser: null, browserDiagnostics: [],
271
+ sandbox: { csp: previewPolicy, iframeSandbox: 'allow-scripts', connectionAllowlist: '()', dnsPrefetchControl: 'off', referrerPolicy: 'no-referrer', chromiumSandbox: true },
272
+ observation: 'Each fresh variant waits for load and fonts, observes 200 ms before and after its scenario, and captures bounded viewport tiles. Delayed behavior beyond this run requires additional review.',
273
+ variants: [], errors: [],
274
+ };
275
+ let directory;
276
+ let server;
277
+ let browser;
278
+ let config;
279
+ try {
280
+ config = invocation(process.argv.slice(2));
281
+ for (const input of [config.html, config.expectations].filter(Boolean)) assert(config.evidence !== input && !config.evidence.startsWith(`${input}${sep}`), 'Evidence destination must not overwrite an input file');
282
+ await mkdir(config.evidence, { recursive: true });
283
+ directory = await mkdtemp(join(config.evidence, 'local-'));
284
+ report.evidenceDirectory = directory;
285
+ const bytes = await readFile(config.html);
286
+ report.input = { path: await realpath(config.html), sha256: hash(bytes), bytes: bytes.length };
287
+ const checks = config.expectations ? parseExpectations(JSON.parse(await readFile(config.expectations, 'utf8'))) : { expectations: [], timeoutMs: 3000 };
288
+ const content = `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${escape(framePolicy)}"><meta name="referrer" content="no-referrer"></head><body>${bytes.toString('utf8')}</body></html>`;
289
+ report.sandbox.contentSha256 = hash(content);
290
+ const chosen = await chooseBrowser(playwright(), config.browser, report.browserDiagnostics);
291
+ browser = chosen.browser;
292
+ report.browser = chosen.identity;
293
+ server = createServer((request, response) => {
294
+ if (request.method !== 'GET' || !['/', '/content'].includes(request.url)) { response.writeHead(404).end(); return; }
295
+ response.setHeader('content-type', 'text/html; charset=utf-8');
296
+ response.setHeader('cache-control', 'no-store');
297
+ response.setHeader('x-content-type-options', 'nosniff');
298
+ response.setHeader('referrer-policy', 'no-referrer');
299
+ response.setHeader('x-dns-prefetch-control', 'off');
300
+ if (request.url === '/content') response.setHeader('connection-allowlist', '()');
301
+ response.setHeader('content-security-policy', request.url === '/content' ? previewPolicy : "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'");
302
+ response.end(request.url === '/content' ? content : '<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}iframe{display:block;border:0;width:100%;height:100%}</style><iframe title="Local artifact" sandbox="allow-scripts" referrerpolicy="no-referrer" src="/content"></iframe>');
303
+ });
304
+ server.listen(0, '127.0.0.1');
305
+ await once(server, 'listening');
306
+ const origin = `http://127.0.0.1:${server.address().port}`;
307
+ for (const variant of variants) report.variants.push(await runVariant(browser, origin, checks, variant, directory));
308
+ report.status = report.variants.every((item) => item.status === 'passed') ? 'passed' : 'failed';
309
+ } catch (error) { report.errors.push(message(error)); }
310
+ finally {
311
+ try { await browser?.close(); } catch (error) { report.errors.push(`Browser cleanup failed. ${message(error)}`); }
312
+ if (server) {
313
+ try { await new Promise((done, reject) => server.close((error) => error ? reject(error) : done())); }
314
+ catch (error) { report.errors.push(`Local server cleanup failed. ${message(error)}`); }
315
+ }
316
+ if (report.errors.length && report.status === 'passed') report.status = 'failed';
317
+ if (report.input) {
318
+ try {
319
+ report.input.sha256After = hash(await readFile(config.html));
320
+ assert.equal(report.input.sha256After, report.input.sha256, 'Input HTML changed during verification');
321
+ } catch (error) { report.status = 'failed'; report.errors.push(message(error)); }
322
+ }
323
+ if (directory) {
324
+ await writeFile(join(directory, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
325
+ const links = report.variants.flatMap((item) => item.screenshots.map((capture) => `- [${capture.path}](${capture.path}). Frame scroll offset ${capture.scrollY} px.`));
326
+ await writeFile(join(directory, 'index.md'), `# Local rendering evidence\n\nEvidence kind is renderizado local. Authenticated publication is not verified.\n\nAutomatic checks ${report.status}. Visual review remains required.\n\n${links.join('\n')}\n`);
327
+ }
328
+ console.log(JSON.stringify(report));
329
+ process.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2;
330
+ }
331
+ }
332
+
333
+ await main();
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from 'node:os';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { z } from 'zod';
9
- import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
9
+ import { createSchema, listSharesSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
10
10
  import { canonicalResource } from '@yargote/artifacts-contracts/http';
11
11
  import { createArtifactHttpClient, HttpError, prepareArtifactEdit } from '@yargote/artifacts-client';
12
12
  import { accessToken, login, logout } from "./auth.js";
@@ -46,6 +46,9 @@ Commands:
46
46
  export ID --revision REV --out FILE
47
47
  move ID --project NAME [--request-id ID]
48
48
  move ID --no-project [--request-id ID]
49
+ share ID --email EMAIL [--notify] [--request-id ID]
50
+ shares ID
51
+ unshare ID SHARE_ID [--request-id ID]
49
52
  retry REQUEST_ID
50
53
 
51
54
  Mutation command files contain the complete create, update, or publish JSON.
@@ -75,6 +78,7 @@ const commandOptions = {
75
78
  update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
76
79
  edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
77
80
  publish: ['revision', 'expected-version', 'request-id', 'command'],
81
+ share: ['email', 'notify', 'request-id'], shares: [], unshare: ['request-id'],
78
82
  export: ['revision', 'out'], move: ['project', 'no-project', 'request-id'], retry: [],
79
83
  skills: ['full', 'all', 'agent', 'global', 'force'],
80
84
  };
@@ -112,7 +116,7 @@ async function main() {
112
116
  summary: { type: 'boolean' }, out: { type: 'string' }, quiet: { type: 'boolean' },
113
117
  full: { type: 'boolean' }, all: { type: 'boolean' }, agent: { type: 'string' },
114
118
  global: { type: 'boolean' }, force: { type: 'boolean' },
115
- project: { type: 'string' }, 'no-project': { type: 'boolean' },
119
+ project: { type: 'string' }, 'no-project': { type: 'boolean' }, email: { type: 'string' }, notify: { type: 'boolean' },
116
120
  },
117
121
  });
118
122
  const seen = new Set();
@@ -255,11 +259,18 @@ async function main() {
255
259
  throw new UsageError('Specify exactly one of --project NAME or --no-project');
256
260
  return mutate({ kind: 'move', command: moveSchema.parse({ artifactId, requestId: values['request-id'] ?? randomUUID(), project: values.project ?? null }) });
257
261
  }
262
+ case 'share': return mutate({ kind: 'share', command: shareSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), email: required('email'), ...(values.notify === undefined ? {} : { notify: values.notify }) }) });
263
+ case 'shares': return client.listShares(listSharesSchema.parse({ artifactId: argument() }));
264
+ case 'unshare': {
265
+ if (arguments_.length !== 2)
266
+ throw new UsageError('unshare requires two positional arguments');
267
+ return mutate({ kind: 'unshare', command: unshareSchema.parse({ artifactId: arguments_[0], shareId: arguments_[1], requestId: values['request-id'] ?? randomUUID() }) });
268
+ }
258
269
  case 'retry': {
259
270
  const requestId = argument();
260
271
  const owner = await client.identity();
261
272
  const operation = await loadOperation(stateDir, requestId, resource, owner.userId);
262
- process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}. The result is its retained receipt and may differ from current state.\n`);
273
+ process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}. Replay confirms the original operation and does not establish current state.\n`);
263
274
  return submitOperation(client, operation);
264
275
  }
265
276
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
2
+ import { createSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
3
3
  import type { ArtifactHttpClient } from '@yargote/artifacts-client';
4
4
  export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
5
  kind: z.ZodLiteral<"create">;
@@ -16,6 +16,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
16
16
  text: "text";
17
17
  html: "html";
18
18
  markdown: "markdown";
19
+ design: "design";
19
20
  }>;
20
21
  text: z.ZodString;
21
22
  }, z.core.$strict>;
@@ -55,6 +56,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
55
56
  text: "text";
56
57
  html: "html";
57
58
  markdown: "markdown";
59
+ design: "design";
58
60
  }>;
59
61
  text: z.ZodString;
60
62
  }, z.core.$strict>;
@@ -97,6 +99,28 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
97
99
  version: z.ZodLiteral<1>;
98
100
  resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
99
101
  ownerId: z.ZodString;
102
+ }, z.core.$strict>, z.ZodObject<{
103
+ kind: z.ZodLiteral<"share">;
104
+ command: z.ZodObject<{
105
+ requestId: z.ZodString;
106
+ artifactId: z.ZodString;
107
+ email: z.ZodPipe<z.ZodString, z.ZodEmail>;
108
+ notify: z.ZodOptional<z.ZodBoolean>;
109
+ requireNew: z.ZodOptional<z.ZodLiteral<true>>;
110
+ }, z.core.$strict>;
111
+ version: z.ZodLiteral<1>;
112
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
113
+ ownerId: z.ZodString;
114
+ }, z.core.$strict>, z.ZodObject<{
115
+ kind: z.ZodLiteral<"unshare">;
116
+ command: z.ZodObject<{
117
+ requestId: z.ZodString;
118
+ artifactId: z.ZodString;
119
+ shareId: z.ZodString;
120
+ }, z.core.$strict>;
121
+ version: z.ZodLiteral<1>;
122
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
123
+ ownerId: z.ZodString;
100
124
  }, z.core.$strict>], "kind">;
101
125
  export type PreparedOperation = z.infer<typeof operationSchema>;
102
126
  export type Mutation = {
@@ -111,11 +135,33 @@ export type Mutation = {
111
135
  } | {
112
136
  kind: 'move';
113
137
  command: z.input<typeof moveSchema>;
138
+ } | {
139
+ kind: 'share';
140
+ command: z.input<typeof shareSchema>;
141
+ } | {
142
+ kind: 'unshare';
143
+ command: z.input<typeof unshareSchema>;
114
144
  };
115
145
  export declare function operationPath(stateDir: string, resource: string, requestId: string): string;
116
146
  export declare function retainOperation(stateDir: string, resource: string, ownerId: string, mutation: Mutation): Promise<PreparedOperation>;
117
147
  export declare function loadOperation(stateDir: string, requestId: string, resource: string, ownerId: string): Promise<PreparedOperation>;
118
- export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<{
148
+ export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<void> | Promise<{
149
+ shareId: string;
150
+ artifactId: string;
151
+ kind: "email";
152
+ value: string;
153
+ createdAt: string;
154
+ notification?: {
155
+ status: "requested";
156
+ } | {
157
+ status: "skipped";
158
+ reason: "disabled";
159
+ } | {
160
+ status: "skipped";
161
+ reason: "rate_limited";
162
+ retryAt: string;
163
+ } | undefined;
164
+ }> | Promise<{
119
165
  artifactId: string;
120
166
  project: string | null;
121
167
  }> | Promise<{
package/src/operations.js CHANGED
@@ -3,7 +3,7 @@ import { lstat, mkdir, open } from 'node:fs/promises';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { join } from 'node:path';
5
5
  import { z } from 'zod';
6
- import { createSchema, moveSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
6
+ import { createSchema, moveSchema, publishSchema, shareSchema, unshareSchema, updateSchema } from '@yargote/artifacts-contracts';
7
7
  import { canonicalResource } from '@yargote/artifacts-contracts/http';
8
8
  const binding = {
9
9
  version: z.literal(1),
@@ -15,6 +15,8 @@ export const operationSchema = z.discriminatedUnion('kind', [
15
15
  z.strictObject({ ...binding, kind: z.literal('update'), command: updateSchema }),
16
16
  z.strictObject({ ...binding, kind: z.literal('publish'), command: publishSchema }),
17
17
  z.strictObject({ ...binding, kind: z.literal('move'), command: moveSchema }),
18
+ z.strictObject({ ...binding, kind: z.literal('share'), command: shareSchema }),
19
+ z.strictObject({ ...binding, kind: z.literal('unshare'), command: unshareSchema }),
18
20
  ]);
19
21
  export function operationPath(stateDir, resource, requestId) {
20
22
  createSchema.shape.requestId.parse(requestId);
@@ -88,5 +90,9 @@ export function submitOperation(client, operation) {
88
90
  case 'update': return client.update(operation.command);
89
91
  case 'publish': return client.publish(operation.command);
90
92
  case 'move': return client.move(operation.command);
93
+ case 'share': return client.share(operation.command);
94
+ case 'unshare': return client.unshare(operation.command);
91
95
  }
96
+ const exhaustive = operation;
97
+ throw new Error(`Unsupported retained operation: ${exhaustive}`);
92
98
  }