@fluid-app/fluid-cli-droplets 0.1.198 → 0.1.199

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.
@@ -0,0 +1,344 @@
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 output = jest.fn();
106
+ const ctx: CommandContext = {
107
+ output,
108
+ getClient: jest.fn(async () => ({ post }) as never),
109
+ getClientFor: jest.fn(),
110
+ parseBody: JSON.parse,
111
+ verbose: false,
112
+ };
113
+ const fetchMock = jest.fn();
114
+ let stdout: jest.SpyInstance;
115
+ const env = { ...process.env };
116
+ beforeEach(() => {
117
+ process.exitCode = undefined;
118
+ jest.clearAllMocks();
119
+ stdout = jest.spyOn(process.stdout, "write").mockReturnValue(true);
120
+ jest.spyOn(process.stderr, "write").mockReturnValue(true);
121
+ global.fetch = fetchMock;
122
+ process.env.GITHUB_ACTIONS = "true";
123
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://github.test/token?x=1";
124
+ process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = "request-secret";
125
+ fetchMock.mockReset();
126
+ });
127
+ afterEach(() => {
128
+ process.exitCode = undefined;
129
+ process.env = { ...env };
130
+ jest.restoreAllMocks();
131
+ });
132
+ function response(body: unknown, status = 200) {
133
+ return { ok: status < 400, status, json: async () => body };
134
+ }
135
+ function seed(body: unknown) {
136
+ fetchMock
137
+ .mockResolvedValueOnce(response({ value: "oidc-secret" }))
138
+ .mockResolvedValueOnce(
139
+ response({ publish_grant: { token: "grant-secret" } }, 201),
140
+ )
141
+ .mockResolvedValueOnce(response(body));
142
+ }
143
+ async function run(...args: string[]) {
144
+ const command = new Command("droplets").option("--base-url <url>");
145
+ registerManifestCommands(command, ctx);
146
+ registerRollbackCommands(command, ctx);
147
+ await command.parseAsync(args, { from: "user" });
148
+ }
149
+ test("validate output and local invalid exit 2 without network", async () => {
150
+ await run("manifest", "validate", path);
151
+ expect(output).toHaveBeenCalledWith(expect.objectContaining({ valid: true }));
152
+ expect(stdout).not.toHaveBeenCalled();
153
+ await run("manifest", "validate", path, "--json");
154
+ expect(JSON.parse(stdout.mock.calls[0]![0]).valid).toBe(true);
155
+ await run(
156
+ "manifest",
157
+ "validate",
158
+ path.replace("valid.json", "rfc8785_numbers.json"),
159
+ "--json",
160
+ );
161
+ expect(process.exitCode).toBe(2);
162
+ expect(fetchMock).not.toHaveBeenCalled();
163
+ });
164
+ test("oversized file exits 2 with the validator's too-large finding", async () => {
165
+ const dir = mkdtempSync(resolve(tmpdir(), "manifest-"));
166
+ const text = " ".repeat(256 * 1024 + 1);
167
+ writeFileSync(`${dir}/large.json`, text);
168
+ await run("manifest", "validate", `${dir}/large.json`, "--json").finally(() =>
169
+ rmSync(dir, { recursive: true, force: true }),
170
+ );
171
+ expect(process.exitCode).toBe(2);
172
+ expect(JSON.parse(stdout.mock.calls[0]![0]).error.details).toEqual(
173
+ validateManifest(text).errors,
174
+ );
175
+ });
176
+ test("an endless file with no reported size is refused without buffering it", async () => {
177
+ await run("manifest", "validate", "/dev/zero", "--json");
178
+ expect(process.exitCode).toBe(2);
179
+ });
180
+ test.each(["publish", "diff"])(
181
+ "%s uses OIDC, grant, correct auth and no platform client",
182
+ async (verb) => {
183
+ seed({ digest: "result", widening_atoms: [] });
184
+ await run(
185
+ "--base-url",
186
+ "https://api.fluid.app",
187
+ "manifest",
188
+ verb,
189
+ path,
190
+ "--json",
191
+ );
192
+ expect(fetchMock.mock.calls[0]![0]).toContain(
193
+ "audience=https%3A%2F%2Fapi.fluid.app%2Fdroplets%2Fmanifest",
194
+ );
195
+ expect(fetchMock.mock.calls[1]![0]).toBe(
196
+ "https://api.fluid.app/api/droplets/v2026-10/publish-grants",
197
+ );
198
+ expect(fetchMock.mock.calls.map((c) => c[1].headers.Authorization)).toEqual(
199
+ ["Bearer request-secret", "Bearer oidc-secret", "Bearer grant-secret"],
200
+ );
201
+ expect(fetchMock.mock.calls[2]![0]).toContain(
202
+ verb === "diff" ? "?dry_run=true" : "manifest-publications",
203
+ );
204
+ expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
205
+ verb === "diff" ? true : undefined,
206
+ );
207
+ expect(ctx.getClient).not.toHaveBeenCalled();
208
+ expect(stdout).toHaveBeenCalledTimes(1);
209
+ expect(JSON.parse(stdout.mock.calls[0]![0])).toEqual({
210
+ digest: "result",
211
+ widening_atoms: [],
212
+ });
213
+ expect(JSON.stringify(stdout.mock.calls)).not.toContain("secret");
214
+ },
215
+ );
216
+ test.each([false, true])(
217
+ "dry run widening exits 3 even with allow-widening=%s",
218
+ async (allow) => {
219
+ seed({ widening_atoms: ["scope:orders"] });
220
+ await run(
221
+ "manifest",
222
+ "publish",
223
+ path,
224
+ "--dry-run",
225
+ ...(allow ? ["--allow-widening"] : []),
226
+ );
227
+ expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
228
+ true,
229
+ );
230
+ expect(process.exitCode).toBe(3);
231
+ },
232
+ );
233
+ test("explicit widening real publish succeeds", async () => {
234
+ seed({ version: "1.0.0" });
235
+ await run("manifest", "publish", path, "--allow-widening");
236
+ expect(JSON.parse(fetchMock.mock.calls[2]![1].body).allow_widening).toBe(
237
+ true,
238
+ );
239
+ expect(process.exitCode).toBeUndefined();
240
+ });
241
+ test.each([
242
+ "GITHUB_ACTIONS",
243
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
244
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
245
+ ])("missing %s refuses without network", async (key) => {
246
+ delete process.env[key];
247
+ await run("manifest", "publish", path, "--json");
248
+ expect(process.exitCode).toBe(1);
249
+ expect(fetchMock).not.toHaveBeenCalled();
250
+ expect(stdout.mock.calls[0]![0]).toContain(key);
251
+ expect(stdout.mock.calls[0]![0]).toContain("id-token: write");
252
+ });
253
+ test.each([
254
+ [403, "publisher_not_bound", 1],
255
+ [409, "version_conflict", 1],
256
+ [422, "widening_not_allowed", 3],
257
+ [422, "manifest_invalid", 2],
258
+ [422, "digest_mismatch", 1],
259
+ ])("API %s %s maps exit", async (status, code, exit) => {
260
+ const finding = { pointer: "/x", code: "unknown", detail: "Unknown" };
261
+ fetchMock.mockResolvedValueOnce(response({ value: "oidc-secret" }));
262
+ if (status !== 403)
263
+ fetchMock.mockResolvedValueOnce(
264
+ response({ publish_grant: { token: "grant-secret" } }),
265
+ );
266
+ fetchMock.mockResolvedValueOnce(
267
+ response(
268
+ {
269
+ error: {
270
+ message: "secret must not leak",
271
+ details: { code, details: [{ ...finding, token: "grant-secret" }] },
272
+ },
273
+ },
274
+ Number(status),
275
+ ),
276
+ );
277
+ await run("manifest", "publish", path, "--json");
278
+ expect(process.exitCode).toBe(exit);
279
+ if (code === "manifest_invalid")
280
+ expect(JSON.parse(stdout.mock.calls[0]![0]).error.details).toEqual([
281
+ finding,
282
+ ]);
283
+ expect(JSON.parse(stdout.mock.calls[0]![0]).error.code).toBe(code);
284
+ expect(JSON.stringify(stdout.mock.calls)).not.toContain("secret");
285
+ });
286
+ test.each([
287
+ ["--install", "dri_1"],
288
+ ["--all", "--droplet", "drp_1"],
289
+ ])("rollback %j uses authenticated client", async (...args) => {
290
+ post.mockResolvedValue({ classification: "would_await_consent" });
291
+ await run("rollback", "1", ...args, "--yes");
292
+ expect(post).toHaveBeenCalledWith(
293
+ expect.stringContaining(
294
+ args[0] === "--install"
295
+ ? "/installations/dri_1/rollback"
296
+ : "/droplets/drp_1/rollback",
297
+ ),
298
+ args[0] === "--install" ? { version: 1 } : { version: 1, all: true },
299
+ );
300
+ expect(output).toHaveBeenCalledWith({
301
+ classification: "would_await_consent",
302
+ });
303
+ });
304
+ test.each([
305
+ [],
306
+ ["--all"],
307
+ ["--install", "dri_1", "--all"],
308
+ ["--install", "dri_1"],
309
+ ])("rollback invalid or unconfirmed %j makes no request", async (...args) => {
310
+ await run("rollback", "1", ...args);
311
+ expect(process.exitCode).toBe(1);
312
+ expect(post).not.toHaveBeenCalled();
313
+ });
314
+
315
+ test.each(["/%C3%A9", "/a%00", "/a%01", "/a%20b", "/a%5Bb%5D", "/a%7Cb"])(
316
+ "accepts Ruby URI path %s",
317
+ (p) => {
318
+ expect(
319
+ validateManifest(JSON.stringify({ ...valid, health: { path: p } }))
320
+ .errors,
321
+ ).toEqual([]);
322
+ },
323
+ );
324
+ test.each(["1.0.0", "0", "-1", "1.5", "9007199254740992", "1e2", "", " 1"])(
325
+ "rejects version %s",
326
+ async (version) => {
327
+ await run("rollback", "--install", "dri_1", "--", version);
328
+ expect(process.exitCode).toBe(1);
329
+ expect(post).not.toHaveBeenCalled();
330
+ expect(
331
+ JSON.stringify(jest.mocked(process.stderr.write).mock.calls),
332
+ ).toContain("positive safe integer");
333
+ },
334
+ );
335
+ test.each(
336
+ "-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(
337
+ " ",
338
+ ),
339
+ )("publisher transport %s", async (entry) => {
340
+ seed({ widening_atoms: [] });
341
+ await run("--base-url", entry.slice(1), "manifest", "diff", path);
342
+ expect(fetchMock).toHaveBeenCalledTimes(entry[0] === "+" ? 3 : 0);
343
+ expect(process.exitCode).toBe(entry[0] === "+" ? undefined : 1);
344
+ });
@@ -0,0 +1,113 @@
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
+
7
+ /** Public: Register local validation and Actions-only manifest publication commands. */
8
+ export function registerManifestCommands(
9
+ parent: Command,
10
+ ctx: CommandContext,
11
+ ): void {
12
+ const manifest = parent
13
+ .command("manifest")
14
+ .description(`Droplet manifests. ${AUTHORITY}`);
15
+ for (const verb of ["validate", "publish", "diff"]) {
16
+ const cmd = manifest
17
+ .command(`${verb} [path]`)
18
+ .description(
19
+ `${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}`,
20
+ )
21
+ .option("--json", "Print one JSON document");
22
+ if (verb === "publish")
23
+ cmd.option(
24
+ "--allow-widening",
25
+ "Permit widening when publishing; previews always report widening (exit 3)",
26
+ );
27
+ if (verb === "publish")
28
+ cmd.option("--dry-run", "Preview without publishing");
29
+ cmd.action(
30
+ async (
31
+ path: string | undefined,
32
+ opts: { json?: boolean; dryRun?: boolean; allowWidening?: boolean },
33
+ command: Command,
34
+ ) => {
35
+ const output = (data: Record<string, unknown>) =>
36
+ opts.json
37
+ ? process.stdout.write(JSON.stringify(data) + "\n")
38
+ : ctx.output(data);
39
+ try {
40
+ const file = path ?? "./fluid.droplet.json";
41
+ const limit = 256 * 1024;
42
+ const buffer = Buffer.alloc(limit + 1);
43
+ let length = 0;
44
+ const handle = await open(file, "r");
45
+ try {
46
+ while (length < buffer.length) {
47
+ const { bytesRead } = await handle.read(
48
+ buffer,
49
+ length,
50
+ buffer.length - length,
51
+ null,
52
+ );
53
+ if (bytesRead === 0) break;
54
+ length += bytesRead;
55
+ }
56
+ } finally {
57
+ await handle.close();
58
+ }
59
+ if (length > limit) {
60
+ const code = "manifest_too_large";
61
+ throw new ManifestError("manifest_invalid", 2, [
62
+ { pointer: "/", code, detail: code },
63
+ ]);
64
+ }
65
+ const bytes = buffer.subarray(0, length);
66
+ let text: string;
67
+ try {
68
+ text = new TextDecoder("utf-8", {
69
+ fatal: true,
70
+ ignoreBOM: true,
71
+ }).decode(bytes);
72
+ } catch {
73
+ throw new ManifestError("invalid_json", 2);
74
+ }
75
+ const result = validateManifest(text);
76
+ if (!result.manifest)
77
+ throw new ManifestError("manifest_invalid", 2, result.errors);
78
+ if (verb === "validate") {
79
+ output({ valid: true, note: AUTHORITY });
80
+ return;
81
+ }
82
+ const dryRun = verb === "diff" || Boolean(opts.dryRun);
83
+ const response = await publishManifest(
84
+ result.manifest,
85
+ command.optsWithGlobals().baseUrl ?? getFluidApiBase(),
86
+ dryRun,
87
+ Boolean(opts.allowWidening),
88
+ );
89
+ output(response);
90
+ if (
91
+ dryRun &&
92
+ Array.isArray(response.widening_atoms) &&
93
+ response.widening_atoms.length
94
+ )
95
+ process.exitCode = 3;
96
+ } catch (error) {
97
+ const failure =
98
+ error instanceof ManifestError
99
+ ? error
100
+ : new ManifestError("Unable to read or process manifest");
101
+ output({
102
+ error: {
103
+ code: failure.code,
104
+ ...(failure.details ? { details: failure.details } : {}),
105
+ },
106
+ note: AUTHORITY,
107
+ });
108
+ process.exitCode = failure.exitCode;
109
+ }
110
+ },
111
+ );
112
+ }
113
+ }
@@ -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
- // Hand-written commands on top of the generated OpenAPI surface:
19
- // `install`, `uninstall`, `installed` and `health`, over the v2026-10
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
+ }