@pipelex/create-method-app 0.4.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/lib/main.mjs ADDED
@@ -0,0 +1,453 @@
1
+ /**
2
+ * `create-method-app`: write a template of the family into a directory, make
3
+ * the pristine commit where nothing of the user's is at stake, and run the
4
+ * copy's own `make create`.
5
+ *
6
+ * The order is the safety story:
7
+ *
8
+ * 1. **A preflight that leaves nothing behind.** The arguments and the
9
+ * template; Node at or above the template's `engines` floor; `make`, and
10
+ * `git` unless `--no-git`; `--method` and `PIPELEX_API_KEY` unless
11
+ * `--no-create`, the key tested for presence and never printed; the
12
+ * destination rule; git's reading of the destination, and an identity when
13
+ * a commit will be made. It writes nothing but the throwaway repository in
14
+ * which a new repository's identity is read when none shows outside one,
15
+ * and removes that before going on. Each failure is a `refused:` verdict
16
+ * naming the fix.
17
+ * 2. **The write**, exclusive, removing what it created when it cannot finish.
18
+ * 3. **Git**: a new repository on `main` outside any work tree, then the pristine
19
+ * commit; the commit alone at the root of a repository with no commit yet;
20
+ * nothing inside another repository's work tree, or with `--no-git`.
21
+ * 4. **`make create`**, unless `--no-create`, streamed or logged.
22
+ * 5. **The report**: the gesture's warnings, the git outcome, and the verdict
23
+ * line, last.
24
+ *
25
+ * The initializer never prompts.
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import process from "node:process";
32
+
33
+ import { HELP_HINT, parseArgs, USAGE } from "./args.mjs";
34
+ import { destinationProblem, nearestExisting, readDestination } from "./destination.mjs";
35
+ import {
36
+ commitPristine,
37
+ hasIdentity,
38
+ hasIdentityForInit,
39
+ pristineByHand,
40
+ pristineMessage,
41
+ readGit,
42
+ templateOrigin,
43
+ } from "./git.mjs";
44
+ import { extractWarnings, makeArgs, runMake } from "./make.mjs";
45
+ import { decodePack, PackError } from "./pack.mjs";
46
+ import { loadTable, PACKAGE_ROOT } from "./templates.mjs";
47
+ import { EXIT_OK, shellQuote, Verdict } from "./verdict.mjs";
48
+ import { DestinationChanged, Interrupted, writeTree } from "./write.mjs";
49
+
50
+ export const PACKS_DIR = path.join(PACKAGE_ROOT, "templates");
51
+
52
+ /** This package's own version. */
53
+ export function ownVersion() {
54
+ return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version;
55
+ }
56
+
57
+ /** Whether an executable named `tool` is on the `PATH` `env` carries. */
58
+ export function onPath(tool, env) {
59
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
60
+ if (dir === "") continue;
61
+ const candidate = path.join(dir, tool);
62
+ try {
63
+ fs.accessSync(candidate, fs.constants.X_OK);
64
+ if (fs.statSync(candidate).isFile()) return true;
65
+ } catch {
66
+ // not here
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Compare two `X.Y.Z` versions. */
73
+ export function compareVersions(a, b) {
74
+ const pa = a.split(".").map(Number);
75
+ const pb = b.split(".").map(Number);
76
+ for (let i = 0; i < 3; i += 1) {
77
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ /** The template's pack, from the package. */
83
+ export function loadPack(packDir, template) {
84
+ const file = path.join(packDir, `${template}.pack`);
85
+ let buffer;
86
+ try {
87
+ buffer = fs.readFileSync(file);
88
+ } catch (error) {
89
+ if (error.code !== "ENOENT") throw error;
90
+ throw Verdict.failed(
91
+ "write",
92
+ `this copy of the initializer carries no packed ${template} (${file}); a checkout of the family packs it with npm run pack-templates. Nothing was written.`,
93
+ );
94
+ }
95
+ let pack;
96
+ try {
97
+ pack = decodePack(buffer);
98
+ } catch (error) {
99
+ if (!(error instanceof PackError)) throw error;
100
+ throw Verdict.failed("write", `${file} is unreadable: ${error.message}. Nothing was written.`);
101
+ }
102
+ if (pack.template !== template) {
103
+ throw Verdict.failed(
104
+ "write",
105
+ `${file} holds ${pack.template}, not ${template}. Nothing was written.`,
106
+ );
107
+ }
108
+ return pack;
109
+ }
110
+
111
+ /** The `engines.node` floor of a packed template, as `X.Y.Z`. */
112
+ function nodeFloor(pack) {
113
+ const manifest = pack.files.find((file) => file.path === "package.json");
114
+ const engines = manifest && JSON.parse(manifest.data.toString("utf8")).engines?.node;
115
+ return /^>=(\d+\.\d+\.\d+)$/.exec(engines ?? "")?.[1] ?? null;
116
+ }
117
+
118
+ /** Everything a run reads from outside, so a test can substitute each. */
119
+ export function resolveDeps(deps = {}) {
120
+ return {
121
+ cwd: deps.cwd ?? process.cwd(),
122
+ env: deps.env ?? process.env,
123
+ out: deps.out ?? process.stdout,
124
+ packDir: deps.packDir ?? PACKS_DIR,
125
+ nodeVersion: deps.nodeVersion ?? process.versions.node,
126
+ signal: deps.signal,
127
+ tmpDir: deps.tmpDir ?? os.tmpdir(),
128
+ table: deps.table ?? loadTable(),
129
+ };
130
+ }
131
+
132
+ /** Choose the template, refusing one this initializer does not serve and an option it does not take. */
133
+ function chooseTemplate(args, table) {
134
+ const template = args.template ?? table.defaultTemplate;
135
+ if (!Object.hasOwn(table.templates, template)) {
136
+ const other = table.otherEcosystemOf(template);
137
+ if (other !== null) {
138
+ throw Verdict.refused(
139
+ "other-ecosystem",
140
+ `${template} is not a Node template, and ${other} serves it: run ${other} <dir> --template ${template} --method …`,
141
+ );
142
+ }
143
+ throw Verdict.refused(
144
+ "unknown-template",
145
+ `${template} is not a template of the family this initializer serves; it serves ${Object.keys(table.templates).join(", ")}`,
146
+ );
147
+ }
148
+ const takes = new Set(table.variablesOf(template));
149
+ for (const [variable, flag] of Object.entries(args.given)) {
150
+ if (!takes.has(variable)) {
151
+ throw Verdict.refused(
152
+ "unknown-option",
153
+ `${flag} is not an option of ${template}'s make create`,
154
+ );
155
+ }
156
+ }
157
+ return template;
158
+ }
159
+
160
+ function insideTemplateCheckout(dest, origin) {
161
+ return Verdict.refused(
162
+ "inside-template-checkout",
163
+ `${dest} is inside a checkout of ${origin}, a template's own repository, not a place for a project: choose a directory outside it`,
164
+ );
165
+ }
166
+
167
+ /** The preflight's git reading, as the plan the write follows. */
168
+ function planGit(args, dest, found, env) {
169
+ const from = found.kind === "missing" ? nearestExisting(dest) : dest;
170
+ if (args.noGit) {
171
+ // --no-git makes no repository, but a template's own checkout is no place
172
+ // for a project either way, so it is refused whenever git can read it.
173
+ const origin = onPath("git", env) ? templateOrigin({ from, env }) : null;
174
+ if (origin !== null) throw insideTemplateCheckout(dest, origin);
175
+ return {
176
+ commit: false,
177
+ init: false,
178
+ line: "git: --no-git, so nothing was initialized or committed.",
179
+ };
180
+ }
181
+ const reading = readGit({
182
+ dest,
183
+ from,
184
+ destExists: found.kind !== "missing",
185
+ destHasGit: found.kind === "lone-git",
186
+ env,
187
+ });
188
+ let plan;
189
+ switch (reading.kind) {
190
+ case "template-checkout":
191
+ throw insideTemplateCheckout(dest, reading.origin);
192
+ case "unreadable-git":
193
+ throw Verdict.refused(
194
+ "not-empty",
195
+ `${dest} holds a .git that git does not read as a repository; the template is written only into a directory that is missing, empty or holds nothing but a repository's .git`,
196
+ );
197
+ case "root":
198
+ if (reading.history) {
199
+ throw Verdict.refused(
200
+ "repository-has-history",
201
+ `${dest} is a repository with commits whose working tree holds nothing but .git, so every tracked file shows as deleted, and the pristine commit would record that deletion: start in a new directory, or restore the files first`,
202
+ );
203
+ }
204
+ if (reading.staged.length > 0) {
205
+ const shown = reading.staged.slice(0, 3).join(", ");
206
+ const more = reading.staged.length > 3 ? ` and ${reading.staged.length - 3} more` : "";
207
+ throw Verdict.refused(
208
+ "repository-has-staged-files",
209
+ `${dest} is a repository with no commit whose index already holds ${shown}${more}, which the pristine commit would record beside the template: start in a new directory, or empty the index first with git -C ${shellQuote(dest)} rm -r -q --cached .`,
210
+ );
211
+ }
212
+ plan = { commit: true, init: false };
213
+ break;
214
+ case "inside":
215
+ return {
216
+ commit: false,
217
+ init: false,
218
+ line: `git: ${dest} is inside the work tree of ${reading.toplevel}, so no repository was made and nothing was committed; the project is new files in that repository.`,
219
+ };
220
+ default:
221
+ plan = { commit: true, init: true };
222
+ }
223
+ const identity = plan.init
224
+ ? hasIdentityForInit({ dest, from, env })
225
+ : hasIdentity({ cwd: from, env });
226
+ if (!identity) {
227
+ throw Verdict.refused(
228
+ "no-git-identity",
229
+ "git has no identity to make the pristine commit with: set one with git config --global user.name '…' and git config --global user.email '…', or pass --no-git",
230
+ );
231
+ }
232
+ return plan;
233
+ }
234
+
235
+ /** The make create line to run next, without --dry-run, quoted for a shell. */
236
+ function nextCreate(table, template, values) {
237
+ const forwarded = { ...values };
238
+ delete forwarded.DRY_RUN;
239
+ const words = makeArgs(table.variablesOf(template), forwarded, table.switches).map(shellQuote);
240
+ if (values[table.required] === undefined) words.splice(1, 0, `${table.required}=<method>`);
241
+ return `make ${words.join(" ")}`;
242
+ }
243
+
244
+ async function create(argv, d, say, state) {
245
+ const { table } = d;
246
+ const args = parseArgs(argv, table);
247
+ if (args.help || args.version) return { help: args.help, version: args.version };
248
+ if (args.dir === undefined || args.dir.trim() === "") {
249
+ throw Verdict.refused(
250
+ "usage",
251
+ `no directory given: name the one to create the project in ${HELP_HINT}`,
252
+ );
253
+ }
254
+ const template = chooseTemplate(args, table);
255
+ const pack = loadPack(d.packDir, template);
256
+
257
+ const floor = nodeFloor(pack);
258
+ if (floor !== null && compareVersions(d.nodeVersion, floor) < 0) {
259
+ throw Verdict.refused(
260
+ "node-too-old",
261
+ `${template} needs Node ${floor} or later, and this is Node ${d.nodeVersion}: switch to a newer Node, then run this again`,
262
+ );
263
+ }
264
+ const missing = ["make", ...(args.noGit ? [] : ["git"])].filter((tool) => !onPath(tool, d.env));
265
+ if (missing.length > 0) {
266
+ throw Verdict.refused(
267
+ "missing-tool",
268
+ `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} not on the PATH: install ${missing.length > 1 ? "them" : "it"}${missing.includes("git") ? ", or pass --no-git to make no repository" : ""}`,
269
+ );
270
+ }
271
+ if (!args.noCreate && args.values[table.required] === undefined) {
272
+ throw Verdict.refused(
273
+ "no-method",
274
+ "pass --method with a .mthds file or a directory of them, a catalog id (mt_…) or a package address (github.com/owner/repo[/package][@tag]), or --no-create to write the template alone",
275
+ );
276
+ }
277
+ if (!args.noCreate && !d.env.PIPELEX_API_KEY?.trim()) {
278
+ throw Verdict.refused(
279
+ "no-key",
280
+ "PIPELEX_API_KEY is not set: export it in this shell (make create copies it into the project's .env.local), or pass --no-create and write .env.local yourself before running make create",
281
+ );
282
+ }
283
+
284
+ const dest = path.resolve(d.cwd, args.dir);
285
+ let found;
286
+ try {
287
+ found = readDestination(dest);
288
+ } catch (error) {
289
+ // A file where a directory of the path should be, or a directory that
290
+ // cannot be read: the preflight stops here, and nothing was written.
291
+ if (typeof error?.code !== "string") throw error;
292
+ throw Verdict.refused(
293
+ "unusable-destination",
294
+ `${dest} cannot be read (${error.message}): choose a directory whose path is made of directories you can read`,
295
+ );
296
+ }
297
+ const problem = destinationProblem(dest, found);
298
+ if (problem !== null) throw Verdict.refused("not-empty", problem);
299
+ const git = planGit(args, dest, found, d.env);
300
+
301
+ const values = { ...args.values };
302
+ if (values[table.required] !== undefined) {
303
+ const candidate = path.resolve(d.cwd, values[table.required]);
304
+ if (fs.existsSync(candidate)) values[table.required] = candidate;
305
+ }
306
+
307
+ // ── The write ──
308
+ if (d.signal?.aborted) {
309
+ throw Verdict.failed(
310
+ "write",
311
+ `${d.signal.reason ?? "a signal"} interrupted the run before anything was written`,
312
+ );
313
+ }
314
+ say(`create-method-app: writing ${template} ${pack.version} (${pack.source}) into ${dest}`);
315
+ try {
316
+ await writeTree(dest, pack.files, { signal: d.signal });
317
+ } catch (error) {
318
+ const left = error.left?.length
319
+ ? ` It could not remove ${error.left.join(", ")}, which it had created.`
320
+ : "";
321
+ if (error instanceof DestinationChanged) {
322
+ throw Verdict.refused(
323
+ "not-empty",
324
+ `${destinationProblem(dest, error.found)}, found when it was read again before the write.${left}`,
325
+ );
326
+ }
327
+ const cause =
328
+ error instanceof Interrupted ? error.message : `the write failed: ${error.message}`;
329
+ throw Verdict.failed("write", `${cause}; what it had created was removed.${left}`);
330
+ }
331
+ say(`create-method-app: wrote ${pack.files.length} files`);
332
+
333
+ // ── Git ──
334
+ state.phase = "commit";
335
+ let gitLine = git.line;
336
+ if (git.commit) {
337
+ const message = pristineMessage(pack);
338
+ try {
339
+ const sha = commitPristine({
340
+ dest,
341
+ init: git.init,
342
+ paths: pack.files.map((file) => file.path),
343
+ message,
344
+ env: d.env,
345
+ });
346
+ gitLine = git.init
347
+ ? `git: made a repository on main and committed the template as ${sha.slice(0, 12)}, "${message}".`
348
+ : `git: committed the template as the repository's first commit, ${sha.slice(0, 12)}, "${message}".`;
349
+ } catch (error) {
350
+ for (const line of error.message.split("\n")) say(line);
351
+ throw Verdict.failed(
352
+ "commit",
353
+ `git refused the pristine commit (above). The copy stands in ${dest}: commit it with ${pristineByHand({ dest, init: git.init, message, quote: shellQuote })}, then run cd ${shellQuote(dest)} && ${nextCreate(table, template, values)}`,
354
+ );
355
+ }
356
+ }
357
+
358
+ const where = shellQuote(dest);
359
+ if (args.noCreate) {
360
+ say(gitLine);
361
+ return { line: `copied ${dest}; next: cd ${where} && ${nextCreate(table, template, values)}` };
362
+ }
363
+
364
+ // ── make create ──
365
+ state.phase = "create";
366
+ const stands = git.commit ? "The copy and its commit stand" : "The copy stands";
367
+ if (d.signal?.aborted) {
368
+ say(gitLine);
369
+ throw Verdict.failed(
370
+ "create",
371
+ `${d.signal.reason ?? "a signal"} interrupted the run before make create started. ${stands} in ${dest}; run cd ${where} && ${nextCreate(table, template, values)}`,
372
+ );
373
+ }
374
+ let logFile;
375
+ if (args.quiet) {
376
+ logFile = path.join(
377
+ fs.mkdtempSync(path.join(d.tmpDir, "create-method-app-")),
378
+ "make-create.log",
379
+ );
380
+ say(`create-method-app: running make create in ${dest}; its output goes to ${logFile}`);
381
+ } else {
382
+ say(`create-method-app: running make create in ${dest}\n`);
383
+ }
384
+ const argvMake = makeArgs(table.variablesOf(template), values, table.switches);
385
+ const result = await runMake(argvMake, {
386
+ cwd: dest,
387
+ env: d.env,
388
+ out: d.out,
389
+ logFile,
390
+ signal: d.signal,
391
+ });
392
+
393
+ const warnings = extractWarnings(result.output);
394
+ say("");
395
+ say(warnings.length > 0 ? "warnings from make create:" : "warnings from make create: none");
396
+ for (const warning of warnings) say(` ${warning}`);
397
+ say(gitLine);
398
+ if (result.error !== null) {
399
+ throw Verdict.failed(
400
+ "create",
401
+ `make could not be run: ${result.error.message}. ${stands} in ${dest}.`,
402
+ );
403
+ }
404
+ if (result.status !== 0) {
405
+ const how =
406
+ result.status === null ? `was stopped by ${result.signal}` : `exited ${result.status}`;
407
+ const said = args.quiet ? `the end of its log, ${logFile},` : "its own message above";
408
+ throw Verdict.failed(
409
+ "create",
410
+ `make create ${how}. ${stands} in ${dest}, and ${said} says what to run next: a refusal before it wrote anything can be fixed and run again, and a failure after it cannot.`,
411
+ );
412
+ }
413
+ if (values.DRY_RUN) {
414
+ return {
415
+ line: `copied ${dest} (make create --dry-run changed nothing); next: cd ${where} && ${nextCreate(table, template, values)}`,
416
+ };
417
+ }
418
+ return { line: `created ${dest}; next: cd ${where} && make serve` };
419
+ }
420
+
421
+ /**
422
+ * The whole run, verdict included. Returns the exit code; prints the verdict
423
+ * last. An error nothing anticipated becomes the verdict of the phase it
424
+ * happened in.
425
+ */
426
+ export async function run(argv, deps = {}) {
427
+ const d = resolveDeps(deps);
428
+ const say = (line) => d.out.write(`${line}\n`);
429
+ const state = { phase: "write" };
430
+ let verdict;
431
+ try {
432
+ const done = await create(argv, d, say, state);
433
+ if (done.help) {
434
+ say(
435
+ `create-method-app ${ownVersion()} — start a Pipelex method app from the family's template.\n\n${USAGE}`,
436
+ );
437
+ return EXIT_OK;
438
+ }
439
+ if (done.version) {
440
+ say(ownVersion());
441
+ return EXIT_OK;
442
+ }
443
+ say(done.line);
444
+ return EXIT_OK;
445
+ } catch (error) {
446
+ verdict =
447
+ error instanceof Verdict
448
+ ? error
449
+ : Verdict.failed(state.phase, `${error instanceof Error ? error.message : String(error)}`);
450
+ }
451
+ say(verdict.line);
452
+ return verdict.exitCode;
453
+ }
package/lib/make.mjs ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The copy's `make create`, run with an argument vector and no shell, so no
3
+ * value ever needs quoting: each variable reaches make as one `NAME=value`
4
+ * word, which the template's Makefile reads with `$(value …)` and hands to its
5
+ * gesture exactly as typed.
6
+ *
7
+ * The output is streamed, or, with `--quiet`, written to a log alone. Either
8
+ * way it is read for the gesture's warnings, which open with `! ` (the
9
+ * gesture's own) or `warning: ` (the bootstrap's), and are printed again at the
10
+ * end because `make all` fills the tail. The gesture runs its bootstrap twice,
11
+ * a dry run then the write, so each warning is kept once.
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import fs from "node:fs";
16
+
17
+ import { gitEnv } from "./git.mjs";
18
+
19
+ /**
20
+ * Variables through which an enclosing make would reach this one as if they
21
+ * had been typed on its command line. The Makefile trusts only command-line
22
+ * values, so none may arrive any other way.
23
+ */
24
+ const MAKE_CHANNELS = ["MAKEFLAGS", "MFLAGS", "MAKELEVEL", "MAKEOVERRIDES"];
25
+
26
+ /**
27
+ * The environment `make create` runs in: without the make channels, and
28
+ * without the variables that point git elsewhere, which the initializer drops
29
+ * for its own git calls too. The copy's install wires its hooks with
30
+ * `git config`, which would otherwise write into the repository a caller's
31
+ * `GIT_DIR` names rather than the copy's.
32
+ */
33
+ export function makeEnv(env) {
34
+ const clean = gitEnv(env);
35
+ for (const name of MAKE_CHANNELS) delete clean[name];
36
+ return clean;
37
+ }
38
+
39
+ /** `make create`'s arguments: `create`, then `NAME=value` for each value given, in the contract's order. */
40
+ export function makeArgs(order, values, switches) {
41
+ const args = ["create"];
42
+ for (const variable of order) {
43
+ if (switches.has(variable)) {
44
+ if (values[variable]) args.push(`${variable}=1`);
45
+ } else if (values[variable] !== undefined) {
46
+ args.push(`${variable}=${values[variable]}`);
47
+ }
48
+ }
49
+ return args;
50
+ }
51
+
52
+ /** The warnings in a run's output, each once, in the order they first appeared. */
53
+ export function extractWarnings(output) {
54
+ const seen = new Set();
55
+ for (const line of output.split(/\r?\n/)) {
56
+ if ((line.startsWith("! ") || line.startsWith("warning: ")) && !seen.has(line)) seen.add(line);
57
+ }
58
+ return [...seen];
59
+ }
60
+
61
+ /**
62
+ * Run `make` in `cwd`. Resolves `{ status, signal, output, error }`; never
63
+ * rejects. `out` receives the stream unless `logFile` is given. An abort of
64
+ * `signal` passes SIGTERM on to make.
65
+ */
66
+ export function runMake(args, { cwd, env, out, logFile, signal }) {
67
+ return new Promise((resolve) => {
68
+ const log = logFile ? fs.openSync(logFile, "w", 0o600) : null;
69
+ const chunks = [];
70
+ const child = spawn("make", args, {
71
+ cwd,
72
+ env: makeEnv(env),
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ });
75
+ const take = (chunk) => {
76
+ chunks.push(chunk);
77
+ if (log !== null) fs.writeSync(log, chunk);
78
+ else out.write(chunk);
79
+ };
80
+ child.stdout.on("data", take);
81
+ child.stderr.on("data", take);
82
+ const onAbort = () => child.kill("SIGTERM");
83
+ signal?.addEventListener("abort", onAbort, { once: true });
84
+ let failed = null;
85
+ let settled = false;
86
+ const finish = (status, killedBy) => {
87
+ if (settled) return;
88
+ settled = true;
89
+ signal?.removeEventListener("abort", onAbort);
90
+ if (log !== null) fs.closeSync(log);
91
+ resolve({
92
+ status,
93
+ signal: killedBy,
94
+ output: Buffer.concat(chunks).toString("utf8"),
95
+ error: failed,
96
+ });
97
+ };
98
+ child.on("error", (error) => {
99
+ failed = error;
100
+ // A make that never started emits no close of its own.
101
+ if (child.pid === undefined) finish(null, null);
102
+ });
103
+ child.on("close", finish);
104
+ });
105
+ }
package/lib/pack.mjs ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The packed form of a template: the tree `git ls-files <template>` lists, as
3
+ * one gzip-compressed file the package carries.
4
+ *
5
+ * npm cannot ship a template as a list of files. `npm pack` never includes
6
+ * `package-lock.json`, and it reads each directory's `.gitignore` as exclusion
7
+ * rules, so a package listing the template's files would publish something
8
+ * other than the template. One file sidesteps both, and reading it back needs
9
+ * nothing but Node's own `zlib`.
10
+ *
11
+ * The file is gzip over one line of JSON, then the contents of every file one
12
+ * after the other. The line is the header:
13
+ *
14
+ * {"format":1,"template":"webapp-js","version":"0.4.0","source":"<sha>",
15
+ * "files":[{"path":"package.json","mode":"100644","size":2345}, …]}
16
+ *
17
+ * `version` is the family's, and `source` the commit the tree was packed from,
18
+ * both named by the pristine commit. `mode` is git's: a plain or an executable
19
+ * file, and nothing else. A path is relative to the template's directory.
20
+ */
21
+
22
+ import zlib from "node:zlib";
23
+
24
+ export const FORMAT = 1;
25
+
26
+ /** The modes a packed file may carry, and what each becomes on disk. */
27
+ export const MODES = { 100644: 0o644, 100755: 0o755 };
28
+
29
+ export class PackError extends Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = "PackError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Whether a path is safe to write under a destination: relative, forward
38
+ * slashes, no empty, `.` or `..` segment, and nothing inside a `.git`.
39
+ */
40
+ export function safePath(rel) {
41
+ if (typeof rel !== "string" || rel === "" || rel.startsWith("/") || rel.includes("\\")) {
42
+ return false;
43
+ }
44
+ return rel
45
+ .split("/")
46
+ .every((part) => part !== "" && part !== "." && part !== ".." && part !== ".git");
47
+ }
48
+
49
+ /** Pack a tree: `files` is `[{ path, mode, data }]`, `data` a Buffer. */
50
+ export function encodePack({ template, version, source, files }) {
51
+ const header = {
52
+ format: FORMAT,
53
+ template,
54
+ version,
55
+ source,
56
+ files: files.map(({ path, mode, data }) => {
57
+ if (!safePath(path))
58
+ throw new PackError(`${JSON.stringify(path)} is not a path a pack can hold`);
59
+ if (!(mode in MODES))
60
+ throw new PackError(`${path} has mode ${mode}, which a pack cannot hold`);
61
+ return { path, mode, size: data.length };
62
+ }),
63
+ };
64
+ const body = Buffer.concat([
65
+ Buffer.from(`${JSON.stringify(header)}\n`, "utf8"),
66
+ ...files.map(({ data }) => data),
67
+ ]);
68
+ return zlib.gzipSync(body, { level: 9 });
69
+ }
70
+
71
+ /** Read a pack back, refusing anything that is not exactly what `encodePack` writes. */
72
+ export function decodePack(buffer) {
73
+ let body;
74
+ try {
75
+ body = zlib.gunzipSync(buffer);
76
+ } catch (error) {
77
+ throw new PackError(`the pack is not gzip data: ${error.message}`);
78
+ }
79
+ const newline = body.indexOf(0x0a);
80
+ if (newline < 0) throw new PackError("the pack has no header line");
81
+ let header;
82
+ try {
83
+ header = JSON.parse(body.subarray(0, newline).toString("utf8"));
84
+ } catch (error) {
85
+ throw new PackError(`the pack's header is not JSON: ${error.message}`);
86
+ }
87
+ if (header.format !== FORMAT) {
88
+ throw new PackError(
89
+ `the pack is in format ${header.format}, and this initializer reads ${FORMAT}`,
90
+ );
91
+ }
92
+ const files = [];
93
+ const seen = new Set();
94
+ let at = newline + 1;
95
+ for (const { path, mode, size } of header.files) {
96
+ if (!safePath(path))
97
+ throw new PackError(`the pack holds ${JSON.stringify(path)}, which is not a safe path`);
98
+ if (seen.has(path)) throw new PackError(`the pack holds ${path} twice`);
99
+ if (!(mode in MODES)) throw new PackError(`the pack gives ${path} mode ${mode}`);
100
+ if (!Number.isInteger(size) || size < 0 || at + size > body.length) {
101
+ throw new PackError(`the pack's size for ${path} runs past its end`);
102
+ }
103
+ seen.add(path);
104
+ files.push({ path, mode, data: body.subarray(at, at + size) });
105
+ at += size;
106
+ }
107
+ if (at !== body.length)
108
+ throw new PackError("the pack carries bytes its header does not account for");
109
+ return { template: header.template, version: header.version, source: header.source, files };
110
+ }