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/src/config.ts CHANGED
@@ -77,6 +77,45 @@ const LinearSourceSchema = z.union([
77
77
  }),
78
78
  ]);
79
79
 
80
+ /**
81
+ * An absolute http(s) URL. A scheme-less `localhost:5173` is called out by
82
+ * name: `URL` would otherwise parse it as protocol `localhost:`.
83
+ */
84
+ const HttpUrlSchema = z.string().superRefine((value, ctx) => {
85
+ if (!value.includes("://")) {
86
+ ctx.addIssue({
87
+ code: "custom",
88
+ message: 'URL needs a scheme, e.g. "http://localhost:5173/"',
89
+ });
90
+ } else if (!/^https?:\/\//i.test(value) || !URL.canParse(value)) {
91
+ ctx.addIssue({ code: "custom", message: "Expected an http(s) URL" });
92
+ }
93
+ });
94
+
95
+ /**
96
+ * A rendered page as numbered PNGs in `outDir`, independent of the text
97
+ * output path. See ADR 006 for why it is a separate source key.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * bundles: {
102
+ * home: { screenshot: "http://localhost:5173/", onDemand: true },
103
+ * phone: { screenshot: { url: "http://localhost:5173/", viewport: "mobile" } },
104
+ * }
105
+ * ```
106
+ */
107
+ const ScreenshotSourceSchema = z.union([
108
+ /** Shorthand for `{ url: "<url>" }`. */
109
+ HttpUrlSchema,
110
+ z.strictObject({
111
+ url: HttpUrlSchema,
112
+ /** Defaults to "desktop". */
113
+ viewport: z.enum(["desktop", "mobile"]).optional(),
114
+ /** CSS selectors hidden during capture: cookie banners, chat widgets. */
115
+ hide: z.array(z.string().min(1)).optional(),
116
+ }),
117
+ ]);
118
+
80
119
  /**
81
120
  * Bundle configuration. Accepts a string pattern, array of patterns, or object.
82
121
  * Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
@@ -86,7 +125,8 @@ const LinearSourceSchema = z.union([
86
125
  * `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
87
126
  * `git:main`, `git:HEAD~3`).
88
127
  *
89
- * The object form takes files (`include`), Linear issues (`linear`), or both.
128
+ * The object form takes files (`include`), Linear issues (`linear`), page
129
+ * screenshots (`screenshot`), or any combination.
90
130
  *
91
131
  * @example
92
132
  * ```ts
@@ -105,15 +145,46 @@ const BundleConfigSchema = z.union([
105
145
  include: PatternsSchema.optional(),
106
146
  /** Linear issues to include in the bundle. */
107
147
  linear: LinearSourceSchema.optional(),
148
+ /** Page to capture as numbered PNGs in `outDir`. */
149
+ screenshot: ScreenshotSourceSchema.optional(),
108
150
  /** Custom output file path. Defaults to `<outDir>/<bundleName>.txt`. */
109
151
  outfile: z.string().min(1).optional(),
110
- /** Include file index header in output. Defaults to true. */
111
- index: z.boolean().default(true),
152
+ /**
153
+ * Include file index header in output. Defaults to true — read as
154
+ * `index ?? true`, so screenshot-only bundles can reject an explicit value.
155
+ */
156
+ index: z.boolean().optional(),
112
157
  /** Text to prepend to bundle (e.g., review instructions for LLMs). */
113
158
  prompt: z.string().optional(),
159
+ /**
160
+ * Skipped by a full run, built when named: `srcpack <name>`. For bundles
161
+ * too slow, remote or situational to rebuild every time. A full run that
162
+ * empties `outDir` still removes their output there — preserving it
163
+ * would make emptying a growing list of exceptions (ADR 005).
164
+ */
165
+ onDemand: z.boolean().optional(),
114
166
  })
115
- .refine((bundle) => bundle.include || bundle.linear, {
116
- message: 'Bundle needs a source: "include" patterns, "linear", or both',
167
+ .superRefine((bundle, ctx) => {
168
+ if (bundle.include || bundle.linear) return;
169
+ if (!bundle.screenshot) {
170
+ ctx.addIssue({
171
+ code: "custom",
172
+ message:
173
+ 'Bundle needs a source: "include" patterns, "linear", or "screenshot"',
174
+ });
175
+ return;
176
+ }
177
+ // These shape a text file that a screenshot-only bundle never writes, so
178
+ // setting one is a misunderstanding rather than a harmless no-op
179
+ for (const key of ["prompt", "index", "outfile"] as const) {
180
+ if (bundle[key] !== undefined) {
181
+ ctx.addIssue({
182
+ code: "custom",
183
+ path: [key],
184
+ message: `"${key}" applies to the text file, and a screenshot-only bundle writes none. Add "include" or "linear", or remove it`,
185
+ });
186
+ }
187
+ }
117
188
  }),
118
189
  ]);
119
190
 
@@ -164,10 +235,8 @@ const ConfigSchema = z
164
235
  bundles: z.record(BundleNameSchema, BundleConfigSchema),
165
236
  })
166
237
  .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.
238
+ // Reject misspelled exclusions so a bundle intended to stay local cannot
239
+ // silently upload. Removed bundles must also be removed from this list.
171
240
  const uploads = config.upload
172
241
  ? Array.isArray(config.upload)
173
242
  ? config.upload
@@ -193,6 +262,7 @@ const ConfigSchema = z
193
262
 
194
263
  export type UploadConfig = z.infer<typeof UploadConfigSchema>;
195
264
  export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
265
+ export type ScreenshotSource = z.infer<typeof ScreenshotSourceSchema>;
196
266
  export type BundleConfig = z.infer<typeof BundleConfigSchema>;
197
267
  export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
198
268
  export type Config = z.infer<typeof ConfigSchema>;
package/src/fs.ts ADDED
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { realpath, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+
6
+ /**
7
+ * Compare paths using Unicode NFC followed by lowercase, on every platform.
8
+ * Normalizing first gives equivalent spellings the same input to lowercasing.
9
+ * This catches case and normalization collisions even for unwritten outputs
10
+ * and unresolved final entries, where realpath cannot canonicalize spelling.
11
+ * Applying one rule everywhere prevents a config from passing on Linux and
12
+ * overwriting a bundle on a case-insensitive filesystem (ADR 004).
13
+ *
14
+ * Comparison only: I/O retains the original spelling. Ownership and deletion
15
+ * use exact matches so folding cannot widen what srcpack removes.
16
+ */
17
+ export function pathKey(path: string): string {
18
+ return path.normalize("NFC").toLowerCase();
19
+ }
20
+
21
+ /**
22
+ * Where a path physically is, with symlinks resolved. Destructive decisions are
23
+ * made on this rather than the lexical path: `.srcpack -> ../shared` is inside
24
+ * the project by name and somewhere else in fact, and it is the somewhere else
25
+ * whose contents `rm` would take.
26
+ *
27
+ * Resolves as much of the path as exists, however deep that is. Stopping at the
28
+ * immediate parent would call `.srcpack/nested/x.txt` and `alias/nested/x.txt`
29
+ * different files until `mkdir -p` runs, which is one step too late to still be
30
+ * a check: aliasing is a property of the ancestors, not of when they were made.
31
+ */
32
+ export async function physicalPath(path: string): Promise<string> {
33
+ try {
34
+ return await realpath(path);
35
+ } catch {
36
+ const parent = dirname(path);
37
+ // dirname("/") === "/": nothing above the filesystem root left to resolve
38
+ if (parent === path) return path;
39
+ return join(await physicalPath(parent), basename(path));
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Where `rename` puts a directory entry: ancestors resolved, the entry itself
45
+ * left alone. Writing replaces the entry instead of following it, so a bundle
46
+ * whose output is a symlink is identified as the link rather than its target —
47
+ * writing to the link path and to its target produces two separate files.
48
+ */
49
+ export async function entryPath(path: string): Promise<string> {
50
+ return join(await physicalPath(dirname(path)), basename(path));
51
+ }
52
+
53
+ export function isInside(path: string, dir: string): boolean {
54
+ const rel = relative(dir, path);
55
+ // Compare against ".." as a whole segment — "..cache/x" is a child, not an escape
56
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
57
+ }
58
+
59
+ /**
60
+ * Write a file by replacing the directory entry rather than the file behind
61
+ * it. Writing in place follows a symlink sitting at the output path, so
62
+ * `.srcpack/web.txt -> ~/.ssh/config` would be written through; rename replaces
63
+ * the link itself. It also makes each file appear whole or not at all.
64
+ *
65
+ * The temp name carries the pid so two runs can't rename each other's file.
66
+ */
67
+ export async function writeFileAtomic(
68
+ path: string,
69
+ data: string | Uint8Array,
70
+ ): Promise<void> {
71
+ const temp = `${path}.${process.pid}.tmp`;
72
+ try {
73
+ await writeFile(temp, data);
74
+ await rename(temp, path);
75
+ } finally {
76
+ // A failed write or rename would otherwise leave a partial file behind:
77
+ // stale inside outDir, and bundled by the next run beside a custom outfile.
78
+ await rm(temp, { force: true });
79
+ }
80
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,238 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import type { BundleResult } from "./bundle.ts";
5
+ import { ConfigError, type BundleConfig } from "./config.ts";
6
+ import { entryPath, pathKey, physicalPath } from "./fs.ts";
7
+ import {
8
+ isImageOf,
9
+ toScreenshotTarget,
10
+ type CapturedPage,
11
+ type ScreenshotTarget,
12
+ } from "./screenshot.ts";
13
+
14
+ // Check output destinations before resolving sources or launching a browser.
15
+ // Retain each active plan through writing, reporting and upload.
16
+
17
+ /** Where a bundle writes. */
18
+ export interface PlannedBundle {
19
+ name: string;
20
+ source: BundleConfig;
21
+ /** Absolute path of the text output. Absent for a screenshot-only bundle. */
22
+ text?: { outfile: string };
23
+ /** Page to capture into `<dir>/<name>-NN.png`. */
24
+ images?: { target: ScreenshotTarget; dir: string };
25
+ }
26
+
27
+ /** What a bundle produced, ready to write. */
28
+ export interface ResolvedBundle {
29
+ plan: PlannedBundle;
30
+ text?: BundleResult;
31
+ images?: CapturedPage;
32
+ }
33
+
34
+ export interface BundleSelection {
35
+ /** Bundles this run builds, in the order named or configured. */
36
+ names: string[];
37
+ /** On-demand bundles a full run left out. Empty when bundles are named. */
38
+ skipped: string[];
39
+ }
40
+
41
+ function isOnDemand(config: BundleConfig): boolean {
42
+ return (
43
+ typeof config === "object" &&
44
+ !Array.isArray(config) &&
45
+ config.onDemand === true
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Pick the bundles a run builds. Naming a bundle always builds it; a full run
51
+ * builds everything not marked `onDemand`.
52
+ */
53
+ export function selectBundles(
54
+ bundles: Record<string, BundleConfig>,
55
+ requested: string[],
56
+ ): BundleSelection {
57
+ for (const name of requested) {
58
+ // hasOwn, not `in`: `srcpack toString` would otherwise find Object.prototype
59
+ if (!Object.hasOwn(bundles, name)) {
60
+ throw new ConfigError(`Unknown bundle: ${name}`);
61
+ }
62
+ }
63
+ if (requested.length) {
64
+ return { names: [...new Set(requested)], skipped: [] };
65
+ }
66
+
67
+ const names: string[] = [];
68
+ const skipped: string[] = [];
69
+ for (const [name, config] of Object.entries(bundles)) {
70
+ (isOnDemand(config) ? skipped : names).push(name);
71
+ }
72
+ return { names, skipped };
73
+ }
74
+
75
+ export interface OutputPlan {
76
+ /** The bundles this run builds, in order. */
77
+ bundles: PlannedBundle[];
78
+ /**
79
+ * Absolute paths srcpack writes, never to be bundled: every configured text
80
+ * output and every active one, each in lexical and entry spelling. A glob
81
+ * rooted at a symlink yields lexical paths, one rooted at the real directory
82
+ * yields physical ones, and either can name a file the previous run wrote.
83
+ *
84
+ * Image families need no entry: they always live in outDir, which the CLI
85
+ * excludes, and when outDir holds the root a PNG is skipped as binary anyway.
86
+ */
87
+ ownOutputs: string[];
88
+ }
89
+
90
+ function planBundle(
91
+ name: string,
92
+ source: BundleConfig,
93
+ root: string,
94
+ outDir: string,
95
+ ): PlannedBundle {
96
+ const object =
97
+ typeof source === "object" && !Array.isArray(source) ? source : undefined;
98
+ const plan: PlannedBundle = { name, source };
99
+ // Every bundle writes text unless a screenshot is all it declares
100
+ if (!object || object.include || object.linear) {
101
+ const outfile = object?.outfile ?? join(outDir, `${name}.txt`);
102
+ plan.text = { outfile: resolve(root, outfile) };
103
+ }
104
+ // Keep image cleanup within outDir, independent of any text outfile.
105
+ if (object?.screenshot) {
106
+ plan.images = {
107
+ target: toScreenshotTarget(object.screenshot),
108
+ dir: resolve(root, outDir),
109
+ };
110
+ }
111
+ return plan;
112
+ }
113
+
114
+ /**
115
+ * A claim on a destination: a text file, or an image family — a directory
116
+ * plus a name prefix. Compared folded (`pathKey`) and physically.
117
+ *
118
+ * A text file's directory is its entry path's parent, as for any output. A
119
+ * family's directory resolves fully: it is an ancestor of every PNG, and
120
+ * `rename` replaces only the last component (ADR 004).
121
+ */
122
+ interface Claim {
123
+ owner: string;
124
+ kind: "file" | "family";
125
+ dir: string;
126
+ /** Folded file name, or folded bundle name for a family. */
127
+ name: string;
128
+ /** Absolute path shown in a collision message. */
129
+ display: string;
130
+ }
131
+
132
+ function overlaps(a: Claim, b: Claim): boolean {
133
+ if (a.dir !== b.dir) return false;
134
+ if (a.kind === b.kind) return a.name === b.name;
135
+ const [file, family] = a.kind === "file" ? [a, b] : [b, a];
136
+ return isImageOf(family.name, file.name);
137
+ }
138
+
139
+ /** `entry` is the text output's entry path, resolved once by the caller. */
140
+ async function claimsOf(
141
+ plan: PlannedBundle,
142
+ entry: string | undefined,
143
+ ): Promise<Claim[]> {
144
+ const claims: Claim[] = [];
145
+ if (plan.text && entry) {
146
+ claims.push({
147
+ owner: plan.name,
148
+ kind: "file",
149
+ dir: pathKey(dirname(entry)),
150
+ name: pathKey(basename(entry)),
151
+ display: plan.text.outfile,
152
+ });
153
+ }
154
+ if (plan.images) {
155
+ claims.push({
156
+ owner: plan.name,
157
+ kind: "family",
158
+ dir: pathKey(await physicalPath(plan.images.dir)),
159
+ name: pathKey(plan.name),
160
+ display: join(plan.images.dir, `${plan.name}-NN.png`),
161
+ });
162
+ }
163
+ return claims;
164
+ }
165
+
166
+ function collision(first: Claim, second: Claim, root: string): ConfigError {
167
+ // Name the later bundle's destination, unless only the earlier one is a file:
168
+ // a file is what the user can change
169
+ const { display } =
170
+ first.kind === "file" && second.kind === "family" ? first : second;
171
+ const where = relative(root, display) || display;
172
+ if (first.owner === second.owner) {
173
+ return new ConfigError(
174
+ `Bundle "${first.owner}" writes its text and its images to "${where}". Give it another outfile.`,
175
+ );
176
+ }
177
+ return new ConfigError(
178
+ `Bundles "${first.owner}" and "${second.owner}" both write to "${where}". ` +
179
+ (first.kind === "family" && second.kind === "family"
180
+ ? "Rename one of them."
181
+ : "Give one of them its own outfile."),
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Derive where every bundle writes and reject two bundles sharing a file.
187
+ *
188
+ * Resolve directory aliases and compare destinations with `pathKey` to prevent
189
+ * silent overwrites. Text files also collide with numbered images in the same
190
+ * directory, including their own bundle's images.
191
+ *
192
+ * Configured bundles are checked against each other on every run, so a config
193
+ * error doesn't depend on what was asked for. Active bundles — the ones `active`
194
+ * names, ad-hoc included — are checked against configured bundles of a
195
+ * different name: an ad-hoc bundle shadows a configured bundle of its own name
196
+ * (`--staged` over a `staged` bundle) but never overwrites another's output.
197
+ * Active bundles need no check among themselves: they are either configured,
198
+ * and already checked, or a single ad-hoc bundle.
199
+ */
200
+ export async function planOutputs(
201
+ root: string,
202
+ outDir: string,
203
+ configured: Record<string, BundleConfig>,
204
+ active: [name: string, source: BundleConfig][],
205
+ ): Promise<OutputPlan> {
206
+ const ownOutputs = new Set<string>();
207
+ const claims: Claim[] = [];
208
+
209
+ const claim = async (plan: PlannedBundle, shadowsOwnName: boolean) => {
210
+ const entry = plan.text && (await entryPath(plan.text.outfile));
211
+ for (const next of await claimsOf(plan, entry)) {
212
+ const taken = claims.find(
213
+ (prior) =>
214
+ !(shadowsOwnName && prior.owner === next.owner) &&
215
+ overlaps(prior, next),
216
+ );
217
+ if (taken) throw collision(taken, next, root);
218
+ claims.push(next);
219
+ }
220
+ if (plan.text && entry) {
221
+ ownOutputs.add(plan.text.outfile);
222
+ ownOutputs.add(entry);
223
+ }
224
+ };
225
+
226
+ for (const [name, source] of Object.entries(configured)) {
227
+ await claim(planBundle(name, source, root, outDir), false);
228
+ }
229
+
230
+ const bundles: PlannedBundle[] = [];
231
+ for (const [name, source] of active) {
232
+ const plan = planBundle(name, source, root, outDir);
233
+ await claim(plan, true);
234
+ bundles.push(plan);
235
+ }
236
+
237
+ return { bundles, ownOutputs: [...ownOutputs] };
238
+ }