@aikdna/kdna-cli 0.17.0 → 0.18.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/README.md +42 -23
- package/package.json +5 -4
- package/skills/kdna-loader/SKILL.md +5 -6
- package/src/agent.js +145 -109
- package/src/cli.js +104 -60
- package/src/cmds/_common.js +32 -16
- package/src/cmds/badge.js +7 -7
- package/src/cmds/changelog.js +1 -1
- package/src/cmds/cluster.js +16 -48
- package/src/cmds/doctor.js +10 -27
- package/src/cmds/domain.js +77 -400
- package/src/cmds/explain.js +122 -0
- package/src/cmds/legacy.js +8 -8
- package/src/cmds/license.js +483 -26
- package/src/cmds/quality.js +2 -2
- package/src/cmds/registry.js +15 -67
- package/src/cmds/studio.js +4 -5
- package/src/cmds/test.js +4 -4
- package/src/cmds/trace.js +11 -7
- package/src/compare.js +28 -22
- package/src/diff.js +11 -13
- package/src/init.js +2 -2
- package/src/install.js +138 -460
- package/src/loader.js +10 -10
- package/src/package-store.js +229 -0
- package/src/paths.js +44 -0
- package/src/publish.js +73 -22
- package/src/registry.js +76 -9
- package/src/setup.js +19 -20
- package/src/verify.js +233 -124
- package/templates/standard-domain/kdna.json +2 -1
- package/src/cmds/encrypt.js +0 -199
package/src/loader.js
CHANGED
|
@@ -15,8 +15,8 @@ const FILE_MAP = core.FILE_MAP;
|
|
|
15
15
|
* Read and parse a KDNA JSON file.
|
|
16
16
|
* Returns null if the file does not exist.
|
|
17
17
|
*/
|
|
18
|
-
function readFile(
|
|
19
|
-
const filePath = path.join(
|
|
18
|
+
function readFile(sourceDir, filename) {
|
|
19
|
+
const filePath = path.join(sourceDir, filename);
|
|
20
20
|
if (!fs.existsSync(filePath)) return null;
|
|
21
21
|
try {
|
|
22
22
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -29,32 +29,32 @@ function readFile(domainDir, filename) {
|
|
|
29
29
|
* Load the minimum required KDNA files (Core + Patterns).
|
|
30
30
|
* Always load these. They form the cognition baseline.
|
|
31
31
|
*/
|
|
32
|
-
function loadCorePatterns(
|
|
33
|
-
const coreData = readFile(
|
|
34
|
-
const patternsData = readFile(
|
|
32
|
+
function loadCorePatterns(sourceDir) {
|
|
33
|
+
const coreData = readFile(sourceDir, FILE_MAP.core);
|
|
34
|
+
const patternsData = readFile(sourceDir, FILE_MAP.patterns);
|
|
35
35
|
return core.loadCorePatternsFromData(coreData, patternsData);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Load a complete KDNA domain.
|
|
40
40
|
*
|
|
41
|
-
* @param {string}
|
|
41
|
+
* @param {string} sourceDir — path to a dev source directory
|
|
42
42
|
* @param {object} [options]
|
|
43
43
|
* @param {string} [options.input] — user input text for conditional loading
|
|
44
44
|
* @param {'all'|'minimum'|'auto'} [options.mode='auto'] — loading mode
|
|
45
45
|
* @returns {object|null} loaded KDNA files keyed by type, or null if minimum files are missing
|
|
46
46
|
*/
|
|
47
|
-
function loadDomain(
|
|
47
|
+
function loadDomain(sourceDir, options = {}) {
|
|
48
48
|
const dataMap = { core: null, patterns: null };
|
|
49
49
|
|
|
50
|
-
dataMap.core = readFile(
|
|
51
|
-
dataMap.patterns = readFile(
|
|
50
|
+
dataMap.core = readFile(sourceDir, FILE_MAP.core);
|
|
51
|
+
dataMap.patterns = readFile(sourceDir, FILE_MAP.patterns);
|
|
52
52
|
|
|
53
53
|
if (!dataMap.core || !dataMap.patterns) return null;
|
|
54
54
|
|
|
55
55
|
// Also read optional files that might be present
|
|
56
56
|
for (const key of ['scenarios', 'cases', 'reasoning', 'evolution']) {
|
|
57
|
-
const data = readFile(
|
|
57
|
+
const data = readFile(sourceDir, FILE_MAP[key]);
|
|
58
58
|
if (data) dataMap[key] = data;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const PATHS = require('./paths');
|
|
5
|
+
const { parseName } = require('./registry');
|
|
6
|
+
const core = require('@aikdna/kdna-core');
|
|
7
|
+
|
|
8
|
+
if (typeof core.createKdnaAssetReader !== 'function') {
|
|
9
|
+
throw new Error('@aikdna/kdna-core >=0.5.0 is required for direct .kdna asset loading');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const assetReader = core.createKdnaAssetReader();
|
|
13
|
+
|
|
14
|
+
const INDEX_VERSION = 2;
|
|
15
|
+
|
|
16
|
+
function ensureDir(dir) {
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readJsonFile(file) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJsonFile(file, data) {
|
|
29
|
+
ensureDir(path.dirname(file));
|
|
30
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readIndex() {
|
|
34
|
+
const data = readJsonFile(PATHS.packageIndex);
|
|
35
|
+
if (data?.packages && typeof data.packages === 'object') return data;
|
|
36
|
+
return { version: INDEX_VERSION, packages: {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeIndex(index) {
|
|
40
|
+
index.version = INDEX_VERSION;
|
|
41
|
+
writeJsonFile(PATHS.packageIndex, index);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sha256File(filePath) {
|
|
45
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assetDigest(filePath) {
|
|
49
|
+
return `sha256:${sha256File(filePath)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function contentDigest(kdnaPath) {
|
|
53
|
+
return assetReader.contentDigestSync(assetReader.openSync(kdnaPath));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assetDir(scope, ident, version) {
|
|
57
|
+
return path.join(PATHS.packages, scope, ident, version || 'unknown');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assetFileName(ident, version) {
|
|
61
|
+
return `${ident}-${version || 'unknown'}.kdna`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readContainerJson(kdnaPath, fileName, options = {}) {
|
|
65
|
+
const asset = assetReader.openSync(kdnaPath);
|
|
66
|
+
return assetReader.readJsonSync(asset, fileName, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readContainerEntry(kdnaPath, fileName) {
|
|
70
|
+
const asset = assetReader.openSync(kdnaPath);
|
|
71
|
+
return assetReader.readEntrySync(asset, fileName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function listContainerEntries(kdnaPath) {
|
|
75
|
+
const asset = assetReader.openSync(kdnaPath);
|
|
76
|
+
return assetReader.listEntriesSync(asset);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readContainer(kdnaPath, options = {}) {
|
|
80
|
+
const asset = assetReader.openSync(kdnaPath);
|
|
81
|
+
return {
|
|
82
|
+
manifest: assetReader.readJsonSync(asset, 'kdna.json'),
|
|
83
|
+
core: assetReader.readJsonSync(asset, 'KDNA_Core.json', options),
|
|
84
|
+
patterns: assetReader.readJsonSync(asset, 'KDNA_Patterns.json', options),
|
|
85
|
+
scenarios: assetReader.readJsonSync(asset, 'KDNA_Scenarios.json', options),
|
|
86
|
+
cases: assetReader.readJsonSync(asset, 'KDNA_Cases.json', options),
|
|
87
|
+
reasoning: assetReader.readJsonSync(asset, 'KDNA_Reasoning.json', options),
|
|
88
|
+
evolution: assetReader.readJsonSync(asset, 'KDNA_Evolution.json', options),
|
|
89
|
+
files: assetReader.listEntriesSync(asset),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifyAsset(kdnaPath, options = {}) {
|
|
94
|
+
const asset = assetReader.openSync(kdnaPath);
|
|
95
|
+
return assetReader.verifySync(asset, options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getInstalled(input) {
|
|
99
|
+
const parsed = parseName(input);
|
|
100
|
+
if (!parsed) return null;
|
|
101
|
+
const index = readIndex();
|
|
102
|
+
const entry = index.packages[parsed.full];
|
|
103
|
+
if (!entry?.asset_path || !fs.existsSync(entry.asset_path)) return null;
|
|
104
|
+
return { ...entry, parsed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listInstalled() {
|
|
108
|
+
const index = readIndex();
|
|
109
|
+
return Object.entries(index.packages)
|
|
110
|
+
.map(([full, entry]) => ({ full, ...entry }))
|
|
111
|
+
.filter((entry) => entry.asset_path && fs.existsSync(entry.asset_path))
|
|
112
|
+
.sort((a, b) => a.full.localeCompare(b.full));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readAssetManifest(assetPath) {
|
|
116
|
+
return readContainerJson(assetPath, 'kdna.json') || {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function receiptPathForAsset(assetPath) {
|
|
120
|
+
return path.join(path.dirname(assetPath), 'receipt.json');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function installAsset({ sourcePath, name, version, source = {} }) {
|
|
124
|
+
const parsed = parseName(name);
|
|
125
|
+
if (!parsed) throw new Error(`Invalid scoped domain name: ${name}`);
|
|
126
|
+
const finalVersion = version || 'unknown';
|
|
127
|
+
const destDir = assetDir(parsed.scope, parsed.ident, finalVersion);
|
|
128
|
+
const dest = path.join(destDir, assetFileName(parsed.ident, finalVersion));
|
|
129
|
+
ensureDir(destDir);
|
|
130
|
+
fs.copyFileSync(sourcePath, dest);
|
|
131
|
+
|
|
132
|
+
const manifest = readAssetManifest(dest);
|
|
133
|
+
const installedAt = new Date().toISOString();
|
|
134
|
+
const computedAssetDigest = assetDigest(dest);
|
|
135
|
+
const computedContentDigest = contentDigest(dest);
|
|
136
|
+
const receiptPath = receiptPathForAsset(dest);
|
|
137
|
+
const receipt = {
|
|
138
|
+
version: 1,
|
|
139
|
+
name: parsed.full,
|
|
140
|
+
asset_path: dest,
|
|
141
|
+
asset_digest: computedAssetDigest,
|
|
142
|
+
content_digest: computedContentDigest,
|
|
143
|
+
package_version: finalVersion,
|
|
144
|
+
judgment_version: manifest.judgment_version || null,
|
|
145
|
+
access: manifest.access || 'open',
|
|
146
|
+
signature: manifest.signature || null,
|
|
147
|
+
installed_at: installedAt,
|
|
148
|
+
source,
|
|
149
|
+
};
|
|
150
|
+
writeJsonFile(receiptPath, receipt);
|
|
151
|
+
|
|
152
|
+
const index = readIndex();
|
|
153
|
+
index.packages[parsed.full] = {
|
|
154
|
+
name: parsed.full,
|
|
155
|
+
version: finalVersion,
|
|
156
|
+
asset_path: dest,
|
|
157
|
+
receipt_path: receiptPath,
|
|
158
|
+
asset_digest: computedAssetDigest,
|
|
159
|
+
content_digest: computedContentDigest,
|
|
160
|
+
judgment_version: manifest.judgment_version || null,
|
|
161
|
+
access: manifest.access || 'open',
|
|
162
|
+
signature: manifest.signature || null,
|
|
163
|
+
installed_at: installedAt,
|
|
164
|
+
source,
|
|
165
|
+
};
|
|
166
|
+
writeIndex(index);
|
|
167
|
+
return index.packages[parsed.full];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveAsset(input) {
|
|
171
|
+
const expanded = input.replace(/^~/, process.env.HOME || '');
|
|
172
|
+
const looksLikeFile =
|
|
173
|
+
input.endsWith('.kdna') &&
|
|
174
|
+
(input.startsWith('./') || input.startsWith('/') || input.startsWith('~/') || fs.existsSync(expanded));
|
|
175
|
+
if (looksLikeFile) {
|
|
176
|
+
const abs = path.resolve(expanded);
|
|
177
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
|
178
|
+
const manifest = readAssetManifest(abs);
|
|
179
|
+
const full = manifest.name;
|
|
180
|
+
const parsed = full ? parseName(full) : null;
|
|
181
|
+
return {
|
|
182
|
+
name: full || path.basename(abs, '.kdna'),
|
|
183
|
+
parsed,
|
|
184
|
+
asset_path: abs,
|
|
185
|
+
receipt_path: null,
|
|
186
|
+
version: manifest.version || null,
|
|
187
|
+
judgment_version: manifest.judgment_version || null,
|
|
188
|
+
access: manifest.access || 'open',
|
|
189
|
+
asset_digest: assetDigest(abs),
|
|
190
|
+
content_digest: contentDigest(abs),
|
|
191
|
+
source: { type: 'local-file', path: abs },
|
|
192
|
+
local_file: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return getInstalled(input);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function removeInstalled(input) {
|
|
199
|
+
const parsed = parseName(input);
|
|
200
|
+
if (!parsed) return false;
|
|
201
|
+
const index = readIndex();
|
|
202
|
+
const entry = index.packages[parsed.full];
|
|
203
|
+
if (!entry) return false;
|
|
204
|
+
delete index.packages[parsed.full];
|
|
205
|
+
writeIndex(index);
|
|
206
|
+
if (entry.asset_path) {
|
|
207
|
+
const versionDir = path.dirname(entry.asset_path);
|
|
208
|
+
fs.rmSync(versionDir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
readIndex,
|
|
215
|
+
writeIndex,
|
|
216
|
+
sha256File,
|
|
217
|
+
assetDigest,
|
|
218
|
+
contentDigest,
|
|
219
|
+
readContainer,
|
|
220
|
+
readContainerEntry,
|
|
221
|
+
readContainerJson,
|
|
222
|
+
listContainerEntries,
|
|
223
|
+
verifyAsset,
|
|
224
|
+
getInstalled,
|
|
225
|
+
listInstalled,
|
|
226
|
+
installAsset,
|
|
227
|
+
resolveAsset,
|
|
228
|
+
removeInstalled,
|
|
229
|
+
};
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// KDNA shared path configuration — canonical source for ~/.kdna structure
|
|
2
|
+
// Spec: docs/local-kdna-home-spec.md
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const KDNA_HOME = process.env.KDNA_HOME
|
|
7
|
+
|| path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
|
|
8
|
+
|
|
9
|
+
const PATHS = {
|
|
10
|
+
root: KDNA_HOME,
|
|
11
|
+
config: path.join(KDNA_HOME, 'config.json'),
|
|
12
|
+
identity: path.join(KDNA_HOME, 'identity'),
|
|
13
|
+
domains: {
|
|
14
|
+
root: path.join(KDNA_HOME, 'domains'),
|
|
15
|
+
official: path.join(KDNA_HOME, 'domains', 'official'),
|
|
16
|
+
local: path.join(KDNA_HOME, 'domains', 'local'),
|
|
17
|
+
private: path.join(KDNA_HOME, 'domains', 'private'),
|
|
18
|
+
// Legacy flat path — used for migration only
|
|
19
|
+
legacy: path.join(KDNA_HOME, 'domains'),
|
|
20
|
+
// All three directories for scanning
|
|
21
|
+
all: [
|
|
22
|
+
path.join(KDNA_HOME, 'domains', 'official'),
|
|
23
|
+
path.join(KDNA_HOME, 'domains', 'local'),
|
|
24
|
+
path.join(KDNA_HOME, 'domains', 'private'),
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
clusters: path.join(KDNA_HOME, 'clusters'),
|
|
28
|
+
packages: path.join(KDNA_HOME, 'packages'),
|
|
29
|
+
packageIndex: path.join(KDNA_HOME, 'index.json'),
|
|
30
|
+
registry: path.join(KDNA_HOME, 'registry'),
|
|
31
|
+
registryCache: path.join(KDNA_HOME, 'registry', 'cache.json'),
|
|
32
|
+
traces: path.join(KDNA_HOME, 'traces'),
|
|
33
|
+
feedback: path.join(KDNA_HOME, 'feedback'),
|
|
34
|
+
evals: path.join(KDNA_HOME, 'evals'),
|
|
35
|
+
cache: path.join(KDNA_HOME, 'cache'),
|
|
36
|
+
licenses: path.join(KDNA_HOME, 'licenses'),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Runtime asset store aliases
|
|
40
|
+
PATHS.USER_KDNA_DIR = KDNA_HOME;
|
|
41
|
+
PATHS.INSTALL_DIR = PATHS.packages;
|
|
42
|
+
|
|
43
|
+
module.exports = PATHS;
|
|
44
|
+
module.exports.KDNA_HOME = KDNA_HOME;
|
package/src/publish.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
-
const { EXIT } = require('./cmds/_common');
|
|
10
|
+
const { EXIT, selfCheckText, isYesNoSelfCheck } = require('./cmds/_common');
|
|
11
11
|
|
|
12
12
|
function error(msg, code = EXIT.VALIDATION_FAILED) {
|
|
13
13
|
console.error(`Error: ${msg}`);
|
|
@@ -143,8 +143,6 @@ function isGenericSelfCheck(question) {
|
|
|
143
143
|
|
|
144
144
|
// ─── Human Lock Gate ──────────────────────────────────────────────────
|
|
145
145
|
|
|
146
|
-
const JUDGMENT_CARD_TYPES = ['axiom', 'boundary', 'risk', 'aesthetic'];
|
|
147
|
-
|
|
148
146
|
/**
|
|
149
147
|
* Check whether the domain satisfies Human Lock requirements.
|
|
150
148
|
* Returns { passed, issues[] } — publish should be blocked if !passed.
|
|
@@ -384,21 +382,22 @@ function cmdPublishCheck(domainPath, args = []) {
|
|
|
384
382
|
}
|
|
385
383
|
for (let i = 0; i < core.stances.length; i++) {
|
|
386
384
|
const s = core.stances[i];
|
|
387
|
-
|
|
385
|
+
const stanceText = typeof s === 'string' ? s : s && typeof s === 'object' ? s.stance : null;
|
|
386
|
+
if (!stanceText || typeof stanceText !== 'string') {
|
|
388
387
|
fail(
|
|
389
388
|
'KDNA_Core.json',
|
|
390
389
|
`stances[${i}]`,
|
|
391
390
|
JSON.stringify(s),
|
|
392
|
-
'Must be a string
|
|
391
|
+
'Must be a string or an object with a stance string.',
|
|
393
392
|
);
|
|
394
|
-
} else if (isSlogan(
|
|
393
|
+
} else if (isSlogan(stanceText)) {
|
|
395
394
|
fail(
|
|
396
395
|
'KDNA_Core.json',
|
|
397
396
|
`stances[${i}]`,
|
|
398
|
-
|
|
397
|
+
stanceText,
|
|
399
398
|
'Reads like a slogan. Stances must be prescriptive positions that bias agent behavior.',
|
|
400
399
|
);
|
|
401
|
-
} else if (isVague(
|
|
400
|
+
} else if (isVague(stanceText)) {
|
|
402
401
|
warn('KDNA_Core.json', `stances[${i}]`, 'Contains vague language.');
|
|
403
402
|
} else {
|
|
404
403
|
pass('KDNA_Core.json', `stances[${i}]`);
|
|
@@ -442,22 +441,23 @@ function cmdPublishCheck(domainPath, args = []) {
|
|
|
442
441
|
if (patterns.self_check && Array.isArray(patterns.self_check)) {
|
|
443
442
|
for (let i = 0; i < patterns.self_check.length; i++) {
|
|
444
443
|
const sc = patterns.self_check[i];
|
|
445
|
-
|
|
444
|
+
const text = selfCheckText(sc);
|
|
445
|
+
if (!text) {
|
|
446
446
|
fail(
|
|
447
447
|
'KDNA_Patterns.json',
|
|
448
448
|
`self_check[${i}]`,
|
|
449
449
|
JSON.stringify(sc),
|
|
450
|
-
'Must be a string
|
|
450
|
+
'Must be a string or an object with a question string.',
|
|
451
451
|
);
|
|
452
|
-
} else if (isGenericSelfCheck(
|
|
452
|
+
} else if (isGenericSelfCheck(text)) {
|
|
453
453
|
fail(
|
|
454
454
|
'KDNA_Patterns.json',
|
|
455
455
|
`self_check[${i}]`,
|
|
456
|
-
|
|
456
|
+
text,
|
|
457
457
|
'Generic question. Self-checks must be domain-specific, not "is this helpful?".',
|
|
458
458
|
);
|
|
459
|
-
} else if (!sc
|
|
460
|
-
warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should
|
|
459
|
+
} else if (!isYesNoSelfCheck(sc)) {
|
|
460
|
+
warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should be answerable with yes/no.');
|
|
461
461
|
passes++;
|
|
462
462
|
} else {
|
|
463
463
|
pass('KDNA_Patterns.json', `self_check[${i}]`);
|
|
@@ -531,9 +531,9 @@ function identityPaths() {
|
|
|
531
531
|
* inside the .kdna ZIP, joined as `name:hex\n`. This is what the signature covers.
|
|
532
532
|
*
|
|
533
533
|
* Excludes the `signature` field from kdna.json itself (computed by removing it
|
|
534
|
-
* before hashing). All other files included as-is.
|
|
534
|
+
* before hashing). Digest self-reference fields are also excluded. All other files included as-is.
|
|
535
535
|
*/
|
|
536
|
-
function canonicalPayload(srcDir) {
|
|
536
|
+
function canonicalPayload(srcDir, opts = {}) {
|
|
537
537
|
const files = fs
|
|
538
538
|
.readdirSync(srcDir)
|
|
539
539
|
.filter((f) => f.endsWith('.json'))
|
|
@@ -545,6 +545,10 @@ function canonicalPayload(srcDir) {
|
|
|
545
545
|
if (f === 'kdna.json') {
|
|
546
546
|
const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
547
547
|
delete obj.signature;
|
|
548
|
+
delete obj.asset_digest;
|
|
549
|
+
delete obj.container_sha256;
|
|
550
|
+
if (!opts.includeContentDigest) delete obj.content_digest;
|
|
551
|
+
delete obj._source;
|
|
548
552
|
buf = Buffer.from(JSON.stringify(obj));
|
|
549
553
|
} else {
|
|
550
554
|
buf = fs.readFileSync(full);
|
|
@@ -555,6 +559,46 @@ function canonicalPayload(srcDir) {
|
|
|
555
559
|
return parts.join('\n');
|
|
556
560
|
}
|
|
557
561
|
|
|
562
|
+
function stableStringify(value) {
|
|
563
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
564
|
+
if (value && typeof value === 'object') {
|
|
565
|
+
return `{${Object.keys(value)
|
|
566
|
+
.sort()
|
|
567
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
568
|
+
.join(',')}}`;
|
|
569
|
+
}
|
|
570
|
+
return JSON.stringify(value);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function manifestForContentDigest(manifest) {
|
|
574
|
+
const copy = { ...(manifest || {}) };
|
|
575
|
+
delete copy.signature;
|
|
576
|
+
delete copy.asset_digest;
|
|
577
|
+
delete copy.container_sha256;
|
|
578
|
+
delete copy.content_digest;
|
|
579
|
+
delete copy._source;
|
|
580
|
+
return copy;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function sourceContentDigest(srcDir) {
|
|
584
|
+
const files = fs
|
|
585
|
+
.readdirSync(srcDir)
|
|
586
|
+
.filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE')
|
|
587
|
+
.sort();
|
|
588
|
+
const parts = [];
|
|
589
|
+
for (const f of files) {
|
|
590
|
+
let buf;
|
|
591
|
+
if (f === 'kdna.json') {
|
|
592
|
+
const obj = JSON.parse(fs.readFileSync(path.join(srcDir, f), 'utf8'));
|
|
593
|
+
buf = Buffer.from(stableStringify(manifestForContentDigest(obj)));
|
|
594
|
+
} else {
|
|
595
|
+
buf = fs.readFileSync(path.join(srcDir, f));
|
|
596
|
+
}
|
|
597
|
+
parts.push(`${f}:${crypto.createHash('sha256').update(buf).digest('hex')}`);
|
|
598
|
+
}
|
|
599
|
+
return `sha256:${crypto.createHash('sha256').update(Buffer.from(parts.join('\n'))).digest('hex')}`;
|
|
600
|
+
}
|
|
601
|
+
|
|
558
602
|
function signPayload(payload, privateKeyPem) {
|
|
559
603
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
|
560
604
|
const sig = crypto.sign(null, Buffer.from(payload), privateKey);
|
|
@@ -582,7 +626,7 @@ function packToFile(domainDir, outPath) {
|
|
|
582
626
|
const files = fs
|
|
583
627
|
.readdirSync(domainDir)
|
|
584
628
|
.filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE');
|
|
585
|
-
if (!files.includes('kdna.json')) error('kdna.json required in
|
|
629
|
+
if (!files.includes('kdna.json')) error('kdna.json required in dev source directory for publish.');
|
|
586
630
|
|
|
587
631
|
const script = `import zipfile, os
|
|
588
632
|
src = ${JSON.stringify(domainDir)}
|
|
@@ -679,9 +723,14 @@ function cmdPublish(domainPath, args = []) {
|
|
|
679
723
|
|
|
680
724
|
// 2. Write signature
|
|
681
725
|
delete manifest.signature;
|
|
726
|
+
delete manifest.asset_digest;
|
|
727
|
+
delete manifest.container_sha256;
|
|
728
|
+
delete manifest.content_digest;
|
|
729
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
730
|
+
manifest.content_digest = sourceContentDigest(abs);
|
|
682
731
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
683
|
-
const
|
|
684
|
-
const sig = signPayload(
|
|
732
|
+
const signedPayload = canonicalPayload(abs, { includeContentDigest: true });
|
|
733
|
+
const sig = signPayload(signedPayload, privateKey);
|
|
685
734
|
manifest.signature = 'ed25519:' + sig;
|
|
686
735
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
687
736
|
console.log(
|
|
@@ -697,9 +746,10 @@ function cmdPublish(domainPath, args = []) {
|
|
|
697
746
|
const outPath = path.join(outDir, fileName);
|
|
698
747
|
packToFile(abs, outPath);
|
|
699
748
|
const sha256 = sha256File(outPath);
|
|
749
|
+
const assetDigest = `sha256:${sha256}`;
|
|
700
750
|
const size = fs.statSync(outPath).size;
|
|
701
751
|
console.log(` ✓ Packed: ${outPath} (${size} bytes)`);
|
|
702
|
-
console.log(` ✓
|
|
752
|
+
console.log(` ✓ asset_digest: ${assetDigest}`);
|
|
703
753
|
|
|
704
754
|
// 4. Optional upload via gh CLI
|
|
705
755
|
const tagIdx = args.indexOf('--release-tag');
|
|
@@ -726,8 +776,9 @@ function cmdPublish(domainPath, args = []) {
|
|
|
726
776
|
name,
|
|
727
777
|
type: manifest.cluster ? 'cluster' : 'domain',
|
|
728
778
|
version: manifest.version,
|
|
729
|
-
|
|
730
|
-
|
|
779
|
+
asset_url: kdnaUrl,
|
|
780
|
+
asset_digest: assetDigest,
|
|
781
|
+
content_digest: manifest.content_digest || null,
|
|
731
782
|
signature: manifest.signature,
|
|
732
783
|
release_status: kdnaUrl ? 'published_signed' : 'published_signed_local',
|
|
733
784
|
author: { ...manifest.author },
|
package/src/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RegistryResolver — KDNA
|
|
2
|
+
* RegistryResolver — KDNA asset-first registry client.
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* 1. Resolve names: bare → @aikdna/bare, validate @scope/name format
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Cache registry metadata locally
|
|
8
8
|
* 4. Surface scope trust info to install/publish
|
|
9
9
|
*
|
|
10
|
-
* Schema
|
|
10
|
+
* Schema v3.0 — see kdna-registry/SCHEMA.md
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
@@ -22,6 +22,7 @@ const DEFAULT_OFFICIAL_SCOPE = '@aikdna';
|
|
|
22
22
|
const CANONICAL_REGISTRY_URL =
|
|
23
23
|
process.env.KDNA_REGISTRY_URL ||
|
|
24
24
|
'https://raw.githubusercontent.com/aikdna/kdna-registry/main/domains.json';
|
|
25
|
+
const REQUIRED_SCHEMA_VERSION = '3.0';
|
|
25
26
|
|
|
26
27
|
const NAME_RE = /^@([a-z][a-z0-9-]*)\/([a-z][a-z0-9_]*)$/;
|
|
27
28
|
const BARE_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
@@ -39,6 +40,60 @@ function writeJson(file, data) {
|
|
|
39
40
|
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function parseDate(value) {
|
|
44
|
+
const date = value ? new Date(value) : null;
|
|
45
|
+
return date && !Number.isNaN(date.getTime()) ? date : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function registryTrustIssues(registry, { now = new Date() } = {}) {
|
|
49
|
+
const issues = [];
|
|
50
|
+
const trust = registry?.trust || {};
|
|
51
|
+
|
|
52
|
+
if (!registry || registry.schema_version !== REQUIRED_SCHEMA_VERSION) {
|
|
53
|
+
issues.push(
|
|
54
|
+
`Registry schema_version must be ${REQUIRED_SCHEMA_VERSION}, got ${JSON.stringify(registry?.schema_version)}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!trust.model) issues.push('registry.trust.model is required');
|
|
59
|
+
if (!trust.snapshot) issues.push('registry.trust.snapshot is required');
|
|
60
|
+
if (!trust.timestamp) issues.push('registry.trust.timestamp is required');
|
|
61
|
+
|
|
62
|
+
const snapshotVersion = trust.snapshot?.registry_version;
|
|
63
|
+
if (snapshotVersion && snapshotVersion !== registry.registry_version) {
|
|
64
|
+
issues.push(
|
|
65
|
+
`registry.trust.snapshot.registry_version ${snapshotVersion} does not match registry_version ${registry.registry_version}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const snapshotExpires = parseDate(trust.snapshot?.expires_at);
|
|
70
|
+
const timestampExpires = parseDate(trust.timestamp?.expires_at);
|
|
71
|
+
if (!snapshotExpires) issues.push('registry.trust.snapshot.expires_at must be an ISO timestamp');
|
|
72
|
+
if (!timestampExpires) issues.push('registry.trust.timestamp.expires_at must be an ISO timestamp');
|
|
73
|
+
if (snapshotExpires && snapshotExpires <= now) {
|
|
74
|
+
issues.push(`registry snapshot expired at ${trust.snapshot.expires_at}`);
|
|
75
|
+
}
|
|
76
|
+
if (timestampExpires && timestampExpires <= now) {
|
|
77
|
+
issues.push(`registry timestamp expired at ${trust.timestamp.expires_at}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return issues;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function registryRevocations(registry) {
|
|
84
|
+
return registry?.trust?.revocations || [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isEntryRevoked(registry, entry) {
|
|
88
|
+
const revocations = registryRevocations(registry);
|
|
89
|
+
return revocations.find((rev) => {
|
|
90
|
+
if (rev.name && rev.name !== entry.name) return false;
|
|
91
|
+
if (rev.version && rev.version !== entry.version) return false;
|
|
92
|
+
if (rev.asset_digest && rev.asset_digest !== entry.asset_digest) return false;
|
|
93
|
+
return rev.name || rev.asset_digest;
|
|
94
|
+
}) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
// ─── Name parsing ───────────────────────────────────────────────────────
|
|
43
98
|
|
|
44
99
|
/**
|
|
@@ -151,7 +206,15 @@ class RegistryResolver {
|
|
|
151
206
|
_loadRegistryForScope(scopeName) {
|
|
152
207
|
if (this._registries.has(scopeName)) return this._registries.get(scopeName);
|
|
153
208
|
const source = this._sourceForScope(scopeName);
|
|
154
|
-
|
|
209
|
+
let data = source.load({ allowNetwork: this.allowNetwork, refresh: this.refresh });
|
|
210
|
+
let trustIssues = data ? registryTrustIssues(data) : [];
|
|
211
|
+
if (trustIssues.length && this.allowNetwork && !this.refresh) {
|
|
212
|
+
data = source.load({ allowNetwork: true, refresh: true });
|
|
213
|
+
trustIssues = data ? registryTrustIssues(data) : [];
|
|
214
|
+
}
|
|
215
|
+
if (trustIssues.length) {
|
|
216
|
+
throw new Error(`Registry trust check failed:\n${trustIssues.map((i) => `- ${i}`).join('\n')}`);
|
|
217
|
+
}
|
|
155
218
|
this._registries.set(scopeName, data);
|
|
156
219
|
return data;
|
|
157
220
|
}
|
|
@@ -186,12 +249,6 @@ class RegistryResolver {
|
|
|
186
249
|
);
|
|
187
250
|
}
|
|
188
251
|
|
|
189
|
-
if (registry.schema_version && registry.schema_version !== '2.0') {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`Registry schema_version ${registry.schema_version} not supported. This CLI requires 2.0.`,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
252
|
const scope = registry.scopes?.[parsed.scope];
|
|
196
253
|
if (!scope) {
|
|
197
254
|
throw new Error(`Scope ${parsed.scope} not registered in registry.`);
|
|
@@ -215,6 +272,13 @@ class RegistryResolver {
|
|
|
215
272
|
throw new Error(`${entry.name}@${entry.version} has been yanked${when}.${reason}${replace}`);
|
|
216
273
|
}
|
|
217
274
|
|
|
275
|
+
const revocation = isEntryRevoked(registry, entry);
|
|
276
|
+
if (revocation) {
|
|
277
|
+
const reason = revocation.reason ? `\nReason: ${revocation.reason}` : '';
|
|
278
|
+
const when = revocation.revoked_at ? ` (revoked ${revocation.revoked_at.slice(0, 10)})` : '';
|
|
279
|
+
throw new Error(`${entry.name}@${entry.version} has been revoked${when}.${reason}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
218
282
|
return { parsed, scope, entry, registry };
|
|
219
283
|
}
|
|
220
284
|
|
|
@@ -250,6 +314,9 @@ function fetchRegistry() {
|
|
|
250
314
|
module.exports = {
|
|
251
315
|
RegistryResolver,
|
|
252
316
|
parseName,
|
|
317
|
+
REQUIRED_SCHEMA_VERSION,
|
|
318
|
+
registryTrustIssues,
|
|
319
|
+
isEntryRevoked,
|
|
253
320
|
loadRegistry,
|
|
254
321
|
fetchRegistry,
|
|
255
322
|
CANONICAL_REGISTRY_URL,
|