@knolo/cli 5.2.2 → 5.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,28 +57,23 @@ knolo login --token kno_…
57
57
  # write requests use: Authorization: Bearer kno_…
58
58
  ```
59
59
 
60
- `knolo publish` uploads bytes with `@vercel/blob` to the public store, waits
61
- for verification, creates an attested draft, and releases it. Set
62
- `PACKS_READ_WRITE_TOKEN` from the production `knolo-hub` Vercel environment
63
- before publishing; do not substitute `BLOB_READ_WRITE_TOKEN`:
60
+ `knolo publish` asks Hub for a Blob PUT grant with the dashboard token, PUTs
61
+ bytes to that grant URL, then completes with the public pack URL from the PUT
62
+ response. A `kno_…` token is the only credential. Do not set
63
+ `PACKS_READ_WRITE_TOKEN` — that is Hub’s store secret, not a publisher token.
64
64
 
65
65
  ```bash
66
- cd /path/to/knolo-hub
67
- npx vercel env pull /tmp/knolo-env-prod --environment=production --yes
68
- export PACKS_READ_WRITE_TOKEN="$(awk -F= '$1 == "PACKS_READ_WRITE_TOKEN" {sub(/^[^=]*=/, ""); gsub(/^\"|\"$/, ""); print; exit}' /tmp/knolo-env-prod)"
69
-
66
+ knolo login --token kno_…
70
67
  knolo publish ./dist/knowledge.knolo \
71
68
  --slug refund-policy --version 1.2.0 --license Apache-2.0
72
69
  knolo yank acme/refund-policy@1.2.0
73
70
  ```
74
71
 
75
- The upload uses pathname `sha256/<64-lowercase-hex>.knolo`, public access,
76
- `addRandomSuffix: false`, `application/octet-stream`, and a one-year cache.
77
- Only public Blob URLs are accepted because Hub verification fetches the
78
- artifact itself. The CLI never sends the `kno_…` token to Blob, in a query
79
- string, cookie, or pack bytes. A 401 usually means the `Bearer` word is
80
- missing or the token was revoked; it does not mean the Hub write API requires
81
- a GitHub browser session.
72
+ The grant pathname is `sha256/<64-lowercase-hex>.knolo`. Only public Blob URLs
73
+ (`*.public.blob.vercel-storage.com`) are sent to `/api/upload/complete`. The
74
+ CLI never sends the `kno_…` token to Blob, in a query string, cookie, or pack
75
+ bytes. A 401 usually means the `Bearer` word is missing or the token was
76
+ revoked; it does not mean the Hub write API requires a GitHub browser session.
82
77
 
83
78
  The older `@knolo/cli@5.2.0` publish/yank command was an unimplemented stub;
84
79
  current versions use the live Hub HTTP sequence. For a direct smoke test of a
package/bin/knolo.mjs CHANGED
@@ -115,7 +115,7 @@ function printCommandHelp(command) {
115
115
  login: 'Usage: knolo login [--token kno_…] [--stdin] [--registry <url>]\n\nStores the raw token locally. Hub write calls send Authorization: Bearer kno_….\nMint tokens at https://hub.knolo.dev/dashboard/tokens; login does not call POST /api/v1/tokens.',
116
116
  whoami: 'Usage: knolo whoami',
117
117
  logout: 'Usage: knolo logout',
118
- publish: 'Usage: knolo publish <pack.knolo> --slug <slug> --version <version> --license <SPDX> [--publisher <handle>] [--readme <text>] [--sources <text>] [--intended-use <text>] [--json] [--registry <url>]\n\nUses the stored token as Authorization: Bearer kno_…. Uploads go directly to a public Blob URL; Hub verifies before release.\nOlder @knolo/cli 5.2.0 builds were unimplemented; Hub has always accepted Bearer CLI tokens.',
118
+ publish: 'Usage: knolo publish <pack.knolo> --slug <slug> --version <version> --license <SPDX> [--publisher <handle>] [--readme <text>] [--sources <text>] [--intended-use <text>] [--json] [--registry <url>]\n\nUses the stored dashboard token as Authorization: Bearer kno_…. Hub issues a public Blob PUT grant; the CLI never needs PACKS_READ_WRITE_TOKEN.\nUploads go to the grant URL without the kno_ token. Complete sends the public Blob URL, then Hub verifies before release.',
119
119
  yank: 'Usage: knolo yank <publisher>/<slug>@<version> [--json] [--registry <url>]\n\nUses the stored token as Authorization: Bearer kno_…. Yank is owner-only and leaves the public Blob in place.',
120
120
  build: 'Usage: knolo build',
121
121
  query: 'Usage: knolo query <question> [--pack <path>] [--k <number>] [--receipt <file>] [--json]',
@@ -11,7 +11,6 @@ import { createHash, randomUUID } from 'node:crypto';
11
11
  import { isIP } from 'node:net';
12
12
  import { homedir } from 'node:os';
13
13
  import path from 'node:path';
14
- import { put as putBlob } from '@vercel/blob';
15
14
  import {
16
15
  normalizeRegistryUrl,
17
16
  registryApiUrl,
@@ -24,7 +23,7 @@ import {
24
23
  stageCachedArtifact,
25
24
  } from './artifacts.mjs';
26
25
  import { RegistryError } from './errors.mjs';
27
- import { getJson, postJson } from './http.mjs';
26
+ import { getJson, postJson, putBytes } from './http.mjs';
28
27
  import {
29
28
  cachePath,
30
29
  stageLockfileUpdate,
@@ -291,7 +290,6 @@ export async function runHubPublish(
291
290
  homeDir,
292
291
  print = console.log,
293
292
  sleep = wait,
294
- putImpl = putBlob,
295
293
  pollIntervalMs = 1000,
296
294
  maxPollAttempts = 120,
297
295
  } = {}
@@ -329,28 +327,35 @@ export async function runHubPublish(
329
327
  const sha256 = createSha256(bytes);
330
328
  const pathname = `sha256/${sha256}.knolo`;
331
329
 
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
- );
330
+ const grant = await postJson(
331
+ hubApiUrl(registry, ['upload', 'token']),
332
+ { sha256, contentLength: bytes.length },
333
+ { fetchImpl, headers: authHeaders }
334
+ );
335
+ const upload = resolveUploadGrant(grant, sha256);
336
+ let blobUrl;
337
+ if (upload.reused) {
338
+ blobUrl = assertPublicPackBlobUrl(upload.url, 'reused artifact').toString();
339
+ } else {
340
+ assertGrantPutUrl(upload.putUrl, 'upload grant');
341
+ const uploaded = await putBytes(upload.putUrl, bytes, {
342
+ fetchImpl,
343
+ headers: {
344
+ 'content-type': 'application/octet-stream',
345
+ ...(upload.putHeaders || {}),
346
+ 'content-length': String(bytes.length),
347
+ },
348
+ });
349
+ if (uploaded.pathname !== undefined && uploaded.pathname !== pathname) {
350
+ throw new Error(
351
+ `Public Blob upload pathname is not locked to ${pathname}.`
352
+ );
353
+ }
354
+ blobUrl = assertPublicPackBlobUrl(
355
+ uploaded.url,
356
+ 'uploaded artifact'
357
+ ).toString();
349
358
  }
350
- const blobUrl = assertPublicBlobUrl(
351
- uploaded.url,
352
- 'uploaded artifact'
353
- ).toString();
354
359
 
355
360
  await postJson(
356
361
  hubApiUrl(registry, ['upload', 'complete']),
@@ -593,16 +598,59 @@ function createSha256(bytes) {
593
598
  return createHash('sha256').update(bytes).digest('hex');
594
599
  }
595
600
 
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)
601
+ function resolveUploadGrant(body, sha256) {
602
+ const pathname = body?.pathname || body?.upload?.pathname;
603
+ const expectedPathname = `sha256/${sha256}.knolo`;
604
+ if (typeof pathname !== 'string' || pathname !== expectedPathname) {
605
+ throw new Error(`Hub upload pathname is not locked to ${expectedPathname}.`);
606
+ }
607
+
608
+ const reusedUrl =
609
+ (body?.reused && (body?.artifact?.url || body?.url)) ||
610
+ body?.artifact?.url;
611
+ if (typeof reusedUrl === 'string' && reusedUrl) {
612
+ return { reused: true, pathname, url: reusedUrl };
613
+ }
614
+
615
+ const upload = body?.upload && typeof body.upload === 'object' ? body.upload : body;
616
+ if (!upload || typeof upload.url !== 'string' || !upload.url) {
617
+ if (body?.clientToken || upload?.clientToken) {
618
+ throw new Error(
619
+ 'Hub returned a Blob client token without upload.url. This CLI PUTs to upload.url; it does not use PACKS_READ_WRITE_TOKEN.'
620
+ );
621
+ }
622
+ throw new Error('Hub upload token response is missing upload.url.');
623
+ }
624
+ return {
625
+ reused: false,
626
+ pathname,
627
+ putUrl: upload.url,
628
+ putHeaders:
629
+ upload.headers && typeof upload.headers === 'object' ? upload.headers : {},
630
+ };
631
+ }
632
+
633
+ function assertGrantPutUrl(value, label) {
634
+ let url;
635
+ try {
636
+ url = new URL(value);
637
+ } catch {
638
+ throw new Error(`Hub ${label} URL is invalid.`);
639
+ }
640
+ if (url.protocol !== 'https:')
641
+ throw new Error(`Hub ${label} URL must use HTTPS.`);
642
+ const hostname = url.hostname.toLowerCase();
643
+ if (
644
+ hostname.includes('.private.') ||
645
+ hostname.startsWith('private.') ||
646
+ isPrivateOrLocalIp(hostname) ||
647
+ isLocalOnlyHostname(hostname)
648
+ ) {
602
649
  throw new Error(
603
- 'PACKS_READ_WRITE_TOKEN is required for public Blob uploads.'
650
+ `Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`
604
651
  );
605
- return token;
652
+ }
653
+ return url;
606
654
  }
607
655
 
608
656
  function extractPublisherHandle(account) {
@@ -611,8 +659,10 @@ function extractPublisherHandle(account) {
611
659
  account?.publisher?.handle,
612
660
  account?.publisher?.slug,
613
661
  account?.handle,
662
+ account?.account?.handle,
614
663
  account?.account?.publisherHandle,
615
664
  account?.account?.publisher?.handle,
665
+ account?.user?.handle,
616
666
  account?.user?.publisherHandle,
617
667
  account?.publishers?.[0]?.handle,
618
668
  account?.publishers?.[0]?.slug,
@@ -655,6 +705,20 @@ function assertPublicBlobUrl(value, label) {
655
705
  return url;
656
706
  }
657
707
 
708
+ function assertPublicPackBlobUrl(value, label) {
709
+ const url = assertPublicBlobUrl(value, label);
710
+ const hostname = url.hostname.toLowerCase();
711
+ const blobHost =
712
+ hostname === 'blob.vercel-storage.com' ||
713
+ hostname.endsWith('.blob.vercel-storage.com');
714
+ if (!blobHost || hostname.includes('.private.')) {
715
+ throw new Error(
716
+ `Hub ${label} URL must be a public Vercel Blob pack URL (not a signed grant URL).`
717
+ );
718
+ }
719
+ return url;
720
+ }
721
+
658
722
  function isLocalOnlyHostname(hostname) {
659
723
  const normalized = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
660
724
  return (
@@ -19,6 +19,67 @@ export async function postJson(
19
19
  return requestJson(url, { fetchImpl, headers, method: 'POST', body });
20
20
  }
21
21
 
22
+ export async function putBytes(
23
+ url,
24
+ bytes,
25
+ { fetchImpl = globalThis.fetch, headers = {} } = {}
26
+ ) {
27
+ if (typeof fetchImpl !== 'function')
28
+ throw new Error('This Node runtime does not provide fetch.');
29
+
30
+ let response;
31
+ try {
32
+ response = await fetchImpl(url, {
33
+ method: 'PUT',
34
+ redirect: 'follow',
35
+ headers: { 'content-type': 'application/octet-stream', ...headers },
36
+ body: bytes,
37
+ });
38
+ } catch (error) {
39
+ throw new RegistryNetworkError(
40
+ `Could not upload artifact to ${new URL(url).origin}.`,
41
+ { url: String(url), cause: error }
42
+ );
43
+ }
44
+
45
+ const text = await readResponseText(response, url);
46
+ let parsed;
47
+ if (text) {
48
+ try {
49
+ parsed = JSON.parse(text);
50
+ } catch {
51
+ parsed = undefined;
52
+ }
53
+ }
54
+
55
+ if (!response.ok) {
56
+ const detail =
57
+ (parsed && typeof parsed.error === 'object' && parsed.error?.message) ||
58
+ (typeof parsed?.error === 'string' && parsed.error) ||
59
+ (typeof parsed?.message === 'string' && parsed.message) ||
60
+ (text ? text.slice(0, 240) : '');
61
+ throw new RegistryError(
62
+ `Blob upload failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`,
63
+ {
64
+ status: response.status,
65
+ url: String(url),
66
+ body: parsed ?? text,
67
+ }
68
+ );
69
+ }
70
+
71
+ const publicUrl =
72
+ (parsed && typeof parsed.url === 'string' && parsed.url) ||
73
+ response.url ||
74
+ String(url);
75
+ return {
76
+ status: response.status,
77
+ url: publicUrl,
78
+ pathname: parsed && typeof parsed.pathname === 'string' ? parsed.pathname : undefined,
79
+ body: parsed,
80
+ };
81
+ }
82
+
22
83
  async function requestJson(
23
84
  url,
24
85
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knolo/cli",
3
- "version": "5.2.2",
3
+ "version": "5.2.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "knolo": "bin/knolo.mjs"
@@ -32,8 +32,7 @@
32
32
  "test:hub": "node --test test/hub.integration.test.mjs"
33
33
  },
34
34
  "dependencies": {
35
- "@knolo/core": "^5.1.0",
36
- "@vercel/blob": "2.8.0"
35
+ "@knolo/core": "^5.1.0"
37
36
  },
38
37
  "private": false
39
38
  }