setup-git-repo 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 ADDED
@@ -0,0 +1,105 @@
1
+ # setup-git-repo
2
+
3
+ One command to give a repository the whole GitHub setup: Turborepo, the CI
4
+ workflows, the README badge block, and the docs explaining every part of it.
5
+
6
+ ```bash
7
+ bunx setup-git-repo # or: npx setup-git-repo
8
+ ```
9
+
10
+ Run it inside the repo you want to set up. Owner, repo name and default branch
11
+ are read from `git remote get-url origin`; the optional badges are prompted for
12
+ and left out if you skip them.
13
+
14
+ ## What it writes
15
+
16
+ ```
17
+ .github/workflows/tests.yml auto-discovering test matrix → Codecov
18
+ .github/workflows/npm-publish.yml publishes packages whose content changed
19
+ .github/workflows/deploy-test-reports.yml HTML test report → Cloudflare Workers
20
+ .github/workflows/auto-merge-claude.yml auto-merge for agent and maintainer PRs
21
+ .github/workflows/auto-merge-and-create-prs.yml twice-daily branch/PR sweep
22
+ .github/scripts/next-free-version.mjs picks a version npm has not spent
23
+ turbo.json package.json vitest.config.ts the Turborepo task graph
24
+ codecov.yml flags, carryforward, comment layout
25
+ README.md the badge block, filled in
26
+ docs/BADGES.md docs/WORKFLOWS.md docs/SECRETS.md
27
+ ```
28
+
29
+ Existing files are never overwritten unless you pass `--force`, so it is safe to
30
+ run against a repo that already has some of this.
31
+
32
+ ## Flags
33
+
34
+ Identity — detected from git when omitted:
35
+
36
+ | Flag | |
37
+ | --- | --- |
38
+ | `--owner <name>` | GitHub owner |
39
+ | `--repo <name>` | Repository name |
40
+ | `--branch <name>` | Default branch, used in every workflow's `branches:` filter |
41
+ | `--description <text>` | One-line description for the README |
42
+
43
+ Optional badges — a badge whose value you omit is dropped from the README
44
+ rather than shipped pointing at a placeholder:
45
+
46
+ | Flag | Badge |
47
+ | --- | --- |
48
+ | `--package <name>` | npm version + monthly downloads |
49
+ | `--doi <10.5281/zenodo.N>` | Zenodo DOI |
50
+ | `--docs <url>` / `--api <url>` / `--youtube <url>` | link badges |
51
+ | `--uptime <page-id>` | UptimeRobot status page |
52
+ | `--discord-id <id>` + `--discord-invite <url>` | Discord (both required) |
53
+
54
+ Behavior:
55
+
56
+ | Flag | |
57
+ | --- | --- |
58
+ | `--dir <path>` | Target directory (default: the current one) |
59
+ | `-n, --dry-run` | Print the plan, write nothing |
60
+ | `-f, --force` | Overwrite files that already exist |
61
+ | `-y, --yes` | Never prompt; use flags and git detection only |
62
+ | `--workflows-only` / `--badges-only` / `--docs-only` / `--turbo-only` | Write one part |
63
+ | `--template <path>` | Use a template directory other than the bundled one |
64
+
65
+ ## Examples
66
+
67
+ ```bash
68
+ # See what would land, without writing anything
69
+ bunx setup-git-repo --dry-run
70
+
71
+ # Non-interactive, for a script or an agent
72
+ bunx setup-git-repo -y --owner acme --repo widget --branch main --package widget-cli
73
+
74
+ # Add just the workflows to a repo that already has its own README
75
+ bunx setup-git-repo --workflows-only
76
+
77
+ # Refresh the badge block after the repo moved to a new owner
78
+ bunx setup-git-repo --badges-only --force --owner new-org
79
+ ```
80
+
81
+ ## After it runs
82
+
83
+ It prints the repository secrets and settings the workflows need —
84
+ `CODECOV_TOKEN`, `NPM_TOKEN` (or trusted publishing), the two Cloudflare values,
85
+ and `GIT_TOKEN` — plus the "Allow auto-merge" and branch-protection settings
86
+ that make `gh pr merge --auto` wait for CI instead of merging immediately.
87
+ `docs/SECRETS.md`, written into your repo, has the same list with where each
88
+ value comes from.
89
+
90
+ ## Where the template lives
91
+
92
+ The single source of truth is
93
+ [`starter-templates/template-git-repo/`](../../starter-templates/template-git-repo)
94
+ in this monorepo. `prepack` copies it into `template/` inside the package so the
95
+ published tarball carries it, and the CLI checks that bundled copy first — a CLI
96
+ that only climbs out of its own package works in the repo and breaks the moment
97
+ it is installed from npm.
98
+
99
+ Its `.gitignore` is stored as `gitignore`, without the dot, because npm strips
100
+ `.gitignore` files out of published tarballs; the CLI restores the dot on write.
101
+
102
+ ## Related skills
103
+
104
+ - [`ask-github-actions-setup`](../../skills/ask-github-actions-setup/SKILL.md) — the workflows: what each needs, how each fails
105
+ - [`ask-git-badges`](../../skills/ask-git-badges/SKILL.md) — the badge block: every badge's setup and failure modes
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+
7
+ import { detectRepo } from "../src/git.mjs";
8
+ import { resolveTemplateDir } from "../src/resolve-template.mjs";
9
+ import {
10
+ OPTIONAL_BADGES,
11
+ SUBSETS,
12
+ collectFiles,
13
+ configuredBadges,
14
+ destinationFor,
15
+ planFiles,
16
+ render,
17
+ } from "../src/template.mjs";
18
+
19
+ const c = {
20
+ reset: "\x1b[0m",
21
+ bold: "\x1b[1m",
22
+ dim: "\x1b[2m",
23
+ green: "\x1b[32m",
24
+ yellow: "\x1b[33m",
25
+ cyan: "\x1b[36m",
26
+ red: "\x1b[31m",
27
+ };
28
+ const bold = (s) => `${c.bold}${s}${c.reset}`;
29
+ const dim = (s) => `${c.dim}${s}${c.reset}`;
30
+ const green = (s) => `${c.green}${s}${c.reset}`;
31
+ const yellow = (s) => `${c.yellow}${s}${c.reset}`;
32
+ const cyan = (s) => `${c.cyan}${s}${c.reset}`;
33
+ const red = (s) => `${c.red}${s}${c.reset}`;
34
+
35
+ // ─── Flags ───────────────────────────────────────────────────────────────────
36
+ // Each flag maps to a placeholder in the template, except the behavioral ones.
37
+ const FLAGS = {
38
+ owner: "OWNER",
39
+ repo: "REPO",
40
+ branch: "DEFAULT_BRANCH",
41
+ description: "DESCRIPTION",
42
+ package: "PACKAGE",
43
+ doi: "DOI",
44
+ docs: "DOCS_URL",
45
+ api: "API_URL",
46
+ youtube: "YOUTUBE_URL",
47
+ uptime: "UPTIME_ID",
48
+ "discord-id": "DISCORD_ID",
49
+ "discord-invite": "DISCORD_INVITE",
50
+ };
51
+
52
+ function parseArgs(argv) {
53
+ const opts = { values: {}, dir: ".", force: false, dryRun: false, yes: false, only: null, template: null };
54
+
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ const take = () => {
58
+ const next = argv[++i];
59
+ if (next === undefined) {
60
+ console.error(red(`Missing value for ${arg}`));
61
+ process.exit(2);
62
+ }
63
+ return next;
64
+ };
65
+
66
+ if (arg === "--help" || arg === "-h") return { help: true };
67
+ else if (arg === "--version" || arg === "-v") return { version: true };
68
+ else if (arg === "--force" || arg === "-f") opts.force = true;
69
+ else if (arg === "--dry-run" || arg === "-n") opts.dryRun = true;
70
+ else if (arg === "--yes" || arg === "-y") opts.yes = true;
71
+ else if (arg === "--dir") opts.dir = take();
72
+ else if (arg === "--template") opts.template = take();
73
+ else if (arg.endsWith("-only") && arg.startsWith("--")) {
74
+ const name = arg.slice(2, -5);
75
+ if (!SUBSETS[name]) {
76
+ console.error(red(`Unknown subset ${arg}. Try: ${Object.keys(SUBSETS).map((s) => `--${s}-only`).join(", ")}`));
77
+ process.exit(2);
78
+ }
79
+ opts.only = [...(opts.only ?? []), ...SUBSETS[name]];
80
+ } else if (arg.startsWith("--")) {
81
+ const [flag, inline] = arg.slice(2).split(/=(.*)/s);
82
+ const key = FLAGS[flag];
83
+ if (!key) {
84
+ console.error(red(`Unknown flag --${flag}. Run with --help.`));
85
+ process.exit(2);
86
+ }
87
+ opts.values[key] = inline ?? take();
88
+ } else {
89
+ // A bare argument is the target directory: `setup-git-repo ./my-repo`.
90
+ opts.dir = arg;
91
+ }
92
+ }
93
+
94
+ return opts;
95
+ }
96
+
97
+ function help() {
98
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
99
+ console.log(`
100
+ ${bold("setup-git-repo")} ${dim(`v${pkg.version}`)}
101
+
102
+ One command to give a repo the whole GitHub setup: Turborepo, the CI
103
+ workflows, the README badge block, and the docs explaining each of them.
104
+
105
+ ${bold("Usage")}
106
+
107
+ ${cyan("bunx setup-git-repo")} ${dim("# set up the repo you are standing in")}
108
+ ${cyan("bunx setup-git-repo ./my-repo")} ${dim("# or one somewhere else")}
109
+ ${cyan("bunx setup-git-repo --dry-run")} ${dim("# show what would be written")}
110
+
111
+ ${bold("Identity")} ${dim("(detected from git remote when omitted)")}
112
+
113
+ --owner <name> GitHub owner
114
+ --repo <name> Repository name
115
+ --branch <name> Default branch, used in workflow filters
116
+ --description <text> One-line description for the README
117
+
118
+ ${bold("Optional badges")} ${dim("(a badge whose value is omitted is left out of the README)")}
119
+
120
+ --package <name> npm package, for the version + downloads badges
121
+ --doi <10.5281/zenodo.N> Zenodo DOI
122
+ --docs <url> Documentation link
123
+ --api <url> API reference link
124
+ --youtube <url> Demo video link
125
+ --uptime <page-id> UptimeRobot status page id
126
+ --discord-id <id> Discord server id ${dim("(both are needed for")}
127
+ --discord-invite <url> Discord invite link ${dim("the Discord badge)")}
128
+
129
+ ${bold("Behavior")}
130
+
131
+ --dir <path> Target directory (default: the current one)
132
+ -f, --force Overwrite files that already exist
133
+ -n, --dry-run Print the plan, write nothing
134
+ -y, --yes Never prompt; use flags and git detection only
135
+ --workflows-only Only .github/
136
+ --badges-only Only README.md
137
+ --docs-only Only docs/
138
+ --turbo-only Only turbo.json, package.json, vitest.config.ts
139
+ --template <path> Use a template directory other than the bundled one
140
+
141
+ ${bold("After it runs")} it prints the secrets and repo settings the workflows
142
+ need. ${dim("docs/SECRETS.md")} has the same list with where each value comes from.
143
+ `);
144
+ }
145
+
146
+ async function prompt(rl, label, fallback) {
147
+ const suffix = fallback ? dim(` (${fallback})`) : dim(" (skip)");
148
+ const answer = (await rl.question(` ${label}${suffix}: `)).trim();
149
+ return answer || fallback || "";
150
+ }
151
+
152
+ async function main() {
153
+ const opts = parseArgs(process.argv.slice(2));
154
+
155
+ if (opts.help) return help();
156
+ if (opts.version) {
157
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
158
+ return console.log(pkg.version);
159
+ }
160
+
161
+ const target = resolve(opts.dir);
162
+ const templateDir = resolveTemplateDir(opts.template);
163
+
164
+ console.log(`\n${bold("setup-git-repo")} → ${cyan(target)}\n`);
165
+
166
+ if (!existsSync(target)) {
167
+ mkdirSync(target, { recursive: true });
168
+ console.log(dim(` created ${target}`));
169
+ }
170
+
171
+ // ── Identity: flags win, then git, then the directory name ────────────────
172
+ const detected = detectRepo(target);
173
+ const values = {
174
+ OWNER: opts.values.OWNER ?? detected.owner ?? "",
175
+ REPO: opts.values.REPO ?? detected.repo ?? target.split("/").pop(),
176
+ DEFAULT_BRANCH: opts.values.DEFAULT_BRANCH ?? detected.defaultBranch ?? "main",
177
+ DESCRIPTION: opts.values.DESCRIPTION ?? "",
178
+ ...opts.values,
179
+ };
180
+
181
+ if (!detected.isRepo) {
182
+ console.log(yellow(" Not a git repository — owner and branch cannot be detected."));
183
+ }
184
+
185
+ const interactive = !opts.yes && stdin.isTTY && stdout.isTTY;
186
+
187
+ if (interactive) {
188
+ const rl = createInterface({ input: stdin, output: stdout });
189
+ try {
190
+ console.log(bold(" Repository"));
191
+ values.OWNER = await prompt(rl, "GitHub owner", values.OWNER);
192
+ values.REPO = await prompt(rl, "Repository name", values.REPO);
193
+ values.DEFAULT_BRANCH = await prompt(rl, "Default branch", values.DEFAULT_BRANCH);
194
+ values.DESCRIPTION = await prompt(rl, "One-line description", values.DESCRIPTION);
195
+
196
+ console.log(`\n${bold(" Optional badges")} ${dim("— press Enter to leave one out")}`);
197
+ values.PACKAGE = await prompt(rl, "npm package name", values.PACKAGE);
198
+ values.DOI = await prompt(rl, "Zenodo DOI", values.DOI);
199
+ values.DOCS_URL = await prompt(rl, "Docs URL", values.DOCS_URL);
200
+ values.API_URL = await prompt(rl, "API URL", values.API_URL);
201
+ values.YOUTUBE_URL = await prompt(rl, "YouTube URL", values.YOUTUBE_URL);
202
+ values.UPTIME_ID = await prompt(rl, "UptimeRobot status page id", values.UPTIME_ID);
203
+ values.DISCORD_ID = await prompt(rl, "Discord server id", values.DISCORD_ID);
204
+ values.DISCORD_INVITE = await prompt(rl, "Discord invite URL", values.DISCORD_INVITE);
205
+ console.log("");
206
+ } finally {
207
+ rl.close();
208
+ }
209
+ }
210
+
211
+ if (!values.OWNER || !values.REPO) {
212
+ console.error(red("\n Owner and repo are required. Pass --owner and --repo, or run inside a git repo with an origin remote.\n"));
213
+ process.exit(1);
214
+ }
215
+ if (!values.DESCRIPTION) values.DESCRIPTION = `${values.REPO} — a Turborepo monorepo.`;
216
+
217
+ // ── Plan ──────────────────────────────────────────────────────────────────
218
+ const files = collectFiles(templateDir);
219
+ const plan = planFiles(files, {
220
+ exists: (file) => existsSync(join(target, destinationFor(file))),
221
+ force: opts.force,
222
+ only: opts.only,
223
+ });
224
+
225
+ const report = (label, list, color) => {
226
+ if (!list.length) return;
227
+ console.log(` ${color(label)} ${dim(`(${list.length})`)}`);
228
+ for (const file of list) console.log(` ${destinationFor(file)}`);
229
+ };
230
+
231
+ report(opts.dryRun ? "would write" : "write", plan.write, green);
232
+ report(opts.dryRun ? "would overwrite" : "overwrite", plan.overwrite, yellow);
233
+ report("skip, already present", plan.skip, dim);
234
+
235
+ if (plan.skip.length && !opts.force) {
236
+ console.log(dim("\n Re-run with --force to overwrite the skipped files."));
237
+ }
238
+
239
+ if (opts.dryRun) {
240
+ console.log(dim("\n --dry-run: nothing was written.\n"));
241
+ return;
242
+ }
243
+
244
+ for (const file of [...plan.write, ...plan.overwrite]) {
245
+ const destination = join(target, destinationFor(file));
246
+ mkdirSync(dirname(destination), { recursive: true });
247
+ writeFileSync(destination, render(readFileSync(join(templateDir, file), "utf8"), values));
248
+ }
249
+
250
+ // ── What is still on the human ────────────────────────────────────────────
251
+ const badges = configuredBadges(values);
252
+ const omitted = Object.keys(OPTIONAL_BADGES).filter((b) => !badges.has(b));
253
+
254
+ console.log(`\n${green("✓")} ${bold(`${plan.write.length + plan.overwrite.length} files written`)}\n`);
255
+
256
+ if (omitted.length) {
257
+ console.log(`${bold("Badges left out")} ${dim("(no value given)")}: ${omitted.join(", ")}`);
258
+ console.log(dim(" docs/BADGES.md has the snippet and setup steps for each.\n"));
259
+ }
260
+
261
+ console.log(bold("Next: the secrets these workflows need"));
262
+ console.log(dim(" Settings → Secrets and variables → Actions → New repository secret"));
263
+ console.log(`
264
+ ${cyan("CODECOV_TOKEN")} coverage + test analytics ${dim("(tests.yml)")}
265
+ ${cyan("NPM_TOKEN")} npm publishing ${dim("(npm-publish.yml — or set up trusted publishing instead)")}
266
+ ${cyan("CLOUDFLARE_API_TOKEN")} test report deploys ${dim("(deploy-test-reports.yml)")}
267
+ ${cyan("CLOUDFLARE_ACCOUNT_ID")} test report deploys ${dim("(deploy-test-reports.yml)")}
268
+ ${cyan("GIT_TOKEN")} auto-merge ${dim("(a PAT: GITHUB_TOKEN's merges do not trigger other workflows)")}
269
+ `);
270
+
271
+ console.log(bold("Next: the repository settings they need"));
272
+ console.log(`
273
+ Settings → General → Pull Requests → ${cyan("Allow auto-merge")}
274
+ Settings → Branches → branch protection with ${cyan("at least one required check")}
275
+ ${dim("without both, --auto is rejected and the fallback merges without waiting for CI")}
276
+ Settings → Actions → General → ${cyan("Read and write permissions")} ${dim("(for the version-bump commit)")}
277
+ Settings → Actions → General → ${cyan("Allow Actions to create pull requests")}
278
+ `);
279
+
280
+ console.log(`${bold("Then")}\n`);
281
+ console.log(` ${cyan("bun install")}`);
282
+ console.log(` ${cyan("bun run build")} ${dim("# turbo fans out across packages/ and apps/")}`);
283
+ console.log(`\n Read ${cyan("docs/BADGES.md")}, ${cyan("docs/WORKFLOWS.md")} and ${cyan("docs/SECRETS.md")} for the details.\n`);
284
+ }
285
+
286
+ main().catch((error) => {
287
+ console.error(red(`\n${error.message}\n`));
288
+ process.exit(1);
289
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "setup-git-repo",
3
+ "version": "1.0.0",
4
+ "description": "One command to set up a GitHub repo: Turborepo, CI workflows, README badges, and the docs for each",
5
+ "type": "module",
6
+ "bin": {
7
+ "setup-git-repo": "bin/setup-git-repo.js"
8
+ },
9
+ "scripts": {
10
+ "sync-template": "node scripts/sync-template.mjs",
11
+ "prepack": "node scripts/sync-template.mjs",
12
+ "test": "vitest run",
13
+ "test:ci": "vitest run --reporter=junit --outputFile=junit.xml --coverage",
14
+ "test:watch": "vitest",
15
+ "coverage": "vitest run --coverage"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "template"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "github-actions",
27
+ "turborepo",
28
+ "monorepo",
29
+ "badges",
30
+ "shields",
31
+ "codecov",
32
+ "ci",
33
+ "scaffold",
34
+ "template"
35
+ ],
36
+ "author": "vtempest <grokthiscontact@gmail.com>",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/OpenSourceAGI/dev-tools-starter-agent.git",
41
+ "directory": "packages/setup-git-repo"
42
+ },
43
+ "homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/setup-git-repo",
44
+ "devDependencies": {
45
+ "@vitest/coverage-v8": "^4.1.0",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Parse an `owner/repo` pair out of a git remote URL.
5
+ *
6
+ * Handles the four shapes a remote realistically takes:
7
+ * https://github.com/owner/repo.git
8
+ * git@github.com:owner/repo.git
9
+ * ssh://git@github.com/owner/repo
10
+ * https://user:token@github.com/owner/repo.git (credentials in the URL)
11
+ *
12
+ * Returns null rather than throwing, so a repo with an unusual remote falls
13
+ * back to prompting instead of failing the run.
14
+ */
15
+ export function parseRemote(url) {
16
+ if (typeof url !== "string" || !url.trim()) return null;
17
+
18
+ let rest = url.trim().replace(/\.git$/, "");
19
+
20
+ // scp-style: git@host:owner/repo
21
+ const scp = rest.match(/^[^/]+@[^:/]+:(.+)$/);
22
+ if (scp) {
23
+ rest = scp[1];
24
+ } else {
25
+ // Any URL scheme, with or without embedded credentials.
26
+ const withScheme = rest.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\/(.+)$/i);
27
+ if (withScheme) rest = withScheme[1];
28
+ }
29
+
30
+ const parts = rest.split("/").filter(Boolean);
31
+ if (parts.length < 2) return null;
32
+
33
+ // Take the last two segments: enterprise hosts prefix paths with more.
34
+ const [owner, repo] = parts.slice(-2);
35
+ if (!owner || !repo) return null;
36
+ return { owner, repo };
37
+ }
38
+
39
+ /** Run a git command, returning trimmed stdout or null if git fails. */
40
+ function git(args, cwd) {
41
+ try {
42
+ return execFileSync("git", args, {
43
+ cwd,
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"],
46
+ }).trim();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Best-effort detection of owner, repo and default branch for a checkout.
54
+ *
55
+ * The default branch is read from origin's HEAD ref, which is only present once
56
+ * something has fetched it. When it is missing, fall back to the current branch
57
+ * — in a fresh `git init` that is the branch the first push will create.
58
+ */
59
+ export function detectRepo(cwd = process.cwd()) {
60
+ const remote = git(["remote", "get-url", "origin"], cwd);
61
+ const parsed = parseRemote(remote);
62
+
63
+ let defaultBranch = null;
64
+ const originHead = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
65
+ if (originHead) defaultBranch = originHead.replace(/^origin\//, "");
66
+ if (!defaultBranch) defaultBranch = git(["branch", "--show-current"], cwd);
67
+
68
+ return {
69
+ owner: parsed?.owner ?? null,
70
+ repo: parsed?.repo ?? null,
71
+ defaultBranch: defaultBranch || null,
72
+ isRepo: git(["rev-parse", "--is-inside-work-tree"], cwd) === "true",
73
+ };
74
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * Find the template directory.
9
+ *
10
+ * Two locations, in order, because the package has to work both ways:
11
+ *
12
+ * 1. `template/` inside the package — what the published tarball ships.
13
+ * `scripts/sync-template.mjs` copies it in at pack time.
14
+ * 2. `starter-templates/template-git-repo/` up in the monorepo — what a
15
+ * checkout has, and the single source of truth the copy is made from.
16
+ *
17
+ * Checking the bundled copy first matters: a CLI that only climbs out of its
18
+ * own package works in the repo and breaks the moment it is installed from npm,
19
+ * which is exactly the trap `create-starter-app` fell into.
20
+ */
21
+ export function resolveTemplateDir(override = null) {
22
+ if (override) {
23
+ const abs = resolve(override);
24
+ if (!existsSync(abs)) {
25
+ throw new Error(`--template path does not exist: ${abs}`);
26
+ }
27
+ return abs;
28
+ }
29
+
30
+ const candidates = [
31
+ join(here, "..", "template"),
32
+ join(here, "..", "..", "..", "starter-templates", "template-git-repo"),
33
+ ];
34
+
35
+ for (const candidate of candidates) {
36
+ if (existsSync(join(candidate, "package.json"))) return resolve(candidate);
37
+ }
38
+
39
+ throw new Error(
40
+ "Could not find the template. Looked in:\n" +
41
+ candidates.map((c) => ` ${resolve(c)}`).join("\n") +
42
+ "\nPass --template <path> to point at it explicitly.",
43
+ );
44
+ }