total-recall-brain 3.31.2 → 3.32.0
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/total-recall.mjs +2 -2
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
- package/src/cli/app/index.mjs +181 -1
- package/src/cli/command.mjs +269 -5
- package/src/cli/connect.mjs +1 -1
- package/src/cli/daemon.mjs +3 -3
- package/src/cli/init.mjs +54 -0
- package/src/cli/plugin/create.mjs +20 -10
- package/src/cli/repo-expert-generate.mjs +37 -3
- package/src/cli/skill-config.mjs +211 -0
- package/src/cli/skill.mjs +37 -2
- package/src/core/app-deploy/adapter-contract.mjs +176 -0
- package/src/core/app-deploy/design-tokens.mjs +148 -108
- package/src/core/app-deploy/plan.mjs +2 -2
- package/src/core/app-deploy/resolve.mjs +30 -2
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
- package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
- package/src/core/app-deploy/runtime-cli.mjs +254 -0
- package/src/core/app-deploy/ui-elements.mjs +473 -0
- package/src/core/app-deploy/verify.mjs +18 -0
- package/src/core/command-surface.mjs +129 -0
- package/src/core/daemon-control.mjs +31 -12
- package/src/core/daemon-loop.mjs +14 -7
- package/src/core/plugin-loader.mjs +12 -12
- package/src/core/schema.mjs +72 -13
- package/src/core/skill-config.mjs +376 -0
- package/src/core/skills-registry.mjs +147 -31
- package/src/core/surface.mjs +52 -7
- package/src/server/routes/system.mjs +1 -1
- package/plugins/code-quality/README.md +0 -25
- package/plugins/code-quality/cli.mjs +0 -25
- package/plugins/code-quality/plugin.json +0 -17
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI for a layered skill's repo-layer config. Shared by
|
|
3
|
+
* `total-recall skill config <id> …` and the per-skill command generated by
|
|
4
|
+
* `total-recall command create <name> --config-for-skill <id>`.
|
|
5
|
+
*
|
|
6
|
+
* Exit codes: 0 ok · 1 runtime failure (missing skill/contract, I/O) ·
|
|
7
|
+
* 2 usage or validation error.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createInterface } from 'node:readline/promises';
|
|
12
|
+
import {
|
|
13
|
+
SkillConfigError,
|
|
14
|
+
resolveSkillDir,
|
|
15
|
+
loadConfigSchema,
|
|
16
|
+
validateSkillConfig,
|
|
17
|
+
readSkillConfig,
|
|
18
|
+
readSkillConfigState,
|
|
19
|
+
projectSkillConfig,
|
|
20
|
+
recordPath,
|
|
21
|
+
writeSkillConfig,
|
|
22
|
+
getConfigValue,
|
|
23
|
+
setConfigValue,
|
|
24
|
+
parseCliValue,
|
|
25
|
+
listCollections,
|
|
26
|
+
parseCollectionItem,
|
|
27
|
+
addCollectionItem,
|
|
28
|
+
removeCollectionItem,
|
|
29
|
+
detectSkillConfig,
|
|
30
|
+
missingRequired,
|
|
31
|
+
checkSkillLayerContract,
|
|
32
|
+
} from '../core/skill-config.mjs';
|
|
33
|
+
|
|
34
|
+
function usage(prefix, collections) {
|
|
35
|
+
const nouns = Object.entries(collections)
|
|
36
|
+
.map(([noun, c]) => ` ${prefix} ${noun} list|add|remove Manage '${c.property}' (key: ${c.key})`)
|
|
37
|
+
.join('\n');
|
|
38
|
+
return `Usage:
|
|
39
|
+
${prefix} config get [path] Print the repo config or one value
|
|
40
|
+
${prefix} config set <path> <value> Set a value (JSON or plain string), validated
|
|
41
|
+
${prefix} config unset <path> Remove a value, validated
|
|
42
|
+
${prefix} config validate Check the record against the contract and config.json for drift
|
|
43
|
+
${prefix} config rebuild Regenerate config.json from the SSSS record
|
|
44
|
+
${prefix} config import Record an existing config.json (adoption)
|
|
45
|
+
${prefix} config check Contract checks: record, validity, drift, gate commands
|
|
46
|
+
${prefix} config schema Print core/config.schema.json
|
|
47
|
+
${nouns ? `${nouns}\n` : ''} ${prefix} detect [--apply] [--force] Propose a config from the repo (core/detect.mjs)
|
|
48
|
+
${prefix} init [--yes] [--force] Create config.json from defaults, detection and prompts
|
|
49
|
+
|
|
50
|
+
Options: --json (structured output) · --repo <path> (with 'skill config')`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function topLevelDefaults(schema) {
|
|
54
|
+
const defaults = {};
|
|
55
|
+
for (const [key, definition] of Object.entries(schema.properties || {})) {
|
|
56
|
+
if (definition && Object.hasOwn(definition, 'default')) defaults[key] = structuredClone(definition.default);
|
|
57
|
+
}
|
|
58
|
+
return defaults;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function promptMissing(schema, config, keys) {
|
|
62
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
63
|
+
try {
|
|
64
|
+
let next = config;
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const definition = schema.properties?.[key] || {};
|
|
67
|
+
const hint = definition.enum ? ` (${definition.enum.join('|')})` : definition.type ? ` (${definition.type})` : '';
|
|
68
|
+
const label = definition.description ? `${key} — ${definition.description}` : key;
|
|
69
|
+
const answer = (await rl.question(`${label}${hint}: `)).trim();
|
|
70
|
+
if (!answer) throw new SkillConfigError(`'${key}' is required`);
|
|
71
|
+
next = setConfigValue(next, key, parseCliValue(answer));
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
} finally {
|
|
75
|
+
rl.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} skillId
|
|
81
|
+
* @param {string[]} args verb and arguments, e.g. ['config', 'set', 'tier', 'fast']
|
|
82
|
+
* @param {{ repoRoot: string, prefix?: string }} opts
|
|
83
|
+
*/
|
|
84
|
+
export async function runSkillConfig(skillId, args, { repoRoot, prefix = `total-recall skill config ${skillId}` } = {}) {
|
|
85
|
+
const json = args.includes('--json');
|
|
86
|
+
const flags = new Set(args.filter((arg) => arg.startsWith('--')));
|
|
87
|
+
const positional = args.filter((arg) => !arg.startsWith('--'));
|
|
88
|
+
const out = (payload, text) => {
|
|
89
|
+
if (json) console.log(JSON.stringify({ ok: true, ...payload }));
|
|
90
|
+
else if (text !== undefined) console.log(text);
|
|
91
|
+
return { ok: true, ...payload };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const skillDir = resolveSkillDir(repoRoot, skillId);
|
|
96
|
+
const contract = loadConfigSchema(skillDir);
|
|
97
|
+
const collections = listCollections(contract.schema);
|
|
98
|
+
const [verb, action, ...rest] = positional;
|
|
99
|
+
|
|
100
|
+
if (!verb || verb === 'help' || flags.has('--help')) {
|
|
101
|
+
console.log(usage(prefix, collections));
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (verb === 'config') {
|
|
106
|
+
const state = readSkillConfigState(skillDir);
|
|
107
|
+
const current = state.config;
|
|
108
|
+
if (action === 'schema') return out({ schema: contract.schema }, JSON.stringify(contract.schema, null, 2));
|
|
109
|
+
if (action === 'validate') {
|
|
110
|
+
if (current === null) throw new SkillConfigError('No config yet; run init or detect --apply', { exitCode: 1 });
|
|
111
|
+
const result = validateSkillConfig(contract, current);
|
|
112
|
+
if (!result.valid) throw new SkillConfigError(`${state.source === 'record' ? recordPath(skillId) : 'config.json'} is invalid:\n ${result.errors.join('\n ')}`, { issues: result.errors });
|
|
113
|
+
if (state.source !== 'record') throw new SkillConfigError(`config.json is valid but not recorded in SSSS; run '${prefix} config import'`, { exitCode: 1 });
|
|
114
|
+
if (!state.inSync) throw new SkillConfigError(`config.json has drifted from ${recordPath(skillId)}; run '${prefix} config rebuild'`, { exitCode: 1 });
|
|
115
|
+
return out({ valid: true, in_sync: true }, `${recordPath(skillId)} satisfies core/config.schema.json and config.json matches it`);
|
|
116
|
+
}
|
|
117
|
+
if (action === 'check') {
|
|
118
|
+
const report = checkSkillLayerContract(skillDir);
|
|
119
|
+
const text = report.checks.map((c) => `${c.ok ? '✔' : c.level === 'warn' ? '⚠' : '✖'} ${c.message}`).join('\n');
|
|
120
|
+
if (json) console.log(JSON.stringify({ ok: report.ok, exit_code: report.ok ? 0 : 1, ...report }));
|
|
121
|
+
else console.log(text);
|
|
122
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
123
|
+
return report;
|
|
124
|
+
}
|
|
125
|
+
if (action === 'rebuild') {
|
|
126
|
+
if (state.source !== 'record') throw new SkillConfigError(`No ${recordPath(skillId)} record to rebuild from`, { exitCode: 1 });
|
|
127
|
+
projectSkillConfig(skillDir, state.record.config);
|
|
128
|
+
return out({ config: state.record.config }, '✔ Rebuilt config.json from the SSSS record');
|
|
129
|
+
}
|
|
130
|
+
if (action === 'import') {
|
|
131
|
+
if (state.source === 'record') return out({ config: current, imported: false }, `Already recorded at ${recordPath(skillId)}`);
|
|
132
|
+
if (state.projection === null) throw new SkillConfigError('No config.json to import', { exitCode: 1 });
|
|
133
|
+
const written = await writeSkillConfig(skillDir, contract, state.projection);
|
|
134
|
+
return out({ config: written, imported: true }, `✔ Recorded config.json at ${recordPath(skillId)}`);
|
|
135
|
+
}
|
|
136
|
+
if (action === 'get') {
|
|
137
|
+
const value = rest[0] ? getConfigValue(current ?? {}, rest[0]) : current;
|
|
138
|
+
if (rest[0] && value === undefined) throw new SkillConfigError(`No value at '${rest[0]}'`, { exitCode: 1 });
|
|
139
|
+
return out({ value: value ?? null }, typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
|
|
140
|
+
}
|
|
141
|
+
if (action === 'set' || action === 'unset') {
|
|
142
|
+
if (!rest[0] || (action === 'set' && rest.length < 2)) throw new SkillConfigError(`Usage: ${prefix} config ${action} <path>${action === 'set' ? ' <value>' : ''}`);
|
|
143
|
+
if (current === null) throw new SkillConfigError('No config.json yet; run init or detect --apply first', { exitCode: 1 });
|
|
144
|
+
const value = action === 'set' ? parseCliValue(rest.slice(1).join(' ')) : undefined;
|
|
145
|
+
const written = await writeSkillConfig(skillDir, contract, setConfigValue(current, rest[0], value));
|
|
146
|
+
return out({ config: written }, `✔ ${action === 'set' ? 'Set' : 'Removed'} ${rest[0]}`);
|
|
147
|
+
}
|
|
148
|
+
throw new SkillConfigError(`Unknown config action '${action ?? ''}'\n${usage(prefix, collections)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (Object.hasOwn(collections, verb)) {
|
|
152
|
+
const collection = collections[verb];
|
|
153
|
+
const current = readSkillConfig(skillDir);
|
|
154
|
+
if (action === 'list') {
|
|
155
|
+
const items = getConfigValue(current ?? {}, collection.property) ?? [];
|
|
156
|
+
return out({ items }, items.length ? items.map((item) => `- ${item?.[collection.key]}: ${JSON.stringify(item)}`).join('\n') : `No ${collection.property}.`);
|
|
157
|
+
}
|
|
158
|
+
if (current === null) throw new SkillConfigError('No config.json yet; run init or detect --apply first', { exitCode: 1 });
|
|
159
|
+
if (action === 'add') {
|
|
160
|
+
const item = parseCollectionItem(rest);
|
|
161
|
+
const written = await writeSkillConfig(skillDir, contract, addCollectionItem(current, collection, item));
|
|
162
|
+
return out({ config: written }, `✔ Added ${verb} '${item[collection.key]}'`);
|
|
163
|
+
}
|
|
164
|
+
if (action === 'remove') {
|
|
165
|
+
if (!rest[0]) throw new SkillConfigError(`Usage: ${prefix} ${verb} remove <${collection.key}>`);
|
|
166
|
+
const written = await writeSkillConfig(skillDir, contract, removeCollectionItem(current, collection, rest[0]));
|
|
167
|
+
return out({ config: written }, `✔ Removed ${verb} '${rest[0]}'`);
|
|
168
|
+
}
|
|
169
|
+
throw new SkillConfigError(`Usage: ${prefix} ${verb} list|add|remove`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (verb === 'detect') {
|
|
173
|
+
const proposal = await detectSkillConfig(skillDir, repoRoot);
|
|
174
|
+
const checked = validateSkillConfig(contract, proposal);
|
|
175
|
+
if (!flags.has('--apply')) {
|
|
176
|
+
return out({ proposal, valid: checked.valid, errors: checked.errors },
|
|
177
|
+
`${JSON.stringify(proposal, null, 2)}\n${checked.valid ? 'Valid. Re-run with --apply to write config.json.' : `Invalid:\n ${checked.errors.join('\n ')}`}`);
|
|
178
|
+
}
|
|
179
|
+
if (readSkillConfig(skillDir) !== null && !flags.has('--force')) {
|
|
180
|
+
throw new SkillConfigError('A config already exists; use --force to replace it with the detected config', { exitCode: 1 });
|
|
181
|
+
}
|
|
182
|
+
const written = await writeSkillConfig(skillDir, contract, proposal);
|
|
183
|
+
return out({ config: written }, '✔ Recorded the detected config and wrote config.json');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (verb === 'init') {
|
|
187
|
+
if (readSkillConfig(skillDir) !== null && !flags.has('--force')) {
|
|
188
|
+
throw new SkillConfigError(`A config already exists; change it with '${prefix} config set' or pass --force`, { exitCode: 1 });
|
|
189
|
+
}
|
|
190
|
+
let config = topLevelDefaults(contract.schema);
|
|
191
|
+
if (fs.existsSync(path.join(skillDir, 'core', 'detect.mjs'))) config = { ...config, ...(await detectSkillConfig(skillDir, repoRoot)) };
|
|
192
|
+
const missing = missingRequired(contract.schema, config);
|
|
193
|
+
if (missing.length) {
|
|
194
|
+
if (flags.has('--yes') || json || !process.stdin.isTTY) {
|
|
195
|
+
throw new SkillConfigError(`Missing required fields: ${missing.join(', ')}. Run interactively or set them after 'detect --apply'.`);
|
|
196
|
+
}
|
|
197
|
+
config = await promptMissing(contract.schema, config, missing);
|
|
198
|
+
}
|
|
199
|
+
const written = await writeSkillConfig(skillDir, contract, config);
|
|
200
|
+
return out({ config: written }, `✔ Initialized ${path.relative(process.cwd(), path.join(skillDir, 'config.json')) || 'config.json'}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new SkillConfigError(`Unknown subcommand '${verb}'\n${usage(prefix, collections)}`);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
206
|
+
if (json) console.log(JSON.stringify({ ok: false, exit_code: exitCode, error: error.message, ...(error.issues?.length ? { issues: error.issues } : {}) }));
|
|
207
|
+
else console.error(`Error: ${error.message}`);
|
|
208
|
+
process.exitCode = exitCode;
|
|
209
|
+
return { ok: false, exit_code: exitCode, error: error.message };
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/cli/skill.mjs
CHANGED
|
@@ -68,6 +68,8 @@ function printHelp() {
|
|
|
68
68
|
registry List catalog entries in skills-registry/index.yaml
|
|
69
69
|
deploy <id> Copy skill into a repo (.agent/skills/<id>/) and record install map
|
|
70
70
|
status [id] Registry + install map + drift (omit id = summary)
|
|
71
|
+
config <id> … Repo-layer config from core/config.schema.json (get|set|unset|rebuild|import|check|
|
|
72
|
+
validate|schema, <collection> list|add|remove, detect, init)
|
|
71
73
|
sync-registry Register all local brain skills into the catalog
|
|
72
74
|
unregister <id> Remove catalog entry (does not delete repo copies)
|
|
73
75
|
track <path> Track any repo (full project brain + registry) for skill sync
|
|
@@ -131,6 +133,24 @@ function parseFlagValue(args, flag) {
|
|
|
131
133
|
|
|
132
134
|
export default async function skillCli(args) {
|
|
133
135
|
const { layer, remainingArgs } = parseLayerFlag(args);
|
|
136
|
+
if (remainingArgs[0] === 'config') {
|
|
137
|
+
// Repo-layer config is per repository; it needs no brain resolution.
|
|
138
|
+
const repoAt = remainingArgs.indexOf('--repo');
|
|
139
|
+
const repoRoot = repoAt === -1 ? process.cwd() : remainingArgs[repoAt + 1];
|
|
140
|
+
const rest = repoAt === -1 ? remainingArgs : remainingArgs.filter((_, i) => i !== repoAt && i !== repoAt + 1);
|
|
141
|
+
const skillId = rest[1];
|
|
142
|
+
if (!skillId || !repoRoot) {
|
|
143
|
+
console.error('❌ Usage: total-recall skill config <id> <config|detect|init|collection> … [--repo <path>] [--json]');
|
|
144
|
+
process.exitCode = 2;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const { runSkillConfig } = await import('./skill-config.mjs');
|
|
148
|
+
const verbArgs = rest.slice(2);
|
|
149
|
+
// `skill config <id> get x` reads as the config verb; `skill config <id> detect` stays a verb.
|
|
150
|
+
const normalized = ['get', 'set', 'unset', 'validate', 'schema', 'rebuild', 'import', 'check'].includes(verbArgs[0]) ? ['config', ...verbArgs] : verbArgs;
|
|
151
|
+
await runSkillConfig(skillId, normalized, { repoRoot });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
134
154
|
if (remainingArgs.length === 0 || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
|
|
135
155
|
printHelp();
|
|
136
156
|
return;
|
|
@@ -486,6 +506,9 @@ Provide a high-level explanation of the skill's capabilities and context.
|
|
|
486
506
|
console.log(`\n ✅ Deployed: ${result.skillId}`);
|
|
487
507
|
console.log(` → ${result.destDir}`);
|
|
488
508
|
console.log(` hash=${result.install.content_hash} adapted=${result.adapt.adapted}`);
|
|
509
|
+
if (result.install.layered) {
|
|
510
|
+
console.log(` core=${result.install.core_hash} repo=${result.install.repo_hash}`);
|
|
511
|
+
}
|
|
489
512
|
console.log(` Install map updated in ${resolveRegistryPath(brainDir)}\n`);
|
|
490
513
|
} catch (err) {
|
|
491
514
|
console.error(`❌ Deploy failed: ${err.message}`);
|
|
@@ -518,13 +541,25 @@ Provide a high-level explanation of the skill's capabilities and context.
|
|
|
518
541
|
console.log(` Version: ${st.entry.version} hash=${st.entry.content_hash}`);
|
|
519
542
|
console.log(` Source: ${st.entry.source_path || st.entry.source}`);
|
|
520
543
|
}
|
|
521
|
-
console.log(` Installs: ${st.install_count} drift=${st.any_drift ? 'YES' : 'no'}`);
|
|
544
|
+
console.log(` Installs: ${st.install_count} drift=${st.any_drift ? 'YES' : 'no'}${st.any_contract_failure ? ' contract=FAILED' : ''}`);
|
|
522
545
|
for (const inst of st.installs) {
|
|
523
546
|
console.log(
|
|
524
|
-
` • ${inst.path}\n exists=${inst.exists} live=${inst.live_hash || '-'} drift=${inst.drift}
|
|
547
|
+
` • ${inst.path}\n exists=${inst.exists} live=${inst.live_hash || '-'} drift=${inst.drift}` +
|
|
548
|
+
(inst.adopted === false ? ' (legacy copy, not adopted: deploy to adopt)' : '') +
|
|
549
|
+
(inst.error ? `\n error=${inst.error}` : ''),
|
|
525
550
|
);
|
|
551
|
+
if (inst.layered) {
|
|
552
|
+
console.log(` core=${inst.core_hash || '-'} repo=${inst.repo_hash || '-'} repo_changed=${inst.repo_changed}`);
|
|
553
|
+
}
|
|
554
|
+
if (inst.contract) {
|
|
555
|
+
console.log(` contract=${inst.contract.ok ? 'ok' : 'FAILED'}`);
|
|
556
|
+
for (const c of inst.contract.checks.filter((check) => !check.ok)) {
|
|
557
|
+
console.log(` ${c.level === 'warn' ? '⚠' : '✖'} ${c.message}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
526
560
|
}
|
|
527
561
|
console.log('');
|
|
562
|
+
if (st.any_contract_failure) process.exitCode = 1;
|
|
528
563
|
} catch (err) {
|
|
529
564
|
console.error(`❌ ${err.message}`);
|
|
530
565
|
process.exit(1);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared conformance boundary for app deployment adapters.
|
|
3
|
+
*
|
|
4
|
+
* An adapter owns the target runtime, but every adapter must prove the same
|
|
5
|
+
* lifecycle against an isolated app. This runner intentionally uses the SSSS
|
|
6
|
+
* package's canonical operation fixture rather than a Total Recall-only record.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export const ADAPTER_CONTRACT_VERSION = '1.0.0';
|
|
14
|
+
export const ADAPTER_METHODS = Object.freeze([
|
|
15
|
+
'detect',
|
|
16
|
+
'generate',
|
|
17
|
+
'executeSsss',
|
|
18
|
+
'rebuildProjection',
|
|
19
|
+
'start',
|
|
20
|
+
'cleanup'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function assertAdapterContract(adapter) {
|
|
24
|
+
if (!adapter || typeof adapter !== 'object' || !/^[a-z][a-z0-9-]*$/.test(adapter.id || '')) {
|
|
25
|
+
throw new TypeError('Adapter must have a safe, lowercase id');
|
|
26
|
+
}
|
|
27
|
+
for (const method of ADAPTER_METHODS) {
|
|
28
|
+
if (typeof adapter[method] !== 'function') {
|
|
29
|
+
throw new TypeError(`Adapter '${adapter.id}' must implement ${method}()`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return adapter;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fail(stage, message) {
|
|
36
|
+
throw new Error(`Adapter contract ${stage}: ${message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function digest(file) {
|
|
40
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function inside(parent, child) {
|
|
44
|
+
const relative = path.relative(parent, child);
|
|
45
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function snapshot(dir) {
|
|
49
|
+
const entries = [];
|
|
50
|
+
function walk(current, prefix = '') {
|
|
51
|
+
for (const item of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
52
|
+
const relative = path.posix.join(prefix, item.name);
|
|
53
|
+
const absolute = path.join(current, item.name);
|
|
54
|
+
const stat = fs.lstatSync(absolute);
|
|
55
|
+
if (stat.isSymbolicLink()) fail('generation', `symlink '${relative}' is not portable`);
|
|
56
|
+
if (stat.isDirectory()) walk(absolute, relative);
|
|
57
|
+
else if (stat.isFile()) entries.push([relative, digest(absolute)]);
|
|
58
|
+
else fail('generation', `unsupported file '${relative}'`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
walk(dir);
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the same lifecycle proof for each declared adapter. The caller supplies
|
|
67
|
+
* a canonical SSSS operation fixture with request and expected_response.
|
|
68
|
+
* Adapter methods receive an isolated target and never the operator's vault.
|
|
69
|
+
*/
|
|
70
|
+
export async function runAdapterContractFixtures(adapter, operationFixture) {
|
|
71
|
+
assertAdapterContract(adapter);
|
|
72
|
+
if (!operationFixture?.request || operationFixture.expected_response?.success !== true) {
|
|
73
|
+
throw new TypeError('A successful canonical SSSS operation fixture is required');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-adapter-contract-'));
|
|
77
|
+
const target = path.join(root, 'app');
|
|
78
|
+
const first = path.join(root, 'generated-a');
|
|
79
|
+
const second = path.join(root, 'generated-b');
|
|
80
|
+
const vault = path.join(target, 'vault');
|
|
81
|
+
const privateFile = path.join(vault, 'tenant-private.txt');
|
|
82
|
+
let runtime;
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(vault, { recursive: true });
|
|
85
|
+
fs.mkdirSync(first);
|
|
86
|
+
fs.mkdirSync(second);
|
|
87
|
+
fs.writeFileSync(privateFile, 'private user data\n');
|
|
88
|
+
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
|
|
89
|
+
name: 'adapter-contract-app',
|
|
90
|
+
version: '0.0.0',
|
|
91
|
+
type: 'module',
|
|
92
|
+
ssss: { adapter: adapter.id }
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const targetBefore = snapshot(target);
|
|
96
|
+
const detection = await adapter.detect(target);
|
|
97
|
+
if (detection?.supported !== true || detection.adapter !== adapter.id) {
|
|
98
|
+
fail('detection', `expected a compatible '${adapter.id}' target`);
|
|
99
|
+
}
|
|
100
|
+
if (JSON.stringify(snapshot(target)) !== JSON.stringify(targetBefore)) {
|
|
101
|
+
fail('detection', 'target detection changed the app');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await adapter.generate({ target, output: first });
|
|
105
|
+
await adapter.generate({ target, output: second });
|
|
106
|
+
const generated = snapshot(first);
|
|
107
|
+
if (generated.length === 0 || JSON.stringify(generated) !== JSON.stringify(snapshot(second))) {
|
|
108
|
+
fail('generation', 'source output must be nonempty and deterministic');
|
|
109
|
+
}
|
|
110
|
+
if (JSON.stringify(snapshot(target)) !== JSON.stringify(targetBefore)) {
|
|
111
|
+
fail('generation', 'planning/generation changed the target app');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const request = structuredClone(operationFixture.request);
|
|
115
|
+
const denied = await adapter.executeSsss(request, { target, principal: null });
|
|
116
|
+
if (denied?.success !== false) fail('kernel bridge', 'missing verified principal was accepted');
|
|
117
|
+
|
|
118
|
+
const accepted = await adapter.executeSsss(request, {
|
|
119
|
+
target,
|
|
120
|
+
principal: { id: 'fixture-admin', role: 'system', verified: true }
|
|
121
|
+
});
|
|
122
|
+
if (accepted?.success !== true || accepted?.validation?.valid !== true) {
|
|
123
|
+
fail('kernel bridge', `canonical operation failed: ${JSON.stringify(accepted?.validation?.errors || [])}`);
|
|
124
|
+
}
|
|
125
|
+
const document = path.join(vault, request.path);
|
|
126
|
+
if (!fs.existsSync(document)) fail('kernel bridge', 'canonical document was not committed to the app vault');
|
|
127
|
+
const canonicalHash = digest(document);
|
|
128
|
+
const replay = await adapter.executeSsss(request, {
|
|
129
|
+
target,
|
|
130
|
+
principal: { id: 'fixture-admin', role: 'system', verified: true }
|
|
131
|
+
});
|
|
132
|
+
if (replay?.success !== true || replay?.replay !== true || digest(document) !== canonicalHash) {
|
|
133
|
+
fail('kernel bridge', 'same operation did not replay idempotently');
|
|
134
|
+
}
|
|
135
|
+
const escape = { ...request, idempotency_key: `${request.idempotency_key}-escape`, path: '../outside.md' };
|
|
136
|
+
const escaped = await adapter.executeSsss(escape, {
|
|
137
|
+
target,
|
|
138
|
+
principal: { id: 'fixture-admin', role: 'system', verified: true }
|
|
139
|
+
});
|
|
140
|
+
if (escaped?.success !== false || fs.existsSync(path.join(target, 'outside.md'))) {
|
|
141
|
+
fail('kernel bridge', 'path escape was accepted');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const projection = await adapter.rebuildProjection({ target });
|
|
145
|
+
const projectionPath = projection?.path && path.resolve(projection.path);
|
|
146
|
+
if (!projectionPath || !inside(target, projectionPath) || inside(vault, projectionPath)
|
|
147
|
+
|| !fs.existsSync(projectionPath) || !fs.lstatSync(projectionPath).isFile()
|
|
148
|
+
|| !inside(fs.realpathSync(target), fs.realpathSync(projectionPath))) {
|
|
149
|
+
fail('projection', 'rebuild must produce a regular file inside the app and outside its vault');
|
|
150
|
+
}
|
|
151
|
+
const projectionHash = digest(projectionPath);
|
|
152
|
+
fs.unlinkSync(projectionPath);
|
|
153
|
+
await adapter.rebuildProjection({ target });
|
|
154
|
+
if (!fs.existsSync(projectionPath) || digest(projectionPath) !== projectionHash || digest(document) !== canonicalHash) {
|
|
155
|
+
fail('projection', 'rebuild is nondeterministic or changed canonical state');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
runtime = await adapter.start({ target });
|
|
159
|
+
if (!runtime || typeof runtime.health !== 'function' || typeof runtime.stop !== 'function') {
|
|
160
|
+
fail('runtime', 'start must return health() and stop()');
|
|
161
|
+
}
|
|
162
|
+
const health = await runtime.health();
|
|
163
|
+
if (health?.status !== 'healthy') fail('runtime', 'health check did not report healthy');
|
|
164
|
+
await runtime.stop();
|
|
165
|
+
runtime = null;
|
|
166
|
+
|
|
167
|
+
await adapter.cleanup({ target, generated: [first, second] });
|
|
168
|
+
if (fs.readFileSync(privateFile, 'utf8') !== 'private user data\n' || digest(document) !== canonicalHash) {
|
|
169
|
+
fail('cleanup', 'tenant-private or canonical app data changed');
|
|
170
|
+
}
|
|
171
|
+
return { valid: true, adapter: adapter.id, contract_version: ADAPTER_CONTRACT_VERSION };
|
|
172
|
+
} finally {
|
|
173
|
+
if (runtime?.stop) await runtime.stop();
|
|
174
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
}
|