@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evotis S.A.S.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @pipelex/create-method-app
2
+
3
+ Start an app that runs an [MTHDS](https://mthds.ai) method through the [Pipelex](https://pipelex.com) API, with the method you already have:
4
+
5
+ ```bash
6
+ export PIPELEX_API_KEY=… # from app.pipelex.com
7
+ export PIPELEX_BASE_URL=https://api-dev.pipelex.com # for now, see the template's README
8
+ npm create @pipelex/method-app@latest my-app -- --method ./receipt_review.mthds
9
+ make -C my-app serve
10
+ ```
11
+
12
+ The first command writes the [`webapp-js`](https://github.com/Pipelex/pipelex-method-apps/tree/main/webapp-js) template of [`pipelex-method-apps`](https://github.com/Pipelex/pipelex-method-apps) into `my-app/`, commits it as it came, and runs the copy's own `make create`, which scaffolds the method, names the project after it and runs `make all`. The second starts the dev server in the background, proves that the page answers, and prints its URL; `make stop` stops it. `make dev` runs the same server in the foreground instead.
13
+
14
+ `--method` takes a `.mthds` file or a directory of them, a method id from your organization's catalog (`mt_…`), or a published package address (`github.com/Pipelex/methods/text_stats@v0.1.1`). A path is read from where you typed the command.
15
+
16
+ ## What it needs
17
+
18
+ - **Node.js** at or above the template's floor (22.12 for `webapp-js`), `make`, and `git` unless you pass `--no-git`.
19
+ - **`PIPELEX_API_KEY`** in the environment, since a fresh copy has no `.env.local` for `make create` to read it from. `make create` copies it into the project's `.env.local`. The initializer only checks that it is set, and never prints it.
20
+ - **A directory that is missing, empty, or holds only `.git`.** Anything else, `.DS_Store` included, is refused, and nothing is written.
21
+
22
+ The package has no dependency. It carries the template inside it, packed from the family's repository at the commit it was published from, so `@X.Y.Z` writes exactly the X.Y.Z template and nothing is fetched from GitHub.
23
+
24
+ ## Options
25
+
26
+ | Option | What it does |
27
+ | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
+ | `--method <m>` | The method the app runs. Required unless `--no-create`. |
29
+ | `--name`, `--title`, `--description` | The project's package name, title and description, which `make create` otherwise derives from the method. |
30
+ | `--pipe`, `--method-name`, `--label` | The pipe to run, the method's directory name and its tab label, when the method does not settle them. |
31
+ | `--author-name`, `--author-email`, `--repo-url`, `--license`, `--license-holder`, `--license-year` | What the project says about who made it. Nothing is invented. |
32
+ | `--dry-run` | `make create` plans and prints the identity without changing a file. The copy and its commit stand, and the verdict names the real `make create` to run. |
33
+ | `--no-create` | Write the template and commit it, then stop. The verdict names the `make create` to run, after writing `.env.local` yourself, for instance. |
34
+ | `--no-git` | Make no repository and no commit. |
35
+ | `--quiet` | Write `make create`'s output to a log and print its path, so the summary stays in view. |
36
+ | `--template <name>` | The template to write. `webapp-js` is the default and the only one. |
37
+
38
+ Each create option is named after the `make create` variable it sets (`--author-email` is `AUTHOR_EMAIL`), and reaches `make create` as one argument, so no value needs quoting beyond what your shell needs. A blank value is not passed on. Nothing is ever asked: a missing value is a refusal naming the option.
39
+
40
+ ## Git
41
+
42
+ The initializer reads git before it writes anything:
43
+
44
+ | The destination | What happens |
45
+ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
46
+ | Inside no repository | A new repository on `main`, then the pristine commit |
47
+ | A repository with no commit yet (a `git init` by hand, or a clone of an empty GitHub repository) | The pristine commit, as its first |
48
+ | A repository with no commit whose index already holds a file, added and then deleted | Refused: the commit would record that file beside the template |
49
+ | A repository with commits, holding only `.git` | Refused: every tracked file would show as deleted, and the commit would record that |
50
+ | Inside another repository's work tree | No repository and no commit: the project is new files in that repository. `--no-create` lets you commit the template there first |
51
+ | Inside a checkout of `pipelex-method-apps` or of a starter | Refused, `--no-git` included whenever git is on the PATH |
52
+
53
+ A commit needs a git identity, and a missing one is refused before anything is written. When git shows none outside a repository, the initializer asks again inside a throwaway repository at the destination, which it removes before going on, so an identity given only by an `includeIf "gitdir:…"` section is found.
54
+
55
+ The pristine commit reads `Start from Pipelex/pipelex-method-apps/webapp-js <version> (<sha>)`, so `make create`'s changes are a diff you can read before committing them. `make create` itself commits nothing.
56
+
57
+ ## What it prints
58
+
59
+ A run ends with one verdict line, the last line of its output, whose first word is stable:
60
+
61
+ | Verdict | Meaning |
62
+ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `created <dir>; next: cd <dir> && make serve` | The copy, the git outcome, and a green `make create` |
64
+ | `copied <dir>; next: …` | With `--no-create` or `--dry-run`: the copy and the git outcome, and the `make create` to run next |
65
+ | `refused: not-empty` | The destination holds something other than a lone `.git`; nothing was written |
66
+ | `refused: unusable-destination` | The destination cannot be read: a file stands where a directory of its path should be, or a directory on it cannot be read; nothing was written |
67
+ | `refused: repository-has-history`, `refused: repository-has-staged-files`, `refused: inside-template-checkout` | The git reading above; nothing was written |
68
+ | `refused: no-method`, `no-key`, `missing-tool`, `node-too-old`, `no-git-identity`, `unknown-option`, `unknown-template`, `other-ecosystem`, `usage` | The preflight; nothing was written, and the line names the fix |
69
+ | `failed: write` | The copy failed or was interrupted, and everything the initializer had created was removed; nothing else was touched |
70
+ | `failed: commit` | Git refused the pristine commit, for instance through a global hook; the copy stands, and the line says how to commit it and what to run next |
71
+ | `failed: create` | `make create` did not succeed; the copy and its commit stand, and `make create`'s own message says what to run next: a refusal before it wrote anything can be fixed and run again, and a failure after it cannot |
72
+
73
+ Before the verdict come the warnings `make create` printed, each once, and the git outcome. The exit code is 0 for `created` and `copied` and 1 otherwise, but the verdict is the line to read.
74
+
75
+ A Python template gets its own initializer, run with `uvx create-pipelex-method-app`, and this one refuses it with `refused: other-ecosystem`.
76
+
77
+ ## License
78
+
79
+ [MIT](LICENSE). The template it writes carries its own license, MIT, which the project can change with `--license`.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm create @pipelex/method-app@latest <dir> -- --method …` runs this. The
4
+ * behavior lives in `lib/main.mjs`; this file catches SIGINT and SIGTERM, so an
5
+ * interruption ends in a verdict rather than a dead process, and sets the exit
6
+ * code.
7
+ */
8
+ import process from "node:process";
9
+
10
+ import { run } from "../lib/main.mjs";
11
+
12
+ const controller = new AbortController();
13
+ const interrupt = (signal) => controller.abort(signal);
14
+ process.on("SIGINT", interrupt);
15
+ process.on("SIGTERM", interrupt);
16
+
17
+ process.exitCode = await run(process.argv.slice(2), { signal: controller.signal });
package/lib/args.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The command line:
3
+ *
4
+ * create-method-app <dir> --method <bundle | mt_… | address> [create options]
5
+ * [--template <name>] [--no-create] [--no-git] [--quiet]
6
+ *
7
+ * Each create option is a flag named after its make variable, taking its value
8
+ * as the next word or after `=`; a switch such as `--dry-run` takes none. A
9
+ * blank value is not given, as the Makefile's `given` ignores one. Nothing is
10
+ * ever asked: a missing value is a refusal naming the flag.
11
+ */
12
+
13
+ import { Verdict } from "./verdict.mjs";
14
+
15
+ export const USAGE =
16
+ "usage: npm create @pipelex/method-app@latest <dir> -- --method <path/to/bundle | mt_… | github.com/owner/repo[/package][@tag]> " +
17
+ "[--name <package>] [--title <title>] [--description <text>] [--pipe <pipe_code>] " +
18
+ "[--author-name <name>] [--author-email <email>] [--repo-url <url>] [--license <mit|proprietary|spdx>] " +
19
+ "[--license-holder <holder>] [--license-year <year>] [--dry-run] [--method-name <dir-name>] [--label <label>] " +
20
+ "[--template webapp-js] [--no-create] [--no-git] [--quiet]";
21
+
22
+ /** Appended to a refusal of the command line, which stays one line. */
23
+ export const HELP_HINT = "(--help lists the options)";
24
+
25
+ const OWN_SWITCHES = { "--no-create": "noCreate", "--no-git": "noGit", "--quiet": "quiet" };
26
+
27
+ /**
28
+ * Parse `argv` against the table. Returns
29
+ * `{ help, version, dir, template, values, given, noCreate, noGit, quiet }`,
30
+ * `values` holding each create variable given, by name, and `given` the flag
31
+ * each came from; throws a `Verdict` refusal otherwise.
32
+ */
33
+ export function parseArgs(argv, table) {
34
+ const parsed = {
35
+ help: false,
36
+ version: false,
37
+ dir: undefined,
38
+ template: undefined,
39
+ values: {},
40
+ given: {},
41
+ noCreate: false,
42
+ noGit: false,
43
+ quiet: false,
44
+ };
45
+ const seen = new Set();
46
+ const once = (flag) => {
47
+ if (seen.has(flag)) throw Verdict.refused("usage", `${flag} is given twice. ${HELP_HINT}`);
48
+ seen.add(flag);
49
+ };
50
+
51
+ let options = true;
52
+ for (let i = 0; i < argv.length; i += 1) {
53
+ const arg = argv[i];
54
+ if (options && arg === "--") {
55
+ options = false;
56
+ continue;
57
+ }
58
+ if (!options || !arg.startsWith("-") || arg === "-") {
59
+ if (parsed.dir !== undefined) {
60
+ throw Verdict.refused(
61
+ "usage",
62
+ `${JSON.stringify(arg)} is a second directory; the template is written into one. ${HELP_HINT}`,
63
+ );
64
+ }
65
+ parsed.dir = arg;
66
+ continue;
67
+ }
68
+
69
+ const eq = arg.indexOf("=");
70
+ const flag = eq > 0 ? arg.slice(0, eq) : arg;
71
+ const inline = eq > 0 ? arg.slice(eq + 1) : undefined;
72
+ const noValue = () => {
73
+ if (inline !== undefined)
74
+ throw Verdict.refused("usage", `${flag} takes no value. ${HELP_HINT}`);
75
+ };
76
+ const value = () => {
77
+ if (inline !== undefined) return inline;
78
+ const next = argv[i + 1];
79
+ if (next === undefined || next.startsWith("--")) {
80
+ throw Verdict.refused("usage", `${flag} needs a value. ${HELP_HINT}`);
81
+ }
82
+ i += 1;
83
+ return next;
84
+ };
85
+
86
+ if (flag === "--help" || flag === "-h") {
87
+ parsed.help = true;
88
+ } else if (flag === "--version") {
89
+ parsed.version = true;
90
+ } else if (flag in OWN_SWITCHES) {
91
+ noValue();
92
+ once(flag);
93
+ parsed[OWN_SWITCHES[flag]] = true;
94
+ } else if (flag === "--template") {
95
+ once(flag);
96
+ parsed.template = value().trim() || undefined;
97
+ } else if (table.variables.has(flag)) {
98
+ once(flag);
99
+ const variable = table.variables.get(flag);
100
+ if (table.switches.has(variable)) {
101
+ noValue();
102
+ parsed.values[variable] = true;
103
+ } else {
104
+ const given = value();
105
+ if (given.trim() !== "") parsed.values[variable] = given;
106
+ }
107
+ parsed.given[variable] = flag;
108
+ } else {
109
+ throw Verdict.refused(
110
+ "unknown-option",
111
+ `${flag} is not an option of this initializer. ${HELP_HINT}`,
112
+ );
113
+ }
114
+ }
115
+ return parsed;
116
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The destination rule, which is the scaffold skill's: a directory that is
3
+ * missing, empty, or holds only `.git` is accepted, and anything else is
4
+ * refused. The lone `.git` is a ruling about `.git` and nothing else, so
5
+ * `.DS_Store`, an editor's settings and every other entry refuse, because
6
+ * judging which of a user's files matter is exactly what the rule forbids.
7
+ *
8
+ * The destination is resolved to an absolute path first, so `.` works, and it
9
+ * is read twice: in the preflight, and again immediately before the first
10
+ * write.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ /**
17
+ * What stands at the destination: `missing`, `empty`, `lone-git`, a
18
+ * `not-empty` directory with some of what it holds, or a `not-directory`.
19
+ */
20
+ export function readDestination(dir) {
21
+ let stat;
22
+ try {
23
+ stat = fs.statSync(dir);
24
+ } catch (error) {
25
+ if (error.code === "ENOENT") return { kind: "missing" };
26
+ throw error;
27
+ }
28
+ if (!stat.isDirectory()) return { kind: "not-directory" };
29
+ const entries = fs.readdirSync(dir).sort();
30
+ if (entries.length === 0) return { kind: "empty" };
31
+ if (entries.length === 1 && entries[0] === ".git") return { kind: "lone-git" };
32
+ return { kind: "not-empty", entries: entries.filter((entry) => entry !== ".git") };
33
+ }
34
+
35
+ /** The refusal's explanation of what stands there, or null when the rule accepts it. */
36
+ export function destinationProblem(dir, found) {
37
+ if (found.kind === "not-directory") return `${dir} exists and is not a directory`;
38
+ if (found.kind !== "not-empty") return null;
39
+ const shown = found.entries.slice(0, 3).join(", ");
40
+ const more = found.entries.length > 3 ? ` and ${found.entries.length - 3} more` : "";
41
+ return `${dir} already holds ${shown}${more}; the template is written only into a directory that is missing, empty or holds nothing but .git`;
42
+ }
43
+
44
+ /** The nearest ancestor of `dir`, itself included, that exists. */
45
+ export function nearestExisting(dir) {
46
+ let at = dir;
47
+ while (!fs.existsSync(at)) {
48
+ const parent = path.dirname(at);
49
+ if (parent === at) return at;
50
+ at = parent;
51
+ }
52
+ return at;
53
+ }
package/lib/git.mjs ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Git, read before anything is written, and one outcome for each case:
3
+ *
4
+ * inside no work tree → a new repository on main, then the pristine commit
5
+ * the root of a repository with no commit → the pristine commit, its first
6
+ * … whose index already holds an entry → refused: repository-has-staged-files
7
+ * the root of a repository with history → refused: repository-has-history
8
+ * inside another repository's work tree → no repository and no commit
9
+ * inside a checkout of a template → refused: inside-template-checkout
10
+ *
11
+ * A directory holding only `.git` whose repository has commits is one whose
12
+ * working tree shows every tracked file deleted, and landing the template on it
13
+ * would fold that deletion into the commit. One with no commit can still hold
14
+ * staged entries, a file added and then deleted from the directory, and the
15
+ * pristine commit would record them beside the template. A repository planted
16
+ * inside another one's work tree makes the enclosing one fail `git add -A`
17
+ * until the nested one has a commit, and then see an embedded repository, so
18
+ * the project becomes new files in the enclosing repository instead, as
19
+ * `create-next-app` does.
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ /**
27
+ * The repositories whose checkouts are templates, read from `origin`: the
28
+ * family's own, and the starters. Running the initializer inside one would
29
+ * make a project of a template's checkout.
30
+ */
31
+ export const TEMPLATE_ORIGINS =
32
+ /[/:](pipelex\/(pipelex-method-apps|pipelex-starter-js|pipelex-starter-python)|mthds-ai\/mthds-starter-js)(\.git)?\/?$/i;
33
+
34
+ /**
35
+ * Variables that point git at a repository other than the one it would find
36
+ * from the directory it runs in. A hook or a wrapper can leave them set, and
37
+ * the reading must be about the destination.
38
+ */
39
+ const REDIRECTING = [
40
+ "GIT_DIR",
41
+ "GIT_WORK_TREE",
42
+ "GIT_INDEX_FILE",
43
+ "GIT_OBJECT_DIRECTORY",
44
+ "GIT_COMMON_DIR",
45
+ "GIT_NAMESPACE",
46
+ "GIT_PREFIX",
47
+ ];
48
+
49
+ export function gitEnv(env) {
50
+ const clean = { ...env };
51
+ for (const name of REDIRECTING) delete clean[name];
52
+ return clean;
53
+ }
54
+
55
+ /** Run git, returning its status and trimmed output. */
56
+ export function git(args, { cwd, env }) {
57
+ const result = spawnSync("git", args, { cwd, env: gitEnv(env), encoding: "utf8" });
58
+ if (result.error) return { status: null, stdout: "", stderr: result.error.message };
59
+ return { status: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim() };
60
+ }
61
+
62
+ /**
63
+ * Read git at the destination. `from` is the destination when it exists, or
64
+ * its nearest existing ancestor. Returns one of:
65
+ *
66
+ * { kind: "outside" }
67
+ * { kind: "template-checkout", origin }
68
+ * { kind: "root", history: boolean, staged: string[] } `staged` is read only when there is no history
69
+ * { kind: "inside", toplevel }
70
+ * { kind: "unreadable-git" } the destination holds a `.git` git does not read as its repository
71
+ */
72
+ export function readGit({ dest, from, destExists, destHasGit, env }) {
73
+ const top = git(["rev-parse", "--show-toplevel"], { cwd: from, env });
74
+ if (top.status !== 0) return destHasGit ? { kind: "unreadable-git" } : { kind: "outside" };
75
+
76
+ const origin = templateOrigin({ from, env });
77
+ if (origin !== null) return { kind: "template-checkout", origin };
78
+
79
+ // `--show-prefix` prints nothing at a repository's root, which needs no path
80
+ // comparison and so survives symlinks and letter case.
81
+ if (destExists) {
82
+ const prefix = git(["rev-parse", "--show-prefix"], { cwd: dest, env });
83
+ if (prefix.status === 0 && prefix.stdout === "") {
84
+ const head = git(["rev-parse", "-q", "--verify", "HEAD"], { cwd: dest, env });
85
+ if (head.status === 0) return { kind: "root", history: true, staged: [] };
86
+ const index = git(["ls-files", "-z"], { cwd: dest, env });
87
+ return { kind: "root", history: false, staged: index.stdout.split("\0").filter(Boolean) };
88
+ }
89
+ if (destHasGit) return { kind: "unreadable-git" };
90
+ }
91
+ return { kind: "inside", toplevel: top.stdout };
92
+ }
93
+
94
+ /** The `origin` of the repository `from` stands in when it is a template's own, or null. */
95
+ export function templateOrigin({ from, env }) {
96
+ const origin = git(["remote", "get-url", "origin"], { cwd: from, env });
97
+ return origin.status === 0 && TEMPLATE_ORIGINS.test(origin.stdout) ? origin.stdout : null;
98
+ }
99
+
100
+ /** Whether git can name an author and a committer, as a commit needs. */
101
+ export function hasIdentity({ cwd, env }) {
102
+ return (
103
+ git(["var", "GIT_AUTHOR_IDENT"], { cwd, env }).status === 0 &&
104
+ git(["var", "GIT_COMMITTER_IDENT"], { cwd, env }).status === 0
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Whether git can name an author and a committer for the commit in the
110
+ * repository about to be made at `dest`, which `from`, the destination or its
111
+ * nearest existing ancestor, stands outside of. Outside a repository git reads
112
+ * no `includeIf "gitdir:…"` section, so an identity given only to the
113
+ * repositories under a directory is invisible from `from`. When that reading
114
+ * finds none, the question is asked again inside a throwaway repository made
115
+ * at `dest`, and what the probe made is removed before the answer is returned.
116
+ *
117
+ * It removes only what it made, as the write does. Each missing directory and
118
+ * the `.git` are made without `recursive`, so a path that stands there already,
119
+ * a dangling symlink the reading took for missing, or one another process
120
+ * creates meanwhile fails the probe instead of being taken over and removed.
121
+ * The directories go deepest first and only while empty, so what another
122
+ * process writes into one meanwhile stays. A probe that cannot be made
123
+ * answers no.
124
+ */
125
+ export function hasIdentityForInit({ dest, from, env }) {
126
+ if (hasIdentity({ cwd: from, env })) return true;
127
+ const missing = [];
128
+ for (let at = dest; at !== from && path.dirname(at) !== at; at = path.dirname(at)) {
129
+ missing.unshift(at);
130
+ }
131
+ const probe = path.join(dest, ".git");
132
+ const made = [];
133
+ let probeMade = false;
134
+ try {
135
+ for (const dir of missing) {
136
+ fs.mkdirSync(dir);
137
+ made.push(dir);
138
+ }
139
+ fs.mkdirSync(probe);
140
+ probeMade = true;
141
+ return git(["init", "-q"], { cwd: dest, env }).status === 0 && hasIdentity({ cwd: dest, env });
142
+ } catch {
143
+ return false;
144
+ } finally {
145
+ if (probeMade) fs.rmSync(probe, { recursive: true, force: true });
146
+ for (const dir of made.reverse()) {
147
+ try {
148
+ fs.rmdirSync(dir);
149
+ } catch {
150
+ // Not empty: another process wrote into it, and what it wrote stays.
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ /** The pristine commit's message, in the scaffold skill's format. */
157
+ export function pristineMessage({ template, version, source }) {
158
+ return `Start from Pipelex/pipelex-method-apps/${template} ${version} (${source})`;
159
+ }
160
+
161
+ /**
162
+ * Initialize when asked, then make the pristine commit of exactly `paths`, the
163
+ * files the write created. Returns the commit's SHA, or throws with git's own
164
+ * message. Git's output is not shown on success.
165
+ *
166
+ * The repository is born on `main` through `symbolic-ref` rather than
167
+ * `init -b`, which git before 2.28 does not know. The paths are added with
168
+ * `--force` and read literally: a user's `core.excludesFile` or the
169
+ * repository's `info/exclude` must not silently drop a file of the template
170
+ * from a commit that claims to hold it as it came, and the template ignores
171
+ * none of its own files.
172
+ */
173
+ export function commitPristine({ dest, init, paths, message, env }) {
174
+ const steps = [
175
+ ...(init
176
+ ? [
177
+ ["init", ["init", "-q"]],
178
+ ["symbolic-ref", ["symbolic-ref", "HEAD", "refs/heads/main"]],
179
+ ]
180
+ : []),
181
+ ["add", ["--literal-pathspecs", "add", "--force", "--", ...paths]],
182
+ ["commit", ["commit", "-q", "-m", message]],
183
+ ];
184
+ for (const [name, args] of steps) {
185
+ const result = git(args, { cwd: dest, env });
186
+ if (result.status !== 0) {
187
+ const said = [result.stderr, result.stdout].filter(Boolean).join("\n");
188
+ throw new Error(`git ${name} failed${said ? `:\n${said}` : ""}`);
189
+ }
190
+ }
191
+ return git(["rev-parse", "HEAD"], { cwd: dest, env }).stdout;
192
+ }
193
+
194
+ /**
195
+ * The commands a person runs to make the pristine commit by hand, once the
196
+ * copy stands, joined with `&&` for a shell. `quote` quotes one word.
197
+ */
198
+ export function pristineByHand({ dest, init, message, quote }) {
199
+ const at = `git -C ${quote(dest)}`;
200
+ return [
201
+ ...(init ? [`${at} init -q`, `${at} symbolic-ref HEAD refs/heads/main`] : []),
202
+ `${at} add --force -A`,
203
+ `${at} commit -m ${quote(message)}`,
204
+ ].join(" && ");
205
+ }