@hominis/fireforge 0.10.1 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -6,7 +6,10 @@ import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
7
  import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
8
8
  import { FIREFORGE_DIR } from './config.js';
9
+ import { resolveFtlDir } from './furnace-constants.js';
10
+ import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
9
11
  import { quarantineStateFile, withStateFileLock } from './state-file.js';
12
+ export { detectComposesCycles };
10
13
  /** Name of the furnace configuration file */
11
14
  export const FURNACE_CONFIG_FILENAME = 'furnace.json';
12
15
  /** Name of the furnace state file */
@@ -81,6 +84,7 @@ function parseOverrideConfig(data, name) {
81
84
  description: data['description'],
82
85
  basePath: data['basePath'],
83
86
  baseVersion: data['baseVersion'],
87
+ ...(isString(data['baseCommit']) ? { baseCommit: data['baseCommit'] } : {}),
84
88
  };
85
89
  }
86
90
  /**
@@ -117,6 +121,37 @@ function parseCustomConfig(data, name) {
117
121
  : {}),
118
122
  };
119
123
  }
124
+ /** The current (and only) config schema version. */
125
+ const CURRENT_CONFIG_VERSION = 1;
126
+ /**
127
+ * Migrates a furnace config from an older schema version to the current one.
128
+ * Returns the data unchanged if it is already at the current version.
129
+ *
130
+ * When a future version 2 is introduced, add a `case 1:` that transforms
131
+ * v1 data into v2 shape and falls through to validation. The pattern is:
132
+ *
133
+ * ```
134
+ * case 1:
135
+ * data = migrateV1ToV2(data);
136
+ * // fallthrough
137
+ * case 2:
138
+ * break;
139
+ * ```
140
+ */
141
+ export function migrateFurnaceConfig(data) {
142
+ const version = data['version'];
143
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
144
+ throw new FurnaceError(`Furnace config: "version" must be a positive integer (got ${JSON.stringify(version)}). ` +
145
+ `Current schema version is ${CURRENT_CONFIG_VERSION}.`);
146
+ }
147
+ if (version > CURRENT_CONFIG_VERSION) {
148
+ throw new FurnaceError(`Furnace config: version ${version} is newer than what this version of FireForge supports (${CURRENT_CONFIG_VERSION}). ` +
149
+ 'Upgrade FireForge to read this config.');
150
+ }
151
+ // Today only version 1 exists, so no migration is needed. When future
152
+ // versions are added, migration steps will be chained here.
153
+ return data;
154
+ }
120
155
  /**
121
156
  * Validates a raw config object and returns a typed FurnaceConfig.
122
157
  * @param data - Raw data to validate
@@ -127,27 +162,39 @@ export function validateFurnaceConfig(data) {
127
162
  if (!isObject(data)) {
128
163
  throw new FurnaceError('Furnace config must be an object');
129
164
  }
130
- if (data['version'] !== 1) {
131
- throw new FurnaceError('Furnace config: "version" must be 1');
165
+ // Run migration before validation so older configs are transparently upgraded.
166
+ const migrated = migrateFurnaceConfig(data);
167
+ if (migrated['version'] !== CURRENT_CONFIG_VERSION) {
168
+ throw new FurnaceError(`Furnace config: "version" must be ${CURRENT_CONFIG_VERSION} after migration`);
132
169
  }
133
- if (!isString(data['componentPrefix'])) {
170
+ if (!isString(migrated['componentPrefix'])) {
134
171
  throw new FurnaceError('Furnace config: "componentPrefix" must be a string');
135
172
  }
136
173
  // Validate optional tokenPrefix
137
- if (data['tokenPrefix'] !== undefined && !isString(data['tokenPrefix'])) {
174
+ if (migrated['tokenPrefix'] !== undefined && !isString(migrated['tokenPrefix'])) {
138
175
  throw new FurnaceError('Furnace config: "tokenPrefix" must be a string if provided');
139
176
  }
140
177
  // Validate optional tokenAllowlist
141
- if (data['tokenAllowlist'] !== undefined) {
142
- parseStringArray(data['tokenAllowlist'], 'tokenAllowlist');
178
+ if (migrated['tokenAllowlist'] !== undefined) {
179
+ parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
180
+ }
181
+ const stock = parseStringArray(migrated['stock'], 'stock');
182
+ const stockSet = new Set();
183
+ for (const name of stock) {
184
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
185
+ throw new FurnaceError(`Furnace config: stock entry "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
186
+ }
187
+ if (stockSet.has(name)) {
188
+ throw new FurnaceError(`Furnace config: duplicate stock entry "${name}"`);
189
+ }
190
+ stockSet.add(name);
143
191
  }
144
- const stock = parseStringArray(data['stock'], 'stock');
145
192
  // Validate overrides
146
- if (!isObject(data['overrides'])) {
193
+ if (!isObject(migrated['overrides'])) {
147
194
  throw new FurnaceError('Furnace config: "overrides" must be an object');
148
195
  }
149
196
  const overrides = {};
150
- for (const [name, value] of Object.entries(data['overrides'])) {
197
+ for (const [name, value] of Object.entries(migrated['overrides'])) {
151
198
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
152
199
  throw new FurnaceError(`Furnace config: override name "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
153
200
  }
@@ -157,11 +204,11 @@ export function validateFurnaceConfig(data) {
157
204
  overrides[name] = parseOverrideConfig(value, name);
158
205
  }
159
206
  // Validate custom
160
- if (!isObject(data['custom'])) {
207
+ if (!isObject(migrated['custom'])) {
161
208
  throw new FurnaceError('Furnace config: "custom" must be an object');
162
209
  }
163
210
  const custom = {};
164
- for (const [name, value] of Object.entries(data['custom'])) {
211
+ for (const [name, value] of Object.entries(migrated['custom'])) {
165
212
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
166
213
  throw new FurnaceError(`Furnace config: custom name "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
167
214
  }
@@ -170,18 +217,42 @@ export function validateFurnaceConfig(data) {
170
217
  }
171
218
  custom[name] = parseCustomConfig(value, name);
172
219
  }
220
+ // Detect circular composes references among custom components.
221
+ detectComposesCycles(custom);
222
+ // Validate that every composes reference points to a known component.
223
+ validateComposesReferences(stock, overrides, custom);
173
224
  const config = {
174
- version: 1,
175
- componentPrefix: data['componentPrefix'],
225
+ version: CURRENT_CONFIG_VERSION,
226
+ componentPrefix: migrated['componentPrefix'],
176
227
  stock,
177
228
  overrides,
178
229
  custom,
179
230
  };
180
- if (data['tokenPrefix'] !== undefined) {
181
- config.tokenPrefix = data['tokenPrefix'];
231
+ if (migrated['tokenPrefix'] !== undefined) {
232
+ config.tokenPrefix = migrated['tokenPrefix'];
182
233
  }
183
- if (data['tokenAllowlist'] !== undefined) {
184
- config.tokenAllowlist = parseStringArray(data['tokenAllowlist'], 'tokenAllowlist');
234
+ if (migrated['tokenAllowlist'] !== undefined) {
235
+ config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
236
+ }
237
+ // Validate optional ftlBasePath
238
+ if (migrated['ftlBasePath'] !== undefined) {
239
+ if (!isString(migrated['ftlBasePath'])) {
240
+ throw new FurnaceError('Furnace config: "ftlBasePath" must be a string if provided');
241
+ }
242
+ if (migrated['ftlBasePath'].includes('..')) {
243
+ throw new FurnaceError('Furnace config: "ftlBasePath" must not contain ".." (path traversal)');
244
+ }
245
+ config.ftlBasePath = migrated['ftlBasePath'];
246
+ }
247
+ // Validate optional scanPaths
248
+ if (migrated['scanPaths'] !== undefined) {
249
+ const paths = parseStringArray(migrated['scanPaths'], 'scanPaths');
250
+ for (const p of paths) {
251
+ if (p.includes('..')) {
252
+ throw new FurnaceError('Furnace config: "scanPaths" entries must not contain ".." (path traversal)');
253
+ }
254
+ }
255
+ config.scanPaths = paths;
185
256
  }
186
257
  return config;
187
258
  }
@@ -197,6 +268,39 @@ export function validateFurnaceState(data) {
197
268
  }
198
269
  return result.state;
199
270
  }
271
+ const PENDING_REPAIR_OPERATIONS = [
272
+ 'preview-teardown',
273
+ 'apply-rollback',
274
+ 'deploy-rollback',
275
+ 'remove-rollback',
276
+ 'create-rollback',
277
+ 'override-rollback',
278
+ 'scan-rollback',
279
+ 'rename-rollback',
280
+ 'refresh-rollback',
281
+ ];
282
+ function parsePendingRepair(data) {
283
+ if (!isObject(data)) {
284
+ return { error: 'field "pendingRepair" must be an object' };
285
+ }
286
+ if (!isString(data['operation']) ||
287
+ !PENDING_REPAIR_OPERATIONS.includes(data['operation'])) {
288
+ return {
289
+ error: `pendingRepair.operation must be one of: ${PENDING_REPAIR_OPERATIONS.join(', ')}`,
290
+ };
291
+ }
292
+ if (!isString(data['timestamp'])) {
293
+ return { error: 'pendingRepair.timestamp must be a string' };
294
+ }
295
+ if (!isString(data['reason'])) {
296
+ return { error: 'pendingRepair.reason must be a string' };
297
+ }
298
+ return {
299
+ operation: data['operation'],
300
+ timestamp: data['timestamp'],
301
+ reason: data['reason'],
302
+ };
303
+ }
200
304
  function sanitizeFurnaceState(data) {
201
305
  if (!isObject(data)) {
202
306
  return {
@@ -238,6 +342,33 @@ function sanitizeFurnaceState(data) {
238
342
  }
239
343
  }
240
344
  }
345
+ if (data['engineChecksums'] !== undefined) {
346
+ if (!isObject(data['engineChecksums'])) {
347
+ issues.push('field "engineChecksums" must be an object of string checksum values');
348
+ }
349
+ else {
350
+ const engineChecksums = {};
351
+ for (const [filePath, checksum] of Object.entries(data['engineChecksums'])) {
352
+ if (isString(checksum)) {
353
+ engineChecksums[filePath] = checksum;
354
+ }
355
+ }
356
+ if (Object.keys(engineChecksums).length > 0) {
357
+ state.engineChecksums = engineChecksums;
358
+ recoveredFields.push('engineChecksums');
359
+ }
360
+ }
361
+ }
362
+ if (data['pendingRepair'] !== undefined) {
363
+ const parsed = parsePendingRepair(data['pendingRepair']);
364
+ if ('error' in parsed) {
365
+ issues.push(parsed.error);
366
+ }
367
+ else {
368
+ state.pendingRepair = parsed;
369
+ recoveredFields.push('pendingRepair');
370
+ }
371
+ }
241
372
  return { state, issues, recoveredFields };
242
373
  }
243
374
  async function recoverInvalidFurnaceState(statePath, result, alreadyLocked = false) {
@@ -369,4 +500,36 @@ export async function updateFurnaceState(root, updates) {
369
500
  await writeJson(paths.furnaceState, validateFurnaceState(nextState));
370
501
  });
371
502
  }
503
+ /**
504
+ * Collects engine-relative path prefixes that are managed by the Furnace
505
+ * component system (overrides, custom components, and their Fluent l10n
506
+ * files). Used by `status` and `export-all` to classify engine changes
507
+ * as Furnace-managed rather than unmanaged drift.
508
+ *
509
+ * Returns an empty set when no furnace config exists (opt-in subsystem).
510
+ * Prefixes always end with `/` so callers can use `startsWith()`.
511
+ */
512
+ export async function collectFurnaceManagedPrefixes(root) {
513
+ if (!(await furnaceConfigExists(root)))
514
+ return new Set();
515
+ const config = await loadFurnaceConfig(root);
516
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
517
+ const prefixes = new Set();
518
+ for (const [, overrideCfg] of Object.entries(config.overrides)) {
519
+ const base = overrideCfg.basePath.endsWith('/')
520
+ ? overrideCfg.basePath
521
+ : overrideCfg.basePath + '/';
522
+ prefixes.add(base);
523
+ }
524
+ for (const [, customCfg] of Object.entries(config.custom)) {
525
+ const target = customCfg.targetPath.endsWith('/')
526
+ ? customCfg.targetPath
527
+ : customCfg.targetPath + '/';
528
+ prefixes.add(target);
529
+ if (customCfg.localized) {
530
+ prefixes.add(ftlDir.endsWith('/') ? ftlDir : ftlDir + '/');
531
+ }
532
+ }
533
+ return prefixes;
534
+ }
372
535
  //# sourceMappingURL=furnace-config.js.map
@@ -2,3 +2,25 @@
2
2
  export declare const CUSTOM_ELEMENTS_JS = "toolkit/content/customElements.js";
3
3
  /** Path to jar.mn within the engine source tree (toolkit global) */
4
4
  export declare const JAR_MN = "toolkit/content/jar.mn";
5
+ /** Default Fluent localization directory for toolkit global components, relative to engine root */
6
+ export declare const FTL_DIR = "toolkit/locales/en-US/toolkit/global";
7
+ /** File extensions that constitute a Furnace component's source files. */
8
+ export declare const COMPONENT_FILE_EXTENSIONS: readonly [".mjs", ".css", ".ftl"];
9
+ /** Returns true when `fileName` has one of the standard component file extensions. */
10
+ export declare function isComponentSourceFile(fileName: string): boolean;
11
+ /**
12
+ * Resolves the FTL base path, preferring the user-configured value from
13
+ * `furnace.json` over the built-in default.
14
+ */
15
+ export declare function resolveFtlDir(configuredPath?: string): string;
16
+ /**
17
+ * Converts a kebab-case tag name to PascalCase class name.
18
+ * e.g. "moz-sidebar-panel" → "MozSidebarPanel"
19
+ */
20
+ export declare function tagNameToClassName(tagName: string): string;
21
+ /**
22
+ * Strips a known component prefix from a tag name to produce a concise
23
+ * display name. Falls back to the full tag name when the prefix doesn't
24
+ * match, so callers never receive an empty string.
25
+ */
26
+ export declare function stripComponentPrefix(tagName: string, componentPrefix: string): string;
@@ -3,4 +3,40 @@
3
3
  export const CUSTOM_ELEMENTS_JS = 'toolkit/content/customElements.js';
4
4
  /** Path to jar.mn within the engine source tree (toolkit global) */
5
5
  export const JAR_MN = 'toolkit/content/jar.mn';
6
+ /** Default Fluent localization directory for toolkit global components, relative to engine root */
7
+ export const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
8
+ /** File extensions that constitute a Furnace component's source files. */
9
+ export const COMPONENT_FILE_EXTENSIONS = ['.mjs', '.css', '.ftl'];
10
+ /** Returns true when `fileName` has one of the standard component file extensions. */
11
+ export function isComponentSourceFile(fileName) {
12
+ return COMPONENT_FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
13
+ }
14
+ /**
15
+ * Resolves the FTL base path, preferring the user-configured value from
16
+ * `furnace.json` over the built-in default.
17
+ */
18
+ export function resolveFtlDir(configuredPath) {
19
+ return configuredPath ?? FTL_DIR;
20
+ }
21
+ /**
22
+ * Converts a kebab-case tag name to PascalCase class name.
23
+ * e.g. "moz-sidebar-panel" → "MozSidebarPanel"
24
+ */
25
+ export function tagNameToClassName(tagName) {
26
+ return tagName
27
+ .split('-')
28
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
29
+ .join('');
30
+ }
31
+ /**
32
+ * Strips a known component prefix from a tag name to produce a concise
33
+ * display name. Falls back to the full tag name when the prefix doesn't
34
+ * match, so callers never receive an empty string.
35
+ */
36
+ export function stripComponentPrefix(tagName, componentPrefix) {
37
+ if (componentPrefix && tagName.startsWith(componentPrefix)) {
38
+ return tagName.slice(componentPrefix.length);
39
+ }
40
+ return tagName;
41
+ }
6
42
  //# sourceMappingURL=furnace-constants.js.map
@@ -0,0 +1,11 @@
1
+ import type { CustomComponentConfig, FurnaceConfig } from '../types/furnace.js';
2
+ /** Throws a {@link FurnaceError} if the composes graph among custom components contains a cycle. */
3
+ export declare function detectComposesCycles(custom: FurnaceConfig['custom']): void;
4
+ /** Validates that every `composes` reference in custom components points to a known component. */
5
+ export declare function validateComposesReferences(stock: string[], overrides: FurnaceConfig['overrides'], custom: FurnaceConfig['custom']): void;
6
+ /**
7
+ * Returns custom component names in topological order so that components
8
+ * depended upon via `composes` are applied before those that compose them.
9
+ * Falls back to insertion order when there are no composes edges.
10
+ */
11
+ export declare function topologicalSortCustom(custom: Record<string, CustomComponentConfig>): string[];
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { FurnaceError } from '../errors/furnace.js';
3
+ /** Throws a {@link FurnaceError} if the composes graph among custom components contains a cycle. */
4
+ export function detectComposesCycles(custom) {
5
+ const visited = new Set();
6
+ const stack = new Set();
7
+ function visit(name, path) {
8
+ if (stack.has(name)) {
9
+ const cycle = [...path.slice(path.indexOf(name)), name];
10
+ throw new FurnaceError(`Furnace config: circular composes dependency detected: ${cycle.join(' → ')}`);
11
+ }
12
+ if (visited.has(name))
13
+ return;
14
+ stack.add(name);
15
+ path.push(name);
16
+ const deps = custom[name]?.composes;
17
+ if (deps) {
18
+ for (const dep of deps) {
19
+ if (dep in custom) {
20
+ visit(dep, path);
21
+ }
22
+ }
23
+ }
24
+ path.pop();
25
+ stack.delete(name);
26
+ visited.add(name);
27
+ }
28
+ for (const name of Object.keys(custom)) {
29
+ visit(name, []);
30
+ }
31
+ }
32
+ /** Validates that every `composes` reference in custom components points to a known component. */
33
+ export function validateComposesReferences(stock, overrides, custom) {
34
+ const known = new Set([...stock, ...Object.keys(overrides), ...Object.keys(custom)]);
35
+ for (const [name, config] of Object.entries(custom)) {
36
+ if (!config.composes)
37
+ continue;
38
+ for (const ref of config.composes) {
39
+ if (!known.has(ref)) {
40
+ throw new FurnaceError(`Furnace config: custom "${name}" composes unknown component "${ref}". ` +
41
+ 'The referenced component must be registered as stock, override, or custom.');
42
+ }
43
+ }
44
+ }
45
+ }
46
+ /**
47
+ * Returns custom component names in topological order so that components
48
+ * depended upon via `composes` are applied before those that compose them.
49
+ * Falls back to insertion order when there are no composes edges.
50
+ */
51
+ export function topologicalSortCustom(custom) {
52
+ const names = Object.keys(custom);
53
+ const inDegree = new Map();
54
+ const dependents = new Map();
55
+ for (const name of names) {
56
+ inDegree.set(name, 0);
57
+ dependents.set(name, []);
58
+ }
59
+ for (const [name, config] of Object.entries(custom)) {
60
+ if (!config.composes)
61
+ continue;
62
+ for (const dep of config.composes) {
63
+ if (dep in custom) {
64
+ inDegree.set(name, (inDegree.get(name) ?? 0) + 1);
65
+ dependents.get(dep)?.push(name);
66
+ }
67
+ }
68
+ }
69
+ const queue = names.filter((n) => (inDegree.get(n) ?? 0) === 0);
70
+ const sorted = [];
71
+ while (queue.length > 0) {
72
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by while condition
73
+ const current = queue.shift();
74
+ sorted.push(current);
75
+ for (const dep of dependents.get(current) ?? []) {
76
+ const newDegree = (inDegree.get(dep) ?? 1) - 1;
77
+ inDegree.set(dep, newDegree);
78
+ if (newDegree === 0) {
79
+ queue.push(dep);
80
+ }
81
+ }
82
+ }
83
+ // If a cycle was somehow missed by config validation, fall back to insertion order
84
+ // for any nodes not reached.
85
+ if (sorted.length < names.length) {
86
+ for (const name of names) {
87
+ if (!sorted.includes(name)) {
88
+ sorted.push(name);
89
+ }
90
+ }
91
+ }
92
+ return sorted;
93
+ }
94
+ //# sourceMappingURL=furnace-graph-utils.js.map
@@ -0,0 +1,108 @@
1
+ import type { FurnacePendingRepairOperation } from '../types/furnace.js';
2
+ import { type RollbackJournal } from './furnace-rollback.js';
3
+ /**
4
+ * The signal names the lifecycle wrapper knows how to react to. Spelled out
5
+ * as a literal union (rather than `NodeJS.Signals`) so the public type
6
+ * surface does not depend on the NodeJS global namespace — consumers of
7
+ * `@hominis/fireforge` may compile against tsconfigs that omit `@types/node`.
8
+ */
9
+ export type FurnaceShutdownSignal = 'SIGINT' | 'SIGTERM';
10
+ /**
11
+ * Context handed to a furnace mutation body so it can register the in-flight
12
+ * rollback journal with the lifecycle wrapper. The wrapper uses the registered
13
+ * journal to perform rollback when the process receives SIGINT/SIGTERM mid-run.
14
+ */
15
+ export interface FurnaceOperationContext {
16
+ /**
17
+ * Registers the rollback journal for the current operation. Must be called
18
+ * once the body has constructed its journal so the signal handler can find
19
+ * it. Calling more than once replaces the prior reference (this is fine for
20
+ * commands that build the journal lazily).
21
+ */
22
+ registerJournal(journal: RollbackJournal): void;
23
+ /**
24
+ * Registers an extra cleanup callback to run during signal-driven teardown
25
+ * in addition to the journal restore. Used by `furnace preview` to make
26
+ * sure `cleanStories` runs even when the user hits Ctrl+C mid-run. The
27
+ * callback should be best-effort and idempotent: cleanup errors are
28
+ * collected, not re-thrown.
29
+ */
30
+ registerCleanup(cleanup: () => Promise<void>): void;
31
+ }
32
+ /** Options for `runFurnaceMutation`. */
33
+ export interface RunFurnaceMutationOptions {
34
+ /**
35
+ * If true, skip lock acquisition and signal-handler installation entirely.
36
+ * Used by dry-run paths where no engine mutation occurs.
37
+ *
38
+ * Note: a dry-run can overlap with a real mutation because it does not
39
+ * acquire the lock. This is safe because dry-runs only read; however, a
40
+ * dry-run that starts before a real mutation and finishes after it may
41
+ * observe partially-written engine state. Accept this trade-off so that
42
+ * concurrent dry-runs never block each other or a real apply.
43
+ */
44
+ dryRun?: boolean;
45
+ /**
46
+ * Override the default 30s lock timeout. The watch-mode caller may want a
47
+ * shorter window so an interactive rebuild fails fast instead of stalling.
48
+ */
49
+ lockTimeoutMs?: number;
50
+ /**
51
+ * If true, skip the pendingRepair pre-flight check. Used by `doctor
52
+ * --repair-furnace` which must be able to mutate the engine even when a
53
+ * pendingRepair marker is set.
54
+ */
55
+ skipPendingRepairCheck?: boolean;
56
+ }
57
+ /**
58
+ * Returns true while a signal-driven rollback is in progress. The bin entry
59
+ * point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
60
+ * trigger a second rollback that races the first. Exposed for the bin shim
61
+ * (and the test suite); production callers should not need it.
62
+ */
63
+ export declare function isSignalRollbackInFlight(): boolean;
64
+ /**
65
+ * Rolls back every in-flight furnace operation and writes a pendingRepair
66
+ * marker for each. Each cleanup callback and journal restore is bounded by a
67
+ * timeout so a stuck I/O operation cannot hang the process indefinitely.
68
+ */
69
+ export declare function rollbackActiveOperationsForSignal(signal: FurnaceShutdownSignal): Promise<void>;
70
+ /**
71
+ * Resolves the path of the lock directory used to serialize furnace mutations
72
+ * for a given project root. Exposed for tests; production callers should not
73
+ * touch this directly.
74
+ */
75
+ export declare function getFurnaceLockPath(root: string): string;
76
+ /**
77
+ * Runs a furnace-mutating body under the apply-wide lock and registers it
78
+ * with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
79
+ * two `furnace apply`/`deploy`/`create`/etc. runs from racing on the engine
80
+ * working copy; the CLI entrypoint's global signal handlers consult this
81
+ * registry and invoke rollback (writing a `pendingRepair` marker when needed)
82
+ * if the user hits Ctrl+C mid-run.
83
+ *
84
+ * Dry-run callers should pass `options.dryRun = true` so the wrapper skips
85
+ * the lock entirely (concurrent dry-runs are safe and shouldn't block each
86
+ * other).
87
+ *
88
+ * The body receives a {@link FurnaceOperationContext}; it must call
89
+ * `ctx.registerJournal(journal)` once it has constructed its rollback journal.
90
+ * Bodies that don't manage a journal directly (e.g. apply, which delegates to
91
+ * `applyAllComponents`) can pass an internal callback through.
92
+ */
93
+ export declare function runFurnaceMutation<T>(root: string, kind: FurnacePendingRepairOperation, body: (ctx: FurnaceOperationContext) => Promise<T>, options?: RunFurnaceMutationOptions): Promise<T>;
94
+ /**
95
+ * Persists an `apply-rollback` (or other operation-kind) `pendingRepair`
96
+ * marker on behalf of a caller that detected a rollback failure outside the
97
+ * signal-handler path (e.g. apply's own catch-around-restore). Exposed so
98
+ * `furnace-apply.ts` can write the marker without taking on a dependency on
99
+ * the lifecycle wrapper's internals.
100
+ */
101
+ export declare function recordFurnaceRollbackFailure(root: string, operation: FurnacePendingRepairOperation, reason: string): Promise<void>;
102
+ /**
103
+ * Test-only helper: tears down the module-scoped state. Vitest workers may
104
+ * reuse the module across tests, so the test suite must call this between
105
+ * cases that exercise the signal pathway. Not exported from the package
106
+ * entry point.
107
+ */
108
+ export declare function __resetFurnaceOperationStateForTests(): void;