@knolo/cli 5.2.0 → 5.2.2

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.
@@ -1,18 +1,60 @@
1
- import { copyFile, mkdir, rename, unlink } from 'node:fs/promises';
1
+ import {
2
+ chmod,
3
+ copyFile,
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ rename,
8
+ unlink,
9
+ } from 'node:fs/promises';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import { isIP } from 'node:net';
2
12
  import { homedir } from 'node:os';
3
13
  import path from 'node:path';
4
- import { registryApiUrl, resolveRegistryUrl } from './config.mjs';
5
- import { downloadBlobToTemp, installCachedArtifact, readArtifact } from './artifacts.mjs';
14
+ import { put as putBlob } from '@vercel/blob';
15
+ import {
16
+ normalizeRegistryUrl,
17
+ registryApiUrl,
18
+ resolveRegistryUrl,
19
+ } from './config.mjs';
20
+ import {
21
+ downloadBlobToTemp,
22
+ MAX_ARTIFACT_BYTES,
23
+ readArtifact,
24
+ stageCachedArtifact,
25
+ } from './artifacts.mjs';
6
26
  import { RegistryError } from './errors.mjs';
7
- import { getJson } from './http.mjs';
8
- import { cachePath, upsertLockfile, validateLockfileUpdate } from './lockfile.mjs';
27
+ import { getJson, postJson } from './http.mjs';
28
+ import {
29
+ cachePath,
30
+ stageLockfileUpdate,
31
+ validateLockfileUpdate,
32
+ } from './lockfile.mjs';
9
33
  import { validatePackManifest } from './manifest.mjs';
10
34
  import { parsePackName, parsePackSpec } from './specs.mjs';
11
- import { loadCredentials, promptForToken, removeCredentials, saveCredentials, validateToken } from './credentials.mjs';
35
+ import {
36
+ loadCredentials,
37
+ promptForToken,
38
+ removeCredentials,
39
+ saveCredentials,
40
+ validateToken,
41
+ } from './credentials.mjs';
12
42
 
13
43
  const PACK_SHA256_PATTERN = /^[0-9a-f]{64}$/;
44
+ const LOCAL_ONLY_HOSTNAMES = new Set([
45
+ 'localhost',
46
+ 'localhost.localdomain',
47
+ 'ip6-localhost',
48
+ 'ip6-loopback',
49
+ 'broadcasthost',
50
+ 'local',
51
+ 'internal',
52
+ ]);
14
53
 
15
- export async function runHubSearch(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
54
+ export async function runHubSearch(
55
+ args,
56
+ { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}
57
+ ) {
16
58
  const { query, flags } = parseSearchArgs(args);
17
59
  const registry = resolveRegistryUrl({ value: flags.registry, env });
18
60
  const url = registryApiUrl(registry, ['packs'], {
@@ -31,7 +73,10 @@ export async function runHubSearch(args, { env = process.env, fetchImpl = global
31
73
  return { registry, url: String(url), body };
32
74
  }
33
75
 
34
- export async function runHubInfo(args, { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}) {
76
+ export async function runHubInfo(
77
+ args,
78
+ { env = process.env, fetchImpl = globalThis.fetch, print = console.log } = {}
79
+ ) {
35
80
  const { positional, flags } = parseInfoArgs(args);
36
81
  const pack = parsePackName(positional[0]);
37
82
  const registry = resolveRegistryUrl({ value: flags.registry, env });
@@ -45,31 +90,52 @@ export async function runHubInfo(args, { env = process.env, fetchImpl = globalTh
45
90
  return { registry, url: String(url), body };
46
91
  }
47
92
 
48
- export async function runHubAdd(args, {
49
- env = process.env,
50
- fetchImpl = globalThis.fetch,
51
- core,
52
- cwd = process.cwd(),
53
- homeDir,
54
- print = console.log,
55
- warn = console.error,
56
- } = {}) {
93
+ export async function runHubAdd(
94
+ args,
95
+ {
96
+ env = process.env,
97
+ fetchImpl = globalThis.fetch,
98
+ core,
99
+ cwd = process.cwd(),
100
+ homeDir,
101
+ print = console.log,
102
+ warn = console.error,
103
+ } = {}
104
+ ) {
57
105
  const { spec, flags } = parseAddArgs(args);
58
- if (!core) throw new Error('Could not load @knolo/core for Hub artifact validation.');
106
+ if (!core)
107
+ throw new Error('Could not load @knolo/core for Hub artifact validation.');
59
108
  const registry = resolveRegistryUrl({ value: flags.registry, env });
60
- const manifestUrl = registryApiUrl(registry, ['packs', spec.publisher, spec.slug, spec.version]);
109
+ const manifestUrl = registryApiUrl(registry, [
110
+ 'packs',
111
+ spec.publisher,
112
+ spec.slug,
113
+ spec.version,
114
+ ]);
61
115
 
62
116
  let manifest;
63
117
  let yanked = false;
64
118
  try {
65
119
  const body = await getJson(manifestUrl, { fetchImpl });
66
- manifest = validatePackManifest(body, { expectedName: spec.name, requestedVersion: spec.version });
120
+ manifest = validatePackManifest(body, {
121
+ expectedName: spec.name,
122
+ requestedVersion: spec.version,
123
+ });
67
124
  } catch (error) {
68
- if (error instanceof RegistryError && (error.status === 404 || error.code === 'not_found')) {
125
+ if (
126
+ error instanceof RegistryError &&
127
+ (error.status === 404 || error.code === 'not_found')
128
+ ) {
69
129
  throw new Error('pack version not found');
70
130
  }
71
- if (error instanceof RegistryError && (error.status === 410 || error.code === 'yanked')) {
72
- manifest = validatePackManifest(error.body, { expectedName: spec.name, requestedVersion: spec.version });
131
+ if (
132
+ error instanceof RegistryError &&
133
+ (error.status === 410 || error.code === 'yanked')
134
+ ) {
135
+ manifest = validatePackManifest(error.body, {
136
+ expectedName: spec.name,
137
+ requestedVersion: spec.version,
138
+ });
73
139
  yanked = true;
74
140
  } else {
75
141
  throw error;
@@ -77,10 +143,15 @@ export async function runHubAdd(args, {
77
143
  }
78
144
 
79
145
  if (manifest.yanked) yanked = true;
80
- if (yanked && !flags.force) throw new Error('version yanked; use --force to download yanked bytes');
81
- if (yanked) warn(`warning: downloading yanked version ${manifest.name}@${manifest.version}`);
146
+ if (yanked && !flags.force)
147
+ throw new Error('version yanked; use --force to download yanked bytes');
148
+ if (yanked)
149
+ warn(
150
+ `warning: downloading yanked version ${manifest.name}@${manifest.version}`
151
+ );
82
152
  if (!manifest.url) throw new Error('artifact bytes are not stored yet');
83
- if (!PACK_SHA256_PATTERN.test(manifest.sha256)) throw new Error('manifest sha256 is invalid');
153
+ if (!PACK_SHA256_PATTERN.test(manifest.sha256))
154
+ throw new Error('manifest sha256 is invalid');
84
155
 
85
156
  const artifactHome = homeDir || homedir();
86
157
  await validateLockfileUpdate({ cwd, registry, manifest, force: flags.force });
@@ -91,26 +162,61 @@ export async function runHubAdd(args, {
91
162
  expectedSize: manifest.sizeBytes,
92
163
  fetchImpl,
93
164
  });
94
- let installed = false;
165
+ const stagedFiles = [];
95
166
  try {
96
167
  if (download.sha256 !== manifest.sha256) {
97
- throw new Error(`artifact sha256 mismatch: expected ${manifest.sha256}, got ${download.sha256}`);
168
+ throw new Error(
169
+ `artifact sha256 mismatch: expected ${manifest.sha256}, got ${download.sha256}`
170
+ );
98
171
  }
99
172
  const bytes = await readArtifact(download.tempPath);
100
173
  const validation = await validateKnowledgeImage(core, bytes);
101
- if (manifest.format && String(manifest.format).toUpperCase() !== validation.format) {
102
- throw new Error(`artifact format mismatch: manifest=${manifest.format}, bytes=${validation.format}`);
174
+ if (
175
+ manifest.format &&
176
+ String(manifest.format).toUpperCase() !== validation.format
177
+ ) {
178
+ throw new Error(
179
+ `artifact format mismatch: manifest=${manifest.format}, bytes=${validation.format}`
180
+ );
103
181
  }
104
- if (manifest.stateRoot !== undefined && manifest.stateRoot !== validation.stateRoot) {
105
- throw new Error(`artifact stateRoot mismatch: expected ${manifest.stateRoot}, got ${validation.stateRoot || 'none'}`);
182
+ if (validation.format === 'V5' && !manifest.stateRoot) {
183
+ throw new Error('V5 artifact requires manifest stateRoot.');
106
184
  }
107
- const cache = await installCachedArtifact(download.tempPath, finalCachePath);
108
- installed = true;
109
- const lock = await upsertLockfile({ cwd, registry, manifest, force: flags.force });
110
- let outputPath = cache;
185
+ if (
186
+ manifest.stateRoot !== undefined &&
187
+ manifest.stateRoot !== validation.stateRoot
188
+ ) {
189
+ throw new Error(
190
+ `artifact stateRoot mismatch: expected ${manifest.stateRoot}, got ${validation.stateRoot || 'none'}`
191
+ );
192
+ }
193
+ const stagedCachePath = await stageCachedArtifact(
194
+ download.tempPath,
195
+ finalCachePath
196
+ );
197
+ stagedFiles.push({ targetPath: finalCachePath, tempPath: stagedCachePath });
198
+ let outputPath = finalCachePath;
111
199
  if (flags.out) {
112
- outputPath = await copyArtifact(cache, flags.out, cwd);
200
+ const stagedOutput = await stageArtifactCopy(
201
+ download.tempPath,
202
+ flags.out,
203
+ cwd
204
+ );
205
+ stagedFiles.push(stagedOutput);
206
+ outputPath = stagedOutput.targetPath;
113
207
  }
208
+ const stagedLock = await stageLockfileUpdate({
209
+ cwd,
210
+ registry,
211
+ manifest,
212
+ force: flags.force,
213
+ });
214
+ stagedFiles.push({
215
+ targetPath: stagedLock.path,
216
+ tempPath: stagedLock.tempPath,
217
+ });
218
+ await commitStagedFiles(stagedFiles);
219
+ const lock = { path: stagedLock.path, entry: stagedLock.entry };
114
220
  const result = { ...manifest, path: outputPath, lockfile: lock.path };
115
221
  if (flags.json) print(JSON.stringify(result, null, 2));
116
222
  else {
@@ -121,29 +227,44 @@ export async function runHubAdd(args, {
121
227
  }
122
228
  return result;
123
229
  } finally {
124
- if (!installed) await unlinkIfPresent(download.tempPath);
230
+ await unlinkIfPresent(download.tempPath);
231
+ await Promise.all(
232
+ stagedFiles.map(({ tempPath }) => unlinkIfPresent(tempPath))
233
+ );
125
234
  }
126
235
  }
127
236
 
128
- export async function runHubLogin(args, {
129
- env = process.env,
130
- homeDir,
131
- input = process.stdin,
132
- output = process.stderr,
133
- print = console.log,
134
- } = {}) {
237
+ export async function runHubLogin(
238
+ args,
239
+ {
240
+ env = process.env,
241
+ homeDir,
242
+ input = process.stdin,
243
+ output = process.stderr,
244
+ print = console.log,
245
+ } = {}
246
+ ) {
135
247
  const { flags } = parseLoginArgs(args);
136
248
  const registry = resolveRegistryUrl({ value: flags.registry, env });
137
- print(`Create a token at ${registry}/dashboard/tokens (Sign in with GitHub).`);
138
- const token = (flags.token ?? await promptForToken({ input, output, forceStdin: flags.stdin })).trim();
249
+ print(
250
+ `Create a token at ${registry}/dashboard/tokens (Sign in with GitHub).`
251
+ );
252
+ const token = (
253
+ flags.token ??
254
+ (await promptForToken({ input, output, forceStdin: flags.stdin }))
255
+ ).trim();
139
256
  validateToken(token);
140
257
  const saved = await saveCredentials({ registry, token, env, homeDir });
141
258
  print(`logged in to ${saved.registry}`);
142
259
  print(`token ${saved.prefix}`);
260
+ print('Authorization: Bearer kno_…');
143
261
  return saved;
144
262
  }
145
263
 
146
- export async function runHubWhoami(args, { env = process.env, homeDir, print = console.log } = {}) {
264
+ export async function runHubWhoami(
265
+ args,
266
+ { env = process.env, homeDir, print = console.log } = {}
267
+ ) {
147
268
  if (args.length) throw new Error('Usage: knolo whoami');
148
269
  const credentials = await loadCredentials({ env, homeDir });
149
270
  print(`registry ${credentials.registry}`);
@@ -151,15 +272,553 @@ export async function runHubWhoami(args, { env = process.env, homeDir, print = c
151
272
  return { registry: credentials.registry, prefix: credentials.prefix };
152
273
  }
153
274
 
154
- export async function runHubLogout(args, { env = process.env, homeDir, print = console.log } = {}) {
275
+ export async function runHubLogout(
276
+ args,
277
+ { env = process.env, homeDir, print = console.log } = {}
278
+ ) {
155
279
  if (args.length) throw new Error('Usage: knolo logout');
156
280
  const result = await removeCredentials({ env, homeDir });
157
281
  print(result.removed ? 'logged out' : 'not logged in');
158
282
  return result;
159
283
  }
160
284
 
161
- export function runHubPublishStub() {
162
- throw new Error('Hub write APIs do not accept CLI tokens yet');
285
+ export async function runHubPublish(
286
+ args,
287
+ {
288
+ env = process.env,
289
+ fetchImpl = globalThis.fetch,
290
+ cwd = process.cwd(),
291
+ homeDir,
292
+ print = console.log,
293
+ sleep = wait,
294
+ putImpl = putBlob,
295
+ pollIntervalMs = 1000,
296
+ maxPollAttempts = 120,
297
+ } = {}
298
+ ) {
299
+ const { packPath, flags } = parsePublishArgs(args);
300
+ const credentials = await loadCredentials({ env, homeDir });
301
+ const registry = resolveCredentialRegistry(
302
+ flags.registry,
303
+ credentials.registry
304
+ );
305
+ const authHeaders = authorizationHeaders(credentials.token);
306
+
307
+ // This request is deliberately first: it proves the stored dashboard token
308
+ // is a Hub Bearer credential before any upload or publish mutation starts.
309
+ const account = await getJson(registryApiUrl(registry, ['account']), {
310
+ fetchImpl,
311
+ headers: authHeaders,
312
+ });
313
+ const accountPublisher = extractPublisherHandle(account);
314
+ if (
315
+ flags.publisher &&
316
+ accountPublisher &&
317
+ flags.publisher !== accountPublisher
318
+ ) {
319
+ throw new Error(
320
+ `Token is scoped to publisher ${accountPublisher}, not ${flags.publisher}.`
321
+ );
322
+ }
323
+ const publisher = flags.publisher || accountPublisher;
324
+
325
+ const packFile = path.resolve(cwd, packPath);
326
+ const bytes = await readFile(packFile);
327
+ if (bytes.length > MAX_ARTIFACT_BYTES)
328
+ throw new Error('Artifact exceeds Hub’s 250 MB limit.');
329
+ const sha256 = createSha256(bytes);
330
+ const pathname = `sha256/${sha256}.knolo`;
331
+
332
+ const uploaded = await putImpl(pathname, bytes, {
333
+ access: 'public',
334
+ addRandomSuffix: false,
335
+ // The pathname is content-addressed by the already-computed SHA-256.
336
+ // Allowing overwrite makes a retry safe after a partial Hub publish.
337
+ allowOverwrite: true,
338
+ contentType: 'application/octet-stream',
339
+ cacheControlMaxAge: 31536000,
340
+ token: packsReadWriteToken(env),
341
+ });
342
+ if (!uploaded || typeof uploaded.url !== 'string' || !uploaded.url) {
343
+ throw new Error('Public Blob upload did not return a URL.');
344
+ }
345
+ if (uploaded.pathname !== undefined && uploaded.pathname !== pathname) {
346
+ throw new Error(
347
+ `Public Blob upload pathname is not locked to ${pathname}.`
348
+ );
349
+ }
350
+ const blobUrl = assertPublicBlobUrl(
351
+ uploaded.url,
352
+ 'uploaded artifact'
353
+ ).toString();
354
+
355
+ await postJson(
356
+ hubApiUrl(registry, ['upload', 'complete']),
357
+ {
358
+ sha256,
359
+ url: blobUrl,
360
+ sizeBytes: bytes.length,
361
+ pathname,
362
+ },
363
+ { fetchImpl, headers: authHeaders }
364
+ );
365
+
366
+ const verifyResponse = await postJson(
367
+ registryApiUrl(registry, ['publish', 'verify']),
368
+ { sha256 },
369
+ {
370
+ fetchImpl,
371
+ headers: authHeaders,
372
+ }
373
+ );
374
+ const jobId = extractId(verifyResponse, ['jobId', 'id', 'job']);
375
+ if (!jobId)
376
+ throw new Error('Hub verify response did not include a publish job id.');
377
+ const job = await waitForPublishJob(registry, jobId, {
378
+ fetchImpl,
379
+ headers: authHeaders,
380
+ sleep,
381
+ pollIntervalMs,
382
+ maxPollAttempts,
383
+ });
384
+
385
+ const draftPayload = {
386
+ sha256,
387
+ slug: flags.slug,
388
+ version: flags.version,
389
+ license: flags.license,
390
+ attested: true,
391
+ ...(flags.readme !== undefined ? { readme: flags.readme } : {}),
392
+ ...(flags.sources !== undefined ? { sources: flags.sources } : {}),
393
+ ...(flags.intendedUse !== undefined
394
+ ? { intendedUse: flags.intendedUse }
395
+ : {}),
396
+ };
397
+ const draftResponse = await postJson(
398
+ registryApiUrl(registry, ['publish', 'drafts']),
399
+ draftPayload,
400
+ {
401
+ fetchImpl,
402
+ headers: authHeaders,
403
+ }
404
+ );
405
+ const draftId = extractId(draftResponse, ['draftId', 'id', 'draft']);
406
+ if (!draftId)
407
+ throw new Error('Hub draft response did not include a draft id.');
408
+
409
+ const releaseResponse = await postJson(
410
+ registryApiUrl(registry, ['publish', 'drafts', draftId, 'release']),
411
+ undefined,
412
+ {
413
+ fetchImpl,
414
+ headers: authHeaders,
415
+ }
416
+ );
417
+ const name = `${publisher ? `${publisher}/` : ''}${flags.slug}`;
418
+ const result = {
419
+ registry,
420
+ ...(publisher ? { publisher } : {}),
421
+ name,
422
+ slug: flags.slug,
423
+ version: flags.version,
424
+ sha256,
425
+ sizeBytes: bytes.length,
426
+ pathname,
427
+ url: blobUrl,
428
+ jobId: String(jobId),
429
+ draftId: String(draftId),
430
+ verification: job,
431
+ release: releaseResponse,
432
+ };
433
+
434
+ if (flags.json) print(JSON.stringify(result, null, 2));
435
+ else {
436
+ print(`published ${name}@${flags.version}`);
437
+ print(`sha256 ${sha256}`);
438
+ print(`url ${blobUrl}`);
439
+ print(`job ${jobId}`);
440
+ }
441
+ return result;
442
+ }
443
+
444
+ export async function runHubYank(
445
+ args,
446
+ {
447
+ env = process.env,
448
+ fetchImpl = globalThis.fetch,
449
+ homeDir,
450
+ print = console.log,
451
+ } = {}
452
+ ) {
453
+ const { spec, flags } = parseYankArgs(args);
454
+ const credentials = await loadCredentials({ env, homeDir });
455
+ const registry = resolveCredentialRegistry(
456
+ flags.registry,
457
+ credentials.registry
458
+ );
459
+ const response = await postJson(
460
+ registryApiUrl(registry, [
461
+ 'packs',
462
+ spec.publisher,
463
+ spec.slug,
464
+ spec.version,
465
+ 'yank',
466
+ ]),
467
+ undefined,
468
+ {
469
+ fetchImpl,
470
+ headers: authorizationHeaders(credentials.token),
471
+ }
472
+ );
473
+ const result = {
474
+ registry,
475
+ name: spec.name,
476
+ version: spec.version,
477
+ response,
478
+ };
479
+ if (flags.json) print(JSON.stringify(result, null, 2));
480
+ else print(`yanked ${spec.name}@${spec.version}`);
481
+ return result;
482
+ }
483
+
484
+ export function parsePublishArgs(args) {
485
+ const positional = [];
486
+ const flags = {};
487
+ const valueFlags = new Map([
488
+ ['--slug', 'slug'],
489
+ ['--version', 'version'],
490
+ ['--license', 'license'],
491
+ ['--publisher', 'publisher'],
492
+ ['--readme', 'readme'],
493
+ ['--sources', 'sources'],
494
+ ['--intended-use', 'intendedUse'],
495
+ ['--registry', 'registry'],
496
+ ]);
497
+ for (let i = 0; i < args.length; i++) {
498
+ const arg = args[i];
499
+ if (arg === '--json') {
500
+ flags.json = true;
501
+ continue;
502
+ }
503
+ if (valueFlags.has(arg)) {
504
+ const value = args[++i];
505
+ if (value === undefined || value.startsWith('--'))
506
+ throw new Error(`Missing value for ${arg}`);
507
+ flags[valueFlags.get(arg)] = value;
508
+ continue;
509
+ }
510
+ if (arg.startsWith('--'))
511
+ throw new Error(`Unknown flag for publish: ${arg}`);
512
+ positional.push(arg);
513
+ }
514
+
515
+ if (
516
+ positional.length !== 1 ||
517
+ !flags.slug ||
518
+ !flags.version ||
519
+ !flags.license
520
+ ) {
521
+ throw new Error(
522
+ 'Usage: knolo publish <pack.knolo> --slug <slug> --version <version> --license <SPDX> [--publisher <handle>] [--readme <text>] [--sources <text>] [--intended-use <text>] [--json] [--registry <url>]'
523
+ );
524
+ }
525
+ if (!/^[a-z0-9-]+$/.test(flags.slug))
526
+ throw new Error(
527
+ 'Publish slug must contain lowercase letters, numbers, and hyphens.'
528
+ );
529
+ if (!flags.version.trim() || flags.version.includes('@'))
530
+ throw new Error('Publish version must be a non-empty version without @.');
531
+ if (!flags.license.trim()) throw new Error('Publish license is required.');
532
+ if (flags.publisher && !/^[a-z0-9-]+$/.test(flags.publisher))
533
+ throw new Error(
534
+ 'Publish publisher must contain lowercase letters, numbers, and hyphens.'
535
+ );
536
+ return { packPath: positional[0], flags };
537
+ }
538
+
539
+ export function parseYankArgs(args) {
540
+ const positional = [];
541
+ const flags = {};
542
+ for (let i = 0; i < args.length; i++) {
543
+ const arg = args[i];
544
+ if (arg === '--json') {
545
+ flags.json = true;
546
+ continue;
547
+ }
548
+ if (arg === '--registry') {
549
+ const value = args[++i];
550
+ if (value === undefined || value.startsWith('--'))
551
+ throw new Error('Missing value for --registry');
552
+ flags.registry = value;
553
+ continue;
554
+ }
555
+ if (arg.startsWith('--')) throw new Error(`Unknown flag for yank: ${arg}`);
556
+ positional.push(arg);
557
+ }
558
+ if (positional.length !== 1)
559
+ throw new Error(
560
+ 'Usage: knolo yank <publisher>/<slug>@<version> [--json] [--registry <url>]'
561
+ );
562
+ const spec = parsePackSpec(positional[0]);
563
+ if (spec.version === 'latest')
564
+ throw new Error(
565
+ 'Yank requires an exact version: <publisher>/<slug>@<version>.'
566
+ );
567
+ return { spec, flags };
568
+ }
569
+
570
+ function authorizationHeaders(token) {
571
+ return { Authorization: `Bearer ${token}` };
572
+ }
573
+
574
+ function resolveCredentialRegistry(requestedRegistry, storedRegistry) {
575
+ const stored = normalizeRegistryUrl(storedRegistry);
576
+ if (!requestedRegistry) return stored;
577
+ const requested = normalizeRegistryUrl(requestedRegistry);
578
+ if (requested !== stored) {
579
+ throw new Error(
580
+ `Stored credentials belong to ${stored}; refusing to send them to ${requested}. Log in to the requested registry first.`
581
+ );
582
+ }
583
+ return requested;
584
+ }
585
+
586
+ function hubApiUrl(registry, segments) {
587
+ const url = new URL(registry);
588
+ url.pathname = `/api/${segments.map((segment) => encodeURIComponent(String(segment))).join('/')}`;
589
+ return url;
590
+ }
591
+
592
+ function createSha256(bytes) {
593
+ return createHash('sha256').update(bytes).digest('hex');
594
+ }
595
+
596
+ function packsReadWriteToken(env) {
597
+ const token =
598
+ typeof env?.PACKS_READ_WRITE_TOKEN === 'string'
599
+ ? env.PACKS_READ_WRITE_TOKEN.trim()
600
+ : '';
601
+ if (!token)
602
+ throw new Error(
603
+ 'PACKS_READ_WRITE_TOKEN is required for public Blob uploads.'
604
+ );
605
+ return token;
606
+ }
607
+
608
+ function extractPublisherHandle(account) {
609
+ const candidates = [
610
+ account?.publisherHandle,
611
+ account?.publisher?.handle,
612
+ account?.publisher?.slug,
613
+ account?.handle,
614
+ account?.account?.publisherHandle,
615
+ account?.account?.publisher?.handle,
616
+ account?.user?.publisherHandle,
617
+ account?.publishers?.[0]?.handle,
618
+ account?.publishers?.[0]?.slug,
619
+ typeof account?.publisher === 'string' ? account.publisher : undefined,
620
+ ];
621
+ return candidates
622
+ .find((value) => typeof value === 'string' && value.trim())
623
+ ?.trim();
624
+ }
625
+
626
+ function assertPublicBlobUrl(value, label) {
627
+ if (typeof value !== 'string' || value.includes('.private.')) {
628
+ throw new Error(
629
+ `Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`
630
+ );
631
+ }
632
+ let url;
633
+ try {
634
+ url = new URL(value);
635
+ } catch {
636
+ throw new Error(`Hub ${label} URL is invalid.`);
637
+ }
638
+ const hostname = url.hostname.toLowerCase();
639
+ if (url.protocol !== 'https:')
640
+ throw new Error(`Hub ${label} URL must use HTTPS.`);
641
+ if (
642
+ hostname.includes('.private.') ||
643
+ hostname.startsWith('private.') ||
644
+ isPrivateOrLocalIp(hostname)
645
+ ) {
646
+ throw new Error(
647
+ `Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`
648
+ );
649
+ }
650
+ if (isLocalOnlyHostname(hostname)) {
651
+ throw new Error(
652
+ `Refusing local-only Blob URL for ${label}; Hub verification requires a public Blob URL.`
653
+ );
654
+ }
655
+ return url;
656
+ }
657
+
658
+ function isLocalOnlyHostname(hostname) {
659
+ const normalized = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
660
+ return (
661
+ LOCAL_ONLY_HOSTNAMES.has(normalized) ||
662
+ normalized.endsWith('.localhost') ||
663
+ normalized.endsWith('.local') ||
664
+ normalized.endsWith('.internal') ||
665
+ normalized.endsWith('.home.arpa')
666
+ );
667
+ }
668
+
669
+ function isPrivateOrLocalIp(hostname) {
670
+ const normalized = hostname.replace(/^\[|\]$/g, '');
671
+ const version = isIP(normalized);
672
+ if (version === 4) return isPrivateIpv4(normalized);
673
+ if (version !== 6) return false;
674
+
675
+ const words = parseIpv6Words(normalized);
676
+ if (!words) return false;
677
+ const first = words[0];
678
+ const allZero = words.every((word) => word === 0);
679
+ const loopback =
680
+ words.slice(0, 7).every((word) => word === 0) && words[7] === 1;
681
+ const uniqueLocal = (first & 0xfe00) === 0xfc00;
682
+ const linkLocal = (first & 0xffc0) === 0xfe80;
683
+ const mappedIpv4 =
684
+ words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
685
+ const mappedAddress = mappedIpv4
686
+ ? `${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`
687
+ : undefined;
688
+ return (
689
+ allZero ||
690
+ loopback ||
691
+ uniqueLocal ||
692
+ linkLocal ||
693
+ (mappedAddress ? isPrivateIpv4(mappedAddress) : false)
694
+ );
695
+ }
696
+
697
+ function isPrivateIpv4(value) {
698
+ const octets = value.split('.').map(Number);
699
+ if (
700
+ octets.length !== 4 ||
701
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
702
+ )
703
+ return false;
704
+ const [first, second] = octets;
705
+ return (
706
+ first === 0 ||
707
+ first === 10 ||
708
+ first === 127 ||
709
+ (first === 100 && second >= 64 && second <= 127) ||
710
+ (first === 169 && second === 254) ||
711
+ (first === 172 && second >= 16 && second <= 31) ||
712
+ (first === 192 && (second === 0 || second === 168)) ||
713
+ (first === 198 && (second === 18 || second === 19)) ||
714
+ first >= 224
715
+ );
716
+ }
717
+
718
+ function parseIpv6Words(value) {
719
+ const sections = value.toLowerCase().split('::');
720
+ if (sections.length > 2) return undefined;
721
+ const left = parseIpv6Section(sections[0]);
722
+ const right = sections.length === 2 ? parseIpv6Section(sections[1]) : [];
723
+ if (!left || !right) return undefined;
724
+ const zeroCount = sections.length === 2 ? 8 - left.length - right.length : 0;
725
+ if (
726
+ zeroCount < (sections.length === 2 ? 1 : 0) ||
727
+ left.length + right.length + zeroCount !== 8
728
+ )
729
+ return undefined;
730
+ return [...left, ...new Array(zeroCount).fill(0), ...right];
731
+ }
732
+
733
+ function parseIpv6Section(section) {
734
+ if (!section) return [];
735
+ const words = [];
736
+ for (const part of section.split(':')) {
737
+ if (part.includes('.')) {
738
+ const octets = part.split('.').map(Number);
739
+ if (
740
+ octets.length !== 4 ||
741
+ octets.some(
742
+ (octet) => !Number.isInteger(octet) || octet < 0 || octet > 255
743
+ )
744
+ )
745
+ return undefined;
746
+ words.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
747
+ } else {
748
+ if (!/^[0-9a-f]{1,4}$/.test(part)) return undefined;
749
+ words.push(Number.parseInt(part, 16));
750
+ }
751
+ }
752
+ return words;
753
+ }
754
+
755
+ function extractId(body, keys) {
756
+ for (const key of keys) {
757
+ const value = body?.[key];
758
+ if (typeof value === 'string' && value) return value;
759
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
760
+ if (value && typeof value === 'object') {
761
+ if (typeof value.id === 'string' && value.id) return value.id;
762
+ if (typeof value.id === 'number' && Number.isSafeInteger(value.id))
763
+ return value.id;
764
+ if (typeof value.jobId === 'string' && value.jobId) return value.jobId;
765
+ if (typeof value.draftId === 'string' && value.draftId)
766
+ return value.draftId;
767
+ }
768
+ }
769
+ return undefined;
770
+ }
771
+
772
+ async function waitForPublishJob(
773
+ registry,
774
+ jobId,
775
+ {
776
+ fetchImpl,
777
+ headers,
778
+ sleep: sleepImpl = wait,
779
+ pollIntervalMs = 1000,
780
+ maxPollAttempts = 120,
781
+ } = {}
782
+ ) {
783
+ if (!Number.isSafeInteger(maxPollAttempts) || maxPollAttempts < 1)
784
+ throw new Error('maxPollAttempts must be a positive integer.');
785
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0)
786
+ throw new Error('pollIntervalMs must be a non-negative number.');
787
+
788
+ for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
789
+ if (attempt > 0 && pollIntervalMs > 0) await sleepImpl(pollIntervalMs);
790
+ const body = await getJson(
791
+ registryApiUrl(registry, ['publish', 'jobs', jobId]),
792
+ { fetchImpl, headers }
793
+ );
794
+ const job = body?.job && typeof body.job === 'object' ? body.job : body;
795
+ const status = String(job?.status || job?.state || '').toLowerCase();
796
+ if (
797
+ [
798
+ 'passed',
799
+ 'pass',
800
+ 'complete',
801
+ 'completed',
802
+ 'succeeded',
803
+ 'success',
804
+ ].includes(status)
805
+ )
806
+ return job;
807
+ if (['failed', 'failure', 'error', 'rejected'].includes(status)) {
808
+ const detail = job?.error || job?.message || body?.error;
809
+ throw new Error(
810
+ `Hub publish verification failed${detail ? `: ${detail}` : '.'}`
811
+ );
812
+ }
813
+ }
814
+
815
+ throw new Error(
816
+ `Timed out waiting for Hub publish verification job ${jobId}.`
817
+ );
818
+ }
819
+
820
+ function wait(milliseconds) {
821
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
163
822
  }
164
823
 
165
824
  export function parseAddArgs(args) {
@@ -173,14 +832,18 @@ export function parseAddArgs(args) {
173
832
  }
174
833
  if (['--out', '--registry'].includes(arg)) {
175
834
  const value = args[++i];
176
- if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
835
+ if (value === undefined || value.startsWith('--'))
836
+ throw new Error(`Missing value for ${arg}`);
177
837
  flags[arg.slice(2)] = value;
178
838
  continue;
179
839
  }
180
840
  if (arg.startsWith('--')) throw new Error(`Unknown flag for add: ${arg}`);
181
841
  positional.push(arg);
182
842
  }
183
- if (positional.length !== 1) throw new Error('Usage: knolo add <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]');
843
+ if (positional.length !== 1)
844
+ throw new Error(
845
+ 'Usage: knolo add <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]'
846
+ );
184
847
  return { spec: parsePackSpec(positional[0]), flags };
185
848
  }
186
849
 
@@ -194,12 +857,15 @@ export function parseLoginArgs(args) {
194
857
  }
195
858
  if (arg === '--token' || arg === '--registry') {
196
859
  const value = args[++i];
197
- if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
860
+ if (value === undefined || value.startsWith('--'))
861
+ throw new Error(`Missing value for ${arg}`);
198
862
  flags[arg.slice(2)] = value;
199
863
  continue;
200
864
  }
201
865
  if (arg.startsWith('--')) throw new Error(`Unknown flag for login: ${arg}`);
202
- throw new Error('Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]');
866
+ throw new Error(
867
+ 'Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]'
868
+ );
203
869
  }
204
870
  return { flags };
205
871
  }
@@ -237,17 +903,25 @@ export function parseSearchArgs(args) {
237
903
  }
238
904
  if (['--format', '--license', '--registry'].includes(arg)) {
239
905
  const value = args[++i];
240
- if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
906
+ if (value === undefined || value.startsWith('--'))
907
+ throw new Error(`Missing value for ${arg}`);
241
908
  flags[arg.slice(2)] = value;
242
909
  continue;
243
910
  }
244
- if (arg.startsWith('--')) throw new Error(`Unknown flag for search: ${arg}`);
911
+ if (arg.startsWith('--'))
912
+ throw new Error(`Unknown flag for search: ${arg}`);
245
913
  positional.push(arg);
246
914
  }
247
915
 
248
916
  const query = positional.join(' ').trim();
249
- if (!query) throw new Error('Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]');
250
- if (flags.format && !['V4', 'V5'].includes(String(flags.format).toUpperCase())) {
917
+ if (!query)
918
+ throw new Error(
919
+ 'Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]'
920
+ );
921
+ if (
922
+ flags.format &&
923
+ !['V4', 'V5'].includes(String(flags.format).toUpperCase())
924
+ ) {
251
925
  throw new Error('--format must be V4 or V5.');
252
926
  }
253
927
  if (flags.format) flags.format = String(flags.format).toUpperCase();
@@ -265,14 +939,18 @@ export function parseInfoArgs(args) {
265
939
  }
266
940
  if (arg === '--registry') {
267
941
  const value = args[++i];
268
- if (value === undefined || value.startsWith('--')) throw new Error('Missing value for --registry');
942
+ if (value === undefined || value.startsWith('--'))
943
+ throw new Error('Missing value for --registry');
269
944
  flags.registry = value;
270
945
  continue;
271
946
  }
272
947
  if (arg.startsWith('--')) throw new Error(`Unknown flag for info: ${arg}`);
273
948
  positional.push(arg);
274
949
  }
275
- if (positional.length !== 1) throw new Error('Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]');
950
+ if (positional.length !== 1)
951
+ throw new Error(
952
+ 'Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]'
953
+ );
276
954
  return { positional, flags };
277
955
  }
278
956
 
@@ -283,7 +961,13 @@ function validatePacksResponse(body) {
283
961
  }
284
962
 
285
963
  function validatePackInfoResponse(body) {
286
- if (!body || typeof body !== 'object' || typeof body.name !== 'string' || !body.pack || typeof body.pack !== 'object') {
964
+ if (
965
+ !body ||
966
+ typeof body !== 'object' ||
967
+ typeof body.name !== 'string' ||
968
+ !body.pack ||
969
+ typeof body.pack !== 'object'
970
+ ) {
287
971
  throw new Error('Registry returned an invalid pack response.');
288
972
  }
289
973
  }
@@ -294,33 +978,100 @@ async function validateKnowledgeImage(core, bytes) {
294
978
  const verification = core.verifyKnowledgeImageV5(bytes);
295
979
  return { format: 'V5', stateRoot: verification.stateRoot };
296
980
  } catch (error) {
297
- throw new Error(`invalid V5 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
981
+ throw new Error(
982
+ `invalid V5 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`
983
+ );
298
984
  }
299
985
  }
300
986
 
301
- const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
987
+ const buffer = bytes.buffer.slice(
988
+ bytes.byteOffset,
989
+ bytes.byteOffset + bytes.byteLength
990
+ );
302
991
  if (core.isPackV4?.(buffer)) {
303
992
  try {
304
993
  await core.mountPack({ src: bytes });
305
994
  return { format: 'V4' };
306
995
  } catch (error) {
307
- throw new Error(`invalid V4 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`);
996
+ throw new Error(
997
+ `invalid V4 Knowledge Image: ${error instanceof Error ? error.message : String(error)}`
998
+ );
308
999
  }
309
1000
  }
310
1001
  throw new Error('not a Knowledge Image');
311
1002
  }
312
1003
 
313
- async function copyArtifact(sourcePath, outputPath, cwd) {
1004
+ async function stageArtifactCopy(sourcePath, outputPath, cwd) {
314
1005
  const targetPath = path.resolve(cwd, outputPath);
315
- const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
316
1006
  await mkdir(path.dirname(targetPath), { recursive: true });
1007
+ const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`;
317
1008
  try {
318
1009
  await copyFile(sourcePath, tempPath);
319
- await rename(tempPath, targetPath);
320
- } finally {
1010
+ await chmod(tempPath, 0o644);
1011
+ return { targetPath, tempPath };
1012
+ } catch (error) {
321
1013
  await unlinkIfPresent(tempPath);
1014
+ throw error;
1015
+ }
1016
+ }
1017
+
1018
+ async function commitStagedFiles(entries) {
1019
+ const targets = new Set();
1020
+ const records = entries.map((entry) => {
1021
+ if (targets.has(entry.targetPath))
1022
+ throw new Error(`Install paths must be unique: ${entry.targetPath}`);
1023
+ targets.add(entry.targetPath);
1024
+ return {
1025
+ ...entry,
1026
+ backupPath: `${entry.targetPath}.backup-${process.pid}-${randomUUID()}`,
1027
+ hadOriginal: false,
1028
+ committed: false,
1029
+ };
1030
+ });
1031
+
1032
+ try {
1033
+ for (const record of records) {
1034
+ const existing = await statIfPresent(record.targetPath);
1035
+ if (existing?.isDirectory())
1036
+ throw new Error(`Cannot replace directory at ${record.targetPath}.`);
1037
+ }
1038
+
1039
+ for (const record of records) {
1040
+ if (await pathExists(record.targetPath)) {
1041
+ record.hadOriginal = true;
1042
+ await rename(record.targetPath, record.backupPath);
1043
+ }
1044
+ await rename(record.tempPath, record.targetPath);
1045
+ record.committed = true;
1046
+ }
1047
+ } catch (error) {
1048
+ for (const record of [...records].reverse()) {
1049
+ if (record.committed) await unlinkIfPresent(record.targetPath);
1050
+ if (record.hadOriginal && (await pathExists(record.backupPath))) {
1051
+ await unlinkIfPresent(record.targetPath);
1052
+ await rename(record.backupPath, record.targetPath);
1053
+ }
1054
+ }
1055
+ throw error;
1056
+ }
1057
+
1058
+ for (const record of records) {
1059
+ if (record.hadOriginal)
1060
+ await unlinkIfPresent(record.backupPath).catch(() => {});
322
1061
  }
323
- return targetPath;
1062
+ }
1063
+
1064
+ async function statIfPresent(filePath) {
1065
+ try {
1066
+ return await lstat(filePath);
1067
+ } catch (error) {
1068
+ if (error?.code === 'ENOENT') return undefined;
1069
+ throw error;
1070
+ }
1071
+ }
1072
+
1073
+ async function pathExists(filePath) {
1074
+ return Boolean(await statIfPresent(filePath));
324
1075
  }
325
1076
 
326
1077
  async function unlinkIfPresent(filePath) {
@@ -334,24 +1085,36 @@ async function unlinkIfPresent(filePath) {
334
1085
  export function renderSearchTable(packs) {
335
1086
  if (!packs.length) return 'No packs found.';
336
1087
 
337
- const rows = packs.map((pack) => [
338
- pack.id || pack.name || `${pack.publisher || ''}/${pack.slug || ''}`,
339
- pack.version ?? '',
340
- pack.format ?? '',
341
- pack.license ?? '',
342
- pack.pulls ?? 0,
343
- pack.description ?? '',
344
- ].map((value) => String(value)));
1088
+ const rows = packs.map((pack) =>
1089
+ [
1090
+ pack.id || pack.name || `${pack.publisher || ''}/${pack.slug || ''}`,
1091
+ pack.version ?? '',
1092
+ pack.format ?? '',
1093
+ pack.license ?? '',
1094
+ pack.pulls ?? 0,
1095
+ pack.description ?? '',
1096
+ ].map((value) => String(value))
1097
+ );
345
1098
  const widths = [24, 9, 6, 11, 8];
346
- const header = ['NAME', 'VERSION', 'FORMAT', 'LICENSE', 'PULLS', 'DESCRIPTION'];
347
- const formatRow = (row) => [
348
- pad(truncate(row[0], widths[0]), widths[0]),
349
- pad(truncate(row[1], widths[1]), widths[1]),
350
- pad(truncate(row[2], widths[2]), widths[2]),
351
- pad(truncate(row[3], widths[3]), widths[3]),
352
- pad(truncate(row[4], widths[4]), widths[4]),
353
- truncate(row[5], 72),
354
- ].join(' ').trimEnd();
1099
+ const header = [
1100
+ 'NAME',
1101
+ 'VERSION',
1102
+ 'FORMAT',
1103
+ 'LICENSE',
1104
+ 'PULLS',
1105
+ 'DESCRIPTION',
1106
+ ];
1107
+ const formatRow = (row) =>
1108
+ [
1109
+ pad(truncate(row[0], widths[0]), widths[0]),
1110
+ pad(truncate(row[1], widths[1]), widths[1]),
1111
+ pad(truncate(row[2], widths[2]), widths[2]),
1112
+ pad(truncate(row[3], widths[3]), widths[3]),
1113
+ pad(truncate(row[4], widths[4]), widths[4]),
1114
+ truncate(row[5], 72),
1115
+ ]
1116
+ .join(' ')
1117
+ .trimEnd();
355
1118
 
356
1119
  return [formatRow(header), ...rows.map(formatRow)].join('\n');
357
1120
  }