@hominis/fireforge 0.19.6 → 0.21.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 +44 -0
- package/README.md +22 -4
- package/dist/src/commands/build.js +19 -1
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/download.js +188 -185
- package/dist/src/commands/export-flow.js +2 -13
- package/dist/src/commands/furnace/chrome-doc-remove.d.ts +13 -0
- package/dist/src/commands/furnace/chrome-doc-remove.js +142 -0
- package/dist/src/commands/furnace/chrome-doc.d.ts +32 -0
- package/dist/src/commands/furnace/chrome-doc.js +113 -1
- package/dist/src/commands/furnace/create-validation.d.ts +6 -0
- package/dist/src/commands/furnace/create-validation.js +59 -0
- package/dist/src/commands/furnace/create.js +13 -88
- package/dist/src/commands/furnace/index.js +14 -0
- package/dist/src/commands/furnace/refresh.js +11 -2
- package/dist/src/commands/furnace/remove-state.d.ts +5 -0
- package/dist/src/commands/furnace/remove-state.js +14 -0
- package/dist/src/commands/furnace/remove.js +33 -45
- package/dist/src/commands/furnace/rename-browser-test.d.ts +2 -0
- package/dist/src/commands/furnace/rename-browser-test.js +28 -0
- package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
- package/dist/src/commands/furnace/rename-helpers.js +42 -0
- package/dist/src/commands/furnace/rename.js +29 -48
- package/dist/src/commands/status.js +22 -3
- package/dist/src/commands/test.js +3 -0
- package/dist/src/commands/watch.js +9 -2
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +5 -0
- package/dist/src/core/config.js +11 -7
- package/dist/src/core/file-lock.js +2 -2
- package/dist/src/core/firefox-cache.d.ts +1 -1
- package/dist/src/core/firefox-cache.js +43 -17
- package/dist/src/core/firefox-download.js +12 -4
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +2 -2
- package/dist/src/core/furnace-config.js +4 -0
- package/dist/src/core/furnace-refresh.js +16 -5
- package/dist/src/core/patch-lint-imports.d.ts +5 -0
- package/dist/src/core/patch-lint-imports.js +68 -0
- package/dist/src/core/patch-lint.js +2 -3
- package/dist/src/types/config.d.ts +2 -0
- package/dist/src/utils/fs.d.ts +5 -0
- package/dist/src/utils/fs.js +54 -1
- package/dist/src/utils/process.js +4 -1
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
|
|
4
|
+
import { withFileLock } from '../core/file-lock.js';
|
|
4
5
|
import { downloadFirefoxSource, formatBytes } from '../core/firefox.js';
|
|
5
6
|
import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
|
|
6
7
|
import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRepository, } from '../core/git.js';
|
|
@@ -9,7 +10,7 @@ import { getDirtyFiles } from '../core/git-status.js';
|
|
|
9
10
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
10
11
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
12
|
import { toError } from '../utils/errors.js';
|
|
12
|
-
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
13
|
+
import { checkDiskSpace, ensureDir, pathExists, pathExistsStrict, removeDir } from '../utils/fs.js';
|
|
13
14
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
14
15
|
import { pickDefined } from '../utils/options.js';
|
|
15
16
|
/**
|
|
@@ -124,203 +125,205 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
124
125
|
info(`Firefox version: ${version}`);
|
|
125
126
|
// Disk space pre-flight: Firefox source is ~5 GB
|
|
126
127
|
await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
128
|
+
await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
|
|
129
|
+
// Check if engine already exists
|
|
130
|
+
if (await pathExistsStrict(paths.engine)) {
|
|
131
|
+
if (!options.force) {
|
|
132
|
+
if (await isGitRepository(paths.engine)) {
|
|
133
|
+
try {
|
|
134
|
+
await getHead(paths.engine);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (isMissingHeadError(error)) {
|
|
138
|
+
// Partial init detected — attempt to resume instead of requiring --force
|
|
139
|
+
info('Detected partially initialized engine. Attempting to resume...');
|
|
140
|
+
// Snapshot patch-touched files that are already dirty so we
|
|
141
|
+
// can preserve them after the resume commit.
|
|
142
|
+
const patchFiles = await getPatchTouchedFiles(paths.patches);
|
|
143
|
+
const preExistingDirty = patchFiles.size > 0
|
|
144
|
+
? new Set(await getDirtyFiles(paths.engine, [...patchFiles]))
|
|
145
|
+
: new Set();
|
|
146
|
+
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
147
|
+
try {
|
|
148
|
+
await resumeRepository(paths.engine, {
|
|
149
|
+
// The non-TTY spinner fallback in `src/utils/logger.ts`
|
|
150
|
+
// already calls `p.log.step(msg)` from `message()`, so
|
|
151
|
+
// forwarding the progress message is the single authority
|
|
152
|
+
// in both TTY and non-TTY modes. Before 0.16.0 this
|
|
153
|
+
// callback also invoked `step(message)` explicitly when
|
|
154
|
+
// stdio was not a TTY, which printed the same step line
|
|
155
|
+
// twice in CI logs (once from the fallback, once from
|
|
156
|
+
// the explicit call).
|
|
157
|
+
onProgress: (message) => {
|
|
158
|
+
resumeSpinner.message(message);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const baseCommit = await getHead(paths.engine);
|
|
162
|
+
resumeSpinner.stop('Git repository resumed successfully');
|
|
163
|
+
// Restore patch-touched files BEFORE stamping state. If this
|
|
164
|
+
// step fails (disk full, permission denied, git object issue),
|
|
165
|
+
// state.json keeps the previous downloadedVersion so the
|
|
166
|
+
// invariant "state.downloadedVersion matches a clean engine"
|
|
167
|
+
// holds. A retry of `fireforge download` then re-enters the
|
|
168
|
+
// resume path instead of declaring success against a dirty
|
|
169
|
+
// engine.
|
|
170
|
+
await cleanPatchTouchedFiles(paths.engine, paths.patches, preExistingDirty);
|
|
171
|
+
await updateState(projectRoot, {
|
|
172
|
+
downloadedVersion: version,
|
|
173
|
+
baseCommit,
|
|
174
|
+
});
|
|
175
|
+
await noteUnappliedPatches(paths.patches);
|
|
176
|
+
outro(`Firefox ${version} is ready! (resumed from partial init)`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
resumeSpinner.error('Resume failed');
|
|
181
|
+
// Preserve the underlying cause so the user sees *why* the
|
|
182
|
+
// resume failed (timeout, permission denied, corrupted object,
|
|
183
|
+
// disk full, …) instead of only the generic "partial engine
|
|
184
|
+
// exists" story. Verbose mode prints the stack for deeper
|
|
185
|
+
// triage.
|
|
186
|
+
const cause = toError(error);
|
|
187
|
+
verbose(`Resume failure detail: ${cause.message}`);
|
|
188
|
+
if (cause.stack) {
|
|
189
|
+
verbose(cause.stack);
|
|
190
|
+
}
|
|
191
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
188
192
|
}
|
|
189
|
-
throw new PartialEngineExistsError(paths.engine, cause);
|
|
190
193
|
}
|
|
194
|
+
// Re-throw unexpected git errors (corrupted objects, permission
|
|
195
|
+
// denied, …) wrapped in PartialEngineExistsError so the user sees
|
|
196
|
+
// both narratives: "we detected a partial engine and attempted
|
|
197
|
+
// resume" AND the underlying git failure. Without the wrap the
|
|
198
|
+
// raw git error loses the context that resume was in flight.
|
|
199
|
+
const cause = toError(error);
|
|
200
|
+
verbose(`Partial-engine probe failed with unexpected error: ${cause.message}`);
|
|
201
|
+
if (cause.stack) {
|
|
202
|
+
verbose(cause.stack);
|
|
203
|
+
}
|
|
204
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
191
205
|
}
|
|
192
|
-
// Re-throw unexpected git errors (corrupted objects, permission
|
|
193
|
-
// denied, …) wrapped in PartialEngineExistsError so the user sees
|
|
194
|
-
// both narratives: "we detected a partial engine and attempted
|
|
195
|
-
// resume" AND the underlying git failure. Without the wrap the
|
|
196
|
-
// raw git error loses the context that resume was in flight.
|
|
197
|
-
const cause = toError(error);
|
|
198
|
-
verbose(`Partial-engine probe failed with unexpected error: ${cause.message}`);
|
|
199
|
-
if (cause.stack) {
|
|
200
|
-
verbose(cause.stack);
|
|
201
|
-
}
|
|
202
|
-
throw new PartialEngineExistsError(paths.engine, cause);
|
|
203
206
|
}
|
|
207
|
+
throw new EngineExistsError(paths.engine);
|
|
208
|
+
}
|
|
209
|
+
warn('Removing existing engine directory...');
|
|
210
|
+
await removeDir(paths.engine);
|
|
211
|
+
// --force installs a new baseCommit, which invalidates every applied
|
|
212
|
+
// checksum in furnace-state.json. Clearing the state now prevents a
|
|
213
|
+
// subsequent `furnace apply` from reporting "up to date" against an
|
|
214
|
+
// engine that no longer contains any of the deployed files. Preserve
|
|
215
|
+
// pendingRepair: authoring-side rollback markers describe unresolved
|
|
216
|
+
// component workspace state and should survive an engine refresh.
|
|
217
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
218
|
+
if (await pathExists(furnacePaths.furnaceState)) {
|
|
219
|
+
await updateFurnaceState(projectRoot, (current) => ({
|
|
220
|
+
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
221
|
+
}));
|
|
204
222
|
}
|
|
205
|
-
throw new EngineExistsError(paths.engine);
|
|
206
|
-
}
|
|
207
|
-
warn('Removing existing engine directory...');
|
|
208
|
-
await removeDir(paths.engine);
|
|
209
|
-
// --force installs a new baseCommit, which invalidates every applied
|
|
210
|
-
// checksum in furnace-state.json. Clearing the state now prevents a
|
|
211
|
-
// subsequent `furnace apply` from reporting "up to date" against an
|
|
212
|
-
// engine that no longer contains any of the deployed files. Preserve
|
|
213
|
-
// pendingRepair: authoring-side rollback markers describe unresolved
|
|
214
|
-
// component workspace state and should survive an engine refresh.
|
|
215
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
216
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
217
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
218
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
219
|
-
}));
|
|
220
223
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
224
|
+
// Ensure cache directory exists
|
|
225
|
+
const cacheDir = join(paths.fireforgeDir, 'cache');
|
|
226
|
+
await ensureDir(cacheDir);
|
|
227
|
+
// Phase-switched spinners: the download phase runs with the byte-count
|
|
228
|
+
// progress callbacks below; the extract phase is blocking tar-xz and
|
|
229
|
+
// has no incremental progress, but it can take 30–90s on a ~600 MB
|
|
230
|
+
// Firefox tree, so it gets its own spinner message. Before the phase
|
|
231
|
+
// split, a single "Downloading Firefox … 100%" spinner covered both
|
|
232
|
+
// — the first-run setup looked hung precisely when the archive had
|
|
233
|
+
// already reached disk and `tar` was the long pole.
|
|
234
|
+
let s = spinner(`Downloading Firefox ${version}...`);
|
|
235
|
+
let lastPercent = 0;
|
|
236
|
+
const phaseState = { value: 'download' };
|
|
237
|
+
try {
|
|
238
|
+
await downloadFirefoxSource(version, config.firefox.product, paths.engine, cacheDir, (downloaded, total) => {
|
|
239
|
+
if (total <= 0)
|
|
240
|
+
return;
|
|
241
|
+
const percent = Math.floor((downloaded / total) * 100);
|
|
242
|
+
if (percent !== lastPercent && percent % 5 === 0) {
|
|
243
|
+
s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
|
|
244
|
+
lastPercent = percent;
|
|
245
|
+
}
|
|
246
|
+
}, (phase) => {
|
|
247
|
+
if (phase === 'extract' && phaseState.value === 'download') {
|
|
248
|
+
s.stop(`Firefox ${version} downloaded`);
|
|
249
|
+
phaseState.value = 'extract';
|
|
250
|
+
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
251
|
+
}
|
|
252
|
+
}, config.firefox.sha256);
|
|
253
|
+
if (phaseState.value === 'extract') {
|
|
254
|
+
s.stop(`Firefox ${version} extracted`);
|
|
243
255
|
}
|
|
244
|
-
|
|
245
|
-
if (phase === 'extract' && phaseState.value === 'download') {
|
|
256
|
+
else {
|
|
246
257
|
s.stop(`Firefox ${version} downloaded`);
|
|
247
|
-
phaseState.value = 'extract';
|
|
248
|
-
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
249
258
|
}
|
|
250
|
-
});
|
|
251
|
-
if (phaseState.value === 'extract') {
|
|
252
|
-
s.stop(`Firefox ${version} extracted`);
|
|
253
259
|
}
|
|
254
|
-
|
|
255
|
-
s.
|
|
260
|
+
catch (error) {
|
|
261
|
+
s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
|
|
262
|
+
throw error;
|
|
256
263
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
264
|
+
// Finding #17: the git indexing phase of `download` can block for
|
|
265
|
+
// minutes on a ~600 MB Firefox tree — the spinner updates less often
|
|
266
|
+
// than operators expect during the monolithic `git add -A` pass, and
|
|
267
|
+
// non-TTY shells see long stretches of silence. Emit a one-line
|
|
268
|
+
// heads-up banner BEFORE the spinner starts so even a log-scraping
|
|
269
|
+
// CI job notes the expected duration. The progress callbacks below
|
|
270
|
+
// still fire as usual; this is an additional up-front signal, not a
|
|
271
|
+
// replacement.
|
|
272
|
+
info('Indexing downloaded source into git (one-time; typically 1–3 minutes on a ~600 MB Firefox tree)...');
|
|
273
|
+
// Initialize git repository
|
|
274
|
+
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
275
|
+
let baseCommit;
|
|
276
|
+
try {
|
|
277
|
+
await initRepository(paths.engine, 'firefox', {
|
|
278
|
+
// Same one-authority rule as the resume path above: the non-TTY
|
|
279
|
+
// spinner fallback already emits `step(msg)` internally, so
|
|
280
|
+
// calling `step()` in addition to `.message()` duplicated every
|
|
281
|
+
// git-init progress line in CI logs.
|
|
282
|
+
onProgress: (message) => {
|
|
283
|
+
gitSpinner.message(message);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
baseCommit = await getHead(paths.engine);
|
|
287
|
+
gitSpinner.stop('Git repository initialized');
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
gitSpinner.error('Failed to initialize git repository');
|
|
291
|
+
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
// Restore any patch-touched files that ended up dirty after the initial
|
|
295
|
+
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
296
|
+
// a subsequent `fireforge import` works without --force.
|
|
297
|
+
//
|
|
298
|
+
// Wrapped in a dedicated spinner because the restore can itself take
|
|
299
|
+
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
300
|
+
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
301
|
+
// eval's "download looks hung" report landed at least partly on this
|
|
302
|
+
// post-commit window. An operator watching the CLI needs to see that
|
|
303
|
+
// this phase is distinct from the preceding git-add work.
|
|
304
|
+
//
|
|
305
|
+
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
306
|
+
// downloadedVersion in state.json. The invariant we preserve is
|
|
307
|
+
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
308
|
+
// version only after the restore succeeds means a failed clean-up will
|
|
309
|
+
// re-enter the resume path on the next `fireforge download` rather than
|
|
310
|
+
// reporting success against a dirty engine.
|
|
311
|
+
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
312
|
+
try {
|
|
313
|
+
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
314
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
restoreSpinner.error('Failed to restore patch-touched files');
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
await updateState(projectRoot, {
|
|
321
|
+
downloadedVersion: version,
|
|
322
|
+
baseCommit,
|
|
283
323
|
});
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
gitSpinner.error('Failed to initialize git repository');
|
|
289
|
-
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
290
|
-
throw error;
|
|
291
|
-
}
|
|
292
|
-
// Restore any patch-touched files that ended up dirty after the initial
|
|
293
|
-
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
294
|
-
// a subsequent `fireforge import` works without --force.
|
|
295
|
-
//
|
|
296
|
-
// Wrapped in a dedicated spinner because the restore can itself take
|
|
297
|
-
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
298
|
-
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
299
|
-
// eval's "download looks hung" report landed at least partly on this
|
|
300
|
-
// post-commit window. An operator watching the CLI needs to see that
|
|
301
|
-
// this phase is distinct from the preceding git-add work.
|
|
302
|
-
//
|
|
303
|
-
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
304
|
-
// downloadedVersion in state.json. The invariant we preserve is
|
|
305
|
-
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
306
|
-
// version only after the restore succeeds means a failed clean-up will
|
|
307
|
-
// re-enter the resume path on the next `fireforge download` rather than
|
|
308
|
-
// reporting success against a dirty engine.
|
|
309
|
-
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
310
|
-
try {
|
|
311
|
-
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
312
|
-
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
313
|
-
}
|
|
314
|
-
catch (error) {
|
|
315
|
-
restoreSpinner.error('Failed to restore patch-touched files');
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
await updateState(projectRoot, {
|
|
319
|
-
downloadedVersion: version,
|
|
320
|
-
baseCommit,
|
|
324
|
+
await noteUnappliedPatches(paths.patches);
|
|
325
|
+
outro(`Firefox ${version} is ready!`);
|
|
321
326
|
});
|
|
322
|
-
await noteUnappliedPatches(paths.patches);
|
|
323
|
-
outro(`Firefox ${version} is ready!`);
|
|
324
327
|
}
|
|
325
328
|
/** Registers the download command on the CLI program. */
|
|
326
329
|
export function registerDownload(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* testable without dragging the whole command harness along for the ride.
|
|
9
9
|
*/
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
-
import { findAllPatchesForFilesWithDetails, planExport } from '../core/patch-export.js';
|
|
11
|
+
import { findAllPatchesForFilesWithDetails, planExport, sanitizeName, } from '../core/patch-export.js';
|
|
12
12
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
13
13
|
import { withPatchDirectoryLock } from '../core/patch-lock.js';
|
|
14
14
|
import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
|
|
@@ -17,20 +17,9 @@ import { InvalidArgumentError } from '../errors/base.js';
|
|
|
17
17
|
import { toError } from '../utils/errors.js';
|
|
18
18
|
import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
|
|
19
19
|
import { info, warn } from '../utils/logger.js';
|
|
20
|
-
/**
|
|
21
|
-
* Sanitizes a patch name for use in a filename. Mirrors the private helper
|
|
22
|
-
* in patch-export.ts.
|
|
23
|
-
*/
|
|
24
|
-
function sanitizeExportName(name) {
|
|
25
|
-
return name
|
|
26
|
-
.toLowerCase()
|
|
27
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
-
.replace(/^-+|-+$/g, '')
|
|
29
|
-
.slice(0, 50);
|
|
30
|
-
}
|
|
31
20
|
function buildFilenameForPlacement(category, name, order, width) {
|
|
32
21
|
const padded = String(order).padStart(Math.max(3, width), '0');
|
|
33
|
-
return `${padded}-${category}-${
|
|
22
|
+
return `${padded}-${category}-${sanitizeName(name)}.patch`;
|
|
34
23
|
}
|
|
35
24
|
function getSortedRenameEntries(renameMap) {
|
|
36
25
|
return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge furnace chrome-doc remove <name>` — removes the files and
|
|
3
|
+
* registrations created by `furnace chrome-doc create`.
|
|
4
|
+
*/
|
|
5
|
+
/** Options for `furnace chrome-doc remove`. */
|
|
6
|
+
export interface FurnaceChromeDocRemoveOptions {
|
|
7
|
+
/** Skip confirmation. Required for real non-interactive removal. */
|
|
8
|
+
yes?: boolean;
|
|
9
|
+
/** Print the removal plan without writing files. */
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Runs `furnace chrome-doc remove <name>`. */
|
|
13
|
+
export declare function furnaceChromeDocRemoveCommand(projectRoot: string, name: string, options?: FurnaceChromeDocRemoveOptions): Promise<void>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge furnace chrome-doc remove <name>` — removes the files and
|
|
4
|
+
* registrations created by `furnace chrome-doc create`.
|
|
5
|
+
*/
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { confirm } from '@clack/prompts';
|
|
9
|
+
import { loadConfig } from '../../core/config.js';
|
|
10
|
+
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
11
|
+
import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
|
|
12
|
+
import { FurnaceError } from '../../errors/furnace.js';
|
|
13
|
+
import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
|
|
14
|
+
import { cancel, info, intro, isCancel, note, outro } from '../../utils/logger.js';
|
|
15
|
+
import { buildChromeDocPlan, validateChromeDocName } from './chrome-doc.js';
|
|
16
|
+
function removeExactLine(content, line) {
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
const filtered = lines.filter((candidate) => candidate !== line);
|
|
19
|
+
return filtered.join('\n').replace(/\n*$/, '\n');
|
|
20
|
+
}
|
|
21
|
+
async function removeChromeDocJarEntryIfPresent(engineDir, file, entry, journal) {
|
|
22
|
+
const jarPath = join(engineDir, file);
|
|
23
|
+
if (!(await pathExists(jarPath))) {
|
|
24
|
+
throw new FurnaceError(`Required jar file ${jarPath} does not exist; cannot remove chrome-doc entry. Check that the fork's engine layout matches the expected browser/ and locales/ tree.`);
|
|
25
|
+
}
|
|
26
|
+
const existing = await readText(jarPath);
|
|
27
|
+
if (!existing.includes(entry)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
await snapshotFile(journal, jarPath);
|
|
31
|
+
await writeText(jarPath, removeExactLine(existing, entry));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
async function removeEmptyDirIfPresent(dirPath, journal) {
|
|
35
|
+
if (!(await pathExists(dirPath)))
|
|
36
|
+
return false;
|
|
37
|
+
const entries = await readdir(dirPath);
|
|
38
|
+
if (entries.length > 0)
|
|
39
|
+
return false;
|
|
40
|
+
await snapshotDir(journal, dirPath);
|
|
41
|
+
await removeDir(dirPath);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function renderChromeDocRemoveDryRun(name, plan) {
|
|
45
|
+
const jarLines = plan.jarEntries.map(({ file, entry, present }) => ` engine/${file}: ${present ? 'would remove' : 'not present'} ${entry.trim()}`);
|
|
46
|
+
const testLines = plan.testDir !== undefined
|
|
47
|
+
? ['', 'Would remove test directory if present:', ` engine/${plan.testDir}/`]
|
|
48
|
+
: [];
|
|
49
|
+
return [
|
|
50
|
+
`[dry-run] Chrome document "${name}" removal plan`,
|
|
51
|
+
'',
|
|
52
|
+
'Would remove source files if present:',
|
|
53
|
+
...plan.files.map((f) => ` engine/${f}`),
|
|
54
|
+
...testLines,
|
|
55
|
+
'',
|
|
56
|
+
'Jar registrations:',
|
|
57
|
+
...jarLines,
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
async function performChromeDocRemoveMutations(args) {
|
|
61
|
+
const journal = createRollbackJournal();
|
|
62
|
+
args.operationContext.registerJournal(journal);
|
|
63
|
+
let removedFiles = 0;
|
|
64
|
+
let removedJarEntries = 0;
|
|
65
|
+
let removedTestDir = false;
|
|
66
|
+
try {
|
|
67
|
+
for (const file of args.plan.files) {
|
|
68
|
+
const filePath = join(args.engineDir, file);
|
|
69
|
+
if (await pathExists(filePath)) {
|
|
70
|
+
await snapshotFile(journal, filePath);
|
|
71
|
+
await removeFile(filePath);
|
|
72
|
+
removedFiles++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const { file, entry } of args.plan.jarEntries) {
|
|
76
|
+
if (await removeChromeDocJarEntryIfPresent(args.engineDir, file, entry, journal)) {
|
|
77
|
+
removedJarEntries++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const testDir = join(args.engineDir, 'browser/base/content/test', `${args.binaryName}-xpcshell`, args.name);
|
|
81
|
+
if (await pathExists(testDir)) {
|
|
82
|
+
await snapshotDir(journal, testDir);
|
|
83
|
+
await removeDir(testDir);
|
|
84
|
+
removedTestDir = true;
|
|
85
|
+
}
|
|
86
|
+
await removeEmptyDirIfPresent(join(args.engineDir, 'browser/base/content/test', `${args.binaryName}-xpcshell`), journal);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
await restoreRollbackJournalOrThrow(journal, `Failed to remove chrome-doc "${args.name}"`);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return { removedFiles, removedJarEntries, removedTestDir };
|
|
93
|
+
}
|
|
94
|
+
/** Runs `furnace chrome-doc remove <name>`. */
|
|
95
|
+
export async function furnaceChromeDocRemoveCommand(projectRoot, name, options = {}) {
|
|
96
|
+
intro('Furnace chrome-doc remove');
|
|
97
|
+
validateChromeDocName(name);
|
|
98
|
+
const forgeConfig = await loadConfig(projectRoot);
|
|
99
|
+
const engineDir = join(projectRoot, 'engine');
|
|
100
|
+
if (!(await pathExists(engineDir))) {
|
|
101
|
+
throw new FurnaceError('Engine directory not found. Run "fireforge download" first before removing a chrome-doc.');
|
|
102
|
+
}
|
|
103
|
+
const plan = await buildChromeDocPlan({
|
|
104
|
+
engineDir,
|
|
105
|
+
name,
|
|
106
|
+
withTests: true,
|
|
107
|
+
binaryName: forgeConfig.binaryName,
|
|
108
|
+
includeLocaleEntryWhenWildcard: true,
|
|
109
|
+
});
|
|
110
|
+
if (options.dryRun) {
|
|
111
|
+
note(renderChromeDocRemoveDryRun(name, plan), name);
|
|
112
|
+
outro('Dry run complete');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
116
|
+
if (!options.yes && !isInteractive) {
|
|
117
|
+
throw new FurnaceError(`Cannot remove chrome-doc "${name}" in non-interactive mode without --yes flag.`, name);
|
|
118
|
+
}
|
|
119
|
+
if (!options.yes && isInteractive) {
|
|
120
|
+
const confirmed = await confirm({
|
|
121
|
+
message: `Remove chrome document "${name}" and its scaffolded registrations?`,
|
|
122
|
+
});
|
|
123
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
124
|
+
cancel('Remove cancelled');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const result = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocRemoveMutations({
|
|
129
|
+
name,
|
|
130
|
+
engineDir,
|
|
131
|
+
plan,
|
|
132
|
+
binaryName: forgeConfig.binaryName,
|
|
133
|
+
operationContext: ctx,
|
|
134
|
+
}));
|
|
135
|
+
info(`Removed ${result.removedFiles} source file${result.removedFiles === 1 ? '' : 's'} and ` +
|
|
136
|
+
`${result.removedJarEntries} jar registration${result.removedJarEntries === 1 ? '' : 's'} for "${name}".`);
|
|
137
|
+
if (result.removedTestDir) {
|
|
138
|
+
info('Removed xpcshell packaging test directory.');
|
|
139
|
+
}
|
|
140
|
+
outro('Chrome document removed');
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=chrome-doc-remove.js.map
|