@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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tutti-os/app-release-tools",
3
- "version": "0.0.150",
3
+ "version": "0.0.152",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {