srcpack 0.3.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/dist/plan.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { BundleResult } from "./bundle.ts";
2
+ import { type BundleConfig } from "./config.ts";
3
+ import { type CapturedPage, type ScreenshotTarget } from "./screenshot.ts";
4
+ /** Where a bundle writes. */
5
+ export interface PlannedBundle {
6
+ name: string;
7
+ source: BundleConfig;
8
+ /** Absolute path of the text output. Absent for a screenshot-only bundle. */
9
+ text?: {
10
+ outfile: string;
11
+ };
12
+ /** Page to capture into `<dir>/<name>-NN.png`. */
13
+ images?: {
14
+ target: ScreenshotTarget;
15
+ dir: string;
16
+ };
17
+ }
18
+ /** What a bundle produced, ready to write. */
19
+ export interface ResolvedBundle {
20
+ plan: PlannedBundle;
21
+ text?: BundleResult;
22
+ images?: CapturedPage;
23
+ }
24
+ export interface BundleSelection {
25
+ /** Bundles this run builds, in the order named or configured. */
26
+ names: string[];
27
+ /** On-demand bundles a full run left out. Empty when bundles are named. */
28
+ skipped: string[];
29
+ }
30
+ /**
31
+ * Pick the bundles a run builds. Naming a bundle always builds it; a full run
32
+ * builds everything not marked `onDemand`.
33
+ */
34
+ export declare function selectBundles(bundles: Record<string, BundleConfig>, requested: string[]): BundleSelection;
35
+ export interface OutputPlan {
36
+ /** The bundles this run builds, in order. */
37
+ bundles: PlannedBundle[];
38
+ /**
39
+ * Absolute paths srcpack writes, never to be bundled: every configured text
40
+ * output and every active one, each in lexical and entry spelling. A glob
41
+ * rooted at a symlink yields lexical paths, one rooted at the real directory
42
+ * yields physical ones, and either can name a file the previous run wrote.
43
+ *
44
+ * Image families need no entry: they always live in outDir, which the CLI
45
+ * excludes, and when outDir holds the root a PNG is skipped as binary anyway.
46
+ */
47
+ ownOutputs: string[];
48
+ }
49
+ /**
50
+ * Derive where every bundle writes and reject two bundles sharing a file.
51
+ *
52
+ * Resolve directory aliases and compare destinations with `pathKey` to prevent
53
+ * silent overwrites. Text files also collide with numbered images in the same
54
+ * directory, including their own bundle's images.
55
+ *
56
+ * Configured bundles are checked against each other on every run, so a config
57
+ * error doesn't depend on what was asked for. Active bundles — the ones `active`
58
+ * names, ad-hoc included — are checked against configured bundles of a
59
+ * different name: an ad-hoc bundle shadows a configured bundle of its own name
60
+ * (`--staged` over a `staged` bundle) but never overwrites another's output.
61
+ * Active bundles need no check among themselves: they are either configured,
62
+ * and already checked, or a single ad-hoc bundle.
63
+ */
64
+ export declare function planOutputs(root: string, outDir: string, configured: Record<string, BundleConfig>, active: [name: string, source: BundleConfig][]): Promise<OutputPlan>;
@@ -0,0 +1,113 @@
1
+ import type * as PlaywrightModule from "playwright";
2
+ import type { ScreenshotSource } from "./config.ts";
3
+ /** A capture failure worth a clean message: no Playwright, no page, no browser. */
4
+ export declare class ScreenshotError extends Error {
5
+ /** Nothing answered at the URL — most often a dev server that isn't running. */
6
+ readonly unreachable: boolean;
7
+ constructor(message: string, options?: {
8
+ unreachable?: boolean;
9
+ });
10
+ }
11
+ export type Viewport = "desktop" | "mobile";
12
+ /** A screenshot source with its defaults applied. */
13
+ export interface ScreenshotTarget {
14
+ url: string;
15
+ viewport: Viewport;
16
+ hide: string[];
17
+ }
18
+ export declare function toScreenshotTarget(source: ScreenshotSource): ScreenshotTarget;
19
+ /** A vertical region of the page, in CSS px. */
20
+ export interface Slice {
21
+ y: number;
22
+ height: number;
23
+ }
24
+ /**
25
+ * Split a page into the fewest overlapping slices that cover it top to bottom,
26
+ * each overlapping the next by at least `SLICE_OVERLAP` device px.
27
+ *
28
+ * Slices then shrink to share the page evenly rather than staying at the
29
+ * maximum: a page one pixel taller than a slice becomes two half-page slices,
30
+ * not two near-identical full ones that spend a model's attention twice.
31
+ */
32
+ export declare function planSlices(cssHeight: number, dpr: number): Slice[];
33
+ /**
34
+ * `home-00.png`, `home-01.png`, … Index 0 is the whole page, so filename
35
+ * order is review order. Padding grows with the highest index so the order
36
+ * holds past 99.
37
+ */
38
+ export declare function imageFileName(name: string, index: number, highestIndex: number): string;
39
+ /**
40
+ * Whether `file` is one of bundle `name`'s numbered images. The suffix is
41
+ * digits only, so `home-01-02.png` (bundle `home-01`) is never one of `home`'s.
42
+ *
43
+ * An exact match, because it decides what stale-image cleanup deletes and
44
+ * folding could only widen that (ADR 004). To ask whether two spellings
45
+ * collide, pass `pathKey`s.
46
+ */
47
+ export declare function isImageOf(name: string, file: string): boolean;
48
+ export type Playwright = Pick<typeof PlaywrightModule, "chromium" | "devices">;
49
+ /** Loads a module by specifier, throwing `MODULE_NOT_FOUND` when absent. */
50
+ export type Require = (id: string) => unknown;
51
+ /**
52
+ * Load Playwright without bundling it. `require` through `createRequire` keeps
53
+ * `bun build` from inlining an optional peer that most users never install.
54
+ */
55
+ export declare function loadPlaywright(root: string, from?: [Require, string][], userAgent?: string | undefined): Playwright;
56
+ /**
57
+ * Launch Playwright's Chromium, falling back to system Chrome only when the
58
+ * executable is missing: the browser download is where this flow loses people,
59
+ * and most developers already have Chrome. Other launch errors stay visible.
60
+ */
61
+ export declare function launchBrowser(playwright: Playwright, userAgent?: string | undefined): Promise<PlaywrightModule.Browser>;
62
+ /** What a capture produced. Dimensions are the page's, in CSS px. */
63
+ export interface CapturedPage {
64
+ width: number;
65
+ height: number;
66
+ /**
67
+ * Index 0 is the whole page, 1… the detail slices top to bottom. An index is
68
+ * carried rather than implied by position, so an omitted overview leaves
69
+ * `01…` in place instead of renumbering a detail slice into `00`.
70
+ */
71
+ images: {
72
+ index: number;
73
+ data: Uint8Array;
74
+ }[];
75
+ }
76
+ /** One browser for a whole run, launched when the first capture needs it. */
77
+ export interface Capturer {
78
+ capture(target: ScreenshotTarget, warn: (message: string) => void): Promise<CapturedPage>;
79
+ /** Close the browser, if one was launched. Never throws. */
80
+ close(): Promise<void>;
81
+ }
82
+ export declare function createCapturer(root: string, load?: () => Playwright): Capturer;
83
+ export interface NetworkWatch {
84
+ /**
85
+ * Resolves after the quiet window with nothing in flight, or after `limit`
86
+ * ms — never longer than the cap.
87
+ */
88
+ idle(limit?: number): Promise<void>;
89
+ }
90
+ /** The part of a Playwright page that reports requests. */
91
+ export interface RequestEvents {
92
+ on(event: "request" | "requestfinished" | "requestfailed", listener: (request: unknown) => void): unknown;
93
+ }
94
+ /**
95
+ * Track requests from before navigation onward. Not Playwright's `networkidle`
96
+ * load state: once reached it resolves immediately, so waiting for it after
97
+ * scrolling misses exactly the requests scrolling started.
98
+ *
99
+ * Quiet is measured from the last request event, not sampled: a request that
100
+ * starts and finishes between two polls still restarts the window.
101
+ */
102
+ export declare function watchNetwork(page: RequestEvents, { quiet, cap }?: {
103
+ quiet?: number | undefined;
104
+ cap?: number | undefined;
105
+ }): NetworkWatch;
106
+ /**
107
+ * Match install commands to `npm_config_user_agent`, defaulting to npm when
108
+ * the invoking package manager is unknown.
109
+ */
110
+ export declare function packageManager(userAgent?: string): {
111
+ add: string;
112
+ exec: string;
113
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srcpack",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "Zero-config CLI for bundling code into LLM-optimized context files",
5
5
  "keywords": [
6
6
  "llm",
@@ -87,9 +87,18 @@
87
87
  "picomatch": "^4.0.5",
88
88
  "zod": "^4.4.3"
89
89
  },
90
+ "peerDependencies": {
91
+ "playwright": "*"
92
+ },
93
+ "peerDependenciesMeta": {
94
+ "playwright": {
95
+ "optional": true
96
+ }
97
+ },
90
98
  "devDependencies": {
91
99
  "@types/bun": "^1.3.14",
92
100
  "@types/picomatch": "^4.0.3",
101
+ "playwright": "1.63.0",
93
102
  "prettier": "^3.9.6",
94
103
  "typescript": "^6.0.3",
95
104
  "vitepress": "^2.0.0-alpha.19",
package/src/args.ts ADDED
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { parseArgs } from "node:util";
4
+ import type { BundleConfig } from "./config.ts";
5
+
6
+ /** A mistake on the command line: reported without a stack trace. */
7
+ export class UsageError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "UsageError";
11
+ }
12
+ }
13
+
14
+ /**
15
+ * One-off bundle from a `git:` source or a URL; no config file required.
16
+ */
17
+ export interface AdHocBundle {
18
+ name: string;
19
+ source: BundleConfig;
20
+ }
21
+
22
+ export interface CliArgs {
23
+ /** Bundle names in input order. Empty for full and ad-hoc runs. */
24
+ bundles: string[];
25
+ adHoc: AdHocBundle | null;
26
+ dryRun: boolean;
27
+ /** Explicit CLI override; `undefined` lets the CLI apply config and run defaults. */
28
+ emptyOutDir: boolean | undefined;
29
+ upload: boolean;
30
+ }
31
+
32
+ /**
33
+ * Every option the CLI accepts; `strict` rejects the rest. A typo that gets
34
+ * quietly dropped is the dangerous kind: `--no-uplaod` uploads, `--dry-rnu`
35
+ * writes, `--no-emptyOutdir` empties. Same rule as the config — a token that
36
+ * changes what a run destroys or publishes is never a silent no-op.
37
+ *
38
+ * Negatives are declared literally rather than with `allowNegative`, which
39
+ * negates every boolean and would make `--upload` valid by defining `upload`.
40
+ */
41
+ const OPTIONS = {
42
+ staged: { type: "boolean" },
43
+ dirty: { type: "boolean" },
44
+ // Valued options are `multiple` so a repeat is seen and rejected rather
45
+ // than last-one-wins
46
+ since: { type: "string", multiple: true },
47
+ screenshot: { type: "string", multiple: true },
48
+ viewport: { type: "string", multiple: true },
49
+ "dry-run": { type: "boolean" },
50
+ emptyOutDir: { type: "boolean" },
51
+ "no-emptyOutDir": { type: "boolean" },
52
+ "no-upload": { type: "boolean" },
53
+ } as const;
54
+
55
+ const AD_HOC_FLAGS = ["staged", "dirty", "since", "screenshot"] as const;
56
+
57
+ const MISSING_VALUE = {
58
+ since: "Missing revision: --since <rev> (e.g. --since main)",
59
+ screenshot:
60
+ "Missing URL: --screenshot <url> (e.g. --screenshot localhost:5173)",
61
+ viewport: "Missing viewport: --viewport <desktop|mobile>",
62
+ };
63
+
64
+ const VIEWPORTS = ["desktop", "mobile"] as const;
65
+
66
+ /**
67
+ * Parse everything after `srcpack` except `--help`, `--version` and the
68
+ * `init`/`login` subcommands, which the CLI handles before this.
69
+ */
70
+ export function parseCliArgs(argv: string[]): CliArgs {
71
+ // parseArgs accepts `--` as a terminator even when strict, which would turn
72
+ // a stray `srcpack --` into a full run that empties and uploads
73
+ if (argv.includes("--")) throw unknownOption("--");
74
+
75
+ let parsed;
76
+ try {
77
+ parsed = parseArgs({
78
+ args: argv,
79
+ options: OPTIONS,
80
+ strict: true,
81
+ allowPositionals: true,
82
+ });
83
+ } catch (error) {
84
+ throw toUsageError(error as NodeJS.ErrnoException);
85
+ }
86
+ const { values, positionals } = parsed;
87
+
88
+ // Both flags set both values, so parseArgs can't reject this on its own
89
+ if (values.emptyOutDir && values["no-emptyOutDir"]) {
90
+ throw new UsageError("Cannot combine --emptyOutDir with --no-emptyOutDir.");
91
+ }
92
+
93
+ const adHocFlags = AD_HOC_FLAGS.filter((flag) => values[flag] !== undefined);
94
+ if (adHocFlags.length > 1) {
95
+ throw new UsageError(
96
+ `Cannot combine ${adHocFlags.map((flag) => `--${flag}`).join(" and ")}.`,
97
+ );
98
+ }
99
+ for (const flag of Object.keys(
100
+ MISSING_VALUE,
101
+ ) as (keyof typeof MISSING_VALUE)[]) {
102
+ const given = values[flag] ?? [];
103
+ if (given.length > 1) {
104
+ throw new UsageError(
105
+ `--${flag} takes one value; got ${given.map((v) => `"${v}"`).join(" and ")}.`,
106
+ );
107
+ }
108
+ }
109
+ const [viewport] = values.viewport ?? [];
110
+ if (viewport !== undefined && !values.screenshot) {
111
+ throw new UsageError("--viewport applies to --screenshot <url>.");
112
+ }
113
+ const adHoc = toAdHocBundle(adHocFlags[0], {
114
+ since: values.since?.[0],
115
+ screenshot: values.screenshot?.[0],
116
+ viewport,
117
+ });
118
+ if (adHoc && positionals.length) {
119
+ throw new UsageError(`Cannot combine --${adHoc.name} with named bundles.`);
120
+ }
121
+
122
+ return {
123
+ bundles: positionals,
124
+ adHoc,
125
+ dryRun: values["dry-run"] ?? false,
126
+ emptyOutDir: values.emptyOutDir
127
+ ? true
128
+ : values["no-emptyOutDir"]
129
+ ? false
130
+ : undefined,
131
+ upload: !values["no-upload"],
132
+ };
133
+ }
134
+
135
+ function toAdHocBundle(
136
+ flag: (typeof AD_HOC_FLAGS)[number] | undefined,
137
+ value: { since?: string; screenshot?: string; viewport?: string },
138
+ ): AdHocBundle | null {
139
+ switch (flag) {
140
+ case "staged":
141
+ return { name: "staged", source: ["git:staged"] };
142
+ case "dirty":
143
+ return { name: "dirty", source: ["git:dirty"] };
144
+ case "since": {
145
+ const rev = value.since;
146
+ // `--since=` parses as an empty value rather than a missing one
147
+ if (!rev) throw new UsageError(MISSING_VALUE.since);
148
+ // A range pins both endpoints, so it would silently drop the uncommitted
149
+ // work --since promises. Ranges belong in a config `git:` source.
150
+ if (rev.includes("..")) {
151
+ throw new UsageError(
152
+ `--since takes a revision, not a range: "${rev}". Use a git: source in your config for ranges.`,
153
+ );
154
+ }
155
+ // `git diff` can't see untracked files, but a new file written on this
156
+ // branch is part of "what changed since <rev>"
157
+ return { name: "since", source: [`git:${rev}`, "git:untracked"] };
158
+ }
159
+ case "screenshot": {
160
+ if (!value.screenshot) throw new UsageError(MISSING_VALUE.screenshot);
161
+ // Typed at a prompt, `localhost:5173` means http. Config URLs must carry
162
+ // the scheme, since there they are written once and read by others.
163
+ // Only a leading scheme counts: `localhost:5173/?next=https://x` has none
164
+ const url = /^[a-z][a-z\d+.-]*:\/\//i.test(value.screenshot)
165
+ ? value.screenshot
166
+ : `http://${value.screenshot}`;
167
+ if (!/^https?:\/\//i.test(url) || !URL.canParse(url)) {
168
+ throw new UsageError(
169
+ `--screenshot takes an http(s) URL, got "${value.screenshot}".`,
170
+ );
171
+ }
172
+ const viewport = value.viewport ?? "desktop";
173
+ if (!(VIEWPORTS as readonly string[]).includes(viewport)) {
174
+ throw new UsageError(
175
+ `--viewport must be "desktop" or "mobile", got "${viewport}".`,
176
+ );
177
+ }
178
+ return {
179
+ name: "screenshot",
180
+ source: {
181
+ screenshot: {
182
+ url,
183
+ viewport: viewport as (typeof VIEWPORTS)[number],
184
+ },
185
+ },
186
+ };
187
+ }
188
+ default:
189
+ return null;
190
+ }
191
+ }
192
+
193
+ function unknownOption(option: string): UsageError {
194
+ return new UsageError(
195
+ `Unknown option: ${option}\nRun \`srcpack --help\` to see the available options.`,
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Translate unknown options and missing values into srcpack usage errors.
201
+ * Avoid parseArgs suggestions to use `--`, which srcpack rejects.
202
+ */
203
+ function toUsageError(error: NodeJS.ErrnoException): Error {
204
+ switch (error.code) {
205
+ case "ERR_PARSE_ARGS_UNKNOWN_OPTION":
206
+ return unknownOption(
207
+ /'([^']+)'/.exec(error.message)?.[1] ?? error.message,
208
+ );
209
+ case "ERR_PARSE_ARGS_INVALID_OPTION_VALUE": {
210
+ // Missing (`--since`) or swallowed by the next flag (`--since -x`)
211
+ const flag = /^Option '--([\w-]+)/.exec(error.message)?.[1];
212
+ return new UsageError(
213
+ flag && Object.hasOwn(MISSING_VALUE, flag)
214
+ ? MISSING_VALUE[flag as keyof typeof MISSING_VALUE]
215
+ : error.message,
216
+ );
217
+ }
218
+ default:
219
+ return error;
220
+ }
221
+ }
package/src/bundle.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type BundleConfigInput,
12
12
  type LinearSourceInput,
13
13
  } from "./config.ts";
14
+ import { pathKey } from "./fs.ts";
14
15
  import { isGitSource, resolveGitSource } from "./git.ts";
15
16
  import { resolveLinearSource } from "./linear.ts";
16
17
 
@@ -330,30 +331,6 @@ function isExternalPattern(pattern: string): boolean {
330
331
  return normalized.startsWith("../");
331
332
  }
332
333
 
333
- /**
334
- * The key two paths are compared by: canonical spelling, then case folded, on
335
- * every platform. A case-insensitive filesystem — the default on macOS and
336
- * Windows — treats `Context.txt` and `context.txt` as one directory entry, and
337
- * APFS additionally folds Unicode normalisation, so `Café.txt` written as
338
- * precomposed U+00E9 and as `e` plus U+0301 is also one entry. Normalising
339
- * before folding is what makes the comparison sound: equal inputs stay equal
340
- * afterwards whether or not case folding preserves normalisation. `realpath`
341
- * resolves
342
- * an existing component to its on-disk spelling, but that doesn't cover these:
343
- * an output not yet written has no on-disk spelling, and the destination entry
344
- * is deliberately left unresolved so `rename` replaces a symlink rather than
345
- * following it. A config that works in Linux CI and loses a bundle on the
346
- * author's laptop is worse than one rejected everywhere, so the rule is the
347
- * same on every platform rather than keyed to the filesystem under it.
348
- *
349
- * Comparison only. Paths used for I/O keep their original spelling, and
350
- * ownership stays an exact match: folding there could only widen what srcpack
351
- * deletes, which is the one direction that must never be widened by a guess.
352
- */
353
- export function pathKey(path: string): string {
354
- return path.normalize("NFC").toLowerCase();
355
- }
356
-
357
334
  /**
358
335
  * Whether a path is one srcpack writes. `outputs` holds absolute paths of
359
336
  * files or directories; a directory covers everything beneath it.