@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
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import type { CommandContext } from "@fluid-app/fluid-cli";
|
|
6
|
+
import { canonicalize, manifestDigest } from "../manifest/canonical";
|
|
7
|
+
import { schema, validateManifest } from "../manifest/validate";
|
|
8
|
+
import { registerManifestCommands } from "../commands/manifest";
|
|
9
|
+
import { registerRollbackCommands } from "../commands/rollback";
|
|
10
|
+
|
|
11
|
+
const root = resolve(__dirname, "../../../../..");
|
|
12
|
+
const fixture = (name: string) =>
|
|
13
|
+
readFileSync(
|
|
14
|
+
`${root}/apps/rails/test/fixtures/files/droplet_manifest/${name}.json`,
|
|
15
|
+
"utf8",
|
|
16
|
+
);
|
|
17
|
+
const valid = JSON.parse(fixture("valid"));
|
|
18
|
+
const path = `${root}/apps/rails/test/fixtures/files/droplet_manifest/valid.json`;
|
|
19
|
+
|
|
20
|
+
test("Rails RFC 8785 numbers and structures", () => {
|
|
21
|
+
for (const [bits, expected] of JSON.parse(fixture("rfc8785_numbers")))
|
|
22
|
+
expect(canonicalize(Buffer.from(bits, "hex").readDoubleBE())).toBe(
|
|
23
|
+
expected,
|
|
24
|
+
);
|
|
25
|
+
const vectors = JSON.parse(fixture("rfc8785_structures"));
|
|
26
|
+
expect(canonicalize(JSON.parse(vectors.section_3_2_2.input))).toBe(
|
|
27
|
+
vectors.section_3_2_2.expected,
|
|
28
|
+
);
|
|
29
|
+
const ordered = canonicalize(JSON.parse(vectors.section_3_2_3.input));
|
|
30
|
+
expect(
|
|
31
|
+
[...ordered.matchAll(/("(?:[^"\\]|\\.)*"):/g)].map((m) =>
|
|
32
|
+
JSON.parse(m[1]!),
|
|
33
|
+
),
|
|
34
|
+
).toEqual(vectors.section_3_2_3.expected_key_order);
|
|
35
|
+
expect(canonicalize([1, 1.0, -3.0])).toBe("[1,1,-3]");
|
|
36
|
+
expect(canonicalize(1e30)).toBe("1e+30");
|
|
37
|
+
expect(canonicalize("\u0001\b\u007f")).toBe('"\\u0001\\b\u007f"');
|
|
38
|
+
for (const value of [NaN, Infinity, -Infinity, "\ud800", { "\udfff": 1 }])
|
|
39
|
+
expect(() => canonicalize(value)).toThrow();
|
|
40
|
+
});
|
|
41
|
+
test("digests pinned by running the real Ruby canonicalizer", () => {
|
|
42
|
+
expect(manifestDigest(valid)).toBe(
|
|
43
|
+
"sha256:48f9581f827469c3e7caba14df533b2f26b80f0ee7a6a69e0d117f5d56bfd6a2",
|
|
44
|
+
);
|
|
45
|
+
expect(manifestDigest(JSON.parse(fixture("full")))).toBe(
|
|
46
|
+
"sha256:071549c2be5d78bcecfcac6b1605c1751255b835de5768180facdaf9f42bc11b",
|
|
47
|
+
);
|
|
48
|
+
expect(
|
|
49
|
+
manifestDigest(JSON.parse('{"a":"é","b":[1,2],"c":{"x":1,"y":2}}')),
|
|
50
|
+
).toBe(
|
|
51
|
+
manifestDigest(JSON.parse('{"c":{"y":2,"x":1},"b":[1,2.0],"a":"\\u00e9"}')),
|
|
52
|
+
);
|
|
53
|
+
for (const changed of [
|
|
54
|
+
{ a: 2, b: [1, 2] },
|
|
55
|
+
{ a: 1, b: [2, 1] },
|
|
56
|
+
{ a: 1, b: [1, 2], c: null },
|
|
57
|
+
])
|
|
58
|
+
expect(manifestDigest(changed)).not.toBe(
|
|
59
|
+
manifestDigest({ a: 1, b: [1, 2] }),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
test("bundled schema matches Rails and accepts both fixtures", () => {
|
|
63
|
+
expect(schema).toEqual(
|
|
64
|
+
JSON.parse(
|
|
65
|
+
readFileSync(
|
|
66
|
+
`${root}/apps/rails/config/droplet_manifest/v1.schema.json`,
|
|
67
|
+
"utf8",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
for (const name of ["valid", "full"])
|
|
72
|
+
expect(validateManifest(fixture(name)).errors).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
test.each([
|
|
75
|
+
"{",
|
|
76
|
+
'{"a":1,"a":2}',
|
|
77
|
+
'{"a":1,"\\u0061":2}',
|
|
78
|
+
" ".repeat(262145),
|
|
79
|
+
JSON.stringify({ ...valid, version: "bad" }),
|
|
80
|
+
JSON.stringify({ ...valid, origin: "https://user@drop.app/path" }),
|
|
81
|
+
JSON.stringify({ ...valid, min_supported_version: "999.0.0" }),
|
|
82
|
+
JSON.stringify({ ...valid, extra: 1e100 }),
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
...valid,
|
|
85
|
+
callbacks: [
|
|
86
|
+
{ definition: "a", url_path: "/a" },
|
|
87
|
+
{ definition: "a", url_path: "/b" },
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
])("rejects invalid manifests %#", (text) =>
|
|
91
|
+
expect(validateManifest(text).errors.length).toBeGreaterThan(0),
|
|
92
|
+
);
|
|
93
|
+
test.each(
|
|
94
|
+
"//evil,/a%2fb,/a%5cb,/a/%2e.,/../a,/white space,/a%ZZ,/é,/a\u0000,/a\n,/a[b],/a|b".split(
|
|
95
|
+
",",
|
|
96
|
+
),
|
|
97
|
+
)("rejects unsafe path %s", (p) =>
|
|
98
|
+
expect(
|
|
99
|
+
validateManifest(JSON.stringify({ ...valid, health: { path: p } })).errors
|
|
100
|
+
.length,
|
|
101
|
+
).toBeGreaterThan(0),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const post = jest.fn();
|
|
105
|
+
const get = jest.fn();
|
|
106
|
+
const output = jest.fn();
|
|
107
|
+
const ctx: CommandContext = {
|
|
108
|
+
output,
|
|
109
|
+
getClient: jest.fn(async () => ({ post, get }) as never),
|
|
110
|
+
getClientFor: jest.fn(),
|
|
111
|
+
parseBody: JSON.parse,
|
|
112
|
+
verbose: false,
|
|
113
|
+
};
|
|
114
|
+
const fetchMock = jest.fn();
|
|
115
|
+
let stdout: jest.SpyInstance;
|
|
116
|
+
const env = { ...process.env };
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
process.exitCode = undefined;
|
|
119
|
+
jest.clearAllMocks();
|
|
120
|
+
stdout = jest.spyOn(process.stdout, "write").mockReturnValue(true);
|
|
121
|
+
jest.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
122
|
+
global.fetch = fetchMock;
|
|
123
|
+
process.env.GITHUB_ACTIONS = "true";
|
|
124
|
+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://github.test/token?x=1";
|
|
125
|
+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = "request-secret";
|
|
126
|
+
fetchMock.mockReset();
|
|
127
|
+
});
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
process.exitCode = undefined;
|
|
130
|
+
process.env = { ...env };
|
|
131
|
+
jest.restoreAllMocks();
|
|
132
|
+
});
|
|
133
|
+
function response(body: unknown, status = 200) {
|
|
134
|
+
return { ok: status < 400, status, json: async () => body };
|
|
135
|
+
}
|
|
136
|
+
function seed(body: unknown) {
|
|
137
|
+
fetchMock
|
|
138
|
+
.mockResolvedValueOnce(response({ value: "oidc-secret" }))
|
|
139
|
+
.mockResolvedValueOnce(
|
|
140
|
+
response({ publish_grant: { token: "grant-secret" } }, 201),
|
|
141
|
+
)
|
|
142
|
+
.mockResolvedValueOnce(response(body));
|
|
143
|
+
}
|
|
144
|
+
async function run(...args: string[]) {
|
|
145
|
+
const command = new Command("droplets").option("--base-url <url>");
|
|
146
|
+
registerManifestCommands(command, ctx);
|
|
147
|
+
registerRollbackCommands(command, ctx);
|
|
148
|
+
await command.parseAsync(args, { from: "user" });
|
|
149
|
+
}
|
|
150
|
+
test("validate output and local invalid exit 2 without network", async () => {
|
|
151
|
+
await run("manifest", "validate", path);
|
|
152
|
+
expect(output).toHaveBeenCalledWith(expect.objectContaining({ valid: true }));
|
|
153
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
154
|
+
await run("manifest", "validate", path, "--json");
|
|
155
|
+
expect(JSON.parse(stdout.mock.calls[0]![0]).valid).toBe(true);
|
|
156
|
+
await run(
|
|
157
|
+
"manifest",
|
|
158
|
+
"validate",
|
|
159
|
+
path.replace("valid.json", "rfc8785_numbers.json"),
|
|
160
|
+
"--json",
|
|
161
|
+
);
|
|
162
|
+
expect(process.exitCode).toBe(2);
|
|
163
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
test("oversized file exits 2 with the validator's too-large finding", async () => {
|
|
166
|
+
const dir = mkdtempSync(resolve(tmpdir(), "manifest-"));
|
|
167
|
+
const text = " ".repeat(256 * 1024 + 1);
|
|
168
|
+
writeFileSync(`${dir}/large.json`, text);
|
|
169
|
+
await run("manifest", "validate", `${dir}/large.json`, "--json").finally(() =>
|
|
170
|
+
rmSync(dir, { recursive: true, force: true }),
|
|
171
|
+
);
|
|
172
|
+
expect(process.exitCode).toBe(2);
|
|
173
|
+
expect(JSON.parse(stdout.mock.calls[0]![0]).error.details).toEqual(
|
|
174
|
+
validateManifest(text).errors,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
test("an endless file with no reported size is refused without buffering it", async () => {
|
|
178
|
+
await run("manifest", "validate", "/dev/zero", "--json");
|
|
179
|
+
expect(process.exitCode).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
test.each(["publish", "diff"])(
|
|
182
|
+
"%s uses OIDC, grant, correct auth and no platform client",
|
|
183
|
+
async (verb) => {
|
|
184
|
+
seed({ digest: "result", widening_atoms: [] });
|
|
185
|
+
await run(
|
|
186
|
+
"--base-url",
|
|
187
|
+
"https://api.fluid.app",
|
|
188
|
+
"manifest",
|
|
189
|
+
verb,
|
|
190
|
+
path,
|
|
191
|
+
"--json",
|
|
192
|
+
);
|
|
193
|
+
expect(fetchMock.mock.calls[0]![0]).toContain(
|
|
194
|
+
"audience=https%3A%2F%2Fapi.fluid.app%2Fdroplets%2Fmanifest",
|
|
195
|
+
);
|
|
196
|
+
expect(fetchMock.mock.calls[1]![0]).toBe(
|
|
197
|
+
"https://api.fluid.app/api/droplets/v2026-10/publish-grants",
|
|
198
|
+
);
|
|
199
|
+
expect(fetchMock.mock.calls.map((c) => c[1].headers.Authorization)).toEqual(
|
|
200
|
+
["Bearer request-secret", "Bearer oidc-secret", "Bearer grant-secret"],
|
|
201
|
+
);
|
|
202
|
+
expect(fetchMock.mock.calls[2]![0]).toContain(
|
|
203
|
+
verb === "diff" ? "?dry_run=true" : "manifest-publications",
|
|
204
|
+
);
|
|
205
|
+
expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
|
|
206
|
+
verb === "diff" ? true : undefined,
|
|
207
|
+
);
|
|
208
|
+
expect(ctx.getClient).not.toHaveBeenCalled();
|
|
209
|
+
expect(stdout).toHaveBeenCalledTimes(1);
|
|
210
|
+
expect(JSON.parse(stdout.mock.calls[0]![0])).toEqual({
|
|
211
|
+
digest: "result",
|
|
212
|
+
widening_atoms: [],
|
|
213
|
+
});
|
|
214
|
+
expect(JSON.stringify(stdout.mock.calls)).not.toContain("secret");
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
test.each([false, true])(
|
|
218
|
+
"dry run widening exits 3 even with allow-widening=%s",
|
|
219
|
+
async (allow) => {
|
|
220
|
+
seed({ widening_atoms: ["scope:orders"] });
|
|
221
|
+
await run(
|
|
222
|
+
"manifest",
|
|
223
|
+
"publish",
|
|
224
|
+
path,
|
|
225
|
+
"--dry-run",
|
|
226
|
+
...(allow ? ["--allow-widening"] : []),
|
|
227
|
+
);
|
|
228
|
+
expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
|
|
229
|
+
true,
|
|
230
|
+
);
|
|
231
|
+
expect(process.exitCode).toBe(3);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
test("explicit widening real publish succeeds", async () => {
|
|
235
|
+
seed({ version: "1.0.0" });
|
|
236
|
+
await run("manifest", "publish", path, "--allow-widening");
|
|
237
|
+
expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
expect(process.exitCode).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
test.each([
|
|
243
|
+
"GITHUB_ACTIONS",
|
|
244
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
245
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
246
|
+
])("missing %s refuses without network", async (key) => {
|
|
247
|
+
delete process.env[key];
|
|
248
|
+
await run("manifest", "publish", path, "--json");
|
|
249
|
+
expect(process.exitCode).toBe(1);
|
|
250
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
251
|
+
expect(stdout.mock.calls[0]![0]).toContain(key);
|
|
252
|
+
expect(stdout.mock.calls[0]![0]).toContain("id-token: write");
|
|
253
|
+
});
|
|
254
|
+
test.each([
|
|
255
|
+
[403, "publisher_not_bound", 1],
|
|
256
|
+
[409, "version_conflict", 1],
|
|
257
|
+
[422, "widening_not_allowed", 3],
|
|
258
|
+
[422, "manifest_invalid", 2],
|
|
259
|
+
[422, "digest_mismatch", 1],
|
|
260
|
+
])("API %s %s maps exit", async (status, code, exit) => {
|
|
261
|
+
const finding = { pointer: "/x", code: "unknown", detail: "Unknown" };
|
|
262
|
+
fetchMock.mockResolvedValueOnce(response({ value: "oidc-secret" }));
|
|
263
|
+
if (status !== 403)
|
|
264
|
+
fetchMock.mockResolvedValueOnce(
|
|
265
|
+
response({ publish_grant: { token: "grant-secret" } }),
|
|
266
|
+
);
|
|
267
|
+
fetchMock.mockResolvedValueOnce(
|
|
268
|
+
response(
|
|
269
|
+
{
|
|
270
|
+
error: {
|
|
271
|
+
message: "secret must not leak",
|
|
272
|
+
details: { code, details: [{ ...finding, token: "grant-secret" }] },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
Number(status),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
await run("manifest", "publish", path, "--json");
|
|
279
|
+
expect(process.exitCode).toBe(exit);
|
|
280
|
+
if (code === "manifest_invalid")
|
|
281
|
+
expect(JSON.parse(stdout.mock.calls[0]![0]).error.details).toEqual([
|
|
282
|
+
finding,
|
|
283
|
+
]);
|
|
284
|
+
expect(JSON.parse(stdout.mock.calls[0]![0]).error.code).toBe(code);
|
|
285
|
+
expect(JSON.stringify(stdout.mock.calls)).not.toContain("secret");
|
|
286
|
+
});
|
|
287
|
+
test.each([
|
|
288
|
+
["--install", "dri_1"],
|
|
289
|
+
["--all", "--droplet", "drp_1"],
|
|
290
|
+
])("rollback %j uses authenticated client", async (...args) => {
|
|
291
|
+
post.mockResolvedValue({ classification: "would_await_consent" });
|
|
292
|
+
await run("rollback", "1", ...args, "--yes");
|
|
293
|
+
expect(post).toHaveBeenCalledWith(
|
|
294
|
+
expect.stringContaining(
|
|
295
|
+
args[0] === "--install"
|
|
296
|
+
? "/installations/dri_1/rollback"
|
|
297
|
+
: "/droplets/drp_1/rollback",
|
|
298
|
+
),
|
|
299
|
+
args[0] === "--install" ? { version: 1 } : { version: 1, all: true },
|
|
300
|
+
);
|
|
301
|
+
expect(output).toHaveBeenCalledWith({
|
|
302
|
+
classification: "would_await_consent",
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
test.each([
|
|
306
|
+
[],
|
|
307
|
+
["--all"],
|
|
308
|
+
["--install", "dri_1", "--all"],
|
|
309
|
+
["--install", "dri_1"],
|
|
310
|
+
])("rollback invalid or unconfirmed %j makes no request", async (...args) => {
|
|
311
|
+
await run("rollback", "1", ...args);
|
|
312
|
+
expect(process.exitCode).toBe(1);
|
|
313
|
+
expect(post).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test.each(["/%C3%A9", "/a%00", "/a%01", "/a%20b", "/a%5Bb%5D", "/a%7Cb"])(
|
|
317
|
+
"accepts Ruby URI path %s",
|
|
318
|
+
(p) => {
|
|
319
|
+
expect(
|
|
320
|
+
validateManifest(JSON.stringify({ ...valid, health: { path: p } }))
|
|
321
|
+
.errors,
|
|
322
|
+
).toEqual([]);
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
test.each(["1.0.0", "0", "-1", "1.5", "9007199254740992", "1e2", "", " 1"])(
|
|
326
|
+
"rejects version %s",
|
|
327
|
+
async (version) => {
|
|
328
|
+
await run("rollback", "--install", "dri_1", "--", version);
|
|
329
|
+
expect(process.exitCode).toBe(1);
|
|
330
|
+
expect(post).not.toHaveBeenCalled();
|
|
331
|
+
expect(
|
|
332
|
+
JSON.stringify(jest.mocked(process.stderr.write).mock.calls),
|
|
333
|
+
).toContain("positive safe integer");
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
test.each(
|
|
337
|
+
"-http://api.fluid.app -http://localhost.evil -http://127.0.0.2 +http://localhost +http://127.0.0.1 +http://[::1] -https://fluid.app +https://api.fluid.app -https://api.stage.fluid.app -https://shop.fluid.app -https://user@api.fluid.app -https://api.fluid.app:8443 -https://evil.com -https://evilfluid.app -https://fluid.app.evil.com -https://api.fluid.app@evil.com".split(
|
|
338
|
+
" ",
|
|
339
|
+
),
|
|
340
|
+
)("publisher transport %s", async (entry) => {
|
|
341
|
+
seed({ widening_atoms: [] });
|
|
342
|
+
await run("--base-url", entry.slice(1), "manifest", "diff", path);
|
|
343
|
+
expect(fetchMock).toHaveBeenCalledTimes(entry[0] === "+" ? 3 : 0);
|
|
344
|
+
expect(process.exitCode).toBe(entry[0] === "+" ? undefined : 1);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("diff --against reads the ledger with the session client and no OIDC", async () => {
|
|
348
|
+
get.mockResolvedValue({ base_version: 1, target_version: 3, added: [] });
|
|
349
|
+
await run("manifest", "diff", "--droplet", "drp_1", "--against", "1");
|
|
350
|
+
expect(get).toHaveBeenCalledWith(
|
|
351
|
+
"/api/droplets/v2026-10/droplets/drp_1/manifest-diff",
|
|
352
|
+
{ against: "1" },
|
|
353
|
+
);
|
|
354
|
+
expect(output).toHaveBeenCalledWith({
|
|
355
|
+
base_version: 1,
|
|
356
|
+
target_version: 3,
|
|
357
|
+
added: [],
|
|
358
|
+
});
|
|
359
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
360
|
+
expect(post).not.toHaveBeenCalled();
|
|
361
|
+
expect(process.exitCode).toBeUndefined();
|
|
362
|
+
});
|
|
363
|
+
test.each([
|
|
364
|
+
[["--against", "0", "--droplet", "drp_1"]],
|
|
365
|
+
[["--against", "1.5", "--droplet", "drp_1"]],
|
|
366
|
+
[["--against", "abc", "--droplet", "drp_1"]],
|
|
367
|
+
[["--against", "1"]],
|
|
368
|
+
[["--droplet", "drp_1"]],
|
|
369
|
+
[[path, "--against", "1", "--droplet", "drp_1"]],
|
|
370
|
+
])("diff --against %j makes no request", async (args) => {
|
|
371
|
+
await run("manifest", "diff", ...args);
|
|
372
|
+
expect(process.exitCode).toBe(1);
|
|
373
|
+
expect(get).not.toHaveBeenCalled();
|
|
374
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
test("diff without --against still previews through Actions OIDC", async () => {
|
|
377
|
+
seed({ digest: "result", widening_atoms: ["scope:x"] });
|
|
378
|
+
await run("--base-url", "https://api.fluid.app", "manifest", "diff", path);
|
|
379
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
380
|
+
expect(get).not.toHaveBeenCalled();
|
|
381
|
+
expect(ctx.getClient).not.toHaveBeenCalled();
|
|
382
|
+
expect(process.exitCode).toBe(3);
|
|
383
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { getFluidApiBase, type CommandContext } from "@fluid-app/fluid-cli";
|
|
4
|
+
import { AUTHORITY, validateManifest } from "../manifest/validate.js";
|
|
5
|
+
import { ManifestError, publishManifest } from "../manifest/publish.js";
|
|
6
|
+
import { DropletsCliError } from "../install/api.js";
|
|
7
|
+
import { fail } from "./install.js";
|
|
8
|
+
|
|
9
|
+
/** Public: Register local validation and Actions-only manifest publication commands. */
|
|
10
|
+
export function registerManifestCommands(
|
|
11
|
+
parent: Command,
|
|
12
|
+
ctx: CommandContext,
|
|
13
|
+
): void {
|
|
14
|
+
const manifest = parent
|
|
15
|
+
.command("manifest")
|
|
16
|
+
.description(`Droplet manifests. ${AUTHORITY}`);
|
|
17
|
+
for (const verb of ["validate", "publish", "diff"]) {
|
|
18
|
+
const cmd = manifest
|
|
19
|
+
.command(`${verb} [path]`)
|
|
20
|
+
.description(
|
|
21
|
+
`${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}`,
|
|
22
|
+
)
|
|
23
|
+
.option("--json", "Print one JSON document");
|
|
24
|
+
if (verb === "publish")
|
|
25
|
+
cmd.option(
|
|
26
|
+
"--allow-widening",
|
|
27
|
+
"Permit widening when publishing; previews always report widening (exit 3)",
|
|
28
|
+
);
|
|
29
|
+
if (verb === "publish")
|
|
30
|
+
cmd.option("--dry-run", "Preview without publishing");
|
|
31
|
+
if (verb === "diff")
|
|
32
|
+
cmd
|
|
33
|
+
.option(
|
|
34
|
+
"--against <version>",
|
|
35
|
+
"Read-only: diff the droplet's latest ledger version against this one, with your session (needs --droplet)",
|
|
36
|
+
)
|
|
37
|
+
.option("--droplet <uuid>", "Owned droplet, with --against");
|
|
38
|
+
cmd.action(
|
|
39
|
+
async (
|
|
40
|
+
path: string | undefined,
|
|
41
|
+
opts: {
|
|
42
|
+
json?: boolean;
|
|
43
|
+
dryRun?: boolean;
|
|
44
|
+
allowWidening?: boolean;
|
|
45
|
+
against?: string;
|
|
46
|
+
droplet?: string;
|
|
47
|
+
},
|
|
48
|
+
command: Command,
|
|
49
|
+
) => {
|
|
50
|
+
const output = (data: Record<string, unknown>) =>
|
|
51
|
+
opts.json
|
|
52
|
+
? process.stdout.write(JSON.stringify(data) + "\n")
|
|
53
|
+
: ctx.output(data);
|
|
54
|
+
if (verb === "diff" && (opts.against !== undefined || opts.droplet)) {
|
|
55
|
+
try {
|
|
56
|
+
if (
|
|
57
|
+
path !== undefined ||
|
|
58
|
+
!opts.droplet ||
|
|
59
|
+
opts.against === undefined
|
|
60
|
+
)
|
|
61
|
+
throw new DropletsCliError(
|
|
62
|
+
"Use --against <version> with --droplet <uuid> and no manifest path.",
|
|
63
|
+
);
|
|
64
|
+
if (!/^[1-9]\d{0,8}$/.test(opts.against))
|
|
65
|
+
throw new DropletsCliError(
|
|
66
|
+
"Version must be a positive safe integer.",
|
|
67
|
+
);
|
|
68
|
+
const client = await ctx.getClient();
|
|
69
|
+
const response = await client.get(
|
|
70
|
+
`/api/droplets/v2026-10/droplets/${encodeURIComponent(opts.droplet)}/manifest-diff`,
|
|
71
|
+
{ against: opts.against },
|
|
72
|
+
);
|
|
73
|
+
output(response as Record<string, unknown>);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
fail(error);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const file = path ?? "./fluid.droplet.json";
|
|
81
|
+
const limit = 256 * 1024;
|
|
82
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
83
|
+
let length = 0;
|
|
84
|
+
const handle = await open(file, "r");
|
|
85
|
+
try {
|
|
86
|
+
while (length < buffer.length) {
|
|
87
|
+
const { bytesRead } = await handle.read(
|
|
88
|
+
buffer,
|
|
89
|
+
length,
|
|
90
|
+
buffer.length - length,
|
|
91
|
+
null,
|
|
92
|
+
);
|
|
93
|
+
if (bytesRead === 0) break;
|
|
94
|
+
length += bytesRead;
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
await handle.close();
|
|
98
|
+
}
|
|
99
|
+
if (length > limit) {
|
|
100
|
+
const code = "manifest_too_large";
|
|
101
|
+
throw new ManifestError("manifest_invalid", 2, [
|
|
102
|
+
{ pointer: "/", code, detail: code },
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
const bytes = buffer.subarray(0, length);
|
|
106
|
+
let text: string;
|
|
107
|
+
try {
|
|
108
|
+
text = new TextDecoder("utf-8", {
|
|
109
|
+
fatal: true,
|
|
110
|
+
ignoreBOM: true,
|
|
111
|
+
}).decode(bytes);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new ManifestError("invalid_json", 2);
|
|
114
|
+
}
|
|
115
|
+
const result = validateManifest(text);
|
|
116
|
+
if (!result.manifest)
|
|
117
|
+
throw new ManifestError("manifest_invalid", 2, result.errors);
|
|
118
|
+
if (verb === "validate") {
|
|
119
|
+
output({ valid: true, note: AUTHORITY });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const dryRun = verb === "diff" || Boolean(opts.dryRun);
|
|
123
|
+
const response = await publishManifest(
|
|
124
|
+
result.manifest,
|
|
125
|
+
command.optsWithGlobals().baseUrl ?? getFluidApiBase(),
|
|
126
|
+
dryRun,
|
|
127
|
+
Boolean(opts.allowWidening),
|
|
128
|
+
);
|
|
129
|
+
output(response);
|
|
130
|
+
if (
|
|
131
|
+
dryRun &&
|
|
132
|
+
Array.isArray(response.widening_atoms) &&
|
|
133
|
+
response.widening_atoms.length
|
|
134
|
+
)
|
|
135
|
+
process.exitCode = 3;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const failure =
|
|
138
|
+
error instanceof ManifestError
|
|
139
|
+
? error
|
|
140
|
+
: new ManifestError("Unable to read or process manifest");
|
|
141
|
+
output({
|
|
142
|
+
error: {
|
|
143
|
+
code: failure.code,
|
|
144
|
+
...(failure.details ? { details: failure.details } : {}),
|
|
145
|
+
},
|
|
146
|
+
note: AUTHORITY,
|
|
147
|
+
});
|
|
148
|
+
process.exitCode = failure.exitCode;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { CommandContext } from "@fluid-app/fluid-cli";
|
|
3
|
+
import { confirmChange, fail } from "./install.js";
|
|
4
|
+
import { DropletsCliError } from "../install/api.js";
|
|
5
|
+
|
|
6
|
+
/** Public: Register confirmed rollbacks for one installation or all of a droplet. */
|
|
7
|
+
export function registerRollbackCommands(
|
|
8
|
+
parent: Command,
|
|
9
|
+
ctx: CommandContext,
|
|
10
|
+
): void {
|
|
11
|
+
parent
|
|
12
|
+
.command("rollback <version>")
|
|
13
|
+
.description(
|
|
14
|
+
"Roll back live config; would_await_consent still requires merchant consent",
|
|
15
|
+
)
|
|
16
|
+
.option("--install <uuid>", "Installation to roll back")
|
|
17
|
+
.option("--all", "Roll back all installations")
|
|
18
|
+
.option("--droplet <uuid>", "Owned droplet (required with --all)")
|
|
19
|
+
.option("--yes", "Confirm without a prompt", false)
|
|
20
|
+
.action(
|
|
21
|
+
async (
|
|
22
|
+
version: string,
|
|
23
|
+
opts: {
|
|
24
|
+
install?: string;
|
|
25
|
+
all?: boolean;
|
|
26
|
+
droplet?: string;
|
|
27
|
+
yes: boolean;
|
|
28
|
+
},
|
|
29
|
+
) => {
|
|
30
|
+
try {
|
|
31
|
+
if (
|
|
32
|
+
Boolean(opts.install) === Boolean(opts.all) ||
|
|
33
|
+
(opts.all && !opts.droplet) ||
|
|
34
|
+
(opts.install && opts.droplet)
|
|
35
|
+
)
|
|
36
|
+
throw new DropletsCliError(
|
|
37
|
+
"Choose exactly one of --install <uuid> or --all --droplet <uuid>.",
|
|
38
|
+
);
|
|
39
|
+
const targetVersion = Number(version);
|
|
40
|
+
if (
|
|
41
|
+
!/^\d+$/.test(version) ||
|
|
42
|
+
!Number.isSafeInteger(targetVersion) ||
|
|
43
|
+
targetVersion <= 0
|
|
44
|
+
)
|
|
45
|
+
throw new DropletsCliError(
|
|
46
|
+
"Version must be a positive safe integer.",
|
|
47
|
+
);
|
|
48
|
+
await confirmChange(
|
|
49
|
+
`Roll back ${opts.install ?? opts.droplet} to ${version}?`,
|
|
50
|
+
opts.yes,
|
|
51
|
+
);
|
|
52
|
+
const client = await ctx.getClient();
|
|
53
|
+
const target = opts.install
|
|
54
|
+
? `installations/${encodeURIComponent(opts.install)}`
|
|
55
|
+
: `droplets/${encodeURIComponent(opts.droplet!)}`;
|
|
56
|
+
ctx.output(
|
|
57
|
+
await client.post(`/api/droplets/v2026-10/${target}/rollback`, {
|
|
58
|
+
version: targetVersion,
|
|
59
|
+
...(opts.all ? { all: true } : {}),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
fail(error);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { FluidPlugin } from "@fluid-app/fluid-cli";
|
|
2
2
|
import { createDomainCommand } from "@fluid-app/fluid-cli";
|
|
3
3
|
|
|
4
|
+
import { registerManifestCommands } from "./commands/manifest.js";
|
|
5
|
+
import { registerRollbackCommands } from "./commands/rollback.js";
|
|
4
6
|
import { registerInstallCommands } from "./commands/install.js";
|
|
5
7
|
import { registerPublishCommands } from "./commands/publish.js";
|
|
6
8
|
import { registerAllCommands } from "./generated/index.js";
|
|
@@ -15,10 +17,8 @@ const plugin: FluidPlugin = {
|
|
|
15
17
|
"Manage Fluid droplets and installations",
|
|
16
18
|
(parent, cmdCtx) => {
|
|
17
19
|
registerAllCommands(parent, cmdCtx);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// company API, and asking Fluid to publish a droplet (with the
|
|
21
|
-
// root admin's review of those requests).
|
|
20
|
+
registerManifestCommands(parent, cmdCtx);
|
|
21
|
+
registerRollbackCommands(parent, cmdCtx);
|
|
22
22
|
registerInstallCommands(parent, cmdCtx);
|
|
23
23
|
registerPublishCommands(parent, cmdCtx);
|
|
24
24
|
},
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Public: Serialize JSON using RFC 8785 UTF-16 ordering and ECMAScript numbers. */
|
|
4
|
+
export function canonicalize(value: unknown): string {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
if (!value.isWellFormed()) throw new Error("string is not valid Unicode");
|
|
7
|
+
return JSON.stringify(value);
|
|
8
|
+
}
|
|
9
|
+
if (typeof value === "number") {
|
|
10
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
if (value === null || typeof value === "boolean")
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
16
|
+
if (
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
19
|
+
) {
|
|
20
|
+
return `{${Object.keys(value)
|
|
21
|
+
.sort()
|
|
22
|
+
.map(
|
|
23
|
+
(key) =>
|
|
24
|
+
`${canonicalize(key)}:${canonicalize(Reflect.get(value, key))}`,
|
|
25
|
+
)
|
|
26
|
+
.join(",")}}`;
|
|
27
|
+
}
|
|
28
|
+
throw new Error("unsupported JSON value");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Public: Compute the manifest ledger key over its canonical UTF-8 bytes. */
|
|
32
|
+
export function manifestDigest(value: unknown): string {
|
|
33
|
+
return `sha256:${createHash("sha256").update(canonicalize(value)).digest("hex")}`;
|
|
34
|
+
}
|