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/src/cli.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import ora from "ora";
7
+ import { parseCliArgs, UsageError } from "./args.ts";
7
8
  import { bundleOne, type BundleResult } from "./bundle.ts";
8
9
  import {
9
10
  ConfigError,
10
11
  loadConfig,
11
12
  parseConfig,
12
- type BundleConfig,
13
13
  type UploadConfig,
14
14
  } from "./config.ts";
15
- import { GitError } from "./git.ts";
15
+ import { isInside, physicalPath, writeFileAtomic } from "./fs.ts";
16
16
  import {
17
17
  ensureAuthenticated,
18
18
  login,
@@ -20,13 +20,17 @@ import {
20
20
  uploadFile,
21
21
  type UploadResult,
22
22
  } from "./gdrive.ts";
23
+ import { GitError } from "./git.ts";
23
24
  import { runInit } from "./init.ts";
24
-
25
- interface BundleOutput {
26
- name: string;
27
- outfile: string;
28
- result: BundleResult;
29
- }
25
+ import { LinearError } from "./linear.ts";
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";
30
34
 
31
35
  function sumLines(result: BundleResult): number {
32
36
  return result.index.reduce((sum, entry) => sum + entry.lines, 0);
@@ -40,22 +44,48 @@ function plural(n: number, singular: string, pluralForm?: string): string {
40
44
  return n === 1 ? singular : (pluralForm ?? singular + "s");
41
45
  }
42
46
 
43
- function isInside(path: string, dir: string): boolean {
44
- const rel = relative(dir, path);
45
- // Compare against ".." as a whole segment "..cache/x" is a child, not an escape
46
- return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
47
+ /**
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.
51
+ */
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
+ );
66
+ }
67
+ if (images) counts.push(`${formatNumber(images)} ${plural(images, "image")}`);
68
+ return counts.join(", ");
47
69
  }
48
70
 
71
+ /** The directory srcpack owns by convention, and the only one it clears unasked. */
72
+ const DEFAULT_OUT_DIR = ".srcpack";
73
+
49
74
  /**
50
75
  * Empty a directory while preserving specified entries (e.g., `.git`).
51
- * Uses `force: true` to handle read-only or in-use files.
52
76
  */
53
77
  async function emptyDirectory(dir: string, skip: string[] = []): Promise<void> {
54
78
  let entries: string[];
55
79
  try {
56
80
  entries = await readdir(dir);
57
- } catch {
58
- return; // Directory doesn't exist, nothing to empty
81
+ } catch (error) {
82
+ // Only a missing directory is "nothing to empty". Anything else — a
83
+ // permission error, a file where a directory belongs — would otherwise be
84
+ // reported as a clean run that then writes into a directory it never read.
85
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
86
+ throw new ConfigError(
87
+ `Cannot empty outDir "${dir}": ${(error as Error).message}`,
88
+ );
59
89
  }
60
90
  const skipSet = new Set(skip);
61
91
  await Promise.all(
@@ -66,50 +96,30 @@ async function emptyDirectory(dir: string, skip: string[] = []): Promise<void> {
66
96
  }
67
97
 
68
98
  /**
69
- * One-off bundle from a `git:` source instead of a configured one. Needs no
70
- * 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.
71
103
  */
72
- interface AdHocBundle {
73
- name: string;
74
- patterns: string[];
75
- }
76
-
77
- const AD_HOC_FLAGS = ["--staged", "--dirty", "--since"] as const;
78
-
79
- function parseAdHocBundle(args: string[]): AdHocBundle | null {
80
- const flags = AD_HOC_FLAGS.filter((flag) => args.includes(flag));
81
-
82
- if (flags.length > 1) {
83
- console.error(`Cannot combine ${flags.join(" and ")}.`);
84
- process.exit(1);
85
- }
86
-
87
- switch (flags[0]) {
88
- case "--staged":
89
- return { name: "staged", patterns: ["git:staged"] };
90
- case "--dirty":
91
- return { name: "dirty", patterns: ["git:dirty"] };
92
- case "--since": {
93
- const rev = args[args.indexOf("--since") + 1];
94
- if (!rev || rev.startsWith("-")) {
95
- console.error("Missing revision: --since <rev> (e.g. --since main)");
96
- process.exit(1);
97
- }
98
- // A range pins both endpoints, so it would silently drop the uncommitted
99
- // work --since promises. Ranges belong in a config `git:` source.
100
- if (rev.includes("..")) {
101
- console.error(
102
- `--since takes a revision, not a range: "${rev}". Use a git: source in your config for ranges.`,
103
- );
104
- process.exit(1);
105
- }
106
- // `git diff` can't see untracked files, but a new file written on this
107
- // branch is part of "what changed since <rev>"
108
- return { name: "since", patterns: [`git:${rev}`, "git:untracked"] };
109
- }
110
- default:
111
- 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);
112
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;
113
123
  }
114
124
 
115
125
  /** Resolves to the package root from both `src/cli.ts` and `dist/cli.js`. */
@@ -134,59 +144,52 @@ async function main() {
134
144
  srcpack - Bundle and upload tool
135
145
 
136
146
  Usage:
137
- npx srcpack Bundle all, upload if configured
138
- npx srcpack web api Bundle specific bundles only
139
- npx srcpack --staged Bundle staged changes (no config needed)
140
- npx srcpack --dry-run Preview bundles without writing files
141
- npx srcpack --no-upload Bundle only, skip upload
142
- npx srcpack init Interactive config setup
143
- 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
144
156
 
145
157
  Options:
146
- --staged Bundle staged changes only
147
- --dirty Bundle staged, unstaged, and untracked changes
148
- --since <rev> Bundle changes since <rev> (e.g. --since main)
149
- --dry-run Preview bundles without writing files
150
- --emptyOutDir Empty output directory before bundling
151
- --no-emptyOutDir Keep existing files in output directory
152
- --no-upload Skip uploading to cloud storage
153
- -h, --help Show this help message
154
- -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
155
169
  `);
156
170
  return;
157
171
  }
158
172
 
159
173
  // Only in first position: elsewhere the word is a bundle name or a revision,
160
174
  // and `--since init` must diff against the `init` branch, not run the wizard.
161
- if (args[0] === "init") {
162
- await runInit();
163
- return;
164
- }
165
-
166
- if (args[0] === "login") {
167
- await runLogin();
175
+ if (args[0] === "init" || args[0] === "login") {
176
+ // Neither takes arguments, so anything after is a misunderstanding worth
177
+ // saying out loud rather than a flag that silently does nothing.
178
+ if (args.length > 1) {
179
+ console.error(`srcpack ${args[0]} takes no arguments.`);
180
+ process.exit(1);
181
+ }
182
+ await (args[0] === "init" ? runInit() : runLogin());
168
183
  return;
169
184
  }
170
185
 
171
- const dryRun = args.includes("--dry-run");
172
- const noUpload = args.includes("--no-upload");
173
- // CLI flags: --emptyOutDir forces true, --no-emptyOutDir forces false
174
- const emptyOutDirFlag = args.includes("--emptyOutDir")
175
- ? true
176
- : args.includes("--no-emptyOutDir")
177
- ? false
178
- : undefined;
179
- const adHoc = parseAdHocBundle(args);
180
- const sinceIndex = args.indexOf("--since");
181
- const sinceValueIndex = sinceIndex === -1 ? -1 : sinceIndex + 1;
182
- const requestedBundles = args.filter(
183
- (arg, i) => !arg.startsWith("-") && i !== sinceValueIndex,
184
- );
185
-
186
- if (adHoc && requestedBundles.length) {
187
- console.error(`Cannot combine --${adHoc.name} with named bundles.`);
188
- process.exit(1);
189
- }
186
+ const {
187
+ bundles: requestedBundles,
188
+ adHoc,
189
+ dryRun,
190
+ emptyOutDir: emptyOutDirFlag,
191
+ upload,
192
+ } = parseCliArgs(args);
190
193
 
191
194
  let config = await loadConfig();
192
195
 
@@ -201,52 +204,61 @@ Options:
201
204
  config = parseConfig({ bundles: {} });
202
205
  }
203
206
 
204
- const bundles = adHoc ? { [adHoc.name]: adHoc.patterns } : config.bundles;
207
+ const bundles = adHoc ? { [adHoc.name]: adHoc.source } : config.bundles;
205
208
 
206
- // Determine which bundles to process
207
- const bundleNames = requestedBundles.length
208
- ? requestedBundles
209
- : Object.keys(bundles);
210
-
211
- // Validate requested bundle names exist
212
- for (const name of bundleNames) {
213
- if (!(name in bundles)) {
214
- console.error(`Unknown bundle: ${name}`);
215
- process.exit(1);
216
- }
217
- }
209
+ const { names: bundleNames, skipped } = selectBundles(
210
+ bundles,
211
+ requestedBundles,
212
+ );
213
+ const onDemandNote = skipped.length
214
+ ? `On demand: ${skipped.join(", ")}`
215
+ : undefined;
218
216
 
219
- 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) {
220
219
  console.log("No bundles configured.");
221
220
  return;
222
221
  }
223
222
 
224
223
  const root = config.root;
225
224
 
226
- // Resolve emptyOutDir: CLI flag > config > auto (true if inside root).
227
- // Ad-hoc runs never empty by default — they shouldn't delete configured bundles.
225
+ // Resolve emptyOutDir: CLI flag > config > auto.
226
+ //
227
+ // Auto means only the conventional `.srcpack`: recursive deletion needs a
228
+ // directory srcpack demonstrably owns, and `outDir: "src"` reads as an
229
+ // ordinary setting while turning a bundling run into a source-tree wipe.
230
+ // Every other directory belongs to the user until they say otherwise.
231
+ //
232
+ // The comparison is physical, not lexical: `.srcpack -> ../shared` looks
233
+ // inside the project and deletes somewhere else. Ad-hoc runs never empty by
234
+ // default either — they shouldn't delete configured bundles.
235
+ // Lexical is what gets written to and excluded from bundles; physical is what
236
+ // decides ownership. Conflating them is what let a symlink redirect a delete.
237
+ const rootPath = await physicalPath(root);
228
238
  const outDirPath = resolve(root, config.outDir);
229
- const outDirInsideRoot = isInside(outDirPath, root);
230
- const emptyOutDir =
231
- emptyOutDirFlag ??
232
- (adHoc ? false : (config.emptyOutDir ?? outDirInsideRoot));
239
+ const outDirPhysical = await physicalPath(outDirPath);
240
+ const defaultOutDir = join(rootPath, DEFAULT_OUT_DIR);
233
241
 
234
- // Warn if outDir is outside root and emptyOutDir is not explicitly set
242
+ // The conventional name is a claim about a place. A `.srcpack` that resolves
243
+ // somewhere else keeps the name while writing into a directory srcpack was
244
+ // never given — and would overwrite whatever shares a filename there.
235
245
  if (
236
- !adHoc &&
237
- !outDirInsideRoot &&
238
- emptyOutDirFlag === undefined &&
239
- config.emptyOutDir === undefined
246
+ resolve(root, DEFAULT_OUT_DIR) === outDirPath &&
247
+ outDirPhysical !== defaultOutDir
240
248
  ) {
241
- console.warn(
242
- `Warning: outDir "${config.outDir}" is outside project root. ` +
243
- "Use --emptyOutDir to suppress this warning and empty the directory.",
249
+ throw new ConfigError(
250
+ `Refusing to use "${DEFAULT_OUT_DIR}": it resolves to "${outDirPhysical}", not "${defaultOutDir}". ` +
251
+ "Set outDir to that path explicitly if that is where bundles belong.",
244
252
  );
245
253
  }
246
254
 
255
+ const ownsOutDir = outDirPhysical === defaultOutDir;
256
+ const emptyOutDir =
257
+ emptyOutDirFlag ?? (adHoc ? false : (config.emptyOutDir ?? ownsOutDir));
258
+
247
259
  // `outDir: "."` resolves to the project root, where emptying deletes the
248
260
  // whole project — sources, config and all. Refuse rather than warn.
249
- const outDirHoldsRoot = isInside(root, outDirPath);
261
+ const outDirHoldsRoot = isInside(rootPath, outDirPhysical);
250
262
  if (emptyOutDir && outDirHoldsRoot) {
251
263
  throw new ConfigError(
252
264
  `Refusing to empty outDir "${config.outDir}": it contains the project root. ` +
@@ -254,116 +266,190 @@ Options:
254
266
  );
255
267
  }
256
268
 
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
- // srcpack never bundles what srcpack writes. Every configured outfile is
264
- // named explicitly; outDir covers stale bundles from renamed config entries
265
- // too, but not when it holds the root — that would exclude the whole project.
266
- const ownOutputs = Object.entries(config.bundles).map(
267
- ([name, bundleConfig]) =>
268
- resolve(root, getOutfile(bundleConfig, name, config.outDir)),
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]!]),
269
277
  );
270
- if (!outDirHoldsRoot) ownOutputs.push(outDirPath);
278
+ if (!outDirHoldsRoot) ownOutputs.push(outDirPath, outDirPhysical);
271
279
 
272
- 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);
273
287
 
274
288
  // Process all bundles with progress
275
289
  const bundleSpinner = ora({
276
- text: `Bundling ${bundleNames[0]}...`,
290
+ text: "Bundling...",
277
291
  color: "cyan",
278
292
  }).start();
279
293
 
280
294
  try {
281
- for (let i = 0; i < bundleNames.length; i++) {
282
- const name = bundleNames[i]!;
283
- bundleSpinner.text = `Bundling ${name}... (${i + 1}/${bundleNames.length})`;
284
- const bundleConfig = bundles[name]!;
285
- const result = await bundleOne(bundleConfig, root, ownOutputs);
286
- const outfile = getOutfile(bundleConfig, name, config.outDir);
287
- outputs.push({ name, outfile, result });
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 };
299
+ try {
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
+ }
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
+ }
322
+ // A config can declare many bundles; the underlying message says what
323
+ // broke but not which bundle asked for it.
324
+ if (
325
+ error instanceof ConfigError ||
326
+ error instanceof GitError ||
327
+ error instanceof LinearError ||
328
+ error instanceof ScreenshotError
329
+ ) {
330
+ error.message = `Bundle "${plan.name}": ${error.message}`;
331
+ }
332
+ throw error;
333
+ }
334
+ outputs.push(output);
288
335
  }
289
336
  } finally {
290
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);
341
+ }
342
+
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.
346
+ if (emptyOutDir && !dryRun && requestedBundles.length === 0) {
347
+ await emptyDirectory(outDirPath, [".git"]);
291
348
  }
292
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
+
293
357
  // Calculate column widths for aligned output
294
- const maxNameLen = Math.max(...outputs.map((o) => o.name.length));
358
+ const maxNameLen = Math.max(...outputs.map((o) => o.plan.name.length));
295
359
  const maxFilesLen = Math.max(
296
- ...outputs.map((o) => formatNumber(o.result.index.length).length),
360
+ 0,
361
+ ...textResults.map((text) => formatNumber(text.index.length).length),
297
362
  );
298
363
  const maxLinesLen = Math.max(
299
- ...outputs.map((o) => formatNumber(sumLines(o.result)).length),
364
+ 0,
365
+ ...textResults.map((text) => formatNumber(sumLines(text)).length),
300
366
  );
301
367
 
302
- // Print each bundle
368
+ // Print each bundle. A mixed bundle prints its text line, then its images.
303
369
  console.log();
304
- for (const { name, outfile, result } of outputs) {
305
- const fileCount = result.index.length;
306
- const lineCount = sumLines(result);
307
- const outPath = resolve(root, outfile);
308
-
309
- const nameCol = name.padEnd(maxNameLen);
310
- const filesCol = formatNumber(fileCount).padStart(maxFilesLen);
311
- const linesCol = formatNumber(lineCount).padStart(maxLinesLen);
312
-
313
- if (dryRun) {
314
- console.log(
315
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")}`,
316
- );
317
- for (const entry of result.index) {
318
- 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
+ );
319
402
  }
320
- } else if (fileCount === 0) {
321
- // Drop a previous run's file so the bundle never goes stale, but only
322
- // inside outDir — a custom outfile points at a location srcpack doesn't own
323
- if (isInside(outPath, outDirPath)) {
324
- 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
+ );
325
422
  }
326
- console.log(
327
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → skipped`,
328
- );
329
- } else {
330
- await mkdir(dirname(outPath), { recursive: true });
331
- await writeFile(outPath, result.content);
332
- const displayPath = relative(process.cwd(), outPath);
333
- console.log(
334
- ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${displayPath}`,
335
- );
336
423
  }
337
424
  }
338
425
 
339
- // Print summary
340
- const totalFiles = outputs.reduce((sum, o) => sum + o.result.index.length, 0);
341
- const totalLines = outputs.reduce((sum, o) => sum + sumLines(o.result), 0);
342
- const bundleWord = plural(outputs.length, "bundle");
343
- const fileWord = plural(totalFiles, "file");
344
- const lineWord = plural(totalLines, "line");
345
-
346
426
  console.log();
347
427
  if (dryRun) {
348
- console.log(
349
- `Dry run: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
350
- );
428
+ console.log(`Dry run: ${formatCounts(outputs)}`);
429
+ if (onDemandNote) console.log(onDemandNote);
351
430
  } else {
352
- console.log(
353
- `Bundled: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
354
- );
431
+ console.log(`Bundled: ${formatCounts(outputs)}`);
432
+ if (onDemandNote) console.log(onDemandNote);
355
433
 
356
434
  // Ad-hoc bundles stay local: uploading work-in-progress to Drive is not
357
435
  // what --staged asks for, and `upload.exclude` can't name a bundle the
358
436
  // config doesn't declare. Configure a named bundle to publish changes.
359
- if (config.upload && !noUpload && !adHoc) {
437
+ if (config.upload && upload && !adHoc) {
360
438
  const uploads = Array.isArray(config.upload)
361
439
  ? config.upload
362
440
  : [config.upload];
363
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
+
364
450
  for (const uploadConfig of uploads) {
365
451
  if (isGdriveConfigured(uploadConfig)) {
366
- await handleGdriveUpload(uploadConfig, outputs, root);
452
+ await handleGdriveUpload(uploadConfig, outputs);
367
453
  }
368
454
  }
369
455
  }
@@ -378,16 +464,6 @@ function isGdriveConfigured(config: UploadConfig): boolean {
378
464
  );
379
465
  }
380
466
 
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
467
  async function runLogin(): Promise<void> {
392
468
  let config;
393
469
  try {
@@ -453,13 +529,14 @@ function printUploadConfigHelp(): void {
453
529
 
454
530
  async function handleGdriveUpload(
455
531
  uploadConfig: UploadConfig,
456
- outputs: BundleOutput[],
457
- root: string,
532
+ outputs: ResolvedBundle[],
458
533
  ): Promise<void> {
459
534
  // Filter out excluded bundles and empty ones (never written to disk)
460
535
  const excludeSet = new Set(uploadConfig.exclude ?? []);
461
- const toUpload = outputs.filter(
462
- (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
+ : [],
463
540
  );
464
541
 
465
542
  if (toUpload.length === 0) {
@@ -479,10 +556,9 @@ async function handleGdriveUpload(
479
556
 
480
557
  try {
481
558
  for (let i = 0; i < toUpload.length; i++) {
482
- const output = toUpload[i]!;
483
- const filePath = resolve(root, output.outfile);
484
- uploadSpinner.text = `Uploading ${output.name}... (${i + 1}/${toUpload.length})`;
485
- 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);
486
562
  results.push(result);
487
563
  }
488
564
  } finally {
@@ -515,25 +591,17 @@ async function handleGdriveUpload(
515
591
  }
516
592
  }
517
593
 
518
- function getOutfile(
519
- bundleConfig: BundleConfig,
520
- name: string,
521
- outDir: string,
522
- ): string {
523
- if (
524
- typeof bundleConfig === "object" &&
525
- !Array.isArray(bundleConfig) &&
526
- bundleConfig.outfile
527
- ) {
528
- return bundleConfig.outfile;
529
- }
530
- return join(outDir, `${name}.txt`);
531
- }
532
-
533
594
  main().catch((err) => {
534
- // Config and git failures are user-facing; a stack trace only adds noise
595
+ // Usage, config, git, Linear and screenshot failures are user-facing; a stack
596
+ // trace adds noise
535
597
  console.error(
536
- err instanceof ConfigError || err instanceof GitError ? err.message : err,
598
+ err instanceof UsageError ||
599
+ err instanceof ConfigError ||
600
+ err instanceof GitError ||
601
+ err instanceof LinearError ||
602
+ err instanceof ScreenshotError
603
+ ? err.message
604
+ : err,
537
605
  );
538
606
  process.exit(1);
539
607
  });