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/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
5
|
-
import { cosmiconfig } from "cosmiconfig";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
export function expandPath(p: string): string {
|
|
@@ -18,6 +18,104 @@ const PatternsSchema = z.union([
|
|
|
18
18
|
z.array(z.string().min(1)).min(1),
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A name typed by a human and handed to a remote API. Trimmed, because a key
|
|
23
|
+
* pasted with a stray space fails as "not found" or "not authorized", which
|
|
24
|
+
* reads like the wrong key rather than the wrong whitespace.
|
|
25
|
+
*/
|
|
26
|
+
const IdentifierSchema = z.string().trim().min(1);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A bundle name, which is also a filename: the default output is
|
|
30
|
+
* `<outDir>/<name>.txt`. Unconstrained, `"../report"` writes outside `outDir`
|
|
31
|
+
* entirely, and a leading `-` names a bundle the CLI can never be asked for.
|
|
32
|
+
*/
|
|
33
|
+
const BundleNameSchema = z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(
|
|
36
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/,
|
|
37
|
+
"Bundle name must start with a letter or digit and contain only letters, digits, dot, underscore or hyphen",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Every closed object below is strict. Zod strips unknown keys by default, and
|
|
41
|
+
// a stripped key is a typo that changes behaviour without saying so: `liner`
|
|
42
|
+
// drops a bundle's issues, `emptyOutdir` hands the decision back to the
|
|
43
|
+
// automatic default, `exlude` uploads the bundle it was meant to hold back.
|
|
44
|
+
// Config files are edited by hand and read by a machine — the failure has to be
|
|
45
|
+
// loud. Adding a key is a minor version either way, so nothing is lost.
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Linear issues as a bundle source. Each issue becomes a virtual file at
|
|
49
|
+
* `linear/issues/<identifier>.md`, so it gets its own index entry and line
|
|
50
|
+
* range and can be filtered with ordinary `!` exclusions.
|
|
51
|
+
*
|
|
52
|
+
* Authentication reads `LINEAR_API_KEY` from the environment. It is
|
|
53
|
+
* deliberately not a config field: config files are committed, and the
|
|
54
|
+
* `package.json` config form cannot express `process.env`.
|
|
55
|
+
*
|
|
56
|
+
* `team` is required. A workspace-wide fetch is a footgun — it looks innocuous
|
|
57
|
+
* and can pull thousands of issues into a context window.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* bundles: {
|
|
62
|
+
* backlog: { linear: "ENG" },
|
|
63
|
+
* roadmap: { linear: { team: "ENG", project: "Roadmap" } },
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
const LinearSourceSchema = z.union([
|
|
68
|
+
/** Shorthand for `{ team: "<key>" }`. */
|
|
69
|
+
IdentifierSchema,
|
|
70
|
+
z.strictObject({
|
|
71
|
+
/** Team key — the `ENG` in issue identifier `ENG-123`. */
|
|
72
|
+
team: IdentifierSchema,
|
|
73
|
+
/** Project name. Must name exactly one project within the team. */
|
|
74
|
+
project: IdentifierSchema.optional(),
|
|
75
|
+
/** Include completed, canceled and duplicate issues. Defaults to false. */
|
|
76
|
+
includeClosed: z.boolean().default(false),
|
|
77
|
+
}),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An absolute http(s) URL. A scheme-less `localhost:5173` is called out by
|
|
82
|
+
* name: `URL` would otherwise parse it as protocol `localhost:`.
|
|
83
|
+
*/
|
|
84
|
+
const HttpUrlSchema = z.string().superRefine((value, ctx) => {
|
|
85
|
+
if (!value.includes("://")) {
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
message: 'URL needs a scheme, e.g. "http://localhost:5173/"',
|
|
89
|
+
});
|
|
90
|
+
} else if (!/^https?:\/\//i.test(value) || !URL.canParse(value)) {
|
|
91
|
+
ctx.addIssue({ code: "custom", message: "Expected an http(s) URL" });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A rendered page as numbered PNGs in `outDir`, independent of the text
|
|
97
|
+
* output path. See ADR 006 for why it is a separate source key.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* bundles: {
|
|
102
|
+
* home: { screenshot: "http://localhost:5173/", onDemand: true },
|
|
103
|
+
* phone: { screenshot: { url: "http://localhost:5173/", viewport: "mobile" } },
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
const ScreenshotSourceSchema = z.union([
|
|
108
|
+
/** Shorthand for `{ url: "<url>" }`. */
|
|
109
|
+
HttpUrlSchema,
|
|
110
|
+
z.strictObject({
|
|
111
|
+
url: HttpUrlSchema,
|
|
112
|
+
/** Defaults to "desktop". */
|
|
113
|
+
viewport: z.enum(["desktop", "mobile"]).optional(),
|
|
114
|
+
/** CSS selectors hidden during capture: cookie banners, chat widgets. */
|
|
115
|
+
hide: z.array(z.string().min(1)).optional(),
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
|
|
21
119
|
/**
|
|
22
120
|
* Bundle configuration. Accepts a string pattern, array of patterns, or object.
|
|
23
121
|
* Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
|
|
@@ -27,24 +125,67 @@ const PatternsSchema = z.union([
|
|
|
27
125
|
* `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
|
|
28
126
|
* `git:main`, `git:HEAD~3`).
|
|
29
127
|
*
|
|
128
|
+
* The object form takes files (`include`), Linear issues (`linear`), page
|
|
129
|
+
* screenshots (`screenshot`), or any combination.
|
|
130
|
+
*
|
|
30
131
|
* @example
|
|
31
132
|
* ```ts
|
|
32
|
-
* bundles: {
|
|
133
|
+
* bundles: {
|
|
134
|
+
* review: ["git:staged", "!bun.lock"],
|
|
135
|
+
* planning: { include: ["docs/**"], linear: { team: "ENG" } },
|
|
136
|
+
* }
|
|
33
137
|
* ```
|
|
34
138
|
*/
|
|
35
139
|
const BundleConfigSchema = z.union([
|
|
36
140
|
z.string().min(1),
|
|
37
141
|
z.array(z.string().min(1)).min(1),
|
|
38
|
-
z
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
142
|
+
z
|
|
143
|
+
.strictObject({
|
|
144
|
+
/** Glob patterns to include in the bundle. */
|
|
145
|
+
include: PatternsSchema.optional(),
|
|
146
|
+
/** Linear issues to include in the bundle. */
|
|
147
|
+
linear: LinearSourceSchema.optional(),
|
|
148
|
+
/** Page to capture as numbered PNGs in `outDir`. */
|
|
149
|
+
screenshot: ScreenshotSourceSchema.optional(),
|
|
150
|
+
/** Custom output file path. Defaults to `<outDir>/<bundleName>.txt`. */
|
|
151
|
+
outfile: z.string().min(1).optional(),
|
|
152
|
+
/**
|
|
153
|
+
* Include file index header in output. Defaults to true — read as
|
|
154
|
+
* `index ?? true`, so screenshot-only bundles can reject an explicit value.
|
|
155
|
+
*/
|
|
156
|
+
index: z.boolean().optional(),
|
|
157
|
+
/** Text to prepend to bundle (e.g., review instructions for LLMs). */
|
|
158
|
+
prompt: z.string().optional(),
|
|
159
|
+
/**
|
|
160
|
+
* Skipped by a full run, built when named: `srcpack <name>`. For bundles
|
|
161
|
+
* too slow, remote or situational to rebuild every time. A full run that
|
|
162
|
+
* empties `outDir` still removes their output there — preserving it
|
|
163
|
+
* would make emptying a growing list of exceptions (ADR 005).
|
|
164
|
+
*/
|
|
165
|
+
onDemand: z.boolean().optional(),
|
|
166
|
+
})
|
|
167
|
+
.superRefine((bundle, ctx) => {
|
|
168
|
+
if (bundle.include || bundle.linear) return;
|
|
169
|
+
if (!bundle.screenshot) {
|
|
170
|
+
ctx.addIssue({
|
|
171
|
+
code: "custom",
|
|
172
|
+
message:
|
|
173
|
+
'Bundle needs a source: "include" patterns, "linear", or "screenshot"',
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// These shape a text file that a screenshot-only bundle never writes, so
|
|
178
|
+
// setting one is a misunderstanding rather than a harmless no-op
|
|
179
|
+
for (const key of ["prompt", "index", "outfile"] as const) {
|
|
180
|
+
if (bundle[key] !== undefined) {
|
|
181
|
+
ctx.addIssue({
|
|
182
|
+
code: "custom",
|
|
183
|
+
path: [key],
|
|
184
|
+
message: `"${key}" applies to the text file, and a screenshot-only bundle writes none. Add "include" or "linear", or remove it`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}),
|
|
48
189
|
]);
|
|
49
190
|
|
|
50
191
|
/**
|
|
@@ -61,39 +202,67 @@ const BundleConfigSchema = z.union([
|
|
|
61
202
|
* }
|
|
62
203
|
* ```
|
|
63
204
|
*/
|
|
64
|
-
const UploadConfigSchema = z.
|
|
205
|
+
const UploadConfigSchema = z.strictObject({
|
|
65
206
|
/** Upload provider. Currently only "gdrive" is supported. */
|
|
66
207
|
provider: z.literal("gdrive"),
|
|
67
208
|
/** Google Drive folder ID to upload files to. If omitted, uploads to root. */
|
|
68
|
-
folderId:
|
|
209
|
+
folderId: IdentifierSchema.optional(),
|
|
69
210
|
/** OAuth 2.0 client ID from Google Cloud Console. */
|
|
70
|
-
clientId:
|
|
211
|
+
clientId: IdentifierSchema,
|
|
71
212
|
/** OAuth 2.0 client secret from Google Cloud Console. */
|
|
72
|
-
clientSecret:
|
|
213
|
+
clientSecret: IdentifierSchema,
|
|
73
214
|
/** Bundle names to skip during upload. Supports exact names only. */
|
|
74
215
|
exclude: z.array(z.string()).optional(),
|
|
75
216
|
});
|
|
76
217
|
|
|
77
218
|
/** Root configuration for srcpack. */
|
|
78
|
-
const ConfigSchema = z
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
219
|
+
const ConfigSchema = z
|
|
220
|
+
.strictObject({
|
|
221
|
+
/**
|
|
222
|
+
* Project root directory. Can be absolute or relative to CWD.
|
|
223
|
+
* @default process.cwd()
|
|
224
|
+
*/
|
|
225
|
+
root: z.string().default(""),
|
|
226
|
+
/** Output directory for bundle files (relative to root). Defaults to ".srcpack". */
|
|
227
|
+
outDir: z.string().default(".srcpack"),
|
|
228
|
+
/** Empty outDir before writing. Automatic only for the default `.srcpack`. */
|
|
229
|
+
emptyOutDir: z.boolean().optional(),
|
|
230
|
+
/** Upload configuration for cloud storage. Single destination or array. */
|
|
231
|
+
upload: z
|
|
232
|
+
.union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)])
|
|
233
|
+
.optional(),
|
|
234
|
+
/** Named bundles mapping bundle name to glob patterns or config object. */
|
|
235
|
+
bundles: z.record(BundleNameSchema, BundleConfigSchema),
|
|
236
|
+
})
|
|
237
|
+
.superRefine((config, ctx) => {
|
|
238
|
+
// Reject misspelled exclusions so a bundle intended to stay local cannot
|
|
239
|
+
// silently upload. Removed bundles must also be removed from this list.
|
|
240
|
+
const uploads = config.upload
|
|
241
|
+
? Array.isArray(config.upload)
|
|
242
|
+
? config.upload
|
|
243
|
+
: [config.upload]
|
|
244
|
+
: [];
|
|
245
|
+
const names = new Set(Object.keys(config.bundles));
|
|
246
|
+
|
|
247
|
+
uploads.forEach((upload, i) => {
|
|
248
|
+
const path = Array.isArray(config.upload)
|
|
249
|
+
? ["upload", i, "exclude"]
|
|
250
|
+
: ["upload", "exclude"];
|
|
251
|
+
for (const name of upload.exclude ?? []) {
|
|
252
|
+
if (!names.has(name)) {
|
|
253
|
+
ctx.addIssue({
|
|
254
|
+
code: "custom",
|
|
255
|
+
path,
|
|
256
|
+
message: `Unknown bundle "${name}"`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
95
262
|
|
|
96
263
|
export type UploadConfig = z.infer<typeof UploadConfigSchema>;
|
|
264
|
+
export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
|
|
265
|
+
export type ScreenshotSource = z.infer<typeof ScreenshotSourceSchema>;
|
|
97
266
|
export type BundleConfig = z.infer<typeof BundleConfigSchema>;
|
|
98
267
|
export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
|
|
99
268
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
@@ -110,13 +279,57 @@ export class ConfigError extends Error {
|
|
|
110
279
|
}
|
|
111
280
|
}
|
|
112
281
|
|
|
282
|
+
/** A zod issue, plus the nested issues a union or a bad record key carries. */
|
|
283
|
+
interface Issue {
|
|
284
|
+
code: string;
|
|
285
|
+
message: string;
|
|
286
|
+
path: PropertyKey[];
|
|
287
|
+
/** One entry per union branch. */
|
|
288
|
+
errors?: Issue[][];
|
|
289
|
+
/** Why a record key was rejected. */
|
|
290
|
+
issues?: Issue[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Reduce an issue to the leaves that actually explain it. Both wrappers say
|
|
295
|
+
* nothing on their own — "Invalid input", "Invalid key in record" — while the
|
|
296
|
+
* reason sits one level down.
|
|
297
|
+
*/
|
|
298
|
+
function flatten(issue: Issue, prefix: PropertyKey[] = []): Issue[] {
|
|
299
|
+
const path = [...prefix, ...issue.path];
|
|
300
|
+
const nested =
|
|
301
|
+
issue.code === "invalid_union"
|
|
302
|
+
? issue.errors?.flat()
|
|
303
|
+
: issue.code === "invalid_key"
|
|
304
|
+
? issue.issues
|
|
305
|
+
: undefined;
|
|
306
|
+
return nested?.length
|
|
307
|
+
? nested.flatMap((child) => flatten(child, path))
|
|
308
|
+
: [{ ...issue, path }];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Describe the most specific reason a config failed.
|
|
313
|
+
*
|
|
314
|
+
* Bundle and upload configs are unions, and a union reports one failure per
|
|
315
|
+
* branch. Reporting the first would surface "expected string" from a branch
|
|
316
|
+
* that never applied, burying the branch that nearly matched — so prefer a
|
|
317
|
+
* leaf that says something other than "wrong type", deepest path first.
|
|
318
|
+
*/
|
|
319
|
+
function describe(issues: Issue[]): string {
|
|
320
|
+
const leaves = issues.flatMap((issue) => flatten(issue));
|
|
321
|
+
const specific = leaves.filter((leaf) => leaf.code !== "invalid_type");
|
|
322
|
+
const best = (specific.length ? specific : leaves).reduce((a, b) =>
|
|
323
|
+
b.path.length > a.path.length ? b : a,
|
|
324
|
+
);
|
|
325
|
+
const path = best.path.join(".");
|
|
326
|
+
return path ? `${path}: ${best.message}` : best.message;
|
|
327
|
+
}
|
|
328
|
+
|
|
113
329
|
export function parseConfig(value: unknown): Config {
|
|
114
330
|
const result = ConfigSchema.safeParse(value);
|
|
115
331
|
if (!result.success) {
|
|
116
|
-
|
|
117
|
-
const path = issue.path.join(".");
|
|
118
|
-
const message = path ? `${path}: ${issue.message}` : issue.message;
|
|
119
|
-
throw new ConfigError(message);
|
|
332
|
+
throw new ConfigError(describe(result.error.issues as unknown as Issue[]));
|
|
120
333
|
}
|
|
121
334
|
|
|
122
335
|
const config = result.data;
|
package/src/fs.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compare paths using Unicode NFC followed by lowercase, on every platform.
|
|
8
|
+
* Normalizing first gives equivalent spellings the same input to lowercasing.
|
|
9
|
+
* This catches case and normalization collisions even for unwritten outputs
|
|
10
|
+
* and unresolved final entries, where realpath cannot canonicalize spelling.
|
|
11
|
+
* Applying one rule everywhere prevents a config from passing on Linux and
|
|
12
|
+
* overwriting a bundle on a case-insensitive filesystem (ADR 004).
|
|
13
|
+
*
|
|
14
|
+
* Comparison only: I/O retains the original spelling. Ownership and deletion
|
|
15
|
+
* use exact matches so folding cannot widen what srcpack removes.
|
|
16
|
+
*/
|
|
17
|
+
export function pathKey(path: string): string {
|
|
18
|
+
return path.normalize("NFC").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a path physically is, with symlinks resolved. Destructive decisions are
|
|
23
|
+
* made on this rather than the lexical path: `.srcpack -> ../shared` is inside
|
|
24
|
+
* the project by name and somewhere else in fact, and it is the somewhere else
|
|
25
|
+
* whose contents `rm` would take.
|
|
26
|
+
*
|
|
27
|
+
* Resolves as much of the path as exists, however deep that is. Stopping at the
|
|
28
|
+
* immediate parent would call `.srcpack/nested/x.txt` and `alias/nested/x.txt`
|
|
29
|
+
* different files until `mkdir -p` runs, which is one step too late to still be
|
|
30
|
+
* a check: aliasing is a property of the ancestors, not of when they were made.
|
|
31
|
+
*/
|
|
32
|
+
export async function physicalPath(path: string): Promise<string> {
|
|
33
|
+
try {
|
|
34
|
+
return await realpath(path);
|
|
35
|
+
} catch {
|
|
36
|
+
const parent = dirname(path);
|
|
37
|
+
// dirname("/") === "/": nothing above the filesystem root left to resolve
|
|
38
|
+
if (parent === path) return path;
|
|
39
|
+
return join(await physicalPath(parent), basename(path));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where `rename` puts a directory entry: ancestors resolved, the entry itself
|
|
45
|
+
* left alone. Writing replaces the entry instead of following it, so a bundle
|
|
46
|
+
* whose output is a symlink is identified as the link rather than its target —
|
|
47
|
+
* writing to the link path and to its target produces two separate files.
|
|
48
|
+
*/
|
|
49
|
+
export async function entryPath(path: string): Promise<string> {
|
|
50
|
+
return join(await physicalPath(dirname(path)), basename(path));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isInside(path: string, dir: string): boolean {
|
|
54
|
+
const rel = relative(dir, path);
|
|
55
|
+
// Compare against ".." as a whole segment — "..cache/x" is a child, not an escape
|
|
56
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write a file by replacing the directory entry rather than the file behind
|
|
61
|
+
* it. Writing in place follows a symlink sitting at the output path, so
|
|
62
|
+
* `.srcpack/web.txt -> ~/.ssh/config` would be written through; rename replaces
|
|
63
|
+
* the link itself. It also makes each file appear whole or not at all.
|
|
64
|
+
*
|
|
65
|
+
* The temp name carries the pid so two runs can't rename each other's file.
|
|
66
|
+
*/
|
|
67
|
+
export async function writeFileAtomic(
|
|
68
|
+
path: string,
|
|
69
|
+
data: string | Uint8Array,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(temp, data);
|
|
74
|
+
await rename(temp, path);
|
|
75
|
+
} finally {
|
|
76
|
+
// A failed write or rename would otherwise leave a partial file behind:
|
|
77
|
+
// stale inside outDir, and bundled by the next run beside a custom outfile.
|
|
78
|
+
await rm(temp, { force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|