@aikdna/kdna-cli 0.16.10 → 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/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,87 +153,39 @@ 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('./') || input.startsWith('/') || input.startsWith('~/') || fs.existsSync(input))
213
163
  ) {
214
164
  const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
215
165
  if (!fs.existsSync(resolved)) error(`Local file not found: ${resolved}`);
216
166
  return { type: 'local-file', path: resolved };
217
167
  }
218
168
 
219
- // Local directory
220
169
  if (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/')) {
221
170
  const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
222
171
  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 };
172
+ if (fs.statSync(resolved).isDirectory()) {
173
+ error(
174
+ `Directory install is not supported. KDNA installs .kdna assets only.\n` +
175
+ `Use: kdna dev pack ${resolved} --output <dir>, then kdna install <file.kdna>`,
176
+ EXIT.INPUT_ERROR,
177
+ );
178
+ }
179
+ error(`Not a .kdna file: ${resolved}`, EXIT.INPUT_ERROR);
225
180
  }
226
181
 
227
182
  // Registry name (bare or @scope/name)
228
183
  const parsed = parseName(input);
229
184
  if (!parsed) {
230
185
  error(
231
- `Cannot parse "${input}". Use:\n` +
186
+ `Cannot parse "${input}". Use:\n` +
232
187
  ` kdna install <name> # @aikdna/<name>\n` +
233
188
  ` kdna install @scope/name # any scope\n` +
234
- ` kdna install ./folder # local directory\n` +
235
189
  ` kdna install ./file.kdna # local .kdna file`,
236
190
  );
237
191
  }
@@ -266,90 +220,10 @@ function downloadFile(url, dest) {
266
220
  throw new Error(`download failed after 3 attempts: ${stderr}`);
267
221
  }
268
222
 
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
223
  // ─── Signature verification ────────────────────────────────────────────
350
224
 
351
- function verifySignature({ destDir, scope, entry, lenient = true }) {
352
- const manifest = readJson(path.join(destDir, 'kdna.json'));
225
+ function verifySignature({ assetPath, scope, entry, lenient = true }) {
226
+ const manifest = readContainerJson(assetPath, 'kdna.json');
353
227
  if (!manifest) {
354
228
  if (lenient) {
355
229
  console.warn(' ⚠ No kdna.json — cannot verify signature.');
@@ -359,20 +233,8 @@ function verifySignature({ destDir, scope, entry, lenient = true }) {
359
233
  }
360
234
 
361
235
  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
236
  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;
237
+ error(`${entry.name}: registry and .kdna manifest signatures are required.`, EXIT.TRUST_FAILED);
376
238
  }
377
239
 
378
240
  // Author pubkey fingerprint must match scope trust_pubkey
@@ -384,64 +246,23 @@ function verifySignature({ destDir, scope, entry, lenient = true }) {
384
246
  }
385
247
 
386
248
  // 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;
249
+ if (!manifest.author?.public_key_pem) {
250
+ error(`${entry.name}: manifest author.public_key_pem is required for Ed25519 verification.`, EXIT.TRUST_FAILED);
392
251
  }
393
252
 
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) {
253
+ const result = verifyAsset(assetPath, { requireSignature: true });
254
+ for (const e of result.errors || []) {
255
+ if (e.includes('signature') || e.includes('public_key') || e.includes('fingerprint')) {
256
+ error(`${entry.name}: ${e}. Refusing to install.`, EXIT.TRUST_FAILED);
257
+ }
258
+ }
259
+ if (!result.signature_valid) {
397
260
  error(
398
- `${entry.name}: embedded public_key_pem does not match author.pubkey fingerprint. Refusing.`,
261
+ `${entry.name}: Ed25519 signature INVALID. Package may be tampered. Refusing.`,
399
262
  EXIT.TRUST_FAILED,
400
263
  );
401
264
  }
402
-
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}`);
429
- }
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
- }
265
+ console.log(' ✓ Signature OK (Ed25519 verified)');
445
266
  }
446
267
 
447
268
  // ─── Status confirmation (interactive) ─────────────────────────────────
@@ -465,31 +286,12 @@ function confirmStatus(entry, yes) {
465
286
  // ─── Cleanup stale temps ───────────────────────────────────────────────
466
287
 
467
288
  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
- }
289
+ ensureDir(INSTALL_DIR);
487
290
  }
488
291
 
489
292
  // ─── Main install ──────────────────────────────────────────────────────
490
293
 
491
294
  function cmdInstallExtended(input, args = []) {
492
- warnLegacy();
493
295
  ensureDir(INSTALL_DIR);
494
296
  cleanStaleTemps();
495
297
 
@@ -505,11 +307,21 @@ function cmdInstallExtended(input, args = []) {
505
307
  return installFromRegistry(source.parsed, yes, jsonMode);
506
308
  case 'local-file':
507
309
  return installFromLocalFile(source.path, yes, jsonMode);
508
- case 'local-dir':
509
- return installFromLocalDir(source.path, yes, jsonMode);
510
310
  }
511
311
  }
512
312
 
313
+ function scopedNameError(sourceLabel, declared) {
314
+ return (
315
+ `Invalid domain name in ${sourceLabel}: "${declared || '?'}"\n\n` +
316
+ `KDNA v0.7+ requires scoped domain names.\n` +
317
+ `Expected format: @scope/name\n` +
318
+ `Example: @aikdna/my_domain\n\n` +
319
+ `Fix:\n` +
320
+ `- update kdna.json name to "@aikdna/my_domain"\n` +
321
+ `- or initialize a new scoped domain, then copy your content into it`
322
+ );
323
+ }
324
+
513
325
  function installFromRegistry(parsed, yes, jsonMode = false) {
514
326
  const resolver = new RegistryResolver({ allowNetwork: true });
515
327
  let scope, entry;
@@ -541,14 +353,17 @@ function installFromRegistry(parsed, yes, jsonMode = false) {
541
353
  return installCluster(entry, resolver, yes, jsonMode);
542
354
  }
543
355
 
544
- if (!entry.kdna_url) {
356
+ if (!entry.asset_url) {
545
357
  error(
546
- `${entry.name}@${entry.version} has no kdna_url in registry.\n` +
358
+ `${entry.name}@${entry.version} has no asset_url in registry.\n` +
547
359
  `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.)`,
360
+ `(Registry v3 publishes canonical .kdna assets through asset_url only.)`,
549
361
  EXIT.REGISTRY_ERROR,
550
362
  );
551
363
  }
364
+ if (!entry.asset_digest) {
365
+ error(`${entry.name}@${entry.version} has no asset_digest in registry.`, EXIT.REGISTRY_ERROR);
366
+ }
552
367
 
553
368
  if (!confirmStatus(entry, yes)) {
554
369
  console.log('Installation cancelled.');
@@ -559,70 +374,66 @@ function installFromRegistry(parsed, yes, jsonMode = false) {
559
374
  }
560
375
 
561
376
  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`);
377
+ const ident = entry.name.split('/')[1];
378
+ const tmpDir = path.join(USER_KDNA_DIR, 'cache', 'downloads');
379
+ const tmpFile = path.join(tmpDir, `.${ident}-${Date.now()}.kdna.tmp`);
380
+ const assetUrl = entry.asset_url;
565
381
 
566
382
  if (!jsonMode) console.log(` Downloading ${entry.name}@${entry.version}...`);
567
- ensureDir(scopeDir(scopeName));
383
+ ensureDir(tmpDir);
568
384
  try {
569
- downloadFile(entry.kdna_url, tmpFile);
385
+ downloadFile(assetUrl, tmpFile);
570
386
  } catch {
571
- error(`Failed to download ${entry.kdna_url}`, EXIT.REGISTRY_ERROR);
387
+ error(`Failed to download ${assetUrl}`, EXIT.REGISTRY_ERROR);
572
388
  }
573
389
 
574
- // sha256 check
390
+ // asset digest check
575
391
  const actual = sha256File(tmpFile);
576
- if (entry.sha256 && actual !== entry.sha256) {
392
+ const expectedDigest = entry.asset_digest;
393
+ const actualDigest = `sha256:${actual}`;
394
+ if (expectedDigest && actualDigest !== expectedDigest) {
577
395
  try {
578
396
  fs.unlinkSync(tmpFile);
579
397
  } catch {
580
398
  /* ignore */
581
399
  }
582
- error(`sha256 mismatch for ${entry.name}: expected ${entry.sha256}, got ${actual}`);
400
+ error(`asset digest mismatch for ${entry.name}: expected ${expectedDigest}, got ${actualDigest}`);
583
401
  }
584
- if (!jsonMode) console.log(` ✓ sha256 verified`);
402
+ if (!jsonMode) console.log(` ✓ asset digest verified`);
585
403
 
586
- // Replace existing install atomically-ish
587
- if (fs.existsSync(dest)) {
588
- fs.rmSync(dest, { recursive: true, force: true });
589
- }
590
- ensureDir(dest);
404
+ verifySignature({ assetPath: tmpFile, scope, entry, lenient: true });
591
405
 
592
- extractKdna(tmpFile, dest);
406
+ const installed = installAsset({
407
+ sourcePath: tmpFile,
408
+ name: entry.name,
409
+ version: entry.version,
410
+ source: {
411
+ type: 'registry',
412
+ name: entry.name,
413
+ version: entry.version,
414
+ asset_url: assetUrl,
415
+ asset_digest: expectedDigest,
416
+ },
417
+ });
593
418
  try {
594
419
  fs.unlinkSync(tmpFile);
595
420
  } catch {
596
421
  /* ignore */
597
422
  }
598
423
 
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
424
  if (jsonMode) {
614
425
  console.log(
615
426
  JSON.stringify({
616
427
  name: entry.name,
617
428
  version: entry.version,
618
429
  installed: true,
619
- path: dest,
430
+ path: installed.asset_path,
620
431
  type: entry.type || 'domain',
621
432
  }),
622
433
  );
623
434
  } else {
624
435
  console.log(`✓ Installed ${entry.name}@${entry.version}`);
625
- console.log(` Location: ${dest}`);
436
+ console.log(` Asset: ${installed.asset_path}`);
626
437
  }
627
438
  }
628
439
 
@@ -637,8 +448,8 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
637
448
  for (const sub of subdomains) {
638
449
  try {
639
450
  const resolved = resolver.resolve(sub);
640
- if (!resolved.entry.kdna_url) {
641
- if (!jsonMode) console.warn(` ⚠ ${sub}: no kdna_url (skipping)`);
451
+ if (!resolved.entry.asset_url) {
452
+ if (!jsonMode) console.warn(` ⚠ ${sub}: no asset_url (skipping)`);
642
453
  continue;
643
454
  }
644
455
  if (!jsonMode) console.log('');
@@ -648,26 +459,6 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
648
459
  }
649
460
  }
650
461
 
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
462
  if (jsonMode) {
672
463
  console.log(
673
464
  JSON.stringify({
@@ -675,7 +466,6 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
675
466
  version: clusterEntry.version,
676
467
  type: 'cluster',
677
468
  installed: true,
678
- path: clusterDest,
679
469
  subdomains: subdomains.length,
680
470
  }),
681
471
  );
@@ -688,163 +478,64 @@ function installCluster(clusterEntry, resolver, _yes, jsonMode = false) {
688
478
  function installFromLocalFile(filePath, _yes, jsonMode = false) {
689
479
  const abs = path.resolve(filePath);
690
480
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) error(`Not a file: ${abs}`);
481
+ if (!abs.endsWith('.kdna')) error(`Not a .kdna asset: ${abs}`, EXIT.INPUT_ERROR);
691
482
 
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'));
483
+ const manifest = readContainerJson(abs, 'kdna.json');
743
484
  const declared = manifest?.name;
744
485
  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
- );
486
+ error(scopedNameError('package kdna.json.name', declared), EXIT.INPUT_ERROR);
750
487
  }
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');
488
+ const installed = installAsset({
489
+ sourcePath: abs,
490
+ name: declared,
491
+ version: manifest.version,
492
+ source: { type: 'local-file', path: abs },
493
+ });
765
494
 
766
495
  if (jsonMode) {
767
496
  console.log(
768
497
  JSON.stringify({
769
498
  name: declared,
770
499
  installed: true,
771
- path: dest,
500
+ path: installed.asset_path,
501
+ receipt_path: installed.receipt_path,
502
+ asset_digest: installed.asset_digest,
503
+ content_digest: installed.content_digest,
772
504
  source: 'local-file',
773
505
  source_path: abs,
774
- encrypted: isEncrypted,
775
506
  }),
776
507
  );
777
508
  } 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
- }),
813
- );
814
- } else {
815
- console.log(`✓ Installed ${declared} from local directory (dev mode)`);
816
- console.log(` Location: ${dest}`);
509
+ console.log(`✓ Installed ${declared} from local .kdna asset`);
510
+ console.log(` Asset: ${installed.asset_path}`);
817
511
  }
818
512
  }
819
513
 
820
514
  // ─── Remove ─────────────────────────────────────────────────────────────
821
515
 
822
516
  function cmdRemove(input) {
823
- warnLegacy();
824
517
  const parsed = parseName(input);
825
518
  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)) {
519
+ if (!removeInstalled(parsed.full)) {
828
520
  console.log(`${parsed.full} is not installed.`);
829
521
  return;
830
522
  }
831
- fs.rmSync(dest, { recursive: true, force: true });
832
523
  console.log(`✓ Removed ${parsed.full}`);
833
524
  }
834
525
 
835
526
  // ─── Info ───────────────────────────────────────────────────────────────
836
527
 
837
528
  function cmdInfo(input, jsonMode = false) {
838
- warnLegacy();
839
529
  const parsed = parseName(input);
840
530
  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);
531
+ const installed = getInstalled(parsed.full);
532
+ if (!installed) error(`${parsed.full} is not installed.`, EXIT.INPUT_ERROR);
843
533
 
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 || {};
534
+ const container = readContainer(installed.asset_path);
535
+ const manifest = container.manifest || {};
536
+ const core = container.core || {};
537
+ const pat = container.patterns || {};
538
+ const source = installed.source || {};
848
539
 
849
540
  // ─── Judgment surface (computed for both modes) ────────────────────
850
541
  const axiomCount = (core?.axioms || []).length;
@@ -870,17 +561,7 @@ function cmdInfo(input, jsonMode = false) {
870
561
  }
871
562
 
872
563
  // ─── 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
- }
564
+ const evalInfo = null;
884
565
 
885
566
  // ─── Known risks ───────────────────────────────────────────────────
886
567
  const risks = [];
@@ -899,7 +580,7 @@ function cmdInfo(input, jsonMode = false) {
899
580
  'KDNA_Reasoning.json',
900
581
  'KDNA_Evolution.json',
901
582
  ];
902
- const present = expected.filter((f) => fs.existsSync(path.join(dest, f)));
583
+ const present = expected.filter((f) => container.files.includes(f));
903
584
 
904
585
  // ─── JSON mode: emit structured output only, then exit ─────────────
905
586
  if (jsonMode) {
@@ -912,10 +593,12 @@ function cmdInfo(input, jsonMode = false) {
912
593
  author: manifest?.author?.name || '?',
913
594
  pubkey: manifest?.author?.pubkey || null,
914
595
  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,
596
+ source_url: source.asset_url || null,
597
+ asset_digest: installed.asset_digest || source.asset_digest || null,
598
+ content_digest: installed.content_digest || null,
599
+ receipt_path: installed.receipt_path || null,
600
+ installed_at: installed.installed_at || null,
601
+ path: installed.asset_path,
919
602
  axioms: axiomCount,
920
603
  ontology: ontologyCount,
921
604
  stances: stanceCount,
@@ -953,10 +636,14 @@ function cmdInfo(input, jsonMode = false) {
953
636
  } else {
954
637
  console.log(` Embedded PEM: no (legacy pre-v0.7.1 package)`);
955
638
  }
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}`);
639
+ if (source.asset_url) {
640
+ console.log(` Source URL: ${source.asset_url}`);
641
+ }
642
+ if (installed.asset_digest) console.log(` Asset digest: ${installed.asset_digest.slice(0, 39)}…`);
643
+ if (installed.content_digest) console.log(` Content digest: ${installed.content_digest.slice(0, 39)}…`);
644
+ if (installed.receipt_path) console.log(` Receipt: ${installed.receipt_path}`);
645
+ console.log(` Installed: ${installed.installed_at || '?'}`);
646
+ console.log(` Asset: ${installed.asset_path}`);
960
647
 
961
648
  // ─── Judgment surface ──────────────────────────────────────────
962
649
  console.log('');
@@ -1008,16 +695,14 @@ function cmdInfo(input, jsonMode = false) {
1008
695
  // ─── Update ─────────────────────────────────────────────────────────────
1009
696
 
1010
697
  function cmdUpdate(input) {
1011
- warnLegacy();
1012
698
  const parsed = parseName(input);
1013
699
  if (!parsed) error(`Invalid name "${input}".`);
1014
- const dest = domainDir(parsed.scope, parsed.ident);
1015
- if (!fs.existsSync(dest)) {
700
+ const installed = getInstalled(parsed.full);
701
+ if (!installed) {
1016
702
  console.log(`${parsed.full} not installed. Run: kdna install ${input}`);
1017
703
  return;
1018
704
  }
1019
- const manifest = readJson(path.join(dest, 'kdna.json')) || {};
1020
- const installedVersion = manifest.version || manifest._source?.version || '?';
705
+ const installedVersion = installed.version || '?';
1021
706
 
1022
707
  const resolver = new RegistryResolver({ allowNetwork: true, refresh: true });
1023
708
  let entry;
@@ -1036,24 +721,17 @@ function cmdUpdate(input) {
1036
721
  }
1037
722
 
1038
723
  function cmdUpdateAll() {
1039
- warnLegacy();
1040
- if (!fs.existsSync(INSTALL_DIR)) {
724
+ const installed = listInstalled();
725
+ if (!installed.length) {
1041
726
  console.log('No installs.');
1042
727
  return;
1043
728
  }
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
- }
729
+ for (const entry of installed) {
730
+ try {
731
+ cmdUpdate(entry.full);
732
+ } catch (e) {
733
+ console.warn(` ⚠ ${entry.full}: ${e.message.split('\n')[0]}`);
1055
734
  }
1056
- console.log('');
1057
735
  }
1058
736
  }
1059
737