@hominis/fireforge 0.13.2 → 0.15.1

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 (78) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +20 -1
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create-templates.d.ts +26 -0
  13. package/dist/src/commands/furnace/create-templates.js +86 -0
  14. package/dist/src/commands/furnace/create.js +77 -103
  15. package/dist/src/commands/furnace/deploy.js +20 -5
  16. package/dist/src/commands/furnace/diff.js +3 -1
  17. package/dist/src/commands/furnace/init.js +25 -7
  18. package/dist/src/commands/furnace/list.js +15 -7
  19. package/dist/src/commands/furnace/override.js +47 -15
  20. package/dist/src/commands/furnace/remove.js +68 -20
  21. package/dist/src/commands/furnace/rename.js +31 -3
  22. package/dist/src/commands/furnace/scan.js +8 -0
  23. package/dist/src/commands/furnace/validate.js +70 -7
  24. package/dist/src/commands/import.js +65 -11
  25. package/dist/src/commands/re-export.js +11 -4
  26. package/dist/src/commands/rebase/abort.js +26 -14
  27. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  28. package/dist/src/commands/rebase/confirm.js +2 -2
  29. package/dist/src/commands/rebase/continue.js +39 -15
  30. package/dist/src/commands/rebase/index.js +2 -1
  31. package/dist/src/commands/rebase/patch-loop.js +90 -33
  32. package/dist/src/commands/register.js +13 -0
  33. package/dist/src/commands/resolve.js +31 -10
  34. package/dist/src/commands/run.js +9 -44
  35. package/dist/src/commands/setup-support.js +25 -7
  36. package/dist/src/commands/status.js +59 -8
  37. package/dist/src/commands/test.js +33 -7
  38. package/dist/src/commands/token.js +11 -1
  39. package/dist/src/commands/watch.js +51 -1
  40. package/dist/src/commands/wire.js +23 -0
  41. package/dist/src/core/config-paths.d.ts +2 -2
  42. package/dist/src/core/config-paths.js +2 -0
  43. package/dist/src/core/config-validate.js +47 -1
  44. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  45. package/dist/src/core/furnace-apply-ftl.js +102 -0
  46. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  47. package/dist/src/core/furnace-apply-helpers.js +16 -12
  48. package/dist/src/core/furnace-apply.js +7 -4
  49. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  50. package/dist/src/core/furnace-config-tokens.js +28 -0
  51. package/dist/src/core/furnace-config.d.ts +6 -0
  52. package/dist/src/core/furnace-config.js +8 -1
  53. package/dist/src/core/furnace-constants.d.ts +20 -0
  54. package/dist/src/core/furnace-constants.js +32 -0
  55. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  56. package/dist/src/core/furnace-registration-ast.js +58 -25
  57. package/dist/src/core/furnace-registration.d.ts +28 -1
  58. package/dist/src/core/furnace-registration.js +98 -1
  59. package/dist/src/core/furnace-staleness.d.ts +17 -0
  60. package/dist/src/core/furnace-staleness.js +58 -0
  61. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  62. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  63. package/dist/src/core/furnace-validate-helpers.js +81 -0
  64. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  65. package/dist/src/core/furnace-validate-registration.js +34 -9
  66. package/dist/src/core/furnace-validate.js +2 -2
  67. package/dist/src/core/marionette-preflight.d.ts +39 -0
  68. package/dist/src/core/marionette-preflight.js +210 -0
  69. package/dist/src/core/signal-critical.d.ts +49 -0
  70. package/dist/src/core/signal-critical.js +80 -0
  71. package/dist/src/errors/download.d.ts +1 -1
  72. package/dist/src/errors/download.js +6 -3
  73. package/dist/src/types/commands/options.d.ts +6 -0
  74. package/dist/src/types/config.d.ts +7 -0
  75. package/dist/src/types/furnace.d.ts +8 -0
  76. package/dist/src/utils/process.d.ts +15 -2
  77. package/dist/src/utils/process.js +73 -0
  78. package/package.json +1 -1
@@ -39,59 +39,157 @@ function checksumMapsEqual(a, b) {
39
39
  }
40
40
  return true;
41
41
  }
42
+ /**
43
+ * Builds a watch-loop apply-failure message tailored to the error class so
44
+ * transient filesystem errors (EACCES, ENOSPC, lock timeout) look different
45
+ * from genuine apply-level failures; the previous generic "Apply failed: ..."
46
+ * collapsed all causes into one string and made diagnosis difficult.
47
+ */
48
+ function classifyWatchApplyError(err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ if (err instanceof FurnaceError) {
51
+ return `Apply failed: ${message}`;
52
+ }
53
+ const code = err instanceof Error ? err.code : undefined;
54
+ switch (code) {
55
+ case 'EACCES':
56
+ case 'EPERM':
57
+ return `Apply failed: permission denied — ${message}`;
58
+ case 'ENOSPC':
59
+ return `Apply failed: disk full — ${message}`;
60
+ case 'EBUSY':
61
+ case 'ETXTBSY':
62
+ return `Apply failed: file is in use — ${message}`;
63
+ case 'ENOENT':
64
+ return `Apply failed: missing file — ${message}`;
65
+ case 'ETIMEDOUT':
66
+ return `Apply failed: operation timed out — ${message}`;
67
+ default:
68
+ return `Apply failed: ${message}`;
69
+ }
70
+ }
42
71
  async function runWatchLoop(projectRoot) {
43
72
  const furnacePaths = getFurnacePaths(projectRoot);
73
+ // Both categories are eligible targets. The set is fixed; only existence
74
+ // varies over time — a component dir created AFTER watch started (e.g.
75
+ // the user runs `furnace create` in another terminal) must be picked up
76
+ // without restarting watch. The prior one-shot `pathExists` check at
77
+ // startup captured only the dirs that existed then, leaving any later
78
+ // creation invisible.
79
+ const candidateDirs = [furnacePaths.overridesDir, furnacePaths.customDir];
44
80
  const watchDirs = [];
45
- if (await pathExists(furnacePaths.overridesDir))
46
- watchDirs.push(furnacePaths.overridesDir);
47
- if (await pathExists(furnacePaths.customDir))
48
- watchDirs.push(furnacePaths.customDir);
49
- if (watchDirs.length === 0) {
50
- info('No component directories to watch.');
51
- return;
52
- }
81
+ const watchers = new Map();
53
82
  if (process.platform === 'linux') {
54
83
  warn('Watch mode uses fs.watch with recursive: true, which may miss changes ' +
55
84
  'in deeply nested directories on Linux. A periodic poll runs every 30s as a fallback.');
56
85
  }
57
- info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
58
86
  let debounceTimer = null;
59
87
  let applyInFlight = false;
60
- let lastChecksums = await snapshotWatchedChecksums(watchDirs);
88
+ // Coalesces changes that arrive while an apply is running. Without this
89
+ // flag, a second edit during an in-flight apply is debounced, the timer
90
+ // fires while applyInFlight is true, and the change is dropped entirely
91
+ // because the post-apply checksum snapshot already reflects the edit so
92
+ // the 30s poll also sees no diff.
93
+ let pendingChange = false;
94
+ let lastChecksums = new Map();
95
+ let pollTimer = null;
96
+ const runApplyCycle = async () => {
97
+ applyInFlight = true;
98
+ try {
99
+ info('\nChange detected — re-applying...');
100
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
101
+ logApplyResult(result, false);
102
+ const applied = result.applied.length;
103
+ const skipped = result.skipped.length;
104
+ info(`Re-applied: ${applied} applied, ${skipped} skipped`);
105
+ }
106
+ catch (err) {
107
+ warn(classifyWatchApplyError(err));
108
+ }
109
+ finally {
110
+ applyInFlight = false;
111
+ // Update checksums after apply so the next poll does not re-trigger
112
+ // for changes that are already reflected in the engine.
113
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
114
+ }
115
+ // Another change arrived while we were applying — run again so the edit
116
+ // is not silently absorbed into the post-apply checksum bump.
117
+ if (pendingChange) {
118
+ pendingChange = false;
119
+ await runApplyCycle();
120
+ }
121
+ };
61
122
  const triggerApply = () => {
123
+ if (applyInFlight) {
124
+ // An apply is already running; record the change so runApplyCycle
125
+ // re-runs after it completes. Do not schedule a new debounce: the
126
+ // in-flight apply will observe this flag when it finishes.
127
+ pendingChange = true;
128
+ return;
129
+ }
62
130
  if (debounceTimer)
63
131
  clearTimeout(debounceTimer);
64
132
  debounceTimer = setTimeout(() => {
65
- if (applyInFlight)
133
+ debounceTimer = null;
134
+ if (applyInFlight) {
135
+ // Race with a change that started its own apply between the debounce
136
+ // scheduling and the timer firing. Record the pending change; the
137
+ // in-flight apply will pick it up.
138
+ pendingChange = true;
66
139
  return;
67
- applyInFlight = true;
68
- void (async () => {
69
- try {
70
- info('\nChange detected — re-applying...');
71
- const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
72
- logApplyResult(result, false);
73
- const applied = result.applied.length;
74
- const skipped = result.skipped.length;
75
- info(`Re-applied: ${applied} applied, ${skipped} skipped`);
76
- }
77
- catch (err) {
78
- warn(`Apply failed: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ void runApplyCycle();
142
+ }, 300);
143
+ };
144
+ const installWatcher = (dir) => {
145
+ if (watchers.has(dir))
146
+ return false;
147
+ try {
148
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
149
+ if (!filename)
150
+ return;
151
+ if (isComponentSourceFile(filename)) {
152
+ triggerApply();
79
153
  }
80
- finally {
81
- applyInFlight = false;
82
- // Update checksums after apply so the next poll does not re-trigger.
83
- lastChecksums = await snapshotWatchedChecksums(watchDirs);
154
+ });
155
+ watcher.on('error', (err) => {
156
+ warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
157
+ });
158
+ watchers.set(dir, watcher);
159
+ watchDirs.push(dir);
160
+ return true;
161
+ }
162
+ catch (err) {
163
+ // Directory vanished between the pathExists check and fs.watch, or
164
+ // fs.watch otherwise refused. refreshWatchers will retry on the next
165
+ // poll tick.
166
+ warn(`Could not start watcher on ${dir}: ${err instanceof Error ? err.message : String(err)}`);
167
+ return false;
168
+ }
169
+ };
170
+ // Scans candidate dirs for ones we are not yet watching and installs a
171
+ // watcher for each that now exists. Returns true when at least one new
172
+ // watcher was installed so the caller can trigger an apply cycle for
173
+ // the just-noticed content.
174
+ const refreshWatchers = async () => {
175
+ let added = false;
176
+ for (const dir of candidateDirs) {
177
+ if (watchers.has(dir))
178
+ continue;
179
+ if (await pathExists(dir)) {
180
+ if (installWatcher(dir)) {
181
+ info(`Now watching ${dir}`);
182
+ added = true;
84
183
  }
85
- })();
86
- }, 300);
184
+ }
185
+ }
186
+ return added;
87
187
  };
88
188
  // Register signal-driven cleanup BEFORE creating watchers so there is no
89
189
  // race window where a SIGINT could arrive after watchers exist but before
90
190
  // cleanup handlers are registered.
91
- const watchers = [];
92
- let pollTimer = null;
93
191
  const cleanup = () => {
94
- for (const w of watchers)
192
+ for (const w of watchers.values())
95
193
  w.close();
96
194
  if (debounceTimer)
97
195
  clearTimeout(debounceTimer);
@@ -100,26 +198,28 @@ async function runWatchLoop(projectRoot) {
100
198
  };
101
199
  process.once('SIGINT', cleanup);
102
200
  process.once('SIGTERM', cleanup);
103
- for (const dir of watchDirs) {
104
- const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
105
- if (!filename)
106
- return;
107
- if (isComponentSourceFile(filename)) {
108
- triggerApply();
109
- }
110
- });
111
- watcher.on('error', (err) => {
112
- warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
113
- });
114
- watchers.push(watcher);
115
- }
116
- // Periodic checksum-based poll to catch events missed by fs.watch (known
117
- // issue on Linux with recursive: true and certain filesystems).
201
+ await refreshWatchers();
202
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
203
+ if (watchDirs.length === 0) {
204
+ info('No component directories exist yet — will retry every 30s. Create one with "fireforge furnace override" or "fireforge furnace create" in another terminal to begin watching.');
205
+ }
206
+ else {
207
+ info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
208
+ }
209
+ // Periodic checksum-based poll that also picks up newly-created component
210
+ // dirs (fs.watch was not installed for them at startup because they did
211
+ // not yet exist).
118
212
  pollTimer = setInterval(() => {
119
213
  if (applyInFlight)
120
214
  return;
121
215
  void (async () => {
122
216
  try {
217
+ const added = await refreshWatchers();
218
+ if (added) {
219
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
220
+ triggerApply();
221
+ return;
222
+ }
123
223
  const current = await snapshotWatchedChecksums(watchDirs);
124
224
  if (!checksumMapsEqual(current, lastChecksums)) {
125
225
  triggerApply();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * File-content templates for `fireforge furnace create`. Extracted from the
3
+ * command entrypoint so the generator is unit-testable in isolation and the
4
+ * command file stays under the per-file LOC budget.
5
+ */
6
+ /**
7
+ * Generates the .mjs file content for a custom component.
8
+ *
9
+ * `MozLitElement` does NOT expose `insertFTLIfNeeded` — that method lives on
10
+ * `MozXULElement`. Calling it from `connectedCallback` on a Lit-based
11
+ * component throws `TypeError: this.insertFTLIfNeeded is not a function` at
12
+ * every connect. Upstream Firefox components (e.g. `moz-input-folder.mjs`)
13
+ * solve this with a module-level guarded call on `window.MozXULElement` and
14
+ * per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
15
+ * that pattern here so `--localized` produces functional code.
16
+ *
17
+ * The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
18
+ * `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
19
+ * yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
20
+ * widgets ship.
21
+ */
22
+ export declare function generateMjsContent(name: string, className: string, description: string, localized: boolean, header: string, ftlChromeSubPath: string | undefined): string;
23
+ /** Generates the .css file content for a custom component. */
24
+ export declare function generateCssContent(header: string): string;
25
+ /** Generates the .ftl file content for a custom component. */
26
+ export declare function generateFtlContent(name: string, header: string): string;
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * File-content templates for `fireforge furnace create`. Extracted from the
4
+ * command entrypoint so the generator is unit-testable in isolation and the
5
+ * command file stays under the per-file LOC budget.
6
+ */
7
+ /**
8
+ * Generates the .mjs file content for a custom component.
9
+ *
10
+ * `MozLitElement` does NOT expose `insertFTLIfNeeded` — that method lives on
11
+ * `MozXULElement`. Calling it from `connectedCallback` on a Lit-based
12
+ * component throws `TypeError: this.insertFTLIfNeeded is not a function` at
13
+ * every connect. Upstream Firefox components (e.g. `moz-input-folder.mjs`)
14
+ * solve this with a module-level guarded call on `window.MozXULElement` and
15
+ * per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
16
+ * that pattern here so `--localized` produces functional code.
17
+ *
18
+ * The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
19
+ * `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
20
+ * yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
21
+ * widgets ship.
22
+ */
23
+ export function generateMjsContent(name, className, description, localized, header, ftlChromeSubPath) {
24
+ const ftlPath = ftlChromeSubPath !== undefined ? `${ftlChromeSubPath}/${name}.ftl` : `${name}.ftl`;
25
+ const ftlModulePreamble = localized
26
+ ? `
27
+ window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
28
+ `
29
+ : '';
30
+ const lifecycleHooks = localized
31
+ ? `
32
+ connectedCallback() {
33
+ super.connectedCallback();
34
+ this.ownerDocument.l10n?.connectRoot(this.shadowRoot);
35
+ }
36
+
37
+ disconnectedCallback() {
38
+ super.disconnectedCallback();
39
+ this.ownerDocument.l10n?.disconnectRoot(this.shadowRoot);
40
+ }
41
+ `
42
+ : '';
43
+ return `${header}
44
+
45
+ import { html } from "chrome://global/content/vendor/lit.all.mjs";
46
+ import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
47
+ ${ftlModulePreamble}
48
+ /**
49
+ * ${description || name}
50
+ *
51
+ * @tagname ${name}
52
+ */
53
+ class ${className} extends MozLitElement {
54
+ static properties = {};
55
+
56
+ constructor() {
57
+ super();
58
+ }
59
+ ${lifecycleHooks}
60
+ render() {
61
+ return html\`
62
+ <link rel="stylesheet" href="chrome://global/content/elements/${name}.css" />
63
+ <slot></slot>
64
+ \`;
65
+ }
66
+ }
67
+ customElements.define("${name}", ${className});
68
+ `;
69
+ }
70
+ /** Generates the .css file content for a custom component. */
71
+ export function generateCssContent(header) {
72
+ return `${header}
73
+
74
+ :host {
75
+ display: block;
76
+ }
77
+ `;
78
+ }
79
+ /** Generates the .ftl file content for a custom component. */
80
+ export function generateFtlContent(name, header) {
81
+ return `${header}
82
+
83
+ ## Strings for the ${name} component
84
+ `;
85
+ }
86
+ //# sourceMappingURL=create-templates.js.map
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { multiselect, text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
5
  import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
- import { tagNameToClassName } from '../../core/furnace-constants.js';
6
+ import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
8
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
9
9
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
@@ -15,6 +15,7 @@ import { FurnaceError } from '../../errors/furnace.js';
15
15
  import { toError } from '../../utils/errors.js';
16
16
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
17
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
18
+ import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
18
19
  async function loadAuthoringFurnaceConfig(projectRoot) {
19
20
  if (await furnaceConfigExists(projectRoot)) {
20
21
  return loadFurnaceConfig(projectRoot);
@@ -46,65 +47,6 @@ function checkNameConflict(config, name) {
46
47
  }
47
48
  return undefined;
48
49
  }
49
- /**
50
- * Generates the .mjs file content for a custom component.
51
- */
52
- function generateMjsContent(name, className, description, localized, header) {
53
- const connectedCallback = localized
54
- ? `
55
- connectedCallback() {
56
- super.connectedCallback();
57
- this.insertFTLIfNeeded("${name}.ftl");
58
- }
59
- `
60
- : '';
61
- return `${header}
62
-
63
- import { html } from "chrome://global/content/vendor/lit.all.mjs";
64
- import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
65
-
66
- /**
67
- * ${description || name}
68
- *
69
- * @tagname ${name}
70
- */
71
- class ${className} extends MozLitElement {
72
- static properties = {};
73
-
74
- constructor() {
75
- super();
76
- }
77
- ${connectedCallback}
78
- render() {
79
- return html\`
80
- <link rel="stylesheet" href="chrome://global/content/elements/${name}.css" />
81
- <slot></slot>
82
- \`;
83
- }
84
- }
85
- customElements.define("${name}", ${className});
86
- `;
87
- }
88
- /**
89
- * Generates the .css file content for a custom component.
90
- */
91
- function generateCssContent(header) {
92
- return `${header}
93
-
94
- :host {
95
- display: block;
96
- }
97
- `;
98
- }
99
- /**
100
- * Generates the .ftl file content for a custom component.
101
- */
102
- function generateFtlContent(name, header) {
103
- return `${header}
104
-
105
- ## Strings for the ${name} component
106
- `;
107
- }
108
50
  /**
109
51
  * Scaffolds browser mochitest files for a newly created custom component.
110
52
  * @param componentName - Custom element tag name
@@ -134,7 +76,11 @@ async function scaffoldTestFiles(componentName, license, forgeConfig, paths, jou
134
76
  // browser.toml — create if missing, append entry if existing
135
77
  const tomlPath = join(testDir, 'browser.toml');
136
78
  if (await pathExists(tomlPath)) {
137
- // Append the new test entry if not already present
79
+ // Defensive guard: only append if the entry is not already present.
80
+ // With a fresh journal per create, the same test file name cannot be
81
+ // appended twice in a single run — but retaining the check protects
82
+ // against accidental re-entrance or a future refactor that reuses the
83
+ // helper with a stale test directory.
138
84
  const existingToml = await readText(tomlPath);
139
85
  if (!existingToml.includes(`["${testFileName}"]`)) {
140
86
  if (journal)
@@ -254,13 +200,13 @@ async function resolveCreateFeatures(isInteractive, options) {
254
200
  * @param journal - Optional rollback journal that snapshots files before writes
255
201
  * @returns Relative filenames written for the component
256
202
  */
257
- async function writeComponentFiles(componentDir, componentName, className, description, localized, license, journal) {
203
+ async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, journal) {
258
204
  await ensureDir(componentDir);
259
205
  const files = [`${componentName}.mjs`, `${componentName}.css`];
260
206
  const mjsPath = join(componentDir, `${componentName}.mjs`);
261
207
  if (journal)
262
208
  await snapshotFile(journal, mjsPath);
263
- const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'));
209
+ const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath);
264
210
  await writeText(mjsPath, mjsContent);
265
211
  const cssPath = join(componentDir, `${componentName}.css`);
266
212
  if (journal)
@@ -284,15 +230,22 @@ async function writeComponentFiles(componentDir, componentName, className, descr
284
230
  * state.
285
231
  */
286
232
  async function performCreateMutations(args) {
233
+ // Invariant: the journal MUST be registered with the operation context
234
+ // BEFORE any filesystem mutation (including recordCreatedDir, whose entries
235
+ // are consulted by SIGINT rollback). The try/catch below assumes signal
236
+ // handlers can find the journal for any partial write that follows.
287
237
  const journal = createRollbackJournal();
288
238
  if (args.operationContext) {
289
239
  args.operationContext.registerJournal(journal);
290
240
  }
291
- recordCreatedDir(journal, args.componentDir);
292
241
  const testFiles = [];
293
242
  let files;
294
243
  try {
295
- files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
244
+ // Record the componentDir creation entry immediately after registration
245
+ // so signal-driven rollback can clean it up even if writeComponentFiles
246
+ // is interrupted mid-ensureDir.
247
+ recordCreatedDir(journal, args.componentDir);
248
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, journal);
296
249
  const customEntry = {
297
250
  description: args.description,
298
251
  targetPath: `toolkit/content/widgets/${args.componentName}`,
@@ -322,6 +275,58 @@ async function performCreateMutations(args) {
322
275
  }
323
276
  return { files, testFiles };
324
277
  }
278
+ /**
279
+ * Prompts the operator for a description when the command is interactive and
280
+ * the operator did not pass `-d`. Returns the resolved description string.
281
+ */
282
+ async function resolveDescription(isInteractive, options) {
283
+ let description = options.description ?? '';
284
+ if (!description && isInteractive) {
285
+ const descResult = await text({
286
+ message: 'Description (optional):',
287
+ placeholder: 'A brief description of the component',
288
+ });
289
+ if (!isCancel(descResult)) {
290
+ description = String(descResult);
291
+ }
292
+ }
293
+ return description;
294
+ }
295
+ /**
296
+ * Validates the `--compose` targets against registered components and runs
297
+ * cycle detection if the new component is introduced into the graph. Throws
298
+ * on any failure; returns when the graph is clean.
299
+ */
300
+ function validateComposesTargets(config, componentName, composes) {
301
+ if (!composes || composes.length === 0)
302
+ return;
303
+ const known = new Set([
304
+ ...config.stock,
305
+ ...Object.keys(config.overrides),
306
+ ...Object.keys(config.custom),
307
+ ]);
308
+ for (const tag of composes) {
309
+ if (tag === componentName) {
310
+ throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
311
+ }
312
+ if (!known.has(tag)) {
313
+ throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
314
+ 'The referenced component must be registered as stock, override, or custom.');
315
+ }
316
+ }
317
+ // Check for cycles that would be introduced by adding this component.
318
+ const tempCustom = {
319
+ ...config.custom,
320
+ [componentName]: {
321
+ description: '',
322
+ targetPath: `toolkit/content/widgets/${componentName}`,
323
+ register: true,
324
+ localized: false,
325
+ composes,
326
+ },
327
+ };
328
+ detectComposesCycles(tempCustom);
329
+ }
325
330
  /**
326
331
  * Runs the furnace create command to scaffold a new custom component.
327
332
  * @param projectRoot - Root directory of the project
@@ -383,16 +388,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
383
388
  warn(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}".`);
384
389
  }
385
390
  // --- Resolve description ---
386
- let description = options.description ?? '';
387
- if (!description && isInteractive) {
388
- const descResult = await text({
389
- message: 'Description (optional):',
390
- placeholder: 'A brief description of the component',
391
- });
392
- if (!isCancel(descResult)) {
393
- description = String(descResult);
394
- }
395
- }
391
+ const description = await resolveDescription(isInteractive, options);
396
392
  // --- Resolve features ---
397
393
  const featureSelection = await resolveCreateFeatures(isInteractive, options);
398
394
  if (!featureSelection) {
@@ -417,39 +413,16 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
417
413
  // --- Validate --compose targets BEFORE any writes so a failed validation
418
414
  // does not strand component files behind.
419
415
  const composes = options.compose;
420
- if (composes && composes.length > 0) {
421
- const known = new Set([
422
- ...config.stock,
423
- ...Object.keys(config.overrides),
424
- ...Object.keys(config.custom),
425
- ]);
426
- for (const tag of composes) {
427
- if (tag === componentName) {
428
- throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
429
- }
430
- if (!known.has(tag)) {
431
- throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
432
- 'The referenced component must be registered as stock, override, or custom.');
433
- }
434
- }
435
- // Check for cycles that would be introduced by adding this component.
436
- const tempCustom = {
437
- ...config.custom,
438
- [componentName]: {
439
- description: '',
440
- targetPath: `toolkit/content/widgets/${componentName}`,
441
- register: true,
442
- localized: false,
443
- composes,
444
- },
445
- };
446
- detectComposesCycles(tempCustom);
447
- }
416
+ validateComposesTargets(config, componentName, composes);
448
417
  // All validation is done. Hand off to the transactional mutation helper
449
418
  // so any failure restores the workspace and engine to their pre-command
450
419
  // state via the shared rollback journal. The mutation runs under the
451
420
  // furnace-wide lock and is registered with the global SIGINT/SIGTERM
452
421
  // rollback pathway.
422
+ // Derive the FTL chrome sub-path from the configured ftlBasePath so the
423
+ // generated `.mjs` calls `insertFTLIfNeeded` at a URI that actually matches
424
+ // the locale jar.mn entry `furnace apply` will write.
425
+ const ftlChromeSubPath = resolveFtlChromeSubPath(config.ftlBasePath);
453
426
  const { files, testFiles } = await runFurnaceMutation(projectRoot, 'create-rollback', (ctx) => performCreateMutations({
454
427
  projectRoot,
455
428
  componentName,
@@ -465,6 +438,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
465
438
  paths,
466
439
  license,
467
440
  withTests,
441
+ ftlChromeSubPath,
468
442
  operationContext: ctx,
469
443
  }));
470
444
  // --- Success ---
@@ -44,10 +44,25 @@ function getFailedComponentNames(result) {
44
44
  }
45
45
  function getPersistableAppliedEntry(name, appliedEntry) {
46
46
  if (!appliedEntry) {
47
- throw new FurnaceError(`Named deploy for "${name}" completed without an applied entry.`);
47
+ throw new FurnaceError(`Deploy for "${name}" finished without producing an applied component entry; ` +
48
+ `furnace state was not modified. Run "fireforge doctor --repair-furnace" to ` +
49
+ `reconcile state, then retry the deploy. If this persists, file a bug with the ` +
50
+ `output of "fireforge doctor".`);
48
51
  }
49
52
  if (appliedEntry.type !== 'override' && appliedEntry.type !== 'custom') {
50
- throw new FurnaceError(`Named deploy for "${name}" returned unsupported component type "${appliedEntry.type}".`);
53
+ throw new FurnaceError(`Deploy for "${name}" returned an unsupported component type "${appliedEntry.type}"; ` +
54
+ `furnace state was not modified. Run "fireforge doctor --repair-furnace" to reconcile, ` +
55
+ `then verify the component with "fireforge furnace validate" before retrying.`);
56
+ }
57
+ // Guard against future refactors that might reorder or misroute the
58
+ // applied[] array: named deploy persists state under a single component
59
+ // name, so the first applied entry MUST be that component. Persisting a
60
+ // different component's checksums here would cause the next status/apply
61
+ // run to mis-report health for both components involved.
62
+ if (name !== undefined && appliedEntry.name !== name) {
63
+ throw new FurnaceError(`Deploy for "${name}" returned an applied entry for a different component ` +
64
+ `("${appliedEntry.name}"); refusing to persist mismatched state. ` +
65
+ `Run "fireforge doctor --repair-furnace" to reconcile, then retry the deploy.`);
51
66
  }
52
67
  return {
53
68
  name: appliedEntry.name,
@@ -147,7 +162,7 @@ async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
147
162
  * @param isDryRun - Whether file writes should be skipped
148
163
  * @returns Apply result for the named component, or `stock` for stock-only entries
149
164
  */
150
- async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot) {
165
+ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot, markerComment) {
151
166
  const rollbackJournal = isDryRun ? undefined : createRollbackJournal();
152
167
  if (rollbackJournal && operationContext) {
153
168
  operationContext.registerJournal(rollbackJournal);
@@ -186,7 +201,7 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir
186
201
  throw new FurnaceError(`Component directory not found: components/custom/${name}`, name);
187
202
  }
188
203
  try {
189
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal);
204
+ const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal, markerComment !== undefined ? { markerComment } : {});
190
205
  if (isDryRun && actions) {
191
206
  result.actions = actions;
192
207
  }
@@ -302,7 +317,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
302
317
  // `furnace deploy` runs only contend on the actual mutation.
303
318
  const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
304
319
  if (name) {
305
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot);
320
+ const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, forgeConfig.markerComment);
306
321
  if (namedApplyResult === 'stock') {
307
322
  return { kind: 'stock' };
308
323
  }