@tutti-os/app-release-tools 0.0.150 → 0.0.152
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/README.md +5 -0
- package/bin/build-tutti-app-release.mjs +3 -213
- package/bin/tutti-app-cli-manifest.mjs +247 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,11 @@ safely verify and reuse its immutable upload. When the manifest declares
|
|
|
22
22
|
locale files so App Center can localize uninstalled remote apps without
|
|
23
23
|
downloading the package.
|
|
24
24
|
|
|
25
|
+
App CLI manifests may declare `execution.mode: "wait"` for commands that block
|
|
26
|
+
until a run or session reaches a stop point. Wait commands use JSON output and
|
|
27
|
+
must leave `--timeout-ms` to the Tutti CLI as the optional total wait deadline;
|
|
28
|
+
App input schemas must not redefine it.
|
|
29
|
+
|
|
25
30
|
The versions command maintains one mutable `tutti.app.versions.v1` index per
|
|
26
31
|
app. Every catalog-eligible release has an explicit minimum Tutti version and
|
|
27
32
|
an `active` or `withdrawn` status. Reusing an immutable app version with changed
|
|
@@ -17,9 +17,11 @@ import { tmpdir } from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
|
|
19
19
|
import { requireSemver } from "./tutti-app-versioning.mjs";
|
|
20
|
+
import { validateCLIManifest } from "./tutti-app-cli-manifest.mjs";
|
|
21
|
+
|
|
22
|
+
export { validateCLIManifest } from "./tutti-app-cli-manifest.mjs";
|
|
20
23
|
|
|
21
24
|
const manifestSchemaVersion = "tutti.app.manifest.v1";
|
|
22
|
-
const cliManifestSchemaVersion = "tutti.app.cli.v1";
|
|
23
25
|
const releaseSchemaVersion = "tutti.app.release.v1";
|
|
24
26
|
|
|
25
27
|
export async function buildTuttiAppRelease(options) {
|
|
@@ -283,218 +285,6 @@ function validateManifestReferences(references, sourceLabel) {
|
|
|
283
285
|
}
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
export function validateCLIManifest(manifest, sourceLabel = "cli manifest") {
|
|
287
|
-
if (!manifest || typeof manifest !== "object") {
|
|
288
|
-
throw new Error(`${sourceLabel} must be an object`);
|
|
289
|
-
}
|
|
290
|
-
if (manifest.schemaVersion !== cliManifestSchemaVersion) {
|
|
291
|
-
throw new Error(
|
|
292
|
-
`${sourceLabel} schemaVersion must be ${cliManifestSchemaVersion}`
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
requireCLISegment(manifest.scope, `${sourceLabel}.scope`);
|
|
296
|
-
if (!Array.isArray(manifest.commands) || manifest.commands.length === 0) {
|
|
297
|
-
throw new Error(`${sourceLabel}.commands must be a non-empty array`);
|
|
298
|
-
}
|
|
299
|
-
const seenPaths = new Set();
|
|
300
|
-
for (const [index, command] of manifest.commands.entries()) {
|
|
301
|
-
const label = `${sourceLabel}.commands[${index}]`;
|
|
302
|
-
validateCLICommand(command, label, manifest.scope, seenPaths);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function validateCLICommand(command, label, scope, seenPaths) {
|
|
307
|
-
if (!command || typeof command !== "object") {
|
|
308
|
-
throw new Error(`${label} must be an object`);
|
|
309
|
-
}
|
|
310
|
-
if (!Array.isArray(command.path) || command.path.length === 0) {
|
|
311
|
-
throw new Error(`${label}.path must be a non-empty array`);
|
|
312
|
-
}
|
|
313
|
-
if (command.path[0] === scope) {
|
|
314
|
-
throw new Error(`${label}.path must not repeat scope`);
|
|
315
|
-
}
|
|
316
|
-
for (const [index, segment] of command.path.entries()) {
|
|
317
|
-
requireCLISegment(segment, `${label}.path[${index}]`);
|
|
318
|
-
}
|
|
319
|
-
const pathKey = command.path.join(".");
|
|
320
|
-
if (seenPaths.has(pathKey)) {
|
|
321
|
-
throw new Error(`${label}.path must be unique`);
|
|
322
|
-
}
|
|
323
|
-
seenPaths.add(pathKey);
|
|
324
|
-
requireNonEmpty(command.summary, `${label}.summary`);
|
|
325
|
-
validateCLIVisibility(command.visibility, `${label}.visibility`);
|
|
326
|
-
validateCLIInputSchema(command.inputSchema, `${label}.inputSchema`);
|
|
327
|
-
validateCLIOutput(command.output, `${label}.output`);
|
|
328
|
-
validateCLIHandler(command.handler, `${label}.handler`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function validateCLIVisibility(visibility, label) {
|
|
332
|
-
if (visibility === undefined) {
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
if (!["public", "integration"].includes(visibility)) {
|
|
336
|
-
throw new Error(`${label} must be public or integration`);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function validateCLIInputSchema(schema, label) {
|
|
341
|
-
if (schema === undefined) {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
345
|
-
throw new Error(`${label} must be an object`);
|
|
346
|
-
}
|
|
347
|
-
if (schema.type !== "object") {
|
|
348
|
-
throw new Error(`${label}.type must be object`);
|
|
349
|
-
}
|
|
350
|
-
if (
|
|
351
|
-
!schema.properties ||
|
|
352
|
-
typeof schema.properties !== "object" ||
|
|
353
|
-
Array.isArray(schema.properties)
|
|
354
|
-
) {
|
|
355
|
-
throw new Error(`${label}.properties must be an object`);
|
|
356
|
-
}
|
|
357
|
-
for (const [name, property] of Object.entries(schema.properties)) {
|
|
358
|
-
requireCLISegment(name, `${label}.properties key`);
|
|
359
|
-
if (!property || typeof property !== "object" || Array.isArray(property)) {
|
|
360
|
-
throw new Error(`${label}.properties.${name} must be an object`);
|
|
361
|
-
}
|
|
362
|
-
if (!["string", "boolean", "integer"].includes(property.type)) {
|
|
363
|
-
throw new Error(
|
|
364
|
-
`${label}.properties.${name}.type must be string, boolean, or integer`
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
validateCLIInputEnum(
|
|
368
|
-
property.enum,
|
|
369
|
-
property.type,
|
|
370
|
-
`${label}.properties.${name}.enum`
|
|
371
|
-
);
|
|
372
|
-
validateCLIInputDefault(
|
|
373
|
-
property.default,
|
|
374
|
-
property.type,
|
|
375
|
-
property.enum,
|
|
376
|
-
`${label}.properties.${name}.default`
|
|
377
|
-
);
|
|
378
|
-
for (const key of Object.keys(property)) {
|
|
379
|
-
if (!["type", "description", "enum", "default"].includes(key)) {
|
|
380
|
-
throw new Error(`${label}.properties.${name}.${key} is not supported`);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
const required = schema.required ?? [];
|
|
385
|
-
if (!Array.isArray(required)) {
|
|
386
|
-
throw new Error(`${label}.required must be an array`);
|
|
387
|
-
}
|
|
388
|
-
for (const name of required) {
|
|
389
|
-
if (typeof name !== "string" || !Object.hasOwn(schema.properties, name)) {
|
|
390
|
-
throw new Error(
|
|
391
|
-
`${label}.required contains unknown property ${String(name)}`
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
for (const key of Object.keys(schema)) {
|
|
396
|
-
if (!["type", "properties", "required"].includes(key)) {
|
|
397
|
-
throw new Error(`${label}.${key} is not supported`);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function validateCLIInputDefault(value, type, enumValues, label) {
|
|
403
|
-
if (value === undefined) {
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
validateCLIInputValue(value, type, label);
|
|
407
|
-
if (enumValues !== undefined && !enumValues.includes(value)) {
|
|
408
|
-
throw new Error(`${label} must be one of the declared enum values`);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function validateCLIInputEnum(values, type, label) {
|
|
413
|
-
if (values === undefined) {
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (!Array.isArray(values) || values.length === 0) {
|
|
417
|
-
throw new Error(`${label} must be a non-empty array`);
|
|
418
|
-
}
|
|
419
|
-
for (const [index, value] of values.entries()) {
|
|
420
|
-
validateCLIInputValue(value, type, `${label}[${index}]`);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function validateCLIInputValue(value, type, label) {
|
|
425
|
-
if (type === "integer") {
|
|
426
|
-
if (!Number.isInteger(value)) {
|
|
427
|
-
throw new Error(`${label} must be an integer`);
|
|
428
|
-
}
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (typeof value !== type) {
|
|
432
|
-
throw new Error(`${label} must be ${type}`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function validateCLIOutput(output, label) {
|
|
437
|
-
if (!output || typeof output !== "object" || Array.isArray(output)) {
|
|
438
|
-
throw new Error(`${label} must be an object`);
|
|
439
|
-
}
|
|
440
|
-
if (!["json", "table"].includes(output.defaultMode)) {
|
|
441
|
-
throw new Error(`${label}.defaultMode must be json or table`);
|
|
442
|
-
}
|
|
443
|
-
if (output.defaultMode === "json" && output.json !== true) {
|
|
444
|
-
throw new Error(`${label}.json must be true when defaultMode is json`);
|
|
445
|
-
}
|
|
446
|
-
if (output.defaultMode === "table") {
|
|
447
|
-
if (
|
|
448
|
-
!output.table ||
|
|
449
|
-
!Array.isArray(output.table.columns) ||
|
|
450
|
-
output.table.columns.length === 0
|
|
451
|
-
) {
|
|
452
|
-
throw new Error(
|
|
453
|
-
`${label}.table.columns must be a non-empty array when defaultMode is table`
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
for (const [index, column] of output.table.columns.entries()) {
|
|
457
|
-
requireCLISegment(column?.key, `${label}.table.columns[${index}].key`);
|
|
458
|
-
requireNonEmpty(column?.label, `${label}.table.columns[${index}].label`);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function validateCLIHandler(handler, label) {
|
|
464
|
-
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
|
465
|
-
throw new Error(`${label} must be an object`);
|
|
466
|
-
}
|
|
467
|
-
if (handler.kind !== "http") {
|
|
468
|
-
throw new Error(`${label}.kind must be http`);
|
|
469
|
-
}
|
|
470
|
-
if (handler.method !== "POST") {
|
|
471
|
-
throw new Error(`${label}.method must be POST`);
|
|
472
|
-
}
|
|
473
|
-
if (
|
|
474
|
-
typeof handler.path !== "string" ||
|
|
475
|
-
!handler.path.startsWith("/tutti/cli/")
|
|
476
|
-
) {
|
|
477
|
-
throw new Error(`${label}.path must start with /tutti/cli/`);
|
|
478
|
-
}
|
|
479
|
-
const timeoutMs = handler.timeoutMs ?? 30000;
|
|
480
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
|
|
481
|
-
throw new Error(`${label}.timeoutMs must be between 1000 and 600000`);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function requireCLISegment(value, label) {
|
|
486
|
-
const text = requireNonEmpty(value, label);
|
|
487
|
-
if (
|
|
488
|
-
!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(text) ||
|
|
489
|
-
text.startsWith("--")
|
|
490
|
-
) {
|
|
491
|
-
throw new Error(
|
|
492
|
-
`${label} must contain lowercase letters, numbers, and hyphen only`
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
return text;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
288
|
function validateLocalizationInfo(localizationInfo, sourceLabel) {
|
|
499
289
|
if (localizationInfo === undefined) {
|
|
500
290
|
return;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const cliManifestSchemaVersion = "tutti.app.cli.v1";
|
|
2
|
+
|
|
3
|
+
export function validateCLIManifest(manifest, sourceLabel = "cli manifest") {
|
|
4
|
+
if (!manifest || typeof manifest !== "object") {
|
|
5
|
+
throw new Error(`${sourceLabel} must be an object`);
|
|
6
|
+
}
|
|
7
|
+
if (manifest.schemaVersion !== cliManifestSchemaVersion) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`${sourceLabel} schemaVersion must be ${cliManifestSchemaVersion}`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
requireCLISegment(manifest.scope, `${sourceLabel}.scope`);
|
|
13
|
+
if (!Array.isArray(manifest.commands) || manifest.commands.length === 0) {
|
|
14
|
+
throw new Error(`${sourceLabel}.commands must be a non-empty array`);
|
|
15
|
+
}
|
|
16
|
+
const seenPaths = new Set();
|
|
17
|
+
for (const [index, command] of manifest.commands.entries()) {
|
|
18
|
+
const label = `${sourceLabel}.commands[${index}]`;
|
|
19
|
+
validateCLICommand(command, label, manifest.scope, seenPaths);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateCLICommand(command, label, scope, seenPaths) {
|
|
24
|
+
if (!command || typeof command !== "object") {
|
|
25
|
+
throw new Error(`${label} must be an object`);
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(command.path) || command.path.length === 0) {
|
|
28
|
+
throw new Error(`${label}.path must be a non-empty array`);
|
|
29
|
+
}
|
|
30
|
+
if (command.path[0] === scope) {
|
|
31
|
+
throw new Error(`${label}.path must not repeat scope`);
|
|
32
|
+
}
|
|
33
|
+
for (const [index, segment] of command.path.entries()) {
|
|
34
|
+
requireCLISegment(segment, `${label}.path[${index}]`);
|
|
35
|
+
}
|
|
36
|
+
const pathKey = command.path.join(".");
|
|
37
|
+
if (seenPaths.has(pathKey)) {
|
|
38
|
+
throw new Error(`${label}.path must be unique`);
|
|
39
|
+
}
|
|
40
|
+
seenPaths.add(pathKey);
|
|
41
|
+
requireNonEmpty(command.summary, `${label}.summary`);
|
|
42
|
+
validateCLIVisibility(command.visibility, `${label}.visibility`);
|
|
43
|
+
validateCLIInputSchema(command.inputSchema, `${label}.inputSchema`);
|
|
44
|
+
validateCLIOutput(command.output, `${label}.output`);
|
|
45
|
+
validateCLIExecution(
|
|
46
|
+
command.execution,
|
|
47
|
+
command.inputSchema,
|
|
48
|
+
command.output,
|
|
49
|
+
`${label}.execution`
|
|
50
|
+
);
|
|
51
|
+
validateCLIHandler(command.handler, `${label}.handler`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateCLIVisibility(visibility, label) {
|
|
55
|
+
if (visibility === undefined) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!["public", "integration"].includes(visibility)) {
|
|
59
|
+
throw new Error(`${label} must be public or integration`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateCLIExecution(execution, inputSchema, output, label) {
|
|
64
|
+
if (execution === undefined) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!execution || typeof execution !== "object" || Array.isArray(execution)) {
|
|
68
|
+
throw new Error(`${label} must be an object`);
|
|
69
|
+
}
|
|
70
|
+
if (execution.mode !== "wait") {
|
|
71
|
+
throw new Error(`${label}.mode must be wait`);
|
|
72
|
+
}
|
|
73
|
+
if (output?.defaultMode !== "json" || output?.json !== true) {
|
|
74
|
+
throw new Error(`${label} mode wait requires json default output`);
|
|
75
|
+
}
|
|
76
|
+
if (inputSchema?.properties?.["timeout-ms"] !== undefined) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${label} mode wait reserves --timeout-ms for the total CLI wait timeout`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateCLIInputSchema(schema, label) {
|
|
84
|
+
if (schema === undefined) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
88
|
+
throw new Error(`${label} must be an object`);
|
|
89
|
+
}
|
|
90
|
+
if (schema.type !== "object") {
|
|
91
|
+
throw new Error(`${label}.type must be object`);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
!schema.properties ||
|
|
95
|
+
typeof schema.properties !== "object" ||
|
|
96
|
+
Array.isArray(schema.properties)
|
|
97
|
+
) {
|
|
98
|
+
throw new Error(`${label}.properties must be an object`);
|
|
99
|
+
}
|
|
100
|
+
for (const [name, property] of Object.entries(schema.properties)) {
|
|
101
|
+
requireCLISegment(name, `${label}.properties key`);
|
|
102
|
+
if (!property || typeof property !== "object" || Array.isArray(property)) {
|
|
103
|
+
throw new Error(`${label}.properties.${name} must be an object`);
|
|
104
|
+
}
|
|
105
|
+
if (!["string", "boolean", "integer"].includes(property.type)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${label}.properties.${name}.type must be string, boolean, or integer`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
validateCLIInputEnum(
|
|
111
|
+
property.enum,
|
|
112
|
+
property.type,
|
|
113
|
+
`${label}.properties.${name}.enum`
|
|
114
|
+
);
|
|
115
|
+
validateCLIInputDefault(
|
|
116
|
+
property.default,
|
|
117
|
+
property.type,
|
|
118
|
+
property.enum,
|
|
119
|
+
`${label}.properties.${name}.default`
|
|
120
|
+
);
|
|
121
|
+
for (const key of Object.keys(property)) {
|
|
122
|
+
if (!["type", "description", "enum", "default"].includes(key)) {
|
|
123
|
+
throw new Error(`${label}.properties.${name}.${key} is not supported`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const required = schema.required ?? [];
|
|
128
|
+
if (!Array.isArray(required)) {
|
|
129
|
+
throw new Error(`${label}.required must be an array`);
|
|
130
|
+
}
|
|
131
|
+
for (const name of required) {
|
|
132
|
+
if (typeof name !== "string" || !Object.hasOwn(schema.properties, name)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${label}.required contains unknown property ${String(name)}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const key of Object.keys(schema)) {
|
|
139
|
+
if (!["type", "properties", "required"].includes(key)) {
|
|
140
|
+
throw new Error(`${label}.${key} is not supported`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateCLIInputDefault(value, type, enumValues, label) {
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
validateCLIInputValue(value, type, label);
|
|
150
|
+
if (enumValues !== undefined && !enumValues.includes(value)) {
|
|
151
|
+
throw new Error(`${label} must be one of the declared enum values`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateCLIInputEnum(values, type, label) {
|
|
156
|
+
if (values === undefined) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
160
|
+
throw new Error(`${label} must be a non-empty array`);
|
|
161
|
+
}
|
|
162
|
+
for (const [index, value] of values.entries()) {
|
|
163
|
+
validateCLIInputValue(value, type, `${label}[${index}]`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateCLIInputValue(value, type, label) {
|
|
168
|
+
if (type === "integer") {
|
|
169
|
+
if (!Number.isInteger(value)) {
|
|
170
|
+
throw new Error(`${label} must be an integer`);
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (typeof value !== type) {
|
|
175
|
+
throw new Error(`${label} must be ${type}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateCLIOutput(output, label) {
|
|
180
|
+
if (!output || typeof output !== "object" || Array.isArray(output)) {
|
|
181
|
+
throw new Error(`${label} must be an object`);
|
|
182
|
+
}
|
|
183
|
+
if (!["json", "table"].includes(output.defaultMode)) {
|
|
184
|
+
throw new Error(`${label}.defaultMode must be json or table`);
|
|
185
|
+
}
|
|
186
|
+
if (output.defaultMode === "json" && output.json !== true) {
|
|
187
|
+
throw new Error(`${label}.json must be true when defaultMode is json`);
|
|
188
|
+
}
|
|
189
|
+
if (output.defaultMode === "table") {
|
|
190
|
+
if (
|
|
191
|
+
!output.table ||
|
|
192
|
+
!Array.isArray(output.table.columns) ||
|
|
193
|
+
output.table.columns.length === 0
|
|
194
|
+
) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${label}.table.columns must be a non-empty array when defaultMode is table`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
for (const [index, column] of output.table.columns.entries()) {
|
|
200
|
+
requireCLISegment(column?.key, `${label}.table.columns[${index}].key`);
|
|
201
|
+
requireNonEmpty(column?.label, `${label}.table.columns[${index}].label`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateCLIHandler(handler, label) {
|
|
207
|
+
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
|
208
|
+
throw new Error(`${label} must be an object`);
|
|
209
|
+
}
|
|
210
|
+
if (handler.kind !== "http") {
|
|
211
|
+
throw new Error(`${label}.kind must be http`);
|
|
212
|
+
}
|
|
213
|
+
if (handler.method !== "POST") {
|
|
214
|
+
throw new Error(`${label}.method must be POST`);
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
typeof handler.path !== "string" ||
|
|
218
|
+
!handler.path.startsWith("/tutti/cli/")
|
|
219
|
+
) {
|
|
220
|
+
throw new Error(`${label}.path must start with /tutti/cli/`);
|
|
221
|
+
}
|
|
222
|
+
const timeoutMs = handler.timeoutMs ?? 30000;
|
|
223
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
|
|
224
|
+
throw new Error(`${label}.timeoutMs must be between 1000 and 600000`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function requireCLISegment(value, label) {
|
|
229
|
+
const text = requireNonEmpty(value, label);
|
|
230
|
+
if (
|
|
231
|
+
!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(text) ||
|
|
232
|
+
text.startsWith("--")
|
|
233
|
+
) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`${label} must contain lowercase letters, numbers, and hyphen only`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function requireNonEmpty(value, label) {
|
|
242
|
+
const text = String(value ?? "").trim();
|
|
243
|
+
if (!text) {
|
|
244
|
+
throw new Error(`${label} is required`);
|
|
245
|
+
}
|
|
246
|
+
return text;
|
|
247
|
+
}
|