@hominis/fireforge 0.10.1 → 0.11.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 +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +6 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FurnaceError } from '../errors/furnace.js';
|
|
4
|
+
import { toError } from '../utils/errors.js';
|
|
5
|
+
import { warn } from '../utils/logger.js';
|
|
6
|
+
import { FIREFORGE_DIR } from './config-paths.js';
|
|
7
|
+
import { withFileLock } from './file-lock.js';
|
|
8
|
+
import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
|
|
9
|
+
import { restoreRollbackJournal } from './furnace-rollback.js';
|
|
10
|
+
/** Sidecar lock filename used to serialize concurrent furnace mutations. */
|
|
11
|
+
const FURNACE_LOCK_FILENAME = 'furnace.lock';
|
|
12
|
+
const activeOperations = new Map();
|
|
13
|
+
let nextOperationToken = 1;
|
|
14
|
+
let signalRollbackInFlight = false;
|
|
15
|
+
/**
|
|
16
|
+
* Returns true while a signal-driven rollback is in progress. The bin entry
|
|
17
|
+
* point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
|
|
18
|
+
* trigger a second rollback that races the first. Exposed for the bin shim
|
|
19
|
+
* (and the test suite); production callers should not need it.
|
|
20
|
+
*/
|
|
21
|
+
export function isSignalRollbackInFlight() {
|
|
22
|
+
return signalRollbackInFlight;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Rolls back every in-flight furnace operation and writes a pendingRepair
|
|
26
|
+
* marker for each. The bin entry point installs SIGINT/SIGTERM handlers that
|
|
27
|
+
* call this and then exit; calling it directly from inside the library would
|
|
28
|
+
* violate the "process.exit only in bin" invariant. The function is also
|
|
29
|
+
* exposed under this name so the test suite can exercise the teardown path
|
|
30
|
+
* without going through `process.emit` / `process.exit`.
|
|
31
|
+
*/
|
|
32
|
+
/** Maximum time (ms) the signal-driven rollback may take per operation. */
|
|
33
|
+
const SIGNAL_ROLLBACK_TIMEOUT_MS = 15_000;
|
|
34
|
+
/** Races a promise against a deadline, rejecting with a timeout error if the deadline expires. */
|
|
35
|
+
function withTimeout(promise, ms, label) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
39
|
+
}, ms);
|
|
40
|
+
promise.then((value) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve(value);
|
|
43
|
+
}, (error) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Rolls back every in-flight furnace operation and writes a pendingRepair
|
|
51
|
+
* marker for each. Each cleanup callback and journal restore is bounded by a
|
|
52
|
+
* timeout so a stuck I/O operation cannot hang the process indefinitely.
|
|
53
|
+
*/
|
|
54
|
+
export async function rollbackActiveOperationsForSignal(signal) {
|
|
55
|
+
signalRollbackInFlight = true;
|
|
56
|
+
warn(`Received ${signal}; rolling back in-flight furnace mutations…`);
|
|
57
|
+
// Snapshot the active operations so we don't race with `runFurnaceMutation`
|
|
58
|
+
// clearing slots during normal completion.
|
|
59
|
+
const snapshot = [...activeOperations.values()];
|
|
60
|
+
for (const op of snapshot) {
|
|
61
|
+
// If the body completed successfully and is in its finally-block cleanup
|
|
62
|
+
// (deleting the token), skip rollback — the mutation committed cleanly.
|
|
63
|
+
if (op.completed)
|
|
64
|
+
continue;
|
|
65
|
+
const cleanupErrors = [];
|
|
66
|
+
// Run extra cleanup callbacks first (e.g. preview's cleanStories), so the
|
|
67
|
+
// engine is in its tidiest possible shape before the journal restore
|
|
68
|
+
// writes the original file contents back over the top.
|
|
69
|
+
for (const cleanup of op.cleanups) {
|
|
70
|
+
try {
|
|
71
|
+
await withTimeout(cleanup(), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Cleanup callback');
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
cleanupErrors.push(toError(error).message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!op.journal) {
|
|
78
|
+
// The body had not yet handed us a journal — nothing to roll back. We
|
|
79
|
+
// still write a marker because the body may have started mutating the
|
|
80
|
+
// engine before reaching the registerJournal call.
|
|
81
|
+
const cleanupSuffix = cleanupErrors.length > 0 ? `; cleanup errors: ${cleanupErrors.join('; ')}` : '';
|
|
82
|
+
await persistPendingRepair(op.root, op.kind, `interrupted by ${signal} before any state was captured${cleanupSuffix}`).catch((error) => {
|
|
83
|
+
warn(`Could not persist pending-repair marker: ${toError(error).message}`);
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let rollbackError;
|
|
88
|
+
try {
|
|
89
|
+
await withTimeout(restoreRollbackJournal(op.journal), SIGNAL_ROLLBACK_TIMEOUT_MS, 'Rollback journal restore');
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
rollbackError = toError(error).message;
|
|
93
|
+
}
|
|
94
|
+
// A clean signal-driven rollback is not itself a repairable problem:
|
|
95
|
+
// preview/apply/deploy/remove were interrupted, but the engine was restored
|
|
96
|
+
// successfully and the next `doctor` run should remain green. Persist a
|
|
97
|
+
// pending-repair marker only when rollback was incomplete or uncertain.
|
|
98
|
+
if (rollbackError || cleanupErrors.length > 0) {
|
|
99
|
+
const reasonParts = [`interrupted by ${signal}`];
|
|
100
|
+
if (rollbackError) {
|
|
101
|
+
reasonParts.push(`automatic rollback failed: ${rollbackError}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
reasonParts.push('automatic rollback succeeded');
|
|
105
|
+
}
|
|
106
|
+
if (cleanupErrors.length > 0) {
|
|
107
|
+
reasonParts.push(`cleanup errors: ${cleanupErrors.join('; ')}`);
|
|
108
|
+
}
|
|
109
|
+
await persistPendingRepair(op.root, op.kind, reasonParts.join('; ')).catch((error) => {
|
|
110
|
+
warn(`Could not persist pending-repair marker: ${toError(error).message}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function persistPendingRepair(root, operation, reason) {
|
|
116
|
+
await updateFurnaceState(root, (state) => ({
|
|
117
|
+
...state,
|
|
118
|
+
pendingRepair: {
|
|
119
|
+
operation,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
reason,
|
|
122
|
+
},
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolves the path of the lock directory used to serialize furnace mutations
|
|
127
|
+
* for a given project root. Exposed for tests; production callers should not
|
|
128
|
+
* touch this directly.
|
|
129
|
+
*/
|
|
130
|
+
export function getFurnaceLockPath(root) {
|
|
131
|
+
return join(root, FIREFORGE_DIR, FURNACE_LOCK_FILENAME);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Runs a furnace-mutating body under the apply-wide lock and registers it
|
|
135
|
+
* with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
|
|
136
|
+
* two `furnace apply`/`deploy`/`create`/etc. runs from racing on the engine
|
|
137
|
+
* working copy; the CLI entrypoint's global signal handlers consult this
|
|
138
|
+
* registry and invoke rollback (writing a `pendingRepair` marker when needed)
|
|
139
|
+
* if the user hits Ctrl+C mid-run.
|
|
140
|
+
*
|
|
141
|
+
* Dry-run callers should pass `options.dryRun = true` so the wrapper skips
|
|
142
|
+
* the lock entirely (concurrent dry-runs are safe and shouldn't block each
|
|
143
|
+
* other).
|
|
144
|
+
*
|
|
145
|
+
* The body receives a {@link FurnaceOperationContext}; it must call
|
|
146
|
+
* `ctx.registerJournal(journal)` once it has constructed its rollback journal.
|
|
147
|
+
* Bodies that don't manage a journal directly (e.g. apply, which delegates to
|
|
148
|
+
* `applyAllComponents`) can pass an internal callback through.
|
|
149
|
+
*/
|
|
150
|
+
export async function runFurnaceMutation(root, kind, body, options = {}) {
|
|
151
|
+
if (options.dryRun) {
|
|
152
|
+
// Dry-run: no lock, no signal handler, no journal registration. The body
|
|
153
|
+
// is still given a no-op context so callers can use the same shape.
|
|
154
|
+
return body({
|
|
155
|
+
registerJournal: () => undefined,
|
|
156
|
+
registerCleanup: () => undefined,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Pre-flight: refuse to mutate when a previous operation left the engine in
|
|
160
|
+
// a partially-rolled-back state. The user must run `fireforge doctor
|
|
161
|
+
// --repair-furnace` to reconcile before any new mutations can proceed.
|
|
162
|
+
if (!options.skipPendingRepairCheck) {
|
|
163
|
+
const state = await loadFurnaceState(root);
|
|
164
|
+
if (state.pendingRepair) {
|
|
165
|
+
throw new FurnaceError(`A previous "${state.pendingRepair.operation}" left the engine in an inconsistent state ` +
|
|
166
|
+
`(${state.pendingRepair.reason}). Run "fireforge doctor --repair-furnace" to reconcile ` +
|
|
167
|
+
'before running further furnace mutations.');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const token = nextOperationToken++;
|
|
171
|
+
const operation = { root, kind, cleanups: [] };
|
|
172
|
+
const lockPath = getFurnaceLockPath(root);
|
|
173
|
+
const lockOptions = {
|
|
174
|
+
...(options.lockTimeoutMs !== undefined ? { timeoutMs: options.lockTimeoutMs } : {}),
|
|
175
|
+
onTimeoutMessage: `Timed out waiting for the furnace lock at ${lockPath}. ` +
|
|
176
|
+
'Another fireforge furnace command may be running. ' +
|
|
177
|
+
'If no other process is running, remove the stale lock directory and retry.',
|
|
178
|
+
onStaleLockMessage: (ageMs) => `Removing stale furnace lock (age: ${Math.round(ageMs / 1000)}s). ` +
|
|
179
|
+
'A previous fireforge process may have crashed.',
|
|
180
|
+
};
|
|
181
|
+
return withFileLock(lockPath, async () => {
|
|
182
|
+
activeOperations.set(token, operation);
|
|
183
|
+
try {
|
|
184
|
+
return await body({
|
|
185
|
+
registerJournal: (journal) => {
|
|
186
|
+
operation.journal = journal;
|
|
187
|
+
},
|
|
188
|
+
registerCleanup: (cleanup) => {
|
|
189
|
+
operation.cleanups.push(cleanup);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
operation.completed = true;
|
|
195
|
+
activeOperations.delete(token);
|
|
196
|
+
}
|
|
197
|
+
}, lockOptions);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Persists an `apply-rollback` (or other operation-kind) `pendingRepair`
|
|
201
|
+
* marker on behalf of a caller that detected a rollback failure outside the
|
|
202
|
+
* signal-handler path (e.g. apply's own catch-around-restore). Exposed so
|
|
203
|
+
* `furnace-apply.ts` can write the marker without taking on a dependency on
|
|
204
|
+
* the lifecycle wrapper's internals.
|
|
205
|
+
*/
|
|
206
|
+
export async function recordFurnaceRollbackFailure(root, operation, reason) {
|
|
207
|
+
await persistPendingRepair(root, operation, reason);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Test-only helper: tears down the module-scoped state. Vitest workers may
|
|
211
|
+
* reuse the module across tests, so the test suite must call this between
|
|
212
|
+
* cases that exercise the signal pathway. Not exported from the package
|
|
213
|
+
* entry point.
|
|
214
|
+
*/
|
|
215
|
+
export function __resetFurnaceOperationStateForTests() {
|
|
216
|
+
activeOperations.clear();
|
|
217
|
+
nextOperationToken = 1;
|
|
218
|
+
signalRollbackInFlight = false;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=furnace-operation.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RefreshFileResult {
|
|
2
|
+
fileName: string;
|
|
3
|
+
status: 'merged' | 'conflict' | 'unchanged' | 'new-file';
|
|
4
|
+
conflictMarkers?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RefreshResult {
|
|
7
|
+
files: RefreshFileResult[];
|
|
8
|
+
newBaseVersion: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Refreshes a single override file against the current engine HEAD.
|
|
12
|
+
*
|
|
13
|
+
* @param engineDir - Path to the engine git repository
|
|
14
|
+
* @param overridePath - Path to the current override file in the workspace
|
|
15
|
+
* @param engineRelPath - Engine-relative path for git show
|
|
16
|
+
* @param baseCommit - The git ref at which the override was originally created
|
|
17
|
+
* @param fileName - Display name for the file
|
|
18
|
+
* @returns Merge result with the updated content written to the override file
|
|
19
|
+
*/
|
|
20
|
+
export declare function refreshOverrideFile(engineDir: string, overridePath: string, engineRelPath: string, baseCommit: string, fileName: string, dryRun?: boolean, strategy?: 'ours' | 'theirs'): Promise<RefreshFileResult>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Three-way merge logic for refreshing overrides against a newer Firefox baseline.
|
|
4
|
+
*
|
|
5
|
+
* When Firefox moves forward and an override's `baseVersion` drifts, the user
|
|
6
|
+
* needs a way to incorporate upstream changes into their override workspace
|
|
7
|
+
* without losing local modifications. This module uses `git merge-file` to
|
|
8
|
+
* perform a three-way merge between the old baseline, the current override,
|
|
9
|
+
* and the new upstream content.
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { unlink } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { FurnaceError } from '../errors/furnace.js';
|
|
16
|
+
import { readText, writeText } from '../utils/fs.js';
|
|
17
|
+
import { exec } from '../utils/process.js';
|
|
18
|
+
import { ensureGit } from './git-base.js';
|
|
19
|
+
import { getFileContentAtRef } from './git-file-ops.js';
|
|
20
|
+
/**
|
|
21
|
+
* Performs a three-way merge on a single file.
|
|
22
|
+
*
|
|
23
|
+
* Uses `git merge-file` with:
|
|
24
|
+
* - base: the original Firefox content at the override's recorded baseCommit
|
|
25
|
+
* - ours: the current override workspace content (local modifications)
|
|
26
|
+
* - theirs: the current Firefox content at HEAD
|
|
27
|
+
*
|
|
28
|
+
* @returns The merged content and the number of conflict markers (0 = clean merge)
|
|
29
|
+
*/
|
|
30
|
+
async function threeWayMergeFile(base, ours, theirs, label, strategy) {
|
|
31
|
+
await ensureGit();
|
|
32
|
+
// Write all three versions to temp files for git merge-file.
|
|
33
|
+
// Use crypto.randomUUID() for unique, unpredictable temp file names.
|
|
34
|
+
const id = randomUUID();
|
|
35
|
+
const tempBase = join(tmpdir(), `fireforge-merge-base-${id}`);
|
|
36
|
+
const tempOurs = join(tmpdir(), `fireforge-merge-ours-${id}`);
|
|
37
|
+
const tempTheirs = join(tmpdir(), `fireforge-merge-theirs-${id}`);
|
|
38
|
+
try {
|
|
39
|
+
await writeText(tempBase, base);
|
|
40
|
+
await writeText(tempOurs, ours);
|
|
41
|
+
await writeText(tempTheirs, theirs);
|
|
42
|
+
// git merge-file writes the result to the first file (ours) in-place.
|
|
43
|
+
// Exit code 0 = clean merge, >0 = number of conflicts, <0 = error.
|
|
44
|
+
const mergeArgs = [
|
|
45
|
+
'merge-file',
|
|
46
|
+
...(strategy ? [`--${strategy}`] : []),
|
|
47
|
+
'-L',
|
|
48
|
+
label.ours,
|
|
49
|
+
'-L',
|
|
50
|
+
label.base,
|
|
51
|
+
'-L',
|
|
52
|
+
label.theirs,
|
|
53
|
+
tempOurs,
|
|
54
|
+
tempBase,
|
|
55
|
+
tempTheirs,
|
|
56
|
+
];
|
|
57
|
+
const result = await exec('git', mergeArgs);
|
|
58
|
+
const merged = await readText(tempOurs);
|
|
59
|
+
const conflicts = result.exitCode > 0 ? result.exitCode : 0;
|
|
60
|
+
if (result.exitCode < 0) {
|
|
61
|
+
throw new FurnaceError(`git merge-file failed: ${result.stderr}`);
|
|
62
|
+
}
|
|
63
|
+
return { merged, conflicts };
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
// Clean up temp files (best-effort)
|
|
67
|
+
await Promise.allSettled([unlink(tempBase), unlink(tempOurs), unlink(tempTheirs)]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Refreshes a single override file against the current engine HEAD.
|
|
72
|
+
*
|
|
73
|
+
* @param engineDir - Path to the engine git repository
|
|
74
|
+
* @param overridePath - Path to the current override file in the workspace
|
|
75
|
+
* @param engineRelPath - Engine-relative path for git show
|
|
76
|
+
* @param baseCommit - The git ref at which the override was originally created
|
|
77
|
+
* @param fileName - Display name for the file
|
|
78
|
+
* @returns Merge result with the updated content written to the override file
|
|
79
|
+
*/
|
|
80
|
+
export async function refreshOverrideFile(engineDir, overridePath, engineRelPath, baseCommit, fileName, dryRun, strategy) {
|
|
81
|
+
// Read the three versions
|
|
82
|
+
const oursContent = await readText(overridePath);
|
|
83
|
+
const baseContent = await getFileContentAtRef(engineDir, engineRelPath, baseCommit);
|
|
84
|
+
if (baseContent === null) {
|
|
85
|
+
// File didn't exist at baseCommit — this is a new file introduced by the override
|
|
86
|
+
return { fileName, status: 'new-file' };
|
|
87
|
+
}
|
|
88
|
+
const theirsContent = await getFileContentAtRef(engineDir, engineRelPath, 'HEAD');
|
|
89
|
+
if (theirsContent === null) {
|
|
90
|
+
// File was removed upstream — no merge needed, keep the override as-is
|
|
91
|
+
return { fileName, status: 'unchanged' };
|
|
92
|
+
}
|
|
93
|
+
// If upstream hasn't changed, nothing to merge
|
|
94
|
+
if (baseContent === theirsContent) {
|
|
95
|
+
return { fileName, status: 'unchanged' };
|
|
96
|
+
}
|
|
97
|
+
// If our override matches the base (no local changes), just take theirs
|
|
98
|
+
if (oursContent === baseContent) {
|
|
99
|
+
if (!dryRun) {
|
|
100
|
+
await writeText(overridePath, theirsContent);
|
|
101
|
+
}
|
|
102
|
+
return { fileName, status: 'merged' };
|
|
103
|
+
}
|
|
104
|
+
// Three-way merge
|
|
105
|
+
const { merged, conflicts } = await threeWayMergeFile(baseContent, oursContent, theirsContent, {
|
|
106
|
+
ours: `components/overrides/${fileName} (your changes)`,
|
|
107
|
+
base: `Firefox ${baseCommit.slice(0, 8)} (original)`,
|
|
108
|
+
theirs: `Firefox HEAD (upstream)`,
|
|
109
|
+
}, strategy);
|
|
110
|
+
if (!dryRun) {
|
|
111
|
+
await writeText(overridePath, merged);
|
|
112
|
+
}
|
|
113
|
+
if (conflicts > 0) {
|
|
114
|
+
return { fileName, status: 'conflict', conflictMarkers: conflicts };
|
|
115
|
+
}
|
|
116
|
+
return { fileName, status: 'merged' };
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=furnace-refresh.js.map
|
|
@@ -22,3 +22,8 @@ export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
|
|
|
22
22
|
* @param modulePath - chrome:// URI for the module
|
|
23
23
|
*/
|
|
24
24
|
export declare function addCustomElementRegistration(engineDir: string, tagName: string, modulePath: string): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Validates that a custom element registration *would* succeed without
|
|
27
|
+
* writing anything. Used by dry-run to surface registration errors early.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateCustomElementRegistration(engineDir: string, tagName: string, modulePath: string): Promise<void>;
|
|
@@ -8,6 +8,7 @@ import MagicString from 'magic-string';
|
|
|
8
8
|
import { FurnaceError } from '../errors/furnace.js';
|
|
9
9
|
import { toError } from '../utils/errors.js';
|
|
10
10
|
import { pathExists, readText, writeText } from '../utils/fs.js';
|
|
11
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
11
12
|
import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
|
|
12
13
|
import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
|
|
13
14
|
import { validateRegistrationPlacement, validateTagName } from './furnace-registration-validate.js';
|
|
@@ -171,6 +172,60 @@ function addRegistrationAST(content, tagName, modulePath, isESModule) {
|
|
|
171
172
|
}
|
|
172
173
|
return ms.toString();
|
|
173
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Regex-based fallback for inserting a registration entry when the AST parser
|
|
177
|
+
* fails. Finds the last existing `["tag", "path"],` line in the appropriate
|
|
178
|
+
* block and inserts the new entry after it in alphabetical order.
|
|
179
|
+
*
|
|
180
|
+
* This is intentionally less precise than the AST approach — it does not
|
|
181
|
+
* validate indentation or multi-line format — but it is robust against
|
|
182
|
+
* upstream syntax changes that break the parser.
|
|
183
|
+
*/
|
|
184
|
+
function addRegistrationRegexFallback(content, tagName, modulePath, isESModule) {
|
|
185
|
+
// Find all registration entries: ["tag", "path"],
|
|
186
|
+
const entryPattern = /^(\s*)\["([^"]+)",\s*"[^"]+"\],?\s*$/gm;
|
|
187
|
+
let lastMatch = null;
|
|
188
|
+
let insertAfterMatch = null;
|
|
189
|
+
const allMatches = [];
|
|
190
|
+
let match;
|
|
191
|
+
// For ESM modules, only consider entries inside a DOMContentLoaded block.
|
|
192
|
+
// For non-ESM, consider entries outside DOMContentLoaded.
|
|
193
|
+
const dclStart = content.search(/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/);
|
|
194
|
+
const dclBlockStart = dclStart >= 0 ? content.indexOf('{', dclStart) : -1;
|
|
195
|
+
while ((match = entryPattern.exec(content)) !== null) {
|
|
196
|
+
const isInDCL = dclBlockStart >= 0 && match.index > dclBlockStart;
|
|
197
|
+
if (isESModule ? isInDCL : !isInDCL) {
|
|
198
|
+
allMatches.push(match);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Find alphabetical insertion point
|
|
202
|
+
for (const m of allMatches) {
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- capture group [2] always present when regex matches
|
|
204
|
+
const existingTag = m[2];
|
|
205
|
+
if (existingTag < tagName) {
|
|
206
|
+
insertAfterMatch = m;
|
|
207
|
+
}
|
|
208
|
+
lastMatch = m;
|
|
209
|
+
}
|
|
210
|
+
// If we found no entries in the target block, give up
|
|
211
|
+
if (!lastMatch) {
|
|
212
|
+
throw new FurnaceError(`Regex fallback could not find any registration entries in the ${isESModule ? 'DOMContentLoaded' : 'non-DOMContentLoaded'} block of ${CUSTOM_ELEMENTS_JS}.`, tagName);
|
|
213
|
+
}
|
|
214
|
+
const indent = lastMatch[1] ?? ' ';
|
|
215
|
+
const newEntry = `${indent}["${tagName}", "${modulePath}"],`;
|
|
216
|
+
if (insertAfterMatch) {
|
|
217
|
+
// Insert after the last entry that sorts before tagName
|
|
218
|
+
const insertPos = insertAfterMatch.index + insertAfterMatch[0].length;
|
|
219
|
+
return content.slice(0, insertPos) + '\n' + newEntry + content.slice(insertPos);
|
|
220
|
+
}
|
|
221
|
+
// Insert before the first entry (tagName sorts before all existing)
|
|
222
|
+
const firstMatch = allMatches[0];
|
|
223
|
+
if (!firstMatch) {
|
|
224
|
+
throw new FurnaceError(`Regex fallback found no entries in the target block of ${CUSTOM_ELEMENTS_JS}.`, tagName);
|
|
225
|
+
}
|
|
226
|
+
const insertPos = firstMatch.index;
|
|
227
|
+
return content.slice(0, insertPos) + newEntry + '\n' + content.slice(insertPos);
|
|
228
|
+
}
|
|
174
229
|
/**
|
|
175
230
|
* Adds a custom element registration entry to customElements.js.
|
|
176
231
|
*
|
|
@@ -195,24 +250,99 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
|
|
|
195
250
|
}
|
|
196
251
|
const content = await readText(filePath);
|
|
197
252
|
// Idempotency: already registered (standalone block or array entry).
|
|
253
|
+
// Check both double-quote and single-quote variants — upstream Firefox
|
|
254
|
+
// sources may use either style.
|
|
198
255
|
if (content.includes(`setElementCreationCallback("${tagName}"`) ||
|
|
256
|
+
content.includes(`setElementCreationCallback('${tagName}'`) ||
|
|
199
257
|
content.includes(`["${tagName}",`) ||
|
|
200
|
-
|
|
258
|
+
content.includes(`['${tagName}',`) ||
|
|
259
|
+
new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
|
|
201
260
|
return;
|
|
202
261
|
}
|
|
262
|
+
// Validate upfront — tag name errors must not fall through to the regex fallback.
|
|
263
|
+
validateTagName(tagName);
|
|
203
264
|
const isESModule = modulePath.endsWith('.mjs');
|
|
265
|
+
// Cheap pre-flight: the AST walker assumes the file contains at least one
|
|
266
|
+
// destructuring `for (... of [...])` loop with an array literal on the
|
|
267
|
+
// right-hand side, and (for ESM tags) at least one such loop inside a
|
|
268
|
+
// `document.addEventListener("DOMContentLoaded", ...)` block. If either
|
|
269
|
+
// assumption is violated the AST path errors with a confusing
|
|
270
|
+
// "Could not find DOMContentLoaded block" message — fail fast here with
|
|
271
|
+
// actionable guidance instead.
|
|
272
|
+
if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
|
|
273
|
+
throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
|
|
274
|
+
'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
|
|
275
|
+
}
|
|
276
|
+
if (isESModule && !/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/.test(content)) {
|
|
277
|
+
throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} has no DOMContentLoaded block; cannot register ESM element ${tagName}. ` +
|
|
278
|
+
'The file may be corrupt — run "fireforge reset --force" to restore.', tagName);
|
|
279
|
+
}
|
|
204
280
|
let nextContent;
|
|
205
281
|
try {
|
|
206
282
|
nextContent = addRegistrationAST(content, tagName, modulePath, isESModule);
|
|
207
283
|
}
|
|
208
284
|
catch (error) {
|
|
209
285
|
if (error instanceof FurnaceError) {
|
|
210
|
-
|
|
286
|
+
// AST structural errors (missing DOMContentLoaded block, etc.) — try regex fallback
|
|
287
|
+
warn(`AST-based registration failed for ${tagName}: ${error.message}. ` +
|
|
288
|
+
'Falling back to regex-based insertion. Please report this so the AST parser can be updated.');
|
|
289
|
+
try {
|
|
290
|
+
nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
|
|
291
|
+
verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// If regex fallback also fails, throw the original AST error
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const parserError = toError(error);
|
|
300
|
+
warn(`AST parser threw an unexpected error for ${tagName}: ${parserError.message}. ` +
|
|
301
|
+
'Falling back to regex-based insertion.');
|
|
302
|
+
try {
|
|
303
|
+
nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
|
|
304
|
+
verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
throw new FurnaceError(`Failed to update ${CUSTOM_ELEMENTS_JS} using both AST and regex fallback: ${parserError.message}`, tagName, parserError);
|
|
308
|
+
}
|
|
211
309
|
}
|
|
212
|
-
const parserError = toError(error);
|
|
213
|
-
throw new FurnaceError(`Failed to update ${CUSTOM_ELEMENTS_JS} using AST registration parsing: ${parserError.message}`, tagName, parserError);
|
|
214
310
|
}
|
|
215
311
|
validateRegistrationPlacement(nextContent, tagName, isESModule);
|
|
216
312
|
await writeText(filePath, nextContent);
|
|
217
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Validates that a custom element registration *would* succeed without
|
|
316
|
+
* writing anything. Used by dry-run to surface registration errors early.
|
|
317
|
+
*/
|
|
318
|
+
export async function validateCustomElementRegistration(engineDir, tagName, modulePath) {
|
|
319
|
+
const filePath = join(engineDir, CUSTOM_ELEMENTS_JS);
|
|
320
|
+
if (!(await pathExists(filePath))) {
|
|
321
|
+
throw new FurnaceError('customElements.js not found in engine', tagName);
|
|
322
|
+
}
|
|
323
|
+
const content = await readText(filePath);
|
|
324
|
+
if (content.includes(`setElementCreationCallback("${tagName}"`) ||
|
|
325
|
+
content.includes(`setElementCreationCallback('${tagName}'`) ||
|
|
326
|
+
content.includes(`["${tagName}",`) ||
|
|
327
|
+
content.includes(`['${tagName}',`) ||
|
|
328
|
+
new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const isESModule = modulePath.endsWith('.mjs');
|
|
332
|
+
if (!/for\s*\(\s*(?:let|const|var)\s*\[/.test(content)) {
|
|
333
|
+
throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} does not contain a recognizable registration loop; refusing to mutate. ` +
|
|
334
|
+
'Run "fireforge reset --force" to restore the engine, or inspect the file manually.', tagName);
|
|
335
|
+
}
|
|
336
|
+
if (isESModule && !/document\.addEventListener\s*\(\s*["']DOMContentLoaded["']/.test(content)) {
|
|
337
|
+
throw new FurnaceError(`${CUSTOM_ELEMENTS_JS} has no DOMContentLoaded block; cannot register ESM element ${tagName}. ` +
|
|
338
|
+
'The file may be corrupt — run "fireforge reset --force" to restore.', tagName);
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
addRegistrationAST(content, tagName, modulePath, isESModule);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Validation only — if AST fails, try regex to see if the entry could be placed
|
|
345
|
+
addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
218
348
|
//# sourceMappingURL=furnace-registration-ast.js.map
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Removal of custom element registrations from customElements.js.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Uses the same AST parser as the add path (`furnace-registration-ast.ts`) to
|
|
5
|
+
* locate and delete registration entries. The earlier implementation walked
|
|
6
|
+
* the file line-by-line with a 20-line scan bound for bracket matching, which
|
|
7
|
+
* only worked against Firefox's stock formatting and silently failed on any
|
|
8
|
+
* hand-reformatted customElements.js. AST-based bracket matching is format-
|
|
9
|
+
* agnostic by construction.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - Idempotent: if the tag is not registered, the file is left unchanged.
|
|
13
|
+
* - Non-destructive on parse failure: if customElements.js cannot be parsed,
|
|
14
|
+
* the file is left untouched rather than fall through to a line-based
|
|
15
|
+
* heuristic that could delete the wrong range.
|
|
16
|
+
* - Two registration shapes are recognised:
|
|
17
|
+
* (A) Standalone statement:
|
|
18
|
+
* customElements.setElementCreationCallback("tag", ...);
|
|
19
|
+
* (B) Entry inside a `for (... of [ ... ])` registration array:
|
|
20
|
+
* ["tag", "chrome://..."]
|
|
21
|
+
* Both are deleted together with any trailing comma and newline so the
|
|
22
|
+
* resulting file is still valid JavaScript.
|
|
4
23
|
*/
|
|
5
24
|
/**
|
|
6
25
|
* Removes a custom element registration from customElements.js.
|
|
7
26
|
*
|
|
8
|
-
* This operation is idempotent — if the tag is not registered or the file
|
|
9
|
-
* not exist, nothing happens.
|
|
27
|
+
* This operation is idempotent — if the tag is not registered or the file
|
|
28
|
+
* does not exist, nothing happens. If the file exists but cannot be parsed,
|
|
29
|
+
* the file is left unchanged rather than fall back to a line-based
|
|
30
|
+
* heuristic; a corrupted customElements.js is a doctor problem, not
|
|
31
|
+
* something `furnace remove` should "helpfully" edit around.
|
|
10
32
|
*
|
|
11
33
|
* @param engineDir - Path to the Firefox engine source root
|
|
12
34
|
* @param tagName - Custom element tag name to remove
|