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/README.md +34 -15
- package/dist/bundle.d.ts +47 -5
- package/dist/cli.js +818 -400
- package/dist/config.d.ts +50 -9
- package/dist/index.js +50 -13
- package/dist/linear.d.ts +17 -0
- package/package.json +4 -1
- package/src/bundle.ts +242 -51
- package/src/cli.ts +224 -54
- package/src/config.ts +180 -37
- package/src/linear.ts +368 -0
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 {
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
//
|
|
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
|
|
230
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
emptyOutDirFlag === undefined &&
|
|
239
|
-
config.emptyOutDir === undefined
|
|
362
|
+
resolve(root, DEFAULT_OUT_DIR) === outDirPath &&
|
|
363
|
+
outDirPhysical !== defaultOutDir
|
|
240
364
|
) {
|
|
241
|
-
|
|
242
|
-
`
|
|
243
|
-
"
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
700
|
+
// Config, git and Linear failures are user-facing; a stack trace adds noise
|
|
535
701
|
console.error(
|
|
536
|
-
err instanceof ConfigError ||
|
|
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: {
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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:
|
|
138
|
+
folderId: IdentifierSchema.optional(),
|
|
69
139
|
/** OAuth 2.0 client ID from Google Cloud Console. */
|
|
70
|
-
clientId:
|
|
140
|
+
clientId: IdentifierSchema,
|
|
71
141
|
/** OAuth 2.0 client secret from Google Cloud Console. */
|
|
72
|
-
clientSecret:
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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;
|