@intentius/chant 0.110.0 → 0.111.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 (49) 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/workspace/box-cli.d.ts +3 -2
  4. package/dist/workspace/box-cli.d.ts.map +1 -1
  5. package/dist/workspace/box-factory-write.d.ts +71 -0
  6. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  7. package/dist/workspace/box-intent.d.ts +6 -0
  8. package/dist/workspace/box-intent.d.ts.map +1 -1
  9. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  10. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  11. package/dist/workspace/declaration-write.d.ts +78 -0
  12. package/dist/workspace/declaration-write.d.ts.map +1 -0
  13. package/dist/workspace/declaration.d.ts +8 -0
  14. package/dist/workspace/declaration.d.ts.map +1 -1
  15. package/dist/workspace/json-edit.d.ts +11 -0
  16. package/dist/workspace/json-edit.d.ts.map +1 -1
  17. package/dist/workspace/ls.d.ts +8 -0
  18. package/dist/workspace/ls.d.ts.map +1 -1
  19. package/dist/workspace/reason-codes.d.ts +4 -0
  20. package/dist/workspace/reason-codes.d.ts.map +1 -1
  21. package/dist/workspace/status.d.ts.map +1 -1
  22. package/dist/workspace/write-scope.d.ts +10 -0
  23. package/dist/workspace/write-scope.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/main.test.ts +2 -0
  26. package/src/cli/main.ts +18 -0
  27. package/src/op/activities/factory.ts +3 -1
  28. package/src/op/factory.e2e.test.ts +3 -1
  29. package/src/workspace/box-cli.ts +35 -4
  30. package/src/workspace/box-factory-write.schema.json +342 -0
  31. package/src/workspace/box-factory-write.test.ts +108 -0
  32. package/src/workspace/box-factory-write.ts +226 -0
  33. package/src/workspace/box-intent.test.ts +14 -4
  34. package/src/workspace/box-intent.ts +9 -1
  35. package/src/workspace/declaration-extensions.test.ts +79 -0
  36. package/src/workspace/declaration-write-cli.ts +82 -0
  37. package/src/workspace/declaration-write.test.ts +155 -0
  38. package/src/workspace/declaration-write.ts +275 -0
  39. package/src/workspace/declaration.ts +19 -1
  40. package/src/workspace/json-edit.ts +71 -0
  41. package/src/workspace/ls-contract.test.ts +53 -0
  42. package/src/workspace/ls.schema.json +11 -0
  43. package/src/workspace/ls.ts +18 -0
  44. package/src/workspace/member-write.schema.json +193 -0
  45. package/src/workspace/reason-codes.test.ts +4 -0
  46. package/src/workspace/reason-codes.ts +6 -0
  47. package/src/workspace/status.schema.json +10 -1
  48. package/src/workspace/status.ts +25 -6
  49. package/src/workspace/write-scope.ts +17 -0
@@ -184,7 +184,7 @@ describe("the factory Op's seams for an orchestrator (studio#382)", () => {
184
184
  // A builder that reports a run and a check that reports per criterion.
185
185
  writeFileSync(join(dir, "hooks", "builder2.cjs"), `${BUILDER}\nconsole.log(JSON.stringify({ ok: true, run: { id: "run-7" }, agent: "builder-small", chantAgent: "factory", records: ["contract:C-1"] }));\n`);
186
186
  writeFileSync(join(dir, "hooks", "check2.cjs"), `console.log(JSON.stringify({ criteria: { "AC-1": "pass", "AC-2": "fail" }, built: JSON.parse(process.env.FACTORY_BUILD).agent }));`);
187
- writeFileSync(join(dir, "hooks", "after.cjs"), `require("node:fs").writeFileSync(require("node:path").join(process.env.FACTORY_FAIL_DIR, "after.json"), JSON.stringify({ outcome: process.env.FACTORY_OUTCOME, commit: process.env.FACTORY_COMMIT, reason: process.env.FACTORY_REASON, check: JSON.parse(process.env.FACTORY_CHECK) }));`);
187
+ writeFileSync(join(dir, "hooks", "after.cjs"), `require("node:fs").writeFileSync(require("node:path").join(process.env.FACTORY_FAIL_DIR, "after.json"), JSON.stringify({ outcome: process.env.FACTORY_OUTCOME, commit: process.env.FACTORY_COMMIT, reason: process.env.FACTORY_REASON, check: JSON.parse(process.env.FACTORY_CHECK), activity: process.env.CHANT_RUN_ACTIVITY ?? null }));`);
188
188
  // The prepare hook writes W-110 in the checkout and leaves it uncommitted; the others are taken out of the way.
189
189
  writeFileSync(
190
190
  join(dir, "hooks", "prepare.cjs"),
@@ -204,6 +204,8 @@ describe("the factory Op's seams for an orchestrator (studio#382)", () => {
204
204
  expect(result.workLease).toMatchObject({ item: "W-110", outcome: "not_done" });
205
205
  const after = JSON.parse(readFileSync(join(dir, "after.json"), "utf-8"));
206
206
  expect(after).toMatchObject({ outcome: "not_done", reason: "criteria AC-2 have no passing evidence", check: { built: "builder-small" } });
207
+ // A hook gets the run's activity file, as a shell step does (#3637).
208
+ expect(after.activity).toMatch(/\.activity\.jsonl$/);
207
209
  // The attempt was committed with why, and the run kept that commit.
208
210
  expect(after.commit).toMatch(/^[0-9a-f]{40}$/);
209
211
  // The kept attempt holds the carried item with AC-1 ticked and AC-2 failed.
@@ -2,8 +2,9 @@
2
2
  * `chant workspace box`: a member's box block. Two verbs:
3
3
  *
4
4
  * - `box listing set <member>` (#3308) changes the box's listing
5
- * (`box-listing.ts`). A box's other configuration is changed by editing the
6
- * declaration in a reviewed change.
5
+ * (`box-listing.ts`), and `box factory set <member>` (#3600) its factory,
6
+ * such as where it publishes (`box-factory-write.ts`). A box's other
7
+ * configuration is changed by editing the declaration in a reviewed change.
7
8
  * - `box publish <member> <item> | --records` (#3165, ws-088) runs the
8
9
  * publisher the box block names and checks what it did (`box-publish.ts`).
9
10
  */
@@ -12,12 +13,14 @@ import { readFileSync } from "node:fs";
12
13
  import { resolve } from "node:path";
13
14
  import { formatError } from "../cli/format";
14
15
  import type { CommandContext } from "../cli/registry";
16
+ import { BOX_FACTORY_CONTRACT_VERSION, BOX_FACTORY_WRITE_SCHEMA_ID, boxFactorySet, type BoxFactoryWriteDocument } from "./box-factory-write";
15
17
  import { BOX_LISTING_CONTRACT_VERSION, BOX_LISTING_WRITE_SCHEMA_ID, boxListingSet, type BoxListingWriteDocument } from "./box-listing";
16
18
  import { BOX_PUBLISH_CONTRACT_VERSION, BOX_PUBLISH_SCHEMA_ID, boxPublish, type BoxPublishDocument } from "./box-publish";
17
19
  import { readerVersion } from "./declaration";
18
20
  import { AGENT_ENV } from "./write-scope";
19
21
 
20
22
  const USAGE = "chant workspace box listing set <member> [--from <file|->] [--cover <image> [--cover-path <path>]] [--by <principal>] [--dry-run] [--json]";
23
+ const FACTORY_USAGE = "chant workspace box factory set <member> --from <file|-> [--by <principal>] [--dry-run] [--json]";
21
24
  const PUBLISH_USAGE = "chant workspace box publish <member> (<item> | --records) [--by <principal>] [--head <owner/name>] [--dry-run] [--json]";
22
25
 
23
26
  export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
@@ -27,11 +30,13 @@ export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
27
30
  const [, noun, verb, member, ...extra] = args.positionals ?? [];
28
31
  // For publish, the third positional is the member and the fourth the item.
29
32
  if (noun === "publish") return runPublish(ctx, verb, member === undefined ? [] : [member, ...extra]);
33
+ if (noun === "factory" && verb === "set") return runFactorySet(ctx, member, extra);
30
34
  if (noun !== "listing" || verb !== "set") {
35
+ const verbs = "listing set <member>, factory set <member> or publish <member>";
31
36
  console.error(
32
37
  formatError({
33
- message: noun === undefined ? "chant workspace box takes listing set <member> or publish <member>" : `chant workspace box takes listing set <member> or publish <member>, not ${[noun, verb].filter(Boolean).join(" ")}`,
34
- hint: `${USAGE}\n${PUBLISH_USAGE}`,
38
+ message: noun === undefined ? `chant workspace box takes ${verbs}` : `chant workspace box takes ${verbs}, not ${[noun, verb].filter(Boolean).join(" ")}`,
39
+ hint: `${USAGE}\n${FACTORY_USAGE}\n${PUBLISH_USAGE}`,
35
40
  }),
36
41
  );
37
42
  return 1;
@@ -75,6 +80,32 @@ export async function runWorkspaceBox(ctx: CommandContext): Promise<number> {
75
80
  );
76
81
  }
77
82
 
83
+ /** `box factory set <member> --from <file|->` (#3600). */
84
+ function runFactorySet(ctx: CommandContext, member: string | undefined, extra: string[]): number {
85
+ const { args } = ctx;
86
+ const cwd = process.cwd();
87
+ const print = (doc: BoxFactoryWriteDocument): number => {
88
+ console.log(JSON.stringify(doc, null, 2));
89
+ return "error" in doc ? 1 : 0;
90
+ };
91
+ const fail = (code: "write-usage-invalid" | "write-input-invalid", message: string) =>
92
+ print({ $schema: BOX_FACTORY_WRITE_SCHEMA_ID, contract: BOX_FACTORY_CONTRACT_VERSION, chant: readerVersion(), member: member ?? null, error: { code, message } });
93
+ if (member === undefined) return fail("write-usage-invalid", `box factory set needs the member whose box builds; ${FACTORY_USAGE}`);
94
+ if (extra.length > 0) return fail("write-usage-invalid", `box factory set takes one member, and was also given ${extra.join(" ")}`);
95
+ for (const [flag, v] of [["--set", args.set], ["--kind", args.kind], ["--at", args.at], ["--cover", args.cover]] as const) {
96
+ if (v !== undefined) return fail("write-usage-invalid", `box factory set takes its fields with --from, not ${flag}`);
97
+ }
98
+ let fields: string | undefined;
99
+ if (args.migrateFrom !== undefined) {
100
+ try {
101
+ fields = readFileSync(args.migrateFrom === "-" ? 0 : resolve(cwd, args.migrateFrom), "utf-8");
102
+ } catch (err) {
103
+ return fail("write-input-invalid", `--from ${args.migrateFrom} could not be read: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+ return print(boxFactorySet({ cwd, member, fields, by: args.by, agent: process.env[AGENT_ENV] || undefined, dryRun: args.dryRun === true }));
107
+ }
108
+
78
109
  /** `box publish <member> <item>` or `box publish <member> --records`. */
79
110
  function runPublish(ctx: CommandContext, member: string | undefined, rest: string[]): number {
80
111
  const { args } = ctx;
@@ -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
+ });