@rover-studio/answer-me 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/answerme-toolkit.mjs +6 -0
  2. package/distribution/npm/migrations.json +605 -0
  3. package/distribution/npm/package-manifest.json +192 -0
  4. package/distribution/npm/skills/answerme/SKILL.md +94 -0
  5. package/distribution/npm/skills/answerme/agents/openai.yaml +4 -0
  6. package/distribution/npm/skills/answerme/references/api.md +135 -0
  7. package/distribution/npm/skills/answerme/references/creator-credential-deployment.md +19 -0
  8. package/distribution/npm/skills/answerme/references/creator-credential-recovery.md +24 -0
  9. package/distribution/npm/skills/answerme/references/errors.md +44 -0
  10. package/distribution/npm/skills/answerme/references/handoff.md +46 -0
  11. package/distribution/npm/skills/answerme/references/install-self-test.md +28 -0
  12. package/distribution/npm/skills/answerme/references/result-token-store.md +50 -0
  13. package/distribution/npm/skills/answerme/references/templates.md +139 -0
  14. package/distribution/npm/skills/answerme/scripts/answerme-api-base-url.ps1 +40 -0
  15. package/distribution/npm/skills/answerme/scripts/create-answerme.ps1 +1892 -0
  16. package/distribution/npm/skills/answerme/scripts/creator-credential-store.windows.ps1 +503 -0
  17. package/distribution/npm/skills/answerme/scripts/deploy-answerme-creator-credential.ps1 +447 -0
  18. package/distribution/npm/skills/answerme/scripts/enroll-answerme-creator.ps1 +764 -0
  19. package/distribution/npm/skills/answerme/scripts/open-answerme-page.windows.ps1 +272 -0
  20. package/distribution/npm/skills/answerme/scripts/remove-answerme-result-token.ps1 +63 -0
  21. package/distribution/npm/skills/answerme/scripts/result-token-store.windows.ps1 +261 -0
  22. package/distribution/npm/skills/answerme/scripts/test-answerme-installation.ps1 +498 -0
  23. package/distribution/npm/skills/answerme/scripts/wait-answerme-result.ps1 +908 -0
  24. package/distribution/npm/skills/answerme/scripts/windows-crypto.ps1 +57 -0
  25. package/distribution/npm/skills/answerme/scripts/windows-http.ps1 +45 -0
  26. package/distribution/npm/skills/answerme/scripts/windows-process-start-info.ps1 +76 -0
  27. package/distribution/npm/skills/ask-when-needed/SKILL.md +164 -0
  28. package/distribution/npm/skills/ask-when-needed/agents/openai.yaml +4 -0
  29. package/distribution/npm/skills/ask-when-needed/references/interview-strategies.md +43 -0
  30. package/lib/npm-cli/commands.mjs +247 -0
  31. package/lib/npm-cli/constants.mjs +51 -0
  32. package/lib/npm-cli/errors.mjs +15 -0
  33. package/lib/npm-cli/filesystem.mjs +193 -0
  34. package/lib/npm-cli/host-discovery.mjs +404 -0
  35. package/lib/npm-cli/main.mjs +42 -0
  36. package/lib/npm-cli/package-integrity.mjs +212 -0
  37. package/lib/npm-cli/transaction.mjs +375 -0
  38. package/lib/npm-cli/usage-validation.mjs +349 -0
  39. package/package.json +17 -0
@@ -0,0 +1,42 @@
1
+ import { runCommand } from './commands.mjs';
2
+
3
+ function parseArguments(argv) {
4
+ if (!Array.isArray(argv) || argv.length === 0) return null;
5
+ const command = argv[0];
6
+ if (!['install', 'update', 'uninstall', 'doctor'].includes(command)) return null;
7
+ const options = { json: false, interactive: false };
8
+ for (const argument of argv.slice(1)) {
9
+ if (argument === '--json') options.json = true;
10
+ else if (argument === '--interactive' && command === 'doctor') options.interactive = true;
11
+ else return null;
12
+ }
13
+ return { command, options };
14
+ }
15
+
16
+ function humanSummary(result) {
17
+ const usage = result.usageValidation.status === 'not-run'
18
+ ? 'usageValidation=not-run'
19
+ : `usageValidation=${result.usageValidation.status}`;
20
+ const code = result.installation.code ? `; code=${result.installation.code}` : '';
21
+ const location = result.installation.directory ? `\nDirectory: ${result.installation.directory}` : '';
22
+ const issue = result.usageValidation.status === 'issue'
23
+ ? '\nInstallation is retained. Usage validation is incomplete; retry only when requested with: answerme-toolkit doctor --interactive'
24
+ : '';
25
+ const warning = result.usageValidation.cleanupWarning
26
+ ? '\nWarning: temporary validation data could not be cleaned up; installation is retained.'
27
+ : '';
28
+ const limitations = result.limitations?.length ? `\nLimitations: ${result.limitations.join(', ')}` : '';
29
+ return `AnswerMe Toolkit ${result.version}: installation=${result.installation.status}${code}; ${usage}; rollback=${result.rollback.status}${location}${issue}${warning}${limitations}`;
30
+ }
31
+
32
+ export async function runCli({ argv, write = (value) => process.stdout.write(value), dependencies = {} } = {}) {
33
+ const parsed = parseArguments(argv);
34
+ const jsonOnly = parsed?.options.json === true || (Array.isArray(argv) && argv.includes('--json'));
35
+ const outcome = parsed
36
+ ? await runCommand(parsed.command, parsed.options, dependencies)
37
+ : await runCommand('invalid', {}, dependencies);
38
+ write(jsonOnly ? `${JSON.stringify(outcome.result)}\n` : `${humanSummary(outcome.result)}\n`);
39
+ return outcome.exitCode;
40
+ }
41
+
42
+ export const __test = Object.freeze({ parseArguments, humanSummary });
@@ -0,0 +1,212 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {
5
+ INSTALL_LEDGER,
6
+ MANAGED_SKILLS,
7
+ PACKAGE_NAME,
8
+ PACKAGE_VERSION,
9
+ } from './constants.mjs';
10
+ import { InstallerError } from './errors.mjs';
11
+ import {
12
+ assertPlainExisting,
13
+ readJsonFile,
14
+ sha256,
15
+ snapshotPath,
16
+ snapshotsEqual,
17
+ } from './filesystem.mjs';
18
+
19
+ const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
20
+ const SHA256 = /^[0-9a-f]{64}$/;
21
+ const SAFE_RELATIVE = /^(?![./])(?!.+\/\.\.?\/)[A-Za-z0-9._/-]+$/;
22
+
23
+ function isRecord(value) {
24
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+
27
+ function exactKeys(value, keys) {
28
+ return isRecord(value)
29
+ && Object.keys(value).length === keys.length
30
+ && keys.every((key) => Object.hasOwn(value, key));
31
+ }
32
+
33
+ export function isValidManagedSnapshot(value) {
34
+ if (!exactKeys(value, ['exists', 'kind', 'entries'])
35
+ || value.exists !== true
36
+ || value.kind !== 'directory'
37
+ || !Array.isArray(value.entries)) return false;
38
+ let previous = null;
39
+ for (const entry of value.entries) {
40
+ if (!isRecord(entry) || typeof entry.path !== 'string' || !SAFE_RELATIVE.test(entry.path)) return false;
41
+ if (previous !== null && previous.localeCompare(entry.path, 'en') >= 0) return false;
42
+ previous = entry.path;
43
+ if (entry.type === 'directory') {
44
+ if (!exactKeys(entry, ['path', 'type'])) return false;
45
+ } else if (entry.type === 'file') {
46
+ if (!exactKeys(entry, ['path', 'type', 'size', 'sha256'])
47
+ || !Number.isSafeInteger(entry.size) || entry.size < 0 || !SHA256.test(entry.sha256)) return false;
48
+ } else return false;
49
+ }
50
+ return value.entries.some((entry) => entry.path.toLowerCase() === 'skill.md' && entry.type === 'file');
51
+ }
52
+
53
+ async function enumerateFiles(root, current = root, output = []) {
54
+ await assertPlainExisting(current, 'directory');
55
+ const entries = await readdir(current, { withFileTypes: true });
56
+ entries.sort((a, b) => a.name.localeCompare(b.name, 'en'));
57
+ for (const entry of entries) {
58
+ const absolute = path.join(current, entry.name);
59
+ if (entry.isDirectory()) await enumerateFiles(root, absolute, output);
60
+ else if (entry.isFile()) output.push(path.relative(root, absolute).replaceAll('\\', '/'));
61
+ else throw new InstallerError('package-integrity-invalid');
62
+ }
63
+ return output;
64
+ }
65
+
66
+ function validateMigrationManifest(value) {
67
+ if (!exactKeys(value, ['schemaVersion', 'profiles']) || value.schemaVersion !== 1 || !Array.isArray(value.profiles)) {
68
+ throw new InstallerError('package-integrity-invalid');
69
+ }
70
+ const allowed = new Set(['0.3.0', '0.4.0', '0.4.1']);
71
+ if (value.profiles.length !== allowed.size) throw new InstallerError('package-integrity-invalid');
72
+ for (const profile of value.profiles) {
73
+ if (!exactKeys(profile, ['version', 'archiveSha256', 'skills'])
74
+ || !allowed.delete(profile.version)
75
+ || !SHA256.test(profile.archiveSha256)
76
+ || !exactKeys(profile.skills, MANAGED_SKILLS)
77
+ || !MANAGED_SKILLS.every((name) => isValidManagedSnapshot(profile.skills[name]))) {
78
+ throw new InstallerError('package-integrity-invalid');
79
+ }
80
+ }
81
+ return value;
82
+ }
83
+
84
+ export async function verifyPackageIntegrity({ packageRoot = MODULE_ROOT } = {}) {
85
+ await assertPlainExisting(packageRoot, 'directory');
86
+ const packageJson = await readJsonFile(path.join(packageRoot, 'package.json'));
87
+ if (!isRecord(packageJson)
88
+ || packageJson.name !== PACKAGE_NAME
89
+ || packageJson.version !== PACKAGE_VERSION
90
+ || packageJson.engines?.node !== '>=20') {
91
+ throw new InstallerError('package-integrity-invalid');
92
+ }
93
+ const manifestPath = path.join(packageRoot, 'distribution', 'npm', 'package-manifest.json');
94
+ const manifest = await readJsonFile(manifestPath);
95
+ if (!exactKeys(manifest, ['schemaVersion', 'packageName', 'packageVersion', 'files'])
96
+ || manifest.schemaVersion !== 1
97
+ || manifest.packageName !== PACKAGE_NAME
98
+ || manifest.packageVersion !== PACKAGE_VERSION
99
+ || !Array.isArray(manifest.files)) {
100
+ throw new InstallerError('package-integrity-invalid');
101
+ }
102
+
103
+ const roots = [
104
+ path.join(packageRoot, 'bin'),
105
+ path.join(packageRoot, 'lib', 'npm-cli'),
106
+ path.join(packageRoot, 'distribution', 'npm'),
107
+ ];
108
+ const observedPaths = [];
109
+ for (const root of roots) observedPaths.push(...await enumerateFiles(packageRoot, root));
110
+ const manifestRelative = 'distribution/npm/package-manifest.json';
111
+ const manifestIndex = observedPaths.indexOf(manifestRelative);
112
+ if (manifestIndex < 0) throw new InstallerError('package-integrity-invalid');
113
+ observedPaths.splice(manifestIndex, 1);
114
+ observedPaths.sort((a, b) => a.localeCompare(b, 'en'));
115
+
116
+ const manifestPaths = [];
117
+ for (const entry of manifest.files) {
118
+ if (!exactKeys(entry, ['path', 'size', 'sha256'])
119
+ || typeof entry.path !== 'string'
120
+ || !SAFE_RELATIVE.test(entry.path)
121
+ || !Number.isSafeInteger(entry.size)
122
+ || entry.size < 0
123
+ || !SHA256.test(entry.sha256)) {
124
+ throw new InstallerError('package-integrity-invalid');
125
+ }
126
+ manifestPaths.push(entry.path);
127
+ }
128
+ if (JSON.stringify(manifestPaths) !== JSON.stringify(observedPaths)) {
129
+ throw new InstallerError('package-integrity-invalid');
130
+ }
131
+ for (const entry of manifest.files) {
132
+ const absolute = path.resolve(packageRoot, ...entry.path.split('/'));
133
+ if (!absolute.startsWith(`${path.resolve(packageRoot)}${path.sep}`)) throw new InstallerError('package-integrity-invalid');
134
+ await assertPlainExisting(absolute, 'file');
135
+ const bytes = await readFile(absolute);
136
+ if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) {
137
+ throw new InstallerError('package-integrity-invalid');
138
+ }
139
+ }
140
+
141
+ const skillsSource = path.join(packageRoot, 'distribution', 'npm', 'skills');
142
+ const desiredSkills = {};
143
+ for (const name of MANAGED_SKILLS) {
144
+ desiredSkills[name] = await snapshotPath(path.join(skillsSource, name), 'directory');
145
+ if (!isValidManagedSnapshot(desiredSkills[name])) throw new InstallerError('package-integrity-invalid');
146
+ }
147
+ const migrations = validateMigrationManifest(
148
+ await readJsonFile(path.join(packageRoot, 'distribution', 'npm', 'migrations.json')),
149
+ );
150
+ return { packageRoot, skillsSource, desiredSkills, migrations };
151
+ }
152
+
153
+ function validateLedger(value) {
154
+ if (!exactKeys(value, ['schemaVersion', 'packageName', 'packageVersion', 'skills'])
155
+ || value.schemaVersion !== 1
156
+ || value.packageName !== PACKAGE_NAME
157
+ || typeof value.packageVersion !== 'string'
158
+ || !exactKeys(value.skills, MANAGED_SKILLS)
159
+ || !MANAGED_SKILLS.every((name) => isValidManagedSnapshot(value.skills[name]))) return false;
160
+ return true;
161
+ }
162
+
163
+ export function createInstallLedger(desiredSkills) {
164
+ return {
165
+ schemaVersion: 1,
166
+ packageName: PACKAGE_NAME,
167
+ packageVersion: PACKAGE_VERSION,
168
+ skills: structuredClone(desiredSkills),
169
+ };
170
+ }
171
+
172
+ export async function classifyInstallation(skillsRoot, packageInfo) {
173
+ const observed = {};
174
+ for (const name of MANAGED_SKILLS) observed[name] = await snapshotPath(path.join(skillsRoot, name), 'directory');
175
+ const ledgerPath = path.join(skillsRoot, INSTALL_LEDGER);
176
+ const ledgerSnapshot = await snapshotPath(ledgerPath, 'file');
177
+ let ledger = null;
178
+ if (ledgerSnapshot.exists) {
179
+ try {
180
+ ledger = await readJsonFile(ledgerPath);
181
+ } catch {
182
+ throw new InstallerError('unknown-existing-files');
183
+ }
184
+ if (!validateLedger(ledger)) throw new InstallerError('unknown-existing-files');
185
+ }
186
+ const allAbsent = MANAGED_SKILLS.every((name) => observed[name].exists === false);
187
+ if (allAbsent && !ledger) return { kind: 'absent', observed, ledgerSnapshot };
188
+ if (allAbsent) throw new InstallerError('unknown-existing-files');
189
+
190
+ const exactDesired = MANAGED_SKILLS.every((name) => snapshotsEqual(observed[name], packageInfo.desiredSkills[name]));
191
+ if (exactDesired && !ledger) return { kind: 'current-unowned', observed, ledgerSnapshot };
192
+ if (ledger) {
193
+ const exactLedger = MANAGED_SKILLS.every((name) => snapshotsEqual(observed[name], ledger.skills[name]));
194
+ if (!exactLedger) throw new InstallerError('unknown-existing-files');
195
+ if (exactDesired && ledger.packageVersion === PACKAGE_VERSION) {
196
+ return { kind: 'current', observed, ledgerSnapshot, ledger };
197
+ }
198
+ }
199
+
200
+ for (const profile of packageInfo.migrations.profiles) {
201
+ if (MANAGED_SKILLS.every((name) => snapshotsEqual(observed[name], profile.skills[name]))) {
202
+ if (ledger && !MANAGED_SKILLS.every((name) => snapshotsEqual(ledger.skills[name], profile.skills[name]))) {
203
+ throw new InstallerError('unknown-existing-files');
204
+ }
205
+ return { kind: 'legacy', legacyVersion: profile.version, observed, ledgerSnapshot, ledger };
206
+ }
207
+ }
208
+ if (ledger) throw new InstallerError('untrusted-legacy-installation');
209
+ throw new InstallerError('unknown-existing-files');
210
+ }
211
+
212
+ export const __test = Object.freeze({ validateSnapshot: isValidManagedSnapshot, validateMigrationManifest, validateLedger });
@@ -0,0 +1,375 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ rename,
6
+ rm,
7
+ rmdir,
8
+ } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import {
11
+ INSTALL_LEDGER,
12
+ JOURNAL_FILE,
13
+ JOURNAL_SCHEMA_VERSION,
14
+ MANAGED_SKILLS,
15
+ PACKAGE_VERSION,
16
+ TRANSACTION_DIRECTORY,
17
+ } from './constants.mjs';
18
+ import { InstallerError } from './errors.mjs';
19
+ import {
20
+ assertPlainExisting,
21
+ atomicWriteJson,
22
+ copyPlainTree,
23
+ normalizePath,
24
+ pathExists,
25
+ readJsonFile,
26
+ removeVerified,
27
+ snapshotPath,
28
+ snapshotsEqual,
29
+ } from './filesystem.mjs';
30
+ import { createInstallLedger, isValidManagedSnapshot } from './package-integrity.mjs';
31
+
32
+ const TRANSACTION_ID = /^[0-9a-f-]{36}$/;
33
+ const ENTRY_NAMES = Object.freeze([...MANAGED_SKILLS, INSTALL_LEDGER]);
34
+
35
+ function isRecord(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function exactKeys(value, keys) {
40
+ return isRecord(value)
41
+ && Object.keys(value).length === keys.length
42
+ && keys.every((key) => Object.hasOwn(value, key));
43
+ }
44
+
45
+ function kindFor(name) {
46
+ return name === INSTALL_LEDGER ? 'file' : 'directory';
47
+ }
48
+
49
+ function locations(codexHome) {
50
+ const transactionRoot = path.join(codexHome, TRANSACTION_DIRECTORY);
51
+ return {
52
+ transactionRoot,
53
+ journalPath: path.join(transactionRoot, JOURNAL_FILE),
54
+ stageRoot: path.join(transactionRoot, 'stage'),
55
+ backupRoot: path.join(transactionRoot, 'backup'),
56
+ };
57
+ }
58
+
59
+ function targetPath(skillsRoot, name) {
60
+ return path.join(skillsRoot, name);
61
+ }
62
+
63
+ async function snapshotEntries(skillsRoot) {
64
+ const result = {};
65
+ for (const name of ENTRY_NAMES) result[name] = await snapshotPath(targetPath(skillsRoot, name), kindFor(name));
66
+ return result;
67
+ }
68
+
69
+ function entrySnapshotsEqual(left, right) {
70
+ return ENTRY_NAMES.every((name) => snapshotsEqual(left[name], right[name]));
71
+ }
72
+
73
+ function validateJournal(value, binding) {
74
+ const fields = [
75
+ 'schemaVersion', 'transactionId', 'operation', 'phase', 'packageVersion', 'codexHome', 'skillsRoot',
76
+ 'skillsRootExisted', 'originals', 'desired', 'moved', 'installed',
77
+ ];
78
+ if (!exactKeys(value, fields)
79
+ || value.schemaVersion !== JOURNAL_SCHEMA_VERSION
80
+ || !TRANSACTION_ID.test(value.transactionId)
81
+ || !['install', 'update', 'uninstall'].includes(value.operation)
82
+ || !['prepared', 'committing', 'verifying', 'committed', 'rolling-back', 'rolled-back'].includes(value.phase)
83
+ || value.packageVersion !== PACKAGE_VERSION
84
+ || normalizePath(value.codexHome) !== normalizePath(binding.codexHome)
85
+ || normalizePath(value.skillsRoot) !== normalizePath(binding.skillsRoot)
86
+ || typeof value.skillsRootExisted !== 'boolean'
87
+ || !exactKeys(value.originals, ENTRY_NAMES)
88
+ || !exactKeys(value.desired, ENTRY_NAMES)
89
+ || !Array.isArray(value.moved)
90
+ || !Array.isArray(value.installed)
91
+ || value.moved.some((name) => !ENTRY_NAMES.includes(name))
92
+ || value.installed.some((name) => !ENTRY_NAMES.includes(name))
93
+ || new Set(value.moved).size !== value.moved.length
94
+ || new Set(value.installed).size !== value.installed.length) {
95
+ throw new InstallerError('recovery-required', { recoveryRequired: true });
96
+ }
97
+ for (const name of ENTRY_NAMES) {
98
+ const original = value.originals[name];
99
+ const desired = value.desired[name];
100
+ const valid = name === INSTALL_LEDGER
101
+ ? [original, desired].every((snapshot) => exactKeys(snapshot, ['exists', 'kind', 'entries'])
102
+ && snapshot.kind === 'file'
103
+ && typeof snapshot.exists === 'boolean'
104
+ && Array.isArray(snapshot.entries)
105
+ && ((!snapshot.exists && snapshot.entries.length === 0)
106
+ || (snapshot.exists && snapshot.entries.length === 1
107
+ && snapshot.entries[0]?.path === '.'
108
+ && snapshot.entries[0]?.type === 'file'
109
+ && Number.isSafeInteger(snapshot.entries[0]?.size)
110
+ && /^[0-9a-f]{64}$/.test(snapshot.entries[0]?.sha256 ?? ''))))
111
+ : [original, desired].every((snapshot) => (
112
+ (!snapshot?.exists && exactKeys(snapshot, ['exists', 'kind', 'entries'])
113
+ && snapshot.kind === 'directory' && snapshot.entries.length === 0)
114
+ || isValidManagedSnapshot(snapshot)
115
+ ));
116
+ if (!valid) {
117
+ throw new InstallerError('recovery-required', { recoveryRequired: true });
118
+ }
119
+ }
120
+ return value;
121
+ }
122
+
123
+ async function persistJournal(state) {
124
+ await atomicWriteJson(state.journalPath, {
125
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
126
+ transactionId: state.transactionId,
127
+ operation: state.operation,
128
+ phase: state.phase,
129
+ packageVersion: PACKAGE_VERSION,
130
+ codexHome: state.codexHome,
131
+ skillsRoot: state.skillsRoot,
132
+ skillsRootExisted: state.skillsRootExisted,
133
+ originals: state.originals,
134
+ desired: state.desired,
135
+ moved: state.moved,
136
+ installed: state.installed,
137
+ });
138
+ }
139
+
140
+ async function runHook(hooks, name, context) {
141
+ if (typeof hooks?.[name] === 'function') await hooks[name](context);
142
+ }
143
+
144
+ async function verifyDesired(state) {
145
+ const observed = await snapshotEntries(state.skillsRoot);
146
+ return entrySnapshotsEqual(observed, state.desired);
147
+ }
148
+
149
+ async function verifyOriginals(state) {
150
+ const observed = await snapshotEntries(state.skillsRoot);
151
+ return entrySnapshotsEqual(observed, state.originals);
152
+ }
153
+
154
+ async function restoreOne(state, name) {
155
+ const original = state.originals[name];
156
+ const desired = state.desired[name];
157
+ const target = targetPath(state.skillsRoot, name);
158
+ const backup = path.join(state.backupRoot, name);
159
+ const observed = await snapshotPath(target, kindFor(name));
160
+ const backupObserved = await snapshotPath(backup, kindFor(name));
161
+
162
+ if (original.exists) {
163
+ if (snapshotsEqual(observed, original)) return true;
164
+ if (!snapshotsEqual(backupObserved, original)) return false;
165
+ if (observed.exists) {
166
+ if (!snapshotsEqual(observed, desired)) return false;
167
+ await removeVerified(target, desired);
168
+ }
169
+ await rename(backup, target);
170
+ return snapshotsEqual(await snapshotPath(target, kindFor(name)), original);
171
+ }
172
+
173
+ if (!observed.exists) return true;
174
+ if (!snapshotsEqual(observed, desired)) return false;
175
+ await removeVerified(target, desired);
176
+ return !(await pathExists(target));
177
+ }
178
+
179
+ async function rollback(state, hooks = {}) {
180
+ state.phase = 'rolling-back';
181
+ await persistJournal(state).catch(() => {});
182
+ await runHook(hooks, 'beforeRollback', state);
183
+ for (const name of [...ENTRY_NAMES].reverse()) {
184
+ if (!(await restoreOne(state, name))) return false;
185
+ }
186
+ if (!(await verifyOriginals(state))) return false;
187
+ if (!state.skillsRootExisted) {
188
+ const children = await readdir(state.skillsRoot).catch(() => null);
189
+ if (children?.length === 0) await rmdir(state.skillsRoot);
190
+ }
191
+ state.phase = 'rolled-back';
192
+ await persistJournal(state).catch(() => {});
193
+ await runHook(hooks, 'afterRollback', state);
194
+ return true;
195
+ }
196
+
197
+ async function cleanupTransaction(transactionRoot) {
198
+ await rm(transactionRoot, { recursive: true, force: false });
199
+ }
200
+
201
+ async function stateFromJournal(binding, journal) {
202
+ const roots = locations(binding.codexHome);
203
+ return {
204
+ ...roots,
205
+ ...journal,
206
+ backupRoot: roots.backupRoot,
207
+ };
208
+ }
209
+
210
+ export async function recoverPendingTransaction({ codexHome, skillsRoot }, dependencies = {}) {
211
+ const roots = locations(codexHome);
212
+ if (!(await pathExists(roots.transactionRoot))) return { status: 'none' };
213
+ try {
214
+ await assertPlainExisting(roots.transactionRoot, 'directory');
215
+ await assertPlainExisting(roots.journalPath, 'file');
216
+ const journal = validateJournal(await readJsonFile(roots.journalPath), { codexHome, skillsRoot });
217
+ const state = await stateFromJournal({ codexHome, skillsRoot }, journal);
218
+ if (journal.phase === 'committed') {
219
+ if (!(await verifyDesired(state))) throw new InstallerError('recovery-required', { recoveryRequired: true });
220
+ await cleanupTransaction(roots.transactionRoot);
221
+ return { status: 'committed-cleaned' };
222
+ }
223
+ const restored = await rollback(state, dependencies.hooks);
224
+ if (!restored) throw new InstallerError('recovery-required', { recoveryRequired: true });
225
+ await cleanupTransaction(roots.transactionRoot);
226
+ return { status: 'rolled-back' };
227
+ } catch (error) {
228
+ if (error instanceof InstallerError && error.recoveryRequired) throw error;
229
+ throw new InstallerError('recovery-required', { recoveryRequired: true });
230
+ }
231
+ }
232
+
233
+ async function prepareTransaction({ operation, codexHome, skillsRoot, packageInfo }) {
234
+ if (normalizePath(path.dirname(skillsRoot)) !== normalizePath(codexHome)) {
235
+ throw new InstallerError('codex-root-untrusted');
236
+ }
237
+ await assertPlainExisting(codexHome, 'directory');
238
+ const roots = locations(codexHome);
239
+ if (await pathExists(roots.transactionRoot)) throw new InstallerError('transaction-pending');
240
+ const skillsRootExisted = await pathExists(skillsRoot);
241
+ if (skillsRootExisted) await assertPlainExisting(skillsRoot, 'directory');
242
+ const originals = await snapshotEntries(skillsRoot);
243
+
244
+ try {
245
+ await mkdir(roots.transactionRoot, { recursive: false });
246
+ await mkdir(roots.stageRoot, { recursive: false });
247
+ await mkdir(roots.backupRoot, { recursive: false });
248
+ const desired = {};
249
+ if (operation !== 'uninstall') {
250
+ for (const name of MANAGED_SKILLS) {
251
+ const destination = path.join(roots.stageRoot, name);
252
+ await copyPlainTree(path.join(packageInfo.skillsSource, name), destination);
253
+ desired[name] = await snapshotPath(destination, 'directory');
254
+ if (!snapshotsEqual(desired[name], packageInfo.desiredSkills[name])) {
255
+ throw new InstallerError('package-integrity-invalid');
256
+ }
257
+ }
258
+ const ledger = createInstallLedger(packageInfo.desiredSkills);
259
+ await atomicWriteJson(path.join(roots.stageRoot, INSTALL_LEDGER), ledger);
260
+ desired[INSTALL_LEDGER] = await snapshotPath(path.join(roots.stageRoot, INSTALL_LEDGER), 'file');
261
+ } else {
262
+ for (const name of MANAGED_SKILLS) desired[name] = { exists: false, kind: 'directory', entries: [] };
263
+ desired[INSTALL_LEDGER] = { exists: false, kind: 'file', entries: [] };
264
+ }
265
+ const state = {
266
+ ...roots,
267
+ operation,
268
+ transactionId: randomUUID(),
269
+ packageVersion: PACKAGE_VERSION,
270
+ phase: 'prepared',
271
+ codexHome,
272
+ skillsRoot,
273
+ skillsRootExisted,
274
+ originals,
275
+ desired,
276
+ moved: [],
277
+ installed: [],
278
+ };
279
+ await persistJournal(state);
280
+ return state;
281
+ } catch (error) {
282
+ await rm(roots.transactionRoot, { recursive: true, force: true }).catch(() => {});
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ async function commitTransaction(state, hooks = {}) {
288
+ const drift = await snapshotEntries(state.skillsRoot);
289
+ if (!entrySnapshotsEqual(drift, state.originals)) throw new InstallerError('target-drift');
290
+ await runHook(hooks, 'beforeCommit', state);
291
+ const afterHook = await snapshotEntries(state.skillsRoot);
292
+ if (!entrySnapshotsEqual(afterHook, state.originals)) throw new InstallerError('target-drift');
293
+ state.phase = 'committing';
294
+ await persistJournal(state);
295
+ if (!state.skillsRootExisted) await mkdir(state.skillsRoot, { recursive: false });
296
+ for (const name of ENTRY_NAMES) {
297
+ const target = targetPath(state.skillsRoot, name);
298
+ if (state.originals[name].exists) {
299
+ await rename(target, path.join(state.backupRoot, name));
300
+ state.moved.push(name);
301
+ await persistJournal(state);
302
+ }
303
+ if (state.desired[name].exists) {
304
+ await rename(path.join(state.stageRoot, name), target);
305
+ state.installed.push(name);
306
+ await persistJournal(state);
307
+ }
308
+ await runHook(hooks, `after:${name}`, state);
309
+ }
310
+ if (!(await verifyDesired(state))) throw new InstallerError('transaction-failed', { mutated: true });
311
+ state.phase = 'verifying';
312
+ await persistJournal(state);
313
+ }
314
+
315
+ export async function finalizeInstallTransaction(state) {
316
+ if (state.phase !== 'verifying' || !(await verifyDesired(state))) {
317
+ throw new InstallerError('recovery-required', { mutated: true, recoveryRequired: true });
318
+ }
319
+ state.phase = 'committed';
320
+ await persistJournal(state);
321
+ try {
322
+ await cleanupTransaction(state.transactionRoot);
323
+ return { cleanupWarning: null };
324
+ } catch {
325
+ return { cleanupWarning: 'cleanup-warning' };
326
+ }
327
+ }
328
+
329
+ export async function abortInstallTransaction(state, dependencies = {}) {
330
+ const restored = await rollback(state, dependencies.hooks).catch(() => false);
331
+ if (!restored) throw new InstallerError('recovery-required', { mutated: true, recoveryRequired: true });
332
+ await cleanupTransaction(state.transactionRoot).catch(() => {});
333
+ return { rollback: 'completed' };
334
+ }
335
+
336
+ export async function executeInstallTransaction({ operation, codexHome, skillsRoot, packageInfo }, dependencies = {}) {
337
+ let state;
338
+ try {
339
+ state = await prepareTransaction({ operation, codexHome, skillsRoot, packageInfo });
340
+ await runHook(dependencies.hooks, 'afterStage', state);
341
+ await commitTransaction(state, dependencies.hooks);
342
+ if (dependencies.deferFinalize === true) {
343
+ return {
344
+ status: operation === 'uninstall' ? 'removed' : 'completed',
345
+ rollback: 'not-needed',
346
+ cleanupWarning: null,
347
+ transactionHandle: state,
348
+ };
349
+ }
350
+ const finalized = await finalizeInstallTransaction(state);
351
+ return {
352
+ status: operation === 'uninstall' ? 'removed' : 'completed',
353
+ rollback: 'not-needed',
354
+ cleanupWarning: finalized.cleanupWarning,
355
+ };
356
+ } catch (error) {
357
+ if (!state) throw error;
358
+ const mutationStarted = state.phase === 'committing' || state.moved.length > 0 || state.installed.length > 0;
359
+ if (!mutationStarted) {
360
+ await cleanupTransaction(state.transactionRoot).catch(() => {});
361
+ throw error;
362
+ }
363
+ const restored = await rollback(state, dependencies.hooks).catch(() => false);
364
+ if (!restored) throw new InstallerError('recovery-required', { mutated: true, recoveryRequired: true });
365
+ await cleanupTransaction(state.transactionRoot).catch(() => {});
366
+ return { status: 'failed', code: error?.code ?? 'transaction-failed', rollback: 'completed', cleanupWarning: null };
367
+ }
368
+ }
369
+
370
+ export const __test = Object.freeze({
371
+ validateJournal,
372
+ snapshotEntries,
373
+ entrySnapshotsEqual,
374
+ locations,
375
+ });