@knolo/cli 5.1.0 → 5.2.1

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
@@ -25,6 +25,81 @@ Or use via npx:
25
25
  npx knolo build
26
26
  ```
27
27
 
28
+ ## Hub Registry Discovery (V5.2)
29
+
30
+ The CLI can search the public Knolo Hub and inspect a published pack without
31
+ changing the local pack workflow:
32
+
33
+ ```bash
34
+ knolo search "refund policy"
35
+ knolo info acme/refund-policy
36
+ ```
37
+
38
+ The production registry is `https://hub.knolo.dev`. Use `--registry` for a
39
+ one-off override, or set `KNOLO_HUB_URL` for the current environment:
40
+
41
+ ```bash
42
+ knolo search docs --registry https://hub.knolo.dev
43
+ KNOLO_HUB_URL=http://localhost:3000 knolo search docs
44
+ ```
45
+
46
+ An explicit `--registry` value takes precedence over `KNOLO_HUB_URL`. In
47
+ development mode (`NODE_ENV=development`), the default is
48
+ `http://localhost:3000`; otherwise the default is `https://hub.knolo.dev`.
49
+
50
+ Hub writes use a dashboard token as an HTTP Bearer credential. GitHub sign-in
51
+ is needed only to mint that token at
52
+ `https://hub.knolo.dev/dashboard/tokens`; `knolo login` is local-only and does
53
+ not call a Hub login or token-minting endpoint:
54
+
55
+ ```bash
56
+ knolo login --token kno_…
57
+ # write requests use: Authorization: Bearer kno_…
58
+ ```
59
+
60
+ `knolo publish` uploads bytes directly to the public Blob URL returned by Hub,
61
+ waits for verification, creates an attested draft, and releases it. The
62
+ `--slug`, `--version`, and SPDX `--license` are required:
63
+
64
+ ```bash
65
+ knolo publish ./dist/knowledge.knolo \
66
+ --slug refund-policy --version 1.2.0 --license Apache-2.0
67
+ knolo yank acme/refund-policy@1.2.0
68
+ ```
69
+
70
+ Only public Blob URLs are accepted because Hub verification fetches the
71
+ artifact itself. The CLI never sends the `kno_…` token to Blob, in a query
72
+ string, cookie, or pack bytes. A 401 usually means the `Bearer` word is
73
+ missing or the token was revoked; it does not mean the Hub write API requires
74
+ a GitHub browser session.
75
+
76
+ The older `@knolo/cli@5.2.0` publish/yank command was an unimplemented stub;
77
+ current versions use the live Hub HTTP sequence. For a direct smoke test of a
78
+ stored credential, request the account endpoint with the same header:
79
+
80
+ ```bash
81
+ curl -sS \
82
+ -H "Authorization: Bearer kno_…" \
83
+ -H "Accept: application/json" \
84
+ https://hub.knolo.dev/api/v1/account
85
+ ```
86
+
87
+ Do not use `POST /api/v1/tokens` as a CLI login endpoint; it is for the
88
+ dashboard/GitHub session that mints tokens.
89
+
90
+ Hub pack installation remains available without credentials and only happens
91
+ after its manifest, Blob bytes, digest, size, and local Knowledge Image
92
+ structure have all been verified:
93
+
94
+ ```bash
95
+ knolo add acme/refund-policy@1.2.0
96
+ ```
97
+
98
+ Successful installs are cached by SHA-256 and recorded in
99
+ `knolo.lock.json`. A yanked version or an existing conflicting pin requires
100
+ `--force`; digest and artifact validation cannot be bypassed.
101
+
102
+
28
103
  ---
29
104
 
30
105
  ## 🚀 Commands
package/bin/knolo.mjs CHANGED
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import process from 'node:process';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import { createRequire } from 'node:module';
23
+ import { isHubAddInvocation, runHubAdd, runHubInfo, runHubLogin, runHubLogout, runHubPublish, runHubSearch, runHubWhoami, runHubYank } from './registry/commands.mjs';
23
24
 
24
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26
  const require = createRequire(import.meta.url);
@@ -33,7 +34,7 @@ const DEFAULT_CONFIG = {
33
34
  };
34
35
  const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json']);
35
36
  const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.dfx', '.cargo-target']);
36
- const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
37
+ const SUBCOMMANDS = new Set(['init', 'add', 'build', 'query', 'inspect', 'migrate', 'verify', 'explain', 'diff', 'dev', 'v5', 'search', 'info', 'login', 'whoami', 'logout', 'publish', 'yank', 'semantic:index', 'semantic:inspect', 'semantic:validate']);
37
38
  const ICP_SUBCOMMANDS = new Set(['init', 'build-pack', 'upload', 'query', 'health', 'info', 'clear']);
38
39
  const ICP_TEMPLATE_CANDIDATES = [
39
40
  path.resolve(__dirname, '../templates/icp-knowledge-canister'),
@@ -79,7 +80,14 @@ Usage:
79
80
 
80
81
  Commands:
81
82
  init Initialize knolo.config.json and starter docs
82
- add <name> <path> Add or update a source entry in config
83
+ add <name> <path> Add or update a local source entry, or install a Hub pack
84
+ search <query> Search the Knolo Hub registry
85
+ info <publisher>/<slug> Show a Hub registry pack
86
+ login Store a Hub token locally
87
+ whoami Show the stored Hub token prefix
88
+ logout Remove the stored Hub token
89
+ publish Publish a pack with a Hub Bearer token
90
+ yank <pack>@<version> Yank a released pack with a Hub Bearer token
83
91
  build Build a .knolo pack from configured sources
84
92
  query <question> Query a built pack and print top hits
85
93
  inspect <pack> Inspect pack format, sections, and manifest
@@ -101,7 +109,14 @@ Run "knolo <command> --help" for command details.`);
101
109
  function printCommandHelp(command) {
102
110
  const help = {
103
111
  init: 'Usage: knolo init',
104
- add: 'Usage: knolo add <name> <path>',
112
+ add: 'Usage: knolo add <name> <path> | <publisher>/<slug>[@<version>] [--out <path>] [--force] [--json] [--registry <url>]',
113
+ search: 'Usage: knolo search <query> [--format V4|V5] [--license <id>] [--official] [--agents] [--json] [--registry <url>]',
114
+ info: 'Usage: knolo info <publisher>/<slug> [--json] [--registry <url>]',
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
+ whoami: 'Usage: knolo whoami',
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.',
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.',
105
120
  build: 'Usage: knolo build',
106
121
  query: 'Usage: knolo query <question> [--pack <path>] [--k <number>] [--receipt <file>] [--json]',
107
122
  inspect: 'Usage: knolo inspect <pack.knolo>',
@@ -272,6 +287,14 @@ async function cmdInit() {
272
287
  }
273
288
 
274
289
  async function cmdAdd(args) {
290
+ if (isHubAddInvocation(args)) {
291
+ const core = await loadCore();
292
+ return await runHubAdd(args, { core });
293
+ }
294
+ return await cmdAddLocal(args);
295
+ }
296
+
297
+ async function cmdAddLocal(args) {
275
298
  const [name, sourcePath] = args;
276
299
  if (!name || !sourcePath) throw createError('Usage: knolo add <name> <path>');
277
300
 
@@ -1169,6 +1192,13 @@ async function main() {
1169
1192
  if (commandArgs.includes('--help') || commandArgs.includes('-h')) return printCommandHelp(command);
1170
1193
  if (command === 'init') return await cmdInit();
1171
1194
  if (command === 'add') return await cmdAdd(commandArgs);
1195
+ if (command === 'search') return await runHubSearch(commandArgs);
1196
+ if (command === 'info') return await runHubInfo(commandArgs);
1197
+ if (command === 'login') return await runHubLogin(commandArgs);
1198
+ if (command === 'whoami') return await runHubWhoami(commandArgs);
1199
+ if (command === 'logout') return await runHubLogout(commandArgs);
1200
+ if (command === 'publish') return runHubPublish(commandArgs);
1201
+ if (command === 'yank') return runHubYank(commandArgs);
1172
1202
 
1173
1203
  const core = await loadCore();
1174
1204
  if (command === 'build') return await cmdBuild(core);
@@ -0,0 +1,159 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { chmod, copyFile, mkdir, readFile, rename, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+
7
+ export const MAX_ARTIFACT_BYTES = 250 * 1024 * 1024;
8
+
9
+ export async function downloadBlobToTemp(url, {
10
+ tempDir,
11
+ expectedSize = 0,
12
+ fetchImpl = globalThis.fetch,
13
+ } = {}) {
14
+ const blobUrl = new URL(url);
15
+ if (blobUrl.protocol !== 'https:') throw new Error('Blob URL must use HTTPS.');
16
+ if (typeof fetchImpl !== 'function') throw new Error('This Node runtime does not provide fetch.');
17
+ if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw new Error('Manifest sizeBytes is invalid.');
18
+ if (expectedSize > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
19
+
20
+ let response;
21
+ try {
22
+ response = await fetchImpl(blobUrl, { method: 'GET', redirect: 'follow' });
23
+ } catch (error) {
24
+ throw new Error(`Could not download artifact from ${blobUrl.origin}.`, { cause: error });
25
+ }
26
+
27
+ if (!response.ok) {
28
+ const detail = await safeResponseText(response);
29
+ throw new Error(`Artifact download failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`);
30
+ }
31
+ if (response.url) {
32
+ const finalUrl = new URL(response.url);
33
+ if (finalUrl.protocol !== 'https:') throw new Error('Blob redirect must remain HTTPS.');
34
+ }
35
+
36
+ const contentLength = readContentLength(response);
37
+ if (contentLength !== undefined && contentLength > MAX_ARTIFACT_BYTES) {
38
+ throw new Error('Artifact exceeds Hub’s 250 MB limit.');
39
+ }
40
+ if (expectedSize > 0 && contentLength !== undefined && contentLength !== expectedSize) {
41
+ throw new Error(`Artifact Content-Length ${contentLength} does not match manifest sizeBytes ${expectedSize}.`);
42
+ }
43
+
44
+ await mkdir(tempDir, { recursive: true });
45
+ const tempPath = path.join(tempDir, `.download-${randomUUID()}.tmp`);
46
+ const output = createWriteStream(tempPath, { flags: 'wx', mode: 0o600 });
47
+ const hash = createHash('sha256');
48
+ let sizeBytes = 0;
49
+
50
+ try {
51
+ if (response.body && typeof response.body.getReader === 'function') {
52
+ for await (const chunk of Readable.fromWeb(response.body)) {
53
+ const bytes = Buffer.from(chunk);
54
+ sizeBytes += bytes.length;
55
+ if (sizeBytes > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
56
+ hash.update(bytes);
57
+ await writeChunk(output, bytes);
58
+ }
59
+ } else if (typeof response.arrayBuffer === 'function') {
60
+ const bytes = Buffer.from(await response.arrayBuffer());
61
+ sizeBytes = bytes.length;
62
+ if (sizeBytes > MAX_ARTIFACT_BYTES) throw new Error('Artifact exceeds Hub’s 250 MB limit.');
63
+ hash.update(bytes);
64
+ await writeChunk(output, bytes);
65
+ } else {
66
+ throw new Error('Artifact response did not provide a readable body.');
67
+ }
68
+ await endStream(output);
69
+ } catch (error) {
70
+ output.destroy();
71
+ await unlink(tempPath).catch(() => {});
72
+ throw error;
73
+ }
74
+
75
+ if (expectedSize > 0 && sizeBytes !== expectedSize) {
76
+ await unlink(tempPath).catch(() => {});
77
+ throw new Error(`Artifact body length ${sizeBytes} does not match manifest sizeBytes ${expectedSize}.`);
78
+ }
79
+
80
+ await chmod(tempPath, 0o600);
81
+ return { tempPath, sizeBytes, sha256: hash.digest('hex'), contentLength };
82
+ }
83
+
84
+ export async function readArtifact(tempPath) {
85
+ return new Uint8Array(await readFile(tempPath));
86
+ }
87
+
88
+ export async function installCachedArtifact(tempPath, cachePath) {
89
+ const stagedPath = await stageCachedArtifact(tempPath, cachePath);
90
+ try {
91
+ await rename(stagedPath, cachePath);
92
+ await chmod(cachePath, 0o644);
93
+ return cachePath;
94
+ } finally {
95
+ await unlink(stagedPath).catch(() => {});
96
+ }
97
+ }
98
+
99
+ export async function stageCachedArtifact(sourcePath, cachePath) {
100
+ await mkdir(path.dirname(cachePath), { recursive: true });
101
+ const stagedPath = `${cachePath}.tmp-${process.pid}-${randomUUID()}`;
102
+ try {
103
+ await copyFile(sourcePath, stagedPath);
104
+ await chmod(stagedPath, 0o644);
105
+ return stagedPath;
106
+ } catch (error) {
107
+ await unlink(stagedPath).catch(() => {});
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ async function writeChunk(stream, chunk) {
113
+ if (stream.write(chunk)) return;
114
+ await new Promise((resolve, reject) => {
115
+ const onDrain = () => {
116
+ stream.off('error', onError);
117
+ resolve();
118
+ };
119
+ const onError = (error) => {
120
+ stream.off('drain', onDrain);
121
+ reject(error);
122
+ };
123
+ stream.once('drain', onDrain);
124
+ stream.once('error', onError);
125
+ });
126
+ }
127
+
128
+ async function endStream(stream) {
129
+ await new Promise((resolve, reject) => {
130
+ const onClose = () => {
131
+ stream.off('error', onError);
132
+ resolve();
133
+ };
134
+ const onError = (error) => {
135
+ stream.off('close', onClose);
136
+ reject(error);
137
+ };
138
+ stream.once('close', onClose);
139
+ stream.once('error', onError);
140
+ stream.end();
141
+ });
142
+ }
143
+
144
+ function readContentLength(response) {
145
+ const raw = response.headers?.get?.('content-length') ?? response.headers?.['content-length'] ?? response.headers?.['Content-Length'];
146
+ if (raw === undefined || raw === null || raw === '') return undefined;
147
+ const value = Number(raw);
148
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error('Artifact Content-Length is invalid.');
149
+ return value;
150
+ }
151
+
152
+ async function safeResponseText(response) {
153
+ try {
154
+ const text = await response.text();
155
+ return String(text || '').replace(/\s+/g, ' ').slice(0, 240);
156
+ } catch {
157
+ return '';
158
+ }
159
+ }