@zalosai/zalos-cli 0.2.15 → 0.2.16

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.
package/PUBLISHING.md CHANGED
@@ -61,6 +61,9 @@ Do not publish with uncommitted package changes or reuse an existing version.
61
61
 
62
62
  Version 0.2.15 bundles the `zl-ops` production-operations skill and adds offline `zl skills`
63
63
  commands to list, read, and install it into Codex or Claude skill roots.
64
+ Version 0.2.16 adds `zl execution replay export`, which materializes a SHA-256-verified local
65
+ bundle of immutable execution inputs and outputs through the authenticated product API. Deploy
66
+ the replay-bundle route before publishing this version.
64
67
  Version 0.2.14 moves publishing to npm Trusted Publishing through the
65
68
  `publish-zalos-cli.yml` GitHub Actions workflow, so releases no longer rely on a stored npm token.
66
69
  Version 0.2.13 adds revision-safe Markdown import and export for team Pages. It converts common
package/README.md CHANGED
@@ -46,6 +46,12 @@ zl --environment eu-prod auth login
46
46
  ```
47
47
 
48
48
  `auth login` opens the existing browser sign-in and stores the scoped human session.
49
+ If the browser cannot reach the CLI's local listener, choose **Use a code** in the
50
+ browser and enter the code printed by the CLI, then confirm the team. For a CLI
51
+ running on another machine or in a container, run `zl --environment <name> auth login --device`.
52
+ Open the verification page printed by the CLI on a browser machine, enter its six-digit code,
53
+ and confirm the team. The CLI waits up to five minutes and receives the token
54
+ encrypted to the CLI session key.
49
55
  It never changes access-group membership. Database access remains an optional,
50
56
  separately distributed developer capability:
51
57
 
@@ -76,6 +82,25 @@ When AWS access expires, rerun `zalos-db login --environment <name>`; this does
76
82
  your Mesh token. Database reader groups are `zalos-db-staging-readers`,
77
83
  `zalos-db-us-prod-readers`, and `zalos-db-eu-prod-readers`.
78
84
 
85
+ ## Data Tables
86
+
87
+ Define columns, types, write owners, and an optional text business key in a local
88
+ JSON schema. Initial rows may be a JSON array or `.jsonl` file.
89
+
90
+ ```bash
91
+ zl data-table validate --schema /tmp/table.json --rows /tmp/rows.jsonl
92
+ zl --environment staging data-table create --schema /tmp/table.json --rows /tmp/rows.jsonl
93
+ # Review the preview, then run its applyCommand unchanged.
94
+ zl --environment staging data-table list
95
+ zl --environment staging data-table view <table-id>
96
+ zl --environment staging data-table grant-writer <table-id> <workflow-id> --apply
97
+ zl --environment staging data-table revoke-writer <table-id> <workflow-id> --apply
98
+ ```
99
+
100
+ Creation requires team owner access. The preview provides a stable operation key,
101
+ checksum, and destination origin for apply. Import accepts at most 1,000 rows and
102
+ a 1 MB request. Workflow writer grants are separate and preview by default.
103
+
79
104
  ## Product operations
80
105
 
81
106
  ```bash
@@ -89,6 +114,7 @@ zl execution list --limit 1
89
114
  zl execution view <id>
90
115
  zl execution summary <id>
91
116
  zl execution artifact download <artifact-id> --output /tmp/artifact
117
+ zl execution replay export <execution-id> --output .zalosai/replays/<execution-id>
92
118
  zl page list --fields id,title,updatedAt
93
119
  zl page export <page-id> --output /tmp/month-end-close.md
94
120
  zl page create --input /tmp/month-end-close.md --apply
@@ -141,6 +167,11 @@ Downloads request a short-lived URL through the authenticated API and write to a
141
167
  file; they do not require direct S3 access. File moves remain scoped to the selected team profile.
142
168
  Execution artifact downloads use the same private-file and no-overwrite guarantees. Select the
143
169
  artifact's team before downloading; the immutable artifact API enforces that team boundary.
170
+ `execution replay export` packages the selected team's ready immutable input and output artifacts
171
+ with their recorded SHA-256 hashes. It verifies every downloaded byte, writes a new private local
172
+ directory, and never prints signed URLs. Run the bundle locally with
173
+ `uv run zalosai run --replay-bundle <directory>` from Agent Studio; this does not use database,
174
+ SSM, or direct S3 credentials.
144
175
  `page export` writes a Markdown process document with a non-rendered revision marker. Edit the
145
176
  Markdown, then use `page update --apply`; it refuses an update when the Page changed after export.
146
177
  `page create` and `page update` both preview by default and preserve common Tiptap process-document
@@ -1,7 +1,7 @@
1
1
  import { client } from '../client.js';
2
2
  import { clearProfile, readProfile, writeProfile } from '../credentials.js';
3
3
  import { currentEnvironment } from '../environments.js';
4
- import { createBrowserProofServer, fetchCliBrowserAuthorizationConfig, generateSession, openBrowser, pollForToken, } from '../utils/cli-auth.js';
4
+ import { createBrowserProofServer, fetchCliBrowserAuthorizationConfig, generateSession, generateManualCode, generateDeviceCode, openBrowser, pollForToken, registerDeviceSession, } from '../utils/cli-auth.js';
5
5
  import { AuthError, handleError } from '../utils/errors.js';
6
6
  import { printJson } from '../utils/output.js';
7
7
  import { dataRow } from '../utils/product-api.js';
@@ -18,6 +18,21 @@ export async function requestBrowserAuthorization(options) {
18
18
  throw new AuthError(`Invalid API token scopes: ${invalidScopes.join(', ')}`);
19
19
  }
20
20
  const session = await generateSession();
21
+ if (options.device) {
22
+ if (!authConfig.capabilities.includes('device-authorization-v1'))
23
+ throw new AuthError('Selected environment does not support device authorization');
24
+ const deviceCode = generateDeviceCode();
25
+ await registerDeviceSession(environment.apiUrl, {
26
+ sessionId: session.sessionId,
27
+ publicKey: session.publicKey,
28
+ tokenName: options.tokenName,
29
+ environment: environment.name,
30
+ codeHash: deviceCode.hash,
31
+ });
32
+ process.stderr.write(`Open ${authConfig.uiOrigin}/cli-auth?device=1 in a browser and enter this code: ${deviceCode.code}\nWaiting for browser authorization...\n`);
33
+ return pollForToken(environment.apiUrl, session.sessionId, session.privateKey, 600000, 2000, 300000);
34
+ }
35
+ const manual = generateManualCode();
21
36
  const proofServer = await createBrowserProofServer(session.sessionId, authConfig.uiOrigin);
22
37
  const query = new URLSearchParams({
23
38
  session_id: session.sessionId,
@@ -26,6 +41,7 @@ export async function requestBrowserAuthorization(options) {
26
41
  environment: environment.name,
27
42
  proof_hash: proofServer.browserProofHash,
28
43
  callback_port: String(proofServer.callbackPort),
44
+ manual_code_hash: manual.hash,
29
45
  });
30
46
  if (options.scopes) {
31
47
  query.set('provision', 'api-token');
@@ -36,7 +52,7 @@ export async function requestBrowserAuthorization(options) {
36
52
  const url = `${environment.apiUrl}/api/auth/cli-login?${query}`;
37
53
  try {
38
54
  openBrowser(url);
39
- process.stderr.write(`Open this URL to authorize: ${url}\nWaiting for browser authorization...\n`);
55
+ process.stderr.write(`Open this URL to authorize: ${url}\nIf the browser cannot reach this CLI, choose Use a code and enter: ${manual.code}\nWaiting for browser authorization...\n`);
40
56
  return await pollForToken(environment.apiUrl, session.sessionId, session.privateKey, 300000);
41
57
  }
42
58
  finally {
@@ -46,9 +62,9 @@ export async function requestBrowserAuthorization(options) {
46
62
  export async function whoamiAction() {
47
63
  printJson(await client.get('/user/profile'));
48
64
  }
49
- export async function loginAction(profileName = process.env.ZL_PROFILE ?? 'default') {
65
+ export async function loginAction(profileName = process.env.ZL_PROFILE ?? 'default', device = false) {
50
66
  const environment = currentEnvironment();
51
- const result = await requestBrowserAuthorization({ tokenName: 'zl-cli' });
67
+ const result = await requestBrowserAuthorization({ tokenName: 'zl-cli', device });
52
68
  if (result.environment !== environment.name)
53
69
  throw new AuthError('Login environment mismatch');
54
70
  writeProfile(profileName, {
@@ -144,9 +160,10 @@ export function registerAuthCommand(program) {
144
160
  auth
145
161
  .command('login')
146
162
  .description('Sign in through the browser for seven days')
147
- .action(async () => {
163
+ .option('--device', 'Use a code in a browser on another machine')
164
+ .action(async (options) => {
148
165
  try {
149
- await loginAction();
166
+ await loginAction(undefined, options.device === true);
150
167
  printJson({
151
168
  message: 'Logged in',
152
169
  profile: process.env.ZL_PROFILE ?? 'default',
@@ -0,0 +1,215 @@
1
+ import { readFile, writeFile, copyFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { client } from '../client.js';
5
+ import { readProfile } from '../credentials.js';
6
+ import { currentEnvironment } from '../environments.js';
7
+ import { ValidationError } from '../utils/errors.js';
8
+ import { action, dataRow, dataRows, resourceId } from '../utils/product-api.js';
9
+ import { printJson } from '../utils/output.js';
10
+ function revision(value) {
11
+ const parsed = Number(value);
12
+ if (!value || !Number.isSafeInteger(parsed) || parsed < 1)
13
+ throw new ValidationError('revision', 'A positive --revision is required');
14
+ return parsed;
15
+ }
16
+ async function htmlFile(path) {
17
+ let html;
18
+ try {
19
+ html = await readFile(path, 'utf8');
20
+ }
21
+ catch {
22
+ throw new ValidationError('html', `Unable to read HTML file: ${path}`);
23
+ }
24
+ if (Buffer.byteLength(html, 'utf8') > 1_000_000)
25
+ throw new ValidationError('html', 'HTML exceeds 1,000,000 UTF-8 bytes');
26
+ return html;
27
+ }
28
+ function visibility(value) {
29
+ if (value !== 'off' && value !== 'internal' && value !== 'everyone')
30
+ throw new ValidationError('visibility', 'Use off, internal, or everyone');
31
+ return value;
32
+ }
33
+ export async function listCustomPagesAction(options) {
34
+ printJson(dataRows(await client.get('/custom-pages', { manage: String(options.manage ?? false) })));
35
+ }
36
+ export async function getCustomPageAction(id) {
37
+ printJson(dataRow(await client.get(`/custom-pages/${resourceId(id)}`, { manage: 'true' })));
38
+ }
39
+ export async function exportCustomPageAction(id, options) {
40
+ const page = dataRow(await client.get(`/custom-pages/${resourceId(id)}`, { manage: 'true' }));
41
+ if (typeof page.html !== 'string' || typeof page.revision !== 'number')
42
+ throw new ValidationError('page', 'Invalid page response');
43
+ await writeFile(`${options.output}.html`, page.html, { flag: 'wx', mode: 0o600 });
44
+ const { html: _html, ...metadata } = page;
45
+ await writeFile(`${options.output}.json`, JSON.stringify(metadata, null, 2) + '\n', {
46
+ flag: 'wx',
47
+ mode: 0o600,
48
+ });
49
+ printJson({
50
+ html: `${options.output}.html`,
51
+ metadata: `${options.output}.json`,
52
+ id: page.id,
53
+ revision: page.revision,
54
+ });
55
+ }
56
+ export async function createCustomPageAction(options) {
57
+ const payload = {
58
+ name: options.name.trim(),
59
+ html: await htmlFile(options.html),
60
+ visibility: visibility(options.visibility ?? 'off'),
61
+ };
62
+ if (!payload.name || payload.name.length > 80)
63
+ throw new ValidationError('name', 'Name must be 1–80 characters');
64
+ if (!options.apply || options.dryRun) {
65
+ printJson({
66
+ dryRun: true,
67
+ method: 'POST',
68
+ path: '/custom-pages',
69
+ name: payload.name,
70
+ htmlBytes: Buffer.byteLength(payload.html),
71
+ visibility: payload.visibility,
72
+ });
73
+ return;
74
+ }
75
+ printJson(dataRow(await client.post('/custom-pages', payload)));
76
+ }
77
+ export async function updateCustomPageAction(id, options) {
78
+ const pageId = resourceId(id);
79
+ const expectedRevision = revision(options.revision);
80
+ const payload = { expectedRevision };
81
+ if (options.html !== undefined)
82
+ payload.html = await htmlFile(options.html);
83
+ if (options.name !== undefined) {
84
+ const name = options.name.trim();
85
+ if (!name || name.length > 80)
86
+ throw new ValidationError('name', 'Name must be 1–80 characters');
87
+ payload.name = name;
88
+ }
89
+ if (options.visibility !== undefined)
90
+ payload.visibility = visibility(options.visibility);
91
+ if (Object.keys(payload).length === 1)
92
+ throw new ValidationError('update', 'Provide --html, --name, or --visibility');
93
+ if (!options.apply || options.dryRun) {
94
+ printJson({
95
+ dryRun: true,
96
+ method: 'PATCH',
97
+ path: `/custom-pages/${pageId}`,
98
+ ...payload,
99
+ html: payload.html === undefined
100
+ ? undefined
101
+ : `<${Buffer.byteLength(payload.html)} bytes>`,
102
+ });
103
+ return;
104
+ }
105
+ printJson(dataRow(await client.patch(`/custom-pages/${pageId}`, payload)));
106
+ }
107
+ export async function deleteCustomPageAction(id, options) {
108
+ const pageId = resourceId(id), expectedRevision = revision(options.revision);
109
+ if (!options.apply) {
110
+ printJson({
111
+ dryRun: true,
112
+ method: 'DELETE',
113
+ path: `/custom-pages/${pageId}`,
114
+ expectedRevision,
115
+ });
116
+ return;
117
+ }
118
+ await client.delete(`/custom-pages/${pageId}?expectedRevision=${expectedRevision}`);
119
+ printJson({ deleted: pageId });
120
+ }
121
+ export function previewCustomPageAction(id) {
122
+ const profile = readProfile(process.env.ZL_PROFILE ?? 'default');
123
+ const teamId = profile?.team?.publicId;
124
+ if (!teamId)
125
+ throw new ValidationError('team', 'Select a team with `zl team switch` before previewing');
126
+ printJson({
127
+ url: `${currentEnvironment(profile.environment).apiUrl}/t/${encodeURIComponent(teamId)}/custom-pages/${resourceId(id)}`,
128
+ });
129
+ }
130
+ const kit = new URL('../../skills/custom-page-authoring/assets/', import.meta.url);
131
+ export async function starterCustomPageAction(options) {
132
+ const example = options.example ?? 'starter';
133
+ if (!['starter', 'dashboard', 'report', 'table'].includes(example))
134
+ throw new ValidationError('example', 'Use starter, dashboard, report, or table');
135
+ await copyFile(fileURLToPath(new URL(`${example}.html`, kit)), options.output, constants.COPYFILE_EXCL);
136
+ printJson({ output: options.output, example });
137
+ }
138
+ export async function validateCustomPageAction(options) {
139
+ const html = await htmlFile(options.html);
140
+ const problems = [];
141
+ if (/<(?:script|link|img|iframe|source|form)\b[^>]*(?:src|href|action)\s*=\s*["']?(?:https?:)?\/\//i.test(html) ||
142
+ /(?:@import|url\()\s*(?:url\()?\s*["']?(?:https?:)?\/\//i.test(html))
143
+ problems.push('External assets are unsupported');
144
+ if (!/<h1\b/i.test(html))
145
+ problems.push('Add an h1 heading');
146
+ if (/<img\b(?![^>]*\balt\s*=)[^>]*>/i.test(html))
147
+ problems.push('Images need alt text');
148
+ for (const match of html.matchAll(/<input\b[^>]*>/gi)) {
149
+ const tag = match[0];
150
+ if (/\btype\s*=\s*["']?(?:hidden|file)\b/i.test(tag) ||
151
+ /\bclass\s*=\s*["'][^"']*\bhidden\b/i.test(tag))
152
+ continue;
153
+ if (!/\b(?:aria-label|aria-labelledby|id)\s*=/i.test(tag)) {
154
+ problems.push('Inputs need a label or accessible name');
155
+ break;
156
+ }
157
+ }
158
+ if (/\bmesh\.runWorkflow\s*\(/.test(html))
159
+ problems.push('mesh.runWorkflow is no longer supported; use mesh.navigate instead');
160
+ printJson({
161
+ valid: problems.length === 0,
162
+ problems,
163
+ htmlBytes: Buffer.byteLength(html),
164
+ staticAnalysisOnly: true,
165
+ });
166
+ if (problems.length)
167
+ throw new ValidationError('html', problems.join('; '));
168
+ }
169
+ export function registerCustomPageCommand(program) {
170
+ const page = program.command('custom-page').description('Manage team-scoped HTML Custom Pages');
171
+ page
172
+ .command('list')
173
+ .option('--manage', 'Include disabled pages (owner only)')
174
+ .action(async (options) => action(() => listCustomPagesAction(options)));
175
+ page
176
+ .command('get <id>')
177
+ .action(async (id) => action(() => getCustomPageAction(id)));
178
+ page
179
+ .command('export <id>')
180
+ .requiredOption('--output <base>', 'Output basename')
181
+ .action(async (id, options) => action(() => exportCustomPageAction(id, options)));
182
+ page
183
+ .command('create')
184
+ .requiredOption('--html <file>', 'HTML file')
185
+ .requiredOption('--name <name>', 'Page name')
186
+ .option('--visibility <level>', 'off, internal, everyone', 'off')
187
+ .option('--apply')
188
+ .option('--dry-run')
189
+ .action(async (options) => action(() => createCustomPageAction(options)));
190
+ page
191
+ .command('update <id>')
192
+ .requiredOption('--revision <n>', 'Expected revision')
193
+ .option('--html <file>')
194
+ .option('--name <name>')
195
+ .option('--visibility <level>')
196
+ .option('--apply')
197
+ .option('--dry-run')
198
+ .action(async (id, options) => action(() => updateCustomPageAction(id, options)));
199
+ page
200
+ .command('delete <id>')
201
+ .requiredOption('--revision <n>', 'Expected revision')
202
+ .option('--apply')
203
+ .action(async (id, options) => action(() => deleteCustomPageAction(id, options)));
204
+ page.command('preview <id>').action((id) => previewCustomPageAction(id));
205
+ page
206
+ .command('starter')
207
+ .requiredOption('--output <file>')
208
+ .option('--example <name>')
209
+ .action(async (options) => action(() => starterCustomPageAction(options)));
210
+ page
211
+ .command('validate')
212
+ .requiredOption('--html <file>')
213
+ .action(async (options) => action(() => validateCustomPageAction(options)));
214
+ }
215
+ //# sourceMappingURL=custom-page.js.map
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { resolve } from 'node:path';
3
+ import { client } from '../client.js';
4
+ import { readTableProvisionInput, MAX_PROVISION_BYTES } from '../data-table-input.js';
5
+ import { currentEnvironment, ENVIRONMENTS } from '../environments.js';
6
+ import { ValidationError } from '../utils/errors.js';
7
+ import { dataRow, dataRows, fields, printPage, resourceId, action, } from '../utils/product-api.js';
8
+ import { filterSingle, printJson } from '../utils/output.js';
9
+ function quote(value) {
10
+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
11
+ }
12
+ function responseData(value) {
13
+ return dataRow(value);
14
+ }
15
+ export async function validateTableAction(options) {
16
+ const input = await readTableProvisionInput(options.schema, options.rows);
17
+ printJson({
18
+ valid: true,
19
+ columns: input.definition.columns.length,
20
+ rows: input.rows.length,
21
+ remoteAuthorization: 'not checked',
22
+ });
23
+ }
24
+ export async function createTableAction(options) {
25
+ const input = await readTableProvisionInput(options.schema, options.rows);
26
+ if (!options.apply || options.dryRun) {
27
+ const preview = responseData(await client.post('/data-tables/provision/preview', { input }, { detailedAuthErrors: true }));
28
+ if (typeof preview.checksum !== 'string' ||
29
+ !/^[0-9a-f]{64}$/.test(preview.checksum) ||
30
+ typeof preview.teamId !== 'string')
31
+ throw new ValidationError('preview', 'Invalid server preview');
32
+ const environment = currentEnvironment();
33
+ const operationKey = randomUUID();
34
+ const command = [
35
+ 'zl',
36
+ '--environment',
37
+ environment.name,
38
+ ...(environment.apiUrl !== ENVIRONMENTS[environment.name]
39
+ ? ['--api-url', quote(environment.apiUrl)]
40
+ : []),
41
+ ...(process.env.ZL_PROFILE ? ['--profile', quote(process.env.ZL_PROFILE)] : []),
42
+ 'data-table',
43
+ 'create',
44
+ '--schema',
45
+ quote(resolve(options.schema)),
46
+ ...(options.rows ? ['--rows', quote(resolve(options.rows))] : []),
47
+ '--operation-key',
48
+ quote(operationKey),
49
+ '--expected-checksum',
50
+ quote(preview.checksum),
51
+ '--expected-origin',
52
+ quote(environment.apiUrl),
53
+ '--apply',
54
+ ].join(' ');
55
+ printJson({
56
+ dryRun: true,
57
+ environment: environment.name,
58
+ teamId: preview.teamId,
59
+ label: input.definition.label,
60
+ columnCount: preview.columnCount,
61
+ rowCount: preview.rowCount,
62
+ checksum: preview.checksum,
63
+ origin: environment.apiUrl,
64
+ operationKey,
65
+ applyCommand: command,
66
+ });
67
+ return;
68
+ }
69
+ if (!options.operationKey || !options.expectedChecksum || !options.expectedOrigin)
70
+ throw new ValidationError('apply', 'Apply requires --operation-key, --expected-checksum and --expected-origin from the preview');
71
+ if (options.expectedOrigin !== currentEnvironment().apiUrl)
72
+ throw new ValidationError('expectedOrigin', 'Destination changed since preview');
73
+ if (!/^[A-Za-z0-9._:-]{8,200}$/.test(options.operationKey))
74
+ throw new ValidationError('operationKey', 'Expected 8 to 200 safe characters');
75
+ if (!/^[0-9a-f]{64}$/.test(options.expectedChecksum))
76
+ throw new ValidationError('expectedChecksum', 'Expected a SHA-256 checksum');
77
+ const body = {
78
+ input,
79
+ operationKey: options.operationKey,
80
+ expectedChecksum: options.expectedChecksum,
81
+ };
82
+ if (Buffer.byteLength(JSON.stringify(body)) > MAX_PROVISION_BYTES)
83
+ throw new ValidationError('input', 'Provision request exceeds 1,000,000 bytes');
84
+ printJson(responseData(await client.post('/data-tables/provision', body, { detailedAuthErrors: true })));
85
+ }
86
+ export async function listTablesAction(options) {
87
+ printPage(dataRows(await client.get('/data-tables')), options);
88
+ }
89
+ export async function viewTableAction(id, options) {
90
+ printJson(filterSingle(dataRow(await client.get(`/data-tables/${resourceId(id)}`)), options.fields));
91
+ }
92
+ export async function writerAction(tableId, workflowId, grant, options) {
93
+ const path = `/data-tables/${resourceId(tableId)}/writers${grant ? '' : `/${resourceId(workflowId)}`}`;
94
+ const method = grant ? 'POST' : 'DELETE';
95
+ if (!options.apply || options.dryRun) {
96
+ printJson({
97
+ dryRun: true,
98
+ method,
99
+ path,
100
+ ...(grant ? { workflowId: resourceId(workflowId) } : {}),
101
+ });
102
+ return;
103
+ }
104
+ if (grant)
105
+ await client.post(path, { workflowId: resourceId(workflowId) }, { detailedAuthErrors: true });
106
+ else
107
+ await client.delete(path);
108
+ printJson({ tableId, workflowId, granted: grant });
109
+ }
110
+ export function registerDataTableCommand(program) {
111
+ const cmd = program.command('data-table').description('Validate and provision Data Tables');
112
+ cmd
113
+ .command('validate')
114
+ .requiredOption('--schema <file>', 'Explicit table definition JSON')
115
+ .option('--rows <file>', 'Optional initial rows as JSON array or JSONL')
116
+ .action(async (options) => action(() => validateTableAction(options)));
117
+ cmd
118
+ .command('create')
119
+ .requiredOption('--schema <file>', 'Explicit table definition JSON')
120
+ .option('--rows <file>', 'Optional initial rows as JSON array or JSONL, maximum 1,000 rows and 1 MB request')
121
+ .option('--dry-run', 'Validate remotely and preview without writing (default)')
122
+ .option('--apply', 'Create the table and optional rows')
123
+ .option('--operation-key <key>', 'Stable retry key from the preview')
124
+ .option('--expected-checksum <sha256>', 'Destination-bound checksum from the preview')
125
+ .option('--expected-origin <url>', 'Exact API origin from the preview')
126
+ .action(async (options) => action(() => createTableAction(options)));
127
+ cmd
128
+ .command('list')
129
+ .option('--limit <n>', 'Maximum results', '100')
130
+ .option('--offset <n>', 'Result offset', '0')
131
+ .action(async (options) => action(() => listTablesAction({ ...options, ...fields(program) })));
132
+ cmd
133
+ .command('view <id>')
134
+ .action(async (id) => action(() => viewTableAction(id, fields(program))));
135
+ cmd
136
+ .command('grant-writer <table-id> <workflow-id>')
137
+ .option('--apply', 'Grant writer permission')
138
+ .option('--dry-run', 'Preview grant (default)')
139
+ .action(async (tableId, workflowId, options) => action(() => writerAction(tableId, workflowId, true, options)));
140
+ cmd
141
+ .command('revoke-writer <table-id> <workflow-id>')
142
+ .option('--apply', 'Revoke writer permission')
143
+ .option('--dry-run', 'Preview revocation (default)')
144
+ .action(async (tableId, workflowId, options) => action(() => writerAction(tableId, workflowId, false, options)));
145
+ }
146
+ //# sourceMappingURL=data-table.js.map
@@ -1,10 +1,84 @@
1
1
  import { client } from '../client.js';
2
- import { writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
3
6
  import { currentEnvironment } from '../environments.js';
4
7
  import { downloadToNewFile } from '../utils/download-file.js';
5
8
  import { ApiError, ValidationError } from '../utils/errors.js';
6
9
  import { filterFields, filterSingle, printJson } from '../utils/output.js';
7
10
  import { action, dataRow, fields, integer, isRow, resourceId, } from '../utils/product-api.js';
11
+ function isReplayBundleArtifact(value) {
12
+ if (!isRow(value))
13
+ return false;
14
+ return (typeof value.artifactId === 'string' &&
15
+ typeof value.name === 'string' &&
16
+ typeof value.sha256 === 'string' &&
17
+ /^sha256:[a-f0-9]{64}$/.test(value.sha256) &&
18
+ typeof value.size === 'number' &&
19
+ Number.isSafeInteger(value.size) &&
20
+ value.size >= 0 &&
21
+ typeof value.mimeType === 'string');
22
+ }
23
+ function isReplayBundleInput(value) {
24
+ return (isReplayBundleArtifact(value) &&
25
+ isRow(value) &&
26
+ typeof value.fieldName === 'string' &&
27
+ value.fieldName.length > 0);
28
+ }
29
+ function replayBundlePlan(value) {
30
+ const plan = dataRow(value);
31
+ if (plan.schemaVersion !== 1 ||
32
+ typeof plan.executionId !== 'string' ||
33
+ !plan.executionId ||
34
+ typeof plan.agentRef !== 'string' ||
35
+ !isRow(plan.parameters) ||
36
+ !Object.values(plan.parameters).every((parameter) => typeof parameter === 'string') ||
37
+ !Array.isArray(plan.inputs) ||
38
+ !plan.inputs.every(isReplayBundleInput) ||
39
+ !Array.isArray(plan.outputs) ||
40
+ !plan.outputs.every(isReplayBundleArtifact))
41
+ throw new ApiError(502, 'Invalid execution replay bundle response');
42
+ return {
43
+ schemaVersion: 1,
44
+ executionId: plan.executionId,
45
+ agentRef: plan.agentRef,
46
+ parameters: Object.fromEntries(Object.entries(plan.parameters).map(([name, parameter]) => [name, String(parameter)])),
47
+ inputs: plan.inputs,
48
+ outputs: plan.outputs,
49
+ };
50
+ }
51
+ function replayBundleFileName(name, index) {
52
+ const safeName = name
53
+ // oxlint-disable-next-line no-control-regex -- artifact names cannot carry terminal controls
54
+ .replace(/[\\/\u0000-\u001f\u007f]/g, '_')
55
+ .replace(/^\.+$/, '')
56
+ .trim();
57
+ if (!safeName)
58
+ throw new ValidationError('artifact.name', 'Invalid replay bundle artifact name');
59
+ return `${String(index + 1).padStart(3, '0')}-${safeName}`;
60
+ }
61
+ async function sha256File(path) {
62
+ const hash = createHash('sha256');
63
+ for await (const chunk of createReadStream(path))
64
+ hash.update(chunk);
65
+ return `sha256:${hash.digest('hex')}`;
66
+ }
67
+ async function downloadReplayBundleArtifact(artifact, output, allowDataUrl) {
68
+ const download = dataRow(await client.get(`/execution-artifacts/${artifact.artifactId}/download`));
69
+ if (typeof download.url !== 'string') {
70
+ throw new ValidationError('download', 'The execution artifact API response did not include a download URL');
71
+ }
72
+ await mkdir(dirname(output), { recursive: true, mode: 0o700 });
73
+ await downloadToNewFile(download.url, output, { allowDataUrl });
74
+ const downloaded = await stat(output);
75
+ if (downloaded.size !== artifact.size) {
76
+ throw new ApiError(502, `Artifact ${artifact.artifactId} size does not match replay evidence`);
77
+ }
78
+ if ((await sha256File(output)) !== artifact.sha256) {
79
+ throw new ApiError(502, `Artifact ${artifact.artifactId} hash does not match replay evidence`);
80
+ }
81
+ }
8
82
  export async function inspectRecoveryAction(id, options) {
9
83
  const executionId = resourceId(id);
10
84
  const bundle = dataRow(await client.get(`/executions/${executionId}/recovery/inspect`));
@@ -52,6 +126,36 @@ export async function downloadExecutionArtifactAction(artifactId, options) {
52
126
  });
53
127
  printJson({ artifactId: id, ...downloaded });
54
128
  }
129
+ export async function exportExecutionReplayBundleAction(id, options) {
130
+ const executionId = resourceId(id);
131
+ const plan = replayBundlePlan(await client.get(`/executions/${executionId}/replay-bundle`));
132
+ await mkdir(dirname(options.output), { recursive: true, mode: 0o700 });
133
+ await mkdir(options.output, { mode: 0o700 });
134
+ try {
135
+ const inputs = plan.inputs.map((artifact, index) => ({
136
+ ...artifact,
137
+ path: join('inputs', replayBundleFileName(artifact.name, index)),
138
+ }));
139
+ const outputs = plan.outputs.map((artifact, index) => ({
140
+ ...artifact,
141
+ path: join('outputs', replayBundleFileName(artifact.name, index)),
142
+ }));
143
+ for (const artifact of [...inputs, ...outputs]) {
144
+ await downloadReplayBundleArtifact(artifact, join(options.output, artifact.path), options.allowDataUrl);
145
+ }
146
+ await writeFile(join(options.output, 'replay-bundle.json'), `${JSON.stringify({ ...plan, inputs, outputs }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
147
+ }
148
+ catch (error) {
149
+ await rm(options.output, { recursive: true, force: true });
150
+ throw error;
151
+ }
152
+ printJson({
153
+ executionId: plan.executionId,
154
+ output: options.output,
155
+ inputs: plan.inputs.length,
156
+ outputs: plan.outputs.length,
157
+ });
158
+ }
55
159
  export async function listExecutionsAction(options) {
56
160
  const filter = {};
57
161
  if (options.workflowId)
@@ -101,6 +205,19 @@ export function registerExecutionCommand(program) {
101
205
  cmd
102
206
  .command(`${verb} <id>`)
103
207
  .action(async (id) => action(() => viewExecutionAction(id, fields(program), verb === 'summary')));
208
+ cmd
209
+ .command('replay')
210
+ .description('Materialize verified local replay bundles')
211
+ .command('export <execution-id>')
212
+ .description('Download immutable replay inputs and outputs through the authenticated API')
213
+ .requiredOption('--output <path>', 'Write the replay bundle to a new directory')
214
+ .action(async (id, options) => action(() => {
215
+ const hostname = new URL(currentEnvironment().apiUrl).hostname;
216
+ return exportExecutionReplayBundleAction(id, {
217
+ ...options,
218
+ allowDataUrl: ['localhost', '127.0.0.1', '[::1]'].includes(hostname),
219
+ });
220
+ }));
104
221
  const artifact = cmd.command('artifact').description('Access immutable execution artifacts');
105
222
  artifact
106
223
  .command('download <artifact-id>')