nerdsystem 0.0.11 → 0.0.13

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.
@@ -150,3 +150,111 @@ export function cvss3BaseScore(vector) {
150
150
  const roundUp = (x) => Math.ceil(x * 10 - 1e-9) / 10;
151
151
  return scopeChanged ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) : roundUp(Math.min(impact + exploitability, 10));
152
152
  }
153
+ /*
154
+ * Source snapshots (docs/source-snapshots.md). The CLI packs them, the control plane validates
155
+ * them while streaming, and both read the lists below, so a rule added here holds on both sides.
156
+ */
157
+ /** Name of the first entry in every snapshot tar. */
158
+ export const SNAPSHOT_MANIFEST = ".nerd-snapshot.json";
159
+ /** Most files one snapshot may hold. */
160
+ export const MAX_SOURCE_FILES = 20_000;
161
+ /** Largest compressed snapshot accepted, in bytes (25 MB). */
162
+ export const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
163
+ /** Largest uncompressed tar stream accepted, in bytes (200 MB). */
164
+ export const MAX_SOURCE_UNPACKED = 200 * 1024 * 1024;
165
+ /** A single file over this is left out of the snapshot with a warning (50 MB). */
166
+ export const MAX_SNAPSHOT_FILE_BYTES = 50 * 1024 * 1024;
167
+ /** A PAX or GNU long-name header larger than this is refused; real ones are a few hundred bytes. */
168
+ export const MAX_SNAPSHOT_HEADER_BYTES = 1024 * 1024;
169
+ /** Longest entry name accepted, in UTF-16 code units. */
170
+ export const MAX_SNAPSHOT_NAME_LENGTH = 4096;
171
+ /**
172
+ * Never in a snapshot, whatever git or the include list says: repository internals, build output,
173
+ * caches, local databases, env files, keys, package-manager credentials, and agent chat history.
174
+ * Matched against the posix path relative to the app root at any depth.
175
+ */
176
+ export const SNAPSHOT_EXCLUDE = [
177
+ /(^|\/)(\.git|node_modules|dist|\.wrangler|\.vite|coverage|\.nerd|\.turbo|\.cache|\.aws|\.ssh|\.specstory)(\/|$)/,
178
+ /(^|\/)\.aider[^/]*(\/|$)/,
179
+ /(^|\/)(\.DS_Store|Thumbs\.db|\.npmrc|\.yarnrc\.yml|\.netrc)$/i,
180
+ ENV_FILES,
181
+ /\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|db-journal|log)$/i,
182
+ /(^|\/)(id_rsa|id_ed25519)[^/]*$/,
183
+ /(^|\/)\.claude\/settings\.local\.json$/,
184
+ /^\.nerd-snapshot\.json$/,
185
+ ];
186
+ export function isSnapshotExcluded(posixPath) {
187
+ return SNAPSHOT_EXCLUDE.some((re) => re.test(posixPath));
188
+ }
189
+ /** Directories at the app root whose whole tree a rebuild needs even when .gitignore hides it. */
190
+ export const SNAPSHOT_INCLUDE_DIRS = ["migrations", "src", "public", ".claude", ".cursor", ".github"];
191
+ /**
192
+ * Files a rebuild needs; when .gitignore hides one it still goes into the snapshot, with a warning.
193
+ * Root-level config files and the trees in SNAPSHOT_INCLUDE_DIRS. The hard exclude still wins.
194
+ */
195
+ export const SNAPSHOT_INCLUDE = [
196
+ /^(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|nerd\.json|wrangler\.json[^/]*|vite\.config\.[^/]+|tsconfig[^/]*\.json|drizzle\.config\.[^/]+|index\.html|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.mcp\.json|README\.md|\.gitignore)$/,
197
+ new RegExp(`^(${SNAPSHOT_INCLUDE_DIRS.map((d) => d.replace(/\./g, "\\.")).join("|")})/`),
198
+ ];
199
+ export function isSnapshotForced(posixPath) {
200
+ return !isSnapshotExcluded(posixPath) && SNAPSHOT_INCLUDE.some((re) => re.test(posixPath));
201
+ }
202
+ /** True when an ignored directory may hold forced files, so a walker should still descend into it. */
203
+ export function isSnapshotForcedDir(posixDir) {
204
+ const dir = posixDir.replace(/\/+$/, "");
205
+ if (dir === "" || isSnapshotExcluded(`${dir}/`))
206
+ return false;
207
+ return SNAPSHOT_INCLUDE_DIRS.some((d) => dir === d || dir.startsWith(`${d}/`));
208
+ }
209
+ /** The warning printed for every ignored file the snapshot keeps anyway. */
210
+ export function snapshotForcedWarning(posixPath) {
211
+ return `\`${posixPath}\` is in .gitignore but a rebuild needs it; it is in the saved copy`;
212
+ }
213
+ /** Tar entry types a snapshot may hold: regular files, directories, PAX headers, GNU long names. */
214
+ export const SNAPSHOT_ENTRY_TYPES = new Set(["0", "\0", "5", "x", "L"]);
215
+ const TYPE_NAMES = { "1": "hard link", "2": "symbolic link", "3": "character device", "4": "block device", "6": "fifo", "7": "contiguous file", g: "global PAX header", K: "GNU long link", S: "sparse file" };
216
+ function shortName(name) {
217
+ const clean = name.replace(/[^\x20-\x7e]/g, "?");
218
+ return clean.length > 120 ? `${clean.slice(0, 117)}...` : clean;
219
+ }
220
+ /**
221
+ * Checks one tar entry against the snapshot rules. Returns null when it passes, else a short reason.
222
+ * The same check runs on the control plane while the upload streams and in the CLI before a file
223
+ * is written on restore. `type` is the one-character typeflag ("" counts as "\0").
224
+ */
225
+ export function validateSnapshotEntry(entry) {
226
+ const type = entry.type === "" ? "\0" : entry.type;
227
+ const name = entry.name;
228
+ if (!SNAPSHOT_ENTRY_TYPES.has(type)) {
229
+ const what = TYPE_NAMES[type] ?? `type ${JSON.stringify(type)}`;
230
+ return `entry "${shortName(name)}" is a ${what}; only regular files are allowed`;
231
+ }
232
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0)
233
+ return `entry "${shortName(name)}" has an invalid size`;
234
+ if ((type === "0" || type === "\0") && entry.size > MAX_SNAPSHOT_FILE_BYTES)
235
+ return `entry "${shortName(name)}" is larger than ${MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024)} MB`;
236
+ if ((type === "x" || type === "L") && entry.size > MAX_SNAPSHOT_HEADER_BYTES)
237
+ return `entry "${shortName(name)}" has an oversized ${type === "x" ? "PAX" : "long-name"} header`;
238
+ if (name.length === 0)
239
+ return "an entry has no name";
240
+ if (name.length > MAX_SNAPSHOT_NAME_LENGTH)
241
+ return `entry "${shortName(name)}" has a name longer than ${MAX_SNAPSHOT_NAME_LENGTH} characters`;
242
+ if (name.includes("\0"))
243
+ return `entry "${shortName(name)}" has a NUL byte in its name`;
244
+ if (name.includes("\\"))
245
+ return `entry "${shortName(name)}" has a backslash in its name; paths must use /`;
246
+ if (name.startsWith("/"))
247
+ return `entry "${shortName(name)}" is an absolute path`;
248
+ const relative = type === "5" && name.endsWith("/") ? name.slice(0, -1) : name;
249
+ for (const segment of relative.split("/")) {
250
+ if (segment === "")
251
+ return `entry "${shortName(name)}" has an empty path segment`;
252
+ if (segment === ".")
253
+ return `entry "${shortName(name)}" has a "." path segment`;
254
+ if (segment === "..")
255
+ return `entry "${shortName(name)}" has a ".." path segment`;
256
+ if (segment === ".git")
257
+ return `entry "${shortName(name)}" is inside a .git directory`;
258
+ }
259
+ return null;
260
+ }
@@ -0,0 +1,161 @@
1
+ import { type SnapshotManifest } from "./gates/text.js";
2
+ export { MAX_SNAPSHOT_FILE_BYTES, MAX_SOURCE_BYTES, MAX_SOURCE_FILES, MAX_SOURCE_UNPACKED, SNAPSHOT_MANIFEST, validateSnapshotEntry, type SnapshotFile, type SnapshotManifest } from "./gates/text.js";
3
+ export type SnapshotErrorCode = "invalid_snapshot" | "not_empty" | "changed" | "too_large";
4
+ export declare class SnapshotError extends Error {
5
+ readonly code: SnapshotErrorCode;
6
+ constructor(message: string, code: SnapshotErrorCode);
7
+ }
8
+ export declare function formatMb(bytes: number): string;
9
+ export interface SnapshotCandidate {
10
+ /** Posix path relative to the root. */
11
+ path: string;
12
+ abs: string;
13
+ size: number;
14
+ /** 0o644 or 0o755. */
15
+ mode: number;
16
+ }
17
+ export interface SkippedFile {
18
+ path: string;
19
+ size: number;
20
+ reason: string;
21
+ }
22
+ export interface SnapshotSelection {
23
+ files: SnapshotCandidate[];
24
+ warnings: string[];
25
+ skipped: SkippedFile[];
26
+ /** Ignored files a rebuild needs that were kept anyway (every one; `warnings` folds past 20). */
27
+ forced: string[];
28
+ /** Where the candidate list came from. */
29
+ listedBy: "git" | "walk";
30
+ }
31
+ export interface ListOptions {
32
+ /** Set false to skip git and use the filesystem walker (tests). Default true. */
33
+ useGit?: boolean;
34
+ }
35
+ /** `{ sha, branch }` of the checkout at `root`, or null when there is no git, no repo, or no commit yet. */
36
+ export declare function gitInfo(root: string): {
37
+ sha: string;
38
+ branch: string;
39
+ } | null;
40
+ interface IgnoreRule {
41
+ re: RegExp;
42
+ dirOnly: boolean;
43
+ }
44
+ export declare function parseGitignore(text: string, baseRel: string): IgnoreRule[];
45
+ /**
46
+ * The files a snapshot of `root` holds, sorted by path in byte order. Uses git when `.git` exists and
47
+ * git runs, else walks the folder. Every candidate is stat'ed; missing and non-regular paths are
48
+ * dropped, the hard exclude list is applied last, and a file over 50 MB is left out with a warning.
49
+ */
50
+ export declare function listSnapshotFiles(root: string, opts?: ListOptions): SnapshotSelection;
51
+ export interface RawTarEntry {
52
+ name: string;
53
+ type: string;
54
+ data?: Buffer;
55
+ mode?: number;
56
+ linkname?: string;
57
+ /** Written into the ustar prefix field (tests only). */
58
+ prefix?: string;
59
+ }
60
+ /** The bytes of one entry: an optional PAX header for a long path, the ustar header, data, padding. */
61
+ export declare function tarEntryBytes(e: RawTarEntry): Buffer[];
62
+ /** A whole tar from raw entries, in the given order, with the two end blocks. Tests build hostile tars with it. */
63
+ export declare function buildTar(entries: RawTarEntry[]): Buffer;
64
+ /** gzip level 6 with a fixed header (mtime 0, OS byte 3) so the same tar always gives the same bytes. */
65
+ export declare function gzipDeterministic(chunks: AsyncIterable<Buffer> | Iterable<Buffer>, maxBytes?: number): Promise<Buffer>;
66
+ export interface PackOptions extends ListOptions {
67
+ /** CLI version written into the manifest. */
68
+ cli?: string;
69
+ /** Git provenance for the manifest; omitted means "detect from root", null means none. */
70
+ git?: {
71
+ sha: string;
72
+ branch: string;
73
+ } | null;
74
+ /** Manifest timestamp; defaults to now. Tests pin it so two packs give the same bytes. */
75
+ createdAt?: string;
76
+ node?: string;
77
+ }
78
+ export interface TooLarge {
79
+ /** Which cap was hit. */
80
+ reason: "files" | "unpacked" | "compressed";
81
+ /** Size of the selected files on disk. */
82
+ bytes: number;
83
+ fileCount: number;
84
+ /** The five largest selected files, biggest first. */
85
+ largest: {
86
+ path: string;
87
+ size: number;
88
+ }[];
89
+ /** The sentence the CLI prints. */
90
+ message: string;
91
+ }
92
+ export interface PackBase {
93
+ fileCount: number;
94
+ manifest: SnapshotManifest;
95
+ warnings: string[];
96
+ skipped: SkippedFile[];
97
+ forced: string[];
98
+ listedBy: "git" | "walk";
99
+ }
100
+ export interface PackOk extends PackBase {
101
+ gz: Buffer;
102
+ /** Hex sha256 of `gz`, what the API stores and verifies. */
103
+ sha256: string;
104
+ /** Hex sha256 of the uncompressed tar minus the manifest entry: the dedup key. */
105
+ treeHash: string;
106
+ /** Compressed size. */
107
+ bytes: number;
108
+ /** Uncompressed tar size. */
109
+ tarBytes: number;
110
+ tooLarge?: undefined;
111
+ }
112
+ export interface PackTooLarge extends PackBase {
113
+ gz?: undefined;
114
+ sha256?: undefined;
115
+ treeHash?: undefined;
116
+ /** Size of the selected files on disk. */
117
+ bytes: number;
118
+ tarBytes: number;
119
+ tooLarge: TooLarge;
120
+ }
121
+ export type PackResult = PackOk | PackTooLarge;
122
+ /**
123
+ * Packs the app at `root` into a deterministic tar.gz. Returns `tooLarge` instead of `gz` when a
124
+ * cap is hit (a user-caused skip: the deploy proceeds without a snapshot). Throws `SnapshotError`
125
+ * with code `changed` when a file changed under it; the caller should try once more.
126
+ */
127
+ export declare function packSnapshot(root: string, opts?: PackOptions): Promise<PackResult>;
128
+ interface ParsedEntry {
129
+ name: string;
130
+ type: string;
131
+ size: number;
132
+ mode: number;
133
+ data: Buffer;
134
+ }
135
+ /** Raw entries in order; PAX and long-name headers are yielded as entries and resolved by the caller. */
136
+ export declare function parseTar(tar: Buffer): Generator<ParsedEntry>;
137
+ /** Uncompresses a snapshot with the unpacked cap enforced. */
138
+ export declare function gunzipSnapshot(gz: Buffer): Buffer;
139
+ /** The manifest alone, for callers that only need the file list. */
140
+ export declare function readSnapshotManifest(gz: Buffer): SnapshotManifest;
141
+ export interface ExtractOptions {
142
+ /** Write into a directory that already has files in it. */
143
+ force?: boolean;
144
+ }
145
+ export interface ExtractResult {
146
+ /** Regular files written. */
147
+ files: number;
148
+ manifest: SnapshotManifest;
149
+ dir: string;
150
+ }
151
+ /**
152
+ * Extracts a snapshot into `targetDir` with every hardening rule from the spec: only regular files,
153
+ * directories, PAX and long-name headers; relative names with no `..`, backslash, or `.git`; nothing
154
+ * outside the target after resolution (symlinked parents included); every file listed in the
155
+ * manifest with the listed size and sha256, and every listed file present. The whole archive and the
156
+ * whole target are checked before the first write, so a refused archive changes nothing on the disk,
157
+ * the files already in a `force` target included. A non-empty target needs `force`.
158
+ */
159
+ export declare function extractSnapshot(gz: Buffer, targetDir: string, opts?: ExtractOptions): ExtractResult;
160
+ /** The size on disk of a folder's selected files, for messages. */
161
+ export declare function selectionBytes(selection: SnapshotSelection): number;