@enki-dev-expert/global-engineering-standards 0.0.0-stage → 0.1.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/AGENTS.md +303 -0
- package/CHANGELOG.md +28 -0
- package/CITATION.cff +21 -0
- package/CODE_OF_CONDUCT.md +13 -0
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/dist/cli.js +40 -0
- package/dist/content.js +71 -0
- package/dist/filesystem.js +83 -0
- package/dist/installer.js +243 -0
- package/dist/layout.js +42 -0
- package/dist/transaction.js +143 -0
- package/docs/ai-and-llm.md +1312 -0
- package/docs/architecture.md +1007 -0
- package/docs/cost-engineering.md +1322 -0
- package/docs/data.md +993 -0
- package/docs/governance.md +1744 -0
- package/docs/mobile-and-web.md +1362 -0
- package/docs/observability.md +1345 -0
- package/docs/platforms-and-applications.md +1581 -0
- package/docs/privacy.md +566 -0
- package/docs/quality-and-testing.md +1278 -0
- package/docs/scalability.md +1012 -0
- package/docs/security.md +373 -0
- package/handbook/claude-plugin.md +31 -0
- package/handbook/compatibility.md +27 -0
- package/handbook/recovery.md +29 -0
- package/handbook/releasing.md +50 -0
- package/handbook/security-review.md +41 -0
- package/handbook/signing.md +18 -0
- package/handbook/validation.md +50 -0
- package/package.json +65 -4
- package/scripts/content-baseline.json +25 -0
- package/skills/engineering-architecture-review/SKILL.md +1531 -0
- package/skills/engineering-cost-estimate/SKILL.md +1599 -0
- package/skills/engineering-database-review/SKILL.md +1364 -0
- package/skills/engineering-privacy-check/SKILL.md +1295 -0
- package/skills/engineering-safe-implementation/SKILL.md +1502 -0
- package/skills/engineering-security-standards-check/SKILL.md +1112 -0
- package/src/cli.ts +45 -0
- package/src/content.ts +82 -0
- package/src/filesystem.ts +94 -0
- package/src/installer.ts +280 -0
- package/src/layout.ts +70 -0
- package/src/transaction.ts +167 -0
- package/templates/ADR.md +1302 -0
- package/templates/AUDIT_REPORT.md +1693 -0
- package/templates/EXCEPTIONS.md +647 -0
- package/templates/PROJECT_AGENTS.md +868 -0
package/dist/content.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
6
|
+
export const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
export const skills = [
|
|
8
|
+
'engineering-architecture-review',
|
|
9
|
+
'engineering-cost-estimate',
|
|
10
|
+
'engineering-database-review',
|
|
11
|
+
'engineering-privacy-check',
|
|
12
|
+
'engineering-safe-implementation',
|
|
13
|
+
'engineering-security-standards-check',
|
|
14
|
+
];
|
|
15
|
+
export const documents = [
|
|
16
|
+
'ai-and-llm',
|
|
17
|
+
'architecture',
|
|
18
|
+
'cost-engineering',
|
|
19
|
+
'data',
|
|
20
|
+
'governance',
|
|
21
|
+
'mobile-and-web',
|
|
22
|
+
'observability',
|
|
23
|
+
'platforms-and-applications',
|
|
24
|
+
'privacy',
|
|
25
|
+
'quality-and-testing',
|
|
26
|
+
'scalability',
|
|
27
|
+
'security',
|
|
28
|
+
];
|
|
29
|
+
export const templates = ['ADR', 'AUDIT_REPORT', 'EXCEPTIONS', 'PROJECT_AGENTS'];
|
|
30
|
+
export const contentFiles = [
|
|
31
|
+
'AGENTS.md',
|
|
32
|
+
...documents.map((x) => `docs/${x}.md`),
|
|
33
|
+
...templates.map((x) => `templates/${x}.md`),
|
|
34
|
+
...skills.map((x) => `skills/${x}/SKILL.md`),
|
|
35
|
+
];
|
|
36
|
+
/** Validate the complete payload before planning any destination writes. */
|
|
37
|
+
export function readContent(root = packageRoot) {
|
|
38
|
+
const baseline = JSON.parse(readFileSync(path.join(root, 'scripts/content-baseline.json'), 'utf8'));
|
|
39
|
+
const result = new Map();
|
|
40
|
+
for (const name of contentFiles) {
|
|
41
|
+
const data = readFileSync(path.join(root, name));
|
|
42
|
+
if (digest(data) !== baseline[name])
|
|
43
|
+
throw new Error(`Content baseline mismatch: ${name}`);
|
|
44
|
+
const value = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
45
|
+
for (const match of value.matchAll(/(?:docs|templates)\/[A-Za-z0-9_-]+\.md/g)) {
|
|
46
|
+
if (!contentFiles.includes(match[0]))
|
|
47
|
+
throw new Error(`Broken reference in ${name}: ${match[0]}`);
|
|
48
|
+
}
|
|
49
|
+
result.set(name, value);
|
|
50
|
+
}
|
|
51
|
+
for (const skill of skills) {
|
|
52
|
+
const value = result.get(`skills/${skill}/SKILL.md`) ?? '';
|
|
53
|
+
if (!value.startsWith(`---\nname: ${skill}\ndescription: `) ||
|
|
54
|
+
!/\n---\n/.test(value.slice(4))) {
|
|
55
|
+
throw new Error(`Invalid skill metadata: ${skill}`);
|
|
56
|
+
}
|
|
57
|
+
const description = value.split('\n')[2]?.slice('description: '.length) ?? '';
|
|
58
|
+
if (!description.trim() || description.length > 1024)
|
|
59
|
+
throw new Error(`Invalid description: ${skill}`);
|
|
60
|
+
}
|
|
61
|
+
if (readdirSync(path.join(root, 'docs')).filter((x) => x.endsWith('.md')).length !== 12 ||
|
|
62
|
+
readdirSync(path.join(root, 'skills')).length !== 6)
|
|
63
|
+
throw new Error('Unexpected content inventory');
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/** Only installation references change; the canonical policy remains byte-for-byte intact. */
|
|
67
|
+
export function render(value, reference) {
|
|
68
|
+
return value
|
|
69
|
+
.replaceAll('~/engineering-standards/', () => `${reference}/`)
|
|
70
|
+
.replace(/(?<![\w/])((?:docs|templates)\/[A-Za-z0-9_-]+\.md)/g, (_match, relative) => `${reference}/${relative}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const equal = (a, b) => a?.data === b?.data && a?.mode === b?.mode;
|
|
5
|
+
/** Refuse links and special files at every existing component, including dangling links. */
|
|
6
|
+
export function assertSafe(file) {
|
|
7
|
+
const absolute = path.resolve(file);
|
|
8
|
+
const parsed = path.parse(absolute);
|
|
9
|
+
let cursor = parsed.root;
|
|
10
|
+
const parts = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
11
|
+
for (let index = 0; index < parts.length; index++) {
|
|
12
|
+
cursor = path.join(cursor, parts[index] ?? '');
|
|
13
|
+
let stat;
|
|
14
|
+
try {
|
|
15
|
+
stat = lstatSync(cursor);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
if (stat.isSymbolicLink())
|
|
23
|
+
throw new Error(`Refusing symbolic link: ${cursor}`);
|
|
24
|
+
if (index < parts.length - 1 && !stat.isDirectory())
|
|
25
|
+
throw new Error(`Not a directory: ${cursor}`);
|
|
26
|
+
if (index === parts.length - 1 && !stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1)) {
|
|
27
|
+
throw new Error(`Refusing special file or hard link: ${cursor}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function snapshot(file) {
|
|
32
|
+
assertSafe(file);
|
|
33
|
+
if (!existsSync(file))
|
|
34
|
+
return null;
|
|
35
|
+
const stat = lstatSync(file);
|
|
36
|
+
if (!stat.isFile())
|
|
37
|
+
throw new Error(`Expected regular file: ${file}`);
|
|
38
|
+
if (stat.size > 8 * 1024 * 1024)
|
|
39
|
+
throw new Error(`File exceeds 8 MiB safety limit: ${file}`);
|
|
40
|
+
return { data: readFileSync(file).toString('base64'), mode: stat.mode & 0o777 };
|
|
41
|
+
}
|
|
42
|
+
export function textOf(value) {
|
|
43
|
+
return value
|
|
44
|
+
? new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.from(value.data, 'base64'))
|
|
45
|
+
: '';
|
|
46
|
+
}
|
|
47
|
+
export function asSnapshot(value, previous = null) {
|
|
48
|
+
return {
|
|
49
|
+
data: Buffer.from(value).toString('base64'),
|
|
50
|
+
mode: previous?.mode ?? (process.platform === 'win32' ? 0o666 : 0o600),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function ensureDirectory(directory) {
|
|
54
|
+
assertSafe(directory);
|
|
55
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
}
|
|
57
|
+
export function replace(file, value) {
|
|
58
|
+
assertSafe(file);
|
|
59
|
+
if (value === null) {
|
|
60
|
+
if (existsSync(file))
|
|
61
|
+
unlinkSync(file);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
ensureDirectory(path.dirname(file));
|
|
65
|
+
const temporary = path.join(path.dirname(file), `.ges-${randomUUID()}.tmp`);
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = openSync(temporary, 'wx', value.mode);
|
|
69
|
+
writeFileSync(fd, Buffer.from(value.data, 'base64'));
|
|
70
|
+
fsyncSync(fd);
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
fd = undefined;
|
|
73
|
+
chmodSync(temporary, value.mode);
|
|
74
|
+
assertSafe(file);
|
|
75
|
+
renameSync(temporary, file);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (fd !== undefined)
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
if (existsSync(temporary))
|
|
81
|
+
unlinkSync(temporary);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { contentFiles, digest, packageRoot, readContent, render, skills } from './content.js';
|
|
4
|
+
import { asSnapshot, assertSafe, equal, snapshot, textOf } from './filesystem.js';
|
|
5
|
+
import { layout } from './layout.js';
|
|
6
|
+
import { commit, locked, pending, rollback } from './transaction.js';
|
|
7
|
+
const start = '<!-- global-engineering-standards:start -->';
|
|
8
|
+
const end = '<!-- global-engineering-standards:end -->';
|
|
9
|
+
function loadManifest(context) {
|
|
10
|
+
const data = snapshot(context.targets.get('manifest'));
|
|
11
|
+
if (!data)
|
|
12
|
+
return null;
|
|
13
|
+
const manifest = JSON.parse(textOf(data));
|
|
14
|
+
if (manifest.schema !== 1 ||
|
|
15
|
+
manifest.scope !== context.options.scope ||
|
|
16
|
+
typeof manifest.version !== 'string' ||
|
|
17
|
+
!manifest.entries ||
|
|
18
|
+
!Array.isArray(manifest.agents) ||
|
|
19
|
+
!manifest.agents.length ||
|
|
20
|
+
manifest.agents.some((x) => x !== 'claude' && x !== 'codex'))
|
|
21
|
+
throw new Error('Invalid manifest');
|
|
22
|
+
for (const [key, entry] of Object.entries(manifest.entries)) {
|
|
23
|
+
if (!context.targets.has(key) || key === 'manifest' || !/^[a-f0-9]{64}$/.test(entry.hash))
|
|
24
|
+
throw new Error(`Invalid manifest entry: ${key}`);
|
|
25
|
+
if (key.endsWith('-instructions')) {
|
|
26
|
+
if (typeof entry.block !== 'string' ||
|
|
27
|
+
!entry.block.startsWith(`\n\n${start}\n`) ||
|
|
28
|
+
!entry.block.endsWith(`${end}\n`) ||
|
|
29
|
+
digest(entry.block) !== entry.hash ||
|
|
30
|
+
typeof entry.created !== 'boolean') {
|
|
31
|
+
throw new Error(`Invalid instruction ownership: ${key}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (entry.block !== undefined)
|
|
35
|
+
throw new Error(`Invalid file ownership: ${key}`);
|
|
36
|
+
}
|
|
37
|
+
for (const name of contentFiles)
|
|
38
|
+
if (!manifest.entries[`framework/${name}`])
|
|
39
|
+
throw new Error(`Incomplete manifest: ${name}`);
|
|
40
|
+
for (const agent of manifest.agents) {
|
|
41
|
+
if (!manifest.entries[`${agent}-instructions`])
|
|
42
|
+
throw new Error(`Incomplete manifest: ${agent}`);
|
|
43
|
+
for (const skill of skills)
|
|
44
|
+
if (!manifest.entries[`${agent}-skill/${skill}`])
|
|
45
|
+
throw new Error(`Incomplete manifest: ${skill}`);
|
|
46
|
+
}
|
|
47
|
+
return manifest;
|
|
48
|
+
}
|
|
49
|
+
function verify(context, manifest) {
|
|
50
|
+
for (const [key, entry] of Object.entries(manifest.entries)) {
|
|
51
|
+
const value = snapshot(context.targets.get(key));
|
|
52
|
+
if (!value)
|
|
53
|
+
throw new Error(`Missing installed file: ${key}`);
|
|
54
|
+
const text = textOf(value);
|
|
55
|
+
if (entry.block) {
|
|
56
|
+
if (text.split(entry.block).length !== 2 ||
|
|
57
|
+
text.split(start).length !== 2 ||
|
|
58
|
+
text.split(end).length !== 2) {
|
|
59
|
+
throw new Error(`Managed instruction block changed: ${key}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (digest(Buffer.from(value.data, 'base64')) !== entry.hash)
|
|
63
|
+
throw new Error(`Installed file changed: ${key}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function checkOverrides(context, agents) {
|
|
67
|
+
if (!agents.includes('codex'))
|
|
68
|
+
return;
|
|
69
|
+
const instruction = context.targets.get('codex-instructions');
|
|
70
|
+
const override = snapshot(path.join(path.dirname(instruction), 'AGENTS.override.md'));
|
|
71
|
+
if (textOf(override).trim())
|
|
72
|
+
throw new Error('AGENTS.override.md shadows AGENTS.md; resolve this instruction conflict first.');
|
|
73
|
+
}
|
|
74
|
+
function reference(context, target) {
|
|
75
|
+
return ((context.options.scope === 'global'
|
|
76
|
+
? context.framework
|
|
77
|
+
: path.relative(path.dirname(target), context.framework))
|
|
78
|
+
.split(path.sep)
|
|
79
|
+
.join('/') || '.');
|
|
80
|
+
}
|
|
81
|
+
function block(context, target, policy) {
|
|
82
|
+
const root = reference(context, target);
|
|
83
|
+
return (`\n\n${start}\n# Installed Global Engineering Standards\n\n` +
|
|
84
|
+
`Framework root: ${JSON.stringify(root)}. Resolve relative framework paths against the directory containing THIS file, never the current working directory.\n\n` +
|
|
85
|
+
`${render(policy, root).trim()}\n\n${end}\n`);
|
|
86
|
+
}
|
|
87
|
+
function planInstall(context, previous, source) {
|
|
88
|
+
const content = readContent(source);
|
|
89
|
+
const agents = [
|
|
90
|
+
...new Set([...(previous?.agents ?? []), ...context.options.agents]),
|
|
91
|
+
].sort();
|
|
92
|
+
checkOverrides(context, agents);
|
|
93
|
+
const desired = new Map();
|
|
94
|
+
for (const [name, value] of content) {
|
|
95
|
+
const target = context.targets.get(`framework/${name}`);
|
|
96
|
+
const root = reference(context, target);
|
|
97
|
+
desired.set(`framework/${name}`, name.endsWith('SKILL.md') ? renderSkill(value, root) : render(value, root));
|
|
98
|
+
}
|
|
99
|
+
for (const agent of agents) {
|
|
100
|
+
const key = `${agent}-instructions`;
|
|
101
|
+
desired.set(key, block(context, context.targets.get(key), content.get('AGENTS.md')));
|
|
102
|
+
for (const skill of skills) {
|
|
103
|
+
const key = `${agent}-skill/${skill}`;
|
|
104
|
+
const target = context.targets.get(key);
|
|
105
|
+
const directory = path.dirname(target);
|
|
106
|
+
assertSafe(directory);
|
|
107
|
+
if (!previous?.entries[key] && existsSync(directory) && readdirSync(directory).length)
|
|
108
|
+
throw new Error(`Existing skill directory preserved: ${directory}`);
|
|
109
|
+
desired.set(key, renderSkill(content.get(`skills/${skill}/SKILL.md`), reference(context, target)));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const changes = [];
|
|
113
|
+
const entries = {};
|
|
114
|
+
for (const [key, value] of desired) {
|
|
115
|
+
const before = snapshot(context.targets.get(key));
|
|
116
|
+
const old = previous?.entries[key];
|
|
117
|
+
let next = value;
|
|
118
|
+
if (key.endsWith('-instructions')) {
|
|
119
|
+
const original = textOf(before);
|
|
120
|
+
if (!old && (original.includes(start) || original.includes(end)))
|
|
121
|
+
throw new Error(`Unowned managed block: ${key}`);
|
|
122
|
+
next = old?.block ? original.replace(old.block, () => value) : original + value;
|
|
123
|
+
entries[key] = {
|
|
124
|
+
hash: digest(value),
|
|
125
|
+
block: value,
|
|
126
|
+
created: old?.created ?? before === null,
|
|
127
|
+
};
|
|
128
|
+
if (key === 'codex-instructions' && Buffer.byteLength(next) > 32768)
|
|
129
|
+
throw new Error('Codex instruction file exceeds the default 32 KiB limit; reduce existing instructions manually.');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (before && !old)
|
|
133
|
+
throw new Error(`Existing file preserved: ${key}`);
|
|
134
|
+
entries[key] = { hash: digest(value) };
|
|
135
|
+
}
|
|
136
|
+
const after = asSnapshot(next, before);
|
|
137
|
+
if (!equal(before, after))
|
|
138
|
+
changes.push({ key, before, after });
|
|
139
|
+
}
|
|
140
|
+
const version = JSON.parse(readFileSync(path.join(source, 'package.json'), 'utf8')).version;
|
|
141
|
+
const manifest = { schema: 1, version, scope: context.options.scope, agents, entries };
|
|
142
|
+
const before = snapshot(context.targets.get('manifest'));
|
|
143
|
+
const after = asSnapshot(`${JSON.stringify(manifest, null, 2)}\n`, before);
|
|
144
|
+
if (!equal(before, after))
|
|
145
|
+
changes.push({ key: 'manifest', before, after });
|
|
146
|
+
return changes;
|
|
147
|
+
}
|
|
148
|
+
export function renderSkill(value, root) {
|
|
149
|
+
const delimiter = value.indexOf('\n---\n', 4) + 5;
|
|
150
|
+
return (value.slice(0, delimiter) +
|
|
151
|
+
`\nInstallation path resolution: resolve all relative framework paths in this skill against the directory containing this SKILL.md, never the current working directory. Framework root: ${JSON.stringify(root)}.\n` +
|
|
152
|
+
render(value.slice(delimiter), root));
|
|
153
|
+
}
|
|
154
|
+
function planUninstall(context, manifest) {
|
|
155
|
+
const changes = [];
|
|
156
|
+
for (const [key, entry] of Object.entries(manifest.entries)) {
|
|
157
|
+
const before = snapshot(context.targets.get(key));
|
|
158
|
+
let after = null;
|
|
159
|
+
if (entry.block) {
|
|
160
|
+
const remaining = textOf(before).replace(entry.block, '');
|
|
161
|
+
after = remaining === '' && entry.created ? null : asSnapshot(remaining, before);
|
|
162
|
+
}
|
|
163
|
+
changes.push({ key, before, after });
|
|
164
|
+
}
|
|
165
|
+
changes.push({
|
|
166
|
+
key: 'manifest',
|
|
167
|
+
before: snapshot(context.targets.get('manifest')),
|
|
168
|
+
after: null,
|
|
169
|
+
});
|
|
170
|
+
return changes;
|
|
171
|
+
}
|
|
172
|
+
export function run(command, options, source = packageRoot, failAfter) {
|
|
173
|
+
const context = layout(options);
|
|
174
|
+
assertSafe(context.state);
|
|
175
|
+
// Fail safely before even creating a lock or state directory.
|
|
176
|
+
if (command !== 'rollback' && pending(context))
|
|
177
|
+
throw new Error('Interrupted transaction requires rollback.');
|
|
178
|
+
const initial = command === 'rollback' ? null : loadManifest(context);
|
|
179
|
+
if (command === 'doctor') {
|
|
180
|
+
readContent(source);
|
|
181
|
+
if (!initial)
|
|
182
|
+
throw new Error('No installation manifest found for this scope.');
|
|
183
|
+
if (existsSync(path.join(context.state, 'operation.lock')))
|
|
184
|
+
throw new Error('Installation lock exists; check for an active or interrupted process.');
|
|
185
|
+
if (pending(context))
|
|
186
|
+
throw new Error('Interrupted transaction requires rollback.');
|
|
187
|
+
verify(context, initial);
|
|
188
|
+
checkOverrides(context, initial.agents);
|
|
189
|
+
const messages = [
|
|
190
|
+
'Manifest, content hashes, instruction blocks and six skill locations per installed agent verified.',
|
|
191
|
+
'Agent runtime discovery and semantic compliance require an isolated agent session.',
|
|
192
|
+
];
|
|
193
|
+
return { command, dryRun: options.dryRun, changes: [], transaction: null, messages };
|
|
194
|
+
}
|
|
195
|
+
const work = () => {
|
|
196
|
+
if (command === 'rollback') {
|
|
197
|
+
const result = rollback(context);
|
|
198
|
+
return {
|
|
199
|
+
command,
|
|
200
|
+
dryRun: options.dryRun,
|
|
201
|
+
changes: [`${result.changes} files`],
|
|
202
|
+
transaction: result.id,
|
|
203
|
+
messages: ['Backups retained.'],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (pending(context))
|
|
207
|
+
throw new Error('Interrupted transaction requires rollback.');
|
|
208
|
+
const previous = loadManifest(context);
|
|
209
|
+
if (previous)
|
|
210
|
+
verify(context, previous);
|
|
211
|
+
if (command === 'update' && !previous)
|
|
212
|
+
throw new Error('No installation to update. Run init first.');
|
|
213
|
+
const changes = command === 'uninstall'
|
|
214
|
+
? previous
|
|
215
|
+
? planUninstall(context, previous)
|
|
216
|
+
: []
|
|
217
|
+
: planInstall(context, previous, source);
|
|
218
|
+
const transaction = options.dryRun ? null : commit(context, changes, failAfter);
|
|
219
|
+
return {
|
|
220
|
+
command,
|
|
221
|
+
dryRun: options.dryRun,
|
|
222
|
+
changes: changes.map((x) => x.key),
|
|
223
|
+
transaction,
|
|
224
|
+
messages: command === 'uninstall'
|
|
225
|
+
? ['Only owned, unchanged content removed. Backups and empty directories retained.']
|
|
226
|
+
: ['Restart your agent and verify instruction and skill discovery.'],
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
if (options.dryRun)
|
|
230
|
+
return work();
|
|
231
|
+
// Plan first so ordinary conflicts never write even a lock or a backup.
|
|
232
|
+
if (command !== 'rollback') {
|
|
233
|
+
if (initial)
|
|
234
|
+
verify(context, initial);
|
|
235
|
+
if (command === 'update' && !initial)
|
|
236
|
+
throw new Error('No installation to update.');
|
|
237
|
+
if (command !== 'uninstall')
|
|
238
|
+
planInstall(context, initial, source);
|
|
239
|
+
}
|
|
240
|
+
if (command === 'uninstall' && !initial)
|
|
241
|
+
return work();
|
|
242
|
+
return locked(context, work);
|
|
243
|
+
}
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { contentFiles, skills } from './content.js';
|
|
4
|
+
export function layout(options, paths = path) {
|
|
5
|
+
const base = paths.resolve(options.scope === 'global' ? options.home : options.project);
|
|
6
|
+
if (options.scope === 'project' && base === paths.resolve(options.home)) {
|
|
7
|
+
throw new Error('Project installation in the home directory is ambiguous; choose a project directory.');
|
|
8
|
+
}
|
|
9
|
+
const state = paths.join(base, '.engineering-standards');
|
|
10
|
+
const framework = paths.join(state, 'framework');
|
|
11
|
+
const targets = new Map(contentFiles.map((name) => [`framework/${name}`, paths.join(framework, ...name.split('/'))]));
|
|
12
|
+
const claude = options.scope === 'global'
|
|
13
|
+
? paths.resolve(options.claudeHome ?? paths.join(base, '.claude'))
|
|
14
|
+
: paths.join(base, '.claude');
|
|
15
|
+
const codex = options.scope === 'global'
|
|
16
|
+
? paths.resolve(options.codexHome ?? paths.join(base, '.codex'))
|
|
17
|
+
: base;
|
|
18
|
+
targets.set('claude-instructions', paths.join(options.scope === 'global' ? claude : base, 'CLAUDE.md'));
|
|
19
|
+
targets.set('codex-instructions', paths.join(codex, 'AGENTS.md'));
|
|
20
|
+
for (const skill of skills) {
|
|
21
|
+
targets.set(`claude-skill/${skill}`, paths.join(claude, 'skills', skill, 'SKILL.md'));
|
|
22
|
+
targets.set(`codex-skill/${skill}`, paths.join(base, '.agents', 'skills', skill, 'SKILL.md'));
|
|
23
|
+
}
|
|
24
|
+
targets.set('manifest', paths.join(state, 'manifest.json'));
|
|
25
|
+
const values = [...targets.values()].map((x) => x.toLowerCase());
|
|
26
|
+
if (values.some((x) => [...x].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)))
|
|
27
|
+
throw new Error('Control characters in destination paths are unsupported');
|
|
28
|
+
if (new Set(values).size !== values.length)
|
|
29
|
+
throw new Error('Overlapping destination paths');
|
|
30
|
+
return { state, framework, targets, options };
|
|
31
|
+
}
|
|
32
|
+
export function defaults() {
|
|
33
|
+
const options = {
|
|
34
|
+
home: os.homedir(),
|
|
35
|
+
project: process.cwd(),
|
|
36
|
+
};
|
|
37
|
+
if (process.env.CODEX_HOME)
|
|
38
|
+
options.codexHome = path.resolve(process.env.CODEX_HOME);
|
|
39
|
+
if (process.env.CLAUDE_CONFIG_DIR)
|
|
40
|
+
options.claudeHome = path.resolve(process.env.CLAUDE_CONFIG_DIR);
|
|
41
|
+
return options;
|
|
42
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { asSnapshot, assertSafe, ensureDirectory, equal, replace, snapshot, textOf, } from './filesystem.js';
|
|
5
|
+
const journalDir = (context) => path.join(context.state, 'transactions');
|
|
6
|
+
export function journals(context) {
|
|
7
|
+
const directory = journalDir(context);
|
|
8
|
+
assertSafe(directory);
|
|
9
|
+
return existsSync(directory)
|
|
10
|
+
? readdirSync(directory)
|
|
11
|
+
.filter((x) => /^\d+-[a-f0-9-]+\.json$/.test(x))
|
|
12
|
+
.sort()
|
|
13
|
+
.reverse()
|
|
14
|
+
: [];
|
|
15
|
+
}
|
|
16
|
+
function validateSnapshot(value) {
|
|
17
|
+
if (value === null)
|
|
18
|
+
return;
|
|
19
|
+
if (!value ||
|
|
20
|
+
typeof value.data !== 'string' ||
|
|
21
|
+
value.data.length > 12 * 1024 * 1024 ||
|
|
22
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.data) ||
|
|
23
|
+
!Number.isInteger(value.mode) ||
|
|
24
|
+
value.mode < 0 ||
|
|
25
|
+
value.mode > 0o777)
|
|
26
|
+
throw new Error('Invalid transaction snapshot');
|
|
27
|
+
}
|
|
28
|
+
function readJournal(context, name) {
|
|
29
|
+
const value = JSON.parse(textOf(snapshot(path.join(journalDir(context), name))));
|
|
30
|
+
if (value.schema !== 1 ||
|
|
31
|
+
name !== `${value.id}.json` ||
|
|
32
|
+
!['pending', 'committed', 'rolled-back'].includes(value.status) ||
|
|
33
|
+
!Array.isArray(value.changes) ||
|
|
34
|
+
value.changes.length > context.targets.size)
|
|
35
|
+
throw new Error('Invalid transaction journal');
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const change of value.changes) {
|
|
38
|
+
if (!context.targets.has(change.key) || seen.has(change.key))
|
|
39
|
+
throw new Error('Invalid transaction target');
|
|
40
|
+
seen.add(change.key);
|
|
41
|
+
validateSnapshot(change.before);
|
|
42
|
+
validateSnapshot(change.after);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function save(context, journal) {
|
|
47
|
+
const serialized = `${JSON.stringify(journal, null, 2)}\n`;
|
|
48
|
+
if (Buffer.byteLength(serialized) > 8 * 1024 * 1024)
|
|
49
|
+
throw new Error('Transaction backup exceeds the 8 MiB safety limit');
|
|
50
|
+
replace(path.join(journalDir(context), `${journal.id}.json`), asSnapshot(serialized));
|
|
51
|
+
}
|
|
52
|
+
export function pending(context) {
|
|
53
|
+
return journals(context).some((name) => readJournal(context, name).status === 'pending');
|
|
54
|
+
}
|
|
55
|
+
export function locked(context, work) {
|
|
56
|
+
ensureDirectory(context.state);
|
|
57
|
+
const file = path.join(context.state, 'operation.lock');
|
|
58
|
+
assertSafe(file);
|
|
59
|
+
const fd = openSync(file, 'wx', 0o600);
|
|
60
|
+
try {
|
|
61
|
+
return work();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
unlinkSync(file);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function commit(context, changes, failAfter) {
|
|
69
|
+
if (!changes.length)
|
|
70
|
+
return null;
|
|
71
|
+
if (pending(context))
|
|
72
|
+
throw new Error('An interrupted transaction exists. Run rollback before continuing.');
|
|
73
|
+
for (const change of changes) {
|
|
74
|
+
const file = context.targets.get(change.key);
|
|
75
|
+
if (!file || !equal(snapshot(file), change.before))
|
|
76
|
+
throw new Error(`Concurrent modification: ${change.key}`);
|
|
77
|
+
}
|
|
78
|
+
const journal = {
|
|
79
|
+
schema: 1,
|
|
80
|
+
id: `${Date.now()}-${randomUUID()}`,
|
|
81
|
+
status: 'pending',
|
|
82
|
+
changes,
|
|
83
|
+
};
|
|
84
|
+
ensureDirectory(journalDir(context));
|
|
85
|
+
save(context, journal);
|
|
86
|
+
const completed = [];
|
|
87
|
+
try {
|
|
88
|
+
for (const change of changes) {
|
|
89
|
+
const file = context.targets.get(change.key);
|
|
90
|
+
if (!equal(snapshot(file), change.before))
|
|
91
|
+
throw new Error(`Concurrent modification: ${change.key}`);
|
|
92
|
+
replace(file, change.after);
|
|
93
|
+
completed.push(change);
|
|
94
|
+
if (completed.length === failAfter)
|
|
95
|
+
throw new Error('Injected transaction failure');
|
|
96
|
+
}
|
|
97
|
+
journal.status = 'committed';
|
|
98
|
+
save(context, journal);
|
|
99
|
+
return journal.id;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
for (const change of completed.reverse()) {
|
|
103
|
+
const file = context.targets.get(change.key);
|
|
104
|
+
if (!equal(snapshot(file), change.after))
|
|
105
|
+
throw new Error('Rollback conflict; preserve the journal and recover manually.', {
|
|
106
|
+
cause: error,
|
|
107
|
+
});
|
|
108
|
+
replace(file, change.before);
|
|
109
|
+
}
|
|
110
|
+
journal.status = 'rolled-back';
|
|
111
|
+
save(context, journal);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function rollback(context) {
|
|
116
|
+
const journal = journals(context)
|
|
117
|
+
.map((name) => readJournal(context, name))
|
|
118
|
+
.find((x) => x.status !== 'rolled-back');
|
|
119
|
+
if (!journal)
|
|
120
|
+
throw new Error('No transaction to roll back.');
|
|
121
|
+
for (const change of journal.changes) {
|
|
122
|
+
const current = snapshot(context.targets.get(change.key));
|
|
123
|
+
if (!equal(current, change.after) &&
|
|
124
|
+
!(journal.status === 'pending' && equal(current, change.before))) {
|
|
125
|
+
throw new Error(`Rollback would overwrite later edits: ${change.key}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!context.options.dryRun) {
|
|
129
|
+
journal.status = 'pending';
|
|
130
|
+
save(context, journal);
|
|
131
|
+
for (const change of [...journal.changes].reverse()) {
|
|
132
|
+
const file = context.targets.get(change.key);
|
|
133
|
+
const current = snapshot(file);
|
|
134
|
+
if (!equal(current, change.after) &&
|
|
135
|
+
!(journal.status === 'pending' && equal(current, change.before)))
|
|
136
|
+
throw new Error(`Concurrent modification: ${change.key}`);
|
|
137
|
+
replace(file, change.before);
|
|
138
|
+
}
|
|
139
|
+
journal.status = 'rolled-back';
|
|
140
|
+
save(context, journal);
|
|
141
|
+
}
|
|
142
|
+
return { id: journal.id, changes: journal.changes.length };
|
|
143
|
+
}
|