@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 +75 -0
- package/bin/knolo.mjs +33 -3
- package/bin/registry/artifacts.mjs +159 -0
- package/bin/registry/commands.mjs +776 -0
- package/bin/registry/config.mjs +46 -0
- package/bin/registry/credentials.mjs +93 -0
- package/bin/registry/errors.mjs +30 -0
- package/bin/registry/http.mjs +94 -0
- package/bin/registry/lockfile.mjs +89 -0
- package/bin/registry/manifest.mjs +47 -0
- package/bin/registry/specs.mjs +20 -0
- package/package.json +3 -2
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
|
|
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
|
+
}
|