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/README.md +22 -5
- package/dist/args.d.ts +26 -0
- package/dist/bundle.d.ts +0 -21
- package/dist/cli.js +4846 -14943
- package/dist/config.d.ts +43 -3
- package/dist/fs.d.ts +41 -0
- package/dist/index.js +423 -10459
- package/dist/plan.d.ts +64 -0
- package/dist/screenshot.d.ts +113 -0
- package/package.json +10 -1
- package/src/args.ts +221 -0
- package/src/bundle.ts +1 -24
- package/src/cli.ts +238 -340
- package/src/config.ts +79 -9
- package/src/fs.ts +80 -0
- package/src/plan.ts +238 -0
- package/src/screenshot.ts +545 -0
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
|
-
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
151
|
-
name: string
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
249
|
-
npx srcpack web api
|
|
250
|
-
npx srcpack --staged
|
|
251
|
-
npx srcpack --
|
|
252
|
-
|
|
253
|
-
npx srcpack
|
|
254
|
-
npx srcpack
|
|
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
|
|
258
|
-
--dirty
|
|
259
|
-
--since <rev>
|
|
260
|
-
--
|
|
261
|
-
--
|
|
262
|
-
--
|
|
263
|
-
--
|
|
264
|
-
-
|
|
265
|
-
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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.
|
|
207
|
+
const bundles = adHoc ? { [adHoc.name]: adHoc.source } : config.bundles;
|
|
319
208
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
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:
|
|
290
|
+
text: "Bundling...",
|
|
428
291
|
color: "cyan",
|
|
429
292
|
}).start();
|
|
430
293
|
|
|
431
294
|
try {
|
|
432
|
-
for (let i = 0; i <
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
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
|
-
|
|
360
|
+
0,
|
|
361
|
+
...textResults.map((text) => formatNumber(text.index.length).length),
|
|
473
362
|
);
|
|
474
363
|
const maxLinesLen = Math.max(
|
|
475
|
-
|
|
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 {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
526
|
-
);
|
|
428
|
+
console.log(`Dry run: ${formatCounts(outputs)}`);
|
|
429
|
+
if (onDemandNote) console.log(onDemandNote);
|
|
527
430
|
} else {
|
|
528
|
-
console.log(
|
|
529
|
-
|
|
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 &&
|
|
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
|
|
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:
|
|
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.
|
|
628
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
//
|
|
595
|
+
// Usage, config, git, Linear and screenshot failures are user-facing; a stack
|
|
596
|
+
// trace adds noise
|
|
701
597
|
console.error(
|
|
702
|
-
err instanceof
|
|
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
|
);
|