@aikdna/kdna-cli 0.17.0 → 0.19.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/src/install.js CHANGED
@@ -5,31 +5,33 @@
5
5
  * kdna install <bare> → @aikdna/<bare>, from registry
6
6
  * kdna install @scope/name → from registry (any scope)
7
7
  * kdna install @scope/name@1.2.3 → version pinned (TODO post-v0.7.0)
8
- * kdna install ./folder → local directory (dev)
9
8
  * kdna install ./file.kdna → local .kdna file
10
9
  *
11
10
  * Removed in v0.7 (breaking): github:user/repo, --from-git, cluster:github:...,
12
11
  * tarball/SSH fallbacks. Install is now strictly .kdna-driven from the registry.
13
12
  *
14
- * Schema v2.0 — see kdna-registry/SCHEMA.md
13
+ * Schema v3.0 — see kdna-registry/SCHEMA.md
15
14
  */
16
15
 
17
16
  const fs = require('fs');
18
17
  const path = require('path');
19
- const crypto = require('crypto');
20
18
  const { execSync, execFileSync } = require('child_process');
21
19
  const { RegistryResolver, parseName } = require('./registry');
22
20
  const { EXIT, error } = require('./cmds/_common');
21
+ const PATHS = require('./paths');
23
22
  const {
24
- decrypt,
25
- deriveKey,
26
- machineFingerprint,
27
- isEncryptedContainer,
28
- ENCRYPTED_FILES,
29
- } = require('./cmds/encrypt');
30
-
31
- const USER_KDNA_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
32
- const INSTALL_DIR = path.join(USER_KDNA_DIR, 'domains');
23
+ installAsset,
24
+ getInstalled,
25
+ listInstalled,
26
+ removeInstalled,
27
+ sha256File,
28
+ readContainer,
29
+ readContainerJson,
30
+ verifyAsset,
31
+ } = require('./package-store');
32
+
33
+ const USER_KDNA_DIR = PATHS.root;
34
+ const INSTALL_DIR = PATHS.packages;
33
35
 
34
36
  // Agent skill directories (search order)
35
37
  const AGENT_SKILL_DIRS = [
@@ -151,77 +153,33 @@ function ensureDir(dir) {
151
153
  fs.mkdirSync(dir, { recursive: true });
152
154
  }
153
155
 
154
- function readJson(p) {
155
- try {
156
- return JSON.parse(fs.readFileSync(p, 'utf8'));
157
- } catch {
158
- return null;
159
- }
160
- }
161
-
162
- function sha256File(filePath) {
163
- return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
164
- }
165
-
166
- function scopeDir(scope) {
167
- return path.join(INSTALL_DIR, scope);
168
- }
169
-
170
- function domainDir(scope, ident) {
171
- return path.join(INSTALL_DIR, scope, ident);
172
- }
173
-
174
- // ─── Legacy detection ───────────────────────────────────────────────────
175
-
176
- function detectLegacyInstalls() {
177
- if (!fs.existsSync(INSTALL_DIR)) return [];
178
- const entries = fs.readdirSync(INSTALL_DIR);
179
- // Legacy: any direct child of INSTALL_DIR that is a directory AND does NOT start with @
180
- return entries.filter((e) => {
181
- if (e.startsWith('@') || e.startsWith('.')) return false;
182
- try {
183
- return fs.statSync(path.join(INSTALL_DIR, e)).isDirectory();
184
- } catch {
185
- return false;
186
- }
187
- });
188
- }
189
-
190
- function warnLegacy() {
191
- const legacy = detectLegacyInstalls();
192
- if (!legacy.length) return;
193
- console.error('');
194
- console.error('═'.repeat(64));
195
- console.error(' v0.7 breaking change: legacy (un-scoped) domains detected');
196
- console.error('═'.repeat(64));
197
- console.error('');
198
- console.error(' These directories use the old un-scoped path layout:');
199
- legacy.forEach((d) => console.error(` ~/.kdna/domains/${d}/`));
200
- console.error('');
201
- console.error(' Run: kdna remove <name> then kdna install <name>');
202
- console.error(' (CLI will not read or update legacy directories.)');
203
- console.error('');
204
- }
205
-
206
156
  // ─── Source parsing ─────────────────────────────────────────────────────
207
157
 
208
158
  function parseSource(input) {
209
- // Local file (.kdna or .kdnae)
159
+ // Local file (.kdna)
210
160
  if (
211
- (input.endsWith('.kdna') || input.endsWith('.kdnae')) &&
212
- (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/'))
161
+ input.endsWith('.kdna') &&
162
+ (input.startsWith('./') ||
163
+ input.startsWith('/') ||
164
+ input.startsWith('~/') ||
165
+ fs.existsSync(input))
213
166
  ) {
214
167
  const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
215
168
  if (!fs.existsSync(resolved)) error(`Local file not found: ${resolved}`);
216
169
  return { type: 'local-file', path: resolved };
217
170
  }
218
171
 
219
- // Local directory
220
172
  if (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/')) {
221
173
  const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
222
174
  if (!fs.existsSync(resolved)) error(`Local path not found: ${resolved}`);
223
- if (!fs.statSync(resolved).isDirectory()) error(`Not a directory: ${resolved}`);
224
- return { type: 'local-dir', path: resolved };
175
+ if (fs.statSync(resolved).isDirectory()) {
176
+ error(
177
+ `Directory install is not supported. KDNA installs .kdna assets only.\n` +
178
+ `Use: kdna dev pack ${resolved} --output <dir>, then kdna install <file.kdna>`,
179
+ EXIT.INPUT_ERROR,
180
+ );
181
+ }
182
+ error(`Not a .kdna file: ${resolved}`, EXIT.INPUT_ERROR);
225
183
  }
226
184
 
227
185
  // Registry name (bare or @scope/name)
@@ -231,7 +189,6 @@ function parseSource(input) {
231
189
  `Cannot parse "${input}". Use:\n` +
232
190
  ` kdna install <name> # @aikdna/<name>\n` +
233
191
  ` kdna install @scope/name # any scope\n` +
234
- ` kdna install ./folder # local directory\n` +
235
192
  ` kdna install ./file.kdna # local .kdna file`,
236
193
  );
237
194
  }
@@ -266,90 +223,10 @@ function downloadFile(url, dest) {
266
223
  throw new Error(`download failed after 3 attempts: ${stderr}`);
267
224
  }
268
225
 
269
- // ─── Extraction ────────────────────────────────────────────────────────
270
-
271
- function extractKdna(kdnaPath, destDir) {
272
- ensureDir(destDir);
273
- const script = `import zipfile
274
- zf = zipfile.ZipFile(${JSON.stringify(kdnaPath)}, 'r')
275
- zf.extractall(${JSON.stringify(destDir)})
276
- zf.close()
277
- print('ok')
278
- `;
279
- try {
280
- execSync(`python3 -c ${JSON.stringify(script)}`, { stdio: 'pipe' });
281
- return;
282
- } catch {
283
- /* try unzip */
284
- }
285
- try {
286
- execSync(`unzip -q -o "${kdnaPath}" -d "${destDir}"`, { stdio: 'pipe' });
287
- return;
288
- } catch {
289
- error('Cannot extract .kdna file. Install python3 or unzip.');
290
- }
291
- }
292
-
293
- function extractAndDecrypt(kdnaPath, destDir, licenseKey) {
294
- extractKdna(kdnaPath, destDir);
295
- const fp = machineFingerprint();
296
- const key = deriveKey(licenseKey, fp);
297
-
298
- for (const f of fs.readdirSync(destDir)) {
299
- if (ENCRYPTED_FILES.includes(f)) {
300
- try {
301
- const fullPath = path.join(destDir, f);
302
- const encrypted = fs.readFileSync(fullPath);
303
- const decrypted = decrypt(encrypted, key);
304
- fs.writeFileSync(fullPath, decrypted);
305
- } catch (err) {
306
- fs.rmSync(destDir, { recursive: true, force: true });
307
- error(`Failed to decrypt ${f}: ${err.message}. Wrong license key?`);
308
- }
309
- }
310
- }
311
- }
312
-
313
- function findLicense(domainName) {
314
- const licenseDir = path.join(USER_KDNA_DIR, 'licenses');
315
- const licensePath = path.join(
316
- licenseDir,
317
- `${domainName.replace(/^@/, '').replace('/', '-')}.json`,
318
- );
319
- if (fs.existsSync(licensePath)) {
320
- try {
321
- return JSON.parse(fs.readFileSync(licensePath, 'utf8'));
322
- } catch {
323
- /* invalid license */
324
- }
325
- }
326
- return null;
327
- }
328
-
329
- function findLicenseForDomain(domainFull) {
330
- const licenseDir = path.join(USER_KDNA_DIR, 'licenses');
331
- if (!fs.existsSync(licenseDir)) return null;
332
- // Try exact match: @aikdna/writing → @aikdna-writing.json
333
- const candidates = [domainFull.replace(/^@/, '').replace('/', '-')];
334
- // Also try just the domain name
335
- candidates.push(domainFull.split('/').pop());
336
- for (const c of candidates) {
337
- const p = path.join(licenseDir, `${c}.json`);
338
- if (fs.existsSync(p)) {
339
- try {
340
- return JSON.parse(fs.readFileSync(p, 'utf8'));
341
- } catch {
342
- /* skip */
343
- }
344
- }
345
- }
346
- return null;
347
- }
348
-
349
226
  // ─── Signature verification ────────────────────────────────────────────
350
227
 
351
- function verifySignature({ destDir, scope, entry, lenient = true }) {
352
- const manifest = readJson(path.join(destDir, 'kdna.json'));
228
+ function verifySignature({ assetPath, scope, entry, lenient = true }) {
229
+ const manifest = readContainerJson(assetPath, 'kdna.json');
353
230
  if (!manifest) {
354
231
  if (lenient) {
355
232
  console.warn(' ⚠ No kdna.json — cannot verify signature.');
@@ -359,20 +236,8 @@ function verifySignature({ destDir, scope, entry, lenient = true }) {
359
236
  }
360
237
 
361
238
  const trustKey = scope.trust_pubkey;
362
- const isPlaceholder = !trustKey || trustKey.includes('PLACEHOLDER');
363
-
364
- // v0.7 bootstrap: signatures may be absent. Warn but allow.
365
239
  if (!entry.signature || !manifest.signature) {
366
- if (isPlaceholder) {
367
- console.warn(
368
- ` ⚠ Bootstrap mode: scope ${entry.name.split('/')[0]} has placeholder trust key. Signature not verified.`,
369
- );
370
- } else {
371
- console.warn(
372
- ` ⚠ ${entry.name}: no signature on package. (Will be required post-bootstrap.)`,
373
- );
374
- }
375
- return;
240
+ error(`${entry.name}: registry and .kdna manifest signatures are required.`, EXIT.TRUST_FAILED);
376
241
  }
377
242
 
378
243
  // Author pubkey fingerprint must match scope trust_pubkey
@@ -384,64 +249,26 @@ function verifySignature({ destDir, scope, entry, lenient = true }) {
384
249
  }
385
250
 
386
251
  // Full Ed25519 verify (requires public_key_pem embedded in the package)
387
- const pem = manifest.author?.public_key_pem;
388
- if (!pem) {
389
- // Legacy package (signed but no embedded PEM). Trust the fingerprint match.
390
- console.log(' ✓ Signature OK (legacy fingerprint-only mode — no PEM)');
391
- return;
392
- }
393
-
394
- // 1. Confirm the embedded PEM hashes to the claimed pubkey fingerprint
395
- const computedFingerprint = 'ed25519:' + crypto.createHash('sha256').update(pem).digest('hex');
396
- if (computedFingerprint !== manifest.author.pubkey) {
252
+ if (!manifest.author?.public_key_pem) {
397
253
  error(
398
- `${entry.name}: embedded public_key_pem does not match author.pubkey fingerprint. Refusing.`,
254
+ `${entry.name}: manifest author.public_key_pem is required for Ed25519 verification.`,
399
255
  EXIT.TRUST_FAILED,
400
256
  );
401
257
  }
402
258
 
403
- // 2. Verify the Ed25519 signature over the canonical payload
404
- // Canonical payload reconstruction must match publish.js exactly:
405
- // - sorted .json filenames
406
- // - for kdna.json: strip "signature" field before hashing
407
- // - others: raw bytes
408
- // - hash each, format "name:hex", join with "\n"
409
- const sigHex = manifest.signature.replace(/^ed25519:/, '');
410
- try {
411
- const files = fs
412
- .readdirSync(destDir)
413
- .filter((f) => f.endsWith('.json'))
414
- .sort();
415
- const parts = [];
416
- for (const f of files) {
417
- const full = path.join(destDir, f);
418
- let buf;
419
- if (f === 'kdna.json') {
420
- const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
421
- delete obj.signature;
422
- delete obj._source; // install-time metadata, not part of signed payload
423
- buf = Buffer.from(JSON.stringify(obj));
424
- } else {
425
- buf = fs.readFileSync(full);
426
- }
427
- const hash = crypto.createHash('sha256').update(buf).digest('hex');
428
- parts.push(`${f}:${hash}`);
259
+ const result = verifyAsset(assetPath, { requireSignature: true });
260
+ for (const e of result.errors || []) {
261
+ if (e.includes('signature') || e.includes('public_key') || e.includes('fingerprint')) {
262
+ error(`${entry.name}: ${e}. Refusing to install.`, EXIT.TRUST_FAILED);
429
263
  }
430
- const payload = parts.join('\n');
431
-
432
- const publicKey = crypto.createPublicKey(pem);
433
- const ok = crypto.verify(null, Buffer.from(payload), publicKey, Buffer.from(sigHex, 'hex'));
434
- if (!ok) {
435
- error(
436
- `${entry.name}: Ed25519 signature INVALID. Package may be tampered. Refusing.`,
437
- EXIT.TRUST_FAILED,
438
- );
439
- }
440
- console.log(' ✓ Signature OK (Ed25519 verified)');
441
- } catch (e) {
442
- if (e.message?.includes('INVALID')) throw e;
443
- error(`${entry.name}: signature verification failed: ${e.message}`, EXIT.TRUST_FAILED);
444
264
  }
265
+ if (!result.signature_valid) {
266
+ error(
267
+ `${entry.name}: Ed25519 signature INVALID. Package may be tampered. Refusing.`,
268
+ EXIT.TRUST_FAILED,
269
+ );
270
+ }
271
+ console.log(' ✓ Signature OK (Ed25519 verified)');
445
272
  }
446
273
 
447
274
  // ─── Status confirmation (interactive) ─────────────────────────────────
@@ -465,31 +292,12 @@ function confirmStatus(entry, yes) {
465
292
  // ─── Cleanup stale temps ───────────────────────────────────────────────
466
293
 
467
294
  function cleanStaleTemps() {
468
- if (!fs.existsSync(INSTALL_DIR)) return;
469
- try {
470
- for (const scopeName of fs.readdirSync(INSTALL_DIR)) {
471
- if (!scopeName.startsWith('@')) continue;
472
- const sd = path.join(INSTALL_DIR, scopeName);
473
- if (!fs.statSync(sd).isDirectory()) continue;
474
- for (const child of fs.readdirSync(sd)) {
475
- if (child.endsWith('.tmp') || child.endsWith('.kdna.tmp')) {
476
- try {
477
- fs.rmSync(path.join(sd, child), { recursive: true, force: true });
478
- } catch {
479
- /* ignore */
480
- }
481
- }
482
- }
483
- }
484
- } catch {
485
- /* ignore */
486
- }
295
+ ensureDir(INSTALL_DIR);
487
296
  }
488
297
 
489
298
  // ─── Main install ──────────────────────────────────────────────────────
490
299
 
491
300
  function cmdInstallExtended(input, args = []) {
492
- warnLegacy();
493
301
  ensureDir(INSTALL_DIR);
494
302
  cleanStaleTemps();
495
303
 
@@ -505,11 +313,21 @@ function cmdInstallExtended(input, args = []) {
505
313
  return installFromRegistry(source.parsed, yes, jsonMode);
506
314
  case 'local-file':
507
315
  return installFromLocalFile(source.path, yes, jsonMode);
508
- case 'local-dir':
509
- return installFromLocalDir(source.path, yes, jsonMode);
510
316
  }
511
317
  }
512
318
 
319
+ function scopedNameError(sourceLabel, declared) {
320
+ return (
321
+ `Invalid domain name in ${sourceLabel}: "${declared || '?'}"\n\n` +
322
+ `KDNA v0.7+ requires scoped domain names.\n` +
323
+ `Expected format: @scope/name\n` +
324
+ `Example: @aikdna/my_domain\n\n` +
325
+ `Fix:\n` +
326
+ `- update kdna.json name to "@aikdna/my_domain"\n` +
327
+ `- or initialize a new scoped domain, then copy your content into it`
328
+ );
329
+ }
330
+
513
331
  function installFromRegistry(parsed, yes, jsonMode = false) {
514
332
  const resolver = new RegistryResolver({ allowNetwork: true });
515
333
  let scope, entry;
@@ -541,14 +359,17 @@ function installFromRegistry(parsed, yes, jsonMode = false) {
541
359
  return installCluster(entry, resolver, yes, jsonMode);
542
360
  }
543
361
 
544
- if (!entry.kdna_url) {
362
+ if (!entry.asset_url) {
545
363
  error(
546
- `${entry.name}@${entry.version} has no kdna_url in registry.\n` +
364
+ `${entry.name}@${entry.version} has no asset_url in registry.\n` +
547
365
  `release_status: ${entry.release_status || 'unknown'}\n` +
548
- `(This domain has not been published as a .kdna file yet. It will be available after v0.7 republish.)`,
366
+ `(Registry v3 publishes canonical .kdna assets through asset_url only.)`,
549
367
  EXIT.REGISTRY_ERROR,
550
368
  );
551
369
  }
370
+ if (!entry.asset_digest) {
371
+ error(`${entry.name}@${entry.version} has no asset_digest in registry.`, EXIT.REGISTRY_ERROR);
372
+ }
552
373
 
553
374
  if (!confirmStatus(entry, yes)) {
554
375
  console.log('Installation cancelled.');
@@ -559,70 +380,68 @@ function installFromRegistry(parsed, yes, jsonMode = false) {
559
380
  }
560
381
 
561
382
  function installSingleFromUrl({ entry, scope }, jsonMode = false) {
562
- const [scopeName, ident] = entry.name.split('/');
563
- const dest = domainDir(scopeName, ident);
564
- const tmpFile = path.join(scopeDir(scopeName), `.${ident}-${Date.now()}.kdna.tmp`);
383
+ const ident = entry.name.split('/')[1];
384
+ const tmpDir = path.join(USER_KDNA_DIR, 'cache', 'downloads');
385
+ const tmpFile = path.join(tmpDir, `.${ident}-${Date.now()}.kdna.tmp`);
386
+ const assetUrl = entry.asset_url;
565
387
 
566
388
  if (!jsonMode) console.log(` Downloading ${entry.name}@${entry.version}...`);
567
- ensureDir(scopeDir(scopeName));
389
+ ensureDir(tmpDir);
568
390
  try {
569
- downloadFile(entry.kdna_url, tmpFile);
391
+ downloadFile(assetUrl, tmpFile);
570
392
  } catch {
571
- error(`Failed to download ${entry.kdna_url}`, EXIT.REGISTRY_ERROR);
393
+ error(`Failed to download ${assetUrl}`, EXIT.REGISTRY_ERROR);
572
394
  }
573
395
 
574
- // sha256 check
396
+ // asset digest check
575
397
  const actual = sha256File(tmpFile);
576
- if (entry.sha256 && actual !== entry.sha256) {
398
+ const expectedDigest = entry.asset_digest;
399
+ const actualDigest = `sha256:${actual}`;
400
+ if (expectedDigest && actualDigest !== expectedDigest) {
577
401
  try {
578
402
  fs.unlinkSync(tmpFile);
579
403
  } catch {
580
404
  /* ignore */
581
405
  }
582
- error(`sha256 mismatch for ${entry.name}: expected ${entry.sha256}, got ${actual}`);
406
+ error(
407
+ `asset digest mismatch for ${entry.name}: expected ${expectedDigest}, got ${actualDigest}`,
408
+ );
583
409
  }
584
- if (!jsonMode) console.log(` ✓ sha256 verified`);
410
+ if (!jsonMode) console.log(` ✓ asset digest verified`);
585
411
 
586
- // Replace existing install atomically-ish
587
- if (fs.existsSync(dest)) {
588
- fs.rmSync(dest, { recursive: true, force: true });
589
- }
590
- ensureDir(dest);
412
+ verifySignature({ assetPath: tmpFile, scope, entry, lenient: true });
591
413
 
592
- extractKdna(tmpFile, dest);
414
+ const installed = installAsset({
415
+ sourcePath: tmpFile,
416
+ name: entry.name,
417
+ version: entry.version,
418
+ source: {
419
+ type: 'registry',
420
+ name: entry.name,
421
+ version: entry.version,
422
+ asset_url: assetUrl,
423
+ asset_digest: expectedDigest,
424
+ },
425
+ });
593
426
  try {
594
427
  fs.unlinkSync(tmpFile);
595
428
  } catch {
596
429
  /* ignore */
597
430
  }
598
431
 
599
- verifySignature({ destDir: dest, scope, entry, lenient: true });
600
-
601
- // Stamp install metadata
602
- const manifest = readJson(path.join(dest, 'kdna.json')) || {};
603
- manifest._source = {
604
- type: 'registry',
605
- name: entry.name,
606
- version: entry.version,
607
- kdna_url: entry.kdna_url,
608
- sha256: entry.sha256,
609
- installed_at: new Date().toISOString(),
610
- };
611
- fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(manifest, null, 2) + '\n');
612
-
613
432
  if (jsonMode) {
614
433
  console.log(
615
434
  JSON.stringify({
616
435
  name: entry.name,
617
436
  version: entry.version,
618
437
  installed: true,
619
- path: dest,
438
+ path: installed.asset_path,
620
439
  type: entry.type || 'domain',
621
440
  }),
622
441
  );
623
442
  } else {
624
443
  console.log(`✓ Installed ${entry.name}@${entry.version}`);
625
- console.log(` Location: ${dest}`);
444
+ console.log(` Asset: ${installed.asset_path}`);
626
445
  }
627
446
  }
628
447
 
@@ -637,8 +456,8 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
637
456
  for (const sub of subdomains) {
638
457
  try {
639
458
  const resolved = resolver.resolve(sub);
640
- if (!resolved.entry.kdna_url) {
641
- if (!jsonMode) console.warn(` ⚠ ${sub}: no kdna_url (skipping)`);
459
+ if (!resolved.entry.asset_url) {
460
+ if (!jsonMode) console.warn(` ⚠ ${sub}: no asset_url (skipping)`);
642
461
  continue;
643
462
  }
644
463
  if (!jsonMode) console.log('');
@@ -648,26 +467,6 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
648
467
  }
649
468
  }
650
469
 
651
- // Record the cluster itself
652
- const [scopeName, ident] = clusterEntry.name.split('/');
653
- const clusterDest = domainDir(scopeName, ident);
654
- ensureDir(clusterDest);
655
- fs.writeFileSync(
656
- path.join(clusterDest, 'cluster.json'),
657
- JSON.stringify(
658
- {
659
- name: clusterEntry.name,
660
- version: clusterEntry.version,
661
- type: 'cluster',
662
- domains: subdomains,
663
- composition_rules: clusterEntry.cluster.composition_rules || [],
664
- installed_at: new Date().toISOString(),
665
- },
666
- null,
667
- 2,
668
- ) + '\n',
669
- );
670
-
671
470
  if (jsonMode) {
672
471
  console.log(
673
472
  JSON.stringify({
@@ -675,7 +474,6 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
675
474
  version: clusterEntry.version,
676
475
  type: 'cluster',
677
476
  installed: true,
678
- path: clusterDest,
679
477
  subdomains: subdomains.length,
680
478
  }),
681
479
  );
@@ -688,163 +486,64 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
688
486
  function installFromLocalFile(filePath, _yes, jsonMode = false) {
689
487
  const abs = path.resolve(filePath);
690
488
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) error(`Not a file: ${abs}`);
489
+ if (!abs.endsWith('.kdna')) error(`Not a .kdna asset: ${abs}`, EXIT.INPUT_ERROR);
691
490
 
692
- const isEncrypted = isEncryptedContainer(abs);
693
- const tmpDir = path.join(INSTALL_DIR, '.local-tmp-' + Date.now());
694
- ensureDir(tmpDir);
695
-
696
- if (isEncrypted) {
697
- // Find license for this .kdnae file
698
- // First check the license directory, then ask for --license flag from args
699
- const licenseFromArgs = process.argv.includes('--license')
700
- ? process.argv[process.argv.indexOf('--license') + 1]
701
- : null;
702
- let licenseKey = null;
703
-
704
- if (licenseFromArgs && fs.existsSync(licenseFromArgs)) {
705
- try {
706
- const lic = JSON.parse(fs.readFileSync(licenseFromArgs, 'utf8'));
707
- licenseKey = lic.license_id;
708
- } catch {
709
- /* invalid license file */
710
- }
711
- }
712
-
713
- if (!licenseKey) {
714
- // Try to auto-discover from ~/.kdna/licenses/
715
- const manifest = readJson(path.join(tmpDir, 'kdna.json'));
716
- // We need to extract just the manifest first to get the domain name
717
- extractKdna(abs, tmpDir);
718
- const mf = readJson(path.join(tmpDir, 'kdna.json'));
719
- if (mf?.name) {
720
- const lic = findLicenseForDomain(mf.name);
721
- if (lic) licenseKey = lic.license_id;
722
- }
723
- if (!licenseKey) {
724
- fs.rmSync(tmpDir, { recursive: true, force: true });
725
- error(
726
- `Cannot install encrypted .kdnae without a license.\n` +
727
- `Save the license to ~/.kdna/licenses/ or use --license <file>.`,
728
- EXIT.TRUST_FAILED,
729
- );
730
- }
731
- // Re-extract for decryption
732
- fs.rmSync(tmpDir, { recursive: true, force: true });
733
- ensureDir(tmpDir);
734
- }
735
-
736
- console.log(' Decrypting .kdnae container...');
737
- extractAndDecrypt(abs, tmpDir, licenseKey);
738
- } else {
739
- extractKdna(abs, tmpDir);
740
- }
741
-
742
- const manifest = readJson(path.join(tmpDir, 'kdna.json'));
491
+ const manifest = readContainerJson(abs, 'kdna.json');
743
492
  const declared = manifest?.name;
744
493
  if (!declared || !/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9_]*$/.test(declared)) {
745
- fs.rmSync(tmpDir, { recursive: true, force: true });
746
- error(
747
- `Package kdna.json.name "${declared || '?'}" must be @scope/name format.\n` +
748
- `(v0.7 requires scoped names.)`,
749
- );
494
+ error(scopedNameError('package kdna.json.name', declared), EXIT.INPUT_ERROR);
750
495
  }
751
- const [scopeName, ident] = declared.split('/');
752
- const dest = domainDir(scopeName, ident);
753
- if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
754
- ensureDir(path.dirname(dest));
755
- fs.renameSync(tmpDir, dest);
756
-
757
- const destManifest = readJson(path.join(dest, 'kdna.json')) || {};
758
- destManifest._source = {
759
- type: 'local-file',
760
- path: abs,
761
- encrypted: isEncrypted,
762
- installed_at: new Date().toISOString(),
763
- };
764
- fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(destManifest, null, 2) + '\n');
496
+ const installed = installAsset({
497
+ sourcePath: abs,
498
+ name: declared,
499
+ version: manifest.version,
500
+ source: { type: 'local-file', path: abs },
501
+ });
765
502
 
766
503
  if (jsonMode) {
767
504
  console.log(
768
505
  JSON.stringify({
769
506
  name: declared,
770
507
  installed: true,
771
- path: dest,
508
+ path: installed.asset_path,
509
+ receipt_path: installed.receipt_path,
510
+ asset_digest: installed.asset_digest,
511
+ content_digest: installed.content_digest,
772
512
  source: 'local-file',
773
513
  source_path: abs,
774
- encrypted: isEncrypted,
775
- }),
776
- );
777
- } else {
778
- console.log(`✓ Installed ${declared} from ${isEncrypted ? 'encrypted' : 'local'} file`);
779
- console.log(` Location: ${dest}`);
780
- }
781
- }
782
-
783
- function installFromLocalDir(dirPath, _yes, jsonMode = false) {
784
- const abs = path.resolve(dirPath);
785
- const manifest = readJson(path.join(abs, 'kdna.json'));
786
- const declared = manifest?.name;
787
- if (!declared || !/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9_]*$/.test(declared)) {
788
- error(`Source kdna.json.name "${declared || '?'}" must be @scope/name format.`);
789
- }
790
- const [scopeName, ident] = declared.split('/');
791
- const dest = domainDir(scopeName, ident);
792
- if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
793
- ensureDir(path.dirname(dest));
794
- fs.cpSync(abs, dest, { recursive: true });
795
-
796
- const destManifest = readJson(path.join(dest, 'kdna.json')) || {};
797
- destManifest._source = {
798
- type: 'local-dir',
799
- path: abs,
800
- installed_at: new Date().toISOString(),
801
- };
802
- fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(destManifest, null, 2) + '\n');
803
-
804
- if (jsonMode) {
805
- console.log(
806
- JSON.stringify({
807
- name: declared,
808
- installed: true,
809
- path: dest,
810
- source: 'local-dir',
811
- source_path: abs,
812
514
  }),
813
515
  );
814
516
  } else {
815
- console.log(`✓ Installed ${declared} from local directory (dev mode)`);
816
- console.log(` Location: ${dest}`);
517
+ console.log(`✓ Installed ${declared} from local .kdna asset`);
518
+ console.log(` Asset: ${installed.asset_path}`);
817
519
  }
818
520
  }
819
521
 
820
522
  // ─── Remove ─────────────────────────────────────────────────────────────
821
523
 
822
524
  function cmdRemove(input) {
823
- warnLegacy();
824
525
  const parsed = parseName(input);
825
526
  if (!parsed) error(`Invalid name "${input}". Use @scope/name or bare name.`);
826
- const dest = domainDir(parsed.scope, parsed.ident);
827
- if (!fs.existsSync(dest)) {
527
+ if (!removeInstalled(parsed.full)) {
828
528
  console.log(`${parsed.full} is not installed.`);
829
529
  return;
830
530
  }
831
- fs.rmSync(dest, { recursive: true, force: true });
832
531
  console.log(`✓ Removed ${parsed.full}`);
833
532
  }
834
533
 
835
534
  // ─── Info ───────────────────────────────────────────────────────────────
836
535
 
837
536
  function cmdInfo(input, jsonMode = false) {
838
- warnLegacy();
839
537
  const parsed = parseName(input);
840
538
  if (!parsed) error(`Invalid name "${input}".`, EXIT.INPUT_ERROR);
841
- const dest = domainDir(parsed.scope, parsed.ident);
842
- if (!fs.existsSync(dest)) error(`${parsed.full} is not installed.`, EXIT.INPUT_ERROR);
539
+ const installed = getInstalled(parsed.full);
540
+ if (!installed) error(`${parsed.full} is not installed.`, EXIT.INPUT_ERROR);
843
541
 
844
- const manifest = readJson(path.join(dest, 'kdna.json'));
845
- const core = readJson(path.join(dest, 'KDNA_Core.json'));
846
- const pat = readJson(path.join(dest, 'KDNA_Patterns.json'));
847
- const source = manifest?._source || {};
542
+ const container = readContainer(installed.asset_path);
543
+ const manifest = container.manifest || {};
544
+ const core = container.core || {};
545
+ const pat = container.patterns || {};
546
+ const source = installed.source || {};
848
547
 
849
548
  // ─── Judgment surface (computed for both modes) ────────────────────
850
549
  const axiomCount = (core?.axioms || []).length;
@@ -870,17 +569,7 @@ function cmdInfo(input, jsonMode = false) {
870
569
  }
871
570
 
872
571
  // ─── Eval cases ────────────────────────────────────────────────────
873
- const evalDir = path.join(dest, 'evals');
874
- let evalInfo = null;
875
- if (fs.existsSync(evalDir)) {
876
- const evalFiles = fs.readdirSync(evalDir).filter((f) => f.endsWith('.json'));
877
- let totalCases = 0;
878
- for (const f of evalFiles) {
879
- const data = readJson(path.join(evalDir, f));
880
- if (data?.cases) totalCases += data.cases.length;
881
- }
882
- evalInfo = { files: evalFiles.length, totalCases };
883
- }
572
+ const evalInfo = null;
884
573
 
885
574
  // ─── Known risks ───────────────────────────────────────────────────
886
575
  const risks = [];
@@ -899,7 +588,7 @@ function cmdInfo(input, jsonMode = false) {
899
588
  'KDNA_Reasoning.json',
900
589
  'KDNA_Evolution.json',
901
590
  ];
902
- const present = expected.filter((f) => fs.existsSync(path.join(dest, f)));
591
+ const present = expected.filter((f) => container.files.includes(f));
903
592
 
904
593
  // ─── JSON mode: emit structured output only, then exit ─────────────
905
594
  if (jsonMode) {
@@ -912,10 +601,12 @@ function cmdInfo(input, jsonMode = false) {
912
601
  author: manifest?.author?.name || '?',
913
602
  pubkey: manifest?.author?.pubkey || null,
914
603
  has_pem: !!manifest?.author?.public_key_pem,
915
- source_url: source.kdna_url || null,
916
- sha256: source.sha256 || null,
917
- installed_at: source.installed_at || null,
918
- path: dest,
604
+ source_url: source.asset_url || null,
605
+ asset_digest: installed.asset_digest || source.asset_digest || null,
606
+ content_digest: installed.content_digest || null,
607
+ receipt_path: installed.receipt_path || null,
608
+ installed_at: installed.installed_at || null,
609
+ path: installed.asset_path,
919
610
  axioms: axiomCount,
920
611
  ontology: ontologyCount,
921
612
  stances: stanceCount,
@@ -951,12 +642,18 @@ function cmdInfo(input, jsonMode = false) {
951
642
  if (manifest?.author?.public_key_pem) {
952
643
  console.log(` Embedded PEM: yes (full Ed25519 verify available)`);
953
644
  } else {
954
- console.log(` Embedded PEM: no (legacy pre-v0.7.1 package)`);
645
+ console.log(` Embedded PEM: no`);
955
646
  }
956
- if (source.kdna_url) console.log(` Source URL: ${source.kdna_url}`);
957
- if (source.sha256) console.log(` Source sha256: ${source.sha256.slice(0, 32)}…`);
958
- console.log(` Installed: ${source.installed_at || '?'}`);
959
- console.log(` Path: ${dest}`);
647
+ if (source.asset_url) {
648
+ console.log(` Source URL: ${source.asset_url}`);
649
+ }
650
+ if (installed.asset_digest)
651
+ console.log(` Asset digest: ${installed.asset_digest.slice(0, 39)}…`);
652
+ if (installed.content_digest)
653
+ console.log(` Content digest: ${installed.content_digest.slice(0, 39)}…`);
654
+ if (installed.receipt_path) console.log(` Receipt: ${installed.receipt_path}`);
655
+ console.log(` Installed: ${installed.installed_at || '?'}`);
656
+ console.log(` Asset: ${installed.asset_path}`);
960
657
 
961
658
  // ─── Judgment surface ──────────────────────────────────────────
962
659
  console.log('');
@@ -1008,16 +705,14 @@ function cmdInfo(input, jsonMode = false) {
1008
705
  // ─── Update ─────────────────────────────────────────────────────────────
1009
706
 
1010
707
  function cmdUpdate(input) {
1011
- warnLegacy();
1012
708
  const parsed = parseName(input);
1013
709
  if (!parsed) error(`Invalid name "${input}".`);
1014
- const dest = domainDir(parsed.scope, parsed.ident);
1015
- if (!fs.existsSync(dest)) {
710
+ const installed = getInstalled(parsed.full);
711
+ if (!installed) {
1016
712
  console.log(`${parsed.full} not installed. Run: kdna install ${input}`);
1017
713
  return;
1018
714
  }
1019
- const manifest = readJson(path.join(dest, 'kdna.json')) || {};
1020
- const installedVersion = manifest.version || manifest._source?.version || '?';
715
+ const installedVersion = installed.version || '?';
1021
716
 
1022
717
  const resolver = new RegistryResolver({ allowNetwork: true, refresh: true });
1023
718
  let entry;
@@ -1036,24 +731,17 @@ function cmdUpdate(input) {
1036
731
  }
1037
732
 
1038
733
  function cmdUpdateAll() {
1039
- warnLegacy();
1040
- if (!fs.existsSync(INSTALL_DIR)) {
734
+ const installed = listInstalled();
735
+ if (!installed.length) {
1041
736
  console.log('No installs.');
1042
737
  return;
1043
738
  }
1044
- const scopes = fs.readdirSync(INSTALL_DIR).filter((d) => d.startsWith('@'));
1045
- for (const scope of scopes) {
1046
- const sd = path.join(INSTALL_DIR, scope);
1047
- if (!fs.statSync(sd).isDirectory()) continue;
1048
- for (const ident of fs.readdirSync(sd)) {
1049
- if (ident.startsWith('.')) continue;
1050
- try {
1051
- cmdUpdate(`${scope}/${ident}`);
1052
- } catch (e) {
1053
- console.warn(` ⚠ ${scope}/${ident}: ${e.message.split('\n')[0]}`);
1054
- }
739
+ for (const entry of installed) {
740
+ try {
741
+ cmdUpdate(entry.full);
742
+ } catch (e) {
743
+ console.warn(` ⚠ ${entry.full}: ${e.message.split('\n')[0]}`);
1055
744
  }
1056
- console.log('');
1057
745
  }
1058
746
  }
1059
747