@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.
- package/bin/answerme-toolkit.mjs +6 -0
- package/distribution/npm/migrations.json +605 -0
- package/distribution/npm/package-manifest.json +192 -0
- package/distribution/npm/skills/answerme/SKILL.md +94 -0
- package/distribution/npm/skills/answerme/agents/openai.yaml +4 -0
- package/distribution/npm/skills/answerme/references/api.md +135 -0
- package/distribution/npm/skills/answerme/references/creator-credential-deployment.md +19 -0
- package/distribution/npm/skills/answerme/references/creator-credential-recovery.md +24 -0
- package/distribution/npm/skills/answerme/references/errors.md +44 -0
- package/distribution/npm/skills/answerme/references/handoff.md +46 -0
- package/distribution/npm/skills/answerme/references/install-self-test.md +28 -0
- package/distribution/npm/skills/answerme/references/result-token-store.md +50 -0
- package/distribution/npm/skills/answerme/references/templates.md +139 -0
- package/distribution/npm/skills/answerme/scripts/answerme-api-base-url.ps1 +40 -0
- package/distribution/npm/skills/answerme/scripts/create-answerme.ps1 +1892 -0
- package/distribution/npm/skills/answerme/scripts/creator-credential-store.windows.ps1 +503 -0
- package/distribution/npm/skills/answerme/scripts/deploy-answerme-creator-credential.ps1 +447 -0
- package/distribution/npm/skills/answerme/scripts/enroll-answerme-creator.ps1 +764 -0
- package/distribution/npm/skills/answerme/scripts/open-answerme-page.windows.ps1 +272 -0
- package/distribution/npm/skills/answerme/scripts/remove-answerme-result-token.ps1 +63 -0
- package/distribution/npm/skills/answerme/scripts/result-token-store.windows.ps1 +261 -0
- package/distribution/npm/skills/answerme/scripts/test-answerme-installation.ps1 +498 -0
- package/distribution/npm/skills/answerme/scripts/wait-answerme-result.ps1 +908 -0
- package/distribution/npm/skills/answerme/scripts/windows-crypto.ps1 +57 -0
- package/distribution/npm/skills/answerme/scripts/windows-http.ps1 +45 -0
- package/distribution/npm/skills/answerme/scripts/windows-process-start-info.ps1 +76 -0
- package/distribution/npm/skills/ask-when-needed/SKILL.md +164 -0
- package/distribution/npm/skills/ask-when-needed/agents/openai.yaml +4 -0
- package/distribution/npm/skills/ask-when-needed/references/interview-strategies.md +43 -0
- package/lib/npm-cli/commands.mjs +247 -0
- package/lib/npm-cli/constants.mjs +51 -0
- package/lib/npm-cli/errors.mjs +15 -0
- package/lib/npm-cli/filesystem.mjs +193 -0
- package/lib/npm-cli/host-discovery.mjs +404 -0
- package/lib/npm-cli/main.mjs +42 -0
- package/lib/npm-cli/package-integrity.mjs +212 -0
- package/lib/npm-cli/transaction.mjs +375 -0
- package/lib/npm-cli/usage-validation.mjs +349 -0
- 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
|
+
});
|