@hominis/fireforge 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +6 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Unified-diff walking helpers shared between per-patch lint rules,
4
+ * cross-patch lint rules, and the export / re-export projection paths.
5
+ *
6
+ * Factored out of `patch-lint.ts` so the per-patch lint body and
7
+ * cross-patch lint body (in `patch-lint-cross.ts`) can both depend on
8
+ * the same diff walkers without inducing a circular import. Callers
9
+ * should keep importing these through `patch-lint.ts` — this file is
10
+ * an implementation detail.
11
+ */
12
+ /**
13
+ * Extracts new-file paths from a unified diff by scanning for `new file mode` markers.
14
+ */
15
+ export function detectNewFilesInDiff(diffContent) {
16
+ const newFiles = new Set();
17
+ const lines = diffContent.split('\n');
18
+ let currentFile = null;
19
+ for (const line of lines) {
20
+ if (line.startsWith('diff --git')) {
21
+ const match = /^diff --git a\/.+ b\/(.+)$/.exec(line);
22
+ currentFile = match?.[1] ?? null;
23
+ continue;
24
+ }
25
+ if (line.startsWith('new file mode') && currentFile) {
26
+ newFiles.add(currentFile);
27
+ }
28
+ }
29
+ return newFiles;
30
+ }
31
+ /**
32
+ * Extracts added lines per file from a unified diff.
33
+ * Returns a map of file path → array of added line contents (without the leading `+`).
34
+ */
35
+ export function extractAddedLinesPerFile(diffContent) {
36
+ const result = new Map();
37
+ const lines = diffContent.split('\n');
38
+ let currentFile = null;
39
+ let inHunk = false;
40
+ for (const line of lines) {
41
+ if (line.startsWith('diff --git')) {
42
+ const match = /^diff --git a\/.+ b\/(.+)$/.exec(line);
43
+ currentFile = match?.[1] ?? null;
44
+ inHunk = false;
45
+ continue;
46
+ }
47
+ if (line.startsWith('@@')) {
48
+ inHunk = true;
49
+ continue;
50
+ }
51
+ if (inHunk && currentFile && line.startsWith('+') && !line.startsWith('+++')) {
52
+ let arr = result.get(currentFile);
53
+ if (!arr) {
54
+ arr = [];
55
+ result.set(currentFile, arr);
56
+ }
57
+ arr.push(line.slice(1));
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+ /**
63
+ * Builds the `modifiedFileAdditions` map the cross-patch lint expects for
64
+ * a given unified diff. Exposed so callers that construct synthetic /
65
+ * projected `PatchQueueEntry` values (notably `re-export --files`
66
+ * and `export --order`) can populate the field identically to
67
+ * `buildPatchQueueContext`.
68
+ *
69
+ * Matches buildPatchQueueContext's algorithm exactly: skip paths that are
70
+ * created by the diff — those are already covered by the `newFiles` map,
71
+ * which carries full content rather than only the added lines.
72
+ *
73
+ * @param diff - Unified diff content
74
+ */
75
+ export function buildModifiedFileAdditionsFromDiff(diff) {
76
+ const newFilePaths = detectNewFilesInDiff(diff);
77
+ const result = new Map();
78
+ for (const [file, lines] of extractAddedLinesPerFile(diff)) {
79
+ if (!newFilePaths.has(file))
80
+ result.set(file, lines.join('\n'));
81
+ }
82
+ return result;
83
+ }
84
+ //# sourceMappingURL=patch-lint-diff.js.map
@@ -1,14 +1,12 @@
1
1
  import type { PatchLintIssue } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type CommentStyle } from './license-headers.js';
4
+ export { buildPatchQueueContext, collectNewFileCreatorsByPath, type ExtractedSpecifier, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, type PatchQueueContext, type PatchQueueEntry, } from './patch-lint-cross.js';
5
+ export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
4
6
  /**
5
7
  * Detects comment style from file extension for license header checks.
6
8
  */
7
9
  export declare function commentStyleForFile(file: string): CommentStyle | null;
8
- /**
9
- * Extracts new-file paths from a unified diff by scanning for `new file mode` markers.
10
- */
11
- export declare function detectNewFilesInDiff(diffContent: string): Set<string>;
12
10
  /**
13
11
  * Lints patched CSS files for introduced raw color values and non-tokenized
14
12
  * custom properties.
@@ -6,6 +6,18 @@ import { verbose } from '../utils/logger.js';
6
6
  import { hasRawCssColors, stripJsComments } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
8
  import { getLicenseHeader, hasAnyLicenseHeader } from './license-headers.js';
9
+ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Cross-patch lint re-exports
12
+ // ---------------------------------------------------------------------------
13
+ //
14
+ // The cross-patch lint infrastructure (queue context builder, duplicate-
15
+ // creation and forward-import rules, ignore marker) lives in
16
+ // `patch-lint-cross.ts` so the per-patch and cross-patch rule bodies can
17
+ // each stay within the project's per-file line budget. Re-export the
18
+ // public surface so callers continue to import from a single module.
19
+ export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
20
+ export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
9
21
  // ---------------------------------------------------------------------------
10
22
  // Helpers
11
23
  // ---------------------------------------------------------------------------
@@ -29,56 +41,6 @@ export function commentStyleForFile(file) {
29
41
  return 'js';
30
42
  return null;
31
43
  }
32
- /**
33
- * Extracts new-file paths from a unified diff by scanning for `new file mode` markers.
34
- */
35
- export function detectNewFilesInDiff(diffContent) {
36
- const newFiles = new Set();
37
- const lines = diffContent.split('\n');
38
- let currentFile = null;
39
- for (const line of lines) {
40
- if (line.startsWith('diff --git')) {
41
- const match = /^diff --git a\/.+ b\/(.+)$/.exec(line);
42
- currentFile = match?.[1] ?? null;
43
- continue;
44
- }
45
- if (line.startsWith('new file mode') && currentFile) {
46
- newFiles.add(currentFile);
47
- }
48
- }
49
- return newFiles;
50
- }
51
- /**
52
- * Extracts added lines per file from a unified diff.
53
- * Returns a map of file path → array of added line contents (without the leading `+`).
54
- */
55
- function extractAddedLinesPerFile(diffContent) {
56
- const result = new Map();
57
- const lines = diffContent.split('\n');
58
- let currentFile = null;
59
- let inHunk = false;
60
- for (const line of lines) {
61
- if (line.startsWith('diff --git')) {
62
- const match = /^diff --git a\/.+ b\/(.+)$/.exec(line);
63
- currentFile = match?.[1] ?? null;
64
- inHunk = false;
65
- continue;
66
- }
67
- if (line.startsWith('@@')) {
68
- inHunk = true;
69
- continue;
70
- }
71
- if (inHunk && currentFile && line.startsWith('+') && !line.startsWith('+++')) {
72
- let arr = result.get(currentFile);
73
- if (!arr) {
74
- arr = [];
75
- result.set(currentFile, arr);
76
- }
77
- arr.push(line.slice(1));
78
- }
79
- }
80
- return result;
81
- }
82
44
  // ---------------------------------------------------------------------------
83
45
  // CSS lint
84
46
  // ---------------------------------------------------------------------------
@@ -3,6 +3,7 @@
3
3
  * Filesystem-based lock for serializing patch directory mutations.
4
4
  */
5
5
  import { join } from 'node:path';
6
+ import { FireForgeError } from '../errors/base.js';
6
7
  import { PatchError } from '../errors/patch.js';
7
8
  import { toError } from '../utils/errors.js';
8
9
  import { withFileLock } from './file-lock.js';
@@ -20,7 +21,7 @@ export async function withPatchDirectoryLock(patchesDir, operation) {
20
21
  onStaleLockMessage: (ageMs) => `Removing stale patch lock (age: ${Math.round(ageMs / 1000)}s). ` +
21
22
  'A previous fireforge process may have crashed.',
22
23
  }).catch((error) => {
23
- if (error instanceof PatchError) {
24
+ if (error instanceof FireForgeError) {
24
25
  throw error;
25
26
  }
26
27
  throw new PatchError(toError(error).message);
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Manifest I/O: load, save, and add operations for patches.json.
2
+ * Manifest I/O: load, save, and mutating operations for patches.json.
3
+ *
4
+ * Mutations (add / remove / renumber) are intended to be the only sanctioned
5
+ * way to change on-disk manifest state. They run under the shared patch
6
+ * directory lock so concurrent commands cannot race each other into
7
+ * inconsistent manifests.
3
8
  */
9
+ import { FireForgeError } from '../errors/base.js';
4
10
  import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
5
11
  /** Filename for the patches manifest */
6
12
  export declare const PATCHES_MANIFEST = "patches.json";
@@ -34,3 +40,98 @@ export declare function savePatchesManifest(patchesDir: string, manifest: Patche
34
40
  * @param removeFilenames - Optional filenames to remove in the same read-modify-write cycle
35
41
  */
36
42
  export declare function addPatchToManifest(patchesDir: string, metadata: PatchMetadata, removeFilenames?: string[]): Promise<void>;
43
+ /**
44
+ * Removes a single patch entry from the manifest by filename. Leaves the
45
+ * ordinal gap in place — callers wanting to close the gap must use
46
+ * {@link renumberPatchesInManifest} explicitly. This matches the spec: delete
47
+ * is a row removal, not a resequencing.
48
+ *
49
+ * Not atomic with any on-disk patch file deletion; callers are expected to
50
+ * remove the .patch file separately under the same lock.
51
+ *
52
+ * @param patchesDir - Path to the patches directory
53
+ * @param filename - Filename of the patch to remove from the manifest
54
+ * @returns True when the manifest was written (i.e. an entry was removed),
55
+ * false when no matching entry existed
56
+ */
57
+ export declare function removePatchFromManifest(patchesDir: string, filename: string): Promise<boolean>;
58
+ /**
59
+ * A single rename step in a {@link renumberPatchesInManifest} plan.
60
+ */
61
+ export interface PatchRenameEntry {
62
+ /** New filename (e.g. `005-ui-sidebar.patch`). */
63
+ newFilename: string;
64
+ /** New numeric order — must match the prefix of `newFilename`. */
65
+ newOrder: number;
66
+ }
67
+ /**
68
+ * Renames patch files on disk and rewrites the corresponding manifest rows
69
+ * atomically-ish: file renames use a two-phase staging strategy (rename each
70
+ * entry to a unique temp filename first, then rename the temp to its final
71
+ * target) so cycles like `003 → 005` while `005` also moves do not collide.
72
+ *
73
+ * The manifest is rewritten once at the end with all new filenames and
74
+ * orders. Failure semantics:
75
+ *
76
+ * - **Phase 1 (stage)**: rolls back by renaming staged files back to
77
+ * their originals. Manifest is untouched. Best-effort — a rollback
78
+ * rename failure is warned but not re-thrown.
79
+ * - **Phase 2 (stage → final)**: rolls back to the pre-operation state
80
+ * by reversing every partial step: files already at their final
81
+ * names are renamed back to staging, and all staged files are then
82
+ * renamed back to their originals. The manifest is untouched. If
83
+ * the rollback itself fails midway, the thrown error is augmented
84
+ * with a description of the residue so the operator can inspect.
85
+ * - **Phase 3 (manifest write)**: by this point all files are on disk
86
+ * at their new names; a manifest write failure will roll the files
87
+ * back to their original names before re-throwing so the directory
88
+ * and manifest stay in agreement. A rollback failure at this stage
89
+ * is warned (manifest was never mutated) and the original error is
90
+ * re-thrown.
91
+ *
92
+ * Does not sort the rename map for the caller — the map is the authoritative
93
+ * plan. Entries not present in the map keep their existing filename and
94
+ * order.
95
+ *
96
+ * @param patchesDir - Path to the patches directory
97
+ * @param renameMap - Map from existing filename → new filename/order
98
+ */
99
+ export declare function renumberPatchesInManifest(patchesDir: string, renameMap: Map<string, PatchRenameEntry>): Promise<void>;
100
+ /**
101
+ * Thrown when {@link removePatchFileAndManifest} cannot complete the file
102
+ * delete AND cannot restore the manifest row afterward, so the on-disk
103
+ * state and manifest state are known to disagree. Carries both the
104
+ * primary delete error and the rollback error so the caller (and the
105
+ * operator) can see the full failure chain instead of only the original
106
+ * error with a warning about the rollback buried in logs.
107
+ *
108
+ * Extends {@link FireForgeError} so the CLI top-level handler routes it
109
+ * through the rich-error formatter rather than the generic unexpected-error
110
+ * path; the dedicated `.name` is kept so programmatic callers and tests
111
+ * can still distinguish it with `instanceof PatchDeleteRollbackError`.
112
+ */
113
+ export declare class PatchDeleteRollbackError extends FireForgeError {
114
+ readonly filename: string;
115
+ readonly deleteError: Error;
116
+ readonly rollbackError: Error;
117
+ readonly code: 6;
118
+ constructor(filename: string, deleteError: Error, rollbackError: Error);
119
+ }
120
+ /**
121
+ * Deletes both a patch file on disk and its manifest row under the caller's
122
+ * lock. This is a convenience for the `patch delete` command; callers that
123
+ * need different ordering (e.g. deleting the file first without touching the
124
+ * manifest) should call the primitives separately.
125
+ *
126
+ * Failure semantics: if the manifest row was already removed and the
127
+ * file deletion then fails, the original manifest is restored best-effort
128
+ * and the delete error is re-thrown. If the restore itself also fails,
129
+ * a {@link PatchDeleteRollbackError} is thrown that carries both the
130
+ * delete error and the rollback error so neither is hidden behind a
131
+ * warning log. Callers can detect the compound failure with
132
+ * `instanceof PatchDeleteRollbackError`.
133
+ *
134
+ * @param patchesDir - Path to the patches directory
135
+ * @param filename - Patch filename to delete
136
+ */
137
+ export declare function removePatchFileAndManifest(patchesDir: string, filename: string): Promise<void>;
@@ -1,10 +1,20 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Manifest I/O: load, save, and add operations for patches.json.
3
+ * Manifest I/O: load, save, and mutating operations for patches.json.
4
+ *
5
+ * Mutations (add / remove / renumber) are intended to be the only sanctioned
6
+ * way to change on-disk manifest state. They run under the shared patch
7
+ * directory lock so concurrent commands cannot race each other into
8
+ * inconsistent manifests.
4
9
  */
10
+ import { randomUUID } from 'node:crypto';
11
+ import { rename } from 'node:fs/promises';
5
12
  import { join } from 'node:path';
13
+ import { FireForgeError } from '../errors/base.js';
14
+ import { ExitCode } from '../errors/codes.js';
6
15
  import { toError } from '../utils/errors.js';
7
- import { pathExists, readJson, writeJson } from '../utils/fs.js';
16
+ import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
17
+ import { warn } from '../utils/logger.js';
8
18
  import { validatePatchesManifest } from './patch-manifest-validate.js';
9
19
  /** Filename for the patches manifest */
10
20
  export const PATCHES_MANIFEST = 'patches.json';
@@ -74,4 +84,262 @@ export async function addPatchToManifest(patchesDir, metadata, removeFilenames)
74
84
  manifest.patches.sort((a, b) => a.order - b.order);
75
85
  await savePatchesManifest(patchesDir, manifest);
76
86
  }
87
+ /**
88
+ * Removes a single patch entry from the manifest by filename. Leaves the
89
+ * ordinal gap in place — callers wanting to close the gap must use
90
+ * {@link renumberPatchesInManifest} explicitly. This matches the spec: delete
91
+ * is a row removal, not a resequencing.
92
+ *
93
+ * Not atomic with any on-disk patch file deletion; callers are expected to
94
+ * remove the .patch file separately under the same lock.
95
+ *
96
+ * @param patchesDir - Path to the patches directory
97
+ * @param filename - Filename of the patch to remove from the manifest
98
+ * @returns True when the manifest was written (i.e. an entry was removed),
99
+ * false when no matching entry existed
100
+ */
101
+ export async function removePatchFromManifest(patchesDir, filename) {
102
+ const manifest = await loadPatchesManifest(patchesDir);
103
+ if (!manifest)
104
+ return false;
105
+ const originalLength = manifest.patches.length;
106
+ manifest.patches = manifest.patches.filter((p) => p.filename !== filename);
107
+ if (manifest.patches.length === originalLength) {
108
+ return false;
109
+ }
110
+ await savePatchesManifest(patchesDir, manifest);
111
+ return true;
112
+ }
113
+ /**
114
+ * Renames patch files on disk and rewrites the corresponding manifest rows
115
+ * atomically-ish: file renames use a two-phase staging strategy (rename each
116
+ * entry to a unique temp filename first, then rename the temp to its final
117
+ * target) so cycles like `003 → 005` while `005` also moves do not collide.
118
+ *
119
+ * The manifest is rewritten once at the end with all new filenames and
120
+ * orders. Failure semantics:
121
+ *
122
+ * - **Phase 1 (stage)**: rolls back by renaming staged files back to
123
+ * their originals. Manifest is untouched. Best-effort — a rollback
124
+ * rename failure is warned but not re-thrown.
125
+ * - **Phase 2 (stage → final)**: rolls back to the pre-operation state
126
+ * by reversing every partial step: files already at their final
127
+ * names are renamed back to staging, and all staged files are then
128
+ * renamed back to their originals. The manifest is untouched. If
129
+ * the rollback itself fails midway, the thrown error is augmented
130
+ * with a description of the residue so the operator can inspect.
131
+ * - **Phase 3 (manifest write)**: by this point all files are on disk
132
+ * at their new names; a manifest write failure will roll the files
133
+ * back to their original names before re-throwing so the directory
134
+ * and manifest stay in agreement. A rollback failure at this stage
135
+ * is warned (manifest was never mutated) and the original error is
136
+ * re-thrown.
137
+ *
138
+ * Does not sort the rename map for the caller — the map is the authoritative
139
+ * plan. Entries not present in the map keep their existing filename and
140
+ * order.
141
+ *
142
+ * @param patchesDir - Path to the patches directory
143
+ * @param renameMap - Map from existing filename → new filename/order
144
+ */
145
+ export async function renumberPatchesInManifest(patchesDir, renameMap) {
146
+ if (renameMap.size === 0)
147
+ return;
148
+ const manifest = await loadPatchesManifest(patchesDir);
149
+ if (!manifest) {
150
+ throw new Error('Cannot renumber patches: patches.json is missing.');
151
+ }
152
+ // Phase 1: rename each old filename to a unique temp staging name so the
153
+ // later rename to the final target cannot collide with another entry
154
+ // currently occupying that slot.
155
+ const stagingId = randomUUID();
156
+ const stagedRenames = [];
157
+ try {
158
+ for (const [oldFilename, entry] of renameMap) {
159
+ const oldPath = join(patchesDir, oldFilename);
160
+ if (!(await pathExists(oldPath))) {
161
+ throw new Error(`Cannot renumber: patch file is missing on disk: ${oldFilename}`);
162
+ }
163
+ const stagedName = `.fireforge-renumber-${stagingId}-${oldFilename}`;
164
+ const stagedPath = join(patchesDir, stagedName);
165
+ await rename(oldPath, stagedPath);
166
+ stagedRenames.push({ from: oldFilename, staged: stagedName, toEntry: entry });
167
+ }
168
+ }
169
+ catch (error) {
170
+ // Roll back phase 1: put any already-staged files back.
171
+ for (const { from, staged } of stagedRenames) {
172
+ try {
173
+ await rename(join(patchesDir, staged), join(patchesDir, from));
174
+ }
175
+ catch (rollbackError) {
176
+ warn(`Rollback warning: could not restore ${from} from staging: ${toError(rollbackError).message}`);
177
+ }
178
+ }
179
+ throw error;
180
+ }
181
+ // Phase 2: rename each staged file to its final target, tracking which
182
+ // entries have completed so we can reverse the partial state on failure.
183
+ const completedFinalRenames = [];
184
+ try {
185
+ for (const stagedEntry of stagedRenames) {
186
+ const { staged, toEntry } = stagedEntry;
187
+ const targetPath = join(patchesDir, toEntry.newFilename);
188
+ if (await pathExists(targetPath)) {
189
+ throw new Error(`Cannot renumber: target patch filename already exists on disk: ${toEntry.newFilename}`);
190
+ }
191
+ await rename(join(patchesDir, staged), targetPath);
192
+ completedFinalRenames.push(stagedEntry);
193
+ }
194
+ }
195
+ catch (error) {
196
+ // Phase 2 rollback — reverse both the partial final-name moves and
197
+ // the phase-1 staging. This collapses the directory back to its
198
+ // pre-operation filenames so the manifest (which was never
199
+ // touched) remains consistent with what is on disk. If any
200
+ // individual rollback step itself fails, we warn with the residue
201
+ // filename so the operator can finish the cleanup by hand, but we
202
+ // still re-throw the original error so the caller sees the real
203
+ // cause.
204
+ const residue = [];
205
+ for (const completed of completedFinalRenames) {
206
+ try {
207
+ await rename(join(patchesDir, completed.toEntry.newFilename), join(patchesDir, completed.staged));
208
+ }
209
+ catch (rollbackError) {
210
+ residue.push(completed.toEntry.newFilename);
211
+ warn(`Rollback warning: could not revert ${completed.toEntry.newFilename} to staging: ${toError(rollbackError).message}`);
212
+ }
213
+ }
214
+ for (const stagedEntry of stagedRenames) {
215
+ try {
216
+ await rename(join(patchesDir, stagedEntry.staged), join(patchesDir, stagedEntry.from));
217
+ }
218
+ catch (rollbackError) {
219
+ residue.push(stagedEntry.staged);
220
+ warn(`Rollback warning: could not restore ${stagedEntry.from} from staging: ${toError(rollbackError).message}`);
221
+ }
222
+ }
223
+ if (residue.length > 0) {
224
+ warn(`Renumber phase 2 rollback left residue files (pattern: .fireforge-renumber-${stagingId}-*). ` +
225
+ `Inspect ${patchesDir} and remove or rename: ${residue.join(', ')}`);
226
+ }
227
+ throw error;
228
+ }
229
+ // Phase 3: rewrite the manifest rows. Any entry without a rename keeps its
230
+ // existing metadata; entries in the map get their filename + order
231
+ // updated. Sort by the new order so the manifest remains ordered.
232
+ const filenameUpdates = new Map();
233
+ for (const [oldFilename, entry] of renameMap) {
234
+ filenameUpdates.set(oldFilename, entry);
235
+ }
236
+ const updatedPatches = manifest.patches.map((p) => {
237
+ const update = filenameUpdates.get(p.filename);
238
+ if (!update)
239
+ return p;
240
+ return {
241
+ ...p,
242
+ filename: update.newFilename,
243
+ order: update.newOrder,
244
+ };
245
+ });
246
+ updatedPatches.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
247
+ try {
248
+ await savePatchesManifest(patchesDir, {
249
+ ...manifest,
250
+ patches: updatedPatches,
251
+ });
252
+ }
253
+ catch (error) {
254
+ // Phase 3 rollback: reverse every completed rename. The manifest
255
+ // save failed before it could be persisted, so the on-disk state
256
+ // must match what the manifest still records. Best-effort: any
257
+ // individual step that fails gets warned; the original save
258
+ // error is always re-thrown so the caller sees the real cause.
259
+ for (const completed of completedFinalRenames) {
260
+ try {
261
+ await rename(join(patchesDir, completed.toEntry.newFilename), join(patchesDir, completed.from));
262
+ }
263
+ catch (rollbackError) {
264
+ warn(`Rollback warning: could not revert ${completed.toEntry.newFilename} → ${completed.from} after manifest save failed: ${toError(rollbackError).message}`);
265
+ }
266
+ }
267
+ throw error;
268
+ }
269
+ }
270
+ /**
271
+ * Thrown when {@link removePatchFileAndManifest} cannot complete the file
272
+ * delete AND cannot restore the manifest row afterward, so the on-disk
273
+ * state and manifest state are known to disagree. Carries both the
274
+ * primary delete error and the rollback error so the caller (and the
275
+ * operator) can see the full failure chain instead of only the original
276
+ * error with a warning about the rollback buried in logs.
277
+ *
278
+ * Extends {@link FireForgeError} so the CLI top-level handler routes it
279
+ * through the rich-error formatter rather than the generic unexpected-error
280
+ * path; the dedicated `.name` is kept so programmatic callers and tests
281
+ * can still distinguish it with `instanceof PatchDeleteRollbackError`.
282
+ */
283
+ export class PatchDeleteRollbackError extends FireForgeError {
284
+ filename;
285
+ deleteError;
286
+ rollbackError;
287
+ code = ExitCode.PATCH_ERROR;
288
+ constructor(filename, deleteError, rollbackError) {
289
+ super(`Failed to delete ${filename}, AND failed to restore patches.json afterward. ` +
290
+ `The patch directory is now inconsistent: the manifest no longer lists ${filename} ` +
291
+ `but the patch file may still exist on disk. ` +
292
+ `Delete error: ${deleteError.message}. ` +
293
+ `Manifest rollback error: ${rollbackError.message}. ` +
294
+ `Inspect ${filename} in the patches directory and either remove it or restore the manifest row by hand.`);
295
+ this.filename = filename;
296
+ this.deleteError = deleteError;
297
+ this.rollbackError = rollbackError;
298
+ }
299
+ }
300
+ /**
301
+ * Deletes both a patch file on disk and its manifest row under the caller's
302
+ * lock. This is a convenience for the `patch delete` command; callers that
303
+ * need different ordering (e.g. deleting the file first without touching the
304
+ * manifest) should call the primitives separately.
305
+ *
306
+ * Failure semantics: if the manifest row was already removed and the
307
+ * file deletion then fails, the original manifest is restored best-effort
308
+ * and the delete error is re-thrown. If the restore itself also fails,
309
+ * a {@link PatchDeleteRollbackError} is thrown that carries both the
310
+ * delete error and the rollback error so neither is hidden behind a
311
+ * warning log. Callers can detect the compound failure with
312
+ * `instanceof PatchDeleteRollbackError`.
313
+ *
314
+ * @param patchesDir - Path to the patches directory
315
+ * @param filename - Patch filename to delete
316
+ */
317
+ export async function removePatchFileAndManifest(patchesDir, filename) {
318
+ const patchPath = join(patchesDir, filename);
319
+ const originalManifest = await loadPatchesManifest(patchesDir);
320
+ const removedFromManifest = await removePatchFromManifest(patchesDir, filename);
321
+ try {
322
+ if (await pathExists(patchPath)) {
323
+ await removeFile(patchPath);
324
+ }
325
+ }
326
+ catch (error) {
327
+ const deleteError = toError(error);
328
+ if (removedFromManifest && originalManifest) {
329
+ try {
330
+ await savePatchesManifest(patchesDir, originalManifest);
331
+ }
332
+ catch (rollbackError) {
333
+ // Compound failure: both the delete and the rollback failed,
334
+ // so the directory is in a known-inconsistent state. Throw a
335
+ // dedicated error type that carries both causes so the
336
+ // operator's log shows the complete picture instead of the
337
+ // original delete error with a warning about the rollback
338
+ // buried in stderr.
339
+ throw new PatchDeleteRollbackError(filename, deleteError, toError(rollbackError));
340
+ }
341
+ }
342
+ throw deleteError;
343
+ }
344
+ }
77
345
  //# sourceMappingURL=patch-manifest-io.js.map
@@ -43,6 +43,6 @@ export declare function validatePatchIntegrity(patchesDir: string, engineDir: st
43
43
  * manifest read-modify-write cycle.
44
44
  * @param patchesDir - Path to the patches directory
45
45
  * @param filenames - Patch filenames to update
46
- * @param newVersion - Version string to set (e.g. "140.0esr")
46
+ * @param newVersion - Version string to set (e.g. "140.9.0esr")
47
47
  */
48
48
  export declare function stampPatchVersions(patchesDir: string, filenames: string[], newVersion: string): Promise<void>;
@@ -103,7 +103,7 @@ export async function validatePatchIntegrity(patchesDir, engineDir) {
103
103
  * manifest read-modify-write cycle.
104
104
  * @param patchesDir - Path to the patches directory
105
105
  * @param filenames - Patch filenames to update
106
- * @param newVersion - Version string to set (e.g. "140.0esr")
106
+ * @param newVersion - Version string to set (e.g. "140.9.0esr")
107
107
  */
108
108
  export async function stampPatchVersions(patchesDir, filenames, newVersion) {
109
109
  const manifest = await loadPatchesManifest(patchesDir);
@@ -6,6 +6,6 @@
6
6
  */
7
7
  export type { PatchManifestConsistencyIssue } from './patch-manifest-consistency.js';
8
8
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
9
- export { addPatchToManifest, loadPatchesManifest, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest-io.js';
9
+ export { addPatchToManifest, loadPatchesManifest, PatchDeleteRollbackError, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, removePatchFromManifest, renumberPatchesInManifest, savePatchesManifest, } from './patch-manifest-io.js';
10
10
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
11
11
  export { validatePatchesManifest } from './patch-manifest-validate.js';
@@ -6,7 +6,7 @@
6
6
  * is an implementation detail.
7
7
  */
8
8
  export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
9
- export { addPatchToManifest, loadPatchesManifest, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest-io.js';
9
+ export { addPatchToManifest, loadPatchesManifest, PatchDeleteRollbackError, PATCHES_MANIFEST, removePatchFileAndManifest, removePatchFromManifest, renumberPatchesInManifest, savePatchesManifest, } from './patch-manifest-io.js';
10
10
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
11
11
  export { validatePatchesManifest } from './patch-manifest-validate.js';
12
12
  //# sourceMappingURL=patch-manifest.js.map
@@ -2,6 +2,18 @@
2
2
  * Pure content transformation functions for patch operations.
3
3
  * These operate on file content strings without filesystem side effects.
4
4
  */
5
+ /**
6
+ * Extracts the complete file content from a "new file" patch given a raw
7
+ * diff string already in memory. Callers with a patch file path should
8
+ * prefer {@link extractNewFileContent}; this helper exists for code paths
9
+ * that already hold the diff (e.g. the in-flight export planner) and do
10
+ * not want to round-trip through the filesystem.
11
+ *
12
+ * @param diff - Raw unified-diff content
13
+ * @param targetFile - Optional target file to scope extraction to
14
+ * @returns The file content that the patch would create
15
+ */
16
+ export declare function extractNewFileContentFromDiff(diff: string, targetFile?: string): string;
5
17
  /**
6
18
  * Extracts the complete file content from a "new file" patch.
7
19
  * When targetFile is provided, only extracts content for that file