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/cli.ts CHANGED
@@ -1,33 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: MIT
3
3
 
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";
4
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
22
6
  import ora from "ora";
23
- import { bundleOne, pathKey, type BundleResult } from "./bundle.ts";
7
+ import { parseCliArgs, UsageError } from "./args.ts";
8
+ import { bundleOne, type BundleResult } from "./bundle.ts";
24
9
  import {
25
10
  ConfigError,
26
11
  loadConfig,
27
12
  parseConfig,
28
- type BundleConfig,
29
13
  type UploadConfig,
30
14
  } from "./config.ts";
15
+ import { isInside, physicalPath, writeFileAtomic } from "./fs.ts";
31
16
  import {
32
17
  ensureAuthenticated,
33
18
  login,
@@ -38,12 +23,14 @@ import {
38
23
  import { GitError } from "./git.ts";
39
24
  import { runInit } from "./init.ts";
40
25
  import { LinearError } from "./linear.ts";
41
-
42
- interface BundleOutput {
43
- name: string;
44
- outfile: string;
45
- result: BundleResult;
46
- }
26
+ import { planOutputs, selectBundles, type ResolvedBundle } from "./plan.ts";
27
+ import {
28
+ createCapturer,
29
+ imageFileName,
30
+ isImageOf,
31
+ ScreenshotError,
32
+ type CapturedPage,
33
+ } from "./screenshot.ts";
47
34
 
48
35
  function sumLines(result: BundleResult): number {
49
36
  return result.index.reduce((sum, entry) => sum + entry.lines, 0);
@@ -57,70 +44,35 @@ function plural(n: number, singular: string, pluralForm?: string): string {
57
44
  return n === 1 ? singular : (pluralForm ?? singular + "s");
58
45
  }
59
46
 
60
- /** The directory srcpack owns by convention, and the only one it clears unasked. */
61
- const DEFAULT_OUT_DIR = ".srcpack";
62
-
63
47
  /**
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.
48
+ * "3 bundles, 42 files, 3,120 lines, 15 images". Files and lines appear only
49
+ * when some bundle wrote text; images aren't counted in a dry run, which never
50
+ * renders them.
73
51
  */
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));
52
+ function formatCounts(outputs: ResolvedBundle[]): string {
53
+ const texts = outputs.flatMap((o) => (o.text ? [o.text] : []));
54
+ const files = texts.reduce((sum, t) => sum + t.index.length, 0);
55
+ const lines = texts.reduce((sum, t) => sum + sumLines(t), 0);
56
+ const images = outputs.reduce(
57
+ (sum, o) => sum + (o.images?.images.length ?? 0),
58
+ 0,
59
+ );
60
+ const counts = [`${outputs.length} ${plural(outputs.length, "bundle")}`];
61
+ if (texts.length) {
62
+ counts.push(
63
+ `${formatNumber(files)} ${plural(files, "file")}`,
64
+ `${formatNumber(lines)} ${plural(lines, "line")}`,
65
+ );
82
66
  }
67
+ if (images) counts.push(`${formatNumber(images)} ${plural(images, "image")}`);
68
+ return counts.join(", ");
83
69
  }
84
70
 
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
-
95
- function isInside(path: string, dir: string): boolean {
96
- const rel = relative(dir, path);
97
- // Compare against ".." as a whole segment — "..cache/x" is a child, not an escape
98
- return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
99
- }
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
- }
71
+ /** The directory srcpack owns by convention, and the only one it clears unasked. */
72
+ const DEFAULT_OUT_DIR = ".srcpack";
120
73
 
121
74
  /**
122
75
  * Empty a directory while preserving specified entries (e.g., `.git`).
123
- * Uses `force: true` to handle read-only or in-use files.
124
76
  */
125
77
  async function emptyDirectory(dir: string, skip: string[] = []): Promise<void> {
126
78
  let entries: string[];
@@ -144,83 +96,30 @@ async function emptyDirectory(dir: string, skip: string[] = []): Promise<void> {
144
96
  }
145
97
 
146
98
  /**
147
- * One-off bundle from a `git:` source instead of a configured one. Needs no
148
- * config file reviewing what you just wrote is throwaway, not worth committing.
99
+ * Write a bundle's images, then remove the stale ones: a page that shrank from
100
+ * six images to four would otherwise leave `-04` and `-05` to be attached with
101
+ * the new set. Stale files match exactly, never folded — folding could only
102
+ * widen what gets deleted (ADR 004). Returns the paths written, in order.
149
103
  */
150
- interface AdHocBundle {
151
- name: string;
152
- patterns: string[];
153
- }
154
-
155
- const AD_HOC_FLAGS = ["--staged", "--dirty", "--since"] as const;
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
-
190
- function parseAdHocBundle(args: string[]): AdHocBundle | null {
191
- const flags = AD_HOC_FLAGS.filter((flag) => args.includes(flag));
192
-
193
- if (flags.length > 1) {
194
- console.error(`Cannot combine ${flags.join(" and ")}.`);
195
- process.exit(1);
196
- }
197
-
198
- switch (flags[0]) {
199
- case "--staged":
200
- return { name: "staged", patterns: ["git:staged"] };
201
- case "--dirty":
202
- return { name: "dirty", patterns: ["git:dirty"] };
203
- case "--since": {
204
- const rev = args[args.indexOf("--since") + 1];
205
- if (!rev || rev.startsWith("-")) {
206
- console.error("Missing revision: --since <rev> (e.g. --since main)");
207
- process.exit(1);
208
- }
209
- // A range pins both endpoints, so it would silently drop the uncommitted
210
- // work --since promises. Ranges belong in a config `git:` source.
211
- if (rev.includes("..")) {
212
- console.error(
213
- `--since takes a revision, not a range: "${rev}". Use a git: source in your config for ranges.`,
214
- );
215
- process.exit(1);
216
- }
217
- // `git diff` can't see untracked files, but a new file written on this
218
- // branch is part of "what changed since <rev>"
219
- return { name: "since", patterns: [`git:${rev}`, "git:untracked"] };
220
- }
221
- default:
222
- return null;
104
+ async function writeImages(
105
+ name: string,
106
+ dir: string,
107
+ captured: CapturedPage,
108
+ ): Promise<string[]> {
109
+ await mkdir(dir, { recursive: true });
110
+ const highest = Math.max(...captured.images.map((image) => image.index));
111
+ const written: string[] = [];
112
+ for (const { index, data } of captured.images) {
113
+ const path = join(dir, imageFileName(name, index, highest));
114
+ await writeFileAtomic(path, data);
115
+ written.push(path);
223
116
  }
117
+ const current = new Set(written.map((path) => basename(path)));
118
+ const stale = (await readdir(dir)).filter(
119
+ (file) => isImageOf(name, file) && !current.has(file),
120
+ );
121
+ await Promise.all(stale.map((file) => rm(join(dir, file), { force: true })));
122
+ return written;
224
123
  }
225
124
 
226
125
  /** Resolves to the package root from both `src/cli.ts` and `dist/cli.js`. */
@@ -245,24 +144,28 @@ async function main() {
245
144
  srcpack - Bundle and upload tool
246
145
 
247
146
  Usage:
248
- npx srcpack Bundle all, upload if configured
249
- npx srcpack web api Bundle specific bundles only
250
- npx srcpack --staged Bundle staged changes (no config needed)
251
- npx srcpack --dry-run Preview bundles without writing files
252
- npx srcpack --no-upload Bundle only, skip upload
253
- npx srcpack init Interactive config setup
254
- npx srcpack login Authenticate with Google Drive
147
+ npx srcpack Bundle all except on-demand, upload if configured
148
+ npx srcpack web api Bundle specific bundles only
149
+ npx srcpack --staged Bundle staged changes (no config needed)
150
+ npx srcpack --screenshot localhost:5173
151
+ Capture a page as PNGs (no config needed)
152
+ npx srcpack --dry-run Preview bundles without writing files
153
+ npx srcpack --no-upload Bundle only, skip upload
154
+ npx srcpack init Interactive config setup
155
+ npx srcpack login Authenticate with Google Drive
255
156
 
256
157
  Options:
257
- --staged Bundle staged changes only
258
- --dirty Bundle staged, unstaged, and untracked changes
259
- --since <rev> Bundle changes since <rev> (e.g. --since main)
260
- --dry-run Preview bundles without writing files
261
- --emptyOutDir Empty output directory before writing
262
- --no-emptyOutDir Keep existing files in output directory
263
- --no-upload Skip uploading to cloud storage
264
- -h, --help Show this help message
265
- -v, --version Show version
158
+ --staged Bundle staged changes only
159
+ --dirty Bundle staged, unstaged, and untracked changes
160
+ --since <rev> Bundle changes since <rev> (e.g. --since main)
161
+ --screenshot <url> Capture a page as numbered PNGs
162
+ --viewport <name> desktop (default) or mobile, with --screenshot
163
+ --dry-run Preview bundles without writing files
164
+ --emptyOutDir Empty output directory before writing
165
+ --no-emptyOutDir Skip clearing the output directory
166
+ --no-upload Skip uploading to cloud storage
167
+ -h, --help Show this help message
168
+ -v, --version Show version
266
169
  `);
267
170
  return;
268
171
  }
@@ -280,27 +183,13 @@ Options:
280
183
  return;
281
184
  }
282
185
 
283
- assertKnownFlags(args);
284
-
285
- const dryRun = args.includes("--dry-run");
286
- const noUpload = args.includes("--no-upload");
287
- // CLI flags: --emptyOutDir forces true, --no-emptyOutDir forces false
288
- const emptyOutDirFlag = args.includes("--emptyOutDir")
289
- ? true
290
- : args.includes("--no-emptyOutDir")
291
- ? false
292
- : undefined;
293
- const adHoc = parseAdHocBundle(args);
294
- const sinceIndex = args.indexOf("--since");
295
- const sinceValueIndex = sinceIndex === -1 ? -1 : sinceIndex + 1;
296
- const requestedBundles = args.filter(
297
- (arg, i) => !arg.startsWith("-") && i !== sinceValueIndex,
298
- );
299
-
300
- if (adHoc && requestedBundles.length) {
301
- console.error(`Cannot combine --${adHoc.name} with named bundles.`);
302
- process.exit(1);
303
- }
186
+ const {
187
+ bundles: requestedBundles,
188
+ adHoc,
189
+ dryRun,
190
+ emptyOutDir: emptyOutDirFlag,
191
+ upload,
192
+ } = parseCliArgs(args);
304
193
 
305
194
  let config = await loadConfig();
306
195
 
@@ -315,23 +204,18 @@ Options:
315
204
  config = parseConfig({ bundles: {} });
316
205
  }
317
206
 
318
- const bundles = adHoc ? { [adHoc.name]: adHoc.patterns } : config.bundles;
207
+ const bundles = adHoc ? { [adHoc.name]: adHoc.source } : config.bundles;
319
208
 
320
- // Determine which bundles to process
321
- const bundleNames = requestedBundles.length
322
- ? requestedBundles
323
- : Object.keys(bundles);
324
-
325
- // Validate requested bundle names exist
326
- for (const name of bundleNames) {
327
- // hasOwn, not `in`: `srcpack toString` would otherwise find Object.prototype
328
- if (!Object.hasOwn(bundles, name)) {
329
- console.error(`Unknown bundle: ${name}`);
330
- process.exit(1);
331
- }
332
- }
209
+ const { names: bundleNames, skipped } = selectBundles(
210
+ bundles,
211
+ requestedBundles,
212
+ );
213
+ const onDemandNote = skipped.length
214
+ ? `On demand: ${skipped.join(", ")}`
215
+ : undefined;
333
216
 
334
- if (bundleNames.length === 0) {
217
+ // Every bundle on demand is still a full run: it goes on to empty outDir
218
+ if (bundleNames.length === 0 && !onDemandNote) {
335
219
  console.log("No bundles configured.");
336
220
  return;
337
221
  }
@@ -382,164 +266,190 @@ Options:
382
266
  );
383
267
  }
384
268
 
385
- // srcpack never bundles what srcpack writes. Every configured outfile is
386
- // named explicitly; outDir covers stale bundles from renamed config entries
387
- // too, but not when it holds the root — that would exclude the whole project.
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];
269
+ // srcpack never bundles what srcpack writes. Every output is named
270
+ // explicitly; outDir covers stale bundles from renamed config entries too,
271
+ // but not when it holds the root — that would exclude the whole project.
272
+ const { bundles: plans, ownOutputs } = await planOutputs(
273
+ root,
274
+ config.outDir,
275
+ config.bundles,
276
+ bundleNames.map((name) => [name, bundles[name]!]),
277
+ );
278
+ if (!outDirHoldsRoot) ownOutputs.push(outDirPath, outDirPhysical);
422
279
 
423
- const outputs: BundleOutput[] = [];
280
+ const outputs: ResolvedBundle[] = [];
281
+ // Printed once the spinner stops, which would otherwise draw over them
282
+ const warnings: string[] = [];
283
+ // Images stay in memory until every bundle has resolved, so a failure in
284
+ // any capture leaves the previous run's files in place (ADR 003 run order).
285
+ // A dry run previews without a browser: counting images needs a render.
286
+ const capturer = createCapturer(root);
424
287
 
425
288
  // Process all bundles with progress
426
289
  const bundleSpinner = ora({
427
- text: `Bundling ${bundleNames[0]}...`,
290
+ text: "Bundling...",
428
291
  color: "cyan",
429
292
  }).start();
430
293
 
431
294
  try {
432
- for (let i = 0; i < bundleNames.length; i++) {
433
- const name = bundleNames[i]!;
434
- bundleSpinner.text = `Bundling ${name}... (${i + 1}/${bundleNames.length})`;
435
- const bundleConfig = bundles[name]!;
436
- let result: BundleResult;
295
+ for (let i = 0; i < plans.length; i++) {
296
+ const plan = plans[i]!;
297
+ const progress = `${plan.name}... (${i + 1}/${plans.length})`;
298
+ const output: ResolvedBundle = { plan };
437
299
  try {
438
- result = await bundleOne(bundleConfig, root, outputPaths);
300
+ if (plan.text) {
301
+ bundleSpinner.text = `Bundling ${progress}`;
302
+ output.text = await bundleOne(plan.source, root, ownOutputs);
303
+ }
304
+ if (plan.images && !dryRun) {
305
+ bundleSpinner.text = `Capturing ${progress}`;
306
+ output.images = await capturer.capture(
307
+ plan.images.target,
308
+ (message) => warnings.push(`Bundle "${plan.name}": ${message}`),
309
+ );
310
+ }
439
311
  } catch (error) {
312
+ // A dev server that isn't running fails every full run; say how to
313
+ // keep the bundle without that
314
+ if (
315
+ error instanceof ScreenshotError &&
316
+ error.unreachable &&
317
+ requestedBundles.length === 0 &&
318
+ !adHoc
319
+ ) {
320
+ error.message += " Set onDemand: true to capture it only when named.";
321
+ }
440
322
  // A config can declare many bundles; the underlying message says what
441
323
  // broke but not which bundle asked for it.
442
324
  if (
443
325
  error instanceof ConfigError ||
444
326
  error instanceof GitError ||
445
- error instanceof LinearError
327
+ error instanceof LinearError ||
328
+ error instanceof ScreenshotError
446
329
  ) {
447
- error.message = `Bundle "${name}": ${error.message}`;
330
+ error.message = `Bundle "${plan.name}": ${error.message}`;
448
331
  }
449
332
  throw error;
450
333
  }
451
- const outfile = getOutfile(bundleConfig, name, config.outDir);
452
- outputs.push({ name, outfile, result });
334
+ outputs.push(output);
453
335
  }
454
336
  } finally {
455
337
  bundleSpinner.stop();
338
+ await capturer.close();
339
+ // Also when a later bundle fails: a page-growth warning may explain it
340
+ for (const warning of warnings) console.warn(warning);
456
341
  }
457
342
 
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.
343
+ // Clear only after all sources resolve, preserving prior output on failure.
344
+ // Named runs keep other bundles; ad-hoc runs reach this only with an explicit
345
+ // --emptyOutDir. `ownOutputs` excludes prior output during resolution.
465
346
  if (emptyOutDir && !dryRun && requestedBundles.length === 0) {
466
347
  await emptyDirectory(outDirPath, [".git"]);
467
348
  }
468
349
 
350
+ if (outputs.length === 0) {
351
+ console.log(onDemandNote);
352
+ return;
353
+ }
354
+
355
+ const textResults = outputs.flatMap((o) => (o.text ? [o.text] : []));
356
+
469
357
  // Calculate column widths for aligned output
470
- const maxNameLen = Math.max(...outputs.map((o) => o.name.length));
358
+ const maxNameLen = Math.max(...outputs.map((o) => o.plan.name.length));
471
359
  const maxFilesLen = Math.max(
472
- ...outputs.map((o) => formatNumber(o.result.index.length).length),
360
+ 0,
361
+ ...textResults.map((text) => formatNumber(text.index.length).length),
473
362
  );
474
363
  const maxLinesLen = Math.max(
475
- ...outputs.map((o) => formatNumber(sumLines(o.result)).length),
364
+ 0,
365
+ ...textResults.map((text) => formatNumber(sumLines(text)).length),
476
366
  );
477
367
 
478
- // Print each bundle
368
+ // Print each bundle. A mixed bundle prints its text line, then its images.
479
369
  console.log();
480
- for (const { name, outfile, result } of outputs) {
481
- const fileCount = result.index.length;
482
- const lineCount = sumLines(result);
483
- const outPath = resolve(root, outfile);
484
-
485
- const nameCol = name.padEnd(maxNameLen);
486
- const filesCol = formatNumber(fileCount).padStart(maxFilesLen);
487
- const linesCol = formatNumber(lineCount).padStart(maxLinesLen);
488
-
489
- if (dryRun) {
490
- console.log(
491
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")}`,
492
- );
493
- for (const entry of result.index) {
494
- console.log(` ${entry.path}`);
370
+ for (const { plan, text: result, images } of outputs) {
371
+ const nameCol = plan.name.padEnd(maxNameLen);
372
+
373
+ if (plan.text && result) {
374
+ const fileCount = result.index.length;
375
+ const lineCount = sumLines(result);
376
+ const outPath = plan.text.outfile;
377
+ const filesCol = formatNumber(fileCount).padStart(maxFilesLen);
378
+ const linesCol = formatNumber(lineCount).padStart(maxLinesLen);
379
+
380
+ if (dryRun) {
381
+ console.log(
382
+ ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")}`,
383
+ );
384
+ for (const entry of result.index) {
385
+ console.log(` ${entry.path}`);
386
+ }
387
+ } else if (fileCount === 0) {
388
+ // Remove stale text only inside outDir; custom outfiles outside it survive.
389
+ if (isInside(outPath, outDirPath)) {
390
+ await rm(outPath, { force: true });
391
+ }
392
+ console.log(
393
+ ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → skipped`,
394
+ );
395
+ } else {
396
+ await mkdir(dirname(outPath), { recursive: true });
397
+ await writeFileAtomic(outPath, result.content);
398
+ const displayPath = relative(process.cwd(), outPath);
399
+ console.log(
400
+ ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${displayPath}`,
401
+ );
495
402
  }
496
- } else if (fileCount === 0) {
497
- // Drop a previous run's file so the bundle never goes stale, but only
498
- // inside outDir — a custom outfile points at a location srcpack doesn't own
499
- if (isInside(outPath, outDirPath)) {
500
- await rm(outPath, { force: true });
403
+ }
404
+
405
+ if (plan.images) {
406
+ const { target, dir } = plan.images;
407
+ if (!images) {
408
+ const pattern = join(dir, `${plan.name}-NN.png`);
409
+ console.log(
410
+ ` ${nameCol} screenshot ${target.url} ${target.viewport} → ${relative(process.cwd(), pattern)}`,
411
+ );
412
+ } else {
413
+ const written = await writeImages(plan.name, dir, images);
414
+ const first = relative(process.cwd(), written[0]!);
415
+ const range =
416
+ written.length === 1
417
+ ? first
418
+ : `${first} … ${basename(written.at(-1)!)}`;
419
+ console.log(
420
+ ` ${nameCol} ${formatNumber(written.length)} ${plural(written.length, "image")} page ${images.width}×${formatNumber(images.height)} → ${range}`,
421
+ );
501
422
  }
502
- console.log(
503
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → skipped`,
504
- );
505
- } else {
506
- await mkdir(dirname(outPath), { recursive: true });
507
- await writeBundle(outPath, result.content);
508
- const displayPath = relative(process.cwd(), outPath);
509
- console.log(
510
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${displayPath}`,
511
- );
512
423
  }
513
424
  }
514
425
 
515
- // Print summary
516
- const totalFiles = outputs.reduce((sum, o) => sum + o.result.index.length, 0);
517
- const totalLines = outputs.reduce((sum, o) => sum + sumLines(o.result), 0);
518
- const bundleWord = plural(outputs.length, "bundle");
519
- const fileWord = plural(totalFiles, "file");
520
- const lineWord = plural(totalLines, "line");
521
-
522
426
  console.log();
523
427
  if (dryRun) {
524
- console.log(
525
- `Dry run: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
526
- );
428
+ console.log(`Dry run: ${formatCounts(outputs)}`);
429
+ if (onDemandNote) console.log(onDemandNote);
527
430
  } else {
528
- console.log(
529
- `Bundled: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
530
- );
431
+ console.log(`Bundled: ${formatCounts(outputs)}`);
432
+ if (onDemandNote) console.log(onDemandNote);
531
433
 
532
434
  // Ad-hoc bundles stay local: uploading work-in-progress to Drive is not
533
435
  // what --staged asks for, and `upload.exclude` can't name a bundle the
534
436
  // config doesn't declare. Configure a named bundle to publish changes.
535
- if (config.upload && !noUpload && !adHoc) {
437
+ if (config.upload && upload && !adHoc) {
536
438
  const uploads = Array.isArray(config.upload)
537
439
  ? config.upload
538
440
  : [config.upload];
539
441
 
442
+ // Drive finds files by name and updates them in place, so a page that
443
+ // shrank would leave its old slices there with nothing to remove them.
444
+ // A mixed bundle's text file still uploads.
445
+ const local = outputs.filter((o) => o.images).map((o) => o.plan.name);
446
+ if (local.length && uploads.some(isGdriveConfigured)) {
447
+ console.log(`Images stay local: ${local.join(", ")}`);
448
+ }
449
+
540
450
  for (const uploadConfig of uploads) {
541
451
  if (isGdriveConfigured(uploadConfig)) {
542
- await handleGdriveUpload(uploadConfig, outputs, root);
452
+ await handleGdriveUpload(uploadConfig, outputs);
543
453
  }
544
454
  }
545
455
  }
@@ -619,13 +529,14 @@ function printUploadConfigHelp(): void {
619
529
 
620
530
  async function handleGdriveUpload(
621
531
  uploadConfig: UploadConfig,
622
- outputs: BundleOutput[],
623
- root: string,
532
+ outputs: ResolvedBundle[],
624
533
  ): Promise<void> {
625
534
  // Filter out excluded bundles and empty ones (never written to disk)
626
535
  const excludeSet = new Set(uploadConfig.exclude ?? []);
627
- const toUpload = outputs.filter(
628
- (o) => !excludeSet.has(o.name) && o.result.index.length > 0,
536
+ const toUpload = outputs.flatMap(({ plan, text }) =>
537
+ plan.text && text && text.index.length > 0 && !excludeSet.has(plan.name)
538
+ ? [{ name: plan.name, outfile: plan.text.outfile }]
539
+ : [],
629
540
  );
630
541
 
631
542
  if (toUpload.length === 0) {
@@ -645,10 +556,9 @@ async function handleGdriveUpload(
645
556
 
646
557
  try {
647
558
  for (let i = 0; i < toUpload.length; i++) {
648
- const output = toUpload[i]!;
649
- const filePath = resolve(root, output.outfile);
650
- uploadSpinner.text = `Uploading ${output.name}... (${i + 1}/${toUpload.length})`;
651
- const result = await uploadFile(filePath, uploadConfig);
559
+ const { name, outfile } = toUpload[i]!;
560
+ uploadSpinner.text = `Uploading ${name}... (${i + 1}/${toUpload.length})`;
561
+ const result = await uploadFile(outfile, uploadConfig);
652
562
  results.push(result);
653
563
  }
654
564
  } finally {
@@ -681,27 +591,15 @@ async function handleGdriveUpload(
681
591
  }
682
592
  }
683
593
 
684
- function getOutfile(
685
- bundleConfig: BundleConfig,
686
- name: string,
687
- outDir: string,
688
- ): string {
689
- if (
690
- typeof bundleConfig === "object" &&
691
- !Array.isArray(bundleConfig) &&
692
- bundleConfig.outfile
693
- ) {
694
- return bundleConfig.outfile;
695
- }
696
- return join(outDir, `${name}.txt`);
697
- }
698
-
699
594
  main().catch((err) => {
700
- // Config, git and Linear failures are user-facing; a stack trace adds noise
595
+ // Usage, config, git, Linear and screenshot failures are user-facing; a stack
596
+ // trace adds noise
701
597
  console.error(
702
- err instanceof ConfigError ||
598
+ err instanceof UsageError ||
599
+ err instanceof ConfigError ||
703
600
  err instanceof GitError ||
704
- err instanceof LinearError
601
+ err instanceof LinearError ||
602
+ err instanceof ScreenshotError
705
603
  ? err.message
706
604
  : err,
707
605
  );