pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12

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 (71) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +122 -38
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +322 -101
  7. package/package.json +7 -6
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +128 -10
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +648 -0
  14. package/scripts/install/cli-ui.mjs +79 -1
  15. package/scripts/install/exit-codes.mjs +26 -0
  16. package/scripts/install/file-inventory.mjs +555 -0
  17. package/scripts/install/interactive.mjs +15 -1
  18. package/scripts/install/migrate.mjs +32 -6
  19. package/scripts/install/model-picker.mjs +63 -22
  20. package/scripts/install/opencode-version.mjs +9 -1
  21. package/scripts/install/opencode.mjs +761 -63
  22. package/scripts/install/plan-artifact.mjs +517 -0
  23. package/scripts/install/plan.mjs +449 -0
  24. package/scripts/install/plugin.mjs +136 -18
  25. package/scripts/install/shared.mjs +249 -94
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/strings.mjs +20 -0
  28. package/scripts/install/venv.mjs +25 -6
  29. package/scripts/install/verify.mjs +128 -0
  30. package/scripts/install/vision-prompt.mjs +13 -1
  31. package/scripts/install-mcp.mjs +1 -1
  32. package/scripts/manifest-inventory.mjs +49 -3
  33. package/scripts/mcp_persistence.py +50 -15
  34. package/scripts/node-engine-policy.mjs +74 -0
  35. package/scripts/package-evidence.mjs +6 -0
  36. package/scripts/postinstall.mjs +3 -4
  37. package/scripts/prune.mjs +9 -3
  38. package/scripts/reconcile.mjs +325 -0
  39. package/scripts/restore.mjs +222 -0
  40. package/scripts/uninstall.mjs +258 -34
  41. package/scripts/update.mjs +164 -0
  42. package/scripts/validate-node-engine-policy.mjs +13 -0
  43. package/scripts/version-check.mjs +34 -1
  44. package/scripts/versioning.mjs +9 -1
  45. package/skills-lock.json +4 -4
  46. package/src/agents/iris.md +6 -0
  47. package/src/agents/mnemosyne.md +7 -2
  48. package/src/agents/zeus.md +19 -62
  49. package/src/instructions/agent-return-format.instructions.md +17 -15
  50. package/src/instructions/memory-protocol.instructions.md +3 -2
  51. package/src/instructions/test-economy.instructions.md +21 -0
  52. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  53. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  54. package/src/instructions/zeus-memory-operations.instructions.md +11 -6
  55. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  56. package/src/mcp/mcp_persistence.py +50 -15
  57. package/src/pantheon/cost-command.ts +5 -2
  58. package/src/pantheon/model-agents.ts +19 -0
  59. package/src/pantheon/model-command.ts +218 -56
  60. package/src/pantheon/v2-model-overrides.ts +275 -0
  61. package/src/pantheon/v2-tools.ts +18 -29
  62. package/src/pantheon/v2-unsupported.mjs +2 -3
  63. package/src/plugin-v2.ts +13 -0
  64. package/src/plugins/tui/dist/tui-v2.js +2 -2
  65. package/src/plugins/tui/dist/tui.js +1 -1
  66. package/src/plugins/tui/package-lock.json +3 -3
  67. package/src/plugins/tui/package.json +2 -2
  68. package/src/skills/artifact-management/SKILL.md +5 -1
  69. package/src/skills/auto-continue/SKILL.md +14 -6
  70. package/src/skills/memory-bank/SKILL.md +5 -1
  71. package/src/skills/worktrees/SKILL.md +30 -11
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /** Preview or remove only files proven stale by the install ownership inventory. */
3
+ import {
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ fsyncSync,
8
+ openSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { applyFileDriftPlan, classifyFileInventory } from './install/file-inventory.mjs'
16
+ import { readState } from './install/state.mjs'
17
+ import { assertNoSymlinkAncestors, verifyRegularFile } from './install/verify.mjs'
18
+
19
+ const VALID_COMPONENTS = [
20
+ 'agents',
21
+ 'skills',
22
+ 'instructions',
23
+ 'prompts',
24
+ 'commands',
25
+ 'plugins',
26
+ 'runtime',
27
+ ]
28
+ const DEFAULT_COMPONENTS = [...VALID_COMPONENTS]
29
+
30
+ export function parseArgs(argv) {
31
+ const args = {
32
+ project: false,
33
+ global: false,
34
+ target: null,
35
+ components: null,
36
+ apply: false,
37
+ dryRun: false,
38
+ yes: false,
39
+ savePlan: null,
40
+ applyPlan: null,
41
+ help: false,
42
+ }
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const arg = argv[i]
45
+ if (arg === '--project') args.project = true
46
+ else if (arg === '--global') args.global = true
47
+ else if (arg === '--target') {
48
+ const target = argv[++i]
49
+ if (!target || target.startsWith('--')) throw new Error('--target requires a path')
50
+ args.target = target
51
+ } else if (arg === '--components') args.components = argv[++i] ?? null
52
+ else if (arg === '--apply') args.apply = true
53
+ else if (arg === '--dry-run') args.dryRun = true
54
+ else if (arg === '--yes' || arg === '-y') args.yes = true
55
+ else if (arg === '--save-plan') args.savePlan = argv[++i] ?? null
56
+ else if (arg === '--apply-plan') args.applyPlan = argv[++i] ?? null
57
+ else if (arg === '--help' || arg === '-h') args.help = true
58
+ else throw new Error(`Unknown reconcile option: ${arg}`)
59
+ }
60
+ if (!args.applyPlan && !args.help) {
61
+ if (args.project && (args.global || args.target !== null)) {
62
+ throw new Error('--project cannot be combined with --global or --target')
63
+ }
64
+ if (!args.project && !args.global && args.target === null) {
65
+ throw new Error('Choose a target scope: --project, --global, or --target PATH')
66
+ }
67
+ }
68
+ if (args.components !== null) {
69
+ args.components = args.components
70
+ .split(',')
71
+ .map((item) => item.trim())
72
+ .filter(Boolean)
73
+ if (
74
+ args.components.length === 0 ||
75
+ args.components.some((item) => !VALID_COMPONENTS.includes(item))
76
+ ) {
77
+ throw new Error(
78
+ `--components must be a comma-separated list of: ${VALID_COMPONENTS.join(', ')}`,
79
+ )
80
+ }
81
+ args.components = [...new Set(args.components)]
82
+ }
83
+ if (args.apply && args.applyPlan) throw new Error('Use either --apply or --apply-plan, not both')
84
+ if (args.dryRun && (args.apply || args.applyPlan)) {
85
+ throw new Error('--dry-run cannot be combined with an apply option')
86
+ }
87
+ if (args.savePlan && (args.apply || args.applyPlan))
88
+ throw new Error('--save-plan is available only during preview')
89
+ if (args.yes && !args.apply && !args.applyPlan)
90
+ throw new Error('--yes requires --apply or --apply-plan')
91
+ if (args.applyPlan && (args.project || args.global || args.target || args.components)) {
92
+ throw new Error('--apply-plan uses the target and components embedded in the reviewed plan')
93
+ }
94
+ return args
95
+ }
96
+
97
+ function printHelp() {
98
+ console.log(`
99
+ pantheon-opencode reconcile — review and remove exact stale installer-owned files
100
+
101
+ Usage:
102
+ pantheon-opencode reconcile --project [--dry-run] [--components agents,skills] [--save-plan <file>]
103
+ pantheon-opencode reconcile --global [--target PATH] [--dry-run] [--components agents,skills]
104
+ pantheon-opencode reconcile --target PATH [--dry-run] [--components agents,skills]
105
+ pantheon-opencode reconcile --project --apply [--yes]
106
+ pantheon-opencode reconcile --apply-plan <file> --yes
107
+
108
+ Use --global for the global OpenCode config target, or --target PATH for an explicit
109
+ target. An explicit target is project-scoped unless combined with --global, which
110
+ selects the flat global layout for that path. Preview is read-only. Apply removes only unchanged, inventoried stale files;
111
+ legacy, modified, missing, symlinked, and ambiguous files are preserved.
112
+ `)
113
+ }
114
+
115
+ function resolveTarget(args) {
116
+ if (args.target) return resolve(args.target)
117
+ if (args.project) return resolve(process.cwd())
118
+ return resolve(join(homedir(), '.config', 'opencode'))
119
+ }
120
+
121
+ function layoutFor({ global, version }) {
122
+ return Object.fromEntries(
123
+ VALID_COMPONENTS.map((component) => [
124
+ component,
125
+ global ? 'global' : version === 'v2' ? 'project-v2' : 'project-v1',
126
+ ]),
127
+ )
128
+ }
129
+
130
+ async function buildReview(target, components, global) {
131
+ if (!existsSync(target)) throw new Error(`Target directory does not exist: ${target}`)
132
+ const state = readState(target)
133
+ if (!state)
134
+ throw new Error('No readable installer state found; unowned legacy files will not be removed')
135
+ const { installOpenCode } = await import('./install/opencode.mjs')
136
+ const desired = await installOpenCode(target, true, false, components, {
137
+ version: 'auto',
138
+ global,
139
+ quiet: true,
140
+ quietPlan: true,
141
+ planOnly: true,
142
+ headless: true,
143
+ })
144
+ const desiredFiles = new Map(
145
+ (desired?.files ?? [])
146
+ .filter((file) => typeof file.outputFingerprint === 'string')
147
+ .map((file) => [file.path, { sha256: file.outputFingerprint }]),
148
+ )
149
+ const selectedLayouts = layoutFor({ global, version: desired?.openCodeVersion })
150
+ const report = classifyFileInventory(target, state, {
151
+ desiredFiles,
152
+ selectedComponents: components,
153
+ selectedLayouts,
154
+ })
155
+ return {
156
+ state,
157
+ report,
158
+ desiredFiles,
159
+ selectedLayouts,
160
+ components,
161
+ plan: {
162
+ schema_version: 1,
163
+ command: 'reconcile',
164
+ target: resolve(target),
165
+ selected_components: components,
166
+ selected_layouts: selectedLayouts,
167
+ inventory_fingerprint: report.inventoryFingerprint,
168
+ manual_review: report.manualReview,
169
+ operations: report.operations,
170
+ },
171
+ }
172
+ }
173
+
174
+ function safePlanShape(plan) {
175
+ return (
176
+ plan &&
177
+ plan.schema_version === 1 &&
178
+ plan.command === 'reconcile' &&
179
+ typeof plan.target === 'string' &&
180
+ Array.isArray(plan.selected_components) &&
181
+ plan.selected_components.every((component) => VALID_COMPONENTS.includes(component)) &&
182
+ plan.selected_layouts &&
183
+ typeof plan.selected_layouts === 'object' &&
184
+ typeof plan.inventory_fingerprint === 'string' &&
185
+ Array.isArray(plan.operations)
186
+ )
187
+ }
188
+
189
+ function savePlan(filePath, plan) {
190
+ const destination = resolve(filePath)
191
+ assertNoSymlinkAncestors(destination)
192
+ const descriptor = openSync(
193
+ destination,
194
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
195
+ 0o600,
196
+ )
197
+ const contents = `${JSON.stringify(plan, null, 2)}\n`
198
+ try {
199
+ writeFileSync(descriptor, contents, 'utf8')
200
+ fsyncSync(descriptor)
201
+ } finally {
202
+ closeSync(descriptor)
203
+ }
204
+ verifyRegularFile(destination, contents)
205
+ return destination
206
+ }
207
+
208
+ async function authorize(plan, args) {
209
+ const count = plan.operations.length
210
+ console.log(`Reviewed ${count} exact stale-owned file(s) under ${plan.target}:`)
211
+ for (const operation of plan.operations) console.log(` remove ${operation.path}`)
212
+ if (plan.operations.length === 0) return true
213
+ if (args.yes) return true
214
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
215
+ console.error(
216
+ 'Apply requested without authorization. Re-run with --apply --yes or --apply-plan FILE --yes.',
217
+ )
218
+ process.exitCode = 2
219
+ return false
220
+ }
221
+ const readline = await import('node:readline/promises')
222
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout })
223
+ try {
224
+ const answer = await prompt.question('Remove exactly these files? [y/N] ')
225
+ return ['y', 'yes'].includes(answer.trim().toLowerCase())
226
+ } finally {
227
+ prompt.close()
228
+ }
229
+ }
230
+
231
+ function printReport(report) {
232
+ console.log('Installer file ownership report:')
233
+ if (report.legacyAmbiguous) {
234
+ console.log(
235
+ ' manual-review Legacy files without inventory are unknown and will never be auto-deleted.',
236
+ )
237
+ }
238
+ for (const entry of report.entries) {
239
+ const suffix = entry.reason ? ` (${entry.reason})` : ''
240
+ console.log(` ${entry.classification.padEnd(14)} ${entry.path}${suffix}`)
241
+ }
242
+ console.log(
243
+ ` ${report.operations.length} stale-owned regular file(s) eligible for reviewed removal.`,
244
+ )
245
+ }
246
+
247
+ async function runApplyPlan(plan, args) {
248
+ if (!safePlanShape(plan) || plan.manual_review === true) {
249
+ throw new Error('Reviewed reconcile plan is invalid or marked for manual review')
250
+ }
251
+ const target = resolve(plan.target)
252
+ const components = plan.selected_components
253
+ const review = await buildReview(target, components, plan.selected_layouts?.agents === 'global')
254
+ if (JSON.stringify(review.plan.operations) !== JSON.stringify(plan.operations)) {
255
+ throw new Error('Reviewed reconcile operation set drifted; no files were removed')
256
+ }
257
+ const reviewed = {
258
+ target,
259
+ operations: plan.operations,
260
+ inventoryFingerprint: plan.inventory_fingerprint,
261
+ manualReview: plan.manual_review,
262
+ }
263
+ if (!(await authorize(reviewed, args))) return
264
+ const result = applyFileDriftPlan(target, review.state, reviewed, {
265
+ desiredFiles: review.desiredFiles,
266
+ selectedComponents: components,
267
+ selectedLayouts: review.selectedLayouts,
268
+ })
269
+ console.log(`Removed ${result.removed.length} file(s).`)
270
+ if (result.backupDir) console.log(`Restorable backup: ${result.backupDir}`)
271
+ }
272
+
273
+ export async function main(argv = process.argv.slice(2)) {
274
+ let args
275
+ try {
276
+ args = parseArgs(argv)
277
+ } catch (error) {
278
+ console.error(`❌ ${error.message}`)
279
+ process.exitCode = 1
280
+ return
281
+ }
282
+ if (args.help) return printHelp()
283
+
284
+ if (args.applyPlan) {
285
+ try {
286
+ const plan = JSON.parse(readFileSync(resolve(args.applyPlan), 'utf8'))
287
+ await runApplyPlan(plan, args)
288
+ } catch (error) {
289
+ console.error(`❌ ${error.message}`)
290
+ process.exitCode = 3
291
+ }
292
+ return
293
+ }
294
+
295
+ const target = resolveTarget(args)
296
+ const components = args.components ?? DEFAULT_COMPONENTS
297
+ try {
298
+ const review = await buildReview(target, components, args.global)
299
+ printReport(review.report)
300
+ if (args.savePlan) {
301
+ const savedPath = savePlan(args.savePlan, review.plan)
302
+ console.log(`Reviewed plan saved: ${savedPath}`)
303
+ return
304
+ }
305
+ if (!args.apply) {
306
+ console.log('Preview only; no files were written or removed.')
307
+ return
308
+ }
309
+ if (!(await authorize(review.plan, args))) return
310
+ const result = applyFileDriftPlan(target, review.state, review.report, {
311
+ desiredFiles: review.desiredFiles,
312
+ selectedComponents: components,
313
+ selectedLayouts: review.selectedLayouts,
314
+ })
315
+ console.log(`Removed ${result.removed.length} file(s).`)
316
+ if (result.backupDir) console.log(`Restorable backup: ${result.backupDir}`)
317
+ } catch (error) {
318
+ console.error(`❌ ${error.message}`)
319
+ process.exitCode = 1
320
+ }
321
+ }
322
+
323
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
324
+ await main()
325
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * restore.mjs — Pantheon exact-restore CLI (Beta 11 Setup, phase 2).
4
+ *
5
+ * Restores a backup set created by the installer back into the config dir,
6
+ * reproducing each file byte-for-byte and re-applying its recorded mode. The
7
+ * restore is itself reversible: the CURRENT state is backed up first.
8
+ *
9
+ * Safety contract:
10
+ * - the backup manifest is verified (hash of every backed-up file) before any
11
+ * restore; a tampered or incomplete set is refused outright;
12
+ * - the backup-vs-current diff is shown BY PATH ONLY (never file contents);
13
+ * - writing requires explicit authorization (`--yes`); a non-TTY run without
14
+ * `--yes` previews and exits without writing.
15
+ *
16
+ * Usage:
17
+ * pantheon-opencode restore [--project] [--from <dir>] [--dry-run] [--yes]
18
+ *
19
+ * Exit codes:
20
+ * 0 = restored (or preview shown with --dry-run)
21
+ * 1 = refused (missing/tampered backup, or no authorization)
22
+ * 2 = usage error
23
+ */
24
+
25
+ import { existsSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { isAbsolute, join, resolve } from 'node:path'
28
+
29
+ import {
30
+ backupRoot,
31
+ createBackupSet,
32
+ isBackupDirName,
33
+ listBackupSets,
34
+ planRestore,
35
+ restoreBackupSet,
36
+ verifyBackupSet,
37
+ } from './install/backup.mjs'
38
+
39
+ /**
40
+ * Resolve the config dir whose `opencode.json` the installer manages.
41
+ * `--project` targets the CWD; otherwise XDG / PANTHEON_HOME.
42
+ * @param {{ project?: boolean }} opts
43
+ * @returns {string}
44
+ */
45
+ export function resolveConfigDir({ project = false } = {}) {
46
+ if (project) return resolve(process.cwd())
47
+ if (process.env.PANTHEON_HOME) return resolve(process.env.PANTHEON_HOME)
48
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
49
+ return join(base, 'opencode')
50
+ }
51
+
52
+ export function parseArgs(argv) {
53
+ const args = { project: false, from: null, dryRun: false, yes: false, help: false }
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const arg = argv[i]
56
+ switch (arg) {
57
+ case '--project':
58
+ args.project = true
59
+ break
60
+ case '--dry-run':
61
+ args.dryRun = true
62
+ break
63
+ case '--yes':
64
+ case '-y':
65
+ args.yes = true
66
+ break
67
+ case '--from':
68
+ args.from = argv[++i] ?? null
69
+ break
70
+ case '--help':
71
+ case '-h':
72
+ args.help = true
73
+ break
74
+ default:
75
+ break
76
+ }
77
+ }
78
+ return args
79
+ }
80
+
81
+ export function showHelp() {
82
+ console.log(`
83
+ pantheon-opencode restore — restore an exact installer backup
84
+
85
+ Usage:
86
+ pantheon-opencode restore restore newest backup (global config)
87
+ pantheon-opencode restore --project restore newest backup (project)
88
+ pantheon-opencode restore --from <dir> restore a specific backup set
89
+ pantheon-opencode restore --dry-run preview only (writes nothing)
90
+ pantheon-opencode restore --yes authorize the write (non-interactive)
91
+
92
+ A backup set lives under <config-dir>/.pantheon/backups/<timestamp>/.
93
+ Restore reproduces each file byte-for-byte and re-applies its recorded mode.
94
+ File contents are never printed.
95
+ `)
96
+ }
97
+
98
+ /**
99
+ * Choose the backup set to restore.
100
+ * @param {string} configDir
101
+ * @param {string|null} from
102
+ * @returns {{ dir: string, name: string } | null}
103
+ */
104
+ export function pickBackupSet(configDir, from) {
105
+ if (from) {
106
+ // Accept either a bare timestamp set name (resolved under the backups root)
107
+ // or an explicit directory path.
108
+ if (isBackupDirName(from)) {
109
+ const dir = join(backupRoot(configDir), from)
110
+ return existsSync(dir) ? { dir, name: from } : null
111
+ }
112
+ const dir = isAbsolute(from) ? from : resolve(process.cwd(), from)
113
+ if (!existsSync(dir)) return null
114
+ return { dir, name: dir.split(/[\\/]/).pop() }
115
+ }
116
+ return listBackupSets(configDir)[0] ?? null
117
+ }
118
+
119
+ /**
120
+ * @param {object} opts
121
+ * @param {string} opts.configDir
122
+ * @param {string} opts.backupDir
123
+ * @param {boolean} [opts.dryRun]
124
+ * @param {boolean} [opts.yes]
125
+ * @returns {Promise<{ ok: boolean, code: number, entries?: object[] }>}
126
+ */
127
+ export async function runRestore({ configDir, backupDir, dryRun = false, yes = false }) {
128
+ const verify = verifyBackupSet(backupDir)
129
+ if (!verify.ok) {
130
+ console.error(
131
+ `❌ Refusing to restore: backup failed integrity verification (${verify.reason ?? 'hash mismatch'}).`,
132
+ )
133
+ return { ok: false, code: 1 }
134
+ }
135
+
136
+ const plan = planRestore(backupDir, configDir)
137
+ if (plan.error) {
138
+ console.error(`❌ Refusing to restore: ${plan.error}.`)
139
+ return { ok: false, code: 1 }
140
+ }
141
+
142
+ const counts = { create: 0, update: 0, remove: 0, unchanged: 0 }
143
+ for (const entry of plan.entries) counts[entry.action] += 1
144
+
145
+ console.log('')
146
+ console.log(`Restore plan — ${configDir}`)
147
+ console.log(` from ${backupDir}`)
148
+ for (const entry of plan.entries) {
149
+ if (entry.action === 'unchanged') continue
150
+ console.log(` ${entry.action.padEnd(9)} ${entry.relativePath}`)
151
+ }
152
+ console.log(
153
+ ` ${counts.create} create · ${counts.update} update · ${counts.remove} remove · ${counts.unchanged} unchanged`,
154
+ )
155
+
156
+ if (dryRun) {
157
+ console.log(' (dry-run) No files were written.')
158
+ return { ok: true, code: 0, entries: plan.entries }
159
+ }
160
+
161
+ if (!yes) {
162
+ // Reuse the shared confirm semantics (default NO). An interactive TTY may
163
+ // authorize without --yes; a non-TTY run must never proceed silently.
164
+ const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY)
165
+ if (!tty) {
166
+ console.error('❌ Refusing to restore without authorization. Re-run with --yes.')
167
+ return { ok: false, code: 1 }
168
+ }
169
+ const { promptReview } = await import('./install/interactive.mjs')
170
+ const accepted = await promptReview('Restore these changes?', { default: false })
171
+ if (!accepted) {
172
+ console.log(' Nothing restored.')
173
+ return { ok: true, code: 0, entries: plan.entries }
174
+ }
175
+ }
176
+
177
+ // Reversible: snapshot the CURRENT state of exactly the affected paths first.
178
+ // Retention is DISABLED here (`keep: 0`): a retention pass at this point could
179
+ // delete the very set being restored once 5 older sets already exist (the new
180
+ // snapshot would be the 6th, and pruning would drop the oldest two — including
181
+ // the source). Apply still prunes; restore never deletes backups.
182
+ const { dir: preDir } = createBackupSet({
183
+ configDir,
184
+ entries: plan.manifest.entries.map((e) => e.relativePath),
185
+ packageVersion: plan.manifest.packageVersion,
186
+ keep: 0,
187
+ })
188
+ console.log(` pre-restore backup: ${preDir}`)
189
+
190
+ restoreBackupSet(backupDir, configDir, { dryRun: false })
191
+ console.log(` ✅ Restored ${plan.entries.length} path(s).`)
192
+ return { ok: true, code: 0, entries: plan.entries }
193
+ }
194
+
195
+ async function main() {
196
+ const args = parseArgs(process.argv.slice(2))
197
+ if (args.help) {
198
+ showHelp()
199
+ return
200
+ }
201
+ const configDir = resolveConfigDir({ project: args.project })
202
+ const backup = pickBackupSet(configDir, args.from)
203
+ if (!backup) {
204
+ console.error(`❌ No backup set found under ${backupRoot(configDir)}`)
205
+ process.exitCode = 1
206
+ return
207
+ }
208
+ const result = await runRestore({
209
+ configDir,
210
+ backupDir: backup.dir,
211
+ dryRun: args.dryRun,
212
+ yes: args.yes,
213
+ })
214
+ if (!result.ok) process.exitCode = result.code
215
+ }
216
+
217
+ if (import.meta.url === `file://${process.argv[1]}`) {
218
+ main().catch((err) => {
219
+ console.error(`❌ Restore failed: ${err instanceof Error ? err.message : String(err)}`)
220
+ process.exitCode = 1
221
+ })
222
+ }