srcpack 0.2.0 → 1.0.0

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/src/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
+ import { cosmiconfig } from "cosmiconfig";
3
4
  import { homedir } from "node:os";
4
5
  import { join, resolve } from "node:path";
5
- import { cosmiconfig } from "cosmiconfig";
6
6
  import { z } from "zod";
7
7
 
8
8
  export function expandPath(p: string): string {
@@ -18,6 +18,104 @@ const PatternsSchema = z.union([
18
18
  z.array(z.string().min(1)).min(1),
19
19
  ]);
20
20
 
21
+ /**
22
+ * A name typed by a human and handed to a remote API. Trimmed, because a key
23
+ * pasted with a stray space fails as "not found" or "not authorized", which
24
+ * reads like the wrong key rather than the wrong whitespace.
25
+ */
26
+ const IdentifierSchema = z.string().trim().min(1);
27
+
28
+ /**
29
+ * A bundle name, which is also a filename: the default output is
30
+ * `<outDir>/<name>.txt`. Unconstrained, `"../report"` writes outside `outDir`
31
+ * entirely, and a leading `-` names a bundle the CLI can never be asked for.
32
+ */
33
+ const BundleNameSchema = z
34
+ .string()
35
+ .regex(
36
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
37
+ "Bundle name must start with a letter or digit and contain only letters, digits, dot, underscore or hyphen",
38
+ );
39
+
40
+ // Every closed object below is strict. Zod strips unknown keys by default, and
41
+ // a stripped key is a typo that changes behaviour without saying so: `liner`
42
+ // drops a bundle's issues, `emptyOutdir` hands the decision back to the
43
+ // automatic default, `exlude` uploads the bundle it was meant to hold back.
44
+ // Config files are edited by hand and read by a machine — the failure has to be
45
+ // loud. Adding a key is a minor version either way, so nothing is lost.
46
+
47
+ /**
48
+ * Linear issues as a bundle source. Each issue becomes a virtual file at
49
+ * `linear/issues/<identifier>.md`, so it gets its own index entry and line
50
+ * range and can be filtered with ordinary `!` exclusions.
51
+ *
52
+ * Authentication reads `LINEAR_API_KEY` from the environment. It is
53
+ * deliberately not a config field: config files are committed, and the
54
+ * `package.json` config form cannot express `process.env`.
55
+ *
56
+ * `team` is required. A workspace-wide fetch is a footgun — it looks innocuous
57
+ * and can pull thousands of issues into a context window.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * bundles: {
62
+ * backlog: { linear: "ENG" },
63
+ * roadmap: { linear: { team: "ENG", project: "Roadmap" } },
64
+ * }
65
+ * ```
66
+ */
67
+ const LinearSourceSchema = z.union([
68
+ /** Shorthand for `{ team: "<key>" }`. */
69
+ IdentifierSchema,
70
+ z.strictObject({
71
+ /** Team key — the `ENG` in issue identifier `ENG-123`. */
72
+ team: IdentifierSchema,
73
+ /** Project name. Must name exactly one project within the team. */
74
+ project: IdentifierSchema.optional(),
75
+ /** Include completed, canceled and duplicate issues. Defaults to false. */
76
+ includeClosed: z.boolean().default(false),
77
+ }),
78
+ ]);
79
+
80
+ /**
81
+ * An absolute http(s) URL. A scheme-less `localhost:5173` is called out by
82
+ * name: `URL` would otherwise parse it as protocol `localhost:`.
83
+ */
84
+ const HttpUrlSchema = z.string().superRefine((value, ctx) => {
85
+ if (!value.includes("://")) {
86
+ ctx.addIssue({
87
+ code: "custom",
88
+ message: 'URL needs a scheme, e.g. "http://localhost:5173/"',
89
+ });
90
+ } else if (!/^https?:\/\//i.test(value) || !URL.canParse(value)) {
91
+ ctx.addIssue({ code: "custom", message: "Expected an http(s) URL" });
92
+ }
93
+ });
94
+
95
+ /**
96
+ * A rendered page as numbered PNGs in `outDir`, independent of the text
97
+ * output path. See ADR 006 for why it is a separate source key.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * bundles: {
102
+ * home: { screenshot: "http://localhost:5173/", onDemand: true },
103
+ * phone: { screenshot: { url: "http://localhost:5173/", viewport: "mobile" } },
104
+ * }
105
+ * ```
106
+ */
107
+ const ScreenshotSourceSchema = z.union([
108
+ /** Shorthand for `{ url: "<url>" }`. */
109
+ HttpUrlSchema,
110
+ z.strictObject({
111
+ url: HttpUrlSchema,
112
+ /** Defaults to "desktop". */
113
+ viewport: z.enum(["desktop", "mobile"]).optional(),
114
+ /** CSS selectors hidden during capture: cookie banners, chat widgets. */
115
+ hide: z.array(z.string().min(1)).optional(),
116
+ }),
117
+ ]);
118
+
21
119
  /**
22
120
  * Bundle configuration. Accepts a string pattern, array of patterns, or object.
23
121
  * Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
@@ -27,24 +125,67 @@ const PatternsSchema = z.union([
27
125
  * `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
28
126
  * `git:main`, `git:HEAD~3`).
29
127
  *
128
+ * The object form takes files (`include`), Linear issues (`linear`), page
129
+ * screenshots (`screenshot`), or any combination.
130
+ *
30
131
  * @example
31
132
  * ```ts
32
- * bundles: { review: ["git:staged", "!bun.lock"] }
133
+ * bundles: {
134
+ * review: ["git:staged", "!bun.lock"],
135
+ * planning: { include: ["docs/**"], linear: { team: "ENG" } },
136
+ * }
33
137
  * ```
34
138
  */
35
139
  const BundleConfigSchema = z.union([
36
140
  z.string().min(1),
37
141
  z.array(z.string().min(1)).min(1),
38
- z.object({
39
- /** Glob patterns to include in the bundle. */
40
- include: PatternsSchema,
41
- /** Custom output file path. Defaults to `<outDir>/<bundleName>.txt`. */
42
- outfile: z.string().optional(),
43
- /** Include file index header in output. Defaults to true. */
44
- index: z.boolean().default(true),
45
- /** Text to prepend to bundle (e.g., review instructions for LLMs). */
46
- prompt: z.string().optional(),
47
- }),
142
+ z
143
+ .strictObject({
144
+ /** Glob patterns to include in the bundle. */
145
+ include: PatternsSchema.optional(),
146
+ /** Linear issues to include in the bundle. */
147
+ linear: LinearSourceSchema.optional(),
148
+ /** Page to capture as numbered PNGs in `outDir`. */
149
+ screenshot: ScreenshotSourceSchema.optional(),
150
+ /** Custom output file path. Defaults to `<outDir>/<bundleName>.txt`. */
151
+ outfile: z.string().min(1).optional(),
152
+ /**
153
+ * Include file index header in output. Defaults to true — read as
154
+ * `index ?? true`, so screenshot-only bundles can reject an explicit value.
155
+ */
156
+ index: z.boolean().optional(),
157
+ /** Text to prepend to bundle (e.g., review instructions for LLMs). */
158
+ prompt: z.string().optional(),
159
+ /**
160
+ * Skipped by a full run, built when named: `srcpack <name>`. For bundles
161
+ * too slow, remote or situational to rebuild every time. A full run that
162
+ * empties `outDir` still removes their output there — preserving it
163
+ * would make emptying a growing list of exceptions (ADR 005).
164
+ */
165
+ onDemand: z.boolean().optional(),
166
+ })
167
+ .superRefine((bundle, ctx) => {
168
+ if (bundle.include || bundle.linear) return;
169
+ if (!bundle.screenshot) {
170
+ ctx.addIssue({
171
+ code: "custom",
172
+ message:
173
+ 'Bundle needs a source: "include" patterns, "linear", or "screenshot"',
174
+ });
175
+ return;
176
+ }
177
+ // These shape a text file that a screenshot-only bundle never writes, so
178
+ // setting one is a misunderstanding rather than a harmless no-op
179
+ for (const key of ["prompt", "index", "outfile"] as const) {
180
+ if (bundle[key] !== undefined) {
181
+ ctx.addIssue({
182
+ code: "custom",
183
+ path: [key],
184
+ message: `"${key}" applies to the text file, and a screenshot-only bundle writes none. Add "include" or "linear", or remove it`,
185
+ });
186
+ }
187
+ }
188
+ }),
48
189
  ]);
49
190
 
50
191
  /**
@@ -61,39 +202,67 @@ const BundleConfigSchema = z.union([
61
202
  * }
62
203
  * ```
63
204
  */
64
- const UploadConfigSchema = z.object({
205
+ const UploadConfigSchema = z.strictObject({
65
206
  /** Upload provider. Currently only "gdrive" is supported. */
66
207
  provider: z.literal("gdrive"),
67
208
  /** Google Drive folder ID to upload files to. If omitted, uploads to root. */
68
- folderId: z.string().optional(),
209
+ folderId: IdentifierSchema.optional(),
69
210
  /** OAuth 2.0 client ID from Google Cloud Console. */
70
- clientId: z.string().min(1),
211
+ clientId: IdentifierSchema,
71
212
  /** OAuth 2.0 client secret from Google Cloud Console. */
72
- clientSecret: z.string().min(1),
213
+ clientSecret: IdentifierSchema,
73
214
  /** Bundle names to skip during upload. Supports exact names only. */
74
215
  exclude: z.array(z.string()).optional(),
75
216
  });
76
217
 
77
218
  /** Root configuration for srcpack. */
78
- const ConfigSchema = z.object({
79
- /**
80
- * Project root directory. Can be absolute or relative to CWD.
81
- * @default process.cwd()
82
- */
83
- root: z.string().default(""),
84
- /** Output directory for bundle files (relative to root). Defaults to ".srcpack". */
85
- outDir: z.string().default(".srcpack"),
86
- /** Empty outDir before bundling. Auto-enabled when outDir is inside project root. */
87
- emptyOutDir: z.boolean().optional(),
88
- /** Upload configuration for cloud storage. Single destination or array. */
89
- upload: z
90
- .union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)])
91
- .optional(),
92
- /** Named bundles mapping bundle name to glob patterns or config object. */
93
- bundles: z.record(z.string(), BundleConfigSchema),
94
- });
219
+ const ConfigSchema = z
220
+ .strictObject({
221
+ /**
222
+ * Project root directory. Can be absolute or relative to CWD.
223
+ * @default process.cwd()
224
+ */
225
+ root: z.string().default(""),
226
+ /** Output directory for bundle files (relative to root). Defaults to ".srcpack". */
227
+ outDir: z.string().default(".srcpack"),
228
+ /** Empty outDir before writing. Automatic only for the default `.srcpack`. */
229
+ emptyOutDir: z.boolean().optional(),
230
+ /** Upload configuration for cloud storage. Single destination or array. */
231
+ upload: z
232
+ .union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)])
233
+ .optional(),
234
+ /** Named bundles mapping bundle name to glob patterns or config object. */
235
+ bundles: z.record(BundleNameSchema, BundleConfigSchema),
236
+ })
237
+ .superRefine((config, ctx) => {
238
+ // Reject misspelled exclusions so a bundle intended to stay local cannot
239
+ // silently upload. Removed bundles must also be removed from this list.
240
+ const uploads = config.upload
241
+ ? Array.isArray(config.upload)
242
+ ? config.upload
243
+ : [config.upload]
244
+ : [];
245
+ const names = new Set(Object.keys(config.bundles));
246
+
247
+ uploads.forEach((upload, i) => {
248
+ const path = Array.isArray(config.upload)
249
+ ? ["upload", i, "exclude"]
250
+ : ["upload", "exclude"];
251
+ for (const name of upload.exclude ?? []) {
252
+ if (!names.has(name)) {
253
+ ctx.addIssue({
254
+ code: "custom",
255
+ path,
256
+ message: `Unknown bundle "${name}"`,
257
+ });
258
+ }
259
+ }
260
+ });
261
+ });
95
262
 
96
263
  export type UploadConfig = z.infer<typeof UploadConfigSchema>;
264
+ export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
265
+ export type ScreenshotSource = z.infer<typeof ScreenshotSourceSchema>;
97
266
  export type BundleConfig = z.infer<typeof BundleConfigSchema>;
98
267
  export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
99
268
  export type Config = z.infer<typeof ConfigSchema>;
@@ -110,13 +279,57 @@ export class ConfigError extends Error {
110
279
  }
111
280
  }
112
281
 
282
+ /** A zod issue, plus the nested issues a union or a bad record key carries. */
283
+ interface Issue {
284
+ code: string;
285
+ message: string;
286
+ path: PropertyKey[];
287
+ /** One entry per union branch. */
288
+ errors?: Issue[][];
289
+ /** Why a record key was rejected. */
290
+ issues?: Issue[];
291
+ }
292
+
293
+ /**
294
+ * Reduce an issue to the leaves that actually explain it. Both wrappers say
295
+ * nothing on their own — "Invalid input", "Invalid key in record" — while the
296
+ * reason sits one level down.
297
+ */
298
+ function flatten(issue: Issue, prefix: PropertyKey[] = []): Issue[] {
299
+ const path = [...prefix, ...issue.path];
300
+ const nested =
301
+ issue.code === "invalid_union"
302
+ ? issue.errors?.flat()
303
+ : issue.code === "invalid_key"
304
+ ? issue.issues
305
+ : undefined;
306
+ return nested?.length
307
+ ? nested.flatMap((child) => flatten(child, path))
308
+ : [{ ...issue, path }];
309
+ }
310
+
311
+ /**
312
+ * Describe the most specific reason a config failed.
313
+ *
314
+ * Bundle and upload configs are unions, and a union reports one failure per
315
+ * branch. Reporting the first would surface "expected string" from a branch
316
+ * that never applied, burying the branch that nearly matched — so prefer a
317
+ * leaf that says something other than "wrong type", deepest path first.
318
+ */
319
+ function describe(issues: Issue[]): string {
320
+ const leaves = issues.flatMap((issue) => flatten(issue));
321
+ const specific = leaves.filter((leaf) => leaf.code !== "invalid_type");
322
+ const best = (specific.length ? specific : leaves).reduce((a, b) =>
323
+ b.path.length > a.path.length ? b : a,
324
+ );
325
+ const path = best.path.join(".");
326
+ return path ? `${path}: ${best.message}` : best.message;
327
+ }
328
+
113
329
  export function parseConfig(value: unknown): Config {
114
330
  const result = ConfigSchema.safeParse(value);
115
331
  if (!result.success) {
116
- const issue = result.error.issues[0]!;
117
- const path = issue.path.join(".");
118
- const message = path ? `${path}: ${issue.message}` : issue.message;
119
- throw new ConfigError(message);
332
+ throw new ConfigError(describe(result.error.issues as unknown as Issue[]));
120
333
  }
121
334
 
122
335
  const config = result.data;
package/src/fs.ts ADDED
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { realpath, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+
6
+ /**
7
+ * Compare paths using Unicode NFC followed by lowercase, on every platform.
8
+ * Normalizing first gives equivalent spellings the same input to lowercasing.
9
+ * This catches case and normalization collisions even for unwritten outputs
10
+ * and unresolved final entries, where realpath cannot canonicalize spelling.
11
+ * Applying one rule everywhere prevents a config from passing on Linux and
12
+ * overwriting a bundle on a case-insensitive filesystem (ADR 004).
13
+ *
14
+ * Comparison only: I/O retains the original spelling. Ownership and deletion
15
+ * use exact matches so folding cannot widen what srcpack removes.
16
+ */
17
+ export function pathKey(path: string): string {
18
+ return path.normalize("NFC").toLowerCase();
19
+ }
20
+
21
+ /**
22
+ * Where a path physically is, with symlinks resolved. Destructive decisions are
23
+ * made on this rather than the lexical path: `.srcpack -> ../shared` is inside
24
+ * the project by name and somewhere else in fact, and it is the somewhere else
25
+ * whose contents `rm` would take.
26
+ *
27
+ * Resolves as much of the path as exists, however deep that is. Stopping at the
28
+ * immediate parent would call `.srcpack/nested/x.txt` and `alias/nested/x.txt`
29
+ * different files until `mkdir -p` runs, which is one step too late to still be
30
+ * a check: aliasing is a property of the ancestors, not of when they were made.
31
+ */
32
+ export async function physicalPath(path: string): Promise<string> {
33
+ try {
34
+ return await realpath(path);
35
+ } catch {
36
+ const parent = dirname(path);
37
+ // dirname("/") === "/": nothing above the filesystem root left to resolve
38
+ if (parent === path) return path;
39
+ return join(await physicalPath(parent), basename(path));
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Where `rename` puts a directory entry: ancestors resolved, the entry itself
45
+ * left alone. Writing replaces the entry instead of following it, so a bundle
46
+ * whose output is a symlink is identified as the link rather than its target —
47
+ * writing to the link path and to its target produces two separate files.
48
+ */
49
+ export async function entryPath(path: string): Promise<string> {
50
+ return join(await physicalPath(dirname(path)), basename(path));
51
+ }
52
+
53
+ export function isInside(path: string, dir: string): boolean {
54
+ const rel = relative(dir, path);
55
+ // Compare against ".." as a whole segment — "..cache/x" is a child, not an escape
56
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
57
+ }
58
+
59
+ /**
60
+ * Write a file by replacing the directory entry rather than the file behind
61
+ * it. Writing in place follows a symlink sitting at the output path, so
62
+ * `.srcpack/web.txt -> ~/.ssh/config` would be written through; rename replaces
63
+ * the link itself. It also makes each file appear whole or not at all.
64
+ *
65
+ * The temp name carries the pid so two runs can't rename each other's file.
66
+ */
67
+ export async function writeFileAtomic(
68
+ path: string,
69
+ data: string | Uint8Array,
70
+ ): Promise<void> {
71
+ const temp = `${path}.${process.pid}.tmp`;
72
+ try {
73
+ await writeFile(temp, data);
74
+ await rename(temp, path);
75
+ } finally {
76
+ // A failed write or rename would otherwise leave a partial file behind:
77
+ // stale inside outDir, and bundled by the next run beside a custom outfile.
78
+ await rm(temp, { force: true });
79
+ }
80
+ }