@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 +3 -0
- package/README.md +31 -0
- package/dist/commands/auth.js +23 -6
- package/dist/commands/custom-page.js +215 -0
- package/dist/commands/data-table.js +146 -0
- package/dist/commands/execution.js +118 -1
- package/dist/data-table-input.js +180 -0
- package/dist/main.js +7 -0
- package/dist/utils/cli-auth.js +26 -1
- package/dist/utils/errors.js +9 -2
- package/package.json +4 -2
- package/skills/custom-page-authoring/SKILL.md +32 -0
- package/skills/custom-page-authoring/assets/GEIST-LICENSE.txt +92 -0
- package/skills/custom-page-authoring/assets/dashboard.html +35 -0
- package/skills/custom-page-authoring/assets/report.html +35 -0
- package/skills/custom-page-authoring/assets/starter.html +35 -0
- package/skills/custom-page-authoring/assets/table.html +35 -0
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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
-
.
|
|
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 {
|
|
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>')
|