@hominis/fireforge 0.18.1 → 0.18.3

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 (35) hide show
  1. package/README.md +54 -33
  2. package/dist/src/commands/discard.js +93 -1
  3. package/dist/src/commands/doctor.js +17 -4
  4. package/dist/src/commands/download.js +21 -0
  5. package/dist/src/commands/export-all.js +35 -6
  6. package/dist/src/commands/export-flow.d.ts +4 -0
  7. package/dist/src/commands/export-flow.js +8 -0
  8. package/dist/src/commands/export.js +26 -2
  9. package/dist/src/commands/furnace/remove.js +68 -0
  10. package/dist/src/commands/import.js +9 -1
  11. package/dist/src/commands/lint.js +56 -10
  12. package/dist/src/commands/patch/index.d.ts +5 -3
  13. package/dist/src/commands/patch/index.js +10 -4
  14. package/dist/src/commands/patch/lint-ignore.d.ts +39 -0
  15. package/dist/src/commands/patch/lint-ignore.js +200 -0
  16. package/dist/src/commands/patch/tier.d.ts +34 -0
  17. package/dist/src/commands/patch/tier.js +134 -0
  18. package/dist/src/commands/re-export-files.js +88 -45
  19. package/dist/src/commands/re-export.js +49 -6
  20. package/dist/src/commands/status.js +27 -0
  21. package/dist/src/commands/test.js +20 -1
  22. package/dist/src/commands/token.js +1 -1
  23. package/dist/src/core/furnace-config.js +19 -0
  24. package/dist/src/core/git-diff.js +34 -2
  25. package/dist/src/core/license-headers.d.ts +8 -0
  26. package/dist/src/core/license-headers.js +15 -1
  27. package/dist/src/core/manifest-rules.js +9 -1
  28. package/dist/src/core/patch-export.d.ts +77 -2
  29. package/dist/src/core/patch-export.js +82 -3
  30. package/dist/src/core/patch-lint.js +86 -29
  31. package/dist/src/core/register-shared-css.js +8 -2
  32. package/dist/src/types/commands/index.d.ts +1 -1
  33. package/dist/src/types/commands/options.d.ts +67 -0
  34. package/dist/src/types/commands/patches.d.ts +6 -5
  35. package/package.json +1 -1
@@ -74,7 +74,15 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
74
74
  if (dirtyFiles.length > 0) {
75
75
  const unmanagedDirtyFiles = await getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles);
76
76
  if (unmanagedDirtyFiles.length === 0) {
77
- info('Patch-backed materialized files already match the stored patch stack.');
77
+ // Common path here: operator just ran `fireforge resolve` to
78
+ // regenerate a patch from manual conflict edits, so the engine
79
+ // already carries the patch's effects. The import below will
80
+ // still re-apply each patch (a no-op for files whose contents
81
+ // already match), so phrase the line as "no resync needed"
82
+ // rather than "patches already applied" — the latter contradicts
83
+ // the "Applied N patch(es)" summary `applyPatchesWithContinue`
84
+ // prints next, which the 2026-04-25 eval flagged as ambiguous.
85
+ info('Patch-touched files already match the stored patch stack — no engine resync needed before re-applying.');
78
86
  }
79
87
  else if (!forceImport) {
80
88
  warn('Uncommitted changes detected in files that patches will modify:');
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isBrandingManagedPath } from '../core/branding.js';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
7
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
7
8
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
9
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
@@ -35,7 +36,7 @@ import { stripEnginePrefix } from '../utils/paths.js';
35
36
  * previous behaviour: passing a branding file explicitly still lints
36
37
  * it, so operators who need to audit branding content can do so.
37
38
  */
38
- async function resolveLintDiff(engineDir, files, binaryName) {
39
+ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
39
40
  if (files.length > 0) {
40
41
  const collectedFiles = new Set();
41
42
  let fileStatuses;
@@ -115,16 +116,31 @@ async function resolveLintDiff(engineDir, files, binaryName) {
115
116
  const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
116
117
  const allPaths = [...new Set(expanded.map((entry) => entry.file))];
117
118
  const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
118
- const excludedCount = allPaths.length - nonBrandingPaths.length;
119
- if (excludedCount > 0) {
120
- info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
119
+ const brandingExcluded = allPaths.length - nonBrandingPaths.length;
120
+ // Drop Furnace-managed paths the same way branding is dropped: their
121
+ // contents are tool output (overrides, custom widgets, preview-
122
+ // generated stories) that the operator did not author and never
123
+ // intended to land on the patch queue. Without this carve-out, a
124
+ // post-`furnace preview` aggregate `lint` failed with one
125
+ // `missing-license-header` error per generated story file (eval
126
+ // Finding 19) — each story is intentionally header-less because it's
127
+ // re-generated from component metadata on every preview run.
128
+ const filteredPaths = furnacePrefixes
129
+ ? nonBrandingPaths.filter((path) => ![...furnacePrefixes].some((p) => path.startsWith(p)))
130
+ : nonBrandingPaths;
131
+ const furnaceExcluded = nonBrandingPaths.length - filteredPaths.length;
132
+ if (brandingExcluded > 0) {
133
+ info(`Excluded ${brandingExcluded} tool-managed branding file${brandingExcluded === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
121
134
  }
122
- if (nonBrandingPaths.length === 0) {
123
- info('No non-branding changes to lint.');
135
+ if (furnaceExcluded > 0) {
136
+ info(`Excluded ${furnaceExcluded} Furnace-managed file${furnaceExcluded === 1 ? '' : 's'} from lint (deployed components and preview-generated stories). Pass the path explicitly to include them.`);
137
+ }
138
+ if (filteredPaths.length === 0) {
139
+ info('No non-branding, non-Furnace changes to lint.');
124
140
  outro('Nothing to lint');
125
141
  return null;
126
142
  }
127
- const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
143
+ const diff = await getDiffForFilesAgainstHead(engineDir, filteredPaths.sort());
128
144
  if (!diff.trim()) {
129
145
  info('No diff content to lint.');
130
146
  outro('Nothing to lint');
@@ -185,7 +201,13 @@ export async function lintCommand(projectRoot, files, options = {}) {
185
201
  // the diff was resolved; hoisting it is cheap and keeps the two
186
202
  // call sites close together.
187
203
  const config = await loadConfig(projectRoot);
188
- const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
204
+ // Pull the Furnace-managed prefix set up-front so aggregate lint can
205
+ // mirror the branding exclusion for Furnace material — without it,
206
+ // preview-generated stories under `browser/components/storybook/
207
+ // stories/furnace/` show up as license-header errors on every
208
+ // post-preview lint run.
209
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
210
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
189
211
  if (diff === null)
190
212
  return;
191
213
  const filesAffected = extractAffectedFiles(diff);
@@ -284,7 +306,23 @@ export async function lintCommand(projectRoot, files, options = {}) {
284
306
  : '';
285
307
  throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
286
308
  }
287
- outro('Lint passed with warnings');
309
+ // Notices are advisory and don't count as warnings — emitting "passed
310
+ // with warnings" when only notices fired contradicts the preceding
311
+ // `0 warning(s)` summary line and reads as a regression. Distinguish
312
+ // the three pass states explicitly. Errors suppressed by
313
+ // --only-introduced still warrant the "with warnings" outro — they
314
+ // print as ERROR rows but no longer fail the run, which is the same
315
+ // contract the operator gets from a real warning.
316
+ const suppressedErrors = options.onlyIntroduced && errors.length > 0;
317
+ if (warnings.length > 0 || suppressedErrors) {
318
+ outro('Lint passed with warnings');
319
+ }
320
+ else if (notices.length > 0) {
321
+ outro('Lint passed with notices');
322
+ }
323
+ else {
324
+ outro('Lint passed');
325
+ }
288
326
  }
289
327
  /**
290
328
  * Lints each patch in the queue as its own isolated diff, honouring
@@ -357,7 +395,15 @@ async function lintPerPatch(projectRoot, paths) {
357
395
  outro('Lint failed');
358
396
  throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
359
397
  }
360
- outro('Lint passed with warnings');
398
+ if (warnings.length > 0) {
399
+ outro('Lint passed with warnings');
400
+ }
401
+ else if (notices.length > 0) {
402
+ outro('Lint passed with notices');
403
+ }
404
+ else {
405
+ outro('Lint passed');
406
+ }
361
407
  }
362
408
  /** Registers the lint command on the CLI program. */
363
409
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * `fireforge patch <verb>` parent command. Groups single-patch
3
- * mutations (`delete`, `reorder`) so they do not clutter the top-level
4
- * command list. Queue-level verbs like `lint`, `export`, `verify`, and
5
- * `status` stay flat.
3
+ * mutations (`compact`, `delete`, `lint-ignore`, `reorder`, `tier`) so
4
+ * they do not clutter the top-level command list. Queue-level verbs
5
+ * like `lint`, `export`, `verify`, and `status` stay flat.
6
6
  */
7
7
  import { Command } from 'commander';
8
8
  import type { CommandContext } from '../../types/cli.js';
9
9
  export { patchCompactCommand } from './compact.js';
10
10
  export { patchDeleteCommand } from './delete.js';
11
+ export { patchLintIgnoreCommand } from './lint-ignore.js';
11
12
  export { patchReorderCommand } from './reorder.js';
13
+ export { patchTierCommand } from './tier.js';
12
14
  /**
13
15
  * Registers the `patch` subcommand parent and its verbs on the CLI.
14
16
  *
@@ -1,16 +1,20 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
3
  * `fireforge patch <verb>` parent command. Groups single-patch
4
- * mutations (`delete`, `reorder`) so they do not clutter the top-level
5
- * command list. Queue-level verbs like `lint`, `export`, `verify`, and
6
- * `status` stay flat.
4
+ * mutations (`compact`, `delete`, `lint-ignore`, `reorder`, `tier`) so
5
+ * they do not clutter the top-level command list. Queue-level verbs
6
+ * like `lint`, `export`, `verify`, and `status` stay flat.
7
7
  */
8
8
  import { registerPatchCompact } from './compact.js';
9
9
  import { registerPatchDelete } from './delete.js';
10
+ import { registerPatchLintIgnore } from './lint-ignore.js';
10
11
  import { registerPatchReorder } from './reorder.js';
12
+ import { registerPatchTier } from './tier.js';
11
13
  export { patchCompactCommand } from './compact.js';
12
14
  export { patchDeleteCommand } from './delete.js';
15
+ export { patchLintIgnoreCommand } from './lint-ignore.js';
13
16
  export { patchReorderCommand } from './reorder.js';
17
+ export { patchTierCommand } from './tier.js';
14
18
  /**
15
19
  * Registers the `patch` subcommand parent and its verbs on the CLI.
16
20
  *
@@ -20,7 +24,7 @@ export { patchReorderCommand } from './reorder.js';
20
24
  export function registerPatch(program, context) {
21
25
  const patch = program
22
26
  .command('patch')
23
- .description('Manage individual patches in the queue (compact, delete, reorder)')
27
+ .description('Manage individual patches in the queue (compact, delete, lint-ignore, reorder, tier)')
24
28
  // Match `fireforge furnace`'s no-args contract: print the group's help and
25
29
  // exit 0. Without this default action, commander routes `fireforge patch`
26
30
  // (no subcommand) through its own help-then-exit-1 path, so scripts that
@@ -32,6 +36,8 @@ export function registerPatch(program, context) {
32
36
  });
33
37
  registerPatchCompact(patch, context);
34
38
  registerPatchDelete(patch, context);
39
+ registerPatchLintIgnore(patch, context);
35
40
  registerPatchReorder(patch, context);
41
+ registerPatchTier(patch, context);
36
42
  }
37
43
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `fireforge patch lint-ignore <name>` — adds, removes, or clears entries
3
+ * in `PatchMetadata.lintIgnore` without rewriting the `.patch` file body.
4
+ *
5
+ * Companion to `fireforge re-export <name> --lint-ignore <id>` (which is
6
+ * append-only). Existence is justified by the cases re-export cannot
7
+ * express:
8
+ * - Removing a single entry without dropping the rest of the list.
9
+ * - Clearing the entire list when the operator wants the rule(s) to
10
+ * start firing again.
11
+ * - Editing metadata when the patch body is already correct, so the
12
+ * re-export's engine read + diff regeneration roundtrip is wasted.
13
+ *
14
+ * Modes are mutually exclusive: exactly one of `--add`, `--remove`, or
15
+ * `--clear` must be supplied per invocation. The read-modify-write
16
+ * happens inside the patch directory lock via {@link mutatePatchMetadata}
17
+ * so a concurrent writer cannot interleave between the read and the
18
+ * write — important when an operator scripts repeated invocations or
19
+ * runs `--add` and `--remove` back-to-back.
20
+ */
21
+ import { Command } from 'commander';
22
+ import type { CommandContext } from '../../types/cli.js';
23
+ import type { PatchLintIgnoreOptions } from '../../types/commands/index.js';
24
+ /**
25
+ * Runs the `patch lint-ignore` command: reads the patch's existing
26
+ * `lintIgnore`, applies the requested mode, and writes the manifest.
27
+ *
28
+ * @param projectRoot - Project root directory
29
+ * @param identifier - Patch filename, ordinal, or manifest `name`
30
+ * @param options - Command options (exactly one of `add`/`remove`/`clear`)
31
+ */
32
+ export declare function patchLintIgnoreCommand(projectRoot: string, identifier: string, options?: PatchLintIgnoreOptions): Promise<void>;
33
+ /**
34
+ * Registers the `patch lint-ignore` subcommand on the `patch` parent.
35
+ *
36
+ * @param parent - Parent Commander command
37
+ * @param context - Shared CLI registration context
38
+ */
39
+ export declare function registerPatchLintIgnore(parent: Command, context: CommandContext): void;
@@ -0,0 +1,200 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch lint-ignore <name>` — adds, removes, or clears entries
4
+ * in `PatchMetadata.lintIgnore` without rewriting the `.patch` file body.
5
+ *
6
+ * Companion to `fireforge re-export <name> --lint-ignore <id>` (which is
7
+ * append-only). Existence is justified by the cases re-export cannot
8
+ * express:
9
+ * - Removing a single entry without dropping the rest of the list.
10
+ * - Clearing the entire list when the operator wants the rule(s) to
11
+ * start firing again.
12
+ * - Editing metadata when the patch body is already correct, so the
13
+ * re-export's engine read + diff regeneration roundtrip is wasted.
14
+ *
15
+ * Modes are mutually exclusive: exactly one of `--add`, `--remove`, or
16
+ * `--clear` must be supplied per invocation. The read-modify-write
17
+ * happens inside the patch directory lock via {@link mutatePatchMetadata}
18
+ * so a concurrent writer cannot interleave between the read and the
19
+ * write — important when an operator scripts repeated invocations or
20
+ * runs `--add` and `--remove` back-to-back.
21
+ */
22
+ import { getProjectPaths } from '../../core/config.js';
23
+ import { appendHistory } from '../../core/destructive.js';
24
+ import { mutatePatchMetadata } from '../../core/patch-export.js';
25
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
26
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
27
+ import { toError } from '../../utils/errors.js';
28
+ import { pathExists } from '../../utils/fs.js';
29
+ import { info, intro, outro, warn } from '../../utils/logger.js';
30
+ /**
31
+ * Computes the post-mutation `lintIgnore` list for a given mode.
32
+ * Returns `undefined` when the result should drop the field from the
33
+ * manifest entirely (matching the validator's "preserve only when
34
+ * present" contract).
35
+ */
36
+ function applyMode(existing, mode, values) {
37
+ const existingSet = new Set(existing);
38
+ if (mode === 'add') {
39
+ for (const v of values)
40
+ existingSet.add(v);
41
+ const merged = [...existingSet];
42
+ return merged.length > 0 ? merged : undefined;
43
+ }
44
+ if (mode === 'remove') {
45
+ for (const v of values)
46
+ existingSet.delete(v);
47
+ const remaining = [...existingSet];
48
+ return remaining.length > 0 ? remaining : undefined;
49
+ }
50
+ // mode === 'clear'
51
+ return undefined;
52
+ }
53
+ /**
54
+ * Renders a one-line summary of the planned change for use in
55
+ * `info()` / dry-run / history args.
56
+ */
57
+ function describeChange(before, after, mode, values) {
58
+ const beforeSet = new Set(before);
59
+ const afterSet = new Set(after);
60
+ if (mode === 'clear') {
61
+ return before.length === 0
62
+ ? 'lintIgnore was already empty — no change'
63
+ : `lintIgnore cleared (was ${before.join(', ')})`;
64
+ }
65
+ if (mode === 'add') {
66
+ const added = values.filter((v) => !beforeSet.has(v));
67
+ if (added.length === 0) {
68
+ return 'lintIgnore unchanged (all requested IDs were already present)';
69
+ }
70
+ return `lintIgnore += ${added.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
71
+ }
72
+ // mode === 'remove'
73
+ const removed = values.filter((v) => beforeSet.has(v));
74
+ if (removed.length === 0) {
75
+ return 'lintIgnore unchanged (none of the requested IDs were present)';
76
+ }
77
+ return `lintIgnore −= ${removed.join(', ')} → ${[...afterSet].join(', ') || '(empty)'}`;
78
+ }
79
+ /**
80
+ * Runs the `patch lint-ignore` command: reads the patch's existing
81
+ * `lintIgnore`, applies the requested mode, and writes the manifest.
82
+ *
83
+ * @param projectRoot - Project root directory
84
+ * @param identifier - Patch filename, ordinal, or manifest `name`
85
+ * @param options - Command options (exactly one of `add`/`remove`/`clear`)
86
+ */
87
+ export async function patchLintIgnoreCommand(projectRoot, identifier, options = {}) {
88
+ const isDryRun = options.dryRun === true;
89
+ intro(isDryRun ? 'FireForge patch lint-ignore (dry run)' : 'FireForge patch lint-ignore');
90
+ // Mode mutex: exactly one mode per invocation. Combinations like
91
+ // `--add foo --remove bar` are rejected — an operator who needs both
92
+ // runs the command twice (clearer audit trail) and `--clear` plus a
93
+ // mode is contradictory.
94
+ const adding = (options.add?.length ?? 0) > 0;
95
+ const removing = (options.remove?.length ?? 0) > 0;
96
+ const clearing = options.clear === true;
97
+ const modeCount = [adding, removing, clearing].filter(Boolean).length;
98
+ if (modeCount > 1) {
99
+ throw new InvalidArgumentError('--add, --remove, and --clear are mutually exclusive. Pick one mode per invocation.', 'patch lint-ignore');
100
+ }
101
+ if (modeCount === 0) {
102
+ throw new InvalidArgumentError('Specify --add <id>, --remove <id>, or --clear.', 'patch lint-ignore');
103
+ }
104
+ const mode = adding ? 'add' : removing ? 'remove' : 'clear';
105
+ const values = mode === 'add' ? (options.add ?? []) : mode === 'remove' ? (options.remove ?? []) : [];
106
+ const paths = getProjectPaths(projectRoot);
107
+ if (!(await pathExists(paths.patches))) {
108
+ throw new GeneralError('Patches directory not found.');
109
+ }
110
+ const manifest = await loadPatchesManifest(paths.patches);
111
+ if (!manifest || manifest.patches.length === 0) {
112
+ throw new GeneralError('No patches in manifest.');
113
+ }
114
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
115
+ if (!target) {
116
+ const available = manifest.patches
117
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
118
+ .join(', ');
119
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
120
+ }
121
+ if (isDryRun) {
122
+ const existing = target.lintIgnore ?? [];
123
+ const projected = applyMode(existing, mode, values) ?? [];
124
+ info(`[dry-run] ${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
125
+ outro('Dry run complete — no changes made');
126
+ return;
127
+ }
128
+ const result = await mutatePatchMetadata(paths.patches, target.filename, (existing) => {
129
+ const next = applyMode(existing.lintIgnore ?? [], mode, values);
130
+ // Either set the new list when non-empty or unset the field
131
+ // entirely. The mutation API splits these to keep the
132
+ // exactOptionalPropertyTypes contract clean — only set values land
133
+ // in the typed `Partial<PatchMetadata>`, and the unset list is
134
+ // applied via `delete` after spread.
135
+ return next !== undefined ? { set: { lintIgnore: next } } : { unset: ['lintIgnore'] };
136
+ });
137
+ if (!result) {
138
+ // Race: target vanished between the manifest read above and the
139
+ // locked mutate. Surfacing as a hard error rather than a silent
140
+ // no-op — the operator's intent did not land.
141
+ throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during the update. Re-run after investigating.`);
142
+ }
143
+ const existing = result.before.lintIgnore ?? [];
144
+ const projected = result.after.lintIgnore ?? [];
145
+ info(`${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
146
+ try {
147
+ await appendHistory(paths.patches, {
148
+ operation: 'patch-lint-ignore',
149
+ args: {
150
+ filename: target.filename,
151
+ mode,
152
+ values: [...values],
153
+ before: existing,
154
+ after: projected,
155
+ },
156
+ ...(options.yes === true ? { yes: true } : {}),
157
+ result: 'ok',
158
+ });
159
+ }
160
+ catch (historyError) {
161
+ warn(`History log append failed after patch lint-ignore committed (${target.filename}): ${toError(historyError).message}`);
162
+ }
163
+ outro('Patch lint-ignore complete');
164
+ }
165
+ /**
166
+ * Registers the `patch lint-ignore` subcommand on the `patch` parent.
167
+ *
168
+ * @param parent - Parent Commander command
169
+ * @param context - Shared CLI registration context
170
+ */
171
+ export function registerPatchLintIgnore(parent, context) {
172
+ const { getProjectRoot, withErrorHandling } = context;
173
+ parent
174
+ .command('lint-ignore <name>')
175
+ .description('Edit PatchMetadata.lintIgnore on a single patch (no .patch body rewrite). One mode per invocation.')
176
+ .option('--add <check-id>', 'Lint check ID to add to the patch lintIgnore list (repeatable)', (value, prev) => [...prev, value], [])
177
+ .option('--remove <check-id>', 'Lint check ID to remove from the patch lintIgnore list (repeatable)', (value, prev) => [...prev, value], [])
178
+ .option('--clear', 'Drop the lintIgnore field entirely')
179
+ .option('--dry-run', 'Show what would change without writing')
180
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
181
+ .action(withErrorHandling(async (name, options) => {
182
+ // Commander defaults `--add`/`--remove` to `[]` so they appear in
183
+ // the options object even when unused. Strip empty arrays so
184
+ // `pickDefined` sees them as absent — otherwise the mode-count
185
+ // mutex would treat zero-length arrays as a present mode.
186
+ const normalized = {};
187
+ if (options.add !== undefined && options.add.length > 0)
188
+ normalized.add = options.add;
189
+ if (options.remove !== undefined && options.remove.length > 0)
190
+ normalized.remove = options.remove;
191
+ if (options.clear === true)
192
+ normalized.clear = true;
193
+ if (options.dryRun === true)
194
+ normalized.dryRun = true;
195
+ if (options.yes === true)
196
+ normalized.yes = true;
197
+ await patchLintIgnoreCommand(getProjectRoot(), name, normalized);
198
+ }));
199
+ }
200
+ //# sourceMappingURL=lint-ignore.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
3
+ * a single patch without rewriting the `.patch` file body.
4
+ *
5
+ * Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
6
+ * the right tool when the patch body itself needs to be regenerated; this
7
+ * subcommand exists for the metadata-only adjustment, where the operator
8
+ * has discovered (e.g. from a `lint --per-patch` warning) that the
9
+ * threshold-tier override should be set but the patch body is already
10
+ * correct. Avoiding the re-export saves the engine read + diff
11
+ * regeneration roundtrip and leaves the `.patch` file's mtime alone.
12
+ *
13
+ * Modes are mutually exclusive: exactly one of `--tier <branding>` or
14
+ * `--clear` must be supplied per invocation.
15
+ */
16
+ import { Command } from 'commander';
17
+ import type { CommandContext } from '../../types/cli.js';
18
+ import type { PatchTierOptions } from '../../types/commands/index.js';
19
+ /**
20
+ * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
21
+ * named patch (or clears the field) and writes the manifest.
22
+ *
23
+ * @param projectRoot - Project root directory
24
+ * @param identifier - Patch filename, ordinal, or manifest `name`
25
+ * @param options - Command options
26
+ */
27
+ export declare function patchTierCommand(projectRoot: string, identifier: string, options?: PatchTierOptions): Promise<void>;
28
+ /**
29
+ * Registers the `patch tier` subcommand on the `patch` parent.
30
+ *
31
+ * @param parent - Parent Commander command
32
+ * @param context - Shared CLI registration context
33
+ */
34
+ export declare function registerPatchTier(parent: Command, context: CommandContext): void;
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch tier <name>` — sets or clears `PatchMetadata.tier` on
4
+ * a single patch without rewriting the `.patch` file body.
5
+ *
6
+ * Companion to `fireforge re-export <name> --tier <tier>`. Re-export is
7
+ * the right tool when the patch body itself needs to be regenerated; this
8
+ * subcommand exists for the metadata-only adjustment, where the operator
9
+ * has discovered (e.g. from a `lint --per-patch` warning) that the
10
+ * threshold-tier override should be set but the patch body is already
11
+ * correct. Avoiding the re-export saves the engine read + diff
12
+ * regeneration roundtrip and leaves the `.patch` file's mtime alone.
13
+ *
14
+ * Modes are mutually exclusive: exactly one of `--tier <branding>` or
15
+ * `--clear` must be supplied per invocation.
16
+ */
17
+ import { Option } from 'commander';
18
+ import { getProjectPaths } from '../../core/config.js';
19
+ import { appendHistory } from '../../core/destructive.js';
20
+ import { updatePatchMetadata } from '../../core/patch-export.js';
21
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
22
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
23
+ import { toError } from '../../utils/errors.js';
24
+ import { pathExists } from '../../utils/fs.js';
25
+ import { info, intro, outro, warn } from '../../utils/logger.js';
26
+ import { pickDefined } from '../../utils/options.js';
27
+ /**
28
+ * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
29
+ * named patch (or clears the field) and writes the manifest.
30
+ *
31
+ * @param projectRoot - Project root directory
32
+ * @param identifier - Patch filename, ordinal, or manifest `name`
33
+ * @param options - Command options
34
+ */
35
+ export async function patchTierCommand(projectRoot, identifier, options = {}) {
36
+ const isDryRun = options.dryRun === true;
37
+ intro(isDryRun ? 'FireForge patch tier (dry run)' : 'FireForge patch tier');
38
+ // Mode mutex: a single invocation either sets or clears the tier.
39
+ // Combining both is ambiguous — the operator's intent is not obvious
40
+ // and silently picking one would mask the typo.
41
+ const setting = options.tier !== undefined;
42
+ const clearing = options.clear === true;
43
+ if (setting && clearing) {
44
+ throw new InvalidArgumentError('--tier and --clear are mutually exclusive. Pick one mode per invocation.', 'patch tier');
45
+ }
46
+ if (!setting && !clearing) {
47
+ throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
48
+ }
49
+ const paths = getProjectPaths(projectRoot);
50
+ if (!(await pathExists(paths.patches))) {
51
+ throw new GeneralError('Patches directory not found.');
52
+ }
53
+ const manifest = await loadPatchesManifest(paths.patches);
54
+ if (!manifest || manifest.patches.length === 0) {
55
+ throw new GeneralError('No patches in manifest.');
56
+ }
57
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
58
+ if (!target) {
59
+ const available = manifest.patches
60
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
61
+ .join(', ');
62
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
63
+ }
64
+ const before = target.tier;
65
+ const after = setting ? options.tier : undefined;
66
+ if (before === after) {
67
+ info(after === undefined
68
+ ? `${target.filename}: tier is already absent — no change.`
69
+ : `${target.filename}: tier is already "${after}" — no change.`);
70
+ outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch tier (no-op)');
71
+ return;
72
+ }
73
+ const action = after === undefined
74
+ ? `clear tier (was "${before}")`
75
+ : before === undefined
76
+ ? `set tier to "${after}"`
77
+ : `change tier from "${before}" to "${after}"`;
78
+ if (isDryRun) {
79
+ info(`[dry-run] ${target.filename}: would ${action}.`);
80
+ outro('Dry run complete — no changes made');
81
+ return;
82
+ }
83
+ // Single write under the patch directory lock (delegated inside
84
+ // updatePatchMetadata). Setting routes through `updates`; clearing
85
+ // routes through `unsetFields` so TypeScript's exact optional types
86
+ // do not have to carry an explicit `undefined` on the `tier` field.
87
+ if (after !== undefined) {
88
+ await updatePatchMetadata(paths.patches, target.filename, { tier: after });
89
+ }
90
+ else {
91
+ await updatePatchMetadata(paths.patches, target.filename, {}, ['tier']);
92
+ }
93
+ try {
94
+ await appendHistory(paths.patches, {
95
+ operation: 'patch-tier',
96
+ args: {
97
+ filename: target.filename,
98
+ ...(before !== undefined ? { before } : {}),
99
+ ...(after !== undefined ? { after } : {}),
100
+ },
101
+ ...(options.yes === true ? { yes: true } : {}),
102
+ result: 'ok',
103
+ });
104
+ }
105
+ catch (historyError) {
106
+ warn(`History log append failed after patch tier committed (${target.filename}): ${toError(historyError).message}`);
107
+ }
108
+ info(`${target.filename}: ${action}.`);
109
+ outro('Patch tier complete');
110
+ }
111
+ /**
112
+ * Registers the `patch tier` subcommand on the `patch` parent.
113
+ *
114
+ * @param parent - Parent Commander command
115
+ * @param context - Shared CLI registration context
116
+ */
117
+ export function registerPatchTier(parent, context) {
118
+ const { getProjectRoot, withErrorHandling } = context;
119
+ parent
120
+ .command('tier <name>')
121
+ .description('Set or clear PatchMetadata.tier on a single patch (no .patch body rewrite). Use --tier <branding> to set, --clear to remove.')
122
+ .addOption(new Option('--tier <tier>', 'Force the tier override on the patch (only "branding" recognised)').choices(['branding']))
123
+ .option('--clear', 'Remove the tier override (restores tier auto-detection)')
124
+ .option('--dry-run', 'Show what would change without writing')
125
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
126
+ .action(withErrorHandling(async (name, options) => {
127
+ const { tier, ...rest } = options;
128
+ await patchTierCommand(getProjectRoot(), name, {
129
+ ...pickDefined(rest),
130
+ ...(tier !== undefined ? { tier: tier } : {}),
131
+ });
132
+ }));
133
+ }
134
+ //# sourceMappingURL=tier.js.map