@fluid-app/fluid-cli-droplets 0.1.198 → 0.1.200
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/dist/index.mjs +603 -1
- package/jest.mocks/fluid-cli.ts +5 -0
- package/package.json +2 -1
- package/src/__tests__/manifest.test.ts +383 -0
- package/src/commands/manifest.ts +153 -0
- package/src/commands/rollback.ts +67 -0
- package/src/index.ts +4 -4
- package/src/manifest/canonical.ts +34 -0
- package/src/manifest/publish.ts +116 -0
- package/src/manifest/validate.ts +163 -0
- package/tsconfig.json +7 -3
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,499 @@
|
|
|
1
|
-
import { createDomainCommand } from "@fluid-app/fluid-cli";
|
|
1
|
+
import { createDomainCommand, getFluidApiBase } from "@fluid-app/fluid-cli";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
2
5
|
import { createInterface } from "node:readline/promises";
|
|
3
6
|
import "commander";
|
|
7
|
+
//#region ../../../apps/rails/config/droplet_manifest/v1.schema.json
|
|
8
|
+
var v1_schema_default = {
|
|
9
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
10
|
+
$id: "https://schemas.fluid.app/droplet/manifest/v1.json",
|
|
11
|
+
title: "Fluid droplet manifest v1",
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
required: [
|
|
15
|
+
"manifest_version",
|
|
16
|
+
"droplet",
|
|
17
|
+
"version",
|
|
18
|
+
"scopes",
|
|
19
|
+
"origin"
|
|
20
|
+
],
|
|
21
|
+
$defs: {
|
|
22
|
+
"path": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"maxLength": 2048,
|
|
25
|
+
"pattern": "^/(?!/)[^@\\\\?#\\s]*$"
|
|
26
|
+
},
|
|
27
|
+
"semver": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"maxLength": 128,
|
|
30
|
+
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(\\.(0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
|
|
31
|
+
},
|
|
32
|
+
"namespace": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"maxLength": 64,
|
|
35
|
+
"pattern": "^[a-z][a-z0-9_]{0,63}$"
|
|
36
|
+
},
|
|
37
|
+
"name": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"maxLength": 64,
|
|
40
|
+
"pattern": "^[a-z][a-z0-9_.]{0,63}$"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
properties: {
|
|
44
|
+
"manifest_version": { "const": 1 },
|
|
45
|
+
"droplet": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"minLength": 1,
|
|
48
|
+
"maxLength": 64
|
|
49
|
+
},
|
|
50
|
+
"version": { "$ref": "#/$defs/semver" },
|
|
51
|
+
"min_supported_version": { "$ref": "#/$defs/semver" },
|
|
52
|
+
"origin": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"maxLength": 253,
|
|
55
|
+
"pattern": "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?$"
|
|
56
|
+
},
|
|
57
|
+
"scopes": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"maxItems": 64,
|
|
60
|
+
"uniqueItems": true,
|
|
61
|
+
"items": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1,
|
|
64
|
+
"maxLength": 64
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"webhooks": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"maxItems": 256,
|
|
70
|
+
"items": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"required": ["topic", "url_path"],
|
|
74
|
+
"properties": {
|
|
75
|
+
"topic": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"minLength": 1,
|
|
78
|
+
"maxLength": 128,
|
|
79
|
+
"pattern": "^[a-z][a-z0-9_]*$"
|
|
80
|
+
},
|
|
81
|
+
"url_path": { "$ref": "#/$defs/path" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"callbacks": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"maxItems": 64,
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"required": ["definition", "url_path"],
|
|
92
|
+
"properties": {
|
|
93
|
+
"definition": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"minLength": 1,
|
|
96
|
+
"maxLength": 128
|
|
97
|
+
},
|
|
98
|
+
"url_path": { "$ref": "#/$defs/path" },
|
|
99
|
+
"country_codes": {
|
|
100
|
+
"type": "array",
|
|
101
|
+
"maxItems": 300,
|
|
102
|
+
"uniqueItems": true,
|
|
103
|
+
"items": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"pattern": "^[A-Z]{2}$"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"embeds": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"additionalProperties": false,
|
|
114
|
+
"properties": {
|
|
115
|
+
"detail_page": { "$ref": "#/$defs/path" },
|
|
116
|
+
"drop_zones": {
|
|
117
|
+
"type": "array",
|
|
118
|
+
"maxItems": 64,
|
|
119
|
+
"uniqueItems": true,
|
|
120
|
+
"items": { "$ref": "#/$defs/path" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"metafields": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"additionalProperties": false,
|
|
127
|
+
"properties": {
|
|
128
|
+
"owns": {
|
|
129
|
+
"type": "array",
|
|
130
|
+
"maxItems": 64,
|
|
131
|
+
"uniqueItems": true,
|
|
132
|
+
"items": { "$ref": "#/$defs/namespace" }
|
|
133
|
+
},
|
|
134
|
+
"reads": {
|
|
135
|
+
"type": "array",
|
|
136
|
+
"maxItems": 64,
|
|
137
|
+
"items": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"additionalProperties": false,
|
|
140
|
+
"required": ["droplet", "namespace"],
|
|
141
|
+
"properties": {
|
|
142
|
+
"droplet": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"minLength": 1,
|
|
145
|
+
"maxLength": 64
|
|
146
|
+
},
|
|
147
|
+
"namespace": { "$ref": "#/$defs/namespace" }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"publishes": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"additionalProperties": false,
|
|
156
|
+
"properties": {
|
|
157
|
+
"events": {
|
|
158
|
+
"type": "array",
|
|
159
|
+
"maxItems": 256,
|
|
160
|
+
"items": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"additionalProperties": false,
|
|
163
|
+
"required": ["name", "major"],
|
|
164
|
+
"properties": {
|
|
165
|
+
"name": { "$ref": "#/$defs/name" },
|
|
166
|
+
"major": {
|
|
167
|
+
"type": "integer",
|
|
168
|
+
"minimum": 1
|
|
169
|
+
},
|
|
170
|
+
"description": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"maxLength": 1024
|
|
173
|
+
},
|
|
174
|
+
"schema": { "type": "object" }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"actions": {
|
|
179
|
+
"type": "array",
|
|
180
|
+
"maxItems": 256,
|
|
181
|
+
"items": {
|
|
182
|
+
"type": "object",
|
|
183
|
+
"additionalProperties": false,
|
|
184
|
+
"required": ["name"],
|
|
185
|
+
"properties": {
|
|
186
|
+
"name": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"maxLength": 64,
|
|
189
|
+
"pattern": "^[a-z][a-z0-9_]{0,63}$"
|
|
190
|
+
},
|
|
191
|
+
"description": {
|
|
192
|
+
"type": "string",
|
|
193
|
+
"maxLength": 1024
|
|
194
|
+
},
|
|
195
|
+
"input_schema": { "type": "object" },
|
|
196
|
+
"output_schema": { "type": "object" },
|
|
197
|
+
"effects": { "enum": ["none", "writes"] },
|
|
198
|
+
"async": { "type": "boolean" },
|
|
199
|
+
"budgets": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"additionalProperties": false,
|
|
202
|
+
"properties": {
|
|
203
|
+
"concurrency": {
|
|
204
|
+
"type": "integer",
|
|
205
|
+
"minimum": 1
|
|
206
|
+
},
|
|
207
|
+
"timeout_ms": {
|
|
208
|
+
"type": "integer",
|
|
209
|
+
"minimum": 1
|
|
210
|
+
},
|
|
211
|
+
"bytes": {
|
|
212
|
+
"type": "integer",
|
|
213
|
+
"minimum": 1
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"subscribes": {
|
|
223
|
+
"type": "object",
|
|
224
|
+
"additionalProperties": false,
|
|
225
|
+
"properties": { "events": {
|
|
226
|
+
"type": "array",
|
|
227
|
+
"maxItems": 256,
|
|
228
|
+
"items": {
|
|
229
|
+
"type": "object",
|
|
230
|
+
"additionalProperties": false,
|
|
231
|
+
"required": [
|
|
232
|
+
"publisher",
|
|
233
|
+
"event",
|
|
234
|
+
"major",
|
|
235
|
+
"required"
|
|
236
|
+
],
|
|
237
|
+
"properties": {
|
|
238
|
+
"publisher": {
|
|
239
|
+
"type": "string",
|
|
240
|
+
"minLength": 1,
|
|
241
|
+
"maxLength": 64
|
|
242
|
+
},
|
|
243
|
+
"event": { "$ref": "#/$defs/name" },
|
|
244
|
+
"major": {
|
|
245
|
+
"type": "integer",
|
|
246
|
+
"minimum": 1
|
|
247
|
+
},
|
|
248
|
+
"required": { "type": "boolean" }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} }
|
|
252
|
+
},
|
|
253
|
+
"mcp": {
|
|
254
|
+
"type": "object",
|
|
255
|
+
"additionalProperties": false,
|
|
256
|
+
"properties": { "tools": {
|
|
257
|
+
"type": "array",
|
|
258
|
+
"maxItems": 256,
|
|
259
|
+
"items": {
|
|
260
|
+
"type": "object",
|
|
261
|
+
"additionalProperties": false,
|
|
262
|
+
"required": ["action", "class"],
|
|
263
|
+
"properties": {
|
|
264
|
+
"action": {
|
|
265
|
+
"type": "string",
|
|
266
|
+
"maxLength": 64,
|
|
267
|
+
"pattern": "^[a-z][a-z0-9_]{0,63}$"
|
|
268
|
+
},
|
|
269
|
+
"description": {
|
|
270
|
+
"type": "string",
|
|
271
|
+
"maxLength": 1024
|
|
272
|
+
},
|
|
273
|
+
"class": { "enum": ["read", "write"] }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} }
|
|
277
|
+
},
|
|
278
|
+
"health": {
|
|
279
|
+
"type": "object",
|
|
280
|
+
"additionalProperties": false,
|
|
281
|
+
"properties": { "path": {
|
|
282
|
+
"$ref": "#/$defs/path",
|
|
283
|
+
"default": "/api/health"
|
|
284
|
+
} }
|
|
285
|
+
},
|
|
286
|
+
"lifecycle": {
|
|
287
|
+
"type": "object",
|
|
288
|
+
"additionalProperties": false,
|
|
289
|
+
"properties": {
|
|
290
|
+
"install_webhook_path": { "$ref": "#/$defs/path" },
|
|
291
|
+
"uninstall_webhook_path": { "$ref": "#/$defs/path" },
|
|
292
|
+
"events_webhook_path": { "$ref": "#/$defs/path" }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/manifest/validate.ts
|
|
299
|
+
const AUTHORITY = "The server is authoritative and checks additional catalogs, scope ceilings and publisher identity.";
|
|
300
|
+
const checkSchema = new Ajv2020({
|
|
301
|
+
allErrors: true,
|
|
302
|
+
strict: false
|
|
303
|
+
}).compile(v1_schema_default);
|
|
304
|
+
function compareVersion(a, b) {
|
|
305
|
+
const split = (v) => v.split("+")[0].match(/^(\d+)\.(\d+)\.(\d+)(?:-(.*))?$/).slice(1);
|
|
306
|
+
const aa = split(a), bb = split(b);
|
|
307
|
+
const compare = (x, y) => {
|
|
308
|
+
if (x === y) return 0;
|
|
309
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
310
|
+
if (xn && yn) return BigInt(x) < BigInt(y) ? -1 : 1;
|
|
311
|
+
return xn !== yn ? xn ? -1 : 1 : x < y ? -1 : 1;
|
|
312
|
+
};
|
|
313
|
+
for (let i = 0; i < 3; i++) {
|
|
314
|
+
const n = compare(aa[i], bb[i]);
|
|
315
|
+
if (n) return n;
|
|
316
|
+
}
|
|
317
|
+
const ap = aa[3], bp = bb[3];
|
|
318
|
+
if (ap === void 0 || bp === void 0) return ap === bp ? 0 : ap === void 0 ? 1 : -1;
|
|
319
|
+
const ax = ap.split("."), bx = bp.split(".");
|
|
320
|
+
for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
|
|
321
|
+
if (ax[i] === void 0 || bx[i] === void 0) return ax.length < bx.length ? -1 : 1;
|
|
322
|
+
const n = compare(ax[i], bx[i]);
|
|
323
|
+
if (n) return n;
|
|
324
|
+
}
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
/** Public: Validate locally expressible manifest rules; the server checks the rest. */
|
|
328
|
+
function validateManifest(text) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
const add = (pointer, code, detail = code) => errors.push({
|
|
331
|
+
pointer,
|
|
332
|
+
code,
|
|
333
|
+
detail
|
|
334
|
+
});
|
|
335
|
+
if (Buffer.byteLength(text) > 256 * 1024) {
|
|
336
|
+
add("/", "manifest_too_large");
|
|
337
|
+
return { errors };
|
|
338
|
+
}
|
|
339
|
+
let value;
|
|
340
|
+
try {
|
|
341
|
+
value = JSON.parse(text);
|
|
342
|
+
const stack = [];
|
|
343
|
+
for (const token of text.matchAll(/"(?:[^"\\]|\\.)*"\s*:?|[{}]/g)) {
|
|
344
|
+
const t = token[0];
|
|
345
|
+
if (t === "{") stack.push(/* @__PURE__ */ new Set());
|
|
346
|
+
else if (t === "}") stack.pop();
|
|
347
|
+
else if (t.endsWith(":")) {
|
|
348
|
+
const key = JSON.parse(t.slice(0, -1));
|
|
349
|
+
if (stack.at(-1)?.has(key)) throw new Error("duplicate key");
|
|
350
|
+
stack.at(-1)?.add(key);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
add("", "invalid_json", "Invalid JSON or duplicate object keys");
|
|
355
|
+
return { errors };
|
|
356
|
+
}
|
|
357
|
+
function walk(node, pointer) {
|
|
358
|
+
if (typeof node === "number" && (!Number.isFinite(node) || Math.abs(node) > Number.MAX_SAFE_INTEGER)) add(pointer, "invalid_number");
|
|
359
|
+
if (typeof node === "string" && !node.isWellFormed()) add(pointer, "invalid_json", "Invalid Unicode");
|
|
360
|
+
if (node && typeof node === "object") for (const [key, item] of Object.entries(node)) {
|
|
361
|
+
if (!key.isWellFormed()) add(pointer, "invalid_json", "Invalid Unicode key");
|
|
362
|
+
walk(item, `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`);
|
|
363
|
+
}
|
|
364
|
+
if (typeof node === "string" && /^\/(webhooks\/\d+\/url_path|callbacks\/\d+\/url_path|embeds\/(detail_page|drop_zones\/\d+)|health\/path|lifecycle\/(install|uninstall|events)_webhook_path)$/.test(pointer)) {
|
|
365
|
+
if (!/^\/(?!\/)[^@\\?#\s]*$/.test(node) || /[^\x21-\x7e]|%(?![\da-f]{2})|[<>"{}|^`[\]]/i.test(node) || /%(2f|5c)/i.test(node) || /(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(node)) add(pointer, "invalid_path");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
walk(value, "");
|
|
369
|
+
if (value && typeof value === "object" && "manifest_version" in value && value.manifest_version !== 1) {
|
|
370
|
+
add("/manifest_version", "unsupported_manifest_version");
|
|
371
|
+
return { errors };
|
|
372
|
+
}
|
|
373
|
+
if (!checkSchema(value)) {
|
|
374
|
+
for (const e of checkSchema.errors ?? []) add(e.instancePath, "schema_violation", e.message ?? e.keyword);
|
|
375
|
+
return { errors };
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const origin = new URL(String(value.origin));
|
|
379
|
+
if (origin.protocol !== "https:" || origin.username || origin.password || !/^https:\/\/[^/]+$/.test(String(value.origin))) throw new Error();
|
|
380
|
+
} catch {
|
|
381
|
+
add("/origin", "origin_not_https");
|
|
382
|
+
}
|
|
383
|
+
if (typeof value.min_supported_version === "string" && compareVersion(value.min_supported_version, value.version) > 0) add("/min_supported_version", "min_supported_version_exceeds_version");
|
|
384
|
+
for (const [field, key, code] of [[
|
|
385
|
+
"callbacks",
|
|
386
|
+
"definition",
|
|
387
|
+
"definition_not_unique"
|
|
388
|
+
], [
|
|
389
|
+
"webhooks",
|
|
390
|
+
"topic",
|
|
391
|
+
"webhook_not_unique"
|
|
392
|
+
]]) {
|
|
393
|
+
const seen = /* @__PURE__ */ new Set();
|
|
394
|
+
const rows = value[field];
|
|
395
|
+
if (Array.isArray(rows)) rows.forEach((row, i) => {
|
|
396
|
+
if (seen.has(row[key])) add(`/${field}/${i}/${key}`, code);
|
|
397
|
+
seen.add(row[key]);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
manifest: errors.length ? void 0 : value,
|
|
402
|
+
errors
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/manifest/canonical.ts
|
|
407
|
+
/** Public: Serialize JSON using RFC 8785 UTF-16 ordering and ECMAScript numbers. */
|
|
408
|
+
function canonicalize(value) {
|
|
409
|
+
if (typeof value === "string") {
|
|
410
|
+
if (!value.isWellFormed()) throw new Error("string is not valid Unicode");
|
|
411
|
+
return JSON.stringify(value);
|
|
412
|
+
}
|
|
413
|
+
if (typeof value === "number") {
|
|
414
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
415
|
+
return JSON.stringify(value);
|
|
416
|
+
}
|
|
417
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
418
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
419
|
+
if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) return `{${Object.keys(value).sort().map((key) => `${canonicalize(key)}:${canonicalize(Reflect.get(value, key))}`).join(",")}}`;
|
|
420
|
+
throw new Error("unsupported JSON value");
|
|
421
|
+
}
|
|
422
|
+
/** Public: Compute the manifest ledger key over its canonical UTF-8 bytes. */
|
|
423
|
+
function manifestDigest(value) {
|
|
424
|
+
return `sha256:${createHash("sha256").update(canonicalize(value)).digest("hex")}`;
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/manifest/publish.ts
|
|
428
|
+
var ManifestError = class extends Error {
|
|
429
|
+
constructor(code, exitCode = 1, details) {
|
|
430
|
+
super(code);
|
|
431
|
+
this.code = code;
|
|
432
|
+
this.exitCode = exitCode;
|
|
433
|
+
this.details = details;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
async function request(url, token, body) {
|
|
437
|
+
let response;
|
|
438
|
+
try {
|
|
439
|
+
response = await fetch(url, {
|
|
440
|
+
method: body === void 0 ? "GET" : "POST",
|
|
441
|
+
redirect: "error",
|
|
442
|
+
signal: AbortSignal.timeout(3e4),
|
|
443
|
+
headers: {
|
|
444
|
+
Authorization: `Bearer ${token}`,
|
|
445
|
+
"Content-Type": "application/json"
|
|
446
|
+
},
|
|
447
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
448
|
+
});
|
|
449
|
+
} catch {
|
|
450
|
+
throw new ManifestError("Publisher request failed");
|
|
451
|
+
}
|
|
452
|
+
let result;
|
|
453
|
+
try {
|
|
454
|
+
result = await response.json();
|
|
455
|
+
} catch {
|
|
456
|
+
throw new ManifestError("Invalid publisher response");
|
|
457
|
+
}
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
const code = result?.error?.details?.code ?? `HTTP ${response.status}`;
|
|
460
|
+
throw new ManifestError(code, code === "widening_not_allowed" ? 3 : code === "manifest_invalid" ? 2 : 1, code === "manifest_invalid" && Array.isArray(result?.error?.details?.details) ? result.error.details.details.map(({ pointer, code, detail }) => ({
|
|
461
|
+
pointer,
|
|
462
|
+
code,
|
|
463
|
+
detail
|
|
464
|
+
})) : void 0);
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
/** Public: Publish or preview using only GitHub OIDC and a single-use grant. */
|
|
469
|
+
async function publishManifest(manifest, baseUrl, dryRun, allowWidening) {
|
|
470
|
+
const base = new URL(baseUrl);
|
|
471
|
+
if (!(base.origin === "https://api.fluid.app" && !base.username && !base.password || ["http:", "https:"].includes(base.protocol) && [
|
|
472
|
+
"localhost",
|
|
473
|
+
"127.0.0.1",
|
|
474
|
+
"[::1]"
|
|
475
|
+
].includes(base.hostname))) throw new ManifestError(`Rejected publisher host ${base.hostname}; allowed: https://api.fluid.app; HTTP/HTTPS localhost, 127.0.0.1, [::1]`);
|
|
476
|
+
const missing = ["ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"].filter((key) => !process.env[key]);
|
|
477
|
+
if (process.env.GITHUB_ACTIONS !== "true") missing.unshift("GITHUB_ACTIONS=true");
|
|
478
|
+
if (missing.length) throw new ManifestError(`Actions only: missing ${missing.join(", ")}; configure permissions: id-token: write`);
|
|
479
|
+
const digest = manifestDigest(manifest);
|
|
480
|
+
const oidcUrl = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);
|
|
481
|
+
oidcUrl.searchParams.set("audience", "https://api.fluid.app/droplets/manifest");
|
|
482
|
+
const oidc = await request(oidcUrl.toString(), process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
|
|
483
|
+
if (typeof oidc.value !== "string" || !oidc.value) throw new ManifestError("Missing OIDC token");
|
|
484
|
+
const prefix = `${baseUrl.replace(/\/$/, "")}/api/droplets/v2026-10`;
|
|
485
|
+
const token = (await request(`${prefix}/publish-grants`, oidc.value, {
|
|
486
|
+
droplet_uuid: manifest.droplet,
|
|
487
|
+
digest
|
|
488
|
+
})).publish_grant?.token;
|
|
489
|
+
if (typeof token !== "string" || !token) throw new ManifestError("Missing publish grant");
|
|
490
|
+
return request(`${prefix}/manifest-publications${dryRun ? "?dry_run=true" : ""}`, token, {
|
|
491
|
+
manifest,
|
|
492
|
+
digest,
|
|
493
|
+
...dryRun || allowWidening ? { allow_widening: true } : {}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
4
497
|
//#region src/install/api.ts
|
|
5
498
|
/** A failure the CLI reports as-is, with a non-zero exit. */
|
|
6
499
|
var DropletsCliError = class extends Error {
|
|
@@ -228,6 +721,113 @@ function registerInstallCommands(parent, ctx) {
|
|
|
228
721
|
}));
|
|
229
722
|
}
|
|
230
723
|
//#endregion
|
|
724
|
+
//#region src/commands/manifest.ts
|
|
725
|
+
/** Public: Register local validation and Actions-only manifest publication commands. */
|
|
726
|
+
function registerManifestCommands(parent, ctx) {
|
|
727
|
+
const manifest = parent.command("manifest").description(`Droplet manifests. ${AUTHORITY}`);
|
|
728
|
+
for (const verb of [
|
|
729
|
+
"validate",
|
|
730
|
+
"publish",
|
|
731
|
+
"diff"
|
|
732
|
+
]) {
|
|
733
|
+
const cmd = manifest.command(`${verb} [path]`).description(`${verb === "validate" ? "Validate locally" : verb === "diff" ? "Preview in GitHub Actions; exit 3 for widening (permissions: id-token: write)" : "Publish in GitHub Actions (permissions: id-token: write)"}. ${AUTHORITY}`).option("--json", "Print one JSON document");
|
|
734
|
+
if (verb === "publish") cmd.option("--allow-widening", "Permit widening when publishing; previews always report widening (exit 3)");
|
|
735
|
+
if (verb === "publish") cmd.option("--dry-run", "Preview without publishing");
|
|
736
|
+
if (verb === "diff") cmd.option("--against <version>", "Read-only: diff the droplet's latest ledger version against this one, with your session (needs --droplet)").option("--droplet <uuid>", "Owned droplet, with --against");
|
|
737
|
+
cmd.action(async (path, opts, command) => {
|
|
738
|
+
const output = (data) => opts.json ? process.stdout.write(JSON.stringify(data) + "\n") : ctx.output(data);
|
|
739
|
+
if (verb === "diff" && (opts.against !== void 0 || opts.droplet)) {
|
|
740
|
+
try {
|
|
741
|
+
if (path !== void 0 || !opts.droplet || opts.against === void 0) throw new DropletsCliError("Use --against <version> with --droplet <uuid> and no manifest path.");
|
|
742
|
+
if (!/^[1-9]\d{0,8}$/.test(opts.against)) throw new DropletsCliError("Version must be a positive safe integer.");
|
|
743
|
+
output(await (await ctx.getClient()).get(`/api/droplets/v2026-10/droplets/${encodeURIComponent(opts.droplet)}/manifest-diff`, { against: opts.against }));
|
|
744
|
+
} catch (error) {
|
|
745
|
+
fail(error);
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
const file = path ?? "./fluid.droplet.json";
|
|
751
|
+
const limit = 256 * 1024;
|
|
752
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
753
|
+
let length = 0;
|
|
754
|
+
const handle = await open(file, "r");
|
|
755
|
+
try {
|
|
756
|
+
while (length < buffer.length) {
|
|
757
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
758
|
+
if (bytesRead === 0) break;
|
|
759
|
+
length += bytesRead;
|
|
760
|
+
}
|
|
761
|
+
} finally {
|
|
762
|
+
await handle.close();
|
|
763
|
+
}
|
|
764
|
+
if (length > limit) {
|
|
765
|
+
const code = "manifest_too_large";
|
|
766
|
+
throw new ManifestError("manifest_invalid", 2, [{
|
|
767
|
+
pointer: "/",
|
|
768
|
+
code,
|
|
769
|
+
detail: code
|
|
770
|
+
}]);
|
|
771
|
+
}
|
|
772
|
+
const bytes = buffer.subarray(0, length);
|
|
773
|
+
let text;
|
|
774
|
+
try {
|
|
775
|
+
text = new TextDecoder("utf-8", {
|
|
776
|
+
fatal: true,
|
|
777
|
+
ignoreBOM: true
|
|
778
|
+
}).decode(bytes);
|
|
779
|
+
} catch {
|
|
780
|
+
throw new ManifestError("invalid_json", 2);
|
|
781
|
+
}
|
|
782
|
+
const result = validateManifest(text);
|
|
783
|
+
if (!result.manifest) throw new ManifestError("manifest_invalid", 2, result.errors);
|
|
784
|
+
if (verb === "validate") {
|
|
785
|
+
output({
|
|
786
|
+
valid: true,
|
|
787
|
+
note: AUTHORITY
|
|
788
|
+
});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const dryRun = verb === "diff" || Boolean(opts.dryRun);
|
|
792
|
+
const response = await publishManifest(result.manifest, command.optsWithGlobals().baseUrl ?? getFluidApiBase(), dryRun, Boolean(opts.allowWidening));
|
|
793
|
+
output(response);
|
|
794
|
+
if (dryRun && Array.isArray(response.widening_atoms) && response.widening_atoms.length) process.exitCode = 3;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
const failure = error instanceof ManifestError ? error : new ManifestError("Unable to read or process manifest");
|
|
797
|
+
output({
|
|
798
|
+
error: {
|
|
799
|
+
code: failure.code,
|
|
800
|
+
...failure.details ? { details: failure.details } : {}
|
|
801
|
+
},
|
|
802
|
+
note: AUTHORITY
|
|
803
|
+
});
|
|
804
|
+
process.exitCode = failure.exitCode;
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/commands/rollback.ts
|
|
811
|
+
/** Public: Register confirmed rollbacks for one installation or all of a droplet. */
|
|
812
|
+
function registerRollbackCommands(parent, ctx) {
|
|
813
|
+
parent.command("rollback <version>").description("Roll back live config; would_await_consent still requires merchant consent").option("--install <uuid>", "Installation to roll back").option("--all", "Roll back all installations").option("--droplet <uuid>", "Owned droplet (required with --all)").option("--yes", "Confirm without a prompt", false).action(async (version, opts) => {
|
|
814
|
+
try {
|
|
815
|
+
if (Boolean(opts.install) === Boolean(opts.all) || opts.all && !opts.droplet || opts.install && opts.droplet) throw new DropletsCliError("Choose exactly one of --install <uuid> or --all --droplet <uuid>.");
|
|
816
|
+
const targetVersion = Number(version);
|
|
817
|
+
if (!/^\d+$/.test(version) || !Number.isSafeInteger(targetVersion) || targetVersion <= 0) throw new DropletsCliError("Version must be a positive safe integer.");
|
|
818
|
+
await confirmChange(`Roll back ${opts.install ?? opts.droplet} to ${version}?`, opts.yes);
|
|
819
|
+
const client = await ctx.getClient();
|
|
820
|
+
const target = opts.install ? `installations/${encodeURIComponent(opts.install)}` : `droplets/${encodeURIComponent(opts.droplet)}`;
|
|
821
|
+
ctx.output(await client.post(`/api/droplets/v2026-10/${target}/rollback`, {
|
|
822
|
+
version: targetVersion,
|
|
823
|
+
...opts.all ? { all: true } : {}
|
|
824
|
+
}));
|
|
825
|
+
} catch (error) {
|
|
826
|
+
fail(error);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
231
831
|
//#region src/publish/api.ts
|
|
232
832
|
/**
|
|
233
833
|
* Asking Fluid to publish a droplet, and a root admin's review of those
|
|
@@ -490,6 +1090,8 @@ const plugin = {
|
|
|
490
1090
|
register(ctx) {
|
|
491
1091
|
ctx.program.addCommand(createDomainCommand("droplets", "Manage Fluid droplets and installations", (parent, cmdCtx) => {
|
|
492
1092
|
registerAllCommands(parent, cmdCtx);
|
|
1093
|
+
registerManifestCommands(parent, cmdCtx);
|
|
1094
|
+
registerRollbackCommands(parent, cmdCtx);
|
|
493
1095
|
registerInstallCommands(parent, cmdCtx);
|
|
494
1096
|
registerPublishCommands(parent, cmdCtx);
|
|
495
1097
|
}));
|
package/jest.mocks/fluid-cli.ts
CHANGED
|
@@ -8,3 +8,8 @@ export function createDomainCommand(): never {
|
|
|
8
8
|
"createDomainCommand is not available in unit tests (droplets)",
|
|
9
9
|
);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/** Public: Resolve the API origin without loading credentials. */
|
|
13
|
+
export function getFluidApiBase(): string {
|
|
14
|
+
return process.env.FLUID_API_BASE ?? "https://api.fluid.app";
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fluid-app/fluid-cli-droplets",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.200",
|
|
4
4
|
"description": "Fluid CLI commands for droplet management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@fluid-app/fluid-cli": "0.1.197",
|
|
21
|
+
"ajv": "8.17.1",
|
|
21
22
|
"commander": "^12.0.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|