@zalosai/zalos-cli 0.2.13
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 +97 -0
- package/README.md +231 -0
- package/dist/client.js +79 -0
- package/dist/commands/api-token.js +74 -0
- package/dist/commands/auth.js +196 -0
- package/dist/commands/credential.js +15 -0
- package/dist/commands/cycle.js +47 -0
- package/dist/commands/doctor.js +106 -0
- package/dist/commands/execution.js +117 -0
- package/dist/commands/file.js +257 -0
- package/dist/commands/label.js +30 -0
- package/dist/commands/member.js +23 -0
- package/dist/commands/page-markdown.js +213 -0
- package/dist/commands/page.js +178 -0
- package/dist/commands/project.js +97 -0
- package/dist/commands/schema.js +178 -0
- package/dist/commands/setup.js +34 -0
- package/dist/commands/state.js +30 -0
- package/dist/commands/task-comment.js +75 -0
- package/dist/commands/task.js +245 -0
- package/dist/commands/team.js +88 -0
- package/dist/commands/template.js +21 -0
- package/dist/commands/workflow.js +245 -0
- package/dist/credentials.js +123 -0
- package/dist/environments.js +42 -0
- package/dist/main.js +86 -0
- package/dist/utils/cli-auth.js +186 -0
- package/dist/utils/developer-db.js +78 -0
- package/dist/utils/download-file.js +65 -0
- package/dist/utils/errors.js +60 -0
- package/dist/utils/output.js +30 -0
- package/dist/utils/product-api.js +48 -0
- package/dist/utils/stdin.js +23 -0
- package/dist/utils/validate.js +39 -0
- package/dist/workflow-file.js +34 -0
- package/package.json +53 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { writeFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { readWorkflowFile, serializeWorkflow } from '../workflow-file.js';
|
|
5
|
+
import { ValidationError } from '../utils/errors.js';
|
|
6
|
+
import { filterSingle, printJson } from '../utils/output.js';
|
|
7
|
+
import { action, dataRow, dataRows, fields, isRow, printPage, resourceId, } from '../utils/product-api.js';
|
|
8
|
+
import { parseJson } from '../utils/validate.js';
|
|
9
|
+
function publicationStatus(workflow, publishedNow = false) {
|
|
10
|
+
const draftVersionId = typeof workflow.versionId === 'string' ? workflow.versionId : undefined;
|
|
11
|
+
const publishedVersionId = typeof workflow.publishedVersionId === 'string' ? workflow.publishedVersionId : undefined;
|
|
12
|
+
const published = publishedVersionId === undefined ||
|
|
13
|
+
draftVersionId === undefined ||
|
|
14
|
+
draftVersionId === publishedVersionId;
|
|
15
|
+
return {
|
|
16
|
+
published,
|
|
17
|
+
...(draftVersionId === undefined ? {} : { draftVersionId }),
|
|
18
|
+
...(publishedVersionId === undefined ? {} : { publishedVersionId }),
|
|
19
|
+
message: published
|
|
20
|
+
? publishedNow
|
|
21
|
+
? `Published draft ${draftVersionId}. Runs now use this version.`
|
|
22
|
+
: 'Runs use this workflow version.'
|
|
23
|
+
: `Saved draft ${draftVersionId}. Runs still use published ${publishedVersionId}; publish the workflow to make this change live.`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function warnIfWorkflowDraftIsUnpublished(workflow) {
|
|
27
|
+
const workflowId = typeof workflow.id === 'string' ? workflow.id : undefined;
|
|
28
|
+
const draftVersionId = typeof workflow.versionId === 'string' ? workflow.versionId : undefined;
|
|
29
|
+
const publishedVersionId = typeof workflow.publishedVersionId === 'string' ? workflow.publishedVersionId : undefined;
|
|
30
|
+
if (workflowId === undefined ||
|
|
31
|
+
draftVersionId === undefined ||
|
|
32
|
+
publishedVersionId === undefined ||
|
|
33
|
+
draftVersionId === publishedVersionId)
|
|
34
|
+
return;
|
|
35
|
+
process.stderr.write(`${JSON.stringify({
|
|
36
|
+
warning: {
|
|
37
|
+
code: 'WORKFLOW_DRAFT_NOT_PUBLISHED',
|
|
38
|
+
workflowId,
|
|
39
|
+
draftVersionId,
|
|
40
|
+
publishedVersionId,
|
|
41
|
+
message: `Exported draft ${draftVersionId}. Runs still use published ${publishedVersionId}; run \`zl workflow publish ${workflowId} --apply\` to make this change live.`,
|
|
42
|
+
},
|
|
43
|
+
})}\n`);
|
|
44
|
+
}
|
|
45
|
+
export async function listWorkflowsAction(options) {
|
|
46
|
+
const path = options.allTeams ? '/developer/workflows' : '/workflows';
|
|
47
|
+
printPage(dataRows(await client.get(path)), options);
|
|
48
|
+
}
|
|
49
|
+
export async function viewWorkflowAction(id, options) {
|
|
50
|
+
printJson(filterSingle(dataRow(await client.get(`/workflows/${resourceId(id)}`)), options.fields));
|
|
51
|
+
}
|
|
52
|
+
export async function exportWorkflowAction(id, options) {
|
|
53
|
+
const selections = [Boolean(id), Boolean(options.all), Boolean(options.allTeams)].filter(Boolean).length;
|
|
54
|
+
if (selections !== 1)
|
|
55
|
+
throw new ValidationError('id', 'Choose a workflow ID, --all, or --all-teams');
|
|
56
|
+
if (id)
|
|
57
|
+
resourceId(id);
|
|
58
|
+
const output = options.output ? resolve(options.output) : undefined;
|
|
59
|
+
if (options.separate && (!output || !(await stat(output)).isDirectory()))
|
|
60
|
+
throw new ValidationError('output', '--separate requires an existing output directory');
|
|
61
|
+
let rows;
|
|
62
|
+
let ids;
|
|
63
|
+
if (options.allTeams) {
|
|
64
|
+
const regionalRows = dataRows(await client.get('/developer/workflows/export'));
|
|
65
|
+
ids = regionalRows.map((row) => {
|
|
66
|
+
if (typeof row.id !== 'string')
|
|
67
|
+
throw new ValidationError('id', 'Workflow response is missing ID');
|
|
68
|
+
return resourceId(row.id);
|
|
69
|
+
});
|
|
70
|
+
rows = regionalRows.map((row) => filterSingle(row, options.fields));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
ids = id
|
|
74
|
+
? [id]
|
|
75
|
+
: dataRows(await client.get('/workflows')).map((row) => {
|
|
76
|
+
if (typeof row.id !== 'string')
|
|
77
|
+
throw new ValidationError('id', 'Workflow response is missing ID');
|
|
78
|
+
return resourceId(row.id);
|
|
79
|
+
});
|
|
80
|
+
rows = [];
|
|
81
|
+
for (const workflowId of ids)
|
|
82
|
+
rows.push(filterSingle(dataRow(await client.get(`/workflows/${workflowId}`)), options.fields));
|
|
83
|
+
}
|
|
84
|
+
rows.forEach(warnIfWorkflowDraftIsUnpublished);
|
|
85
|
+
if (options.separate && output) {
|
|
86
|
+
const outputs = [];
|
|
87
|
+
for (let index = 0; index < rows.length; index++) {
|
|
88
|
+
const path = join(output, `${ids[index]}.json`);
|
|
89
|
+
await writeFile(path, serializeWorkflow(rows[index], options.pretty), {
|
|
90
|
+
flag: 'wx',
|
|
91
|
+
mode: 0o600,
|
|
92
|
+
});
|
|
93
|
+
outputs.push(path);
|
|
94
|
+
}
|
|
95
|
+
printJson({ outputs, count: rows.length });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const serialized = options.all || options.allTeams
|
|
99
|
+
? JSON.stringify(rows, null, options.pretty ? 2 : undefined) + '\n'
|
|
100
|
+
: serializeWorkflow(rows[0], options.pretty);
|
|
101
|
+
if (output) {
|
|
102
|
+
await writeFile(output, serialized, { flag: 'wx', mode: 0o600 });
|
|
103
|
+
printJson({ output, count: rows.length });
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
process.stdout.write(serialized);
|
|
107
|
+
}
|
|
108
|
+
export async function validateWorkflowAction(path) {
|
|
109
|
+
const value = await readWorkflowFile(path);
|
|
110
|
+
printJson({
|
|
111
|
+
valid: true,
|
|
112
|
+
id: value.id,
|
|
113
|
+
nodeCount: value.nodes.length,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export async function importWorkflowAction(path, options) {
|
|
117
|
+
const payload = await readWorkflowFile(path);
|
|
118
|
+
if (payload.id !== undefined)
|
|
119
|
+
throw new ValidationError('id', 'Import creates a new workflow; remove id or use workflow update');
|
|
120
|
+
if (!options.apply || options.dryRun) {
|
|
121
|
+
printJson({ dryRun: true, method: 'POST', path: '/workflows', payload });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
printJson(dataRow(await client.post('/workflows', payload)));
|
|
125
|
+
}
|
|
126
|
+
export async function updateWorkflowAction(id, options) {
|
|
127
|
+
const path = `/workflows/${resourceId(id)}`;
|
|
128
|
+
const { checksum, expectedChecksum, versionId: _versionId, publishedVersionId: _publishedVersionId, ...workflow } = await readWorkflowFile(options.input, id);
|
|
129
|
+
for (const value of [checksum, expectedChecksum]) {
|
|
130
|
+
if (value !== undefined && (typeof value !== 'string' || !value.trim())) {
|
|
131
|
+
throw new ValidationError('checksum', 'Workflow checksum must be a nonempty string');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (checksum !== undefined && expectedChecksum !== undefined && checksum !== expectedChecksum) {
|
|
135
|
+
throw new ValidationError('checksum', 'expectedChecksum does not match the exported checksum');
|
|
136
|
+
}
|
|
137
|
+
const baseline = expectedChecksum ?? checksum;
|
|
138
|
+
if (options.apply && !options.dryRun && baseline === undefined) {
|
|
139
|
+
throw new ValidationError('checksum', 'Update requires checksum or expectedChecksum; export the current workflow before editing');
|
|
140
|
+
}
|
|
141
|
+
const payload = {
|
|
142
|
+
...workflow,
|
|
143
|
+
...(baseline !== undefined ? { expectedChecksum: baseline } : {}),
|
|
144
|
+
};
|
|
145
|
+
if (!options.apply || options.dryRun) {
|
|
146
|
+
printJson({ dryRun: true, method: 'PUT', path, payload });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const saved = dataRow(await client.put(path, payload));
|
|
150
|
+
printJson({ workflow: saved, publication: publicationStatus(saved) });
|
|
151
|
+
}
|
|
152
|
+
export async function publishWorkflowAction(id, options) {
|
|
153
|
+
const path = `/workflows/${resourceId(id)}/publish`;
|
|
154
|
+
if (!options.apply || options.dryRun) {
|
|
155
|
+
printJson({ dryRun: true, method: 'POST', path });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const published = dataRow(await client.post(path));
|
|
159
|
+
printJson({ workflow: published, publication: publicationStatus(published, true) });
|
|
160
|
+
}
|
|
161
|
+
export async function deactivateWorkflowAction(id, options) {
|
|
162
|
+
const path = `/workflows/${resourceId(id)}/deactivate`;
|
|
163
|
+
if (!options.apply || options.dryRun) {
|
|
164
|
+
printJson({ dryRun: true, method: 'PATCH', path });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
printJson(dataRow(await client.patch(path, {})));
|
|
168
|
+
}
|
|
169
|
+
export async function deleteWorkflowAction(id, options) {
|
|
170
|
+
const workflowId = resourceId(id);
|
|
171
|
+
const path = `/workflows/${workflowId}`;
|
|
172
|
+
if (!options.apply || options.dryRun) {
|
|
173
|
+
printJson({ dryRun: true, method: 'DELETE', path });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await client.delete(path);
|
|
177
|
+
printJson({ deleted: true, workflowId });
|
|
178
|
+
}
|
|
179
|
+
export async function runWorkflowAction(id, options) {
|
|
180
|
+
const path = `/workflows/${resourceId(id)}/run`;
|
|
181
|
+
const triggerData = parseJson(options.json ?? '{}');
|
|
182
|
+
if (!isRow(triggerData))
|
|
183
|
+
throw new ValidationError('json', 'Trigger inputs must be an object');
|
|
184
|
+
const payload = { triggerData };
|
|
185
|
+
if (options.dryRun) {
|
|
186
|
+
printJson({ dryRun: true, method: 'POST', path, payload });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
printJson(dataRow(await client.post(path, payload)));
|
|
190
|
+
}
|
|
191
|
+
export function registerWorkflowCommand(program) {
|
|
192
|
+
const cmd = program.command('workflow').description('Manage workflows through the regional API');
|
|
193
|
+
cmd
|
|
194
|
+
.command('list')
|
|
195
|
+
.option('--all-teams', 'List workflows across every team in the selected region')
|
|
196
|
+
.option('--limit <n>', 'Maximum results', '100')
|
|
197
|
+
.option('--offset <n>', 'Result offset', '0')
|
|
198
|
+
.action(async (o) => action(() => listWorkflowsAction({ ...o, ...fields(program) })));
|
|
199
|
+
cmd
|
|
200
|
+
.command('view <id>')
|
|
201
|
+
.action(async (id) => action(() => viewWorkflowAction(id, fields(program))));
|
|
202
|
+
cmd
|
|
203
|
+
.command('export [id]')
|
|
204
|
+
.option('--all', 'Export every accessible workflow')
|
|
205
|
+
.option('--all-teams', 'Export workflows across every team in the selected region')
|
|
206
|
+
.option('--output <path>', 'Output file (must not exist)')
|
|
207
|
+
.option('--pretty', 'Indent JSON')
|
|
208
|
+
.option('--separate', 'Write ID-named JSON in output directory')
|
|
209
|
+
.action(async (id, o) => action(() => exportWorkflowAction(id, { ...o, ...fields(program) })));
|
|
210
|
+
cmd
|
|
211
|
+
.command('validate <file>')
|
|
212
|
+
.action(async (file) => action(() => validateWorkflowAction(file)));
|
|
213
|
+
cmd
|
|
214
|
+
.command('import <file>')
|
|
215
|
+
.option('--apply', 'Create workflow')
|
|
216
|
+
.option('--dry-run', 'Preview creation')
|
|
217
|
+
.action(async (file, o) => action(() => importWorkflowAction(file, o)));
|
|
218
|
+
cmd
|
|
219
|
+
.command('update <id>')
|
|
220
|
+
.requiredOption('--input <file>', 'Workflow JSON')
|
|
221
|
+
.option('--apply', 'Apply update')
|
|
222
|
+
.option('--dry-run', 'Preview update')
|
|
223
|
+
.action(async (id, o) => action(() => updateWorkflowAction(id, o)));
|
|
224
|
+
cmd
|
|
225
|
+
.command('publish <id>')
|
|
226
|
+
.option('--apply', 'Publish workflow')
|
|
227
|
+
.option('--dry-run', 'Preview publish')
|
|
228
|
+
.action(async (id, o) => action(() => publishWorkflowAction(id, o)));
|
|
229
|
+
cmd
|
|
230
|
+
.command('deactivate <id>')
|
|
231
|
+
.option('--apply', 'Deactivate workflow')
|
|
232
|
+
.option('--dry-run', 'Preview deactivation')
|
|
233
|
+
.action(async (id, o) => action(() => deactivateWorkflowAction(id, o)));
|
|
234
|
+
cmd
|
|
235
|
+
.command('delete <id>')
|
|
236
|
+
.option('--apply', 'Delete workflow')
|
|
237
|
+
.option('--dry-run', 'Preview deletion')
|
|
238
|
+
.action(async (id, o) => action(() => deleteWorkflowAction(id, o)));
|
|
239
|
+
cmd
|
|
240
|
+
.command('run <id>')
|
|
241
|
+
.option('--json <inputs>', 'Trigger inputs as JSON')
|
|
242
|
+
.option('--dry-run', 'Preview execution')
|
|
243
|
+
.action(async (id, o) => action(() => runWorkflowAction(id, o)));
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=workflow.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { chmodSync, constants, closeSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { currentEnvironment } from './environments.js';
|
|
5
|
+
import { AuthError } from './utils/errors.js';
|
|
6
|
+
function credentialsPath() {
|
|
7
|
+
const dir = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'zalos');
|
|
8
|
+
for (const path of [dir, join(dir, 'credentials.json')]) {
|
|
9
|
+
try {
|
|
10
|
+
if (lstatSync(path).isSymbolicLink())
|
|
11
|
+
throw new AuthError('Credentials path must not be a symbolic link');
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== 'ENOENT')
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return join(dir, 'credentials.json');
|
|
19
|
+
}
|
|
20
|
+
function isObject(value) {
|
|
21
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
export function validateProfile(value) {
|
|
24
|
+
if (!isObject(value) ||
|
|
25
|
+
typeof value.token !== 'string' ||
|
|
26
|
+
!value.token ||
|
|
27
|
+
typeof value.apiUrl !== 'string' ||
|
|
28
|
+
!['staging', 'us-prod', 'eu-prod'].includes(String(value.environment)) ||
|
|
29
|
+
typeof value.tokenId !== 'string' ||
|
|
30
|
+
!value.tokenId ||
|
|
31
|
+
typeof value.expiresAt !== 'string' ||
|
|
32
|
+
!Number.isFinite(Date.parse(value.expiresAt)))
|
|
33
|
+
throw new AuthError('Invalid credential profile; run zl --environment <name> auth login');
|
|
34
|
+
for (const key of ['user', 'team', 'organization']) {
|
|
35
|
+
const metadata = value[key];
|
|
36
|
+
if (metadata !== undefined &&
|
|
37
|
+
(!isObject(metadata) ||
|
|
38
|
+
typeof metadata.id !== 'string' ||
|
|
39
|
+
(key === 'team' &&
|
|
40
|
+
metadata.publicId !== undefined &&
|
|
41
|
+
typeof metadata.publicId !== 'string') ||
|
|
42
|
+
(typeof metadata.name !== 'string' && !(key === 'user' && metadata.name === null)) ||
|
|
43
|
+
(key === 'user' && typeof metadata.email !== 'string')))
|
|
44
|
+
throw new AuthError('Invalid credential metadata');
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function readFile() {
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
|
|
51
|
+
if (!isObject(data) || !isObject(data.profiles))
|
|
52
|
+
throw new AuthError('Invalid credentials file');
|
|
53
|
+
const profiles = Object.create(null);
|
|
54
|
+
for (const [name, value] of Object.entries(data.profiles))
|
|
55
|
+
profiles[name] = value;
|
|
56
|
+
return { profiles };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === 'ENOENT')
|
|
60
|
+
return { profiles: Object.create(null) };
|
|
61
|
+
if (error instanceof SyntaxError)
|
|
62
|
+
throw new AuthError('Invalid credentials file');
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function writeFile(data) {
|
|
67
|
+
const path = credentialsPath();
|
|
68
|
+
const dir = join(path, '..');
|
|
69
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
70
|
+
chmodSync(dir, 0o700);
|
|
71
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
72
|
+
try {
|
|
73
|
+
fchmodSync(fd, 0o600);
|
|
74
|
+
writeFileSync(fd, JSON.stringify(data, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function readProfile(name) {
|
|
81
|
+
const { profiles } = readFile();
|
|
82
|
+
return Object.hasOwn(profiles, name) ? validateProfile(profiles[name]) : null;
|
|
83
|
+
}
|
|
84
|
+
export function writeProfile(name, entry) {
|
|
85
|
+
const file = readFile();
|
|
86
|
+
file.profiles[name] = validateProfile(entry);
|
|
87
|
+
writeFile(file);
|
|
88
|
+
}
|
|
89
|
+
export function clearProfile(name) {
|
|
90
|
+
const file = readFile();
|
|
91
|
+
delete file.profiles[name];
|
|
92
|
+
if (Object.keys(file.profiles).length === 0) {
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(credentialsPath());
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* already gone */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
writeFile(file);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function resolveToken() {
|
|
105
|
+
const environment = currentEnvironment();
|
|
106
|
+
const envToken = process.env.ZL_API_TOKEN;
|
|
107
|
+
if (envToken)
|
|
108
|
+
return envToken;
|
|
109
|
+
const defaultProfile = readProfile(process.env.ZL_PROFILE ?? 'default');
|
|
110
|
+
if (defaultProfile?.token) {
|
|
111
|
+
currentEnvironment(defaultProfile.environment);
|
|
112
|
+
if (defaultProfile.apiUrl !== environment.apiUrl)
|
|
113
|
+
throw new AuthError('Profile API origin mismatch; log in to this origin again');
|
|
114
|
+
const remaining = Date.parse(defaultProfile.expiresAt) - Date.now();
|
|
115
|
+
if (remaining <= 0)
|
|
116
|
+
throw new AuthError(`Session expired. Run zl --environment ${environment.name} auth login`);
|
|
117
|
+
if (remaining <= 15 * 60 * 1000)
|
|
118
|
+
process.stderr.write(`Warning: session will expire soon. Run zl --environment ${environment.name} auth login\n`);
|
|
119
|
+
return defaultProfile.token;
|
|
120
|
+
}
|
|
121
|
+
throw new AuthError('Not authenticated. Run: zl auth login or set ZL_API_TOKEN');
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AuthError } from './utils/errors.js';
|
|
2
|
+
export const ENVIRONMENTS = {
|
|
3
|
+
staging: 'https://dev.app.zalos.ai',
|
|
4
|
+
'us-prod': 'https://app.zalos.ai',
|
|
5
|
+
'eu-prod': 'https://app.eu.zalos.ai',
|
|
6
|
+
};
|
|
7
|
+
let localOverride;
|
|
8
|
+
export function configureApiUrlOverride(url) {
|
|
9
|
+
localOverride = url;
|
|
10
|
+
}
|
|
11
|
+
export function resolveEnvironment(input) {
|
|
12
|
+
const name = input.flag ?? input.env;
|
|
13
|
+
if (!name)
|
|
14
|
+
throw new AuthError('Choose --environment staging|us-prod|eu-prod or set ZL_ENVIRONMENT');
|
|
15
|
+
if (!Object.hasOwn(ENVIRONMENTS, name))
|
|
16
|
+
throw new AuthError('Unknown environment');
|
|
17
|
+
if (input.profileEnvironment && input.profileEnvironment !== name)
|
|
18
|
+
throw new AuthError('Profile environment mismatch; choose a matching profile or log in again');
|
|
19
|
+
let apiUrl = ENVIRONMENTS[name];
|
|
20
|
+
if (input.apiUrlOverride) {
|
|
21
|
+
const url = new URL(input.apiUrlOverride);
|
|
22
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ||
|
|
23
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
24
|
+
url.username ||
|
|
25
|
+
url.password ||
|
|
26
|
+
url.search ||
|
|
27
|
+
url.hash ||
|
|
28
|
+
url.pathname !== '/')
|
|
29
|
+
throw new AuthError('--api-url is only allowed for a local development origin');
|
|
30
|
+
apiUrl = url.origin;
|
|
31
|
+
}
|
|
32
|
+
return { name: name, apiUrl };
|
|
33
|
+
}
|
|
34
|
+
export function currentEnvironment(profileEnvironment, flag) {
|
|
35
|
+
return resolveEnvironment({
|
|
36
|
+
flag,
|
|
37
|
+
env: process.env.ZL_ENVIRONMENT,
|
|
38
|
+
profileEnvironment,
|
|
39
|
+
apiUrlOverride: localOverride,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=environments.js.map
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { registerSetupCommand } from './commands/setup.js';
|
|
5
|
+
import { registerDoctorCommand } from './commands/doctor.js';
|
|
6
|
+
import { registerWorkflowCommand } from './commands/workflow.js';
|
|
7
|
+
import { registerExecutionCommand } from './commands/execution.js';
|
|
8
|
+
import { registerTemplateCommand } from './commands/template.js';
|
|
9
|
+
import { registerCredentialCommand } from './commands/credential.js';
|
|
10
|
+
import { registerFileCommand } from './commands/file.js';
|
|
11
|
+
import { registerAuthCommand } from './commands/auth.js';
|
|
12
|
+
import { registerApiTokenCommand } from './commands/api-token.js';
|
|
13
|
+
import { registerCycleCommand } from './commands/cycle.js';
|
|
14
|
+
import { registerLabelCommand } from './commands/label.js';
|
|
15
|
+
import { registerMemberCommand } from './commands/member.js';
|
|
16
|
+
import { registerProjectCommand } from './commands/project.js';
|
|
17
|
+
import { registerSchemaCommand } from './commands/schema.js';
|
|
18
|
+
import { registerStateCommand } from './commands/state.js';
|
|
19
|
+
import { registerTaskCommentCommand } from './commands/task-comment.js';
|
|
20
|
+
import { registerTaskCommand } from './commands/task.js';
|
|
21
|
+
import { registerTeamCommand } from './commands/team.js';
|
|
22
|
+
import { registerPageCommand } from './commands/page.js';
|
|
23
|
+
import { configureApiUrlOverride, currentEnvironment } from './environments.js';
|
|
24
|
+
import { handleError } from './utils/errors.js';
|
|
25
|
+
const program = new Command();
|
|
26
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
27
|
+
program
|
|
28
|
+
.name('zl')
|
|
29
|
+
.description('CLI for Zalos')
|
|
30
|
+
.version(packageJson.version)
|
|
31
|
+
.option('--fields <list>', 'Comma-separated field names to include in output')
|
|
32
|
+
.option('--environment <name>', 'staging, us-prod, or eu-prod (or ZL_ENVIRONMENT)')
|
|
33
|
+
.option('--api-url <url>', 'Explicit local development origin override')
|
|
34
|
+
.option('--profile <name>', 'Named human profile (or ZL_PROFILE)')
|
|
35
|
+
.hook('preAction', (_thisCommand, actionCommand) => {
|
|
36
|
+
const opts = actionCommand.optsWithGlobals();
|
|
37
|
+
if (opts.environment)
|
|
38
|
+
process.env.ZL_ENVIRONMENT = opts.environment;
|
|
39
|
+
if (opts.profile)
|
|
40
|
+
process.env.ZL_PROFILE = opts.profile;
|
|
41
|
+
configureApiUrlOverride(opts.apiUrl);
|
|
42
|
+
if (actionCommand.name() === 'zl' ||
|
|
43
|
+
actionCommand.name() === 'schema' ||
|
|
44
|
+
(actionCommand.name() === 'validate' && actionCommand.parent?.name() === 'workflow') ||
|
|
45
|
+
actionCommand.parent?.name() === 'schema')
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
currentEnvironment();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
handleError(error);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
program.action(() => {
|
|
55
|
+
program.help();
|
|
56
|
+
});
|
|
57
|
+
registerAuthCommand(program);
|
|
58
|
+
registerApiTokenCommand(program);
|
|
59
|
+
registerSetupCommand(program);
|
|
60
|
+
registerDoctorCommand(program);
|
|
61
|
+
registerWorkflowCommand(program);
|
|
62
|
+
registerExecutionCommand(program);
|
|
63
|
+
registerTemplateCommand(program);
|
|
64
|
+
registerCredentialCommand(program);
|
|
65
|
+
registerFileCommand(program);
|
|
66
|
+
registerTeamCommand(program);
|
|
67
|
+
registerPageCommand(program);
|
|
68
|
+
registerSchemaCommand(program);
|
|
69
|
+
registerStateCommand(program);
|
|
70
|
+
registerLabelCommand(program);
|
|
71
|
+
registerCycleCommand(program);
|
|
72
|
+
registerProjectCommand(program);
|
|
73
|
+
registerMemberCommand(program);
|
|
74
|
+
registerTaskCommand(program);
|
|
75
|
+
const taskCmd = program.commands.find((c) => c.name() === 'task');
|
|
76
|
+
if (!taskCmd)
|
|
77
|
+
throw new Error('task command not registered — check registration order in main.ts');
|
|
78
|
+
registerTaskCommentCommand(taskCmd, program);
|
|
79
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
80
|
+
process.stderr.write(JSON.stringify({
|
|
81
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
82
|
+
code: 'UNKNOWN_ERROR',
|
|
83
|
+
}) + '\n');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { validateProfile } from '../credentials.js';
|
|
5
|
+
import { AuthError } from './errors.js';
|
|
6
|
+
const { subtle } = globalThis.crypto;
|
|
7
|
+
export async function generateSession() {
|
|
8
|
+
const keyPair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
|
|
9
|
+
'deriveKey',
|
|
10
|
+
'deriveBits',
|
|
11
|
+
]);
|
|
12
|
+
const rawPub = await subtle.exportKey('raw', keyPair.publicKey);
|
|
13
|
+
return {
|
|
14
|
+
sessionId: randomUUID(),
|
|
15
|
+
publicKey: Buffer.from(rawPub).toString('hex'),
|
|
16
|
+
privateKey: keyPair.privateKey,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function closeServer(server) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
server.close((error) => {
|
|
22
|
+
if (error) {
|
|
23
|
+
reject(error);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function createBrowserProofServer(sessionId, uiOrigin) {
|
|
31
|
+
const browserProof = randomBytes(32).toString('base64url');
|
|
32
|
+
const browserProofHash = createHash('sha256').update(browserProof).digest('hex');
|
|
33
|
+
const server = createServer((request, response) => {
|
|
34
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
35
|
+
const matchesProofRequest = url.pathname === '/cli-proof' && url.searchParams.get('session_id') === sessionId;
|
|
36
|
+
if (request.method === 'OPTIONS' &&
|
|
37
|
+
matchesProofRequest &&
|
|
38
|
+
request.headers.origin === uiOrigin &&
|
|
39
|
+
request.headers['access-control-request-method'] === 'GET') {
|
|
40
|
+
response.writeHead(204, {
|
|
41
|
+
'Access-Control-Allow-Methods': 'GET',
|
|
42
|
+
'Access-Control-Allow-Origin': uiOrigin,
|
|
43
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
44
|
+
'Access-Control-Max-Age': '600',
|
|
45
|
+
Vary: 'Origin, Access-Control-Request-Method, Access-Control-Request-Private-Network',
|
|
46
|
+
});
|
|
47
|
+
response.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (request.method !== 'GET' || !matchesProofRequest || request.headers.origin !== uiOrigin) {
|
|
51
|
+
response.writeHead(403).end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
response.writeHead(200, {
|
|
55
|
+
'Access-Control-Allow-Origin': uiOrigin,
|
|
56
|
+
'Cache-Control': 'no-store',
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
Vary: 'Origin',
|
|
59
|
+
});
|
|
60
|
+
response.end(JSON.stringify({ proof: browserProof }));
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
const onError = (error) => reject(error);
|
|
65
|
+
server.once('error', onError);
|
|
66
|
+
server.listen(0, '127.0.0.1', () => {
|
|
67
|
+
server.off('error', onError);
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new AuthError('Could not start the local browser authorization service');
|
|
74
|
+
}
|
|
75
|
+
const address = server.address();
|
|
76
|
+
if (!address || typeof address === 'string') {
|
|
77
|
+
await closeServer(server);
|
|
78
|
+
throw new AuthError('Could not determine the local browser authorization port');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
browserProofHash,
|
|
82
|
+
callbackPort: address.port,
|
|
83
|
+
close: () => closeServer(server),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function fetchCliAuthConfig(apiUrl, environment) {
|
|
87
|
+
const url = new URL('/api/auth/cli-auth-config', apiUrl);
|
|
88
|
+
url.searchParams.set('environment', environment);
|
|
89
|
+
let response;
|
|
90
|
+
try {
|
|
91
|
+
response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new AuthError('Could not determine the browser authorization origin');
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
throw new AuthError('Could not determine the browser authorization origin');
|
|
98
|
+
const body = await response.json();
|
|
99
|
+
if (typeof body !== 'object' || body === null || !('uiOrigin' in body)) {
|
|
100
|
+
throw new AuthError('Invalid browser authorization origin');
|
|
101
|
+
}
|
|
102
|
+
if (typeof body.uiOrigin !== 'string')
|
|
103
|
+
throw new AuthError('Invalid browser authorization origin');
|
|
104
|
+
const uiOrigin = new URL(body.uiOrigin).origin;
|
|
105
|
+
if (uiOrigin !== body.uiOrigin)
|
|
106
|
+
throw new AuthError('Invalid browser authorization origin');
|
|
107
|
+
const capabilities = 'capabilities' in body && Array.isArray(body.capabilities)
|
|
108
|
+
? body.capabilities.filter((capability) => typeof capability === 'string')
|
|
109
|
+
: [];
|
|
110
|
+
const apiTokenScopes = 'apiTokenScopes' in body && Array.isArray(body.apiTokenScopes)
|
|
111
|
+
? body.apiTokenScopes.filter((scope) => typeof scope === 'string')
|
|
112
|
+
: [];
|
|
113
|
+
return { uiOrigin, capabilities, apiTokenScopes };
|
|
114
|
+
}
|
|
115
|
+
export async function fetchCliBrowserAuthorizationConfig(apiUrl, environment) {
|
|
116
|
+
return fetchCliAuthConfig(apiUrl, environment);
|
|
117
|
+
}
|
|
118
|
+
export async function revokeBrowserAuthorizedToken(apiUrl, token) {
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(`${apiUrl}/api/auth/cli-token/current`, {
|
|
122
|
+
method: 'DELETE',
|
|
123
|
+
headers: { 'X-Mesh-API-Key': token },
|
|
124
|
+
redirect: 'error',
|
|
125
|
+
signal: AbortSignal.timeout(10_000),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new AuthError('Could not revoke the unconfirmed browser-authorized token');
|
|
130
|
+
}
|
|
131
|
+
if (response.status !== 204)
|
|
132
|
+
throw new AuthError('Could not revoke the unconfirmed browser-authorized token');
|
|
133
|
+
}
|
|
134
|
+
export async function decryptToken(encryptedToken, nonce, senderPublicKey, myPrivateKey) {
|
|
135
|
+
const senderPubKeyBytes = Buffer.from(senderPublicKey, 'hex');
|
|
136
|
+
const senderPubKey = await subtle.importKey('raw', senderPubKeyBytes, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
137
|
+
/* oxlint-disable @typescript-eslint/no-unsafe-argument */
|
|
138
|
+
const sharedBits = await subtle.deriveBits({ name: 'ECDH', public: senderPubKey }, myPrivateKey, 256);
|
|
139
|
+
/* oxlint-enable @typescript-eslint/no-unsafe-argument */
|
|
140
|
+
const aesKey = await subtle.importKey('raw', sharedBits, { name: 'AES-GCM' }, false, ['decrypt']);
|
|
141
|
+
const decrypted = await subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(nonce, 'hex') }, aesKey, Buffer.from(encryptedToken, 'hex'));
|
|
142
|
+
return new TextDecoder().decode(decrypted);
|
|
143
|
+
}
|
|
144
|
+
export function openBrowser(url) {
|
|
145
|
+
const [cmd, args] = process.platform === 'darwin'
|
|
146
|
+
? ['open', [url]]
|
|
147
|
+
: process.platform === 'win32'
|
|
148
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
149
|
+
: ['xdg-open', [url]];
|
|
150
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
151
|
+
child.on('error', (err) => {
|
|
152
|
+
process.stderr.write(`Warning: could not open browser: ${err.message}\n`);
|
|
153
|
+
});
|
|
154
|
+
child.unref();
|
|
155
|
+
}
|
|
156
|
+
export async function pollForToken(apiUrl, sessionId, privateKey, timeoutMs, pollIntervalMs = 2000, browserGraceMs = timeoutMs) {
|
|
157
|
+
const deadline = Date.now() + timeoutMs;
|
|
158
|
+
const graceDeadline = Date.now() + browserGraceMs;
|
|
159
|
+
const pollUrl = `${apiUrl}/api/auth/cli-sessions/${sessionId}`;
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
const response = await fetch(pollUrl, {
|
|
162
|
+
redirect: 'error',
|
|
163
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(30_000, deadline - Date.now()))),
|
|
164
|
+
});
|
|
165
|
+
if (response.status === 200) {
|
|
166
|
+
const body = (await response.json());
|
|
167
|
+
const plaintext = await decryptToken(body.encryptedToken, body.nonce, body.senderPublicKey, privateKey);
|
|
168
|
+
let result;
|
|
169
|
+
try {
|
|
170
|
+
result = JSON.parse(plaintext);
|
|
171
|
+
validateProfile({ ...result.profile, ...result, apiUrl });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new AuthError('Invalid login session payload; sign in again');
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
if (response.status === 404 && Date.now() > graceDeadline) {
|
|
179
|
+
throw new AuthError('Session expired — please run zl auth login again');
|
|
180
|
+
}
|
|
181
|
+
// 202 pending, or 404 within grace window — wait then retry
|
|
182
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
183
|
+
}
|
|
184
|
+
throw new AuthError('Login timed out — browser window closed?');
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=cli-auth.js.map
|