@intentius/chant 0.110.0 → 0.112.0

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.
Files changed (59) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/op/activities/factory.d.ts.map +1 -1
  3. package/dist/op/steward.d.ts +1 -1
  4. package/dist/op/steward.d.ts.map +1 -1
  5. package/dist/workspace/box-cli.d.ts +3 -2
  6. package/dist/workspace/box-cli.d.ts.map +1 -1
  7. package/dist/workspace/box-factory-write.d.ts +71 -0
  8. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  9. package/dist/workspace/box-intent.d.ts +6 -0
  10. package/dist/workspace/box-intent.d.ts.map +1 -1
  11. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  12. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  13. package/dist/workspace/declaration-write.d.ts +78 -0
  14. package/dist/workspace/declaration-write.d.ts.map +1 -0
  15. package/dist/workspace/declaration.d.ts +8 -0
  16. package/dist/workspace/declaration.d.ts.map +1 -1
  17. package/dist/workspace/json-edit.d.ts +11 -0
  18. package/dist/workspace/json-edit.d.ts.map +1 -1
  19. package/dist/workspace/ls.d.ts +8 -0
  20. package/dist/workspace/ls.d.ts.map +1 -1
  21. package/dist/workspace/reason-codes.d.ts +4 -0
  22. package/dist/workspace/reason-codes.d.ts.map +1 -1
  23. package/dist/workspace/status-stewards.d.ts +4 -1
  24. package/dist/workspace/status-stewards.d.ts.map +1 -1
  25. package/dist/workspace/status.d.ts.map +1 -1
  26. package/dist/workspace/steward-index.d.ts +89 -0
  27. package/dist/workspace/steward-index.d.ts.map +1 -0
  28. package/dist/workspace/write-scope.d.ts +10 -0
  29. package/dist/workspace/write-scope.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/main.test.ts +2 -0
  32. package/src/cli/main.ts +18 -0
  33. package/src/op/activities/factory.ts +3 -1
  34. package/src/op/factory.e2e.test.ts +3 -1
  35. package/src/op/steward.ts +1 -1
  36. package/src/workspace/box-cli.ts +35 -4
  37. package/src/workspace/box-factory-write.schema.json +342 -0
  38. package/src/workspace/box-factory-write.test.ts +108 -0
  39. package/src/workspace/box-factory-write.ts +226 -0
  40. package/src/workspace/box-intent.test.ts +14 -4
  41. package/src/workspace/box-intent.ts +9 -1
  42. package/src/workspace/declaration-extensions.test.ts +79 -0
  43. package/src/workspace/declaration-write-cli.ts +82 -0
  44. package/src/workspace/declaration-write.test.ts +155 -0
  45. package/src/workspace/declaration-write.ts +275 -0
  46. package/src/workspace/declaration.ts +19 -1
  47. package/src/workspace/json-edit.ts +71 -0
  48. package/src/workspace/ls-contract.test.ts +53 -0
  49. package/src/workspace/ls.schema.json +11 -0
  50. package/src/workspace/ls.ts +18 -0
  51. package/src/workspace/member-write.schema.json +193 -0
  52. package/src/workspace/reason-codes.test.ts +4 -0
  53. package/src/workspace/reason-codes.ts +6 -0
  54. package/src/workspace/status-stewards.ts +23 -35
  55. package/src/workspace/status.schema.json +10 -1
  56. package/src/workspace/status.ts +25 -6
  57. package/src/workspace/steward-index.test.ts +171 -0
  58. package/src/workspace/steward-index.ts +226 -0
  59. package/src/workspace/write-scope.ts +17 -0
@@ -0,0 +1,342 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json",
4
+ "title": "chant workspace box factory set output",
5
+ "description": "What `chant workspace box factory set <member> --from <file|->` prints (#3600): the box's factory before and after, as `status --json` prints it, and the file the write changed, or the reason it wrote nothing. The write changes only the factory's own top-level properties, members[i].box.factory in the declaration, in place, keeping the file's formatting and comments. It never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.110.0 and newer.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "description": "The factory was written, or would be with --dry-run, or already held what was given (changed false). Exit code 0.",
17
+ "type": "object",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "chant",
22
+ "member",
23
+ "declaration",
24
+ "paths",
25
+ "changed",
26
+ "dryRun",
27
+ "previous",
28
+ "factory"
29
+ ],
30
+ "properties": {
31
+ "$schema": {
32
+ "const": "https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json"
33
+ },
34
+ "contract": {
35
+ "const": 1
36
+ },
37
+ "chant": {
38
+ "type": "string",
39
+ "description": "The chant that wrote it."
40
+ },
41
+ "member": {
42
+ "type": "string",
43
+ "description": "The member whose box block holds the factory."
44
+ },
45
+ "declaration": {
46
+ "type": "object",
47
+ "required": [
48
+ "path",
49
+ "sha256"
50
+ ],
51
+ "properties": {
52
+ "path": {
53
+ "type": "string",
54
+ "description": "The declaration file, relative to the repository root (the working directory outside git), with / separators."
55
+ },
56
+ "sha256": {
57
+ "type": "string",
58
+ "pattern": "^[0-9a-f]{64}$",
59
+ "description": "The sha256 of the declaration's bytes after the write, or as they would be with --dry-run."
60
+ }
61
+ }
62
+ },
63
+ "paths": {
64
+ "type": "array",
65
+ "items": {
66
+ "type": "string"
67
+ },
68
+ "description": "The declaration, relative to the repository root, when the write changed it or would with --dry-run; empty when the factory already held what was given."
69
+ },
70
+ "changed": {
71
+ "type": "boolean",
72
+ "description": "Whether the declaration changed: paths is not empty."
73
+ },
74
+ "dryRun": {
75
+ "type": "boolean",
76
+ "description": "True when --dry-run was given: nothing was written, and the document says what would have been."
77
+ },
78
+ "previous": {
79
+ "oneOf": [
80
+ {
81
+ "type": "null"
82
+ },
83
+ {
84
+ "$ref": "#/$defs/factory"
85
+ }
86
+ ],
87
+ "description": "The factory before the write, as status --json prints it, or null when the box declared none."
88
+ },
89
+ "factory": {
90
+ "oneOf": [
91
+ {
92
+ "type": "null"
93
+ },
94
+ {
95
+ "$ref": "#/$defs/factory"
96
+ }
97
+ ],
98
+ "description": "The factory after the write, as status --json prints it, or null when the box still declares none."
99
+ }
100
+ }
101
+ },
102
+ "factory": {
103
+ "type": "object",
104
+ "required": [
105
+ "builds",
106
+ "check",
107
+ "checks",
108
+ "builders",
109
+ "tiers",
110
+ "builderFor",
111
+ "publish"
112
+ ],
113
+ "properties": {
114
+ "builds": {
115
+ "type": "array",
116
+ "minItems": 1,
117
+ "items": {
118
+ "type": "string"
119
+ },
120
+ "description": "The members the factory builds, in declaration order. A work item a person asks for constrains member:<the first>."
121
+ },
122
+ "check": {
123
+ "description": "The verdict command, run from the workspace root, and what it is; null when none is declared.",
124
+ "oneOf": [
125
+ {
126
+ "type": "null"
127
+ },
128
+ {
129
+ "type": "object",
130
+ "required": [
131
+ "run",
132
+ "kind"
133
+ ],
134
+ "properties": {
135
+ "run": {
136
+ "type": "string"
137
+ },
138
+ "kind": {
139
+ "enum": [
140
+ "test",
141
+ "build",
142
+ "lint",
143
+ "plan",
144
+ "conformance"
145
+ ]
146
+ }
147
+ }
148
+ }
149
+ ]
150
+ },
151
+ "checks": {
152
+ "type": [
153
+ "string",
154
+ "null"
155
+ ],
156
+ "description": "Where a work item's builder writes its check, from the workspace root, or null for the orchestrator's choice."
157
+ },
158
+ "builders": {
159
+ "type": [
160
+ "string",
161
+ "null"
162
+ ],
163
+ "description": "The member that declares the builder agents, or null."
164
+ },
165
+ "tiers": {
166
+ "type": "array",
167
+ "items": {
168
+ "type": "object",
169
+ "required": [
170
+ "tier",
171
+ "agent",
172
+ "kinds",
173
+ "session"
174
+ ],
175
+ "properties": {
176
+ "tier": {
177
+ "type": "string"
178
+ },
179
+ "agent": {
180
+ "type": "string"
181
+ },
182
+ "kinds": {
183
+ "oneOf": [
184
+ {
185
+ "type": "null"
186
+ },
187
+ {
188
+ "type": "array",
189
+ "items": {
190
+ "type": "string"
191
+ }
192
+ }
193
+ ]
194
+ },
195
+ "session": {
196
+ "type": [
197
+ "string",
198
+ "null"
199
+ ]
200
+ }
201
+ }
202
+ },
203
+ "description": "Which builder agent builds at which tier, as declared (#3152, ws-094), each { tier, agent, kinds, session } with kinds and session null when not declared; empty when none is. Added in contract 1 by chant 0.103.0."
204
+ },
205
+ "builderFor": {
206
+ "type": "object",
207
+ "additionalProperties": {
208
+ "type": "object",
209
+ "additionalProperties": {
210
+ "type": "object",
211
+ "required": [
212
+ "agent",
213
+ "session"
214
+ ],
215
+ "properties": {
216
+ "agent": {
217
+ "type": "string"
218
+ },
219
+ "session": {
220
+ "type": [
221
+ "string",
222
+ "null"
223
+ ]
224
+ }
225
+ }
226
+ }
227
+ },
228
+ "description": "The builder for each member the factory builds, by tier (#3152): member name, then tier, then { agent, session }. For a member the entry naming its kind is used, else the tier's entry with no kinds; a tier with neither is left out. Added in contract 1 by chant 0.103.0."
229
+ },
230
+ "publish": {
231
+ "description": "Where a finished build is published, or null when the factory doesn't publish past the box.",
232
+ "oneOf": [
233
+ {
234
+ "type": "null"
235
+ },
236
+ {
237
+ "type": "object",
238
+ "required": [
239
+ "forge",
240
+ "repo",
241
+ "base",
242
+ "branchPrefix",
243
+ "head"
244
+ ],
245
+ "properties": {
246
+ "forge": {
247
+ "const": "github"
248
+ },
249
+ "repo": {
250
+ "type": "string",
251
+ "description": "owner/name: where the pull request is opened."
252
+ },
253
+ "base": {
254
+ "type": [
255
+ "string",
256
+ "null"
257
+ ],
258
+ "description": "The branch the pull request targets, or null for the repository's default branch."
259
+ },
260
+ "branchPrefix": {
261
+ "type": "string",
262
+ "description": "What the published branch's name starts with, before the work item's id. chant/work/ unless declared."
263
+ },
264
+ "head": {
265
+ "type": [
266
+ "string",
267
+ "null"
268
+ ],
269
+ "description": "owner/name of the fork the branch is pushed to, or null to push to repo."
270
+ }
271
+ }
272
+ }
273
+ ]
274
+ }
275
+ }
276
+ },
277
+ "failure": {
278
+ "type": "object",
279
+ "description": "Nothing was written. Exit code 1.",
280
+ "required": [
281
+ "$schema",
282
+ "contract",
283
+ "chant",
284
+ "member",
285
+ "error"
286
+ ],
287
+ "properties": {
288
+ "$schema": {
289
+ "const": "https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json"
290
+ },
291
+ "contract": {
292
+ "const": 1
293
+ },
294
+ "chant": {
295
+ "type": "string"
296
+ },
297
+ "member": {
298
+ "type": [
299
+ "string",
300
+ "null"
301
+ ]
302
+ },
303
+ "error": {
304
+ "type": "object",
305
+ "required": [
306
+ "code",
307
+ "message"
308
+ ],
309
+ "properties": {
310
+ "code": {
311
+ "enum": [
312
+ "declaration-missing",
313
+ "declaration-ambiguous",
314
+ "declaration-unparseable",
315
+ "declaration-invalid",
316
+ "placement-invalid",
317
+ "reader-too-old",
318
+ "root-chant-required",
319
+ "not-a-git-repository",
320
+ "revision-unknown",
321
+ "write-usage-invalid",
322
+ "write-input-invalid",
323
+ "factory-member-unknown",
324
+ "factory-box-missing",
325
+ "write-scope-member",
326
+ "write-scope-protected",
327
+ "write-scope-class-unknown",
328
+ "agent-unknown",
329
+ "principal-unidentified",
330
+ "write-lock-timeout",
331
+ "write-lock-not-held"
332
+ ]
333
+ },
334
+ "message": {
335
+ "type": "string"
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * #3600: a box's factory written through chant, so a box planted from a
3
+ * shared template gets its factory.publish target at planting.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, contract, git, repo } from "./__fixtures__/contract-repo";
10
+ import { boxFactorySet, type BoxFactoryWriteDocument, type BoxFactoryWriteRequest } from "./box-factory-write";
11
+ import schema from "./box-factory-write.schema.json";
12
+ import { workspaceStatus } from "./status";
13
+
14
+ afterAll(cleanScratch);
15
+
16
+ const { expectValid } = contract(schema);
17
+
18
+ /** The template's declaration: a box whose factory builds the app and names no publish target. */
19
+ const TEMPLATE = `{
20
+ // planted from the studio template
21
+ "name": "fern",
22
+ "schema": 1,
23
+ "members": [
24
+ { "name": "app", "dir": "app", "kind": "other", "because": "the app" },
25
+ {
26
+ "name": "box",
27
+ "dir": "box",
28
+ "kind": "other",
29
+ "because": "the box",
30
+ "box": {
31
+ "factory": { "builds": ["app"], "check": "npm test" }, // no publish: the template can't know the repo
32
+ "publisher": "node box/publish.mjs",
33
+ },
34
+ },
35
+ ],
36
+ }
37
+ `;
38
+
39
+ function run(root: string, req: Omit<BoxFactoryWriteRequest, "cwd" | "fields"> & { fields?: unknown }): BoxFactoryWriteDocument {
40
+ const doc = boxFactorySet({ ...req, cwd: root, fields: req.fields === undefined ? undefined : JSON.stringify(req.fields) });
41
+ expectValid(doc);
42
+ return doc;
43
+ }
44
+
45
+ const code = (doc: BoxFactoryWriteDocument) => ("error" in doc ? doc.error.code : null);
46
+
47
+ describe("box factory set (#3600)", () => {
48
+ test("a planter sets factory.publish, status reads it back, and the file keeps its comments", async () => {
49
+ const root = repo({ "chant.workspace.jsonc": TEMPLATE, "app/a.txt": "", "box/b.txt": "" });
50
+ const doc = run(root, { member: "box", fields: { publish: { repo: "alex/fern", base: "main" } } });
51
+ expect(doc).toMatchObject({
52
+ member: "box",
53
+ changed: true,
54
+ paths: ["chant.workspace.jsonc"],
55
+ previous: { builds: ["app"], publish: null },
56
+ factory: { builds: ["app"], check: { run: "npm test", kind: "test" }, publish: { forge: "github", repo: "alex/fern", base: "main", branchPrefix: "chant/work/", head: null } },
57
+ });
58
+ const text = readFileSync(join(root, "chant.workspace.jsonc"), "utf-8");
59
+ expect(text).toContain("// no publish: the template can't know the repo");
60
+ expect(text).toContain("// planted from the studio template");
61
+ const status = await workspaceStatus({ cwd: root, env: "box" });
62
+ if ("error" in status) throw new Error(status.error.message);
63
+ expect(status.members.find((m) => m.name === "box")?.box?.factory?.publish?.repo).toBe("alex/fern");
64
+
65
+ // The same again changes nothing; null takes a field out.
66
+ expect(run(root, { member: "box", fields: { publish: { repo: "alex/fern", base: "main" } } })).toMatchObject({ changed: false, paths: [] });
67
+ expect(run(root, { member: "box", fields: { publish: null } })).toMatchObject({ changed: true, factory: { publish: null } });
68
+ });
69
+
70
+ test("a dry run writes nothing, and a box with no factory gets one", () => {
71
+ const root = repo({
72
+ "chant.workspace.json": `${JSON.stringify({ name: "w", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "b", box: {} }] }, null, 2)}\n`,
73
+ "app/a.txt": "",
74
+ });
75
+ const before = readFileSync(join(root, "chant.workspace.json"), "utf-8");
76
+ expect(run(root, { member: "app", fields: { builds: ["app"], publish: { repo: "o/r" } }, dryRun: true })).toMatchObject({ changed: true, dryRun: true, previous: null });
77
+ expect(readFileSync(join(root, "chant.workspace.json"), "utf-8")).toBe(before);
78
+ expect(run(root, { member: "app", fields: { builds: ["app"], publish: { repo: "o/r" } } })).toMatchObject({ factory: { builds: ["app"], publish: { repo: "o/r" } } });
79
+ });
80
+
81
+ test("refuses an unknown member, a member with no box, a field a factory doesn't take, and a repo that isn't owner/name", () => {
82
+ const root = repo({ "chant.workspace.jsonc": TEMPLATE, "app/a.txt": "", "box/b.txt": "" });
83
+ const before = readFileSync(join(root, "chant.workspace.jsonc"), "utf-8");
84
+ expect(code(run(root, { member: "ghost", fields: { publish: { repo: "o/r" } } }))).toBe("factory-member-unknown");
85
+ expect(code(run(root, { member: "app", fields: { publish: { repo: "o/r" } } }))).toBe("factory-box-missing");
86
+ expect(code(run(root, { member: "box", fields: { listing: {} } }))).toBe("write-input-invalid");
87
+ const bad = run(root, { member: "box", fields: { publish: { repo: "{{chant:repo}}" } } });
88
+ expect(code(bad)).toBe("write-input-invalid");
89
+ expect(code(run(root, { member: "box" }))).toBe("write-usage-invalid");
90
+ expect(readFileSync(join(root, "chant.workspace.jsonc"), "utf-8")).toBe(before);
91
+ });
92
+
93
+ test("a protected declaration takes the write when its except names the publish target", () => {
94
+ const decl = (except?: string[]) => ({
95
+ name: "w",
96
+ schema: 1,
97
+ members: [{ name: "app", dir: ".", kind: "other", because: "b", box: { factory: { builds: ["app"] } } }],
98
+ writeScope: { human: { protected: [except ? { path: "chant.workspace.json", except } : "chant.workspace.json"] } },
99
+ });
100
+ const root = repo({ "chant.workspace.json": `${JSON.stringify(decl(), null, 2)}\n` }, true);
101
+ git(root, "branch", "-M", "main");
102
+ expect(code(run(root, { member: "app", fields: { publish: { repo: "o/r" } } }))).toBe("write-scope-protected");
103
+ writeFileSync(join(root, "chant.workspace.json"), `${JSON.stringify(decl(["/members/*/box/factory/publish"]), null, 2)}\n`);
104
+ git(root, "commit", "-q", "-am", "planting may set the publish target");
105
+ expect(run(root, { member: "app", fields: { publish: { repo: "o/r" } } })).toMatchObject({ changed: true });
106
+ expect(code(run(root, { member: "app", fields: { "x-note": 1 } }))).toBe("write-scope-protected");
107
+ });
108
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `chant workspace box factory set <member>` (#3600): change a box's
3
+ * factory, `members[i].box.factory` in the declaration (#3146, ws-077),
4
+ * through chant.
5
+ *
6
+ * A box planted from a shared template can't carry where it publishes:
7
+ * `factory.publish.repo` must be a real owner/name, so the template leaves
8
+ * it out and whoever plants the box, which knows the box's repo, sets it
9
+ * here. The write is the factory's sibling of `box listing set` (#3308): it
10
+ * changes only the factory's own top-level properties, in place, so the file
11
+ * keeps its formatting, key order and comments; it validates the declaration
12
+ * it would write, judges the declaration by the write scope at base (so a
13
+ * protected declaration takes it when its `except` names
14
+ * `/members/*\/box/factory/publish`), refuses a bare `--by` name under
15
+ * `identity.attribution: "identified"`, holds the working tree's write lock,
16
+ * prints one JSON document (`box-factory-write.schema.json`) and never
17
+ * commits.
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, relative, sep } from "node:path";
23
+ import { isDeepStrictEqual } from "node:util";
24
+ import { factoryView, type FactoryView } from "./box-factory";
25
+ import { parseDeclaration, readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
26
+ import { IDENTITY_CODES, IdentityError, refuseUnidentified } from "./identity";
27
+ import { removeProperty, setProperty, valueAt } from "./json-edit";
28
+ import { parseJsonText } from "./jsonc";
29
+ import type { ReasonCode } from "./reason-codes";
30
+ import { locateWorkspace } from "./which-chant";
31
+ import { judgePath, resolveWriter, scopeSource, unknownClassVerdict, WriteScopeError } from "./write-scope";
32
+ import { withWriteLockSync, WriteLockError, WRITE_LOCK_CODES } from "./write-lock";
33
+
34
+ /** The version of the document this write prints. */
35
+ export const BOX_FACTORY_CONTRACT_VERSION = 1;
36
+
37
+ /** `$id` of the JSON Schema for what `box factory set` prints, shipped beside this file. */
38
+ export const BOX_FACTORY_WRITE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json";
39
+
40
+ /** Why a factory write wrote nothing. Closed. */
41
+ export const BOX_FACTORY_ERROR_CODES = [
42
+ ...WORKSPACE_ERROR_CODES,
43
+ "write-usage-invalid",
44
+ "write-input-invalid",
45
+ "factory-member-unknown",
46
+ "factory-box-missing",
47
+ "write-scope-member",
48
+ "write-scope-protected",
49
+ "write-scope-class-unknown",
50
+ "agent-unknown",
51
+ ...IDENTITY_CODES,
52
+ ...WRITE_LOCK_CODES,
53
+ ] as const satisfies readonly ReasonCode[];
54
+ export type BoxFactoryErrorCode = (typeof BOX_FACTORY_ERROR_CODES)[number];
55
+
56
+ /** The factory properties a write may set. Any `x-` key is passed through as well. */
57
+ export const FACTORY_FIELDS = ["builds", "check", "checks", "builders", "tiers", "publish"] as const;
58
+
59
+ type Head = { $schema: string; contract: number; chant: string };
60
+
61
+ /** What `box factory set` prints. */
62
+ export type BoxFactoryWriteDocument =
63
+ | (Head & {
64
+ member: string;
65
+ /** The declaration file, from the repository root, and the sha256 of its bytes after the write (or as it would be, with dryRun). */
66
+ declaration: { path: string; sha256: string };
67
+ /** The declaration, from the repository root, when the write changed it; else empty. */
68
+ paths: string[];
69
+ changed: boolean;
70
+ dryRun: boolean;
71
+ /** The factory before the write, as status --json prints it, or null when the box declared none. */
72
+ previous: FactoryView | null;
73
+ /** The factory after the write, as status --json prints it, or null when the write took every field out. */
74
+ factory: FactoryView | null;
75
+ })
76
+ | (Head & { member: string | null; error: { code: BoxFactoryErrorCode; message: string } });
77
+
78
+ export interface BoxFactoryWriteRequest {
79
+ cwd: string;
80
+ member: string;
81
+ /** The factory fields as JSON text (`--from`). */
82
+ fields?: string;
83
+ /** Who is writing (`--by`). */
84
+ by?: string;
85
+ /** The agent session (`CHANT_AGENT`). */
86
+ agent?: string;
87
+ dryRun?: boolean;
88
+ }
89
+
90
+ class FactoryWriteError extends Error {
91
+ constructor(
92
+ readonly code: BoxFactoryErrorCode,
93
+ message: string,
94
+ ) {
95
+ super(message);
96
+ }
97
+ }
98
+
99
+ const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
100
+ const sha256 = (b: string) => createHash("sha256").update(b).digest("hex");
101
+ const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
102
+
103
+ /** The factory fields given with --from: a JSON object of the factory's fields and x- keys; null for a key takes it out. */
104
+ function readFields(text: string | undefined): Record<string, unknown> {
105
+ if (text === undefined || text.trim() === "") throw new FactoryWriteError("write-usage-invalid", "box factory set needs the factory fields: --from <file|->, such as {\"publish\": {\"repo\": \"owner/name\"}}");
106
+ let value: unknown;
107
+ try {
108
+ value = JSON.parse(text);
109
+ } catch (err) {
110
+ throw new FactoryWriteError("write-input-invalid", `the factory fields are not JSON: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ if (!isObject(value)) throw new FactoryWriteError("write-input-invalid", "the factory fields are a JSON object, such as {\"publish\": {\"repo\": \"owner/name\"}}");
113
+ for (const k of Object.keys(value)) {
114
+ if (k.startsWith("x-") || (FACTORY_FIELDS as readonly string[]).includes(k)) continue;
115
+ throw new FactoryWriteError("write-input-invalid", `${k} is not a factory field; a factory takes ${FACTORY_FIELDS.join(", ")} and x- keys`);
116
+ }
117
+ return value;
118
+ }
119
+
120
+ /** Write `text` to `file` through a sibling temporary file, so a reader never sees half of it. */
121
+ function writeAtomically(file: string, text: string): void {
122
+ mkdirSync(dirname(file), { recursive: true });
123
+ const tmp = `${file}.chant-${process.pid}.tmp`;
124
+ writeFileSync(tmp, text);
125
+ renameSync(tmp, file);
126
+ }
127
+
128
+ /** Run the write and build the document it prints. Never throws for a refusal. */
129
+ export function boxFactorySet(req: BoxFactoryWriteRequest): BoxFactoryWriteDocument {
130
+ const head: Head = { $schema: BOX_FACTORY_WRITE_SCHEMA_ID, contract: BOX_FACTORY_CONTRACT_VERSION, chant: readerVersion() };
131
+ try {
132
+ return withWriteLockSync(req.cwd, { verb: "box factory set", by: req.by ?? null, agent: req.agent || null }, req.dryRun, () => write(req, head));
133
+ } catch (err) {
134
+ const fail = (code: BoxFactoryErrorCode, message: string): BoxFactoryWriteDocument => ({ ...head, member: req.member || null, error: { code, message } });
135
+ if (err instanceof FactoryWriteError) return fail(err.code, err.message);
136
+ if (err instanceof WriteScopeError) return fail(err.code as BoxFactoryErrorCode, err.message);
137
+ if (err instanceof IdentityError) return fail(err.code, err.message);
138
+ if (err instanceof WriteLockError) return fail(err.code, err.message);
139
+ if (err instanceof WorkspaceReadError) return fail(err.code as BoxFactoryErrorCode, err.describe());
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ function write(req: BoxFactoryWriteRequest, head: Head): BoxFactoryWriteDocument {
145
+ if (!req.member) throw new FactoryWriteError("write-usage-invalid", "box factory set needs the member whose box builds: box factory set <member>");
146
+ const fields = readFields(req.fields);
147
+
148
+ const located = locateWorkspace(req.cwd);
149
+ const declaration = readDeclaration(located.tree);
150
+ const member = declaration.members.find((m) => m.name === req.member);
151
+ if (!member) {
152
+ throw new FactoryWriteError("factory-member-unknown", `no member is named ${req.member}; the declaration's members are ${declaration.members.map((m) => m.name).join(", ") || "none"}`);
153
+ }
154
+ if (!member.box) throw new FactoryWriteError("factory-box-missing", `member ${member.name} declares no box block, so it has no factory; a factory is what a box builds (#3146)`);
155
+
156
+ const repoBase = located.top ?? located.rootOnDisk;
157
+ const declPath = toPosix(relative(repoBase, join(located.rootOnDisk, declaration.file)));
158
+ const declFile = join(located.rootOnDisk, declaration.file);
159
+ const before = readFileSync(declFile, "utf-8");
160
+ const jsonc = declaration.file.endsWith(".jsonc");
161
+
162
+ // The declaration, with only the factory's own properties changed.
163
+ const boxPointer = member.box.pointer;
164
+ const factoryPointer = `${boxPointer}/factory`;
165
+ const parsed = parseJsonText(before, { jsonc });
166
+ if (!parsed.ok) throw new WorkspaceReadError("declaration-unparseable", parsed.message);
167
+ const raw = valueAt(parsed.value, factoryPointer);
168
+ let after = before;
169
+ const expected = structuredClone(parsed.value) as Record<string, unknown>;
170
+ const expectedBox = valueAt(expected, boxPointer) as Record<string, unknown>;
171
+ if (raw === undefined) {
172
+ const value = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== null));
173
+ if (Object.keys(value).length > 0) {
174
+ after = setProperty(after, jsonc, boxPointer, "factory", value);
175
+ expectedBox.factory = value;
176
+ }
177
+ } else {
178
+ const expectedFactory = expectedBox.factory as Record<string, unknown>;
179
+ for (const [k, v] of Object.entries(fields)) {
180
+ if (v === null) {
181
+ after = removeProperty(after, jsonc, factoryPointer, k);
182
+ delete expectedFactory[k];
183
+ } else if (!isDeepStrictEqual(expectedFactory[k], v)) {
184
+ after = setProperty(after, jsonc, factoryPointer, k, v);
185
+ expectedFactory[k] = v;
186
+ }
187
+ }
188
+ }
189
+ const reread = parseJsonText(after, { jsonc });
190
+ if (!reread.ok || !isDeepStrictEqual(reread.value, expected)) {
191
+ // An edit that does not read back as the value meant is never written.
192
+ throw new Error(`box factory set: editing ${declaration.file} in place did not give the declaration intended; nothing was written`);
193
+ }
194
+ let next: Declaration;
195
+ try {
196
+ next = parseDeclaration(after, declaration.file);
197
+ } catch (err) {
198
+ if (err instanceof WorkspaceReadError) throw new FactoryWriteError("write-input-invalid", `the factory would make the declaration invalid: ${err.describe()}`);
199
+ throw err;
200
+ }
201
+ const nextMember = next.members.find((m) => m.name === member.name)!;
202
+
203
+ // The write scope at base, and who --by names.
204
+ const source = scopeSource(req.cwd);
205
+ if (req.by !== undefined) refuseUnidentified(source, [req.by], "--by", { agent: req.agent });
206
+ if (after !== before && (source.declaration !== null || (req.agent !== undefined && req.agent !== ""))) {
207
+ const writer = resolveWriter(source.declaration, source.policy, { agent: req.agent || null, principal: req.by ?? null }, source.classes);
208
+ const unknown = unknownClassVerdict(source.declaration, writer, source.classes);
209
+ if (!unknown.ok) throw new WriteScopeError(unknown.code, unknown.message);
210
+ const verdict = judgePath(source.declaration, writer, declaration.file, { before: () => before, after: () => after });
211
+ if (!verdict.ok) throw new FactoryWriteError(verdict.code as BoxFactoryErrorCode, verdict.message);
212
+ }
213
+
214
+ const changed = after !== before;
215
+ if (changed && !req.dryRun) writeAtomically(declFile, after);
216
+ return {
217
+ ...head,
218
+ member: member.name,
219
+ declaration: { path: declPath, sha256: sha256(after) },
220
+ paths: changed ? [declPath] : [],
221
+ changed,
222
+ dryRun: req.dryRun === true,
223
+ previous: factoryView(member.box.factory, declaration.members),
224
+ factory: factoryView(nextMember.box!.factory, next.members),
225
+ };
226
+ }