@hominis/fireforge 0.13.1 → 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 (61) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -8
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create.js +13 -2
  13. package/dist/src/commands/furnace/deploy.js +17 -2
  14. package/dist/src/commands/furnace/diff.js +3 -1
  15. package/dist/src/commands/furnace/init.js +25 -7
  16. package/dist/src/commands/furnace/list.js +15 -7
  17. package/dist/src/commands/furnace/override.js +47 -15
  18. package/dist/src/commands/furnace/remove.js +68 -20
  19. package/dist/src/commands/furnace/rename.js +31 -3
  20. package/dist/src/commands/furnace/scan.js +8 -0
  21. package/dist/src/commands/furnace/validate.js +70 -7
  22. package/dist/src/commands/import.js +65 -11
  23. package/dist/src/commands/patch/compact.d.ts +25 -0
  24. package/dist/src/commands/patch/compact.js +132 -0
  25. package/dist/src/commands/patch/index.d.ts +1 -0
  26. package/dist/src/commands/patch/index.js +4 -1
  27. package/dist/src/commands/patch/reorder.d.ts +5 -1
  28. package/dist/src/commands/patch/reorder.js +4 -2
  29. package/dist/src/commands/re-export.js +11 -4
  30. package/dist/src/commands/rebase/abort.js +26 -14
  31. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  32. package/dist/src/commands/rebase/confirm.js +2 -2
  33. package/dist/src/commands/rebase/continue.js +39 -15
  34. package/dist/src/commands/rebase/index.js +2 -1
  35. package/dist/src/commands/rebase/patch-loop.js +90 -33
  36. package/dist/src/commands/register.js +13 -0
  37. package/dist/src/commands/resolve.js +31 -10
  38. package/dist/src/commands/run.js +9 -44
  39. package/dist/src/commands/setup-support.js +25 -7
  40. package/dist/src/commands/status.js +59 -8
  41. package/dist/src/commands/test.js +13 -7
  42. package/dist/src/commands/token.js +11 -1
  43. package/dist/src/commands/watch.js +51 -1
  44. package/dist/src/commands/wire.js +23 -0
  45. package/dist/src/core/config-validate.js +15 -1
  46. package/dist/src/core/furnace-registration.d.ts +1 -1
  47. package/dist/src/core/furnace-registration.js +2 -1
  48. package/dist/src/core/furnace-staleness.d.ts +17 -0
  49. package/dist/src/core/furnace-staleness.js +58 -0
  50. package/dist/src/core/license-headers.d.ts +15 -0
  51. package/dist/src/core/license-headers.js +28 -0
  52. package/dist/src/core/manifest-rules.js +24 -3
  53. package/dist/src/core/patch-lint.d.ts +11 -0
  54. package/dist/src/core/patch-lint.js +30 -3
  55. package/dist/src/core/signal-critical.d.ts +49 -0
  56. package/dist/src/core/signal-critical.js +80 -0
  57. package/dist/src/errors/download.d.ts +1 -1
  58. package/dist/src/errors/download.js +6 -3
  59. package/dist/src/types/commands/index.d.ts +1 -1
  60. package/dist/src/types/commands/options.d.ts +9 -0
  61. 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
@@ -12,6 +66,17 @@
12
66
  - **`observer-topic-naming` no longer matches across newlines.** The regex that extracts topic strings from `notifyObservers`/`addObserver`/`removeObserver` calls now anchors to a single line, preventing false positives when the call spans multiple lines and an unrelated string literal appears later.
13
67
  - **`raw-color-value` now supports a file allowlist and inline suppression.** New `patchLint.rawColorAllowlist` config array in `fireforge.json` exempts file paths (exact or basename match) from the raw-color check — intended for design token files that must contain raw color values. Individual declarations can also be suppressed with an inline `/* fireforge-ignore: raw-color-value */` comment.
14
68
  - **`large-patch-lines` now uses tiered severity thresholds.** The old single >300-line warning is replaced with a three-tier system matching the `file-too-large` pattern. General patches: 800+ lines notice, 1500+ warning, 3000+ error. Test-only patches (all files match test patterns): 1500+ notice, 3000+ warning, 6000+ error. The previous threshold was too restrictive relative to file LOC limits — creating a single new file at the `file-too-large` notice tier (500 LOC) already exceeded it. Messages now include the applicable soft and hard limits.
69
+ - **`large-patch-lines` now ignores binary content.** Patches whose diff contains GIT binary patch hunks (PNG, ICO, ICNS, BMP, etc.) no longer count base85-encoded data toward the line limit. This removes the need for `--skip-lint` on branding asset patches that are predominantly binary.
70
+ - **`modified-file-missing-header` no longer false-positives on upstream files.** Modified upstream files (e.g. `BrowserGlue.sys.mjs`) that carry an MPL-2.0 header in `/* */` block-comment style were incorrectly flagged because the check only tried the comment style inferred from the file extension. The check now cascades through all comment styles and falls back to scanning leading lines for raw license identifier strings (MPL, Apache, MIT, GPL, SPDX).
71
+
72
+ ### New commands
73
+
74
+ - **`fireforge patch compact`** — closes ordinal gaps in the patch queue in a single atomic operation. After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7); `compact` renumbers them sequentially (1, 2, 3). Previously this required N sequential `patch reorder` calls. Supports `--dry-run` and `--yes`.
75
+
76
+ ### Register improvements
77
+
78
+ - **`register` now supports `.xhtml` and `.css` files in `browser/base/content/`.** Previously only `.js` and `.mjs` files were accepted; XHTML and CSS files required manual `jar.mn` edits.
79
+ - **`register` now gives actionable advice for unregistrable file types.** Attempting to register a `.ftl` locale file explains that FTL files are auto-discovered via `jar.mn` glob patterns. Attempting to register an individual test file explains that it should be added to the corresponding `browser.toml` and suggests the correct `register` invocation for the test directory manifest.
15
80
 
16
81
  ### General Improvements
17
82
 
package/README.md CHANGED
@@ -233,6 +233,7 @@ Then fix with the appropriate primitive:
233
233
  | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
234
234
  | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
235
235
  | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
236
+ | Ordinal gaps after deletes/splits | `fireforge patch compact` |
236
237
  | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
237
238
  | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
238
239
  | Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset` |
@@ -266,13 +267,13 @@ fireforge register browser/modules/mybrowser/MyStore.sys.mjs
266
267
  <details>
267
268
  <summary>Supported register patterns</summary>
268
269
 
269
- | File pattern | Manifest | Entry format |
270
- | ------------------------------------------ | ------------------------------------- | ----------------------------------- |
271
- | `browser/themes/shared/*.css` | `browser/themes/shared/jar.inc.mn` | `skin/classic/browser/{name}.css` |
272
- | `browser/base/content/*.{js,mjs}` | `browser/base/jar.mn` | `content/browser/{file}` |
273
- | `browser/base/content/test/*/browser.toml` | `browser/base/moz.build` | `"content/test/{dir}/browser.toml"` |
274
- | `browser/modules/mybrowser/*.sys.mjs` | `browser/modules/mybrowser/moz.build` | `"{name}.sys.mjs"` |
275
- | `toolkit/content/widgets/*/*.{mjs,css}` | `toolkit/content/jar.mn` | `content/global/elements/{file}` |
270
+ | File pattern | Manifest | Entry format |
271
+ | ------------------------------------------- | ------------------------------------- | ----------------------------------- |
272
+ | `browser/themes/shared/*.css` | `browser/themes/shared/jar.inc.mn` | `skin/classic/browser/{name}.css` |
273
+ | `browser/base/content/*.{js,mjs,xhtml,css}` | `browser/base/jar.mn` | `content/browser/{file}` |
274
+ | `browser/base/content/test/*/browser.toml` | `browser/base/moz.build` | `"content/test/{dir}/browser.toml"` |
275
+ | `browser/modules/mybrowser/*.sys.mjs` | `browser/modules/mybrowser/moz.build` | `"{name}.sys.mjs"` |
276
+ | `toolkit/content/widgets/*/*.{mjs,css}` | `toolkit/content/jar.mn` | `content/global/elements/{file}` |
276
277
 
277
278
  </details>
278
279
 
@@ -327,9 +328,12 @@ fireforge patch reorder 003-ui-sidebar-tweaks.patch --to 1
327
328
 
328
329
  # Move a patch before or after another
329
330
  fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
331
+
332
+ # Close ordinal gaps after deletes or splits (e.g. 1, 3, 7 → 1, 2, 3)
333
+ fireforge patch compact
330
334
  ```
331
335
 
332
- Both subcommands support `--dry-run` and `--yes`.
336
+ All subcommands support `--dry-run` and `--yes`.
333
337
 
334
338
  ### Additional workflow commands
335
339
 
@@ -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