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/README.md +55 -19
- package/dist/args.d.ts +26 -0
- package/dist/bundle.d.ts +26 -5
- package/dist/cli.js +5454 -15133
- package/dist/config.d.ts +92 -11
- package/dist/fs.d.ts +41 -0
- package/dist/index.js +448 -10447
- package/dist/linear.d.ts +17 -0
- package/dist/plan.d.ts +64 -0
- package/dist/screenshot.d.ts +113 -0
- package/package.json +13 -1
- package/src/args.ts +221 -0
- package/src/bundle.ts +219 -51
- package/src/cli.ts +304 -236
- package/src/config.ts +250 -37
- package/src/fs.ts +80 -0
- package/src/linear.ts +368 -0
- package/src/plan.ts +238 -0
- package/src/screenshot.ts +545 -0
package/src/bundle.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
-
import { lstat, open, readFile } from "node:fs/promises";
|
|
4
|
-
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
5
3
|
import { glob } from "fast-glob";
|
|
6
|
-
import picomatch from "picomatch";
|
|
7
4
|
import ignore, { type Ignore } from "ignore";
|
|
8
|
-
import {
|
|
5
|
+
import { lstat, open, readFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7
|
+
import picomatch from "picomatch";
|
|
8
|
+
import {
|
|
9
|
+
ConfigError,
|
|
10
|
+
expandPath,
|
|
11
|
+
type BundleConfigInput,
|
|
12
|
+
type LinearSourceInput,
|
|
13
|
+
} from "./config.ts";
|
|
14
|
+
import { pathKey } from "./fs.ts";
|
|
9
15
|
import { isGitSource, resolveGitSource } from "./git.ts";
|
|
16
|
+
import { resolveLinearSource } from "./linear.ts";
|
|
10
17
|
|
|
11
18
|
// Binary file detection: check first 8KB for null bytes (same heuristic as git)
|
|
12
19
|
const BINARY_CHECK_SIZE = 8192;
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Shared glob options. `followSymbolicLinks: false` is the load-bearing one:
|
|
23
|
+
* fast-glob defaults to true, so a link like `vendor -> ../../elsewhere` would
|
|
24
|
+
* be walked and every regular file under it bundled. Rejecting symlinks at the
|
|
25
|
+
* final component (see {@link isBundleable}) can't catch that — the leaf is an
|
|
26
|
+
* ordinary file; the escape happened in a directory along the way.
|
|
27
|
+
*/
|
|
28
|
+
const GLOB_OPTIONS = {
|
|
29
|
+
onlyFiles: true,
|
|
30
|
+
dot: true,
|
|
31
|
+
followSymbolicLinks: false,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Whether a path can be read into a bundle: an existing regular text file.
|
|
16
36
|
* Globs only yield files, but git can name a submodule directory or a file
|
|
@@ -46,16 +66,29 @@ async function isBundleable(filePath: string): Promise<boolean> {
|
|
|
46
66
|
}
|
|
47
67
|
}
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
/**
|
|
70
|
+
* One bundle member, before its content is laid out.
|
|
71
|
+
*
|
|
72
|
+
* `content` present means the entry is virtual — produced by a non-filesystem
|
|
73
|
+
* source such as Linear — and its `path` is synthetic. Absent means an ordinary
|
|
74
|
+
* file, read from disk at bundle time.
|
|
75
|
+
*/
|
|
76
|
+
export interface Entry {
|
|
77
|
+
path: string;
|
|
78
|
+
content?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One line of the bundle index — a file or a virtual entry, once laid out. */
|
|
82
|
+
export interface IndexEntry {
|
|
83
|
+
path: string; // Relative path from cwd, or a synthetic path
|
|
84
|
+
lines: number; // Line count in the entry's content
|
|
52
85
|
startLine: number; // Start line in bundle (1-indexed)
|
|
53
86
|
endLine: number; // End line in bundle
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
export interface BundleResult {
|
|
57
90
|
content: string;
|
|
58
|
-
index:
|
|
91
|
+
index: IndexEntry[];
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
/**
|
|
@@ -88,6 +121,9 @@ function normalizePatterns(config: BundleConfigInput): {
|
|
|
88
121
|
patterns = [config];
|
|
89
122
|
} else if (Array.isArray(config)) {
|
|
90
123
|
patterns = config;
|
|
124
|
+
} else if (config.include === undefined) {
|
|
125
|
+
// A `linear`-only bundle has no patterns at all
|
|
126
|
+
patterns = [];
|
|
91
127
|
} else {
|
|
92
128
|
patterns = Array.isArray(config.include)
|
|
93
129
|
? config.include
|
|
@@ -156,25 +192,29 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
|
|
|
156
192
|
// Skip empty lines and comments
|
|
157
193
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
158
194
|
|
|
195
|
+
// A trailing slash only says "directory", which is what this prunes anyway.
|
|
196
|
+
// Stripping it first matters: `node_modules/` is the common spelling, and
|
|
197
|
+
// testing for "/" before stripping rejected every one of them.
|
|
198
|
+
const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
199
|
+
|
|
159
200
|
// Skip patterns with special gitignore features we can't safely convert:
|
|
160
201
|
// - Root-anchored (starts with /)
|
|
161
202
|
// - Contains globs (*, ?, [)
|
|
162
203
|
// - Contains path separators (complex paths)
|
|
163
204
|
// - Escaped characters
|
|
164
205
|
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
name.startsWith("/") ||
|
|
207
|
+
name.includes("*") ||
|
|
208
|
+
name.includes("?") ||
|
|
209
|
+
name.includes("[") ||
|
|
210
|
+
name.includes("/") ||
|
|
211
|
+
name.includes("\\")
|
|
171
212
|
) {
|
|
172
213
|
continue;
|
|
173
214
|
}
|
|
174
215
|
|
|
175
216
|
// Only convert simple directory names (e.g., "node_modules", "dist")
|
|
176
217
|
// These are safe to prune at any depth
|
|
177
|
-
const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
178
218
|
if (name && /^[\w.-]+$/.test(name)) {
|
|
179
219
|
patterns.push(`**/${name}/**`);
|
|
180
220
|
}
|
|
@@ -184,28 +224,98 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
|
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
interface GitignoreResult {
|
|
187
|
-
ignore
|
|
227
|
+
/** Whether git would ignore this cwd-relative posix path. */
|
|
228
|
+
ignores: (path: string) => boolean;
|
|
188
229
|
globPatterns: string[];
|
|
189
230
|
}
|
|
190
231
|
|
|
232
|
+
/** One directory's ignore rules. `dir` is a cwd-relative prefix, "" for root. */
|
|
233
|
+
interface IgnoreLayer {
|
|
234
|
+
dir: string;
|
|
235
|
+
ig: Ignore;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read an ignore file. Only a missing file means "no rules" — a permission or
|
|
240
|
+
* I/O error would otherwise widen the bundle to exactly the files someone chose
|
|
241
|
+
* to hide, so it fails the run instead.
|
|
242
|
+
*/
|
|
243
|
+
async function readIgnoreFile(path: string): Promise<string | null> {
|
|
244
|
+
try {
|
|
245
|
+
return await readFile(path, "utf-8");
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
248
|
+
throw new ConfigError(
|
|
249
|
+
`Cannot read "${path}": ${(error as Error).message}. ` +
|
|
250
|
+
"srcpack stops rather than bundle files it cannot confirm are ignored.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
191
255
|
/**
|
|
192
|
-
* Load
|
|
193
|
-
*
|
|
256
|
+
* Load the .gitignore rules that apply anywhere under `cwd`.
|
|
257
|
+
*
|
|
258
|
+
* Git resolves ignores per directory: a path is governed by the ignore file in
|
|
259
|
+
* its own directory and in every parent, deepest rule winning. Reading only the
|
|
260
|
+
* root file bundles whatever a nested .gitignore hides — `packages/app/.env` in
|
|
261
|
+
* a monorepo being the case that matters, since ignored secrets staying out is
|
|
262
|
+
* a documented guarantee rather than a convenience.
|
|
194
263
|
*/
|
|
195
264
|
async function loadGitignore(cwd: string): Promise<GitignoreResult> {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
265
|
+
const rootContent = await readIgnoreFile(join(cwd, ".gitignore"));
|
|
266
|
+
const globPatterns = rootContent
|
|
267
|
+
? gitignoreToGlobPatterns(rootContent.split("\n"))
|
|
268
|
+
: [];
|
|
269
|
+
|
|
270
|
+
const layers: IgnoreLayer[] = [];
|
|
271
|
+
if (rootContent) layers.push({ dir: "", ig: ignore().add(rootContent) });
|
|
272
|
+
|
|
273
|
+
// Pruned by the root file's directory patterns: no point walking node_modules
|
|
274
|
+
// to collect ignore files that only govern paths already ignored.
|
|
275
|
+
const nested = await glob(["**/.gitignore"], {
|
|
276
|
+
cwd,
|
|
277
|
+
dot: true,
|
|
278
|
+
onlyFiles: true,
|
|
279
|
+
followSymbolicLinks: false,
|
|
280
|
+
ignore: globPatterns,
|
|
281
|
+
});
|
|
199
282
|
|
|
200
|
-
|
|
201
|
-
const content = await
|
|
202
|
-
ig.add(content);
|
|
203
|
-
globPatterns = gitignoreToGlobPatterns(content.split("\n"));
|
|
204
|
-
} catch {
|
|
205
|
-
// No .gitignore file, return empty ignore instance
|
|
283
|
+
for (const file of nested) {
|
|
284
|
+
const content = await readIgnoreFile(join(cwd, file));
|
|
285
|
+
if (content) layers.push({ dir: dirname(file), ig: ignore().add(content) });
|
|
206
286
|
}
|
|
207
287
|
|
|
208
|
-
return {
|
|
288
|
+
return { ignores: makeIgnores(layers), globPatterns };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve the layered rules for one path, deepest directory first. */
|
|
292
|
+
function makeIgnores(layers: IgnoreLayer[]): (path: string) => boolean {
|
|
293
|
+
if (layers.length === 0) return () => false;
|
|
294
|
+
const ordered = [...layers].sort((a, b) => b.dir.length - a.dir.length);
|
|
295
|
+
|
|
296
|
+
// First layer with an opinion wins; an explicit negation (`!keep.env`) is an
|
|
297
|
+
// opinion too, which is what lets a nested file re-include what its parent hid.
|
|
298
|
+
const opinion = (path: string): boolean | undefined => {
|
|
299
|
+
for (const { dir, ig } of ordered) {
|
|
300
|
+
if (dir && !path.startsWith(`${dir}/`)) continue;
|
|
301
|
+
const relative = dir ? path.slice(dir.length + 1) : path;
|
|
302
|
+
if (!relative || relative === "/") continue;
|
|
303
|
+
const { ignored, unignored } = ig.test(relative);
|
|
304
|
+
if (ignored) return true;
|
|
305
|
+
if (unignored) return false;
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return (path: string) => {
|
|
311
|
+
// Ancestors first: git never descends into an ignored directory, so nothing
|
|
312
|
+
// beneath one can be negated back in.
|
|
313
|
+
const segments = path.split("/");
|
|
314
|
+
for (let i = 1; i < segments.length; i++) {
|
|
315
|
+
if (opinion(`${segments.slice(0, i).join("/")}/`)) return true;
|
|
316
|
+
}
|
|
317
|
+
return opinion(path) === true;
|
|
318
|
+
};
|
|
209
319
|
}
|
|
210
320
|
|
|
211
321
|
/**
|
|
@@ -226,9 +336,11 @@ function isExternalPattern(pattern: string): boolean {
|
|
|
226
336
|
* files or directories; a directory covers everything beneath it.
|
|
227
337
|
*/
|
|
228
338
|
function isOwnOutput(filePath: string, outputs: string[]): boolean {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
339
|
+
const key = pathKey(filePath);
|
|
340
|
+
return outputs.some((out) => {
|
|
341
|
+
const outKey = pathKey(out);
|
|
342
|
+
return key === outKey || key.startsWith(outKey + sep);
|
|
343
|
+
});
|
|
232
344
|
}
|
|
233
345
|
|
|
234
346
|
/**
|
|
@@ -280,33 +392,86 @@ export async function resolvePatterns(
|
|
|
280
392
|
// Internal patterns (within cwd): respect .gitignore
|
|
281
393
|
const internalPatterns = globs.filter((p) => !isExternalPattern(p));
|
|
282
394
|
if (internalPatterns.length > 0) {
|
|
283
|
-
const {
|
|
395
|
+
const { ignores, globPatterns } = await loadGitignore(cwd);
|
|
284
396
|
const matches = await glob(internalPatterns, {
|
|
397
|
+
...GLOB_OPTIONS,
|
|
285
398
|
cwd,
|
|
286
|
-
onlyFiles: true,
|
|
287
|
-
dot: true,
|
|
288
399
|
ignore: globPatterns,
|
|
289
400
|
});
|
|
290
|
-
await add(matches.filter((m) => !
|
|
401
|
+
await add(matches.filter((m) => !ignores(m)));
|
|
291
402
|
}
|
|
292
403
|
|
|
293
404
|
// External patterns: skip .gitignore (it doesn't apply outside cwd)
|
|
294
405
|
const externalPatterns = globs.filter(isExternalPattern);
|
|
295
406
|
if (externalPatterns.length > 0) {
|
|
296
|
-
await add(
|
|
297
|
-
await glob(externalPatterns, { cwd, onlyFiles: true, dot: true }),
|
|
298
|
-
);
|
|
407
|
+
await add(await glob(externalPatterns, { ...GLOB_OPTIONS, cwd }));
|
|
299
408
|
}
|
|
300
409
|
|
|
301
410
|
// Force includes: bypass .gitignore (no ignore patterns passed to glob)
|
|
302
411
|
if (force.length > 0) {
|
|
303
|
-
await add(await glob(force, {
|
|
412
|
+
await add(await glob(force, { ...GLOB_OPTIONS, cwd }));
|
|
304
413
|
}
|
|
305
414
|
|
|
306
415
|
// Sort for deterministic output
|
|
307
416
|
return [...files].sort();
|
|
308
417
|
}
|
|
309
418
|
|
|
419
|
+
/** Read the `linear` source off a bundle config, if it declares one. */
|
|
420
|
+
function getLinear(config: BundleConfigInput): LinearSourceInput | undefined {
|
|
421
|
+
if (typeof config === "object" && !Array.isArray(config)) {
|
|
422
|
+
return config.linear;
|
|
423
|
+
}
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Resolve every source a bundle declares into a sorted entry list.
|
|
429
|
+
*
|
|
430
|
+
* Filesystem and `git:` sources produce paths ({@link resolvePatterns}, which
|
|
431
|
+
* never touches the network); `linear` produces virtual entries carrying their
|
|
432
|
+
* own content. Both then meet the same rules — `!` exclusions apply uniformly,
|
|
433
|
+
* and the result is sorted by path so bundles stay deterministic.
|
|
434
|
+
*/
|
|
435
|
+
export async function resolveEntries(
|
|
436
|
+
config: BundleConfigInput,
|
|
437
|
+
cwd: string,
|
|
438
|
+
outputs: string[] = [],
|
|
439
|
+
): Promise<Entry[]> {
|
|
440
|
+
const linearSource = getLinear(config);
|
|
441
|
+
|
|
442
|
+
// Sequential, not parallel: a bad pattern should fail before spending a
|
|
443
|
+
// network round trip, and a failed fetch shouldn't race a filesystem walk.
|
|
444
|
+
const paths = await resolvePatterns(config, cwd, outputs);
|
|
445
|
+
const entries: Entry[] = paths.map((path) => ({ path }));
|
|
446
|
+
|
|
447
|
+
if (linearSource) {
|
|
448
|
+
const { exclude } = normalizePatterns(config);
|
|
449
|
+
const excludeMatchers = exclude.map((p) => picomatch(p));
|
|
450
|
+
// Compare resolved paths, not the strings: an absolute pattern and a
|
|
451
|
+
// relative one name the same file with different spellings, and only the
|
|
452
|
+
// resolved form tells whether a real file occupies a synthetic path.
|
|
453
|
+
const taken = new Set(paths.map((path) => resolve(cwd, path)));
|
|
454
|
+
|
|
455
|
+
for (const entry of await resolveLinearSource(linearSource)) {
|
|
456
|
+
if (isExcluded(entry.path, excludeMatchers)) continue;
|
|
457
|
+
// `linear/issues/` is a reserved namespace once a bundle pulls issues:
|
|
458
|
+
// a real file there would put two entries in the index under one name,
|
|
459
|
+
// and an `!` exclusion can't drop one without dropping both.
|
|
460
|
+
if (taken.has(resolve(cwd, entry.path))) {
|
|
461
|
+
throw new ConfigError(
|
|
462
|
+
`Linear issue collides with the file "${entry.path}". ` +
|
|
463
|
+
"Rename the file, or narrow the include patterns so it isn't matched.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
entries.push(entry);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return entries.sort((a, b) =>
|
|
471
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
310
475
|
/**
|
|
311
476
|
* Count lines in a string (handles empty strings correctly)
|
|
312
477
|
*/
|
|
@@ -324,7 +489,7 @@ function countLines(content: string): number {
|
|
|
324
489
|
* - ASCII-only characters for broad compatibility
|
|
325
490
|
* - Line locations that point to actual file content
|
|
326
491
|
*/
|
|
327
|
-
export function formatIndex(index:
|
|
492
|
+
export function formatIndex(index: IndexEntry[]): string {
|
|
328
493
|
if (index.length === 0) return "# Index\n# (empty)";
|
|
329
494
|
|
|
330
495
|
const count = index.length;
|
|
@@ -355,43 +520,46 @@ function formatSeparator(index: number, filePath: string): string {
|
|
|
355
520
|
}
|
|
356
521
|
|
|
357
522
|
/**
|
|
358
|
-
* Create a bundle from a list of
|
|
523
|
+
* Create a bundle from a list of entries.
|
|
359
524
|
* Line numbers in the index point to the first line of actual file content,
|
|
360
525
|
* not to the separator line.
|
|
361
526
|
*/
|
|
362
527
|
export async function createBundle(
|
|
363
|
-
|
|
528
|
+
entries: Entry[],
|
|
364
529
|
cwd: string,
|
|
365
530
|
options: BundleOptions = {},
|
|
366
531
|
): Promise<BundleResult> {
|
|
367
532
|
const { includeIndex = true } = options;
|
|
368
533
|
// Normalize prompt: trim and treat whitespace-only as no prompt
|
|
369
534
|
const prompt = options.prompt?.trim() || undefined;
|
|
370
|
-
const index:
|
|
535
|
+
const index: IndexEntry[] = [];
|
|
371
536
|
const contentParts: string[] = [];
|
|
372
537
|
let currentLine = 1;
|
|
373
538
|
|
|
374
|
-
for (let i = 0; i <
|
|
375
|
-
const
|
|
376
|
-
const
|
|
539
|
+
for (let i = 0; i < entries.length; i++) {
|
|
540
|
+
const entry = entries[i]!;
|
|
541
|
+
const filePath = entry.path;
|
|
542
|
+
// Virtual entries carry their content; files are read from disk
|
|
543
|
+
const content =
|
|
544
|
+
entry.content ?? (await readFile(resolve(cwd, filePath), "utf-8"));
|
|
377
545
|
const lines = countLines(content);
|
|
378
546
|
|
|
379
547
|
// Separator takes 1 line, then content starts on next line
|
|
380
548
|
const contentStartLine = currentLine + 1;
|
|
381
549
|
|
|
382
|
-
const
|
|
550
|
+
const indexEntry: IndexEntry = {
|
|
383
551
|
path: filePath,
|
|
384
552
|
lines,
|
|
385
553
|
startLine: contentStartLine,
|
|
386
554
|
endLine: contentStartLine + Math.max(0, lines - 1),
|
|
387
555
|
};
|
|
388
|
-
index.push(
|
|
556
|
+
index.push(indexEntry);
|
|
389
557
|
|
|
390
558
|
contentParts.push(formatSeparator(i + 1, filePath));
|
|
391
559
|
contentParts.push(content.endsWith("\n") ? content.slice(0, -1) : content);
|
|
392
560
|
|
|
393
561
|
// Next separator line = after content
|
|
394
|
-
currentLine =
|
|
562
|
+
currentLine = indexEntry.endLine + 1;
|
|
395
563
|
}
|
|
396
564
|
|
|
397
565
|
// Calculate prompt offset (prompt text + blank + "---" + blank)
|
|
@@ -494,8 +662,8 @@ export async function bundleOne(
|
|
|
494
662
|
cwd: string,
|
|
495
663
|
outputs: string[] = [],
|
|
496
664
|
): Promise<BundleResult> {
|
|
497
|
-
const
|
|
665
|
+
const entries = await resolveEntries(config, cwd, outputs);
|
|
498
666
|
const includeIndex = getIncludeIndex(config);
|
|
499
667
|
const prompt = await resolvePrompt(getPrompt(config), cwd);
|
|
500
|
-
return createBundle(
|
|
668
|
+
return createBundle(entries, cwd, { includeIndex, prompt });
|
|
501
669
|
}
|