@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 CHANGED
@@ -1,7 +1,15 @@
1
1
  # OVERSTORY
2
2
 
3
+ [![CI](https://github.com/NORTHTEKDevs/overstory/actions/workflows/ci.yml/badge.svg)](https://github.com/NORTHTEKDevs/overstory/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@northtek/overstory)](https://www.npmjs.com/package/@northtek/overstory)
5
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
6
+ [![overstory](https://overstory-virid.vercel.app/badge/gh/NORTHTEKDevs/overstory.svg)](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/plans/2026-07-21-registry-design.md.
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;
@@ -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 entries = readTar(gunzipSync(tarball));
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 (!/^[\w.-]+$/u.test(owner) || !/^[\w.-]+$/u.test(repo))
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 = Buffer.from(await res.arrayBuffer());
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.0",
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",