@northtek/overstory 0.1.0 → 0.1.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 +9 -1
- package/dist/cli/index.js +27 -1
- package/dist/registry/github.d.ts +7 -0
- package/dist/registry/github.js +49 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
# OVERSTORY
|
|
2
2
|
|
|
3
|
+
[](https://github.com/NORTHTEKDevs/overstory/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@northtek/overstory)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://overstory-virid.vercel.app/gh/NORTHTEKDevs/overstory)
|
|
7
|
+
|
|
3
8
|
**A knowledge tree of your codebase where every claim carries a receipt.**
|
|
4
9
|
|
|
10
|
+
> The last badge is this repository's own claims, re-verified against this repository's
|
|
11
|
+
> current code every time you load it. If it drops below 100%, the docs drifted.
|
|
12
|
+
|
|
5
13
|
> **30 seconds, no jargon:** AI tools sound exactly as confident when they're wrong as when
|
|
6
14
|
> they're right. OVERSTORY reads your codebase, writes it up as short statements, and then
|
|
7
15
|
> **checks every statement against the actual code** — click any one to see the exact lines
|
|
@@ -80,7 +88,7 @@ There's an experimental zero-storage registry that can show verified trees of pu
|
|
|
80
88
|
repos and re-check published trees against the live code (the registry stores nothing —
|
|
81
89
|
your repo is the database). It's in quiet preview and not the point of v1: the product is
|
|
82
90
|
the local tool. `overstory publish` targets it if you want to try it; details in
|
|
83
|
-
docs/
|
|
91
|
+
[docs/registry-design.md](docs/registry-design.md).
|
|
84
92
|
|
|
85
93
|
## MCP: notarize your agent's answers
|
|
86
94
|
|
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { buildTree, verifyExisting } from '../build/builder.js';
|
|
6
7
|
import { loadCorpus } from '../core/corpus.js';
|
|
7
8
|
import { loadTree, treePath } from '../core/store.js';
|
|
@@ -34,7 +35,26 @@ Options:
|
|
|
34
35
|
--out <file> Output path for site
|
|
35
36
|
--port <n> Port for serve (default 7433)
|
|
36
37
|
--json Machine-readable output
|
|
38
|
+
--version, -v Print the installed version
|
|
39
|
+
--help, -h Show this help
|
|
37
40
|
`;
|
|
41
|
+
/** Read the version from the shipped package.json. Resolved relative to this module so it
|
|
42
|
+
* is correct whether running from dist/ or from source. */
|
|
43
|
+
const packageVersion = () => {
|
|
44
|
+
for (const up of ['../../package.json', '../../../package.json']) {
|
|
45
|
+
try {
|
|
46
|
+
const path = fileURLToPath(new URL(up, import.meta.url));
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
+
if (parsed !== null && typeof parsed === 'object' && 'version' in parsed) {
|
|
49
|
+
return String(parsed.version);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// try the next candidate
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return 'unknown';
|
|
57
|
+
};
|
|
38
58
|
/** A mis-typed numeric flag must error, never silently disable the cap it configures. */
|
|
39
59
|
const numFlag = (raw, flag) => {
|
|
40
60
|
const n = Number(raw);
|
|
@@ -66,6 +86,8 @@ const parseArgs = (argv) => {
|
|
|
66
86
|
flags.port = numFlag(argv[++i], '--port');
|
|
67
87
|
else if (a === '--help' || a === '-h')
|
|
68
88
|
positional.push('help');
|
|
89
|
+
else if (a === '--version' || a === '-v')
|
|
90
|
+
positional.unshift('version');
|
|
69
91
|
else
|
|
70
92
|
positional.push(a);
|
|
71
93
|
}
|
|
@@ -80,6 +102,10 @@ const main = async () => {
|
|
|
80
102
|
const { cmd, positional, flags } = parseArgs(process.argv.slice(2));
|
|
81
103
|
const rootArg = positional[cmd === 'ask' ? 1 : 0];
|
|
82
104
|
const root = resolve(rootArg ?? '.');
|
|
105
|
+
if (cmd === 'version') {
|
|
106
|
+
process.stdout.write(`${packageVersion()}\n`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
83
109
|
if (cmd === 'help') {
|
|
84
110
|
process.stdout.write(HELP);
|
|
85
111
|
return 0;
|
|
@@ -8,6 +8,10 @@ export interface GithubSnapshot {
|
|
|
8
8
|
export interface FetchOptions {
|
|
9
9
|
ref?: string;
|
|
10
10
|
maxTarballBytes?: number;
|
|
11
|
+
/** Ceiling on the DECOMPRESSED tarball. A compressed-size cap alone does not bound
|
|
12
|
+
* memory: gzip ratios above 1000:1 are trivial to construct, so a repo well under the
|
|
13
|
+
* compressed cap can still expand to gigabytes. */
|
|
14
|
+
maxUncompressedBytes?: number;
|
|
11
15
|
maxFiles?: number;
|
|
12
16
|
maxFileBytes?: number;
|
|
13
17
|
fetchImpl?: typeof fetch;
|
|
@@ -20,6 +24,9 @@ interface TarEntry {
|
|
|
20
24
|
/** Minimal ustar reader — enough for GitHub codeload tarballs. Handles pax extended
|
|
21
25
|
* headers ('x' path overrides) and skips the pax_global_header ('g'). */
|
|
22
26
|
export declare const readTar: (tar: Buffer) => TarEntry[];
|
|
27
|
+
/** Decompress with a hard output ceiling so a gzip bomb fails fast and loudly instead of
|
|
28
|
+
* exhausting the function's memory. */
|
|
29
|
+
export declare const gunzipUnderCap: (tarball: Buffer, maxOutputLength: number) => Buffer;
|
|
23
30
|
/** Turn a GitHub codeload tarball into an in-memory corpus. The tarball's root directory
|
|
24
31
|
* is `{repo}-{sha}/` — the sha comes free, no API call needed. */
|
|
25
32
|
export declare const snapshotFromTarball: (tarball: Buffer, ref: string, opts?: FetchOptions) => GithubSnapshot;
|
package/dist/registry/github.js
CHANGED
|
@@ -48,12 +48,58 @@ export const readTar = (tar) => {
|
|
|
48
48
|
return entries;
|
|
49
49
|
};
|
|
50
50
|
const looksBinary = (buf) => buf.subarray(0, 8000).includes(0);
|
|
51
|
+
/** Decompress with a hard output ceiling so a gzip bomb fails fast and loudly instead of
|
|
52
|
+
* exhausting the function's memory. */
|
|
53
|
+
export const gunzipUnderCap = (tarball, maxOutputLength) => {
|
|
54
|
+
try {
|
|
55
|
+
return gunzipSync(tarball, { maxOutputLength });
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const code = err?.code;
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
if (code === 'ERR_BUFFER_TOO_LARGE' || /larger than|maxOutputLength/iu.test(message)) {
|
|
61
|
+
throw new Error(`repo expands past the registry's ${Math.round(maxOutputLength / 1e6)}MB uncompressed cap`);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/** Read a response body against a running byte ceiling, cancelling the transfer the moment
|
|
67
|
+
* it is exceeded. Buffering the whole body and checking `.length` afterwards would let a
|
|
68
|
+
* hostile repo exhaust memory before the check ever ran. */
|
|
69
|
+
const readUnderCap = async (res, cap) => {
|
|
70
|
+
if (!res.body) {
|
|
71
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
72
|
+
if (buf.length > cap)
|
|
73
|
+
throw new Error(tarballTooLarge(cap));
|
|
74
|
+
return buf;
|
|
75
|
+
}
|
|
76
|
+
const reader = res.body.getReader();
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done)
|
|
82
|
+
break;
|
|
83
|
+
total += value.byteLength;
|
|
84
|
+
if (total > cap) {
|
|
85
|
+
await reader.cancel().catch(() => undefined);
|
|
86
|
+
throw new Error(tarballTooLarge(cap));
|
|
87
|
+
}
|
|
88
|
+
chunks.push(Buffer.from(value));
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks);
|
|
91
|
+
};
|
|
92
|
+
const tarballTooLarge = (cap) => `repo too large for the registry (tarball exceeds the ${Math.round(cap / 1e6)}MB cap)`;
|
|
93
|
+
/** GitHub owners are <=39 chars and repos <=100; neither may contain a path segment or a
|
|
94
|
+
* traversal sequence. Validated before the URL is built, not after. */
|
|
95
|
+
const validSegment = (segment) => /^[\w.-]{1,100}$/u.test(segment) && !segment.includes('..') && segment !== '.';
|
|
51
96
|
/** Turn a GitHub codeload tarball into an in-memory corpus. The tarball's root directory
|
|
52
97
|
* is `{repo}-{sha}/` — the sha comes free, no API call needed. */
|
|
53
98
|
export const snapshotFromTarball = (tarball, ref, opts = {}) => {
|
|
54
99
|
const maxFiles = opts.maxFiles ?? 5_000;
|
|
55
100
|
const maxFileBytes = opts.maxFileBytes ?? 1_000_000;
|
|
56
|
-
const
|
|
101
|
+
const maxUncompressedBytes = opts.maxUncompressedBytes ?? 400_000_000;
|
|
102
|
+
const entries = readTar(gunzipUnderCap(tarball, maxUncompressedBytes));
|
|
57
103
|
if (entries.length === 0)
|
|
58
104
|
throw new Error('empty tarball');
|
|
59
105
|
const rootDir = entries[0].name.split('/')[0];
|
|
@@ -90,7 +136,7 @@ export const snapshotFromTarball = (tarball, ref, opts = {}) => {
|
|
|
90
136
|
};
|
|
91
137
|
/** Fetch a public GitHub repo snapshot via codeload — no git, no API token, one request. */
|
|
92
138
|
export const fetchGithubSnapshot = async (owner, repo, opts = {}) => {
|
|
93
|
-
if (
|
|
139
|
+
if (!validSegment(owner) || !validSegment(repo))
|
|
94
140
|
throw new Error('invalid owner/repo');
|
|
95
141
|
const ref = opts.ref ?? 'HEAD';
|
|
96
142
|
if (!/^[\w][\w./-]*$/u.test(ref) || ref.includes('..'))
|
|
@@ -105,9 +151,6 @@ export const fetchGithubSnapshot = async (owner, repo, opts = {}) => {
|
|
|
105
151
|
throw new Error(`GitHub repo not found (or private): ${owner}/${repo}@${ref}`);
|
|
106
152
|
if (!res.ok)
|
|
107
153
|
throw new Error(`GitHub tarball fetch failed: HTTP ${res.status}`);
|
|
108
|
-
const buf =
|
|
109
|
-
if (buf.length > maxTarballBytes) {
|
|
110
|
-
throw new Error(`repo too large for the registry (${Math.round(buf.length / 1e6)}MB tarball; cap ${Math.round(maxTarballBytes / 1e6)}MB)`);
|
|
111
|
-
}
|
|
154
|
+
const buf = await readUnderCap(res, maxTarballBytes);
|
|
112
155
|
return snapshotFromTarball(buf, ref, opts);
|
|
113
156
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@northtek/overstory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Turn any repo or docs folder into a living knowledge tree where every claim carries a verifiable receipt. Local-first: your code never leaves your machine.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|