@hominis/fireforge 0.44.1 → 0.44.2

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 (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +18 -110
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/furnace/apply.js +2 -2
  6. package/dist/src/commands/furnace/deploy.js +2 -2
  7. package/dist/src/commands/furnace/index.js +12 -6
  8. package/dist/src/commands/furnace/init.js +5 -2
  9. package/dist/src/commands/furnace/sync.js +2 -2
  10. package/dist/src/commands/re-export-files.js +63 -23
  11. package/dist/src/commands/re-export-scan.js +18 -2
  12. package/dist/src/commands/re-export-single.js +19 -7
  13. package/dist/src/commands/test-register.js +5 -1
  14. package/dist/src/commands/test-run.d.ts +5 -0
  15. package/dist/src/commands/test-run.js +11 -4
  16. package/dist/src/commands/test-verdict.js +10 -1
  17. package/dist/src/commands/test.js +9 -0
  18. package/dist/src/commands/token.js +33 -11
  19. package/dist/src/core/build-audit.js +6 -2
  20. package/dist/src/core/furnace-jsconfig.js +5 -3
  21. package/dist/src/core/furnace-operation.d.ts +25 -2
  22. package/dist/src/core/furnace-operation.js +27 -2
  23. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  24. package/dist/src/core/mach-known-noise-filter.js +19 -0
  25. package/dist/src/core/mach.d.ts +8 -3
  26. package/dist/src/core/mach.js +47 -3
  27. package/dist/src/core/run-log.d.ts +60 -0
  28. package/dist/src/core/run-log.js +183 -0
  29. package/dist/src/core/test-harness-output.d.ts +24 -1
  30. package/dist/src/core/test-harness-output.js +46 -15
  31. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  32. package/dist/src/core/test-xpcshell-retry.js +6 -1
  33. package/dist/src/core/token-manager.d.ts +7 -2
  34. package/dist/src/core/token-manager.js +74 -20
  35. package/dist/src/core/token-variant.d.ts +34 -0
  36. package/dist/src/core/token-variant.js +84 -16
  37. package/dist/src/types/commands/options.d.ts +30 -1
  38. package/dist/src/utils/child-env.d.ts +30 -0
  39. package/dist/src/utils/child-env.js +30 -0
  40. package/dist/src/utils/options.d.ts +28 -0
  41. package/dist/src/utils/options.js +32 -1
  42. package/dist/src/utils/process.d.ts +22 -0
  43. package/dist/src/utils/process.js +8 -7
  44. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { loadFurnaceConfig } from './furnace-config.js';
11
11
  import { assertTokenCategoryExists, categoryHeaderExists, declareCategoryBanner, findCategorySection, findTokenDeclarationInRoot, } from './token-category.js';
12
12
  import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
13
  import { addTokenToDocs } from './token-docs.js';
14
- import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
14
+ import { findVariantBlockDeclaration, insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, variantBlockQualifier, } from './token-variant.js';
15
15
  /**
16
16
  * Dark mode behaviour for a token.
17
17
  *
@@ -95,7 +95,9 @@ function normalizeVariantOption(options) {
95
95
  }
96
96
  if (options.createCategory === true) {
97
97
  throw new InvalidArgumentError('--create-category cannot be combined with --variant; variant declarations are routed ' +
98
- 'into a :root<selector> block, not a category section.', 'variant');
98
+ 'into a :root<selector> block, not a category section. Add the BASE token first ' +
99
+ '(with --create-category) to author the category, then add each variant with ' +
100
+ '--variant.', 'variant');
99
101
  }
100
102
  const result = validateVariantSelector(options.variant);
101
103
  if (!result.ok) {
@@ -103,6 +105,30 @@ function normalizeVariantOption(options) {
103
105
  }
104
106
  return result.value;
105
107
  }
108
+ /**
109
+ * Narrows {@link AddTokenOptions.category} for the base-declaration path,
110
+ * which cannot proceed without one.
111
+ *
112
+ * `--category` used to be unconditionally required, INCLUDING under
113
+ * `--variant`, where the declaration is routed into a `:root<selector>`
114
+ * block and the category names nothing about where the token lands. The
115
+ * requirement existed so the argument would not be silently discarded —
116
+ * a fair goal that produced a mandatory argument describing nothing. Under
117
+ * a variant it is now optional; everywhere else this states the reason
118
+ * rather than leaving commander to print a bare "required option not
119
+ * specified".
120
+ *
121
+ * @param options - Token options
122
+ * @returns The category
123
+ */
124
+ function requireCategory(options) {
125
+ if (options.category === undefined || options.category.trim() === '') {
126
+ throw new InvalidArgumentError('A base token declaration lands in a category section, so --category is required. ' +
127
+ 'Pass --variant <selector> instead if this declaration belongs in a ' +
128
+ ':root<selector> block, where no category applies.', 'category');
129
+ }
130
+ return options.category;
131
+ }
106
132
  /** Throws when the tokens CSS file is missing (variant mode skips category checks). */
107
133
  async function assertTokensCssExists(engineDir, tokensCssPath) {
108
134
  if (!(await pathExists(join(engineDir, tokensCssPath)))) {
@@ -112,15 +138,22 @@ async function assertTokensCssExists(engineDir, tokensCssPath) {
112
138
  /**
113
139
  * Routes a declaration into the top-level `:root<variant>` block — creating
114
140
  * the block after the base `:root` block if absent, or appending to it if
115
- * present. Idempotent within the block. Returns `{ added: false }` when the
116
- * token already lives in that block.
141
+ * present. Idempotent within the block.
142
+ *
143
+ * A skip carries the LOCATION of the existing declaration, mirroring
144
+ * {@link evaluateBaseTokenIdempotency}. The bare `{ added: false }` it used
145
+ * to return told the caller nothing, so a re-run intending to CHANGE a
146
+ * value exited 0 having changed nothing and said only "already exists" —
147
+ * while the base path had reported the category and line for the same
148
+ * situation all along.
117
149
  */
118
150
  async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant) {
119
151
  await assertTokensCssExists(engineDir, tokensCssPath);
120
152
  const filePath = join(engineDir, tokensCssPath);
121
153
  const lines = (await readText(filePath)).split('\n');
122
- if (variantBlockHasToken(lines, variant, options.tokenName))
123
- return { added: false };
154
+ const declared = findVariantBlockDeclaration(lines, variant, options.tokenName);
155
+ if (declared !== undefined)
156
+ return { added: false, existing: declared };
124
157
  const annotation = getModeAnnotation(options.mode, options.value);
125
158
  insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${options.value}; /* ${annotation} */`);
126
159
  await writeText(filePath, lines.join('\n'));
@@ -158,14 +191,17 @@ export async function validateTokenAdd(root, options) {
158
191
  validateTokenNameSyntax(options.tokenName);
159
192
  await validateTokenPrefix(root, options);
160
193
  validateDarkValue(options);
161
- // Variant mode targets a `:root<attr>` block, not a category section —
162
- // but the required --category must still name a REAL category rather
163
- // than being silently discarded (bypass 2).
194
+ // Variant mode targets a `:root<attr>` block, not a category section, so
195
+ // no category is consulted — and none is required. When one IS passed it
196
+ // is still checked, so a typo'd category is not silently accepted just
197
+ // because the flag happens to be inert here.
164
198
  if (normalizeVariantOption(options) !== undefined) {
165
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
199
+ if (options.category !== undefined && options.category.trim() !== '') {
200
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
201
+ }
166
202
  return;
167
203
  }
168
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
204
+ await assertTokenCategoryExists(engineDir, tokensCssPath, requireCategory(options), options.createCategory === true);
169
205
  }
170
206
  /**
171
207
  * Adds a design token to the CSS file and documentation.
@@ -209,16 +245,19 @@ export async function addToken(root, options) {
209
245
  // docs row). Done before the base-CSS path so an override of an existing
210
246
  // base token is not short-circuited by the global idempotency check.
211
247
  if (normalizedVariant !== undefined) {
212
- // The required --category must name a real category even though the
213
- // declaration routes into the variant block (bypass 2).
214
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
215
- const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
248
+ // A category is optional here and names nothing about where the
249
+ // declaration lands, but a category that was passed is still verified.
250
+ if (options.category !== undefined && options.category.trim() !== '') {
251
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
252
+ }
253
+ const { added, existing } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
216
254
  return {
217
255
  cssAdded: added,
218
256
  docsAdded: false,
219
257
  unmappedAdded: false,
220
258
  countUpdated: false,
221
259
  skipped: !added,
260
+ ...(existing !== undefined ? { skippedExisting: existing } : {}),
222
261
  };
223
262
  }
224
263
  // --- CSS file ---
@@ -234,7 +273,11 @@ export async function addToken(root, options) {
234
273
  };
235
274
  }
236
275
  // --- Documentation ---
237
- const docsResult = await addTokenToDocs(engineDir, options, getModeAnnotation(options.mode, options.value));
276
+ // Only the base path reaches here (variant declarations returned above),
277
+ // so a category is guaranteed — `requireCategory` already ran inside
278
+ // `addTokenToCSS`. Re-narrowing rather than asserting keeps the docs
279
+ // input's required `category` honest.
280
+ const docsResult = await addTokenToDocs(engineDir, { ...options, category: requireCategory(options) }, getModeAnnotation(options.mode, options.value));
238
281
  return {
239
282
  cssAdded,
240
283
  docsAdded: docsResult.docsAdded,
@@ -274,6 +317,16 @@ function insertThemeAttributeOverrides(lines, options) {
274
317
  const value = pick === 'dark' ? options.darkValue : options.value;
275
318
  insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${value};`);
276
319
  written.push(`:root${variant}`);
320
+ // A qualified block is matched — skipping it is the worse failure, and
321
+ // the one that shipped — but the qualifier narrows where the token
322
+ // applies, so say which selector was written through rather than
323
+ // letting the operator meet the narrowing as a theme bug later.
324
+ const qualifier = variantBlockQualifier(lines, variant);
325
+ if (qualifier !== undefined && qualifier.length > 0) {
326
+ warn(`Wrote ${options.tokenName} into the qualified block ":root${variant}${qualifier}". ` +
327
+ `The "${qualifier}" qualifier narrows where this override applies; ` +
328
+ 'add an unqualified block if it should apply to every case.');
329
+ }
277
330
  }
278
331
  return written;
279
332
  }
@@ -304,7 +357,8 @@ function insertDarkModeOverride(lines, options) {
304
357
  */
305
358
  async function addTokenToCSS(engineDir, options, tokensCssPath) {
306
359
  const filePath = join(engineDir, tokensCssPath);
307
- await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
360
+ const category = requireCategory(options);
361
+ await assertTokenCategoryExists(engineDir, tokensCssPath, category, options.createCategory === true);
308
362
  const content = await readText(filePath);
309
363
  const lines = content.split('\n');
310
364
  // Per-category idempotency: a declaration in the TARGET
@@ -324,11 +378,11 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
324
378
  // token insertion — the file is written exactly once, so a failure
325
379
  // between "banner declared" and "token inserted" cannot occur.
326
380
  let categoryCreated = false;
327
- if (options.createCategory === true && !categoryHeaderExists(lines, options.category)) {
328
- declareCategoryBanner(lines, options.category, tokensCssPath);
381
+ if (options.createCategory === true && !categoryHeaderExists(lines, category)) {
382
+ declareCategoryBanner(lines, category, tokensCssPath);
329
383
  categoryCreated = true;
330
384
  }
331
- const { categoryLine, sectionEnd } = findCategorySection(lines, options.category, tokensCssPath);
385
+ const { categoryLine, sectionEnd } = findCategorySection(lines, category, tokensCssPath);
332
386
  // Build the insertion lines
333
387
  const insertLines = [];
334
388
  if (options.description) {
@@ -9,6 +9,7 @@
9
9
  * `:root<variant>` block so `token-manager.ts` can splice a declaration into
10
10
  * it, keeping all token authoring in the CLI.
11
11
  */
12
+ import type { TokenDeclarationLocation } from './token-category.js';
12
13
  /**
13
14
  * Outcome of {@link validateVariantSelector}. `ok: true` carries the
14
15
  * normalized (quoted) selector fragment; `ok: false` carries a
@@ -36,8 +37,41 @@ export declare function validateVariantSelector(raw: unknown): VariantValidation
36
37
  * the block, which is only wanted for the explicit `--variant` flow.
37
38
  */
38
39
  export declare function variantBlockExists(lines: string[], variant: string): boolean;
40
+ /**
41
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
42
+ * `undefined` when no block matched. `''` for an unqualified block.
43
+ *
44
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
45
+ * writing into that block scopes the declaration to the qualified case, and
46
+ * that may not be what the operator meant. Matching it is still the right
47
+ * call — silently skipping it is the worse failure, and the one that
48
+ * shipped — but the override path uses this to say out loud which selector
49
+ * it wrote through, so the narrowing is a visible decision rather than a
50
+ * theme bug discovered later.
51
+ *
52
+ * @param lines - Tokens CSS split into lines
53
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
54
+ * @returns The qualifier, or undefined when no block matched
55
+ */
56
+ export declare function variantBlockQualifier(lines: string[], variant: string): string | undefined;
39
57
  /** True when the `:root<variant>` block already declares `tokenName`. */
40
58
  export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
59
+ /**
60
+ * Locates an existing declaration of `tokenName` inside the
61
+ * `:root<variant>` block, or `undefined` when the block has none (or does
62
+ * not exist).
63
+ *
64
+ * The located form of {@link variantBlockHasToken}: the variant add path
65
+ * reports a skip the way the base path does — naming the block and the
66
+ * line — instead of returning a bare boolean that leaves a no-op re-run
67
+ * indistinguishable from a write.
68
+ *
69
+ * @param lines - Tokens CSS split into lines
70
+ * @param variant - Attribute selector fragment
71
+ * @param tokenName - Full token name including prefix
72
+ * @returns The declaration's 1-based line, or undefined
73
+ */
74
+ export declare function findVariantBlockDeclaration(lines: string[], variant: string, tokenName: string): TokenDeclarationLocation | undefined;
41
75
  /**
42
76
  * Splices `declLine` into the `:root<variant>` block, creating the block
43
77
  * (after the base `:root` block) when absent or appending after the last
@@ -53,15 +53,27 @@ function canonicalSelector(selector) {
53
53
  return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
54
  }
55
55
  /**
56
- * The `:root[…]` selector fragment on `line`, spanning to the LAST `]` that
57
- * precedes the block's opening brace, or `null` when the line carries none.
56
+ * Splits the `:root[…]` selector on `line` into its attribute run and any
57
+ * trailing qualifier, or `null` when the line carries none.
58
58
  *
59
59
  * Index arithmetic rather than `/:root\[[^{]*\]/`: `[^{]` also matches `]`,
60
60
  * so that pattern backtracks quadratically on a line repeating `:root[`
61
- * (CodeQL `js/polynomial-redos`). Taking the last `]` — not the first —
62
- * preserves the greedy regex's reach, so a multi-attribute selector still
63
- * compares as the whole fragment and does not collide with a single-attribute
64
- * variant that happens to be its prefix.
61
+ * (CodeQL `js/polynomial-redos`). This walk is linear and allocation-free
62
+ * per group.
63
+ *
64
+ * It consumes CONSECUTIVE `[…]` groups and stops at the first character that
65
+ * is not `[`. The previous implementation instead sliced to the LAST `]`
66
+ * before the brace, which kept multi-attribute selectors comparing as a
67
+ * whole — that property is preserved here, since consecutive groups are all
68
+ * consumed — but it also swallowed a pseudo-class tail. On
69
+ * `:root[data-theme="light"]:not([data-private]) {` the last `]` is the one
70
+ * inside `:not(…)`, so the computed fragment was
71
+ * `:root[data-theme="light"]:not([data-private]` and the canonical
72
+ * comparison could never match. The unqualified
73
+ * `:root[data-theme="dark"] { }` beside it matched fine, and that asymmetry
74
+ * WAS the bug: an override add mirrored itself into the dark block and
75
+ * silently skipped the light one, which is precisely the half-finished
76
+ * themed edit `THEME_ATTRIBUTE_VARIANTS` exists to prevent.
65
77
  */
66
78
  function rootAttributeSelector(line) {
67
79
  const start = line.indexOf(':root[');
@@ -69,10 +81,18 @@ function rootAttributeSelector(line) {
69
81
  return null;
70
82
  const brace = line.indexOf('{', start);
71
83
  const limit = brace === -1 ? line.length : brace;
72
- const close = line.lastIndexOf(']', limit - 1);
73
- if (close <= start + ':root['.length - 1)
84
+ let cursor = start + ':root'.length;
85
+ let end = cursor;
86
+ while (cursor < limit && line[cursor] === '[') {
87
+ const close = line.indexOf(']', cursor);
88
+ if (close === -1 || close >= limit)
89
+ break;
90
+ cursor = close + 1;
91
+ end = cursor;
92
+ }
93
+ if (end === start + ':root'.length)
74
94
  return null;
75
- return line.slice(start, close + 1);
95
+ return { attributes: line.slice(start, end), qualifier: line.slice(end, limit).trim() };
76
96
  }
77
97
  /**
78
98
  * Finds the top-level `:root<variant>` block whose attribute selector
@@ -89,7 +109,7 @@ function findVariantBlock(lines, variant) {
89
109
  for (let i = 0; i < stripped.length; i++) {
90
110
  const line = stripped[i] ?? '';
91
111
  const selector = rootAttributeSelector(line);
92
- if (selector === null || canonicalSelector(selector) !== want)
112
+ if (selector === null || canonicalSelector(selector.attributes) !== want)
93
113
  continue;
94
114
  let openLine = /\{/.test(line) ? i : -1;
95
115
  if (openLine === -1) {
@@ -108,7 +128,7 @@ function findVariantBlock(lines, variant) {
108
128
  const close = findBlockCloseIndex(stripped, openLine);
109
129
  if (close === -1)
110
130
  continue;
111
- return { open: openLine, close };
131
+ return { open: openLine, close, qualifier: selector.qualifier };
112
132
  }
113
133
  return null;
114
134
  }
@@ -138,16 +158,64 @@ function findVariantBlockInsertionPoint(lines) {
138
158
  export function variantBlockExists(lines, variant) {
139
159
  return findVariantBlock(lines, variant) !== null;
140
160
  }
161
+ /**
162
+ * The pseudo-class qualifier on the matched `:root<variant>` block, or
163
+ * `undefined` when no block matched. `''` for an unqualified block.
164
+ *
165
+ * A qualifier such as `:not([data-private])` is semantically LOAD-BEARING:
166
+ * writing into that block scopes the declaration to the qualified case, and
167
+ * that may not be what the operator meant. Matching it is still the right
168
+ * call — silently skipping it is the worse failure, and the one that
169
+ * shipped — but the override path uses this to say out loud which selector
170
+ * it wrote through, so the narrowing is a visible decision rather than a
171
+ * theme bug discovered later.
172
+ *
173
+ * @param lines - Tokens CSS split into lines
174
+ * @param variant - Attribute selector fragment, e.g. `[data-theme="light"]`
175
+ * @returns The qualifier, or undefined when no block matched
176
+ */
177
+ export function variantBlockQualifier(lines, variant) {
178
+ return findVariantBlock(lines, variant)?.qualifier;
179
+ }
141
180
  /** True when the `:root<variant>` block already declares `tokenName`. */
142
181
  export function variantBlockHasToken(lines, variant, tokenName) {
182
+ return findVariantBlockDeclaration(lines, variant, tokenName) !== undefined;
183
+ }
184
+ /**
185
+ * Locates an existing declaration of `tokenName` inside the
186
+ * `:root<variant>` block, or `undefined` when the block has none (or does
187
+ * not exist).
188
+ *
189
+ * The located form of {@link variantBlockHasToken}: the variant add path
190
+ * reports a skip the way the base path does — naming the block and the
191
+ * line — instead of returning a bare boolean that leaves a no-op re-run
192
+ * indistinguishable from a write.
193
+ *
194
+ * @param lines - Tokens CSS split into lines
195
+ * @param variant - Attribute selector fragment
196
+ * @param tokenName - Full token name including prefix
197
+ * @returns The declaration's 1-based line, or undefined
198
+ */
199
+ export function findVariantBlockDeclaration(lines, variant, tokenName) {
143
200
  const block = findVariantBlock(lines, variant);
144
201
  if (!block)
145
- return false;
146
- const blockText = lines
147
- .slice(block.open, block.close + 1)
202
+ return undefined;
203
+ // Comments are stripped across the WHOLE block before scanning, matching
204
+ // what the boolean form always did: a `/* … */` spanning several lines
205
+ // must not leave its tail looking like a declaration.
206
+ const blockLines = lines.slice(block.open, block.close + 1);
207
+ // Blank the comment's CHARACTERS rather than deleting it: removing a
208
+ // multi-line comment outright collapses its lines and shifts every line
209
+ // number after it, which is the one thing this function exists to report.
210
+ const stripped = blockLines
148
211
  .join('\n')
149
- .replace(/\/\*[\s\S]*?\*\//g, '');
150
- return blockText.includes(`${tokenName}:`);
212
+ .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
213
+ .split('\n');
214
+ for (let i = 0; i < stripped.length; i++) {
215
+ if ((stripped[i] ?? '').includes(`${tokenName}:`))
216
+ return { line: block.open + i + 1 };
217
+ }
218
+ return undefined;
151
219
  }
152
220
  /**
153
221
  * Splices `declLine` into the `:root<variant>` block, creating the block
@@ -539,6 +539,12 @@ export interface RunOptions {
539
539
  export interface TestOptions {
540
540
  /** Run tests in headless mode */
541
541
  headless?: boolean;
542
+ /**
543
+ * Pass mozbuild's full verbosity through for the TEST phase by unsetting
544
+ * the coding-agent markers it quiets output on. Off by default, because
545
+ * that quieting is worth keeping for builds.
546
+ */
547
+ fullOutput?: boolean;
542
548
  /** Run incremental UI build before testing */
543
549
  build?: boolean;
544
550
  /**
@@ -652,6 +658,12 @@ export interface FurnaceApplyOptions {
652
658
  force?: boolean;
653
659
  /** Watch component directories and re-apply on changes */
654
660
  watch?: boolean;
661
+ /**
662
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
663
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
664
+ * engine session lock, so one flag governs both.
665
+ */
666
+ waitLockSeconds?: number;
655
667
  }
656
668
  /**
657
669
  * Options for the furnace preview command.
@@ -670,6 +682,12 @@ export interface FurnaceDeployOptions {
670
682
  force?: boolean;
671
683
  /** Skip the validation step (apply only, no accessibility/compatibility checks) */
672
684
  skipValidate?: boolean;
685
+ /**
686
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
687
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
688
+ * engine session lock, so one flag governs both.
689
+ */
690
+ waitLockSeconds?: number;
673
691
  }
674
692
  /**
675
693
  * Options for the furnace refresh command.
@@ -692,6 +710,12 @@ export interface FurnaceSyncOptions {
692
710
  dryRun?: boolean;
693
711
  /** Conflict resolution strategy for three-way merge (ours = keep local, theirs = accept upstream) */
694
712
  strategy?: 'ours' | 'theirs';
713
+ /**
714
+ * Resolved `--wait-lock` budget in seconds, or undefined for the default.
715
+ * Threaded to the furnace lock `runFurnaceMutation` takes INSIDE the
716
+ * engine session lock, so one flag governs both.
717
+ */
718
+ waitLockSeconds?: number;
695
719
  }
696
720
  /**
697
721
  * Options for the furnace validate command.
@@ -965,7 +989,12 @@ export interface StatusOptions {
965
989
  * Options for the token add command.
966
990
  */
967
991
  export interface TokenAddOptions {
968
- category: string;
992
+ /**
993
+ * Required for a base declaration; optional under
994
+ * {@link TokenAddOptions.variant}, which routes into a `:root<selector>`
995
+ * block where no category applies.
996
+ */
997
+ category?: string;
969
998
  mode: string;
970
999
  description?: string;
971
1000
  darkValue?: string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Environment construction for spawned children.
3
+ *
4
+ * Its own module rather than a helper inside `process.ts` only because that
5
+ * file sits on a 500-line budget; the logic belongs to the exec layer.
6
+ */
7
+ /** The `env` / `envUnset` slice of exec options this module needs. */
8
+ export interface ChildEnvOptions {
9
+ /** Variables to add or overwrite on top of `process.env`. */
10
+ env?: Record<string, string> | undefined;
11
+ /** Variables to remove after the merge. */
12
+ envUnset?: readonly string[] | undefined;
13
+ }
14
+ /**
15
+ * The child's environment: `process.env`, with `env` merged over it, then
16
+ * every `envUnset` key removed.
17
+ *
18
+ * One helper rather than five inline spreads — the removal step is easy to
19
+ * add at four of the five spawn sites and forget at the fifth, which would
20
+ * make the option silently inert exactly where it was not tested.
21
+ *
22
+ * Removal is a real removal, not an assignment to `''`: mozbuild's
23
+ * `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
24
+ * an empty value would not unset it. `Reflect.deleteProperty` rather than
25
+ * `delete obj[key]` because the key is computed.
26
+ *
27
+ * @param options - Options carrying `env` / `envUnset`
28
+ * @returns The environment to hand `spawn`
29
+ */
30
+ export declare function buildChildEnv(options: ChildEnvOptions): NodeJS.ProcessEnv;
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Environment construction for spawned children.
4
+ *
5
+ * Its own module rather than a helper inside `process.ts` only because that
6
+ * file sits on a 500-line budget; the logic belongs to the exec layer.
7
+ */
8
+ /**
9
+ * The child's environment: `process.env`, with `env` merged over it, then
10
+ * every `envUnset` key removed.
11
+ *
12
+ * One helper rather than five inline spreads — the removal step is easy to
13
+ * add at four of the five spawn sites and forget at the fifth, which would
14
+ * make the option silently inert exactly where it was not tested.
15
+ *
16
+ * Removal is a real removal, not an assignment to `''`: mozbuild's
17
+ * `is_running_under_coding_agent()` reads `CLAUDECODE` for its PRESENCE, so
18
+ * an empty value would not unset it. `Reflect.deleteProperty` rather than
19
+ * `delete obj[key]` because the key is computed.
20
+ *
21
+ * @param options - Options carrying `env` / `envUnset`
22
+ * @returns The environment to hand `spawn`
23
+ */
24
+ export function buildChildEnv(options) {
25
+ const merged = { ...process.env, ...options.env };
26
+ for (const key of options.envUnset ?? []) {
27
+ Reflect.deleteProperty(merged, key);
28
+ }
29
+ return merged;
30
+ }
@@ -66,6 +66,28 @@ export declare const WAIT_LOCK_ENV_VAR = "FIREFORGE_WAIT_LOCK";
66
66
  * ABSENCE, which is where it belongs.
67
67
  */
68
68
  export declare function resolveWaitLockSeconds(value: string | number | boolean | undefined): number | undefined;
69
+ /**
70
+ * The session-wide wait budget in MILLISECONDS, or undefined when none is
71
+ * set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
72
+ *
73
+ * Exists for the SECONDARY locks a command takes after the one its
74
+ * `--wait-lock` flag was threaded to. A furnace mutation takes the engine
75
+ * session lock and then `.fireforge/furnace.lock`; only the first had a
76
+ * budget, so a contended deploy died on the file lock's fixed 30 s while
77
+ * its operator believed a much larger budget was in force. Threading the
78
+ * flag reaches the three furnace commands that declare it — this reaches
79
+ * the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
80
+ * `override`, `chrome-doc`, …), which have no flag to thread but are just
81
+ * as contended.
82
+ *
83
+ * Deliberately env-only: a flag belongs to one invocation and is passed
84
+ * explicitly where it applies, while the environment is exactly the place a
85
+ * session-wide default is expressed. An explicit budget at a call site
86
+ * always wins over this.
87
+ *
88
+ * @returns The session budget in milliseconds, or undefined
89
+ */
90
+ export declare function sessionWaitLockMs(): number | undefined;
69
91
  /**
70
92
  * True when `command` already declares `--wait-lock` itself. Used by the
71
93
  * CLI wiring to add the accept-and-ignore variant only where the honoring
@@ -85,5 +107,11 @@ export declare function ensureWaitLockOptionEverywhere(command: Command): void;
85
107
  * for the bare flag or a validated integer; feed it through
86
108
  * {@link resolveWaitLockSeconds} at the call site to obtain the
87
109
  * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
110
+ *
111
+ * A command can take MORE than one lock — a furnace mutation takes the
112
+ * engine session lock and then `.fireforge/furnace.lock` — and the budget
113
+ * has to reach all of them or the flag is a half-truth. The secondary locks
114
+ * read the session budget through {@link sessionWaitLockMs} rather than
115
+ * having it threaded to every call site.
88
116
  */
89
117
  export declare function addWaitLockOption(command: Command): Command;
@@ -144,6 +144,31 @@ export function resolveWaitLockSeconds(value) {
144
144
  }
145
145
  return parsed;
146
146
  }
147
+ /**
148
+ * The session-wide wait budget in MILLISECONDS, or undefined when none is
149
+ * set. Sourced from {@link WAIT_LOCK_ENV_VAR} only.
150
+ *
151
+ * Exists for the SECONDARY locks a command takes after the one its
152
+ * `--wait-lock` flag was threaded to. A furnace mutation takes the engine
153
+ * session lock and then `.fireforge/furnace.lock`; only the first had a
154
+ * budget, so a contended deploy died on the file lock's fixed 30 s while
155
+ * its operator believed a much larger budget was in force. Threading the
156
+ * flag reaches the three furnace commands that declare it — this reaches
157
+ * the twelve that do not (`create`, `remove`, `rename`, `refresh`, `scan`,
158
+ * `override`, `chrome-doc`, …), which have no flag to thread but are just
159
+ * as contended.
160
+ *
161
+ * Deliberately env-only: a flag belongs to one invocation and is passed
162
+ * explicitly where it applies, while the environment is exactly the place a
163
+ * session-wide default is expressed. An explicit budget at a call site
164
+ * always wins over this.
165
+ *
166
+ * @returns The session budget in milliseconds, or undefined
167
+ */
168
+ export function sessionWaitLockMs() {
169
+ const seconds = waitLockSecondsFromEnv();
170
+ return seconds === undefined ? undefined : seconds * 1000;
171
+ }
147
172
  /**
148
173
  * Registers `--wait-lock [seconds]` on a command that takes NO lock, so a
149
174
  * scripted sequence can blanket-append the flag without hitting a usage
@@ -188,7 +213,13 @@ export function ensureWaitLockOptionEverywhere(command) {
188
213
  * for the bare flag or a validated integer; feed it through
189
214
  * {@link resolveWaitLockSeconds} at the call site to obtain the
190
215
  * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
216
+ *
217
+ * A command can take MORE than one lock — a furnace mutation takes the
218
+ * engine session lock and then `.fireforge/furnace.lock` — and the budget
219
+ * has to reach all of them or the flag is a half-truth. The secondary locks
220
+ * read the session budget through {@link sessionWaitLockMs} rather than
221
+ * having it threaded to every call site.
191
222
  */
192
223
  export function addWaitLockOption(command) {
193
- return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release the contended lock, instead of failing on the default budget. Set ${WAIT_LOCK_ENV_VAR} to apply a budget to every command in a session`, commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
224
+ return command.option('--wait-lock [seconds]', `Wait up to this many seconds (default 60) for another FireForge command to release EVERY lock this command takes, instead of failing on the default budget. Set ${WAIT_LOCK_ENV_VAR} to apply a budget to every command in a session`, commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
194
225
  }
@@ -28,6 +28,28 @@ export interface ExecOptions {
28
28
  cwd?: string;
29
29
  /** Environment variables */
30
30
  env?: Record<string, string>;
31
+ /**
32
+ * Environment variables to REMOVE from the child's inherited environment,
33
+ * applied after {@link ExecOptions.env} is merged over `process.env`.
34
+ *
35
+ * Needed because `env` can only add or overwrite, and some variables are
36
+ * read for their PRESENCE — mozbuild's `is_running_under_coding_agent()`
37
+ * keys on `CLAUDECODE`, so setting it empty would not unset it. An
38
+ * explicit delete says what it means; relying on Node dropping
39
+ * `undefined`-valued env entries would be a semantics gamble at a layer
40
+ * that must not have any.
41
+ */
42
+ envUnset?: readonly string[];
43
+ /**
44
+ * Streams to mirror the child's output into, replacing the default
45
+ * `process.stdout` / `process.stderr` on the stdio-inheriting capture
46
+ * path. Lets a caller tee the live output somewhere else (a run log)
47
+ * without a second per-chunk hook.
48
+ */
49
+ mirror?: {
50
+ stdout?: NodeJS.WritableStream;
51
+ stderr?: NodeJS.WritableStream;
52
+ };
31
53
  /** Timeout in milliseconds */
32
54
  timeout?: number;
33
55
  /**