template-git-repo 0.1.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 +91 -0
- package/bin/template-git-repo.js +257 -0
- package/docs/ACTIONS.md +195 -0
- package/docs/BADGES.md +221 -0
- package/package.json +56 -0
- package/src/apply.js +164 -0
- package/src/badges.js +353 -0
- package/src/context.js +228 -0
- package/src/index.js +29 -0
- package/src/readme.js +60 -0
- package/template/.github/workflows/auto-merge-and-create-prs.yml +102 -0
- package/template/.github/workflows/auto-merge-claude.yml +54 -0
- package/template/.github/workflows/deploy-test-reports.yml +98 -0
- package/template/.github/workflows/npm-publish.yml +320 -0
- package/template/.github/workflows/tests.yml +100 -0
- package/template/codecov.yml +46 -0
- package/template/scripts/list-test-packages.mjs +137 -0
- package/template/scripts/next-free-version.mjs +169 -0
- package/template/scripts/pin-workspace-deps.mjs +108 -0
- package/template/scripts/restore-pinned-deps.mjs +101 -0
- package/template/scripts/workspace-build-order.mjs +129 -0
- package/template/turbo.json +36 -0
package/src/badges.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The badge catalog: the README badge row, as data.
|
|
3
|
+
*
|
|
4
|
+
* Modeled on the header of OpenSourceAGI/qwksearch-research-agent, which is
|
|
5
|
+
* where this set was worked out. Each entry knows three things a hand-written
|
|
6
|
+
* `<img>` tag does not:
|
|
7
|
+
*
|
|
8
|
+
* needs the context it cannot render without (an npm package name, a
|
|
9
|
+
* Discord server id, a DOI). A badge whose inputs are missing is
|
|
10
|
+
* left out instead of shipping a README with a broken image.
|
|
11
|
+
* setup what a human has to do outside this repo to make it real, which is
|
|
12
|
+
* the part every badge README omits. `docs/BADGES.md` is generated
|
|
13
|
+
* from these strings, so the docs cannot drift from the catalog.
|
|
14
|
+
* group which line of the badge block it belongs on. Twenty badges in one
|
|
15
|
+
* run is a wall; four rows of five reads.
|
|
16
|
+
*
|
|
17
|
+
* Adding a badge means adding an entry here — nothing else in the package
|
|
18
|
+
* needs to know about it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Row order in the rendered block. */
|
|
22
|
+
export const GROUPS = ['identity', 'quality', 'community', 'stack'];
|
|
23
|
+
|
|
24
|
+
/** @typedef {Record<string, string | undefined>} BadgeContext */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `img.shields.io/badge/` takes its label, color and logo positionally, and
|
|
28
|
+
* every literal `-` in a label has to be doubled. Getting that wrong produces a
|
|
29
|
+
* badge that renders but says the wrong thing.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} label
|
|
32
|
+
* @param {string} color
|
|
33
|
+
* @param {Record<string, string>} [params]
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function shieldsBadge(label, color, params = {}) {
|
|
37
|
+
const encoded = encodeURIComponent(label.replaceAll('-', '--'));
|
|
38
|
+
const query = new URLSearchParams(params).toString();
|
|
39
|
+
return `https://img.shields.io/badge/${encoded}-${color}${query ? `?${query}` : ''}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @type {ReadonlyArray<{ id: string, title: string, group: string, needs: string[], alt: string, href: (c: BadgeContext) => string, img: (c: BadgeContext) => string, setup: string, height?: string }>} */
|
|
43
|
+
export const BADGES = [
|
|
44
|
+
{
|
|
45
|
+
id: 'doi',
|
|
46
|
+
title: 'Zenodo DOI',
|
|
47
|
+
group: 'identity',
|
|
48
|
+
needs: ['doi'],
|
|
49
|
+
alt: 'DOI',
|
|
50
|
+
href: (c) => `https://doi.org/${c.doi}`,
|
|
51
|
+
img: (c) => `https://zenodo.org/badge/DOI/${c.doi}.svg`,
|
|
52
|
+
setup:
|
|
53
|
+
'Sign in to zenodo.org with GitHub, flip this repository on under Account → GitHub, then publish a GitHub Release. Zenodo archives the release and mints a DOI. Use the *concept* DOI (the one that always resolves to the newest version), not the per-release DOI.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'deepwiki',
|
|
57
|
+
title: 'Ask DeepWiki',
|
|
58
|
+
group: 'identity',
|
|
59
|
+
needs: [],
|
|
60
|
+
alt: 'Ask DeepWiki',
|
|
61
|
+
href: (c) => `https://deepwiki.com/${c.repoSlug}`,
|
|
62
|
+
img: () => 'https://deepwiki.com/badge.svg',
|
|
63
|
+
setup:
|
|
64
|
+
'Nothing to configure for a public repo — visit deepwiki.com/<owner>/<repo> once to trigger the first index. Private repos need the DeepWiki GitHub App installed.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'docs',
|
|
68
|
+
title: 'Documentation',
|
|
69
|
+
group: 'identity',
|
|
70
|
+
needs: ['docsUrl'],
|
|
71
|
+
alt: 'Documentation',
|
|
72
|
+
href: (c) => c.docsUrl,
|
|
73
|
+
img: () => shieldsBadge('Docs', 'blue', { logo: 'ReadTheDocs', logoColor: 'white' }),
|
|
74
|
+
setup:
|
|
75
|
+
'Point this at wherever your docs are actually hosted. This is a static shields.io badge — it says "Docs" whether or not the link works, so it is on you to keep the URL alive.',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'api',
|
|
79
|
+
title: 'API reference',
|
|
80
|
+
group: 'identity',
|
|
81
|
+
needs: ['apiUrl'],
|
|
82
|
+
alt: 'API',
|
|
83
|
+
href: (c) => c.apiUrl,
|
|
84
|
+
img: () => shieldsBadge('API', 'blue', { logo: 'fastapi', logoColor: 'white' }),
|
|
85
|
+
setup: 'Point this at your OpenAPI / Swagger page. Static badge, same caveat as Docs.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'youtube',
|
|
89
|
+
title: 'YouTube demo',
|
|
90
|
+
group: 'identity',
|
|
91
|
+
needs: ['youtubeUrl'],
|
|
92
|
+
alt: 'YouTube',
|
|
93
|
+
href: (c) => c.youtubeUrl,
|
|
94
|
+
img: () => shieldsBadge('YouTube', 'red', { style: 'for-the-badge', logo: 'youtube', logoColor: 'white' }),
|
|
95
|
+
setup: 'A demo video does more for a README than three paragraphs. Any YouTube URL works.',
|
|
96
|
+
height: '20px',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'deploy-cloudflare',
|
|
100
|
+
title: 'Deploy to Cloudflare Workers',
|
|
101
|
+
group: 'identity',
|
|
102
|
+
needs: ['cloudflareDeploy'],
|
|
103
|
+
alt: 'Deploy to Cloudflare Workers',
|
|
104
|
+
href: (c) => `https://deploy.workers.cloudflare.com/?url=https://github.com/${c.repoSlug}`,
|
|
105
|
+
img: () => 'https://deploy.workers.cloudflare.com/button',
|
|
106
|
+
setup:
|
|
107
|
+
'The repo must contain a wrangler.toml (or wrangler.jsonc) at the path the button clones. Cloudflare forks the repo into the visitor\'s account and runs the build, so anything the build needs must come from `[vars]` or be prompted for — a build that requires a secret fails for every visitor.',
|
|
108
|
+
height: '24px',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'stars',
|
|
112
|
+
title: 'GitHub stars',
|
|
113
|
+
group: 'community',
|
|
114
|
+
needs: [],
|
|
115
|
+
alt: 'GitHub Stars',
|
|
116
|
+
href: (c) => `https://github.com/${c.repoSlug}/stargazers`,
|
|
117
|
+
img: (c) => `https://img.shields.io/github/stars/${c.repoSlug}`,
|
|
118
|
+
setup: 'Nothing to configure. Public repos only — shields.io cannot read a private repo.',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'npm-downloads',
|
|
122
|
+
title: 'npm monthly downloads',
|
|
123
|
+
group: 'quality',
|
|
124
|
+
needs: ['npmPackage'],
|
|
125
|
+
alt: 'NPM Monthly Downloads',
|
|
126
|
+
href: (c) => `https://www.npmjs.com/package/${c.npmPackage}`,
|
|
127
|
+
img: (c) => `https://img.shields.io/npm/dm/${c.npmPackage}.svg`,
|
|
128
|
+
setup:
|
|
129
|
+
'Requires at least one published version. In a monorepo pick the package you want to advertise — the badge counts one package, not the workspace.',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'npm-version',
|
|
133
|
+
title: 'npm version',
|
|
134
|
+
group: 'quality',
|
|
135
|
+
needs: ['npmPackage'],
|
|
136
|
+
alt: 'npm version',
|
|
137
|
+
href: (c) => `https://www.npmjs.com/package/${c.npmPackage}`,
|
|
138
|
+
img: (c) => `https://img.shields.io/npm/v/${c.npmPackage}.svg`,
|
|
139
|
+
setup: 'Shows the `latest` dist-tag. Publishing under a different tag will not move it.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'codecov',
|
|
143
|
+
title: 'Code coverage',
|
|
144
|
+
group: 'quality',
|
|
145
|
+
needs: [],
|
|
146
|
+
alt: 'Coverage',
|
|
147
|
+
href: (c) => `https://codecov.io/gh/${c.repoSlug}`,
|
|
148
|
+
img: (c) => `https://codecov.io/gh/${c.repoSlug}/graph/badge.svg`,
|
|
149
|
+
setup:
|
|
150
|
+
'Add the repo at codecov.io, copy its upload token into a CODECOV_TOKEN repository secret, and make sure a workflow uploads `coverage/lcov.info` (tests.yml does). Until the first successful upload the badge reads "unknown", which looks identical to a broken badge — check the Codecov dashboard, not the badge, when debugging.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'workflow',
|
|
154
|
+
title: 'CI status',
|
|
155
|
+
group: 'quality',
|
|
156
|
+
needs: ['workflowFile'],
|
|
157
|
+
alt: 'CI status',
|
|
158
|
+
href: (c) => `https://github.com/${c.repoSlug}/actions/workflows/${c.workflowFile}`,
|
|
159
|
+
img: (c) =>
|
|
160
|
+
`https://github.com/${c.repoSlug}/actions/workflows/${c.workflowFile}/badge.svg?branch=${c.defaultBranch}`,
|
|
161
|
+
setup:
|
|
162
|
+
'The filename must match the workflow file exactly, and `?branch=` must name your default branch — without it the badge shows the most recent run on *any* branch, so a failing feature branch reads as a broken main.',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'test-report',
|
|
166
|
+
title: 'Hosted test report',
|
|
167
|
+
group: 'quality',
|
|
168
|
+
needs: ['testReportUrl'],
|
|
169
|
+
alt: 'Test Report',
|
|
170
|
+
href: (c) => c.testReportUrl,
|
|
171
|
+
img: () => shieldsBadge('Test Report', 'brightgreen', { logo: 'vitest', logoColor: 'white' }),
|
|
172
|
+
setup:
|
|
173
|
+
'Backed by `.github/workflows/deploy-test-reports.yml`, which publishes the Vitest HTML reporter output to Cloudflare Workers on every push to the default branch. Needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID secrets.',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'uptime',
|
|
177
|
+
title: 'Uptime',
|
|
178
|
+
group: 'quality',
|
|
179
|
+
needs: ['uptimeUrl'],
|
|
180
|
+
alt: 'Uptime Status',
|
|
181
|
+
href: (c) => c.uptimeUrl,
|
|
182
|
+
img: () => shieldsBadge('Uptime-Status', 'brightgreen', { logo: 'uptimerobot', logoColor: 'white' }),
|
|
183
|
+
setup:
|
|
184
|
+
'Create a monitor at uptimerobot.com, then a public status page, and link the status page here. This is a static badge — it says "brightgreen" even while you are down. For a live one use the UptimeRobot shields endpoint with a read-only API key.',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'commit-activity',
|
|
188
|
+
title: 'Commit activity',
|
|
189
|
+
group: 'community',
|
|
190
|
+
needs: [],
|
|
191
|
+
alt: 'Commit activity',
|
|
192
|
+
href: (c) => `https://github.com/${c.repoSlug}/graphs/contributors`,
|
|
193
|
+
img: (c) => `https://img.shields.io/github/commit-activity/m/${c.repoSlug}`,
|
|
194
|
+
setup: 'Nothing to configure. Commits per month — a quiet month reads as an abandoned project, which is worth knowing before you add it.',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'last-commit',
|
|
198
|
+
title: 'Last commit',
|
|
199
|
+
group: 'community',
|
|
200
|
+
needs: [],
|
|
201
|
+
alt: 'GitHub last commit',
|
|
202
|
+
href: (c) => `https://github.com/${c.repoSlug}/commits/${c.defaultBranch}/`,
|
|
203
|
+
img: (c) => `https://img.shields.io/github/last-commit/${c.repoSlug}.svg`,
|
|
204
|
+
setup: 'Nothing to configure. Same caveat as commit activity.',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'discord',
|
|
208
|
+
title: 'Discord',
|
|
209
|
+
group: 'community',
|
|
210
|
+
needs: ['discordId', 'discordInvite'],
|
|
211
|
+
alt: 'Join Discord',
|
|
212
|
+
href: (c) => c.discordInvite,
|
|
213
|
+
img: (c) =>
|
|
214
|
+
`https://img.shields.io/discord/${c.discordId}.svg?label=Chat&logo=Discord&colorB=7289da&style=flat`,
|
|
215
|
+
setup:
|
|
216
|
+
'Two different values: the numeric server id drives the online-member count (Server Settings → Widget → Enable Server Widget, then copy the Server ID), and the invite link is where the badge points. Without the widget enabled the badge reads "invite" instead of a count.',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'prs-welcome',
|
|
220
|
+
title: 'PRs welcome',
|
|
221
|
+
group: 'community',
|
|
222
|
+
needs: [],
|
|
223
|
+
alt: 'PRs Welcome',
|
|
224
|
+
href: () =>
|
|
225
|
+
'https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request',
|
|
226
|
+
img: () => shieldsBadge('PRs-welcome', 'brightgreen'),
|
|
227
|
+
setup: 'Static. Worth backing with a CONTRIBUTING.md so the badge is not the only thing that says it.',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'license',
|
|
231
|
+
title: 'License',
|
|
232
|
+
group: 'community',
|
|
233
|
+
needs: [],
|
|
234
|
+
alt: 'License',
|
|
235
|
+
href: (c) => `https://github.com/${c.repoSlug}/blob/${c.defaultBranch}/LICENSE.md`,
|
|
236
|
+
img: (c) => `https://img.shields.io/github/license/${c.repoSlug}`,
|
|
237
|
+
setup:
|
|
238
|
+
'Reads the license GitHub detected, which comes from a recognized LICENSE file at the repo root. A custom or modified license shows as "unknown" no matter what the file says.',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'stack',
|
|
242
|
+
title: 'Tech stack chips',
|
|
243
|
+
group: 'stack',
|
|
244
|
+
needs: ['stack'],
|
|
245
|
+
alt: 'Tech stack',
|
|
246
|
+
href: () => '',
|
|
247
|
+
img: () => '',
|
|
248
|
+
setup:
|
|
249
|
+
'Plain static shields with a simple-icons logo — `?logo=<slug>` accepts any slug from simpleicons.org. Purely decorative: they say what the project is built with at a glance. Pass a comma-separated list (e.g. `--stack Claude,Cloudflare,Next.js`).',
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
/** Brand colors for the stack chips, so the common ones look right by default. */
|
|
254
|
+
const STACK_COLORS = {
|
|
255
|
+
claude: 'D97757',
|
|
256
|
+
cloudflare: 'F38020',
|
|
257
|
+
'next.js': 'black',
|
|
258
|
+
react: '20232A',
|
|
259
|
+
typescript: '3178C6',
|
|
260
|
+
bun: '14151A',
|
|
261
|
+
vite: '646CFF',
|
|
262
|
+
vercel: 'black',
|
|
263
|
+
postgresql: '4169E1',
|
|
264
|
+
tailwindcss: '06B6D4',
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {string} name
|
|
269
|
+
* @returns {string} an `<img>` chip for one stack entry
|
|
270
|
+
*/
|
|
271
|
+
export function stackChip(name) {
|
|
272
|
+
const key = name.trim().toLowerCase();
|
|
273
|
+
const color = STACK_COLORS[key] ?? '555555';
|
|
274
|
+
const logo = key.replace(/[.\s]/g, '');
|
|
275
|
+
return `<img src="${shieldsBadge(name.trim(), color, { logo, logoColor: 'white' })}" alt="${name.trim()}" />`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Which badges can render with the context available, and which cannot.
|
|
280
|
+
*
|
|
281
|
+
* Reporting the skipped ones is the point: a badge silently missing from the
|
|
282
|
+
* README is indistinguishable from one you forgot to ask for.
|
|
283
|
+
*
|
|
284
|
+
* @param {BadgeContext} context
|
|
285
|
+
* @param {{ only?: string[], exclude?: string[] }} [options]
|
|
286
|
+
* @returns {{ included: typeof BADGES, skipped: { id: string, missing: string[] }[] }}
|
|
287
|
+
*/
|
|
288
|
+
export function selectBadges(context, options = {}) {
|
|
289
|
+
const { only, exclude = [] } = options;
|
|
290
|
+
|
|
291
|
+
const included = [];
|
|
292
|
+
const skipped = [];
|
|
293
|
+
|
|
294
|
+
for (const badge of BADGES) {
|
|
295
|
+
if (only && !only.includes(badge.id)) continue;
|
|
296
|
+
if (exclude.includes(badge.id)) continue;
|
|
297
|
+
|
|
298
|
+
const missing = badge.needs.filter((key) => !context[key]);
|
|
299
|
+
if (missing.length > 0) {
|
|
300
|
+
skipped.push({ id: badge.id, missing });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
included.push(badge);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { included, skipped };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Render one badge as the `<a><img></a>` pair it becomes in the README.
|
|
312
|
+
*
|
|
313
|
+
* @param {typeof BADGES[number]} badge
|
|
314
|
+
* @param {BadgeContext} context
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
export function renderBadge(badge, context) {
|
|
318
|
+
if (badge.id === 'stack') {
|
|
319
|
+
return String(context.stack)
|
|
320
|
+
.split(',')
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.map(stackChip)
|
|
323
|
+
.join(' ');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const height = badge.height ? ` height="${badge.height}"` : '';
|
|
327
|
+
const img = `<img${height} src="${badge.img(context)}" alt="${badge.alt}" />`;
|
|
328
|
+
|
|
329
|
+
const href = badge.href(context);
|
|
330
|
+
return href ? `<a href="${href}">${img}</a>` : img;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The whole badge block, grouped one row per `GROUPS` entry.
|
|
335
|
+
*
|
|
336
|
+
* @param {BadgeContext} context
|
|
337
|
+
* @param {{ only?: string[], exclude?: string[] }} [options]
|
|
338
|
+
* @returns {{ markdown: string, skipped: { id: string, missing: string[] }[] }}
|
|
339
|
+
*/
|
|
340
|
+
export function renderBadgeBlock(context, options = {}) {
|
|
341
|
+
const { included, skipped } = selectBadges(context, options);
|
|
342
|
+
|
|
343
|
+
const rows = GROUPS.map((group) =>
|
|
344
|
+
included
|
|
345
|
+
.filter((badge) => badge.group === group)
|
|
346
|
+
.map((badge) => ` ${renderBadge(badge, context)}`)
|
|
347
|
+
.join('\n'),
|
|
348
|
+
).filter(Boolean);
|
|
349
|
+
|
|
350
|
+
const markdown = ['<p align="center">', rows.join('\n <br />\n'), '</p>'].join('\n');
|
|
351
|
+
|
|
352
|
+
return { markdown, skipped };
|
|
353
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work out everything the templates need to be substituted with, from the repo
|
|
3
|
+
* itself, so the common case is one command and no flags.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walk up from `start` to the directory holding `.git`.
|
|
11
|
+
*
|
|
12
|
+
* Templating the wrong directory is the one failure that is annoying to undo —
|
|
13
|
+
* running from `packages/foo` must not scatter a `.github/` in there — so the
|
|
14
|
+
* root is found rather than assumed.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} [start]
|
|
17
|
+
* @returns {string | null}
|
|
18
|
+
*/
|
|
19
|
+
export function findRepoRoot(start = process.cwd()) {
|
|
20
|
+
let dir = path.resolve(start);
|
|
21
|
+
|
|
22
|
+
for (;;) {
|
|
23
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return null;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} args
|
|
32
|
+
* @param {string} cwd
|
|
33
|
+
* @returns {string} trimmed stdout, or '' if git failed
|
|
34
|
+
*/
|
|
35
|
+
function git(args, cwd) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `owner/repo` from a remote URL, in any of the shapes git remotes come in:
|
|
45
|
+
* https, ssh, `git@`, with or without `.git`.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} url
|
|
48
|
+
* @returns {string | null}
|
|
49
|
+
*/
|
|
50
|
+
export function parseRepoSlug(url) {
|
|
51
|
+
if (!url) return null;
|
|
52
|
+
|
|
53
|
+
const match = url.match(/(?:github\.com[/:])([^/]+)\/(.+?)(?:\.git)?$/);
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
|
|
56
|
+
return `${match[1]}/${match[2]}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The default branch as the *remote* sees it, falling back to the current
|
|
61
|
+
* branch.
|
|
62
|
+
*
|
|
63
|
+
* This matters more than it looks: the CI status badge takes `?branch=`, and a
|
|
64
|
+
* badge pinned to the wrong branch shows a passing repo as failing (or worse,
|
|
65
|
+
* the reverse) for as long as nobody checks.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} root
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function detectDefaultBranch(root) {
|
|
71
|
+
const symbolic = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root);
|
|
72
|
+
if (symbolic) return symbolic.replace(/^origin\//, '');
|
|
73
|
+
|
|
74
|
+
const current = git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
|
|
75
|
+
// A detached HEAD reports "HEAD", which is not a branch name.
|
|
76
|
+
if (current && current !== 'HEAD') return current;
|
|
77
|
+
|
|
78
|
+
return 'main';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which package manager the repo already uses. The workflows run its `install`,
|
|
83
|
+
* so guessing wrong means CI installs with a tool the lockfile is not for.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} root
|
|
86
|
+
* @returns {'bun' | 'pnpm' | 'yarn' | 'npm'}
|
|
87
|
+
*/
|
|
88
|
+
export function detectPackageManager(root) {
|
|
89
|
+
const manifest = readJson(path.join(root, 'package.json'));
|
|
90
|
+
const declared = manifest?.packageManager;
|
|
91
|
+
if (typeof declared === 'string') {
|
|
92
|
+
const name = declared.split('@')[0];
|
|
93
|
+
if (['bun', 'pnpm', 'yarn', 'npm'].includes(name)) return name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (fs.existsSync(path.join(root, 'bun.lock')) || fs.existsSync(path.join(root, 'bun.lockb'))) return 'bun';
|
|
97
|
+
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
98
|
+
if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
|
|
99
|
+
|
|
100
|
+
return 'npm';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} file
|
|
105
|
+
* @returns {Record<string, any> | null}
|
|
106
|
+
*/
|
|
107
|
+
export function readJson(file) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The directory the workspace globs point at (`packages/*` -> `packages`),
|
|
117
|
+
* which the publish workflow needs for `git add` and for the build order.
|
|
118
|
+
*
|
|
119
|
+
* @param {Record<string, any> | null} manifest
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function detectPackagesDir(manifest) {
|
|
123
|
+
const globs = Array.isArray(manifest?.workspaces)
|
|
124
|
+
? manifest.workspaces
|
|
125
|
+
: (manifest?.workspaces?.packages ?? []);
|
|
126
|
+
|
|
127
|
+
const glob = globs.find((entry) => entry.endsWith('/*'));
|
|
128
|
+
return glob ? glob.slice(0, -2) : 'packages';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The package to advertise in the npm badges: the first non-private workspace
|
|
133
|
+
* package, or the root package if it is publishable.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} root
|
|
136
|
+
* @param {string} packagesDir
|
|
137
|
+
* @returns {string | undefined}
|
|
138
|
+
*/
|
|
139
|
+
export function detectNpmPackage(root, packagesDir) {
|
|
140
|
+
const rootManifest = readJson(path.join(root, 'package.json'));
|
|
141
|
+
if (rootManifest?.name && !rootManifest.private) return rootManifest.name;
|
|
142
|
+
|
|
143
|
+
const dir = path.join(root, packagesDir);
|
|
144
|
+
if (!fs.existsSync(dir)) return undefined;
|
|
145
|
+
|
|
146
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
147
|
+
if (!entry.isDirectory()) continue;
|
|
148
|
+
const pkg = readJson(path.join(dir, entry.name, 'package.json'));
|
|
149
|
+
if (pkg?.name && !pkg.private) return pkg.name;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Everything the templates and badges need, detected then overridden by flags.
|
|
157
|
+
*
|
|
158
|
+
* @param {{ cwd?: string, overrides?: Record<string, string | undefined> }} [options]
|
|
159
|
+
* @returns {Record<string, any>}
|
|
160
|
+
*/
|
|
161
|
+
export function buildContext(options = {}) {
|
|
162
|
+
const { cwd = process.cwd(), overrides = {} } = options;
|
|
163
|
+
|
|
164
|
+
const root = findRepoRoot(cwd) ?? path.resolve(cwd);
|
|
165
|
+
const manifest = readJson(path.join(root, 'package.json'));
|
|
166
|
+
const packagesDir = overrides.packagesDir ?? detectPackagesDir(manifest);
|
|
167
|
+
|
|
168
|
+
const repoSlug =
|
|
169
|
+
overrides.repoSlug ??
|
|
170
|
+
parseRepoSlug(git(['remote', 'get-url', 'origin'], root)) ??
|
|
171
|
+
undefined;
|
|
172
|
+
|
|
173
|
+
const [owner, repo] = repoSlug ? repoSlug.split('/') : [undefined, undefined];
|
|
174
|
+
|
|
175
|
+
const context = {
|
|
176
|
+
root,
|
|
177
|
+
repoSlug,
|
|
178
|
+
owner,
|
|
179
|
+
repo,
|
|
180
|
+
defaultBranch: overrides.defaultBranch ?? detectDefaultBranch(root),
|
|
181
|
+
packageManager: overrides.packageManager ?? detectPackageManager(root),
|
|
182
|
+
packagesDir,
|
|
183
|
+
packagesGlob: `${packagesDir}/*`,
|
|
184
|
+
npmPackage: overrides.npmPackage ?? detectNpmPackage(root, packagesDir),
|
|
185
|
+
workflowFile: overrides.workflowFile ?? 'tests.yml',
|
|
186
|
+
// Everything below has no sensible default — a badge that needs one of
|
|
187
|
+
// these is skipped until it is passed in.
|
|
188
|
+
doi: overrides.doi,
|
|
189
|
+
docsUrl: overrides.docsUrl,
|
|
190
|
+
apiUrl: overrides.apiUrl,
|
|
191
|
+
youtubeUrl: overrides.youtubeUrl,
|
|
192
|
+
uptimeUrl: overrides.uptimeUrl,
|
|
193
|
+
testReportUrl: overrides.testReportUrl,
|
|
194
|
+
discordId: overrides.discordId,
|
|
195
|
+
discordInvite: overrides.discordInvite,
|
|
196
|
+
stack: overrides.stack,
|
|
197
|
+
cloudflareDeploy: overrides.cloudflareDeploy,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return context;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Substitute `{{TOKEN}}` placeholders in a template file's text.
|
|
205
|
+
*
|
|
206
|
+
* Unknown tokens are left alone rather than replaced with `undefined`: a
|
|
207
|
+
* workflow with a visible `{{THING}}` in it is an obvious bug, while one that
|
|
208
|
+
* runs `bun install` as `undefined install` is a confusing one.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} text
|
|
211
|
+
* @param {Record<string, any>} context
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
export function substitute(text, context) {
|
|
215
|
+
const values = {
|
|
216
|
+
DEFAULT_BRANCH: context.defaultBranch,
|
|
217
|
+
PACKAGE_MANAGER: context.packageManager,
|
|
218
|
+
PACKAGES_DIR: context.packagesDir,
|
|
219
|
+
PACKAGES_GLOB: context.packagesGlob,
|
|
220
|
+
REPO_SLUG: context.repoSlug,
|
|
221
|
+
OWNER: context.owner,
|
|
222
|
+
REPO: context.repo,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (match, token) =>
|
|
226
|
+
values[token] === undefined ? match : String(values[token]),
|
|
227
|
+
);
|
|
228
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point. The CLI is the intended interface; these exports exist so
|
|
3
|
+
* the pieces can be scripted (generating a badge row for a docs site, checking
|
|
4
|
+
* a repo's workflows in a test) without shelling out.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
BADGES,
|
|
8
|
+
GROUPS,
|
|
9
|
+
renderBadge,
|
|
10
|
+
renderBadgeBlock,
|
|
11
|
+
selectBadges,
|
|
12
|
+
shieldsBadge,
|
|
13
|
+
stackChip,
|
|
14
|
+
} from './badges.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
buildContext,
|
|
18
|
+
detectDefaultBranch,
|
|
19
|
+
detectNpmPackage,
|
|
20
|
+
detectPackageManager,
|
|
21
|
+
detectPackagesDir,
|
|
22
|
+
findRepoRoot,
|
|
23
|
+
parseRepoSlug,
|
|
24
|
+
substitute,
|
|
25
|
+
} from './context.js';
|
|
26
|
+
|
|
27
|
+
export { END_MARKER, START_MARKER, hasUnmarkedBadges, injectBadges } from './readme.js';
|
|
28
|
+
|
|
29
|
+
export { planFiles, applyPlan } from './apply.js';
|
package/src/readme.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put the badge block into a README without destroying what is already there.
|
|
3
|
+
*
|
|
4
|
+
* Badges are the one part of a README that gets regenerated, so they live
|
|
5
|
+
* between markers. Re-running the CLI replaces what is between them and touches
|
|
6
|
+
* nothing else — which is what makes it safe to run again after publishing a
|
|
7
|
+
* package or setting up Codecov.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const START_MARKER = '<!-- template-git-repo:badges:start -->';
|
|
11
|
+
export const END_MARKER = '<!-- template-git-repo:badges:end -->';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Replace the marked block, or insert one.
|
|
15
|
+
*
|
|
16
|
+
* With no markers present, the block goes at the very top — above the title —
|
|
17
|
+
* because that is where a badge row belongs and because inserting it anywhere
|
|
18
|
+
* else would require guessing at the document's structure.
|
|
19
|
+
*
|
|
20
|
+
* An existing unmarked badge row (a `<p align="center">` full of shields.io
|
|
21
|
+
* images at the top of the file) is left exactly where it is: silently deleting
|
|
22
|
+
* hand-written badges would be worse than a duplicate the author can see and
|
|
23
|
+
* remove.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} readme current README text ('' for a new file)
|
|
26
|
+
* @param {string} block the rendered badge markdown
|
|
27
|
+
* @returns {{ content: string, action: 'replaced' | 'inserted' }}
|
|
28
|
+
*/
|
|
29
|
+
export function injectBadges(readme, block) {
|
|
30
|
+
const marked = [START_MARKER, block, END_MARKER].join('\n');
|
|
31
|
+
|
|
32
|
+
const start = readme.indexOf(START_MARKER);
|
|
33
|
+
const end = readme.indexOf(END_MARKER);
|
|
34
|
+
|
|
35
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
36
|
+
return {
|
|
37
|
+
content: readme.slice(0, start) + marked + readme.slice(end + END_MARKER.length),
|
|
38
|
+
action: 'replaced',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (readme.trim() === '') {
|
|
43
|
+
return { content: `${marked}\n`, action: 'inserted' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { content: `${marked}\n\n${readme.replace(/^\n+/, '')}`, action: 'inserted' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a README already carries a badge row this CLI did not write.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} readme
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function hasUnmarkedBadges(readme) {
|
|
56
|
+
if (readme.includes(START_MARKER)) return false;
|
|
57
|
+
|
|
58
|
+
const head = readme.slice(0, 4000);
|
|
59
|
+
return /img\.shields\.io|badge\.svg/.test(head);
|
|
60
|
+
}
|