srcpack 0.2.0 → 0.3.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/cli.ts CHANGED
@@ -1,10 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import {
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import {
14
+ basename,
15
+ dirname,
16
+ isAbsolute,
17
+ join,
18
+ relative,
19
+ resolve,
20
+ sep,
21
+ } from "node:path";
6
22
  import ora from "ora";
7
- import { bundleOne, type BundleResult } from "./bundle.ts";
23
+ import { bundleOne, pathKey, type BundleResult } from "./bundle.ts";
8
24
  import {
9
25
  ConfigError,
10
26
  loadConfig,
@@ -12,7 +28,6 @@ import {
12
28
  type BundleConfig,
13
29
  type UploadConfig,
14
30
  } from "./config.ts";
15
- import { GitError } from "./git.ts";
16
31
  import {
17
32
  ensureAuthenticated,
18
33
  login,
@@ -20,7 +35,9 @@ import {
20
35
  uploadFile,
21
36
  type UploadResult,
22
37
  } from "./gdrive.ts";
38
+ import { GitError } from "./git.ts";
23
39
  import { runInit } from "./init.ts";
40
+ import { LinearError } from "./linear.ts";
24
41
 
25
42
  interface BundleOutput {
26
43
  name: string;
@@ -40,12 +57,67 @@ function plural(n: number, singular: string, pluralForm?: string): string {
40
57
  return n === 1 ? singular : (pluralForm ?? singular + "s");
41
58
  }
42
59
 
60
+ /** The directory srcpack owns by convention, and the only one it clears unasked. */
61
+ const DEFAULT_OUT_DIR = ".srcpack";
62
+
63
+ /**
64
+ * Where a path physically is, with symlinks resolved. Destructive decisions are
65
+ * made on this rather than the lexical path: `.srcpack -> ../shared` is inside
66
+ * the project by name and somewhere else in fact, and it is the somewhere else
67
+ * whose contents `rm` would take.
68
+ *
69
+ * Resolves as much of the path as exists, however deep that is. Stopping at the
70
+ * immediate parent would call `.srcpack/nested/x.txt` and `alias/nested/x.txt`
71
+ * different files until `mkdir -p` runs, which is one step too late to still be
72
+ * a check: aliasing is a property of the ancestors, not of when they were made.
73
+ */
74
+ async function physicalPath(path: string): Promise<string> {
75
+ try {
76
+ return await realpath(path);
77
+ } catch {
78
+ const parent = dirname(path);
79
+ // dirname("/") === "/": nothing above the filesystem root left to resolve
80
+ if (parent === path) return path;
81
+ return join(await physicalPath(parent), basename(path));
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Where `rename` puts a directory entry: ancestors resolved, the entry itself
87
+ * left alone. Writing replaces the entry instead of following it, so a bundle
88
+ * whose output is a symlink is identified as the link rather than its target —
89
+ * two bundles writing over one link's target are still two separate files.
90
+ */
91
+ async function entryPath(path: string): Promise<string> {
92
+ return join(await physicalPath(dirname(path)), basename(path));
93
+ }
94
+
43
95
  function isInside(path: string, dir: string): boolean {
44
96
  const rel = relative(dir, path);
45
97
  // Compare against ".." as a whole segment — "..cache/x" is a child, not an escape
46
98
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
47
99
  }
48
100
 
101
+ /**
102
+ * Write a bundle by replacing the directory entry rather than the file behind
103
+ * it. Writing in place follows a symlink sitting at the output path, so
104
+ * `.srcpack/web.txt -> ~/.ssh/config` would be written through; rename replaces
105
+ * the link itself. It also makes each file appear whole or not at all.
106
+ *
107
+ * The temp name carries the pid so two runs can't rename each other's file.
108
+ */
109
+ async function writeBundle(path: string, content: string): Promise<void> {
110
+ const temp = `${path}.${process.pid}.tmp`;
111
+ try {
112
+ await writeFile(temp, content);
113
+ await rename(temp, path);
114
+ } finally {
115
+ // A failed write or rename would otherwise leave a partial file behind:
116
+ // stale inside outDir, and bundled by the next run beside a custom outfile.
117
+ await rm(temp, { force: true });
118
+ }
119
+ }
120
+
49
121
  /**
50
122
  * Empty a directory while preserving specified entries (e.g., `.git`).
51
123
  * Uses `force: true` to handle read-only or in-use files.
@@ -54,8 +126,14 @@ async function emptyDirectory(dir: string, skip: string[] = []): Promise<void> {
54
126
  let entries: string[];
55
127
  try {
56
128
  entries = await readdir(dir);
57
- } catch {
58
- return; // Directory doesn't exist, nothing to empty
129
+ } catch (error) {
130
+ // Only a missing directory is "nothing to empty". Anything else — a
131
+ // permission error, a file where a directory belongs — would otherwise be
132
+ // reported as a clean run that then writes into a directory it never read.
133
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
134
+ throw new ConfigError(
135
+ `Cannot empty outDir "${dir}": ${(error as Error).message}`,
136
+ );
59
137
  }
60
138
  const skipSet = new Set(skip);
61
139
  await Promise.all(
@@ -76,6 +154,39 @@ interface AdHocBundle {
76
154
 
77
155
  const AD_HOC_FLAGS = ["--staged", "--dirty", "--since"] as const;
78
156
 
157
+ /**
158
+ * Every option the CLI accepts. Anything else is a typo, and a typo that gets
159
+ * quietly dropped is the dangerous kind: `--no-uplaod` uploads, `--dry-rnu`
160
+ * writes, `--no-emptyOutdir` empties. Same rule as the config — a token that
161
+ * changes what a run destroys or publishes is never a silent no-op.
162
+ */
163
+ const KNOWN_FLAGS = new Set([
164
+ ...AD_HOC_FLAGS,
165
+ "--dry-run",
166
+ "--emptyOutDir",
167
+ "--no-emptyOutDir",
168
+ "--no-upload",
169
+ "--help",
170
+ "-h",
171
+ "--version",
172
+ "-v",
173
+ ]);
174
+
175
+ function assertKnownFlags(args: string[]): void {
176
+ const unknown = args.find(
177
+ (arg) => arg.startsWith("-") && !KNOWN_FLAGS.has(arg),
178
+ );
179
+ if (unknown) {
180
+ console.error(`Unknown option: ${unknown}`);
181
+ console.error("Run `srcpack --help` to see the available options.");
182
+ process.exit(1);
183
+ }
184
+ if (args.includes("--emptyOutDir") && args.includes("--no-emptyOutDir")) {
185
+ console.error("Cannot combine --emptyOutDir with --no-emptyOutDir.");
186
+ process.exit(1);
187
+ }
188
+ }
189
+
79
190
  function parseAdHocBundle(args: string[]): AdHocBundle | null {
80
191
  const flags = AD_HOC_FLAGS.filter((flag) => args.includes(flag));
81
192
 
@@ -147,7 +258,7 @@ Options:
147
258
  --dirty Bundle staged, unstaged, and untracked changes
148
259
  --since <rev> Bundle changes since <rev> (e.g. --since main)
149
260
  --dry-run Preview bundles without writing files
150
- --emptyOutDir Empty output directory before bundling
261
+ --emptyOutDir Empty output directory before writing
151
262
  --no-emptyOutDir Keep existing files in output directory
152
263
  --no-upload Skip uploading to cloud storage
153
264
  -h, --help Show this help message
@@ -158,15 +269,18 @@ Options:
158
269
 
159
270
  // Only in first position: elsewhere the word is a bundle name or a revision,
160
271
  // and `--since init` must diff against the `init` branch, not run the wizard.
161
- if (args[0] === "init") {
162
- await runInit();
272
+ if (args[0] === "init" || args[0] === "login") {
273
+ // Neither takes arguments, so anything after is a misunderstanding worth
274
+ // saying out loud rather than a flag that silently does nothing.
275
+ if (args.length > 1) {
276
+ console.error(`srcpack ${args[0]} takes no arguments.`);
277
+ process.exit(1);
278
+ }
279
+ await (args[0] === "init" ? runInit() : runLogin());
163
280
  return;
164
281
  }
165
282
 
166
- if (args[0] === "login") {
167
- await runLogin();
168
- return;
169
- }
283
+ assertKnownFlags(args);
170
284
 
171
285
  const dryRun = args.includes("--dry-run");
172
286
  const noUpload = args.includes("--no-upload");
@@ -210,7 +324,8 @@ Options:
210
324
 
211
325
  // Validate requested bundle names exist
212
326
  for (const name of bundleNames) {
213
- if (!(name in bundles)) {
327
+ // hasOwn, not `in`: `srcpack toString` would otherwise find Object.prototype
328
+ if (!Object.hasOwn(bundles, name)) {
214
329
  console.error(`Unknown bundle: ${name}`);
215
330
  process.exit(1);
216
331
  }
@@ -223,30 +338,43 @@ Options:
223
338
 
224
339
  const root = config.root;
225
340
 
226
- // Resolve emptyOutDir: CLI flag > config > auto (true if inside root).
227
- // Ad-hoc runs never empty by default — they shouldn't delete configured bundles.
341
+ // Resolve emptyOutDir: CLI flag > config > auto.
342
+ //
343
+ // Auto means only the conventional `.srcpack`: recursive deletion needs a
344
+ // directory srcpack demonstrably owns, and `outDir: "src"` reads as an
345
+ // ordinary setting while turning a bundling run into a source-tree wipe.
346
+ // Every other directory belongs to the user until they say otherwise.
347
+ //
348
+ // The comparison is physical, not lexical: `.srcpack -> ../shared` looks
349
+ // inside the project and deletes somewhere else. Ad-hoc runs never empty by
350
+ // default either — they shouldn't delete configured bundles.
351
+ // Lexical is what gets written to and excluded from bundles; physical is what
352
+ // decides ownership. Conflating them is what let a symlink redirect a delete.
353
+ const rootPath = await physicalPath(root);
228
354
  const outDirPath = resolve(root, config.outDir);
229
- const outDirInsideRoot = isInside(outDirPath, root);
230
- const emptyOutDir =
231
- emptyOutDirFlag ??
232
- (adHoc ? false : (config.emptyOutDir ?? outDirInsideRoot));
355
+ const outDirPhysical = await physicalPath(outDirPath);
356
+ const defaultOutDir = join(rootPath, DEFAULT_OUT_DIR);
233
357
 
234
- // Warn if outDir is outside root and emptyOutDir is not explicitly set
358
+ // The conventional name is a claim about a place. A `.srcpack` that resolves
359
+ // somewhere else keeps the name while writing into a directory srcpack was
360
+ // never given — and would overwrite whatever shares a filename there.
235
361
  if (
236
- !adHoc &&
237
- !outDirInsideRoot &&
238
- emptyOutDirFlag === undefined &&
239
- config.emptyOutDir === undefined
362
+ resolve(root, DEFAULT_OUT_DIR) === outDirPath &&
363
+ outDirPhysical !== defaultOutDir
240
364
  ) {
241
- console.warn(
242
- `Warning: outDir "${config.outDir}" is outside project root. ` +
243
- "Use --emptyOutDir to suppress this warning and empty the directory.",
365
+ throw new ConfigError(
366
+ `Refusing to use "${DEFAULT_OUT_DIR}": it resolves to "${outDirPhysical}", not "${defaultOutDir}". ` +
367
+ "Set outDir to that path explicitly if that is where bundles belong.",
244
368
  );
245
369
  }
246
370
 
371
+ const ownsOutDir = outDirPhysical === defaultOutDir;
372
+ const emptyOutDir =
373
+ emptyOutDirFlag ?? (adHoc ? false : (config.emptyOutDir ?? ownsOutDir));
374
+
247
375
  // `outDir: "."` resolves to the project root, where emptying deletes the
248
376
  // whole project — sources, config and all. Refuse rather than warn.
249
- const outDirHoldsRoot = isInside(root, outDirPath);
377
+ const outDirHoldsRoot = isInside(rootPath, outDirPhysical);
250
378
  if (emptyOutDir && outDirHoldsRoot) {
251
379
  throw new ConfigError(
252
380
  `Refusing to empty outDir "${config.outDir}": it contains the project root. ` +
@@ -254,20 +382,43 @@ Options:
254
382
  );
255
383
  }
256
384
 
257
- // Empty outDir before bundling (unless dry-run). Only for a full run: a named
258
- // subset can't tell what is stale, so `srcpack web` must not delete api.txt.
259
- if (emptyOutDir && !dryRun && requestedBundles.length === 0) {
260
- await emptyDirectory(outDirPath, [".git"]);
261
- }
262
-
263
385
  // srcpack never bundles what srcpack writes. Every configured outfile is
264
386
  // named explicitly; outDir covers stale bundles from renamed config entries
265
387
  // too, but not when it holds the root — that would exclude the whole project.
266
- const ownOutputs = Object.entries(config.bundles).map(
267
- ([name, bundleConfig]) =>
268
- resolve(root, getOutfile(bundleConfig, name, config.outDir)),
269
- );
270
- if (!outDirHoldsRoot) ownOutputs.push(outDirPath);
388
+ //
389
+ // Both spellings of every output are recorded. A glob rooted at a symlink
390
+ // yields lexical paths, one rooted at the real directory yields physical
391
+ // ones, and either can name a file the previous run wrote.
392
+ const ownOutputs = new Set<string>();
393
+ const writers = new Map<string, string>();
394
+ for (const [name, bundleConfig] of Object.entries(config.bundles)) {
395
+ const outfile = resolve(
396
+ root,
397
+ getOutfile(bundleConfig, name, config.outDir),
398
+ );
399
+ // Two bundles sharing one file is silent loss: the second write replaces the
400
+ // first, and the upload step then sends the survivor twice under two names.
401
+ // Keyed by destination entry — `.srcpack/a.txt` and `alias/a.txt` are two
402
+ // spellings of one file as soon as `alias` links to `.srcpack`, and so are
403
+ // `Web.txt` and `web.txt` wherever the filesystem folds case.
404
+ const entry = await entryPath(outfile);
405
+ const key = pathKey(entry);
406
+ const first = writers.get(key);
407
+ if (first) {
408
+ throw new ConfigError(
409
+ `Bundles "${first}" and "${name}" both write to "${relative(root, outfile) || outfile}". ` +
410
+ "Give one of them its own outfile.",
411
+ );
412
+ }
413
+ writers.set(key, name);
414
+ ownOutputs.add(outfile);
415
+ ownOutputs.add(entry);
416
+ }
417
+ if (!outDirHoldsRoot) {
418
+ ownOutputs.add(outDirPath);
419
+ ownOutputs.add(outDirPhysical);
420
+ }
421
+ const outputPaths = [...ownOutputs];
271
422
 
272
423
  const outputs: BundleOutput[] = [];
273
424
 
@@ -282,7 +433,21 @@ Options:
282
433
  const name = bundleNames[i]!;
283
434
  bundleSpinner.text = `Bundling ${name}... (${i + 1}/${bundleNames.length})`;
284
435
  const bundleConfig = bundles[name]!;
285
- const result = await bundleOne(bundleConfig, root, ownOutputs);
436
+ let result: BundleResult;
437
+ try {
438
+ result = await bundleOne(bundleConfig, root, outputPaths);
439
+ } catch (error) {
440
+ // A config can declare many bundles; the underlying message says what
441
+ // broke but not which bundle asked for it.
442
+ if (
443
+ error instanceof ConfigError ||
444
+ error instanceof GitError ||
445
+ error instanceof LinearError
446
+ ) {
447
+ error.message = `Bundle "${name}": ${error.message}`;
448
+ }
449
+ throw error;
450
+ }
286
451
  const outfile = getOutfile(bundleConfig, name, config.outDir);
287
452
  outputs.push({ name, outfile, result });
288
453
  }
@@ -290,6 +455,17 @@ Options:
290
455
  bundleSpinner.stop();
291
456
  }
292
457
 
458
+ // Empty outDir only once every bundle has resolved, and only for a full run:
459
+ // a named subset can't tell what is stale, so `srcpack web` must not delete
460
+ // api.txt. Emptying earlier would destroy a good previous run whenever a
461
+ // later bundle fails — routine once a source is remote, since an expired
462
+ // token or a rate limit aborts the run after outDir is already gone.
463
+ // Resolution doesn't need the files removed first: `ownOutputs` already keeps
464
+ // srcpack's own output from being bundled.
465
+ if (emptyOutDir && !dryRun && requestedBundles.length === 0) {
466
+ await emptyDirectory(outDirPath, [".git"]);
467
+ }
468
+
293
469
  // Calculate column widths for aligned output
294
470
  const maxNameLen = Math.max(...outputs.map((o) => o.name.length));
295
471
  const maxFilesLen = Math.max(
@@ -328,7 +504,7 @@ Options:
328
504
  );
329
505
  } else {
330
506
  await mkdir(dirname(outPath), { recursive: true });
331
- await writeFile(outPath, result.content);
507
+ await writeBundle(outPath, result.content);
332
508
  const displayPath = relative(process.cwd(), outPath);
333
509
  console.log(
334
510
  ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${displayPath}`,
@@ -378,16 +554,6 @@ function isGdriveConfigured(config: UploadConfig): boolean {
378
554
  );
379
555
  }
380
556
 
381
- function getGdriveConfig(config: {
382
- upload?: UploadConfig | UploadConfig[];
383
- }): UploadConfig | null {
384
- if (!config.upload) return null;
385
- const uploads = Array.isArray(config.upload)
386
- ? config.upload
387
- : [config.upload];
388
- return uploads.find(isGdriveConfigured) ?? null;
389
- }
390
-
391
557
  async function runLogin(): Promise<void> {
392
558
  let config;
393
559
  try {
@@ -531,9 +697,13 @@ function getOutfile(
531
697
  }
532
698
 
533
699
  main().catch((err) => {
534
- // Config and git failures are user-facing; a stack trace only adds noise
700
+ // Config, git and Linear failures are user-facing; a stack trace adds noise
535
701
  console.error(
536
- err instanceof ConfigError || err instanceof GitError ? err.message : err,
702
+ err instanceof ConfigError ||
703
+ err instanceof GitError ||
704
+ err instanceof LinearError
705
+ ? err.message
706
+ : err,
537
707
  );
538
708
  process.exit(1);
539
709
  });
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,65 @@ 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
+
21
80
  /**
22
81
  * Bundle configuration. Accepts a string pattern, array of patterns, or object.
23
82
  * Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
@@ -27,24 +86,35 @@ const PatternsSchema = z.union([
27
86
  * `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
28
87
  * `git:main`, `git:HEAD~3`).
29
88
  *
89
+ * The object form takes files (`include`), Linear issues (`linear`), or both.
90
+ *
30
91
  * @example
31
92
  * ```ts
32
- * bundles: { review: ["git:staged", "!bun.lock"] }
93
+ * bundles: {
94
+ * review: ["git:staged", "!bun.lock"],
95
+ * planning: { include: ["docs/**"], linear: { team: "ENG" } },
96
+ * }
33
97
  * ```
34
98
  */
35
99
  const BundleConfigSchema = z.union([
36
100
  z.string().min(1),
37
101
  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
- }),
102
+ z
103
+ .strictObject({
104
+ /** Glob patterns to include in the bundle. */
105
+ include: PatternsSchema.optional(),
106
+ /** Linear issues to include in the bundle. */
107
+ linear: LinearSourceSchema.optional(),
108
+ /** Custom output file path. Defaults to `<outDir>/<bundleName>.txt`. */
109
+ outfile: z.string().min(1).optional(),
110
+ /** Include file index header in output. Defaults to true. */
111
+ index: z.boolean().default(true),
112
+ /** Text to prepend to bundle (e.g., review instructions for LLMs). */
113
+ prompt: z.string().optional(),
114
+ })
115
+ .refine((bundle) => bundle.include || bundle.linear, {
116
+ message: 'Bundle needs a source: "include" patterns, "linear", or both',
117
+ }),
48
118
  ]);
49
119
 
50
120
  /**
@@ -61,39 +131,68 @@ const BundleConfigSchema = z.union([
61
131
  * }
62
132
  * ```
63
133
  */
64
- const UploadConfigSchema = z.object({
134
+ const UploadConfigSchema = z.strictObject({
65
135
  /** Upload provider. Currently only "gdrive" is supported. */
66
136
  provider: z.literal("gdrive"),
67
137
  /** Google Drive folder ID to upload files to. If omitted, uploads to root. */
68
- folderId: z.string().optional(),
138
+ folderId: IdentifierSchema.optional(),
69
139
  /** OAuth 2.0 client ID from Google Cloud Console. */
70
- clientId: z.string().min(1),
140
+ clientId: IdentifierSchema,
71
141
  /** OAuth 2.0 client secret from Google Cloud Console. */
72
- clientSecret: z.string().min(1),
142
+ clientSecret: IdentifierSchema,
73
143
  /** Bundle names to skip during upload. Supports exact names only. */
74
144
  exclude: z.array(z.string()).optional(),
75
145
  });
76
146
 
77
147
  /** 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
- });
148
+ const ConfigSchema = z
149
+ .strictObject({
150
+ /**
151
+ * Project root directory. Can be absolute or relative to CWD.
152
+ * @default process.cwd()
153
+ */
154
+ root: z.string().default(""),
155
+ /** Output directory for bundle files (relative to root). Defaults to ".srcpack". */
156
+ outDir: z.string().default(".srcpack"),
157
+ /** Empty outDir before writing. Automatic only for the default `.srcpack`. */
158
+ emptyOutDir: z.boolean().optional(),
159
+ /** Upload configuration for cloud storage. Single destination or array. */
160
+ upload: z
161
+ .union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)])
162
+ .optional(),
163
+ /** Named bundles mapping bundle name to glob patterns or config object. */
164
+ bundles: z.record(BundleNameSchema, BundleConfigSchema),
165
+ })
166
+ .superRefine((config, ctx) => {
167
+ // `upload.exclude` is the only thing keeping a bundle off Google Drive, so a
168
+ // name that matches nothing uploads the bundle it was meant to hold back —
169
+ // the one failure mode where a typo is worse than a missing line. A stale
170
+ // entry left over from a deleted bundle is cheap to fix by comparison.
171
+ const uploads = config.upload
172
+ ? Array.isArray(config.upload)
173
+ ? config.upload
174
+ : [config.upload]
175
+ : [];
176
+ const names = new Set(Object.keys(config.bundles));
177
+
178
+ uploads.forEach((upload, i) => {
179
+ const path = Array.isArray(config.upload)
180
+ ? ["upload", i, "exclude"]
181
+ : ["upload", "exclude"];
182
+ for (const name of upload.exclude ?? []) {
183
+ if (!names.has(name)) {
184
+ ctx.addIssue({
185
+ code: "custom",
186
+ path,
187
+ message: `Unknown bundle "${name}"`,
188
+ });
189
+ }
190
+ }
191
+ });
192
+ });
95
193
 
96
194
  export type UploadConfig = z.infer<typeof UploadConfigSchema>;
195
+ export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
97
196
  export type BundleConfig = z.infer<typeof BundleConfigSchema>;
98
197
  export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
99
198
  export type Config = z.infer<typeof ConfigSchema>;
@@ -110,13 +209,57 @@ export class ConfigError extends Error {
110
209
  }
111
210
  }
112
211
 
212
+ /** A zod issue, plus the nested issues a union or a bad record key carries. */
213
+ interface Issue {
214
+ code: string;
215
+ message: string;
216
+ path: PropertyKey[];
217
+ /** One entry per union branch. */
218
+ errors?: Issue[][];
219
+ /** Why a record key was rejected. */
220
+ issues?: Issue[];
221
+ }
222
+
223
+ /**
224
+ * Reduce an issue to the leaves that actually explain it. Both wrappers say
225
+ * nothing on their own — "Invalid input", "Invalid key in record" — while the
226
+ * reason sits one level down.
227
+ */
228
+ function flatten(issue: Issue, prefix: PropertyKey[] = []): Issue[] {
229
+ const path = [...prefix, ...issue.path];
230
+ const nested =
231
+ issue.code === "invalid_union"
232
+ ? issue.errors?.flat()
233
+ : issue.code === "invalid_key"
234
+ ? issue.issues
235
+ : undefined;
236
+ return nested?.length
237
+ ? nested.flatMap((child) => flatten(child, path))
238
+ : [{ ...issue, path }];
239
+ }
240
+
241
+ /**
242
+ * Describe the most specific reason a config failed.
243
+ *
244
+ * Bundle and upload configs are unions, and a union reports one failure per
245
+ * branch. Reporting the first would surface "expected string" from a branch
246
+ * that never applied, burying the branch that nearly matched — so prefer a
247
+ * leaf that says something other than "wrong type", deepest path first.
248
+ */
249
+ function describe(issues: Issue[]): string {
250
+ const leaves = issues.flatMap((issue) => flatten(issue));
251
+ const specific = leaves.filter((leaf) => leaf.code !== "invalid_type");
252
+ const best = (specific.length ? specific : leaves).reduce((a, b) =>
253
+ b.path.length > a.path.length ? b : a,
254
+ );
255
+ const path = best.path.join(".");
256
+ return path ? `${path}: ${best.message}` : best.message;
257
+ }
258
+
113
259
  export function parseConfig(value: unknown): Config {
114
260
  const result = ConfigSchema.safeParse(value);
115
261
  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);
262
+ throw new ConfigError(describe(result.error.issues as unknown as Issue[]));
120
263
  }
121
264
 
122
265
  const config = result.data;