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/README.md +55 -19
- package/dist/args.d.ts +26 -0
- package/dist/bundle.d.ts +26 -5
- package/dist/cli.js +5454 -15133
- package/dist/config.d.ts +92 -11
- package/dist/fs.d.ts +41 -0
- package/dist/index.js +448 -10447
- package/dist/linear.d.ts +17 -0
- package/dist/plan.d.ts +64 -0
- package/dist/screenshot.d.ts +113 -0
- package/package.json +13 -1
- package/src/args.ts +221 -0
- package/src/bundle.ts +219 -51
- package/src/cli.ts +304 -236
- package/src/config.ts +250 -37
- package/src/fs.ts +80 -0
- package/src/linear.ts +368 -0
- package/src/plan.ts +238 -0
- package/src/screenshot.ts +545 -0
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
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
73
|
-
name: string
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
138
|
-
npx srcpack web api
|
|
139
|
-
npx srcpack --staged
|
|
140
|
-
npx srcpack --
|
|
141
|
-
|
|
142
|
-
npx srcpack
|
|
143
|
-
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
|
|
144
156
|
|
|
145
157
|
Options:
|
|
146
|
-
--staged
|
|
147
|
-
--dirty
|
|
148
|
-
--since <rev>
|
|
149
|
-
--
|
|
150
|
-
--
|
|
151
|
-
--
|
|
152
|
-
--
|
|
153
|
-
-
|
|
154
|
-
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
207
|
+
const bundles = adHoc ? { [adHoc.name]: adHoc.source } : config.bundles;
|
|
205
208
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
//
|
|
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
|
|
230
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
emptyOutDirFlag === undefined &&
|
|
239
|
-
config.emptyOutDir === undefined
|
|
246
|
+
resolve(root, DEFAULT_OUT_DIR) === outDirPath &&
|
|
247
|
+
outDirPhysical !== defaultOutDir
|
|
240
248
|
) {
|
|
241
|
-
|
|
242
|
-
`
|
|
243
|
-
"
|
|
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(
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
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:
|
|
290
|
+
text: "Bundling...",
|
|
277
291
|
color: "cyan",
|
|
278
292
|
}).start();
|
|
279
293
|
|
|
280
294
|
try {
|
|
281
|
-
for (let i = 0; i <
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
360
|
+
0,
|
|
361
|
+
...textResults.map((text) => formatNumber(text.index.length).length),
|
|
297
362
|
);
|
|
298
363
|
const maxLinesLen = Math.max(
|
|
299
|
-
|
|
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 {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
350
|
-
);
|
|
428
|
+
console.log(`Dry run: ${formatCounts(outputs)}`);
|
|
429
|
+
if (onDemandNote) console.log(onDemandNote);
|
|
351
430
|
} else {
|
|
352
|
-
console.log(
|
|
353
|
-
|
|
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 &&
|
|
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
|
|
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:
|
|
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.
|
|
462
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
//
|
|
595
|
+
// Usage, config, git, Linear and screenshot failures are user-facing; a stack
|
|
596
|
+
// trace adds noise
|
|
535
597
|
console.error(
|
|
536
|
-
err instanceof
|
|
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
|
});
|