@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/bin/fireforge.js +19 -5
  3. package/dist/src/commands/config.js +7 -1
  4. package/dist/src/commands/discard.js +6 -1
  5. package/dist/src/commands/doctor.d.ts +12 -0
  6. package/dist/src/commands/doctor.js +6 -1
  7. package/dist/src/commands/download.js +106 -7
  8. package/dist/src/commands/export-shared.js +7 -0
  9. package/dist/src/commands/export.js +5 -0
  10. package/dist/src/commands/furnace/apply.js +147 -47
  11. package/dist/src/commands/furnace/create.js +13 -2
  12. package/dist/src/commands/furnace/deploy.js +17 -2
  13. package/dist/src/commands/furnace/diff.js +3 -1
  14. package/dist/src/commands/furnace/init.js +25 -7
  15. package/dist/src/commands/furnace/list.js +15 -7
  16. package/dist/src/commands/furnace/override.js +47 -15
  17. package/dist/src/commands/furnace/remove.js +68 -20
  18. package/dist/src/commands/furnace/rename.js +31 -3
  19. package/dist/src/commands/furnace/scan.js +8 -0
  20. package/dist/src/commands/furnace/validate.js +70 -7
  21. package/dist/src/commands/import.js +65 -11
  22. package/dist/src/commands/re-export.js +11 -4
  23. package/dist/src/commands/rebase/abort.js +26 -14
  24. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  25. package/dist/src/commands/rebase/confirm.js +2 -2
  26. package/dist/src/commands/rebase/continue.js +39 -15
  27. package/dist/src/commands/rebase/index.js +2 -1
  28. package/dist/src/commands/rebase/patch-loop.js +90 -33
  29. package/dist/src/commands/register.js +13 -0
  30. package/dist/src/commands/resolve.js +31 -10
  31. package/dist/src/commands/run.js +9 -44
  32. package/dist/src/commands/setup-support.js +25 -7
  33. package/dist/src/commands/status.js +59 -8
  34. package/dist/src/commands/test.js +13 -7
  35. package/dist/src/commands/token.js +11 -1
  36. package/dist/src/commands/watch.js +51 -1
  37. package/dist/src/commands/wire.js +23 -0
  38. package/dist/src/core/config-validate.js +15 -1
  39. package/dist/src/core/furnace-registration.d.ts +1 -1
  40. package/dist/src/core/furnace-registration.js +2 -1
  41. package/dist/src/core/furnace-staleness.d.ts +17 -0
  42. package/dist/src/core/furnace-staleness.js +58 -0
  43. package/dist/src/core/signal-critical.d.ts +49 -0
  44. package/dist/src/core/signal-critical.js +80 -0
  45. package/dist/src/errors/download.d.ts +1 -1
  46. package/dist/src/errors/download.js +6 -3
  47. 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
@@ -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
- rollbackActiveOperationsForSignal(signal)
37
- .catch((error) => {
38
- console.error(`Furnace rollback after ${signal} failed:`, error instanceof Error ? error.message : error);
39
- })
40
- .finally(() => {
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
- if (options.force) {
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}`, error instanceof Error ? error : undefined);
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
- throw new PartialEngineExistsError(paths.engine);
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 (e.g. corrupted objects) rather
62
- // than masking them behind the generic EngineExistsError below.
63
- throw error;
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
- // Update state
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
- if (await pathExists(furnacePaths.overridesDir))
46
- watchDirs.push(furnacePaths.overridesDir);
47
- if (await pathExists(furnacePaths.customDir))
48
- watchDirs.push(furnacePaths.customDir);
49
- if (watchDirs.length === 0) {
50
- info('No component directories to watch.');
51
- return;
52
- }
81
+ const watchers = new Map();
53
82
  if (process.platform === 'linux') {
54
83
  warn('Watch mode uses fs.watch with recursive: true, which may miss changes ' +
55
84
  'in deeply nested directories on Linux. A periodic poll runs every 30s as a fallback.');
56
85
  }
57
- info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
58
86
  let debounceTimer = null;
59
87
  let applyInFlight = false;
60
- let lastChecksums = await snapshotWatchedChecksums(watchDirs);
88
+ // Coalesces changes that arrive while an apply is running. Without this
89
+ // flag, a second edit during an in-flight apply is debounced, the timer
90
+ // fires while applyInFlight is true, and the change is dropped entirely
91
+ // because the post-apply checksum snapshot already reflects the edit so
92
+ // the 30s poll also sees no diff.
93
+ let pendingChange = false;
94
+ let lastChecksums = new Map();
95
+ let pollTimer = null;
96
+ const runApplyCycle = async () => {
97
+ applyInFlight = true;
98
+ try {
99
+ info('\nChange detected — re-applying...');
100
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
101
+ logApplyResult(result, false);
102
+ const applied = result.applied.length;
103
+ const skipped = result.skipped.length;
104
+ info(`Re-applied: ${applied} applied, ${skipped} skipped`);
105
+ }
106
+ catch (err) {
107
+ warn(classifyWatchApplyError(err));
108
+ }
109
+ finally {
110
+ applyInFlight = false;
111
+ // Update checksums after apply so the next poll does not re-trigger
112
+ // for changes that are already reflected in the engine.
113
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
114
+ }
115
+ // Another change arrived while we were applying — run again so the edit
116
+ // is not silently absorbed into the post-apply checksum bump.
117
+ if (pendingChange) {
118
+ pendingChange = false;
119
+ await runApplyCycle();
120
+ }
121
+ };
61
122
  const triggerApply = () => {
123
+ if (applyInFlight) {
124
+ // An apply is already running; record the change so runApplyCycle
125
+ // re-runs after it completes. Do not schedule a new debounce: the
126
+ // in-flight apply will observe this flag when it finishes.
127
+ pendingChange = true;
128
+ return;
129
+ }
62
130
  if (debounceTimer)
63
131
  clearTimeout(debounceTimer);
64
132
  debounceTimer = setTimeout(() => {
65
- if (applyInFlight)
133
+ debounceTimer = null;
134
+ if (applyInFlight) {
135
+ // Race with a change that started its own apply between the debounce
136
+ // scheduling and the timer firing. Record the pending change; the
137
+ // in-flight apply will pick it up.
138
+ pendingChange = true;
66
139
  return;
67
- applyInFlight = true;
68
- void (async () => {
69
- try {
70
- info('\nChange detected — re-applying...');
71
- const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
72
- logApplyResult(result, false);
73
- const applied = result.applied.length;
74
- const skipped = result.skipped.length;
75
- info(`Re-applied: ${applied} applied, ${skipped} skipped`);
76
- }
77
- catch (err) {
78
- warn(`Apply failed: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ void runApplyCycle();
142
+ }, 300);
143
+ };
144
+ const installWatcher = (dir) => {
145
+ if (watchers.has(dir))
146
+ return false;
147
+ try {
148
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
149
+ if (!filename)
150
+ return;
151
+ if (isComponentSourceFile(filename)) {
152
+ triggerApply();
79
153
  }
80
- finally {
81
- applyInFlight = false;
82
- // Update checksums after apply so the next poll does not re-trigger.
83
- lastChecksums = await snapshotWatchedChecksums(watchDirs);
154
+ });
155
+ watcher.on('error', (err) => {
156
+ warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
157
+ });
158
+ watchers.set(dir, watcher);
159
+ watchDirs.push(dir);
160
+ return true;
161
+ }
162
+ catch (err) {
163
+ // Directory vanished between the pathExists check and fs.watch, or
164
+ // fs.watch otherwise refused. refreshWatchers will retry on the next
165
+ // poll tick.
166
+ warn(`Could not start watcher on ${dir}: ${err instanceof Error ? err.message : String(err)}`);
167
+ return false;
168
+ }
169
+ };
170
+ // Scans candidate dirs for ones we are not yet watching and installs a
171
+ // watcher for each that now exists. Returns true when at least one new
172
+ // watcher was installed so the caller can trigger an apply cycle for
173
+ // the just-noticed content.
174
+ const refreshWatchers = async () => {
175
+ let added = false;
176
+ for (const dir of candidateDirs) {
177
+ if (watchers.has(dir))
178
+ continue;
179
+ if (await pathExists(dir)) {
180
+ if (installWatcher(dir)) {
181
+ info(`Now watching ${dir}`);
182
+ added = true;
84
183
  }
85
- })();
86
- }, 300);
184
+ }
185
+ }
186
+ return added;
87
187
  };
88
188
  // Register signal-driven cleanup BEFORE creating watchers so there is no
89
189
  // race window where a SIGINT could arrive after watchers exist but before
90
190
  // cleanup handlers are registered.
91
- const watchers = [];
92
- let pollTimer = null;
93
191
  const cleanup = () => {
94
- for (const w of watchers)
192
+ for (const w of watchers.values())
95
193
  w.close();
96
194
  if (debounceTimer)
97
195
  clearTimeout(debounceTimer);
@@ -100,26 +198,28 @@ async function runWatchLoop(projectRoot) {
100
198
  };
101
199
  process.once('SIGINT', cleanup);
102
200
  process.once('SIGTERM', cleanup);
103
- for (const dir of watchDirs) {
104
- const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
105
- if (!filename)
106
- return;
107
- if (isComponentSourceFile(filename)) {
108
- triggerApply();
109
- }
110
- });
111
- watcher.on('error', (err) => {
112
- warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
113
- });
114
- watchers.push(watcher);
115
- }
116
- // Periodic checksum-based poll to catch events missed by fs.watch (known
117
- // issue on Linux with recursive: true and certain filesystems).
201
+ await refreshWatchers();
202
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
203
+ if (watchDirs.length === 0) {
204
+ info('No component directories exist yet — will retry every 30s. Create one with "fireforge furnace override" or "fireforge furnace create" in another terminal to begin watching.');
205
+ }
206
+ else {
207
+ info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
208
+ }
209
+ // Periodic checksum-based poll that also picks up newly-created component
210
+ // dirs (fs.watch was not installed for them at startup because they did
211
+ // not yet exist).
118
212
  pollTimer = setInterval(() => {
119
213
  if (applyInFlight)
120
214
  return;
121
215
  void (async () => {
122
216
  try {
217
+ const added = await refreshWatchers();
218
+ if (added) {
219
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
220
+ triggerApply();
221
+ return;
222
+ }
123
223
  const current = await snapshotWatchedChecksums(watchDirs);
124
224
  if (!checksumMapsEqual(current, lastChecksums)) {
125
225
  triggerApply();
@@ -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
- // Append the new test entry if not already present
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,