aimship 0.0.0-stage → 0.1.0-preview.7
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/CONTRIBUTING.md +33 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/README.zh-CN.md +90 -0
- package/bin/aimship.mjs +17 -0
- package/docs/cli.md +82 -0
- package/docs/examples.md +5 -0
- package/docs/guide.md +277 -0
- package/docs/guide.zh-CN.md +277 -0
- package/docs/installation.md +24 -0
- package/lib/cli.mjs +171 -0
- package/lib/documents.mjs +155 -0
- package/lib/install.mjs +363 -0
- package/lib/io.mjs +55 -0
- package/lib/migrate.mjs +77 -0
- package/lib/state.mjs +276 -0
- package/lib/view.mjs +69 -0
- package/package.json +43 -4
- package/skills/aimship/SKILL.md +19 -0
- package/skills/aimship/agents/openai.yaml +6 -0
- package/skills/aimship/references/artifact-map.json +163 -0
- package/skills/aimship/references/core.md +88 -0
- package/skills/aimship/references/document-patterns.md +39 -0
- package/skills/aimship/references/hosts.md +17 -0
- package/skills/aimship/references/recovery.md +21 -0
- package/skills/aimship/references/sources-and-changes.md +34 -0
- package/skills/aimship/references/stages/contract.md +23 -0
- package/skills/aimship/references/stages/diagnose.md +25 -0
- package/skills/aimship/references/stages/frame.md +23 -0
- package/skills/aimship/references/stages/handoff.md +23 -0
- package/skills/aimship/references/stages/implement.md +25 -0
- package/skills/aimship/references/stages/plan.md +23 -0
- package/skills/aimship/references/stages/prototype.md +23 -0
- package/skills/aimship/references/stages/requirements.md +23 -0
- package/skills/aimship/references/stages/review.md +23 -0
- package/skills/aimship/references/stages/scope.md +23 -0
- package/skills/aimship/references/stages/tech-design.md +25 -0
- package/skills/aimship/references/stages/test-design.md +23 -0
- package/skills/aimship/references/stages/verify.md +25 -0
- package/skills/aimship/references/templates/change-log.md +14 -0
- package/skills/aimship/references/templates/diagnosis.md +39 -0
- package/skills/aimship/references/templates/handoff.md +34 -0
- package/skills/aimship/references/templates/plan.md +34 -0
- package/skills/aimship/references/templates/prd.md +39 -0
- package/skills/aimship/references/templates/review.md +19 -0
- package/skills/aimship/references/templates/scope.md +39 -0
- package/skills/aimship/references/templates/tech-design.md +39 -0
- package/skills/aimship/references/templates/testing.md +34 -0
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { atomicWrite, exists, fail, fileHash, hash, inside, json, listFiles, noSymlinks, readJSON, withLock } from './io.mjs';
|
|
6
|
+
|
|
7
|
+
export const HOSTS = ['codex', 'cursor', 'claude-code'];
|
|
8
|
+
const validVersion = version => typeof version === 'string' && /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version);
|
|
9
|
+
const REQUIRED_RELEASE_FILES = ['package.json', 'bin/aimship.mjs', 'lib/cli.mjs', 'lib/install.mjs', 'lib/state.mjs', 'lib/io.mjs', 'skills/aimship/SKILL.md', 'skills/aimship/references/core.md'];
|
|
10
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
12
|
+
function relativeFile(file) { return typeof file === 'string' && file.length > 0 && !path.isAbsolute(file) && !/^[/\\]|^[a-zA-Z]:/.test(file) && !file.split(/[\\/]/).some(part => part === '..' || part === ''); }
|
|
13
|
+
function managedFile(file) {
|
|
14
|
+
const normalized = file.replaceAll('\\', '/');
|
|
15
|
+
return relativeFile(file) && (normalized === '.aimship/bin/aimship.mjs' || /^\.(agents|claude|cursor)\/skills\/aimship\/(SKILL\.md|agents\/.+)$/.test(normalized));
|
|
16
|
+
}
|
|
17
|
+
function validateManifest(manifest) {
|
|
18
|
+
if (!object(manifest) || manifest.schemaVersion !== 1 || manifest.product !== 'aimship' || !object(manifest.managed) || !object(manifest.releases) || !Array.isArray(manifest.hosts)) fail('INSTALL_FORMAT', 'Unknown or invalid installation manifest.', 'Preserve the installation and use a compatible installer.');
|
|
19
|
+
for (const [file, value] of Object.entries({ ...manifest.managed, ...(manifest.pending?.managed ?? {}) })) if (!managedFile(file) || !digest(value)) fail('INSTALL_FORMAT', 'Unsafe managed path or digest in installation manifest.', 'Inspect the manifest before proceeding.');
|
|
20
|
+
for (const [version, release] of Object.entries(manifest.releases)) if (!validVersion(version) || !object(release) || !object(release.files) || !REQUIRED_RELEASE_FILES.every(file => digest(release.files[file])) || !Object.entries(release.files).every(([file, value]) => relativeFile(file) && digest(value))) fail('INSTALL_FORMAT', 'Unsafe or incomplete release version/file list in manifest.', 'Inspect the manifest before proceeding.');
|
|
21
|
+
if (!['empty', 'pending', 'complete', 'uninstalled'].includes(manifest.status)) fail('INSTALL_FORMAT', 'Unknown installation status in manifest.', 'Use a compatible installer and preserve this manifest.');
|
|
22
|
+
if (!manifest.hosts.every(x => HOSTS.includes(x))) fail('INSTALL_FORMAT', 'Unknown registered host.', 'Use a compatible installer.');
|
|
23
|
+
if (manifest.activeVersion != null && !validVersion(manifest.activeVersion)) fail('INSTALL_FORMAT', 'Invalid recorded active version.', 'Inspect the retained manifest.');
|
|
24
|
+
if (manifest.activeVersion != null && !digest(manifest.activeDigest)) fail('INSTALL_FORMAT', 'Missing or invalid active-pointer digest.', 'Inspect the retained manifest before changing the installation.');
|
|
25
|
+
if (manifest.pending && (!object(manifest.pending) || !validVersion(manifest.pending.version) || !Array.isArray(manifest.pending.hosts) || !manifest.pending.hosts.every(host => HOSTS.includes(host)) || !object(manifest.pending.managed))) fail('INSTALL_FORMAT', 'Invalid pending installation record.', 'Preserve the manifest and recover with the exact installer.');
|
|
26
|
+
return manifest;
|
|
27
|
+
}
|
|
28
|
+
export async function readConfig(file) {
|
|
29
|
+
await noSymlinks(file);
|
|
30
|
+
const recovery = 'Preserve the existing config and delivery data. Restore a supported config.json with schemaVersion 1 and an absolute dataRoot, then retry; do not silently replace it with defaults.';
|
|
31
|
+
let config;
|
|
32
|
+
try { config = await readJSON(file); } catch (error) { fail('CONFIG_READ', 'Cannot read the retained installation config.', recovery, { configPath: file, cause: error.code }); }
|
|
33
|
+
if (!object(config) || config.schemaVersion !== 1 || typeof config.dataRoot !== 'string' || !config.dataRoot.trim() || config.dataRoot.includes('\0') || !path.isAbsolute(config.dataRoot)) fail('CONFIG_FORMAT', 'Unsupported config schema or invalid dataRoot.', recovery, { configPath: file });
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
export function installPaths(home = os.homedir()) {
|
|
37
|
+
const absolute = path.resolve(home); const root = path.join(absolute, '.aimship');
|
|
38
|
+
return { home: absolute, root, active: path.join(root, 'active.json'), manifest: path.join(root, 'install-manifest.json'), config: path.join(root, 'config.json'), launcher: path.join(root, 'bin', 'aimship.mjs'), lock: path.join(root, '.install.lock') };
|
|
39
|
+
}
|
|
40
|
+
async function inspectJSON(file, validate = value => value) {
|
|
41
|
+
try {
|
|
42
|
+
await noSymlinks(file);
|
|
43
|
+
if (!await exists(file)) return { status: 'missing', path: file, value: null };
|
|
44
|
+
return { status: 'valid', path: file, value: validate(await readJSON(file)) };
|
|
45
|
+
} catch (error) { return { status: 'invalid', path: file, value: null, error: { code: error.code ?? 'IO_ERROR', message: error.message, recovery: error.recovery ?? 'Inspect this managed file before making changes.' } }; }
|
|
46
|
+
}
|
|
47
|
+
function validateActive(active) {
|
|
48
|
+
if (!object(active) || active.launcherProtocol !== 1 || !validVersion(active.version)) fail('INSTALL_FORMAT', 'Invalid or unsupported active pointer.', 'Restore the active pointer using the exact package install.');
|
|
49
|
+
return active;
|
|
50
|
+
}
|
|
51
|
+
export async function installationVersion(options = {}) {
|
|
52
|
+
const p = installPaths(options.home);
|
|
53
|
+
const manifest = await inspectJSON(p.manifest, validateManifest);
|
|
54
|
+
const active = await inspectJSON(p.active, validateActive);
|
|
55
|
+
if (active.status === 'valid' && manifest.status === 'valid') active.integrity = [manifest.value.activeDigest, manifest.value.pending?.activeDigest].filter(Boolean).includes(await fileHash(p.active)) ? 'intact' : 'modified';
|
|
56
|
+
const pending = manifest.value?.pending ?? null;
|
|
57
|
+
let installation = 'not-installed';
|
|
58
|
+
if (manifest.status === 'invalid' || active.status === 'invalid' || active.integrity === 'modified') installation = 'damaged';
|
|
59
|
+
else if (manifest.status === 'missing') installation = active.status === 'valid' || await exists(p.launcher) ? 'unmanaged' : 'not-installed';
|
|
60
|
+
else if (pending) installation = 'incomplete';
|
|
61
|
+
else if (manifest.value.status === 'uninstalled' && active.status === 'missing') installation = 'uninstalled';
|
|
62
|
+
else if (active.status === 'valid' && active.value.version === manifest.value.activeVersion) installation = 'installed';
|
|
63
|
+
else installation = 'damaged';
|
|
64
|
+
return { installation, installedActiveVersion: active.value?.version ?? null, recordedActiveVersion: manifest.value?.activeVersion ?? null, pending, manifest, active, paths: p };
|
|
65
|
+
}
|
|
66
|
+
async function inspectManagedFile(file, expected, pendingExpected) {
|
|
67
|
+
try {
|
|
68
|
+
await noSymlinks(file);
|
|
69
|
+
if (!await exists(file)) return { path: file, present: false, integrity: 'missing' };
|
|
70
|
+
if (!(await fs.lstat(file)).isFile()) return { path: file, present: true, integrity: 'invalid-file-type' };
|
|
71
|
+
const actual = expected || pendingExpected ? await fileHash(file) : null;
|
|
72
|
+
return { path: file, present: true, integrity: actual && actual === expected ? 'intact' : (actual && actual === pendingExpected ? 'pending' : (expected ? 'modified' : 'unmanaged')) };
|
|
73
|
+
} catch (error) { return { path: file, present: null, integrity: 'inaccessible', error: { code: error.code ?? 'IO_ERROR', message: error.message } }; }
|
|
74
|
+
}
|
|
75
|
+
export async function installationStatus(options = {}) {
|
|
76
|
+
const version = await installationVersion(options); const p = version.paths; const manifest = version.manifest.value;
|
|
77
|
+
const issues = [];
|
|
78
|
+
for (const report of [version.manifest, version.active]) if (report.status === 'invalid') issues.push({ path: report.path, ...report.error });
|
|
79
|
+
if (version.installation === 'damaged' && !issues.length) issues.push({ code: 'ACTIVE_MISMATCH', message: 'The active pointer is missing or differs from the recorded activation.' });
|
|
80
|
+
if (version.installation === 'unmanaged') issues.push({ code: 'UNOWNED_INSTALL', message: 'Fixed installation files exist without a valid Aimship manifest.' });
|
|
81
|
+
const launcherKey = path.join('.aimship', 'bin', 'aimship.mjs');
|
|
82
|
+
const launcher = await inspectManagedFile(p.launcher, manifest?.managed[launcherKey], manifest?.pending?.managed?.[launcherKey]);
|
|
83
|
+
if (manifest?.activeVersion && launcher.integrity !== 'intact') issues.push({ code: 'LAUNCHER_INTEGRITY', path: p.launcher, integrity: launcher.integrity });
|
|
84
|
+
const managedFiles = [];
|
|
85
|
+
if (manifest) for (const file of new Set([...Object.keys(manifest.managed), ...Object.keys(manifest.pending?.managed ?? {})])) managedFiles.push(await inspectManagedFile(path.join(p.home, file), manifest.managed[file], manifest.pending?.managed?.[file]));
|
|
86
|
+
for (const report of managedFiles) if (report.integrity !== 'intact') issues.push({ code: report.integrity === 'pending' ? 'PENDING_FILE' : 'MANAGED_FILE_INTEGRITY', path: report.path, integrity: report.integrity });
|
|
87
|
+
let config;
|
|
88
|
+
if (!await exists(p.config)) config = { path: p.config, status: 'missing', dataRoot: null };
|
|
89
|
+
else {
|
|
90
|
+
try { config = { path: p.config, status: 'valid', dataRoot: (await readConfig(p.config)).dataRoot }; }
|
|
91
|
+
catch (error) { config = { path: p.config, status: 'invalid', dataRoot: null, error: { code: error.code ?? 'IO_ERROR', message: error.message, recovery: error.recovery } }; issues.push({ path: p.config, ...config.error }); }
|
|
92
|
+
}
|
|
93
|
+
if (manifest?.activeVersion && config.status === 'missing') issues.push({ code: 'CONFIG_MISSING', path: p.config });
|
|
94
|
+
let release = null;
|
|
95
|
+
if (version.installedActiveVersion) {
|
|
96
|
+
const files = manifest?.releases[version.installedActiveVersion]?.files;
|
|
97
|
+
const releaseRoot = path.join(p.root, 'releases', version.installedActiveVersion);
|
|
98
|
+
const reports = [];
|
|
99
|
+
if (files) for (const [file, expected] of Object.entries(files)) reports.push(await inspectManagedFile(path.join(releaseRoot, file), expected));
|
|
100
|
+
release = { version: version.installedActiveVersion, root: releaseRoot, integrity: files ? (reports.every(x => x.integrity === 'intact') ? 'intact' : 'damaged') : 'unknown', files: reports };
|
|
101
|
+
if (release.integrity !== 'intact') issues.push({ code: 'RELEASE_INTEGRITY', path: releaseRoot, integrity: release.integrity });
|
|
102
|
+
}
|
|
103
|
+
const hosts = [];
|
|
104
|
+
for (const host of HOSTS) {
|
|
105
|
+
const directory = hostDirectories([host])[0]; const entryPath = path.join(p.home, directory, 'SKILL.md');
|
|
106
|
+
const entry = managedFiles.find(x => x.path === entryPath) ?? await inspectManagedFile(entryPath, undefined);
|
|
107
|
+
const registered = manifest ? manifest.hosts.includes(host) : null;
|
|
108
|
+
const entryFiles = managedFiles.filter(file => file.path.startsWith(path.dirname(entryPath) + path.sep));
|
|
109
|
+
const entryIntegrity = entry.integrity === 'intact' && entryFiles.some(file => file.integrity !== 'intact') ? 'damaged' : entry.integrity;
|
|
110
|
+
if (registered && entryIntegrity !== 'intact') issues.push({ code: 'HOST_ENTRY_INTEGRITY', host, path: entryPath, integrity: entryIntegrity });
|
|
111
|
+
hosts.push({ host, registered, entryPath, entryPresent: entry.present, entryIntegrity, sharedWith: HOSTS.filter(other => other !== host && hostDirectories([other])[0] === directory), hostLoaded: 'not-verified', login: 'not-verified', permissions: 'not-verified' });
|
|
112
|
+
}
|
|
113
|
+
const unselectedHostDiscoveryPossible = hosts.some(x => x.registered !== true) && hosts.some(x => x.entryPresent === true);
|
|
114
|
+
return { installation: version.installation === 'installed' && issues.length ? 'damaged' : version.installation, healthy: version.installation === 'installed' && issues.length === 0, installedActiveVersion: version.installedActiveVersion, recordedActiveVersion: version.recordedActiveVersion, pending: version.pending, registeredHosts: manifest ? manifest.hosts : null, hosts, unselectedHostDiscoveryPossible, manifest: { path: p.manifest, status: version.manifest.status, recordedStatus: manifest?.status ?? null }, active: { path: p.active, status: version.active.status, integrity: version.active.integrity ?? 'unknown' }, launcher, config, customPath: path.join(p.root, 'custom'), release, issues, scope: 'Only Aimship manifest paths and fixed installation/entry files were inspected. Delivery data, custom contents, host loading, login, and permissions were not inspected.', discovery: 'Host discovery can expose a shared entry to an unregistered host. Registration and file presence do not prove host loading or access.' };
|
|
115
|
+
}
|
|
116
|
+
export async function releaseFiles(source) {
|
|
117
|
+
const pkg = await readJSON(path.join(source, 'package.json'));
|
|
118
|
+
if (pkg.name !== 'aimship' || !validVersion(pkg.version) || !Array.isArray(pkg.files)) fail('INVALID_RELEASE', 'Release package metadata is invalid.', 'Use a complete Aimship package.');
|
|
119
|
+
const files = new Set(['package.json']);
|
|
120
|
+
for (const entry of pkg.files) {
|
|
121
|
+
if (typeof entry !== 'string' || !entry || path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..') || /[*?{}!]/.test(entry)) fail('INVALID_RELEASE', 'Package files must be explicit in-root files or directories.', 'Review package.json files.');
|
|
122
|
+
const target = path.join(source, entry); if (!await exists(target)) continue;
|
|
123
|
+
await noSymlinks(target);
|
|
124
|
+
if ((await fs.lstat(target)).isDirectory()) for (const name of await listFiles(target)) files.add(path.join(entry, name));
|
|
125
|
+
else files.add(entry);
|
|
126
|
+
}
|
|
127
|
+
const result = {};
|
|
128
|
+
for (const name of [...files].sort()) {
|
|
129
|
+
if (path.basename(name) === '.DS_Store' || path.basename(name).startsWith('._')) continue;
|
|
130
|
+
if (!inside(source, path.resolve(source, name))) fail('INVALID_RELEASE', 'Package path escapes root.', 'Use ordinary in-root files.');
|
|
131
|
+
result[name] = await fileHash(path.join(source, name));
|
|
132
|
+
}
|
|
133
|
+
for (const required of REQUIRED_RELEASE_FILES) if (!result[required]) fail('INCOMPLETE_RELEASE', `Required release file is missing: ${required}`, 'Use the complete package; do not install partial source.');
|
|
134
|
+
return { pkg, files: result };
|
|
135
|
+
}
|
|
136
|
+
const launcherText = `#!/usr/bin/env node
|
|
137
|
+
// Managed by Aimship. Local Node is required; this launcher never uses npm cache.
|
|
138
|
+
import fs from 'node:fs/promises';
|
|
139
|
+
import path from 'node:path';
|
|
140
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
141
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
142
|
+
try {
|
|
143
|
+
let version;
|
|
144
|
+
const valid = value => typeof value === 'string' && /^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(value);
|
|
145
|
+
try {
|
|
146
|
+
const active = JSON.parse(await fs.readFile(path.join(root, 'active.json'), 'utf8'));
|
|
147
|
+
if (active.launcherProtocol !== 1 || !valid(active.version)) throw new Error('Unsupported or invalid active pointer.');
|
|
148
|
+
version = active.version;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (process.argv[2] !== 'status' && !process.argv.slice(2).includes('--version')) throw error;
|
|
151
|
+
const manifest = JSON.parse(await fs.readFile(path.join(root, 'install-manifest.json'), 'utf8'));
|
|
152
|
+
const recorded = manifest.activeVersion ?? manifest.pending?.version;
|
|
153
|
+
if (manifest.schemaVersion !== 1 || manifest.product !== 'aimship' || !valid(recorded)) throw new Error('Cannot locate a diagnostic tool safely. Run status from the exact package installer.');
|
|
154
|
+
version = recorded;
|
|
155
|
+
}
|
|
156
|
+
const cli = path.join(root, 'releases', version, 'bin', 'aimship.mjs');
|
|
157
|
+
const { main } = await import(pathToFileURL(cli).href);
|
|
158
|
+
await main(process.argv.slice(2), { installRoot: root });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { code: 'LAUNCHER_FAILURE', message: error.message, recovery: 'Run the exact local package install again. Preserve delivery data and custom files.' } }) + '\\n');
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
}
|
|
163
|
+
`;
|
|
164
|
+
function hostDirectories(hosts) {
|
|
165
|
+
const dirs = new Set();
|
|
166
|
+
if (hosts.includes('codex') || hosts.includes('cursor')) dirs.add('.agents/skills/aimship');
|
|
167
|
+
if (hosts.includes('claude-code')) dirs.add('.claude/skills/aimship');
|
|
168
|
+
return [...dirs];
|
|
169
|
+
}
|
|
170
|
+
async function desiredFiles(source, p, hosts, version) {
|
|
171
|
+
const result = { '.aimship/bin/aimship.mjs': launcherText };
|
|
172
|
+
const skillRoot = path.join(source, 'skills', 'aimship');
|
|
173
|
+
const files = await listFiles(skillRoot);
|
|
174
|
+
for (const dir of hostDirectories(hosts)) {
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
// References are resolved through bind to the immutable release, avoiding duplicate rule copies.
|
|
177
|
+
if (file !== 'SKILL.md' && !file.startsWith('agents' + path.sep)) continue;
|
|
178
|
+
const raw = await fs.readFile(path.join(skillRoot, file), 'utf8');
|
|
179
|
+
result[path.join(dir, file)] = raw.replaceAll('{{AIMSHIP_LAUNCHER}}', p.launcher).replaceAll('{{AIMSHIP_NODE}}', process.execPath)
|
|
180
|
+
.replace(/\]\((references\/[^)]+)\)/g, (_, relative) => `](<${path.join(p.root, 'releases', version, 'skills', 'aimship', relative)}>)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
async function loadManifest(p) {
|
|
186
|
+
if (await exists(p.manifest)) return validateManifest(await readJSON(p.manifest));
|
|
187
|
+
if (await exists(p.active) || await exists(p.launcher)) fail('UNOWNED_INSTALL', 'Existing installation files have no Aimship manifest.', 'Preserve these files; restore their manifest or choose an isolated home.');
|
|
188
|
+
return { schemaVersion: 1, product: 'aimship', managed: {}, releases: {}, hosts: [], status: 'empty', previousVersion: null };
|
|
189
|
+
}
|
|
190
|
+
async function verifyManaged(p, manifest, desired) {
|
|
191
|
+
for (const file of Object.keys(desired)) {
|
|
192
|
+
const target = path.join(p.home, file); await noSymlinks(target);
|
|
193
|
+
if (!await exists(target)) continue;
|
|
194
|
+
const actual = await fileHash(target);
|
|
195
|
+
if (!manifest.managed[file] && !manifest.pending?.managed?.[file]) fail('FOREIGN_FILE', `Existing file is not managed by Aimship: ${target}`, 'Keep the existing file and resolve the name conflict explicitly.');
|
|
196
|
+
if (actual !== manifest.managed[file] && actual !== manifest.pending?.managed?.[file]) fail('USER_MODIFIED', `Managed file was modified: ${target}`, 'Preserve the modification; merge it or move custom instructions into .aimship/custom before retrying.');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function compareTree(root, expected) {
|
|
200
|
+
if (!await exists(root)) return false;
|
|
201
|
+
const actualFiles = await listFiles(root);
|
|
202
|
+
if (actualFiles.length !== Object.keys(expected).length) return false;
|
|
203
|
+
for (const file of actualFiles) if (!expected[file] || await fileHash(path.join(root, file)) !== expected[file]) return false;
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
async function prepareRelease(source, p, pkg, files, manifest) {
|
|
207
|
+
const target = path.join(p.root, 'releases', pkg.version); await noSymlinks(target);
|
|
208
|
+
if (await exists(target)) {
|
|
209
|
+
if (!manifest.releases[pkg.version]) fail('FOREIGN_RELEASE', `Existing release is not managed: ${target}`, 'Preserve the folder and resolve ownership.');
|
|
210
|
+
const recorded = manifest.releases[pkg.version].files;
|
|
211
|
+
const actual = await listFiles(target);
|
|
212
|
+
let conflict = Object.keys(recorded).length !== Object.keys(files).length || Object.entries(files).some(([file, digest]) => recorded[file] !== digest);
|
|
213
|
+
for (const file of actual) if (!files[file] || await fileHash(path.join(target, file)) !== files[file]) conflict = true;
|
|
214
|
+
if (conflict) fail('RELEASE_CONFLICT', `Installed ${pkg.version} differs from the package.`, 'Keep the installed files; use a new version for changed product files, or reconcile user changes explicitly.');
|
|
215
|
+
// Only absent, previously recorded files may be repaired; existing modifications are never replaced.
|
|
216
|
+
for (const file of Object.keys(files)) if (!actual.includes(file)) {
|
|
217
|
+
const destination = path.join(target, file); await noSymlinks(destination); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.copyFile(path.join(source, file), destination, fs.constants.COPYFILE_EXCL);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const staging = path.join(p.root, 'releases', `.staging-${randomUUID()}`);
|
|
222
|
+
await fs.mkdir(staging, { recursive: true });
|
|
223
|
+
try {
|
|
224
|
+
for (const file of Object.keys(files)) { const destination = path.join(staging, file); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.copyFile(path.join(source, file), destination, fs.constants.COPYFILE_EXCL); }
|
|
225
|
+
if (!await compareTree(staging, files)) fail('COPY_INTEGRITY', 'Staged release did not match the source.', 'Retry the same exact package; no activation has occurred.');
|
|
226
|
+
// Register ownership before rename so an interruption after rename can be resumed.
|
|
227
|
+
manifest.releases[pkg.version] = { files };
|
|
228
|
+
await atomicWrite(p.manifest, json(manifest));
|
|
229
|
+
await fs.rename(staging, target);
|
|
230
|
+
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
|
231
|
+
}
|
|
232
|
+
async function pruneReleases(p, manifest) {
|
|
233
|
+
const preserved = [];
|
|
234
|
+
for (const [version, release] of Object.entries(manifest.releases)) {
|
|
235
|
+
if ([manifest.activeVersion, manifest.previousVersion].includes(version)) continue;
|
|
236
|
+
const root = path.join(p.root, 'releases', version);
|
|
237
|
+
if (!await exists(root)) { delete manifest.releases[version]; continue; }
|
|
238
|
+
try {
|
|
239
|
+
await noSymlinks(root);
|
|
240
|
+
if (await compareTree(root, release.files)) { await fs.rm(root, { recursive: true }); delete manifest.releases[version]; }
|
|
241
|
+
else preserved.push(root);
|
|
242
|
+
} catch (error) { if (error.code === 'SYMLINK') preserved.push(root); else throw error; }
|
|
243
|
+
}
|
|
244
|
+
return preserved;
|
|
245
|
+
}
|
|
246
|
+
function compareVersions(left, right) {
|
|
247
|
+
const parts = version => { const separator = version.indexOf('-'); return separator < 0 ? [version, undefined] : [version.slice(0, separator), version.slice(separator + 1)]; };
|
|
248
|
+
const [leftCore, leftPre] = parts(left); const [rightCore, rightPre] = parts(right);
|
|
249
|
+
const a = leftCore.split('.').map(Number); const b = rightCore.split('.').map(Number);
|
|
250
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
|
|
251
|
+
if (leftPre === rightPre) return 0;
|
|
252
|
+
if (leftPre === undefined || rightPre === undefined) return leftPre === undefined ? 1 : -1;
|
|
253
|
+
const ap = leftPre.split('.'); const bp = rightPre.split('.');
|
|
254
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
255
|
+
if (ap[i] === undefined || bp[i] === undefined) return ap[i] === undefined ? -1 : 1;
|
|
256
|
+
if (ap[i] === bp[i]) continue;
|
|
257
|
+
const an = /^[0-9]+$/.test(ap[i]); const bn = /^[0-9]+$/.test(bp[i]);
|
|
258
|
+
if (an && bn) return Number(ap[i]) > Number(bp[i]) ? 1 : -1;
|
|
259
|
+
if (an !== bn) return an ? -1 : 1;
|
|
260
|
+
return ap[i] > bp[i] ? 1 : -1;
|
|
261
|
+
}
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
function installAction(fromVersion, toVersion, addedHosts) {
|
|
265
|
+
if (!fromVersion) return 'install';
|
|
266
|
+
if (fromVersion !== toVersion) return compareVersions(toVersion, fromVersion) > 0 ? 'upgrade' : 'switch-version';
|
|
267
|
+
return addedHosts.length ? 'add-hosts' : 'reinstall';
|
|
268
|
+
}
|
|
269
|
+
export async function install(source, options = {}) {
|
|
270
|
+
const p = installPaths(options.home);
|
|
271
|
+
let facts = { fromVersion: null, toVersion: null, action: 'install', addedHosts: [], fromVersionKnown: false };
|
|
272
|
+
try {
|
|
273
|
+
const metadata = await readJSON(path.join(source, 'package.json')); facts.toVersion = metadata.version ?? null;
|
|
274
|
+
await noSymlinks(p.root); await fs.mkdir(p.root, { recursive: true });
|
|
275
|
+
return await withLock(p.lock, async () => {
|
|
276
|
+
const manifest = await loadManifest(p); facts.fromVersion = manifest.activeVersion ?? null; facts.fromVersionKnown = true;
|
|
277
|
+
const { pkg, files } = await releaseFiles(source);
|
|
278
|
+
const fromVersion = manifest.activeVersion ?? null; const addedHosts = [...new Set(options.hosts ?? manifest.pending?.hosts ?? [])].filter(host => !manifest.hosts.includes(host));
|
|
279
|
+
facts = { fromVersion, toVersion: pkg.version, action: installAction(fromVersion, pkg.version, addedHosts), addedHosts, fromVersionKnown: true };
|
|
280
|
+
const resumed = Boolean(manifest.pending);
|
|
281
|
+
if (await exists(p.active)) {
|
|
282
|
+
await noSymlinks(p.active);
|
|
283
|
+
const digest = await fileHash(p.active);
|
|
284
|
+
if (digest !== manifest.activeDigest && digest !== manifest.pending?.activeDigest) fail('USER_MODIFIED', 'The active installation pointer was modified.', 'Preserve the pointer and reconcile it with the last complete installation before retrying.');
|
|
285
|
+
}
|
|
286
|
+
const hosts = [...new Set([...manifest.hosts, ...(manifest.pending?.hosts ?? []), ...(options.hosts ?? [])])];
|
|
287
|
+
facts.addedHosts = hosts.filter(host => !manifest.hosts.includes(host)); facts.action = installAction(fromVersion, pkg.version, facts.addedHosts);
|
|
288
|
+
if (!hosts.length && !fromVersion) fail('HOST_REQUIRED', 'Choose at least one host for the first install.', 'Repeat install --host codex, --host cursor, or --host claude-code; --host is repeatable.');
|
|
289
|
+
if (!hosts.every(x => HOSTS.includes(x))) fail('INVALID_HOST', 'Unknown install host.', `Supported hosts: ${HOSTS.join(', ')}.`);
|
|
290
|
+
if (manifest.pending && manifest.pending.version !== pkg.version) fail('PENDING_INSTALL', `Installation of ${manifest.pending.version} is incomplete.`, 'Resume the same exact package install before selecting a different version.');
|
|
291
|
+
const desired = await desiredFiles(source, p, hosts, pkg.version); await verifyManaged(p, manifest, desired);
|
|
292
|
+
let repairsNeeded = !(await exists(path.join(p.root, 'releases', pkg.version))) || !(await exists(p.active)) || !(await exists(p.config)) || !(await exists(path.join(p.root, 'custom')));
|
|
293
|
+
// A present release directory can still need repair when only an internal managed file is missing.
|
|
294
|
+
for (const file of Object.keys(files)) if (!await exists(path.join(p.root, 'releases', pkg.version, file))) repairsNeeded = true;
|
|
295
|
+
for (const file of Object.keys(desired)) if (!await exists(path.join(p.home, file))) repairsNeeded = true;
|
|
296
|
+
const managed = Object.fromEntries(Object.entries(desired).map(([file, data]) => [file, hash(data)]));
|
|
297
|
+
const activeData = json({ version: pkg.version, launcherProtocol: 1 });
|
|
298
|
+
manifest.status = 'pending'; manifest.pending = { version: pkg.version, hosts, managed, previousActive: manifest.activeVersion ?? null, activeDigest: hash(activeData) };
|
|
299
|
+
await atomicWrite(p.manifest, json(manifest));
|
|
300
|
+
try {
|
|
301
|
+
await prepareRelease(source, p, pkg, files, manifest);
|
|
302
|
+
for (const [file, data] of Object.entries(desired)) {
|
|
303
|
+
const target = path.join(p.home, file); await noSymlinks(target); await fs.mkdir(path.dirname(target), { recursive: true });
|
|
304
|
+
if (!await exists(target) || await fileHash(target) !== managed[file]) await atomicWrite(target, data);
|
|
305
|
+
}
|
|
306
|
+
if (!await exists(p.config)) await atomicWrite(p.config, json({ schemaVersion: 1, dataRoot: options.dataRoot ? path.resolve(options.dataRoot) : path.join(p.home, 'Aimship') }));
|
|
307
|
+
else await readConfig(p.config);
|
|
308
|
+
await noSymlinks(path.join(p.root, 'custom')); await fs.mkdir(path.join(p.root, 'custom'), { recursive: true });
|
|
309
|
+
await atomicWrite(p.active, activeData);
|
|
310
|
+
if (manifest.activeVersion && manifest.activeVersion !== pkg.version) manifest.previousVersion = manifest.activeVersion;
|
|
311
|
+
manifest.activeVersion = pkg.version; manifest.activeDigest = hash(activeData); manifest.hosts = hosts; manifest.managed = managed; manifest.status = 'complete'; delete manifest.pending; delete manifest.lastError;
|
|
312
|
+
const preserved = await pruneReleases(p, manifest); await atomicWrite(p.manifest, json(manifest));
|
|
313
|
+
return { version: pkg.version, ...facts, activeVersion: pkg.version, result: resumed ? 'recovered' : (facts.action === 'reinstall' ? (repairsNeeded ? 'repaired' : 'unchanged') : 'success'), resumed, ...p, hosts, entries: hostDirectories(hosts).map(x => path.join(p.home, x, 'SKILL.md')), preserved, node: process.execPath, dataDirectoryCreated: false, reload: 'Reload host skill discovery or start a new session if its selector still shows an old entry.' };
|
|
314
|
+
} catch (error) {
|
|
315
|
+
manifest.lastError = { code: error.code ?? 'IO_ERROR', message: error.message }; await atomicWrite(p.manifest, json(manifest));
|
|
316
|
+
fail('INSTALL_INCOMPLETE', 'Installation is incomplete; existing delivery data was not touched.', 'Inspect details and retry the same exact package install. The manifest records pending paths; activation may still point to the previous complete release.', { cause: manifest.lastError, pending: manifest.pending, active: await exists(p.active) ? await readJSON(p.active) : null });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
const active = await inspectJSON(p.active, validateActive);
|
|
321
|
+
error.details = { ...error.details, ...facts, activeVersion: active.value?.version ?? null, result: error.code === 'INSTALL_INCOMPLETE' ? 'incomplete' : 'failed' };
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export async function uninstall(options = {}) {
|
|
326
|
+
const p = installPaths(options.home);
|
|
327
|
+
if (!await exists(p.manifest)) fail('NOT_INSTALLED', 'No managed Aimship installation found.', 'Preserve any unowned files; use the correct home.');
|
|
328
|
+
return withLock(p.lock, async () => {
|
|
329
|
+
const manifest = await loadManifest(p);
|
|
330
|
+
if (manifest.pending) fail('PENDING_INSTALL', 'An installation is incomplete.', 'Resume the exact pending install before uninstalling.');
|
|
331
|
+
const fullUninstall = !options.hosts?.length;
|
|
332
|
+
const removing = fullUninstall ? manifest.hosts : options.hosts;
|
|
333
|
+
const disconnectedHosts = manifest.hosts.filter(host => removing.includes(host));
|
|
334
|
+
if (!removing.every(x => HOSTS.includes(x))) fail('INVALID_HOST', 'Unknown host.', 'Use a registered host name.');
|
|
335
|
+
const remaining = manifest.hosts.filter(x => !removing.includes(x)); const keepDirectories = hostDirectories(remaining);
|
|
336
|
+
const removed = []; const preserved = [];
|
|
337
|
+
for (const [file, digest] of Object.entries(manifest.managed)) {
|
|
338
|
+
if ((!fullUninstall && file.startsWith('.aimship' + path.sep)) || keepDirectories.some(dir => file.startsWith(dir + path.sep))) continue;
|
|
339
|
+
const target = path.join(p.home, file); await noSymlinks(target);
|
|
340
|
+
if (await exists(target)) {
|
|
341
|
+
if (await fileHash(target) === digest) { await fs.rm(target); removed.push(target); }
|
|
342
|
+
else { preserved.push(target); continue; }
|
|
343
|
+
}
|
|
344
|
+
delete manifest.managed[file];
|
|
345
|
+
}
|
|
346
|
+
if (fullUninstall) {
|
|
347
|
+
for (const [version, release] of Object.entries(manifest.releases)) {
|
|
348
|
+
const root = path.join(p.root, 'releases', version); await noSymlinks(root);
|
|
349
|
+
if (!await exists(root)) { delete manifest.releases[version]; continue; }
|
|
350
|
+
if (await compareTree(root, release.files)) { await fs.rm(root, { recursive: true }); removed.push(root); delete manifest.releases[version]; }
|
|
351
|
+
else preserved.push(root);
|
|
352
|
+
}
|
|
353
|
+
// active is managed only if its exact pointer still matches the last complete activation.
|
|
354
|
+
if (await exists(p.active)) {
|
|
355
|
+
const active = await readJSON(p.active);
|
|
356
|
+
if (active.version === manifest.activeVersion && active.launcherProtocol === 1 && await fileHash(p.active) === manifest.activeDigest) { await fs.rm(p.active); removed.push(p.active); } else preserved.push(p.active);
|
|
357
|
+
}
|
|
358
|
+
manifest.activeVersion = null; manifest.previousVersion = null; delete manifest.activeDigest;
|
|
359
|
+
}
|
|
360
|
+
manifest.hosts = remaining; manifest.status = fullUninstall ? 'uninstalled' : 'complete'; await atomicWrite(p.manifest, json(manifest));
|
|
361
|
+
return { action: fullUninstall ? 'uninstall' : 'disconnect-hosts', result: preserved.length ? 'partial' : 'success', disconnectedHosts, remainingHosts: remaining, coreRetained: !fullUninstall, activeVersion: manifest.activeVersion ?? null, sharedEntriesRetained: keepDirectories.map(dir => ({ path: path.join(p.home, dir, 'SKILL.md'), registeredHosts: remaining.filter(host => hostDirectories([host]).includes(dir)), sharedWith: HOSTS.filter(host => hostDirectories([host]).includes(dir)) })), removed, preserved, retained: [p.config, path.join(p.root, 'custom')], data: 'All delivery data is retained. No delivery directory was read or removed.' };
|
|
362
|
+
});
|
|
363
|
+
}
|
package/lib/io.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export class AimshipError extends Error {
|
|
6
|
+
constructor(code, message, recovery, details) {
|
|
7
|
+
super(message); Object.assign(this, { code, recovery, details });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function fail(code, message, recovery, details) { throw new AimshipError(code, message, recovery, details); }
|
|
11
|
+
export const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
12
|
+
export const hash = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
export async function exists(file) { try { await fs.lstat(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
14
|
+
export async function readJSON(file) {
|
|
15
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
16
|
+
catch (e) { fail(e.code === 'ENOENT' ? 'NOT_FOUND' : 'INVALID_JSON', `Cannot read JSON: ${file}`, 'Read the file and recent recovery copy before making changes.', { cause: e.message }); }
|
|
17
|
+
}
|
|
18
|
+
export function inside(root, target) { const rel = path.relative(root, target); return rel === '' || (!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + path.sep)); }
|
|
19
|
+
export async function noSymlinks(target) {
|
|
20
|
+
const absolute = path.resolve(target); const parsed = path.parse(absolute);
|
|
21
|
+
let current = parsed.root;
|
|
22
|
+
for (const part of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
23
|
+
current = path.join(current, part);
|
|
24
|
+
try { if ((await fs.lstat(current)).isSymbolicLink()) fail('SYMLINK', `Refusing managed path through a symbolic link: ${current}`, 'Choose a real directory or resolve ownership explicitly.'); }
|
|
25
|
+
catch (e) { if (e.code === 'ENOENT') return; throw e; }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function atomicWrite(file, data) {
|
|
29
|
+
await noSymlinks(file);
|
|
30
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
31
|
+
let handle;
|
|
32
|
+
try {
|
|
33
|
+
handle = await fs.open(tmp, 'wx', 0o600); await handle.writeFile(data); await handle.sync(); await handle.close(); handle = undefined;
|
|
34
|
+
await fs.rename(tmp, file);
|
|
35
|
+
} finally { await handle?.close(); await fs.rm(tmp, { force: true }); }
|
|
36
|
+
}
|
|
37
|
+
export async function withLock(file, fn) {
|
|
38
|
+
await noSymlinks(file); let handle;
|
|
39
|
+
try { handle = await fs.open(file, 'wx', 0o600); }
|
|
40
|
+
catch (e) { if (e.code === 'EEXIST') fail('LOCKED', `Another write or interrupted operation owns ${file}`, 'Ensure no writer is active, inspect the lock, then remove only this lock and retry.'); throw e; }
|
|
41
|
+
try { await handle.writeFile(json({ pid: process.pid, createdAt: new Date().toISOString() })); return await fn(); }
|
|
42
|
+
finally { await handle.close(); await fs.rm(file, { force: true }); }
|
|
43
|
+
}
|
|
44
|
+
export async function listFiles(root, prefix = '') {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const item of await fs.readdir(path.join(root, prefix), { withFileTypes: true })) {
|
|
47
|
+
const relative = path.join(prefix, item.name);
|
|
48
|
+
if (item.isSymbolicLink()) fail('SYMLINK', `Release contains a symbolic link: ${relative}`, 'Package ordinary files inside the release root.');
|
|
49
|
+
if (item.isDirectory()) files.push(...await listFiles(root, relative));
|
|
50
|
+
else if (item.isFile()) files.push(relative);
|
|
51
|
+
else fail('FILE_TYPE', `Unsupported release file: ${relative}`, 'Use ordinary files and directories.');
|
|
52
|
+
}
|
|
53
|
+
return files.sort();
|
|
54
|
+
}
|
|
55
|
+
export async function fileHash(file) { return hash(await fs.readFile(file)); }
|
package/lib/migrate.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { atomicWrite, exists, fail, hash, json, noSymlinks, readJSON, withLock } from './io.mjs';
|
|
4
|
+
import { statePaths, validateState, assertInstallationComplete, readState } from './state.mjs';
|
|
5
|
+
import { savedResult } from './view.mjs';
|
|
6
|
+
|
|
7
|
+
export async function migrateState(directory, revision, rulesVersion, runtime, input) {
|
|
8
|
+
assertInstallationComplete(runtime);
|
|
9
|
+
if (rulesVersion !== runtime.version || runtime.version !== runtime.activeVersion) fail('VERSION_MISMATCH','Migration requires the active tool.','Bind the active version.');
|
|
10
|
+
if (!input || Object.keys(input).length !== 1 || typeof input.compatibilityRef !== 'string' || !input.compatibilityRef.trim()) fail('USAGE','Migration input is {compatibilityRef}.','Record the assessment and old/new document disposition first.');
|
|
11
|
+
const old=statePaths(directory,true), modern=statePaths(directory);
|
|
12
|
+
await noSymlinks(modern.internal); await fs.mkdir(modern.internal,{recursive:true});
|
|
13
|
+
return withLock(old.lock,()=>withLock(modern.lock,async()=>{
|
|
14
|
+
const journalPath=path.join(modern.internal,'migration.json');
|
|
15
|
+
for (const file of [journalPath,old.statePath,old.backup,modern.statePath,path.join(modern.internal,'legacy/state.json'),path.join(modern.internal,'legacy/state.previous.json')]) await noSymlinks(file);
|
|
16
|
+
let journal;
|
|
17
|
+
if (await exists(journalPath)) {
|
|
18
|
+
journal=await readJSON(journalPath);
|
|
19
|
+
if (journal.protocol !== 1 || journal.revision !== revision || journal.compatibilityRef !== input.compatibilityRef) fail('MIGRATION_CONFLICT','Migration parameters or journal differ.','Inspect recorded migration; never overwrite the copies.');
|
|
20
|
+
if (journal.status==='complete') {
|
|
21
|
+
if (await exists(old.statePath)) fail('LAYOUT_CONFLICT','A root state reappeared after migration.','Inspect both states before proceeding.');
|
|
22
|
+
return {state:(await readState(directory)).state,migration:'already-complete'};
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
if (await exists(modern.statePath) || await exists(modern.backup)) fail('LAYOUT_CONFLICT','Target state/recovery already exists.','Inspect both layouts; do not merge by timestamp.');
|
|
26
|
+
await noSymlinks(old.statePath); await noSymlinks(old.backup);
|
|
27
|
+
const raw=await fs.readFile(old.statePath,'utf8'), state=JSON.parse(raw);
|
|
28
|
+
validateState(state);
|
|
29
|
+
if (state.schemaVersion!==1) fail('UNKNOWN_SCHEMA','Only schema 1 root state can migrate.','Keep unknown formats read-only.');
|
|
30
|
+
if (state.revision!==revision) fail('REVISION_CONFLICT','Legacy revision changed.','Read and reassess before migration.');
|
|
31
|
+
const next={...state,schemaVersion:2,revision:state.revision+1,updatedAt:new Date().toISOString(),documentPolicy:{authority:'legacy'},documents:[],legacyRunIds:state.stageRuns.map(r=>r.id),migration:{fromSchema:1,compatibilityRef:input.compatibilityRef}};
|
|
32
|
+
// validate the assessment reference with the ordinary state reference protocol.
|
|
33
|
+
validateState({...next,next:{...next.next,inputRefs:[input.compatibilityRef]}});
|
|
34
|
+
const backupRaw=await exists(old.backup)?await fs.readFile(old.backup,'utf8'):null;
|
|
35
|
+
journal={protocol:1,status:'prepared',revision,compatibilityRef:input.compatibilityRef,sourceHash:hash(raw),backupHash:backupRaw===null?null:hash(backupRaw),targetHash:hash(json(next)),target:next};
|
|
36
|
+
const legacyDir=path.join(modern.internal,'legacy'); await noSymlinks(legacyDir); await fs.mkdir(legacyDir,{recursive:true});
|
|
37
|
+
for (const [name,bytes] of [['state.json',raw],['state.previous.json',backupRaw]]) {
|
|
38
|
+
if(bytes===null) continue;
|
|
39
|
+
const file=path.join(legacyDir,name);
|
|
40
|
+
if(await exists(file) && hash(await fs.readFile(file))!==hash(bytes)) fail('MIGRATION_CONFLICT','Legacy backup differs.','Preserve all copies and resolve manually.');
|
|
41
|
+
if(!await exists(file)) await atomicWrite(file,bytes);
|
|
42
|
+
}
|
|
43
|
+
await atomicWrite(journalPath,json(journal));
|
|
44
|
+
}
|
|
45
|
+
validateState(journal.target);
|
|
46
|
+
if (hash(json(journal.target))!==journal.targetHash) fail('MIGRATION_CONFLICT','Migration target checksum changed.','Inspect journal and preserved source.');
|
|
47
|
+
const legacyCopy=path.join(modern.internal,'legacy/state.json');
|
|
48
|
+
if(hash(await fs.readFile(legacyCopy))!==journal.sourceHash) fail('MIGRATION_CONFLICT','Preserved source changed.','Recover the original source before resuming.');
|
|
49
|
+
const recoveryCopy=path.join(modern.internal,'legacy/state.previous.json');
|
|
50
|
+
if (journal.backupHash !== null && (!await exists(recoveryCopy) || hash(await fs.readFile(recoveryCopy))!==journal.backupHash)) fail('MIGRATION_CONFLICT','Preserved recovery copy is missing or changed.','Repair the preserved copy from the original recovery material before resuming.');
|
|
51
|
+
if(await exists(old.statePath) && hash(await fs.readFile(old.statePath))!==journal.sourceHash) fail('MIGRATION_CONFLICT','Legacy state changed during migration.','Reconcile facts; do not overwrite either copy.');
|
|
52
|
+
if(await exists(modern.statePath) && hash(await fs.readFile(modern.statePath))!==journal.targetHash) fail('MIGRATION_CONFLICT','Target state changed during migration.','Reconcile both states.');
|
|
53
|
+
if(await exists(old.backup) && hash(await fs.readFile(old.backup))!==journal.backupHash) fail('MIGRATION_CONFLICT','Legacy recovery copy changed.','Preserve both recovery copies.');
|
|
54
|
+
if(!await exists(modern.statePath)) await atomicWrite(modern.statePath,json(journal.target));
|
|
55
|
+
await fs.rm(old.statePath,{force:true}); await fs.rm(old.backup,{force:true});
|
|
56
|
+
await atomicWrite(journalPath,json({...journal,status:'complete'}));
|
|
57
|
+
return savedResult(directory,journal.target);
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Discovery is intentionally bounded to named material roots, never the home or code tree.
|
|
62
|
+
export async function scanDeliveries(root) {
|
|
63
|
+
const records=[],issues=[],seen=new Map();
|
|
64
|
+
for(const entry of await fs.readdir(root,{withFileTypes:true})) {
|
|
65
|
+
if(!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
66
|
+
const directory=path.join(root,entry.name);
|
|
67
|
+
if(!await exists(path.join(directory,'state.json')) && !await exists(path.join(directory,'.aimship/state.json'))) continue;
|
|
68
|
+
try {
|
|
69
|
+
const read=await readState(directory), id=read.state.deliveryId;
|
|
70
|
+
if(read.conflict || read.readOnly) {issues.push({directory,reason:read.conflict?'layout-conflict':'unknown-schema',candidates:read.candidates}); continue;}
|
|
71
|
+
if(seen.has(id)) {issues.push({directory,reason:'duplicate-delivery-id',other:seen.get(id)}); continue;}
|
|
72
|
+
seen.set(id,directory);
|
|
73
|
+
records.push({directory,statePath:read.statePath,deliveryId:id,rulesVersion:read.state.rulesVersion,repositories:read.state.repositories,currentRun:read.state.currentRun,stageRuns:read.state.stageRuns,outcome:read.state.outcome,next:read.state.next,updatedAt:read.state.updatedAt,migration:read.migration?.status??null});
|
|
74
|
+
} catch(error) {issues.push({directory,reason:error.code??error.message});}
|
|
75
|
+
}
|
|
76
|
+
return {root:path.resolve(root),records,issues,limits:'Named root immediate children only. Records may be stale; unknown roots and non-Aimship work are outside this check. Agent must assess actual writing intent and checkout/worktree identity.'};
|
|
77
|
+
}
|