@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.
- package/CHANGELOG.md +85 -0
- package/README.md +20 -1
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create-templates.d.ts +26 -0
- package/dist/src/commands/furnace/create-templates.js +86 -0
- package/dist/src/commands/furnace/create.js +77 -103
- package/dist/src/commands/furnace/deploy.js +20 -5
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +33 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate.js +47 -1
- package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
- package/dist/src/core/furnace-apply-ftl.js +102 -0
- package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
- package/dist/src/core/furnace-apply-helpers.js +16 -12
- package/dist/src/core/furnace-apply.js +7 -4
- package/dist/src/core/furnace-config-tokens.d.ts +11 -0
- package/dist/src/core/furnace-config-tokens.js +28 -0
- package/dist/src/core/furnace-config.d.ts +6 -0
- package/dist/src/core/furnace-config.js +8 -1
- package/dist/src/core/furnace-constants.d.ts +20 -0
- package/dist/src/core/furnace-constants.js +32 -0
- package/dist/src/core/furnace-registration-ast.d.ts +13 -1
- package/dist/src/core/furnace-registration-ast.js +58 -25
- package/dist/src/core/furnace-registration.d.ts +28 -1
- package/dist/src/core/furnace-registration.js +98 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/furnace-validate-accessibility.js +8 -2
- package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
- package/dist/src/core/furnace-validate-helpers.js +81 -0
- package/dist/src/core/furnace-validate-registration.d.ts +8 -2
- package/dist/src/core/furnace-validate-registration.js +34 -9
- package/dist/src/core/furnace-validate.js +2 -2
- package/dist/src/core/marionette-preflight.d.ts +39 -0
- package/dist/src/core/marionette-preflight.js +210 -0
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/dist/src/types/commands/options.d.ts +6 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +8 -0
- package/dist/src/utils/process.d.ts +15 -2
- package/dist/src/utils/process.js +73 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
void (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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
|
}
|