@hominis/fireforge 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- 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.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- 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 +13 -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-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Concurrency and atomicity
|
|
6
|
+
|
|
7
|
+
- Patch body and manifest writes in `re-export`, `rebase --continue`, and the post-apply re-export loop in `rebase` are now atomic via `updatePatchAndMetadata`, so a concurrent `resolve` / `re-export` / `patch compact` / `patch reorder` cannot leave body and metadata disagreeing.
|
|
8
|
+
- State writes in `import`, `resolve`, and `rebase` (abort, continue, patch loop) use transactional `updateState` so a concurrent command's unrelated keys are no longer clobbered.
|
|
9
|
+
- `rebase` apply + session persist is guarded by a new `runInSignalCriticalSection` primitive in `src/core/signal-critical.ts`; SIGINT / SIGTERM between apply and persist (5 s ceiling) no longer leaves an applied patch marked pending, so `--continue` does not re-apply it.
|
|
10
|
+
|
|
11
|
+
### Rebase
|
|
12
|
+
|
|
13
|
+
- Per-patch re-export failures after apply are collected instead of silently dropped. The session stays on disk and `sourceEsrVersion` is not stamped until every re-export succeeds, so `--continue` can retry after the root cause is fixed.
|
|
14
|
+
- `rebase --continue` retries the post-apply pipeline when the apply loop has already finished; the prior "session may be corrupt" rejection no longer blocks resumption.
|
|
15
|
+
- `rebase --abort` splits into four sequenced steps (git reset, furnace state clear, `pendingResolution` clear, session clear) so failures get correctly-labelled errors and the session stays on disk for retry.
|
|
16
|
+
|
|
17
|
+
### Download
|
|
18
|
+
|
|
19
|
+
- `download` restores patch-touched files to baseline after the initial commit (or a resumed partial init), so extraction artefacts and line-ending normalisation no longer force `fireforge import --force` on a clean install. Pre-existing uncommitted edits are preserved and warned about.
|
|
20
|
+
- `cleanPatchTouchedFiles` runs before stamping `state.downloadedVersion`, preserving the invariant that the stamped version matches a clean engine.
|
|
21
|
+
- Resume preserves the original error cause (timeout, permission denied, corrupted git object, disk full) instead of discarding it behind a generic `PartialEngineExistsError`. Unexpected errors during the partial-engine probe are also wrapped rather than re-thrown bare.
|
|
22
|
+
|
|
23
|
+
### Import
|
|
24
|
+
|
|
25
|
+
- Classification no longer swallows structural errors as "unmanaged dirty file". Only pure-IO errors (`ENOENT`, `EACCES`, `EPERM`, `EISDIR`, `EBUSY`) fall through; `PatchError`, manifest corruption, and patch-parse failures re-throw with the original diagnostic.
|
|
26
|
+
- Patch integrity issues prompt in interactive mode and error in non-interactive mode instead of warn-and-continue; `--force` still bypasses with an explicit warning.
|
|
27
|
+
|
|
28
|
+
### Furnace
|
|
29
|
+
|
|
30
|
+
- `furnace apply --watch` picks up newly-created component directories dynamically, remembers edits that arrive during an in-flight apply (a second cycle runs automatically), and classifies errors errno-aware (`EACCES`, `ENOSPC`, `EBUSY`, `ENOENT`, `ETIMEDOUT`) instead of collapsing into a generic "Apply failed".
|
|
31
|
+
- `furnace override` rejects collisions with `config.stock` and `config.custom` in both single and batch paths, and wraps snapshot + copy pairs in per-file error context so a mid-copy failure names the failing file.
|
|
32
|
+
- `furnace remove` requires a git engine for custom components (not just overrides) and hoists the precondition outside the lock and journal registration. A summary line surfaces when test-file cleanup fails partway.
|
|
33
|
+
- `furnace rename` does prefix-only filename replacement; the prior substring replacement mangled names when `oldName` appeared more than once. Content regexes now escape every metacharacter.
|
|
34
|
+
- `furnace deploy` asserts `applied[0].name` matches the requested component before persisting state; state-mismatch errors recommend `fireforge doctor --repair-furnace`.
|
|
35
|
+
- `furnace validate --fix` reports the actual delta from re-validation instead of inflating the count on no-op fixes.
|
|
36
|
+
- `furnace list -v` tolerates missing or unreadable component directories, rendering `unavailable` instead of terminating the listing.
|
|
37
|
+
- `furnace diff` surfaces `--reset-base` recovery in the primary error rather than a secondary catch block.
|
|
38
|
+
- `furnace init --ftl-base-path` traversal check uses path normalisation instead of substring match, rejecting absolute paths, null bytes, and `..` segments. Interactive detection checks both `stdin.isTTY` and `stdout.isTTY` to match every other interactive command.
|
|
39
|
+
|
|
40
|
+
### Other commands
|
|
41
|
+
|
|
42
|
+
- `setup` rejects project names whose sanitised slug is empty (emoji-only, pure punctuation, `---`). `validateConfig` similarly rejects empty `name`, `vendor`, `appId`, `binaryName`.
|
|
43
|
+
- `config --force` no longer bypasses structural validation for known keys in `SUPPORTED_CONFIG_PATHS`; the flag is only an escape hatch for unknown keys.
|
|
44
|
+
- `watch` probes `watchman --version` with a 5 s timeout before starting, and runs the furnace staleness check previously only in `run`. Both commands share a new `warnIfFurnaceStale` helper in `src/core/furnace-staleness.ts`.
|
|
45
|
+
- `test` path normalisation is case-insensitive, accepts `\` as well as `/`, and trims whitespace, so `Engine/foo/bar` on macOS / Windows no longer reaches `mach` with the prefix intact.
|
|
46
|
+
- `status` caps untracked-directory expansion at 5 000 files per directory (configurable via `FIREFORGE_MAX_UNTRACKED_FILES`) and renders a top-of-output banner when directories were truncated, so large outputs don't hide the warning in scrollback.
|
|
47
|
+
- `export` empty-diff error distinguishes the `--skip-lint` case; `export-shared` always announces when `--skip-lint` is active.
|
|
48
|
+
- `wire <name>` and `register --after <entry>` validate their inputs against strict regexes, rejecting path separators, parent-dir segments, control characters, and line terminators before any filesystem operation.
|
|
49
|
+
- `token add --mode` uses Commander's `.choices()` so invalid modes fail with the built-in message and `--help` lists the valid options.
|
|
50
|
+
- `run` whitelisted exit codes (0, 130 SIGINT, 143 SIGTERM) are documented inline; SIGKILL (137) and other abnormal signal codes surface as build errors.
|
|
51
|
+
- `discard` wraps error causes via `toError` so thrown strings or numbers propagate as real Errors with stack traces.
|
|
52
|
+
|
|
53
|
+
### Internal
|
|
54
|
+
|
|
55
|
+
- New unit tests for `validateCheckDependencies` in `src/commands/doctor.ts` assert the forward-only dependency invariant so a regression cannot slip in when reordering checks.
|
|
56
|
+
|
|
3
57
|
## 0.13.0
|
|
4
58
|
|
|
5
59
|
### Setup
|
package/dist/bin/fireforge.js
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { installBrokenPipeHandler, main } from '../src/cli.js';
|
|
12
12
|
import { isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
|
|
13
|
+
import { waitForActiveCriticalSections } from '../src/core/signal-critical.js';
|
|
13
14
|
import { CommandError } from '../src/errors/base.js';
|
|
15
|
+
/**
|
|
16
|
+
* Upper bound (ms) the signal handler will wait for any in-flight critical
|
|
17
|
+
* section (e.g. rebase apply + session persist) to finish before calling
|
|
18
|
+
* process.exit. Keep short so a stuck I/O operation cannot indefinitely
|
|
19
|
+
* postpone the exit a user requested with Ctrl+C.
|
|
20
|
+
*/
|
|
21
|
+
const SIGNAL_CRITICAL_SECTION_TIMEOUT_MS = 5_000;
|
|
14
22
|
installBrokenPipeHandler();
|
|
15
23
|
process.on('unhandledRejection', (reason) => {
|
|
16
24
|
console.error('Fatal error (unhandled rejection):', reason instanceof Error ? reason.message : reason);
|
|
@@ -33,11 +41,17 @@ function installFurnaceSignalHandler(signal, exitCode) {
|
|
|
33
41
|
// rather than queueing another rollback that will race the first.
|
|
34
42
|
process.exit(exitCode);
|
|
35
43
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
// Run furnace rollback and signal-critical-section drain in parallel.
|
|
45
|
+
// Rebase-style operations register critical sections (apply + session
|
|
46
|
+
// persist) via `runInSignalCriticalSection`; awaiting them here ensures
|
|
47
|
+
// the CLI never exits with a patch applied to the engine but a stale
|
|
48
|
+
// session file that would mis-track progress on `--continue`.
|
|
49
|
+
void Promise.allSettled([
|
|
50
|
+
rollbackActiveOperationsForSignal(signal).catch((error) => {
|
|
51
|
+
console.error(`Furnace rollback after ${signal} failed:`, error instanceof Error ? error.message : error);
|
|
52
|
+
}),
|
|
53
|
+
waitForActiveCriticalSections(SIGNAL_CRITICAL_SECTION_TIMEOUT_MS),
|
|
54
|
+
]).finally(() => {
|
|
41
55
|
process.exit(exitCode);
|
|
42
56
|
});
|
|
43
57
|
});
|
|
@@ -105,8 +105,14 @@ export async function configCommand(projectRoot, key, value, options = {}) {
|
|
|
105
105
|
throw new InvalidArgumentError(`Unknown config key: "${key}". Known keys: ${SUPPORTED_CONFIG_PATHS.join(', ')}. Use --force to set anyway.`);
|
|
106
106
|
}
|
|
107
107
|
const parsedValue = parseValue(value, key);
|
|
108
|
+
const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
|
|
108
109
|
try {
|
|
109
|
-
|
|
110
|
+
// `--force` is intended as an escape hatch for *unknown* keys; it
|
|
111
|
+
// should not also let the user write a structurally invalid value
|
|
112
|
+
// for a *known* key. Apply strict validation whenever the key is
|
|
113
|
+
// listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
|
|
114
|
+
// skip validation for genuinely unknown key paths.
|
|
115
|
+
if (options.force && !keyIsKnown) {
|
|
110
116
|
const updatedConfig = mutateConfig(config, key, parsedValue, true);
|
|
111
117
|
await writeConfigDocument(projectRoot, updatedConfig);
|
|
112
118
|
}
|
|
@@ -7,6 +7,7 @@ import { discardStatusEntry } from '../core/git-file-ops.js';
|
|
|
7
7
|
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
8
8
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
9
9
|
import { GitError } from '../errors/git.js';
|
|
10
|
+
import { toError } from '../utils/errors.js';
|
|
10
11
|
import { pathExists } from '../utils/fs.js';
|
|
11
12
|
import { info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
|
|
12
13
|
import { pickDefined } from '../utils/options.js';
|
|
@@ -79,7 +80,11 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
79
80
|
}
|
|
80
81
|
throw new GitError(`Failed to discard ${file}`, statusEntry.isUntracked
|
|
81
82
|
? `rm ${statusEntry.file}`
|
|
82
|
-
: `restore --source HEAD --staged --worktree -- ${statusEntry.file}`,
|
|
83
|
+
: `restore --source HEAD --staged --worktree -- ${statusEntry.file}`,
|
|
84
|
+
// Always attach the cause via toError so thrown primitives (strings,
|
|
85
|
+
// numbers) produced by poorly-behaved utilities still propagate as
|
|
86
|
+
// an Error, preserving stack traces for verbose-mode triage.
|
|
87
|
+
toError(error));
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
/** Registers the discard command on the CLI program. */
|
|
@@ -109,6 +109,18 @@ export declare function warning(name: string, message: string, fix?: string): Do
|
|
|
109
109
|
* Exported for sibling check modules — see {@link ok}.
|
|
110
110
|
*/
|
|
111
111
|
export declare function failure(name: string, message: string, fix?: string): DoctorCheck;
|
|
112
|
+
/**
|
|
113
|
+
* Validates that every check's `dependsOn` entries appear earlier in the
|
|
114
|
+
* registry. Called once at module load time so a broken reorder surfaces
|
|
115
|
+
* immediately as a thrown error rather than producing a subtle
|
|
116
|
+
* context-population bug at runtime.
|
|
117
|
+
*
|
|
118
|
+
* Exported so tests can exercise the forward-only invariant against
|
|
119
|
+
* fixtures — the real DOCTOR_CHECKS list is also validated at import
|
|
120
|
+
* time, but a targeted unit test makes the contract explicit and
|
|
121
|
+
* prevents regressions if the validator is ever relaxed.
|
|
122
|
+
*/
|
|
123
|
+
export declare function validateCheckDependencies(checks: readonly DoctorCheckDefinition[]): void;
|
|
112
124
|
/**
|
|
113
125
|
* Ordered list of the doctor check names, exported for tests. Pinning
|
|
114
126
|
* the order here is intentional: any reorder that breaks the
|
|
@@ -135,8 +135,13 @@ async function runEngineGitChecks(ctx) {
|
|
|
135
135
|
* registry. Called once at module load time so a broken reorder surfaces
|
|
136
136
|
* immediately as a thrown error rather than producing a subtle
|
|
137
137
|
* context-population bug at runtime.
|
|
138
|
+
*
|
|
139
|
+
* Exported so tests can exercise the forward-only invariant against
|
|
140
|
+
* fixtures — the real DOCTOR_CHECKS list is also validated at import
|
|
141
|
+
* time, but a targeted unit test makes the contract explicit and
|
|
142
|
+
* prevents regressions if the validator is ever relaxed.
|
|
138
143
|
*/
|
|
139
|
-
function validateCheckDependencies(checks) {
|
|
144
|
+
export function validateCheckDependencies(checks) {
|
|
140
145
|
const seen = new Set();
|
|
141
146
|
for (const check of checks) {
|
|
142
147
|
if (check.dependsOn) {
|
|
@@ -4,10 +4,68 @@ import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
|
|
|
4
4
|
import { downloadFirefoxSource, formatBytes } from '../core/firefox.js';
|
|
5
5
|
import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
|
|
6
6
|
import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRepository, } from '../core/git.js';
|
|
7
|
+
import { restoreTrackedPath } from '../core/git-file-ops.js';
|
|
8
|
+
import { getDirtyFiles } from '../core/git-status.js';
|
|
9
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
7
10
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
|
+
import { toError } from '../utils/errors.js';
|
|
8
12
|
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
9
|
-
import { info, intro, outro, spinner, step, warn } from '../utils/logger.js';
|
|
13
|
+
import { info, intro, outro, spinner, step, verbose, warn } from '../utils/logger.js';
|
|
10
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
|
+
/**
|
|
16
|
+
* Collects the set of patch-touched files from the manifest.
|
|
17
|
+
* Returns an empty set when the patches directory or manifest is absent.
|
|
18
|
+
*/
|
|
19
|
+
async function getPatchTouchedFiles(patchesDir) {
|
|
20
|
+
if (!(await pathExists(patchesDir)))
|
|
21
|
+
return new Set();
|
|
22
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
23
|
+
if (!manifest || manifest.patches.length === 0)
|
|
24
|
+
return new Set();
|
|
25
|
+
const files = new Set();
|
|
26
|
+
for (const patch of manifest.patches) {
|
|
27
|
+
for (const file of patch.filesAffected) {
|
|
28
|
+
files.add(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Restores patch-touched files to their committed (HEAD) state so that a
|
|
35
|
+
* subsequent `fireforge import` does not see spurious uncommitted changes.
|
|
36
|
+
*
|
|
37
|
+
* Files that were already dirty *before* the download started (tracked via
|
|
38
|
+
* `preExistingDirty`) are left untouched and warned about.
|
|
39
|
+
*/
|
|
40
|
+
async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
41
|
+
const patchFiles = await getPatchTouchedFiles(patchesDir);
|
|
42
|
+
if (patchFiles.size === 0)
|
|
43
|
+
return;
|
|
44
|
+
const dirtyFiles = await getDirtyFiles(engineDir, [...patchFiles]);
|
|
45
|
+
if (dirtyFiles.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
const toClean = preExistingDirty
|
|
48
|
+
? dirtyFiles.filter((f) => !preExistingDirty.has(f))
|
|
49
|
+
: dirtyFiles;
|
|
50
|
+
const preserved = preExistingDirty ? dirtyFiles.filter((f) => preExistingDirty.has(f)) : [];
|
|
51
|
+
for (const file of toClean) {
|
|
52
|
+
try {
|
|
53
|
+
await restoreTrackedPath(engineDir, file);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
warn(`Could not restore patch-touched file: ${file}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (toClean.length > 0) {
|
|
60
|
+
info(`Restored ${toClean.length} patch-touched file(s) to baseline state.`);
|
|
61
|
+
}
|
|
62
|
+
if (preserved.length > 0) {
|
|
63
|
+
warn(`${preserved.length} patch-touched file(s) had pre-existing changes and were left as-is:`);
|
|
64
|
+
for (const file of preserved) {
|
|
65
|
+
warn(` ${file}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
11
69
|
/**
|
|
12
70
|
* Runs the download command.
|
|
13
71
|
* @param projectRoot - Root directory of the project
|
|
@@ -33,6 +91,12 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
33
91
|
if (isMissingHeadError(error)) {
|
|
34
92
|
// Partial init detected — attempt to resume instead of requiring --force
|
|
35
93
|
info('Detected partially initialized engine. Attempting to resume...');
|
|
94
|
+
// Snapshot patch-touched files that are already dirty so we
|
|
95
|
+
// can preserve them after the resume commit.
|
|
96
|
+
const patchFiles = await getPatchTouchedFiles(paths.patches);
|
|
97
|
+
const preExistingDirty = patchFiles.size > 0
|
|
98
|
+
? new Set(await getDirtyFiles(paths.engine, [...patchFiles]))
|
|
99
|
+
: new Set();
|
|
36
100
|
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
37
101
|
try {
|
|
38
102
|
await resumeRepository(paths.engine, {
|
|
@@ -45,6 +109,14 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
45
109
|
});
|
|
46
110
|
const baseCommit = await getHead(paths.engine);
|
|
47
111
|
resumeSpinner.stop('Git repository resumed successfully');
|
|
112
|
+
// Restore patch-touched files BEFORE stamping state. If this
|
|
113
|
+
// step fails (disk full, permission denied, git object issue),
|
|
114
|
+
// state.json keeps the previous downloadedVersion so the
|
|
115
|
+
// invariant "state.downloadedVersion matches a clean engine"
|
|
116
|
+
// holds. A retry of `fireforge download` then re-enters the
|
|
117
|
+
// resume path instead of declaring success against a dirty
|
|
118
|
+
// engine.
|
|
119
|
+
await cleanPatchTouchedFiles(paths.engine, paths.patches, preExistingDirty);
|
|
48
120
|
await updateState(projectRoot, {
|
|
49
121
|
downloadedVersion: version,
|
|
50
122
|
baseCommit,
|
|
@@ -53,14 +125,31 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
53
125
|
return;
|
|
54
126
|
}
|
|
55
127
|
catch (error) {
|
|
56
|
-
void error;
|
|
57
128
|
resumeSpinner.error('Resume failed');
|
|
58
|
-
|
|
129
|
+
// Preserve the underlying cause so the user sees *why* the
|
|
130
|
+
// resume failed (timeout, permission denied, corrupted object,
|
|
131
|
+
// disk full, …) instead of only the generic "partial engine
|
|
132
|
+
// exists" story. Verbose mode prints the stack for deeper
|
|
133
|
+
// triage.
|
|
134
|
+
const cause = toError(error);
|
|
135
|
+
verbose(`Resume failure detail: ${cause.message}`);
|
|
136
|
+
if (cause.stack) {
|
|
137
|
+
verbose(cause.stack);
|
|
138
|
+
}
|
|
139
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
59
140
|
}
|
|
60
141
|
}
|
|
61
|
-
// Re-throw unexpected git errors (
|
|
62
|
-
//
|
|
63
|
-
|
|
142
|
+
// Re-throw unexpected git errors (corrupted objects, permission
|
|
143
|
+
// denied, …) wrapped in PartialEngineExistsError so the user sees
|
|
144
|
+
// both narratives: "we detected a partial engine and attempted
|
|
145
|
+
// resume" AND the underlying git failure. Without the wrap the
|
|
146
|
+
// raw git error loses the context that resume was in flight.
|
|
147
|
+
const cause = toError(error);
|
|
148
|
+
verbose(`Partial-engine probe failed with unexpected error: ${cause.message}`);
|
|
149
|
+
if (cause.stack) {
|
|
150
|
+
verbose(cause.stack);
|
|
151
|
+
}
|
|
152
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
64
153
|
}
|
|
65
154
|
}
|
|
66
155
|
throw new EngineExistsError(paths.engine);
|
|
@@ -122,7 +211,17 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
122
211
|
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
123
212
|
throw error;
|
|
124
213
|
}
|
|
125
|
-
//
|
|
214
|
+
// Restore any patch-touched files that ended up dirty after the initial
|
|
215
|
+
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
216
|
+
// a subsequent `fireforge import` works without --force.
|
|
217
|
+
//
|
|
218
|
+
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
219
|
+
// downloadedVersion in state.json. The invariant we preserve is
|
|
220
|
+
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
221
|
+
// version only after the restore succeeds means a failed clean-up will
|
|
222
|
+
// re-enter the resume path on the next `fireforge download` rather than
|
|
223
|
+
// reporting success against a dirty engine.
|
|
224
|
+
await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
126
225
|
await updateState(projectRoot, {
|
|
127
226
|
downloadedVersion: version,
|
|
128
227
|
baseCommit,
|
|
@@ -47,6 +47,13 @@ export async function runPatchLint(engineDir, filesAffected, diffContent, config
|
|
|
47
47
|
}
|
|
48
48
|
info(`Lint: ${errors.length} error(s) downgraded to warnings (--skip-lint)`);
|
|
49
49
|
}
|
|
50
|
+
else if (skipLint) {
|
|
51
|
+
// Always announce that --skip-lint was honoured, even when there were
|
|
52
|
+
// no errors to downgrade, so the operator can confirm the flag took
|
|
53
|
+
// effect. Without this, a clean `--skip-lint` run emitted nothing
|
|
54
|
+
// about the flag and looked identical to an unflagged run.
|
|
55
|
+
info('Lint: 0 error(s); --skip-lint is active (no effect on this run).');
|
|
56
|
+
}
|
|
50
57
|
const warnCount = warnings.length + (skipLint ? errors.length : 0);
|
|
51
58
|
if (warnCount > 0) {
|
|
52
59
|
info(`Patch lint: ${warnCount} warning(s)`);
|
|
@@ -139,6 +139,11 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
139
139
|
}
|
|
140
140
|
let diff = await generatePatchDiff(paths.engine, allFiles);
|
|
141
141
|
if (!diff.trim()) {
|
|
142
|
+
if (options.skipLint) {
|
|
143
|
+
throw new GeneralError('The specified paths have no diff content to export. ' +
|
|
144
|
+
'(--skip-lint is set; lint checks were bypassed but there are still no content changes — ' +
|
|
145
|
+
'the paths may have only been lint-level differences that resolved, or the working tree is already clean.)');
|
|
146
|
+
}
|
|
142
147
|
throw new GeneralError('The specified paths have no diff content to export.');
|
|
143
148
|
}
|
|
144
149
|
// Ensure patches directory exists
|
|
@@ -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();
|
|
@@ -134,7 +134,11 @@ async function scaffoldTestFiles(componentName, license, forgeConfig, paths, jou
|
|
|
134
134
|
// browser.toml — create if missing, append entry if existing
|
|
135
135
|
const tomlPath = join(testDir, 'browser.toml');
|
|
136
136
|
if (await pathExists(tomlPath)) {
|
|
137
|
-
//
|
|
137
|
+
// Defensive guard: only append if the entry is not already present.
|
|
138
|
+
// With a fresh journal per create, the same test file name cannot be
|
|
139
|
+
// appended twice in a single run — but retaining the check protects
|
|
140
|
+
// against accidental re-entrance or a future refactor that reuses the
|
|
141
|
+
// helper with a stale test directory.
|
|
138
142
|
const existingToml = await readText(tomlPath);
|
|
139
143
|
if (!existingToml.includes(`["${testFileName}"]`)) {
|
|
140
144
|
if (journal)
|
|
@@ -284,14 +288,21 @@ async function writeComponentFiles(componentDir, componentName, className, descr
|
|
|
284
288
|
* state.
|
|
285
289
|
*/
|
|
286
290
|
async function performCreateMutations(args) {
|
|
291
|
+
// Invariant: the journal MUST be registered with the operation context
|
|
292
|
+
// BEFORE any filesystem mutation (including recordCreatedDir, whose entries
|
|
293
|
+
// are consulted by SIGINT rollback). The try/catch below assumes signal
|
|
294
|
+
// handlers can find the journal for any partial write that follows.
|
|
287
295
|
const journal = createRollbackJournal();
|
|
288
296
|
if (args.operationContext) {
|
|
289
297
|
args.operationContext.registerJournal(journal);
|
|
290
298
|
}
|
|
291
|
-
recordCreatedDir(journal, args.componentDir);
|
|
292
299
|
const testFiles = [];
|
|
293
300
|
let files;
|
|
294
301
|
try {
|
|
302
|
+
// Record the componentDir creation entry immediately after registration
|
|
303
|
+
// so signal-driven rollback can clean it up even if writeComponentFiles
|
|
304
|
+
// is interrupted mid-ensureDir.
|
|
305
|
+
recordCreatedDir(journal, args.componentDir);
|
|
295
306
|
files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
|
|
296
307
|
const customEntry = {
|
|
297
308
|
description: args.description,
|