@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.
- package/CHANGELOG.md +65 -0
- package/README.md +12 -8
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/patch/compact.d.ts +25 -0
- package/dist/src/commands/patch/compact.js +132 -0
- package/dist/src/commands/patch/index.d.ts +1 -0
- package/dist/src/commands/patch/index.js +4 -1
- package/dist/src/commands/patch/reorder.d.ts +5 -1
- package/dist/src/commands/patch/reorder.js +4 -2
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +13 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/license-headers.d.ts +15 -0
- package/dist/src/core/license-headers.js +28 -0
- package/dist/src/core/manifest-rules.js +24 -3
- package/dist/src/core/patch-lint.d.ts +11 -0
- package/dist/src/core/patch-lint.js +30 -3
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +9 -0
- package/package.json +1 -1
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { Command } from 'commander';
|
|
11
11
|
import type { CommandContext } from '../../types/cli.js';
|
|
12
|
-
import type { PatchReorderOptions } from '../../types/commands/index.js';
|
|
12
|
+
import type { PatchMetadata, PatchReorderOptions } from '../../types/commands/index.js';
|
|
13
|
+
/** Zero-pads an ordinal number to the given width. */
|
|
14
|
+
export declare function padOrder(value: number, width: number): string;
|
|
15
|
+
/** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
|
|
16
|
+
export declare function rebuildFilenameForOrder(existing: PatchMetadata, newOrder: number): string;
|
|
13
17
|
/**
|
|
14
18
|
* Runs the `patch reorder` command: computes a rename map moving the
|
|
15
19
|
* target patch to the requested slot, projects the new order through
|
|
@@ -20,10 +20,12 @@ import { pathExists } from '../../utils/fs.js';
|
|
|
20
20
|
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
21
21
|
import { pickDefined } from '../../utils/options.js';
|
|
22
22
|
import { parsePositiveIntegerFlag } from '../../utils/validation.js';
|
|
23
|
-
|
|
23
|
+
/** Zero-pads an ordinal number to the given width. */
|
|
24
|
+
export function padOrder(value, width) {
|
|
24
25
|
return String(value).padStart(width, '0');
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
/** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
|
|
28
|
+
export function rebuildFilenameForOrder(existing, newOrder) {
|
|
27
29
|
const currentPrefixMatch = /^(\d+)-/.exec(existing.filename);
|
|
28
30
|
const currentPrefix = currentPrefixMatch?.[1] ?? '001';
|
|
29
31
|
const width = Math.max(3, currentPrefix.length, String(newOrder).length);
|
|
@@ -5,7 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
5
5
|
import { isGitRepository } from '../core/git.js';
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
7
|
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
|
-
import {
|
|
8
|
+
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
9
|
import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -102,11 +102,18 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
102
102
|
info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
|
|
103
103
|
}
|
|
104
104
|
else {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Atomic body + manifest update under a single patch-directory lock.
|
|
106
|
+
// A split `updatePatch` (lock-free) + `updatePatchMetadata` (lock-guarded)
|
|
107
|
+
// sequence allows a concurrent `resolve` / `rebase --continue` / `patch
|
|
108
|
+
// compact` / `patch reorder` to rewrite the manifest between the two
|
|
109
|
+
// writes and leave patch body and `filesAffected` disagreeing.
|
|
110
|
+
await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, {
|
|
108
111
|
filesAffected: currentFilesAffected,
|
|
109
112
|
});
|
|
113
|
+
// Keep the in-memory manifest in sync so subsequent iterations (notably
|
|
114
|
+
// `--all --scan`, where `getClaimedFiles` reads from this manifest) see
|
|
115
|
+
// the just-written `filesAffected`. The on-disk write above is the
|
|
116
|
+
// authority; this is a cache update.
|
|
110
117
|
const patchIndex = manifest.patches.findIndex((pm) => pm.filename === patch.filename);
|
|
111
118
|
if (patchIndex !== -1) {
|
|
112
119
|
const existingEntry = manifest.patches[patchIndex];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Rebase abort flow.
|
|
4
4
|
*/
|
|
5
|
-
import { getProjectPaths,
|
|
5
|
+
import { getProjectPaths, updateState } from '../../core/config.js';
|
|
6
6
|
import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
|
|
7
7
|
import { resetChanges } from '../../core/git.js';
|
|
8
8
|
import { clearRebaseSession, loadRebaseSession } from '../../core/rebase-session.js';
|
|
@@ -22,7 +22,8 @@ export async function handleAbort(projectRoot, yes) {
|
|
|
22
22
|
if (!(await confirmDirtyEngineReset({
|
|
23
23
|
engineDir: paths.engine,
|
|
24
24
|
yes: yes ?? false,
|
|
25
|
-
|
|
25
|
+
nonInteractiveCommand: 'fireforge rebase --abort --yes',
|
|
26
|
+
argumentName: '--yes',
|
|
26
27
|
warningMessage: 'The engine directory has uncommitted changes that will be lost.',
|
|
27
28
|
promptMessage: 'Discard uncommitted changes and abort rebase?',
|
|
28
29
|
cancelMessage: 'Abort cancelled',
|
|
@@ -30,27 +31,38 @@ export async function handleAbort(projectRoot, yes) {
|
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
33
|
const s = spinner('Restoring engine to pre-rebase state...');
|
|
34
|
+
// Step 1: git reset. If this fails, the rebase session MUST stay on disk
|
|
35
|
+
// so the user can retry the abort — resetChanges is the only irreversible
|
|
36
|
+
// operation in this handler and everything downstream assumes it ran.
|
|
33
37
|
try {
|
|
34
38
|
await resetChanges(paths.engine);
|
|
35
39
|
s.stop('Engine restored');
|
|
36
|
-
// Clear Furnace state — the engine has been rolled back to pre-rebase state.
|
|
37
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
38
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
39
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
40
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
41
|
-
}));
|
|
42
|
-
}
|
|
43
40
|
}
|
|
44
41
|
catch (error) {
|
|
45
42
|
s.error('Failed to restore engine');
|
|
46
43
|
throw error;
|
|
47
44
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
// Step 2: clear Furnace state. A failure here is reported on its own
|
|
46
|
+
// (rather than labelled "Failed to restore engine", which would be
|
|
47
|
+
// misleading now that reset already succeeded). The rebase session is
|
|
48
|
+
// still kept on disk so the user can retry the abort and let it
|
|
49
|
+
// idempotently re-clear furnace state.
|
|
50
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
51
|
+
if (await pathExists(furnacePaths.furnaceState)) {
|
|
52
|
+
await updateFurnaceState(projectRoot, (current) => ({
|
|
53
|
+
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
54
|
+
}));
|
|
53
55
|
}
|
|
56
|
+
// Step 3: clear pending resolution transactionally.
|
|
57
|
+
await updateState(projectRoot, (current) => {
|
|
58
|
+
if (!current.pendingResolution)
|
|
59
|
+
return current;
|
|
60
|
+
const next = { ...current };
|
|
61
|
+
delete next.pendingResolution;
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
// Step 4: clear the rebase session LAST so a failure in any prior step
|
|
65
|
+
// preserves the session on disk and a retry of --abort can succeed.
|
|
54
66
|
await clearRebaseSession(projectRoot);
|
|
55
67
|
success('Rebase aborted and session cleared.');
|
|
56
68
|
outro('Rebase aborted');
|
|
@@ -5,7 +5,20 @@
|
|
|
5
5
|
export interface DirtyEngineConfirmationOptions {
|
|
6
6
|
engineDir: string;
|
|
7
7
|
yes: boolean;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Full remediation command the user should run in non-interactive mode,
|
|
10
|
+
* e.g. `"fireforge rebase --abort --yes"`. Rendered inline in the
|
|
11
|
+
* non-interactive error message so the user gets a paste-ready
|
|
12
|
+
* command instead of a bare flag name.
|
|
13
|
+
*/
|
|
14
|
+
nonInteractiveCommand: string;
|
|
15
|
+
/**
|
|
16
|
+
* Argument identifier attached to the thrown {@link InvalidArgumentError}
|
|
17
|
+
* (typically the flag name, e.g. `"--yes"`). Separate from
|
|
18
|
+
* `nonInteractiveCommand` so the error's `argument` field carries the
|
|
19
|
+
* canonical flag name for structured handling.
|
|
20
|
+
*/
|
|
21
|
+
argumentName: string;
|
|
9
22
|
warningMessage: string;
|
|
10
23
|
promptMessage: string;
|
|
11
24
|
cancelMessage: string;
|
|
@@ -15,4 +28,4 @@ export interface DirtyEngineConfirmationOptions {
|
|
|
15
28
|
* Returns true if safe to proceed, false if the user cancelled.
|
|
16
29
|
* Throws in non-interactive mode without --yes.
|
|
17
30
|
*/
|
|
18
|
-
export declare function confirmDirtyEngineReset({ engineDir, yes,
|
|
31
|
+
export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
|
|
@@ -11,13 +11,13 @@ import { cancel, isCancel, warn } from '../../utils/logger.js';
|
|
|
11
11
|
* Returns true if safe to proceed, false if the user cancelled.
|
|
12
12
|
* Throws in non-interactive mode without --yes.
|
|
13
13
|
*/
|
|
14
|
-
export async function confirmDirtyEngineReset({ engineDir, yes,
|
|
14
|
+
export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveCommand, argumentName, warningMessage, promptMessage, cancelMessage, }) {
|
|
15
15
|
if (!(await hasChanges(engineDir)) || yes) {
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
19
19
|
if (!isInteractive) {
|
|
20
|
-
throw new InvalidArgumentError(
|
|
20
|
+
throw new InvalidArgumentError(`Engine has uncommitted changes and interactive confirmation is not available. Run: ${nonInteractiveCommand}`, argumentName);
|
|
21
21
|
}
|
|
22
22
|
warn(warningMessage);
|
|
23
23
|
const confirmed = await confirm({
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* Rebase --continue flow.
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { getProjectPaths,
|
|
6
|
+
import { getProjectPaths, updateState } from '../../core/config.js';
|
|
7
7
|
import { getStagedDiffForFiles } from '../../core/git-diff.js';
|
|
8
8
|
import { stageFiles, unstageFiles } from '../../core/git-file-ops.js';
|
|
9
|
-
import {
|
|
9
|
+
import { updatePatchAndMetadata } from '../../core/patch-export.js';
|
|
10
10
|
import { loadPatchesManifest } from '../../core/patch-manifest.js';
|
|
11
11
|
import { loadRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
12
|
+
import { runInSignalCriticalSection } from '../../core/signal-critical.js';
|
|
12
13
|
import { GeneralError } from '../../errors/base.js';
|
|
13
14
|
import { NoRebaseSessionError, RebaseError } from '../../errors/rebase.js';
|
|
14
15
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -23,6 +24,17 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
23
24
|
if (!session)
|
|
24
25
|
throw new NoRebaseSessionError();
|
|
25
26
|
const paths = getProjectPaths(projectRoot);
|
|
27
|
+
// Special case: every patch has already applied but a previous run failed
|
|
28
|
+
// somewhere in the post-apply work (re-export, version stamping). In that
|
|
29
|
+
// state currentIndex is past the end of the queue; jumping straight back
|
|
30
|
+
// into runPatchLoop replays the no-op apply loop and re-attempts the
|
|
31
|
+
// post-apply pipeline. Without this branch the user would be stuck —
|
|
32
|
+
// there is no failed patch to resolve, but the session is still active.
|
|
33
|
+
if (session.currentIndex >= session.patches.length) {
|
|
34
|
+
info('All patches already applied; retrying post-apply re-export and version stamping.');
|
|
35
|
+
await runPatchLoop(projectRoot, session, paths, maxFuzz);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
26
38
|
// The current patch should be in 'failed' state
|
|
27
39
|
const currentPatch = session.patches[session.currentIndex];
|
|
28
40
|
if (!currentPatch || currentPatch.status !== 'failed') {
|
|
@@ -53,9 +65,13 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
53
65
|
warn('Either apply your fixes and re-run --continue, or skip this patch (not yet supported).');
|
|
54
66
|
return;
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
// Write the patch body and the manifest metadata atomically under the
|
|
69
|
+
// shared patch-directory lock. The previous lock-free sequence of
|
|
70
|
+
// updatePatch + updatePatchMetadata could interleave with a concurrent
|
|
71
|
+
// export / re-export / patch reorder / patch compact and leave the
|
|
72
|
+
// manifest disagreeing with the freshly-written patch body. Mirrors the
|
|
73
|
+
// v0.14.0 resolve.ts fix.
|
|
74
|
+
await updatePatchAndMetadata(paths.patches, currentPatch.filename, diffContent, {
|
|
59
75
|
sourceEsrVersion: session.toVersion,
|
|
60
76
|
});
|
|
61
77
|
}
|
|
@@ -64,16 +80,24 @@ export async function handleContinue(projectRoot, maxFuzz) {
|
|
|
64
80
|
await unstageFiles(paths.engine, activeFiles);
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
|
-
// Mark resolved and advance
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
// Mark resolved and advance. Wrap in a signal-deferred critical section
|
|
84
|
+
// so SIGINT / SIGTERM between the session update and the pendingResolution
|
|
85
|
+
// clear is held until both writes land, matching the guarantee the apply
|
|
86
|
+
// loop in patch-loop.ts provides.
|
|
87
|
+
await runInSignalCriticalSection(`rebase-continue:${currentPatch.filename}`, async () => {
|
|
88
|
+
currentPatch.status = 'resolved';
|
|
89
|
+
session.currentIndex++;
|
|
90
|
+
await saveRebaseSession(projectRoot, session);
|
|
91
|
+
// Clear pending resolution transactionally so concurrent state-file
|
|
92
|
+
// writes to unrelated keys are not clobbered by a stale reload.
|
|
93
|
+
await updateState(projectRoot, (current) => {
|
|
94
|
+
if (!current.pendingResolution)
|
|
95
|
+
return current;
|
|
96
|
+
const next = { ...current };
|
|
97
|
+
delete next.pendingResolution;
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
});
|
|
77
101
|
success(`Resolved ${currentPatch.filename}`);
|
|
78
102
|
// Continue applying remaining patches
|
|
79
103
|
await runPatchLoop(projectRoot, session, paths, maxFuzz);
|
|
@@ -66,7 +66,8 @@ async function handleFreshStart(projectRoot, options) {
|
|
|
66
66
|
if (!(await confirmDirtyEngineReset({
|
|
67
67
|
engineDir: paths.engine,
|
|
68
68
|
yes: options.yes ?? false,
|
|
69
|
-
|
|
69
|
+
nonInteractiveCommand: 'fireforge rebase --yes',
|
|
70
|
+
argumentName: '--yes',
|
|
70
71
|
warningMessage: 'The engine directory has uncommitted changes that will be lost by the rebase.',
|
|
71
72
|
promptMessage: 'Discard uncommitted changes and start rebase?',
|
|
72
73
|
cancelMessage: 'Rebase cancelled',
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* Patch application loop and re-export flow.
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { updateState } from '../../core/config.js';
|
|
7
7
|
import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
|
|
8
8
|
import { applyPatchWithFuzz } from '../../core/patch-apply-fuzz.js';
|
|
9
9
|
import { updatePatch } from '../../core/patch-export.js';
|
|
10
10
|
import { discoverPatches } from '../../core/patch-files.js';
|
|
11
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
11
12
|
import { loadPatchesManifest, stampPatchVersions } from '../../core/patch-manifest.js';
|
|
12
13
|
import { extractConflictingFiles } from '../../core/patch-parse.js';
|
|
13
14
|
import { clearRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
|
|
15
|
+
import { runInSignalCriticalSection } from '../../core/signal-critical.js';
|
|
16
|
+
import { RebaseError } from '../../errors/rebase.js';
|
|
14
17
|
import { toError } from '../../utils/errors.js';
|
|
15
18
|
import { pathExists } from '../../utils/fs.js';
|
|
16
19
|
import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
|
|
@@ -34,35 +37,57 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
34
37
|
continue;
|
|
35
38
|
}
|
|
36
39
|
s.message(`Applying ${entry.filename}...`);
|
|
37
|
-
|
|
40
|
+
// Apply + session persist is wrapped in a signal-deferred critical
|
|
41
|
+
// section so a SIGINT / SIGTERM between the filesystem mutation and
|
|
42
|
+
// the session-file update is held until the bookkeeping write lands.
|
|
43
|
+
// Without this guard, `rebase --continue` could see a patch that is
|
|
44
|
+
// already in the engine but still marked pending, and would re-apply
|
|
45
|
+
// it on resume (either failing on duplicate hunks or producing
|
|
46
|
+
// divergent results).
|
|
47
|
+
const result = await runInSignalCriticalSection(`rebase-apply:${entry.filename}`, async () => {
|
|
48
|
+
const applyResult = await applyPatchWithFuzz(patchFile.path, paths.engine, maxFuzz);
|
|
49
|
+
if (applyResult.success) {
|
|
50
|
+
if (applyResult.fuzzFactor === 0) {
|
|
51
|
+
entry.status = 'applied-clean';
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
entry.status = 'applied-fuzz';
|
|
55
|
+
entry.fuzzFactor = applyResult.fuzzFactor;
|
|
56
|
+
}
|
|
57
|
+
session.currentIndex = i + 1;
|
|
58
|
+
await saveRebaseSession(projectRoot, session);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
entry.status = 'failed';
|
|
62
|
+
if (applyResult.error) {
|
|
63
|
+
entry.error = applyResult.error;
|
|
64
|
+
}
|
|
65
|
+
entry.conflictingFiles = extractConflictingFiles(applyResult.error);
|
|
66
|
+
session.currentIndex = i;
|
|
67
|
+
await saveRebaseSession(projectRoot, session);
|
|
68
|
+
}
|
|
69
|
+
return applyResult;
|
|
70
|
+
});
|
|
38
71
|
if (result.success) {
|
|
39
72
|
if (result.fuzzFactor === 0) {
|
|
40
|
-
entry.status = 'applied-clean';
|
|
41
73
|
success(` ${entry.filename} — applied cleanly`);
|
|
42
74
|
}
|
|
43
75
|
else {
|
|
44
|
-
entry.status = 'applied-fuzz';
|
|
45
|
-
entry.fuzzFactor = result.fuzzFactor;
|
|
46
76
|
warn(` ${entry.filename} — applied with fuzz=${result.fuzzFactor}`);
|
|
47
77
|
}
|
|
48
|
-
session.currentIndex = i + 1;
|
|
49
|
-
await saveRebaseSession(projectRoot, session);
|
|
50
78
|
}
|
|
51
79
|
else {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
originalError: result.error ?? 'Unknown error',
|
|
64
|
-
};
|
|
65
|
-
await saveState(projectRoot, state);
|
|
80
|
+
// Set pendingResolution in state for visibility. Kept outside the
|
|
81
|
+
// critical section — it is advisory UX, not a correctness invariant,
|
|
82
|
+
// and its absence would at most cause `fireforge status` to omit the
|
|
83
|
+
// pending-conflict hint until the next state write.
|
|
84
|
+
await updateState(projectRoot, (current) => ({
|
|
85
|
+
...current,
|
|
86
|
+
pendingResolution: {
|
|
87
|
+
patchFilename: entry.filename,
|
|
88
|
+
originalError: result.error ?? 'Unknown error',
|
|
89
|
+
},
|
|
90
|
+
}));
|
|
66
91
|
s.error(`${entry.filename} failed to apply`);
|
|
67
92
|
if (result.error) {
|
|
68
93
|
error(` Error: ${result.error}`);
|
|
@@ -79,8 +104,21 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
79
104
|
}
|
|
80
105
|
}
|
|
81
106
|
s.stop('All patches applied');
|
|
82
|
-
// Re-export all successfully applied patches
|
|
83
|
-
|
|
107
|
+
// Re-export all successfully applied patches. Failures here mean the
|
|
108
|
+
// engine has been rebased onto the new Firefox version but some .patch
|
|
109
|
+
// files were not refreshed — the queue would lie about what version each
|
|
110
|
+
// patch was tested against if we proceeded to stamp. Refuse to claim
|
|
111
|
+
// success and leave the session in place so the user can recover via
|
|
112
|
+
// `fireforge rebase --continue` after fixing the underlying cause.
|
|
113
|
+
const reExportFailures = await reExportAppliedPatches(session, paths);
|
|
114
|
+
if (reExportFailures.length > 0) {
|
|
115
|
+
for (const f of reExportFailures) {
|
|
116
|
+
error(` ${f.filename}: ${f.error}`);
|
|
117
|
+
}
|
|
118
|
+
throw new RebaseError(`Apply succeeded but ${reExportFailures.length} patch(es) failed to re-export. ` +
|
|
119
|
+
`Versions were not stamped and the rebase session has been kept so you can retry. ` +
|
|
120
|
+
`Fix the underlying cause shown above, then re-run "fireforge rebase --continue".`);
|
|
121
|
+
}
|
|
84
122
|
// Stamp versions
|
|
85
123
|
const appliedFilenames = session.patches
|
|
86
124
|
.filter((p) => p.status === 'applied-clean' || p.status === 'applied-fuzz' || p.status === 'resolved')
|
|
@@ -91,20 +129,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
91
129
|
// Print summary and clean up
|
|
92
130
|
printSummary(session);
|
|
93
131
|
await clearRebaseSession(projectRoot);
|
|
94
|
-
// Clear pending resolution if any
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
132
|
+
// Clear pending resolution if any (transactionally, so a concurrent
|
|
133
|
+
// state write to an unrelated key is not clobbered by a stale reload).
|
|
134
|
+
await updateState(projectRoot, (current) => {
|
|
135
|
+
if (!current.pendingResolution)
|
|
136
|
+
return current;
|
|
137
|
+
const next = { ...current };
|
|
138
|
+
delete next.pendingResolution;
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
100
141
|
info('');
|
|
101
142
|
success(`All patches re-exported with sourceEsrVersion=${session.toVersion}`);
|
|
102
143
|
outro('Rebase complete!');
|
|
103
144
|
}
|
|
104
145
|
async function reExportAppliedPatches(session, paths) {
|
|
146
|
+
const failures = [];
|
|
105
147
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
106
148
|
if (!manifest)
|
|
107
|
-
return;
|
|
149
|
+
return failures;
|
|
108
150
|
const s = spinner('Re-exporting patches...');
|
|
109
151
|
for (const entry of session.patches) {
|
|
110
152
|
if (entry.status !== 'applied-clean' && entry.status !== 'applied-fuzz')
|
|
@@ -123,13 +165,28 @@ async function reExportAppliedPatches(session, paths) {
|
|
|
123
165
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
124
166
|
if (diffContent.trim()) {
|
|
125
167
|
const patchPath = join(paths.patches, entry.filename);
|
|
126
|
-
|
|
168
|
+
// Hold the patch directory lock for the body rewrite so a concurrent
|
|
169
|
+
// manifest-mutating command (`resolve`, `re-export`, `patch compact`,
|
|
170
|
+
// `patch reorder`) cannot observe a torn patch body mid-write or
|
|
171
|
+
// persist metadata describing a body this loop is about to overwrite.
|
|
172
|
+
// Rebase sessions are serialized against each other by
|
|
173
|
+
// `rebase-session.ts`, so this lock is only defending against other
|
|
174
|
+
// command classes, not peer rebases.
|
|
175
|
+
await withPatchDirectoryLock(paths.patches, () => updatePatch(patchPath, diffContent));
|
|
127
176
|
}
|
|
128
177
|
}
|
|
129
178
|
catch (err) {
|
|
130
|
-
|
|
179
|
+
const message = toError(err).message;
|
|
180
|
+
warn(`Failed to re-export ${entry.filename}: ${message}`);
|
|
181
|
+
failures.push({ filename: entry.filename, error: message });
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
|
-
|
|
184
|
+
if (failures.length > 0) {
|
|
185
|
+
s.error(`Re-export failed for ${failures.length} patch(es)`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
s.stop('Patches re-exported');
|
|
189
|
+
}
|
|
190
|
+
return failures;
|
|
134
191
|
}
|
|
135
192
|
//# sourceMappingURL=patch-loop.js.map
|
|
@@ -15,6 +15,19 @@ import { pickDefined } from '../utils/options.js';
|
|
|
15
15
|
*/
|
|
16
16
|
export async function registerCommand(projectRoot, filePath, options = {}) {
|
|
17
17
|
intro('Register');
|
|
18
|
+
// --after is matched as a substring against existing manifest lines;
|
|
19
|
+
// guard against control characters / line terminators that would either
|
|
20
|
+
// break the match logic or, worse, inject a second line if the value
|
|
21
|
+
// is ever echoed back to a manifest writer. Null bytes are explicitly
|
|
22
|
+
// rejected to mirror the hardening already applied to other user-
|
|
23
|
+
// supplied identifiers (binaryName, furnace targetPath, …).
|
|
24
|
+
if (options.after !== undefined) {
|
|
25
|
+
// eslint-disable-next-line no-control-regex -- control chars in --after would break the manifest match
|
|
26
|
+
const hasControlChar = /[\u0000-\u001f]/.test(options.after);
|
|
27
|
+
if (options.after.length === 0 || hasControlChar) {
|
|
28
|
+
throw new InvalidArgumentError('--after must be a non-empty substring without control characters or line terminators.', 'after');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
18
31
|
// Verify the file exists in engine/ (skip for dry-run)
|
|
19
32
|
if (!options.dryRun) {
|
|
20
33
|
const paths = getProjectPaths(projectRoot);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { confirm } from '@clack/prompts';
|
|
4
|
-
import { getProjectPaths, loadConfig, loadState,
|
|
4
|
+
import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
|
|
5
5
|
import { isGitRepository } from '../core/git.js';
|
|
6
6
|
import { getStagedDiffForFiles } from '../core/git-diff.js';
|
|
7
7
|
import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
|
|
8
|
-
import {
|
|
8
|
+
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
9
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
10
10
|
import { GeneralError, ResolutionError } from '../errors/base.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -54,6 +54,17 @@ export async function resolveCommand(projectRoot) {
|
|
|
54
54
|
if (!patchMetadata) {
|
|
55
55
|
throw new ResolutionError(`Patch ${patchFilename} not found in manifest.`);
|
|
56
56
|
}
|
|
57
|
+
// Refuse to proceed if the patch file was deleted between the conflict
|
|
58
|
+
// and the resolve. Without this check, `updatePatchAndMetadata` would
|
|
59
|
+
// throw a less actionable "patch file is missing" error from inside
|
|
60
|
+
// the lock; the explicit precondition lets us point the user at the
|
|
61
|
+
// exact recovery path.
|
|
62
|
+
const patchPath = join(paths.patches, patchFilename);
|
|
63
|
+
if (!(await pathExists(patchPath))) {
|
|
64
|
+
throw new ResolutionError(`Patch file ${patchFilename} is missing on disk. ` +
|
|
65
|
+
'Delete the "pendingResolution" key from state.json to clear the stale conflict, ' +
|
|
66
|
+
'or restore the patch file before re-running resolve.');
|
|
67
|
+
}
|
|
57
68
|
const s = spinner(`Updating ${patchFilename}...`);
|
|
58
69
|
try {
|
|
59
70
|
const existingFiles = patchMetadata.filesAffected;
|
|
@@ -89,18 +100,28 @@ export async function resolveCommand(projectRoot) {
|
|
|
89
100
|
outro('Resolution unchanged');
|
|
90
101
|
return;
|
|
91
102
|
}
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
103
|
+
// Atomically write the patch body and the metadata update under the
|
|
104
|
+
// shared patch-directory lock. Replaces the previous lock-free
|
|
105
|
+
// sequence of updatePatch + updatePatchMetadata, which a concurrent
|
|
106
|
+
// import / export / re-export / patch reorder / patch compact could
|
|
107
|
+
// interleave with and leave the manifest disagreeing with the
|
|
108
|
+
// freshly-written patch body.
|
|
96
109
|
const config = await loadConfig(projectRoot);
|
|
97
|
-
await
|
|
110
|
+
await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
|
|
98
111
|
...(activeFiles.length < existingFiles.length ? { filesAffected: activeFiles } : {}),
|
|
99
112
|
sourceEsrVersion: config.firefox.version,
|
|
100
113
|
});
|
|
101
|
-
// Cleanup: Clear pendingResolution from state.json
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
// Cleanup: Clear pendingResolution from state.json transactionally so
|
|
115
|
+
// we don't clobber concurrent updates to unrelated keys (e.g. another
|
|
116
|
+
// command writing buildMode or baseCommit between our loadState and
|
|
117
|
+
// saveState). The updater function runs inside the state-file lock
|
|
118
|
+
// with the freshest on-disk state, so only pendingResolution is
|
|
119
|
+
// affected.
|
|
120
|
+
await updateState(projectRoot, (current) => {
|
|
121
|
+
const next = { ...current };
|
|
122
|
+
delete next.pendingResolution;
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
104
125
|
s.stop(`Updated ${patchFilename}`);
|
|
105
126
|
success('Patch updated successfully and resolution state cleared.');
|
|
106
127
|
info('Run "fireforge import" to apply the remaining patches.');
|
package/dist/src/commands/run.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { getProjectPaths } from '../core/config.js';
|
|
5
|
-
import {
|
|
6
|
-
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from '../core/furnace-config.js';
|
|
5
|
+
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
7
6
|
import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
|
|
8
7
|
import { GeneralError } from '../errors/base.js';
|
|
9
8
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
10
9
|
import { toError } from '../utils/errors.js';
|
|
11
10
|
import { pathExists, removeDir, removeFile } from '../utils/fs.js';
|
|
12
|
-
import { info, intro, verbose
|
|
11
|
+
import { info, intro, verbose } from '../utils/logger.js';
|
|
13
12
|
/**
|
|
14
13
|
* Cleans the dev profile to prevent stale-state startup failures.
|
|
15
14
|
*
|
|
@@ -47,47 +46,6 @@ async function cleanDevProfile(engineDir) {
|
|
|
47
46
|
verbose(`Non-fatal dev profile cleanup failure: ${toError(error).message}`);
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Checks whether any Furnace component has changed since the last apply
|
|
52
|
-
* and warns the user. The build command auto-applies, but run does not,
|
|
53
|
-
* so this advisory message prevents the common "forgot to apply" mistake.
|
|
54
|
-
*/
|
|
55
|
-
async function warnIfFurnaceStale(projectRoot) {
|
|
56
|
-
try {
|
|
57
|
-
if (!(await furnaceConfigExists(projectRoot)))
|
|
58
|
-
return;
|
|
59
|
-
const config = await loadFurnaceConfig(projectRoot);
|
|
60
|
-
const state = await loadFurnaceState(projectRoot);
|
|
61
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
62
|
-
if (!state.appliedChecksums)
|
|
63
|
-
return;
|
|
64
|
-
const stale = [];
|
|
65
|
-
for (const name of Object.keys(config.overrides)) {
|
|
66
|
-
const dir = `${furnacePaths.overridesDir}/${name}`;
|
|
67
|
-
if (!(await pathExists(dir)))
|
|
68
|
-
continue;
|
|
69
|
-
const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
|
|
70
|
-
if (await hasComponentChanged(dir, prev))
|
|
71
|
-
stale.push(name);
|
|
72
|
-
}
|
|
73
|
-
for (const name of Object.keys(config.custom)) {
|
|
74
|
-
const dir = `${furnacePaths.customDir}/${name}`;
|
|
75
|
-
if (!(await pathExists(dir)))
|
|
76
|
-
continue;
|
|
77
|
-
const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
|
|
78
|
-
if (await hasComponentChanged(dir, prev))
|
|
79
|
-
stale.push(name);
|
|
80
|
-
}
|
|
81
|
-
if (stale.length > 0) {
|
|
82
|
-
warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
|
|
83
|
-
'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Non-fatal: a broken furnace config should not block run.
|
|
88
|
-
verbose('Furnace staleness check skipped due to an error.');
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
49
|
/**
|
|
92
50
|
* Runs the run command to launch the built browser.
|
|
93
51
|
* @param projectRoot - Root directory of the project
|
|
@@ -120,6 +78,13 @@ export async function runCommand(projectRoot) {
|
|
|
120
78
|
await cleanDevProfile(paths.engine);
|
|
121
79
|
info('Launching browser...\n');
|
|
122
80
|
const exitCode = await run(paths.engine);
|
|
81
|
+
// Exit-code whitelist:
|
|
82
|
+
// 0 — clean shutdown
|
|
83
|
+
// 130 — SIGINT (Ctrl+C), user-initiated termination
|
|
84
|
+
// 143 — SIGTERM, graceful-shutdown termination
|
|
85
|
+
// SIGKILL (137) and other signal-induced codes are intentionally NOT
|
|
86
|
+
// whitelisted: those indicate abnormal termination the operator should
|
|
87
|
+
// see surface as a build-time error.
|
|
123
88
|
if (exitCode !== 0 && exitCode !== 130 && exitCode !== 143) {
|
|
124
89
|
throw new BuildError(`Browser exited with code ${exitCode}`, 'mach run');
|
|
125
90
|
}
|