@grimoire-rs/indexer 0.2.2 → 0.3.1
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 +59 -18
- package/dist/ci.d.ts +149 -0
- package/dist/ci.d.ts.map +1 -0
- package/dist/ci.js +338 -0
- package/dist/ci.js.map +1 -0
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +3 -9
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/ci.d.ts +6 -0
- package/dist/cli/ci.d.ts.map +1 -0
- package/dist/cli/ci.js +43 -0
- package/dist/cli/ci.js.map +1 -0
- package/dist/cli/dev.d.ts +7 -0
- package/dist/cli/dev.d.ts.map +1 -0
- package/dist/cli/dev.js +57 -0
- package/dist/cli/dev.js.map +1 -0
- package/dist/cli/init.d.ts +18 -2
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +584 -100
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +71 -10
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/out_dir.d.ts +11 -0
- package/dist/cli/out_dir.d.ts.map +1 -0
- package/dist/cli/out_dir.js +30 -0
- package/dist/cli/out_dir.js.map +1 -0
- package/dist/cli/validate.d.ts +1 -0
- package/dist/cli/validate.d.ts.map +1 -1
- package/dist/cli/validate.js +47 -1
- package/dist/cli/validate.js.map +1 -1
- package/dist/renderer/astro/components/BrandMark.js +0 -1
- package/dist/renderer/astro/components/Catalog.js +0 -1
- package/dist/renderer/astro/lib/base.js +0 -1
- package/dist/renderer/astro/lib/catalog.js +0 -1
- package/dist/renderer/astro/lib/code.js +0 -1
- package/dist/renderer/astro/lib/data.js +0 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +16 -0
- package/dist/renderer/index.js.map +1 -1
- package/dist/templates.d.ts +12 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +40 -0
- package/dist/templates.js.map +1 -0
- package/dist/validate/adapters/files.d.ts.map +1 -1
- package/dist/validate/adapters/files.js +13 -3
- package/dist/validate/adapters/files.js.map +1 -1
- package/dist/validate/index.js +17 -2
- package/dist/validate/index.js.map +1 -1
- package/package.json +2 -2
- package/templates/README.md +115 -19
- package/templates/ci/github-automerge.yml +36 -0
- package/templates/ci/github-enrich.yml +27 -0
- package/templates/ci/github-pages.yml +64 -0
- package/templates/ci/github-publish.yml +89 -0
- package/templates/ci/github-validate.yml +157 -0
- package/templates/ci/github-verify-ci.yml +47 -0
- package/templates/ci/gitlab-ci.yml +149 -0
- package/templates/ci/gitlab-enrich.sh +26 -0
- package/templates/ci/gitlab-publish.yml +50 -0
- package/templates/ci/gitlab-verify.yml +16 -0
- package/templates/ci/header.txt +19 -0
- package/templates/gitattributes +4 -0
- package/templates/gitignore +7 -2
- package/templates/package.json +18 -0
- package/templates/publish.toml +2 -2
- package/dist/renderer/astro/components/BrandMark.d.ts.map +0 -1
- package/dist/renderer/astro/components/BrandMark.js.map +0 -1
- package/dist/renderer/astro/components/Catalog.d.ts.map +0 -1
- package/dist/renderer/astro/components/Catalog.js.map +0 -1
- package/dist/renderer/astro/lib/base.d.ts.map +0 -1
- package/dist/renderer/astro/lib/base.js.map +0 -1
- package/dist/renderer/astro/lib/catalog.d.ts.map +0 -1
- package/dist/renderer/astro/lib/catalog.js.map +0 -1
- package/dist/renderer/astro/lib/code.d.ts.map +0 -1
- package/dist/renderer/astro/lib/code.js.map +0 -1
- package/dist/renderer/astro/lib/data.d.ts.map +0 -1
- package/dist/renderer/astro/lib/data.js.map +0 -1
- package/templates/github-pages.yml +0 -24
- package/templates/github-validate.yml +0 -51
- package/templates/gitlab-ci.yml +0 -13
- package/templates/reusable/gitlab-index-ci.yml +0 -125
package/dist/cli/init.js
CHANGED
|
@@ -6,14 +6,58 @@
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
9
|
import * as prompts from "@clack/prompts";
|
|
10
|
+
import { FORGES, loadCiConfig, PUBLISH_TRIGGERS, renderCi, resolveCi, staleCi, } from "../ci.js";
|
|
11
|
+
import { CONFIG_FILE } from "../config.js";
|
|
12
|
+
import { fromTemplate } from "../templates.js";
|
|
11
13
|
import { CliError, EXIT } from "./exit.js";
|
|
12
|
-
/** Scaffold templates ship beside `dist/`, so this resolves identically from `src/cli/` and `dist/cli/`. */
|
|
13
|
-
const TEMPLATE_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
|
|
14
|
-
/** `{{name}}`, but never GitHub Actions' `${{ ... }}` — hence the `$` lookbehind. */
|
|
15
|
-
const PLACEHOLDER = /(?<!\$)\{\{\s*([A-Za-z_]\w*)\s*\}\}/g;
|
|
16
14
|
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
15
|
+
/** The committed allowlist the contribution gate reads. */
|
|
16
|
+
const POLICY_FILE = "index-policy.json";
|
|
17
|
+
/** Present only in the combined layout — its existence *is* the layout. */
|
|
18
|
+
const MANIFEST_FILE = "publish.toml";
|
|
19
|
+
/**
|
|
20
|
+
* Scaffold files a repository legitimately fills with its own content, and what
|
|
21
|
+
* rewriting one from the scaffold actually costs.
|
|
22
|
+
*
|
|
23
|
+
* `--force` rewrites them wholesale — no merge, no backup, no diff — so the
|
|
24
|
+
* only honest way to mention it is beside the specific thing it would discard.
|
|
25
|
+
* A blanket "re-run with --force to overwrite them" points at silent data loss
|
|
26
|
+
* and reads like routine advice.
|
|
27
|
+
*/
|
|
28
|
+
const USER_OWNED = {
|
|
29
|
+
"package.json": "added scripts and dependencies",
|
|
30
|
+
".gitignore": "every ignore rule added to it",
|
|
31
|
+
".gitattributes": "every attribute rule added to it",
|
|
32
|
+
"README.md": "the README this index publishes",
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Two files `--force` does not rewrite, at any scope, ever.
|
|
36
|
+
*
|
|
37
|
+
* Both hold data no scaffold can regenerate: the packages this repo declares,
|
|
38
|
+
* and the registry hosts, reserved namespaces and trusted bots the contribution
|
|
39
|
+
* gate bounds every entry by. Every other scaffold file is derivable from the
|
|
40
|
+
* answers plus `index.config.json`; these two are not, and losing either is
|
|
41
|
+
* silent — a rewritten policy narrows the gate's allowlist back to `ghcr.io`
|
|
42
|
+
* without failing anything, and a rewritten manifest publishes nothing on the
|
|
43
|
+
* next release because it declares nothing.
|
|
44
|
+
*
|
|
45
|
+
* The template's own `npm run setup` is `init --force .`, so this is not a
|
|
46
|
+
* rare path: it is the documented one. Creation is unaffected — an absent file
|
|
47
|
+
* is still written, so deleting one to re-scaffold it still works.
|
|
48
|
+
*/
|
|
49
|
+
const NEVER_OVERWRITTEN = {
|
|
50
|
+
[MANIFEST_FILE]: "the packages this repo declares",
|
|
51
|
+
[POLICY_FILE]: "the allowlist, reserved namespaces and trusted bots the gate reads",
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A re-run that could not overwrite anything even if it wanted to: an index is
|
|
55
|
+
* already here and `--force` was not passed, so `write` leaves every differing
|
|
56
|
+
* file alone. Nothing is decidable, so nothing is asked.
|
|
57
|
+
*/
|
|
58
|
+
function isTopUp(dir, flags) {
|
|
59
|
+
return !flags.quick && !flags.force && fs.existsSync(path.join(dir, CONFIG_FILE));
|
|
60
|
+
}
|
|
17
61
|
/**
|
|
18
62
|
* What `[announce]` gets when nothing knew this repo's URL. It is not a
|
|
19
63
|
* locator, so `grim publish --announce` fails on it before any network call —
|
|
@@ -25,6 +69,26 @@ const UNDERIVED = "REPLACE-ME";
|
|
|
25
69
|
const PAGES_HOST = /^([\w-]+)\.(github|gitlab)\.io$/;
|
|
26
70
|
/** scp-like `[user@]host:owner/repo` — the one git remote shape that is not a URL. */
|
|
27
71
|
const SCP_REMOTE = /^(?:[^@/]+@)?([\w.-]+):(?!\/)(.+)$/;
|
|
72
|
+
/** What the scaffold writes when nobody has said anything better. */
|
|
73
|
+
function boilerplateDescription(title) {
|
|
74
|
+
return `${title}, a Grimoire package index.`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Regenerate the description from the title, unless the one on disk was
|
|
78
|
+
* rewritten by hand.
|
|
79
|
+
*
|
|
80
|
+
* Both halves matter. Keeping every prior description would freeze the
|
|
81
|
+
* boilerplate at the first title anyone typed; keeping none would silently
|
|
82
|
+
* overwrite real copy on the next `init --force`. Comparing against what the
|
|
83
|
+
* scaffold *would have written for the prior title* separates the two exactly.
|
|
84
|
+
*/
|
|
85
|
+
function keptDescription(prior, title) {
|
|
86
|
+
if (prior.description === undefined)
|
|
87
|
+
return boilerplateDescription(title);
|
|
88
|
+
return prior.description === boilerplateDescription(prior.title ?? "")
|
|
89
|
+
? boilerplateDescription(title)
|
|
90
|
+
: prior.description;
|
|
91
|
+
}
|
|
28
92
|
function slug(value) {
|
|
29
93
|
return value
|
|
30
94
|
.trim()
|
|
@@ -39,23 +103,6 @@ function titleCase(name) {
|
|
|
39
103
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
40
104
|
.join(" ");
|
|
41
105
|
}
|
|
42
|
-
function readTemplate(rel) {
|
|
43
|
-
const file = path.join(TEMPLATE_DIR, rel);
|
|
44
|
-
try {
|
|
45
|
-
return fs.readFileSync(file, "utf8");
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
throw new CliError(`scaffold template ${rel} is missing from ${TEMPLATE_DIR} — the installed package is incomplete`, EXIT.unavailable);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function render(template, vars, rel) {
|
|
52
|
-
return template.replace(PLACEHOLDER, (match, key) => {
|
|
53
|
-
if (!(key in vars)) {
|
|
54
|
-
throw new CliError(`scaffold template ${rel} references unknown placeholder {{${key}}}`);
|
|
55
|
-
}
|
|
56
|
-
return vars[key];
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
106
|
/** Validate a URL the user typed. Returns the reason it is bad, or `null`. */
|
|
60
107
|
function badUrl(value) {
|
|
61
108
|
let url;
|
|
@@ -76,6 +123,12 @@ function badName(value) {
|
|
|
76
123
|
}
|
|
77
124
|
return null;
|
|
78
125
|
}
|
|
126
|
+
/** What `SiteConfig.logo` accepts — checked here so a typo fails now, not at build time. */
|
|
127
|
+
function badLogo(value) {
|
|
128
|
+
if (value === "" || /^(\/|https?:\/\/)/i.test(value))
|
|
129
|
+
return null;
|
|
130
|
+
return "must be a site-root path (/logo.svg) or an http(s) URL";
|
|
131
|
+
}
|
|
79
132
|
/**
|
|
80
133
|
* The target dir's `origin` remote as an https URL — set when the repo was
|
|
81
134
|
* created on the forge and cloned before scaffolding into it. Any remote
|
|
@@ -92,6 +145,19 @@ function gitRemoteUrl(dir) {
|
|
|
92
145
|
catch {
|
|
93
146
|
return undefined; // no git, no repo, or no `origin`
|
|
94
147
|
}
|
|
148
|
+
return normalizeRepoUrl(remote);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Reduce any repository URL to the https form the rest of this file derives
|
|
152
|
+
* from: Pages URL, forge, announce target and the site's own header link.
|
|
153
|
+
*
|
|
154
|
+
* Applied to `--repo-url` as well as to the git remote, because the obvious
|
|
155
|
+
* thing to paste into it is GitHub's clone box — `https://github.com/acme/idx.git`
|
|
156
|
+
* — and used verbatim that trailing `.git` became `https://acme.github.io/idx.git`
|
|
157
|
+
* as `site`, hence `/idx.git` as Astro's `base`, on a Pages deployment that
|
|
158
|
+
* serves `/idx`. Every link on the built site pointed one path segment wrong.
|
|
159
|
+
*/
|
|
160
|
+
function normalizeRepoUrl(remote) {
|
|
95
161
|
const bare = remote.replace(/\.git$/, "");
|
|
96
162
|
const scp = SCP_REMOTE.exec(bare);
|
|
97
163
|
if (scp)
|
|
@@ -128,6 +194,94 @@ function repoUrlFromPages(baseUrl) {
|
|
|
128
194
|
const project = url.pathname.split("/").filter(Boolean)[0] ?? url.hostname;
|
|
129
195
|
return `https://${pages[2]}.com/${pages[1]}/${project}`;
|
|
130
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Say out loud what was read off the git remote, and what could not be.
|
|
199
|
+
*
|
|
200
|
+
* Everything below is inferred rather than asked, and an inference nobody
|
|
201
|
+
* sees is one nobody checks — a wrong `site` is only noticed once the deploy
|
|
202
|
+
* serves 404s from a subpath that does not exist. The silences matter as much
|
|
203
|
+
* as the hits: a self-hosted forge can serve Pages from anywhere, so there is
|
|
204
|
+
* no URL to guess, and an unrecognised host is not evidence of GitHub.
|
|
205
|
+
*/
|
|
206
|
+
function reportDerivation(what) {
|
|
207
|
+
const say = (label, value) => console.log(` ${label.padEnd(12)}${value}`);
|
|
208
|
+
if (!what.remoteUrl) {
|
|
209
|
+
say("no remote", "nothing to derive - set `site` in index.config.json yourself");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
say(what.fromRemote ? "from origin" : "repository", what.remoteUrl);
|
|
213
|
+
if (what.kept.forge)
|
|
214
|
+
say("forge", `${what.kept.forge} (kept from ${CONFIG_FILE})`);
|
|
215
|
+
else if (what.forge)
|
|
216
|
+
say("forge", what.forge);
|
|
217
|
+
else if (what.forgeFallback) {
|
|
218
|
+
say("forge", "github (the host is neither github nor gitlab - pass --forge)");
|
|
219
|
+
}
|
|
220
|
+
// "not derivable" is only true when nothing else already answered it.
|
|
221
|
+
// Printing it beside a config that names a custom domain read as a warning
|
|
222
|
+
// about a value that was never in doubt.
|
|
223
|
+
if (what.kept.baseUrl)
|
|
224
|
+
say("site", `${what.kept.baseUrl} (kept from ${CONFIG_FILE})`);
|
|
225
|
+
else if (what.baseUrl)
|
|
226
|
+
say("site", what.baseUrl);
|
|
227
|
+
else
|
|
228
|
+
say("site", "not derivable from this host - set `site` in index.config.json");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Which forge hosts `repoUrl`. Read off the host, so a self-hosted
|
|
232
|
+
* `gitlab.example.com` or `github.acme.internal` lands on the right pipeline
|
|
233
|
+
* without anyone having to answer a question about it. Anything unrecognised
|
|
234
|
+
* returns `undefined` and the prompt asks.
|
|
235
|
+
*/
|
|
236
|
+
function forgeFromRepoUrl(repoUrl) {
|
|
237
|
+
let host;
|
|
238
|
+
try {
|
|
239
|
+
host = new URL(repoUrl).hostname.toLowerCase();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
if (host === "github.com" || host.startsWith("github."))
|
|
245
|
+
return "github";
|
|
246
|
+
if (host === "gitlab.com" || host.startsWith("gitlab."))
|
|
247
|
+
return "gitlab";
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The Pages URL a forge serves `repoUrl` from — the inverse of
|
|
252
|
+
* [`repoUrlFromPages`], and the reason nobody has to know their own Pages URL
|
|
253
|
+
* to scaffold an index.
|
|
254
|
+
*
|
|
255
|
+
* `github.com/acme/idx` -> `https://acme.github.io/idx`, and the repository
|
|
256
|
+
* *named after* the Pages host is served from its root rather than a
|
|
257
|
+
* subpath. GitLab keeps everything below the top-level group in the path, so
|
|
258
|
+
* `gitlab.com/acme/team/idx` -> `https://acme.gitlab.io/team/idx`.
|
|
259
|
+
*
|
|
260
|
+
* Only the two public hosts are answered. A self-hosted instance can serve
|
|
261
|
+
* Pages from anywhere, and a custom domain is a fact this command cannot
|
|
262
|
+
* observe — both are why the value is a prompt default and not a decision.
|
|
263
|
+
*/
|
|
264
|
+
function pagesUrlFromRepo(repoUrl) {
|
|
265
|
+
let url;
|
|
266
|
+
try {
|
|
267
|
+
url = new URL(repoUrl);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const host = url.hostname.toLowerCase();
|
|
273
|
+
const forge = host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : undefined;
|
|
274
|
+
if (!forge)
|
|
275
|
+
return undefined;
|
|
276
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
277
|
+
if (segments.length < 2)
|
|
278
|
+
return undefined;
|
|
279
|
+
const pagesHost = `${segments[0]}.${forge}.io`.toLowerCase();
|
|
280
|
+
const project = segments.slice(1).join("/");
|
|
281
|
+
return project.toLowerCase() === pagesHost
|
|
282
|
+
? `https://${pagesHost}`
|
|
283
|
+
: `https://${pagesHost}/${project}`;
|
|
284
|
+
}
|
|
131
285
|
/**
|
|
132
286
|
* The `index/<host>/<namespace>/` this repo's entries land under: its path
|
|
133
287
|
* minus the repo itself — one segment on GitHub, possibly nested on GitLab.
|
|
@@ -142,13 +296,59 @@ function announceNamespace(repoUrl) {
|
|
|
142
296
|
}
|
|
143
297
|
return segments.length > 1 ? segments.slice(0, -1).join("/") : undefined;
|
|
144
298
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
299
|
+
function readJson(file) {
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
302
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
303
|
+
return parsed;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* absent or unreadable — there is simply no prior answer to offer */
|
|
308
|
+
}
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
/** A non-empty string, or nothing. `""` is not an answer worth defaulting to. */
|
|
312
|
+
function text(value) {
|
|
313
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
314
|
+
}
|
|
315
|
+
function nested(value) {
|
|
316
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
317
|
+
? value
|
|
318
|
+
: {};
|
|
319
|
+
}
|
|
320
|
+
function priorAnswers(dir) {
|
|
321
|
+
const config = readJson(path.join(dir, CONFIG_FILE));
|
|
322
|
+
const ci = nested(config.ci);
|
|
323
|
+
const registry = nested(config.registry);
|
|
324
|
+
const hosts = readJson(path.join(dir, POLICY_FILE)).registryHosts;
|
|
325
|
+
const forge = ci.forge;
|
|
326
|
+
const publish = ci.publish;
|
|
327
|
+
return {
|
|
328
|
+
name: text(readJson(path.join(dir, "package.json")).name),
|
|
329
|
+
title: text(config.brand),
|
|
330
|
+
description: text(config.description),
|
|
331
|
+
baseUrl: text(config.site),
|
|
332
|
+
registryAlias: text(registry.alias),
|
|
333
|
+
// The gate reads the whole list; this only offers the first back as the
|
|
334
|
+
// prompt default. A hand-widened allowlist is not narrowed by answering.
|
|
335
|
+
registryHost: Array.isArray(hosts) ? text(hosts[0]) : undefined,
|
|
336
|
+
logo: text(config.logo),
|
|
337
|
+
repoUrl: text(config.repoUrl),
|
|
338
|
+
forge: FORGES.includes(forge) ? forge : undefined,
|
|
339
|
+
publish: PUBLISH_TRIGGERS.includes(publish) ? publish : undefined,
|
|
340
|
+
// The layout is a fact about the tree, not a key in the config.
|
|
341
|
+
withSkills: fs.existsSync(path.join(dir, MANIFEST_FILE)) || undefined,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
150
344
|
async function resolveAnswers(dir, flags) {
|
|
151
|
-
const
|
|
345
|
+
const prior = priorAnswers(dir);
|
|
346
|
+
const defaultName = prior.name ?? (slug(path.basename(path.resolve(dir))) || "my-index");
|
|
347
|
+
// Scaffolding into a clone is the common path — the git remote is where the
|
|
348
|
+
// forge and the Pages URL come from — so "initialize a git repository?" was
|
|
349
|
+
// being asked precisely when the answer could not matter: `initGit` no-ops
|
|
350
|
+
// on an existing `.git`. Decide it here instead of prompting for it.
|
|
351
|
+
const alreadyGit = fs.existsSync(path.join(dir, ".git"));
|
|
152
352
|
// Flags always win, and any flag value is validated whether or not a
|
|
153
353
|
// prompt would have caught it — `--quick` skips the prompt, not the check.
|
|
154
354
|
for (const [flag, value, check] of [
|
|
@@ -156,6 +356,7 @@ async function resolveAnswers(dir, flags) {
|
|
|
156
356
|
["--registry", flags.registry, badName],
|
|
157
357
|
["--base-url", flags.baseUrl, badUrl],
|
|
158
358
|
["--repo-url", flags.repoUrl, badUrl],
|
|
359
|
+
["--logo", flags.logo, badLogo],
|
|
159
360
|
]) {
|
|
160
361
|
if (value !== undefined) {
|
|
161
362
|
const reason = check(value);
|
|
@@ -163,28 +364,87 @@ async function resolveAnswers(dir, flags) {
|
|
|
163
364
|
throw new CliError(`${flag} ${JSON.stringify(value)}: ${reason}`, EXIT.data);
|
|
164
365
|
}
|
|
165
366
|
}
|
|
166
|
-
|
|
367
|
+
// The git remote is the one thing a cloned-then-scaffolded repo already
|
|
368
|
+
// knows about itself, and everything else falls out of it: which forge runs
|
|
369
|
+
// the CI, where Pages will serve the site, and what `--announce` targets. A
|
|
370
|
+
// `--repo-url` overrides it, for scaffolding before the remote exists.
|
|
371
|
+
const remoteUrl = flags.repoUrl ? normalizeRepoUrl(flags.repoUrl) : gitRemoteUrl(dir);
|
|
372
|
+
// Order everywhere below: an explicit flag, then what this index already
|
|
373
|
+
// said, then what can be derived, then the first-run default. A prior answer
|
|
374
|
+
// outranks derivation because it *is* a decision — a custom domain in `site`
|
|
375
|
+
// must not be replaced by the Pages URL the remote implies.
|
|
376
|
+
const derivedBaseUrl = remoteUrl ? pagesUrlFromRepo(remoteUrl) : undefined;
|
|
377
|
+
const derivedForge = remoteUrl ? forgeFromRepoUrl(remoteUrl) : undefined;
|
|
378
|
+
// A second `init` used to ask every question and then write nothing: `write`
|
|
379
|
+
// leaves a file that differs alone unless `--force`, so all eight answers
|
|
380
|
+
// landed on files it was never going to touch. Without `--force` there is
|
|
381
|
+
// nothing here to decide, so there is nothing to ask — say what is happening
|
|
382
|
+
// and top up whatever is missing from the values already on disk.
|
|
383
|
+
const topUp = isTopUp(dir, flags);
|
|
384
|
+
if (topUp) {
|
|
385
|
+
// Deliberately no `--force` suggestion here. It is the destructive path,
|
|
386
|
+
// and the footer names its cost per file — only for the files that would
|
|
387
|
+
// actually lose something.
|
|
388
|
+
console.error(`${CONFIG_FILE} is already here — nothing is asked, and nothing is overwritten.\n`);
|
|
389
|
+
}
|
|
390
|
+
if (flags.quick || topUp) {
|
|
167
391
|
const name = flags.name ?? defaultName;
|
|
168
|
-
|
|
169
|
-
|
|
392
|
+
// Falls back to localhost rather than a guess: `--quick` is the
|
|
393
|
+
// non-interactive path, and a wrong absolute URL is worse than an obvious
|
|
394
|
+
// placeholder the next step tells you to replace.
|
|
395
|
+
//
|
|
396
|
+
// `prior` before `derivedBaseUrl` matters most here, because `--quick`
|
|
397
|
+
// asks nothing: `npm run setup` is `init --force .`, so a re-run that
|
|
398
|
+
// preferred the derived URL would silently overwrite a custom domain.
|
|
399
|
+
const baseUrl = flags.baseUrl ?? prior.baseUrl ?? derivedBaseUrl ?? "http://localhost:4321";
|
|
400
|
+
// `--quick` asks nothing, so every one of these decisions is otherwise
|
|
401
|
+
// invisible — including the two that quietly did not happen: a self-hosted
|
|
402
|
+
// forge has no predictable Pages URL, and an unrecognised host falls back
|
|
403
|
+
// to GitHub Actions rather than guessing.
|
|
404
|
+
reportDerivation({
|
|
405
|
+
remoteUrl,
|
|
406
|
+
fromRemote: flags.repoUrl === undefined && remoteUrl !== undefined,
|
|
407
|
+
// Only what actually won. A derivation reported beside a value it did
|
|
408
|
+
// not produce is worse than silence — it reads as what was written.
|
|
409
|
+
baseUrl: flags.baseUrl === undefined && prior.baseUrl === undefined ? derivedBaseUrl : undefined,
|
|
410
|
+
forge: flags.forge === undefined && prior.forge === undefined ? derivedForge : undefined,
|
|
411
|
+
forgeFallback: flags.forge === undefined && prior.forge === undefined && derivedForge === undefined,
|
|
412
|
+
kept: {
|
|
413
|
+
baseUrl: flags.baseUrl === undefined ? prior.baseUrl : undefined,
|
|
414
|
+
forge: flags.forge === undefined ? prior.forge : undefined,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
const withSkills = flags.withSkills ?? prior.withSkills ?? false;
|
|
170
418
|
return {
|
|
171
419
|
name,
|
|
172
|
-
title: flags.title ?? titleCase(name),
|
|
420
|
+
title: flags.title ?? prior.title ?? titleCase(name),
|
|
173
421
|
baseUrl,
|
|
174
|
-
registryAlias: flags.registry ?? name,
|
|
175
|
-
registryHost: flags.registryHost ?? "ghcr.io",
|
|
176
|
-
logo: flags.logo ?? "",
|
|
177
|
-
forge: flags.forge ?? "github",
|
|
178
|
-
git: flags.git ?? true,
|
|
422
|
+
registryAlias: flags.registry ?? prior.registryAlias ?? name,
|
|
423
|
+
registryHost: flags.registryHost ?? prior.registryHost ?? "ghcr.io",
|
|
424
|
+
logo: flags.logo ?? prior.logo ?? "",
|
|
425
|
+
forge: flags.forge ?? prior.forge ?? derivedForge ?? "github",
|
|
426
|
+
git: alreadyGit ? false : (flags.git ?? true),
|
|
427
|
+
install: flags.install ?? true,
|
|
179
428
|
withSkills,
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
429
|
+
// Tied to the layout, not independent of it: rendering a publish
|
|
430
|
+
// workflow into a repository with no `publish.toml` would emit CI that
|
|
431
|
+
// fails on its first run.
|
|
432
|
+
publish: withSkills ? (flags.publish ?? prior.publish ?? "tag") : "never",
|
|
433
|
+
description: keptDescription(prior, flags.title ?? prior.title ?? titleCase(name)),
|
|
434
|
+
// The combined layout needs an announce target, and every layout wants
|
|
435
|
+
// the header's repository link, which has no default to fall back on.
|
|
436
|
+
// Empty stays empty — nothing is guessed, and a `publish.toml` with no
|
|
437
|
+
// target refuses to publish.
|
|
438
|
+
repoUrl: remoteUrl ?? prior.repoUrl ?? repoUrlFromPages(baseUrl) ?? "",
|
|
185
439
|
};
|
|
186
440
|
}
|
|
187
|
-
|
|
441
|
+
const revisiting = prior.baseUrl !== undefined;
|
|
442
|
+
prompts.intro(`grim-indexer — ${revisiting ? "reconfigure this index" : "new package index"}`);
|
|
443
|
+
// Otherwise a re-run looks exactly like a first run, and the defaults look
|
|
444
|
+
// like guesses rather than like this index's own answers.
|
|
445
|
+
if (revisiting) {
|
|
446
|
+
prompts.log.step(`Defaults below are what ${CONFIG_FILE} already says — Enter keeps them.`);
|
|
447
|
+
}
|
|
188
448
|
const name = flags.name ??
|
|
189
449
|
(await ask(prompts.text({
|
|
190
450
|
message: "Index name (used as the identifier)",
|
|
@@ -192,68 +452,157 @@ async function resolveAnswers(dir, flags) {
|
|
|
192
452
|
defaultValue: defaultName,
|
|
193
453
|
validate: (value) => badName(value || defaultName) ?? undefined,
|
|
194
454
|
})));
|
|
455
|
+
const titleDefault = prior.title ?? titleCase(name);
|
|
195
456
|
const title = flags.title ??
|
|
196
457
|
(await ask(prompts.text({
|
|
197
458
|
message: "Display title",
|
|
198
|
-
placeholder:
|
|
199
|
-
defaultValue:
|
|
459
|
+
placeholder: titleDefault,
|
|
460
|
+
defaultValue: titleDefault,
|
|
461
|
+
})));
|
|
462
|
+
if (remoteUrl) {
|
|
463
|
+
prompts.log.step(`Read from ${flags.repoUrl ? "--repo-url" : "the `origin` remote"}: ${remoteUrl}`);
|
|
464
|
+
}
|
|
465
|
+
// Asked before the base URL, because it is what the base URL defaults from.
|
|
466
|
+
// A repo cloned from its forge answers this without the user typing.
|
|
467
|
+
const repoUrl = remoteUrl ??
|
|
468
|
+
(await ask(prompts.text({
|
|
469
|
+
message: "Repository URL this index lives in",
|
|
470
|
+
placeholder: prior.repoUrl ?? "https://github.com/you/your-index",
|
|
471
|
+
defaultValue: prior.repoUrl ?? "",
|
|
472
|
+
// Blank is allowed: it writes a placeholder that refuses to publish,
|
|
473
|
+
// which beats forcing a URL the user does not have yet.
|
|
474
|
+
validate: (value) => (value ? (badUrl(value) ?? undefined) : undefined),
|
|
200
475
|
})));
|
|
476
|
+
// The whole point of asking for the repository first: on github.com and
|
|
477
|
+
// gitlab.com the Pages URL follows from it, so the answer is usually Enter.
|
|
478
|
+
// It stays a prompt because a custom domain (a CNAME on GitHub Pages, a
|
|
479
|
+
// GitLab Pages domain) is a fact this command cannot observe. The message
|
|
480
|
+
// names where the default came from, so accepting it is a decision rather
|
|
481
|
+
// than a shrug.
|
|
482
|
+
// A prior `site` outranks the derived Pages URL: a custom domain is exactly
|
|
483
|
+
// the case derivation cannot see, and re-deriving over it is how a second
|
|
484
|
+
// run silently moved a live index back onto `<owner>.github.io`.
|
|
485
|
+
const pagesUrl = prior.baseUrl ?? (repoUrl === remoteUrl ? derivedBaseUrl : pagesUrlFromRepo(repoUrl));
|
|
201
486
|
const baseUrl = flags.baseUrl ??
|
|
202
487
|
(await ask(prompts.text({
|
|
203
|
-
message:
|
|
204
|
-
|
|
205
|
-
|
|
488
|
+
message: pagesUrl
|
|
489
|
+
? `Base URL the index is served from (Enter for ${pagesUrl}, or type a custom domain)`
|
|
490
|
+
: "Base URL the index is served from",
|
|
491
|
+
placeholder: pagesUrl ?? "https://index.example.com",
|
|
492
|
+
defaultValue: pagesUrl ?? "",
|
|
493
|
+
validate: (value) => badUrl(value || (pagesUrl ?? "")) ?? undefined,
|
|
206
494
|
})));
|
|
207
|
-
|
|
208
|
-
// `publish.toml` and so announces nothing. The *derived* value is used
|
|
209
|
-
// either way, for the site's own "github" link (see `siteConfig`).
|
|
210
|
-
const derivedRepoUrl = gitRemoteUrl(dir) ?? repoUrlFromPages(baseUrl);
|
|
211
|
-
const repoUrl = !flags.withSkills
|
|
212
|
-
? (flags.repoUrl ?? derivedRepoUrl ?? "")
|
|
213
|
-
: (flags.repoUrl ??
|
|
214
|
-
(await ask(prompts.text({
|
|
215
|
-
message: "Repository URL this index lives in (`grim publish --announce` targets it)",
|
|
216
|
-
placeholder: derivedRepoUrl ?? "https://github.com/you/your-index",
|
|
217
|
-
defaultValue: derivedRepoUrl ?? "",
|
|
218
|
-
// Blank is allowed: it writes a placeholder that refuses to publish,
|
|
219
|
-
// which beats forcing a URL the user does not have yet.
|
|
220
|
-
validate: (value) => (value ? (badUrl(value) ?? undefined) : undefined),
|
|
221
|
-
}))));
|
|
495
|
+
const aliasDefault = prior.registryAlias ?? name;
|
|
222
496
|
const registryAlias = flags.registry ??
|
|
223
497
|
(await ask(prompts.text({
|
|
224
498
|
message: "Registry alias packages are published under",
|
|
225
|
-
placeholder:
|
|
226
|
-
defaultValue:
|
|
227
|
-
validate: (value) => badName(value ||
|
|
499
|
+
placeholder: aliasDefault,
|
|
500
|
+
defaultValue: aliasDefault,
|
|
501
|
+
validate: (value) => badName(value || aliasDefault) ?? undefined,
|
|
228
502
|
})));
|
|
229
503
|
const logo = flags.logo ??
|
|
230
504
|
(await ask(prompts.text({
|
|
231
|
-
message: "Brand logo (path or URL
|
|
232
|
-
|
|
505
|
+
message: "Brand logo (site-root path like /logo.svg, or a URL; blank for none)",
|
|
506
|
+
placeholder: prior.logo ?? "",
|
|
507
|
+
defaultValue: prior.logo ?? "",
|
|
508
|
+
validate: (value) => badLogo(value ?? "") ?? undefined,
|
|
509
|
+
})));
|
|
510
|
+
// Prompted, not assumed. This is the committed allowlist the contribution
|
|
511
|
+
// gate bounds every entry's `ref` by, and defaulting it silently left every
|
|
512
|
+
// index refusing anything not on ghcr.io - including its own packages.
|
|
513
|
+
const hostDefault = prior.registryHost ?? "ghcr.io";
|
|
514
|
+
const registryHost = flags.registryHost ??
|
|
515
|
+
(await ask(prompts.text({
|
|
516
|
+
message: "OCI registry host packages are pulled from (the gate's allowlist)",
|
|
517
|
+
placeholder: hostDefault,
|
|
518
|
+
defaultValue: hostDefault,
|
|
233
519
|
})));
|
|
520
|
+
// Derived from the repository's host when that is recognisable, so the
|
|
521
|
+
// common case never sees this question. One repository runs on one forge —
|
|
522
|
+
// rendering both left every index carrying a pipeline it would never run.
|
|
523
|
+
const detectedForge = repoUrl === remoteUrl ? derivedForge : forgeFromRepoUrl(repoUrl);
|
|
524
|
+
// A recorded `ci.forge` outranks detection. Both skip the prompt, but only
|
|
525
|
+
// one of them is a decision somebody made — re-detecting over it would swap
|
|
526
|
+
// a repository's whole pipeline on a re-run without asking.
|
|
527
|
+
const settledForge = prior.forge ?? detectedForge;
|
|
528
|
+
if (settledForge) {
|
|
529
|
+
prompts.log.step(prior.forge
|
|
530
|
+
? `CI: ${prior.forge}, from ${CONFIG_FILE}`
|
|
531
|
+
: `CI: ${settledForge}, from the repository host`);
|
|
532
|
+
}
|
|
234
533
|
const forge = flags.forge ??
|
|
534
|
+
settledForge ??
|
|
235
535
|
(await ask(prompts.select({
|
|
236
536
|
message: "CI to scaffold",
|
|
237
537
|
options: [
|
|
238
538
|
{ value: "github", label: "GitHub Actions" },
|
|
239
539
|
{ value: "gitlab", label: "GitLab CI" },
|
|
240
|
-
{ value: "both", label: "Both" },
|
|
241
540
|
],
|
|
242
541
|
initialValue: "github",
|
|
243
542
|
})));
|
|
244
|
-
const git =
|
|
245
|
-
|
|
543
|
+
const git = alreadyGit
|
|
544
|
+
? false
|
|
545
|
+
: (flags.git ??
|
|
546
|
+
(await ask(prompts.confirm({ message: "Initialize a git repository?", initialValue: true }))));
|
|
547
|
+
// The lock is what pins the renderer this index builds and validates with,
|
|
548
|
+
// and CI runs `npm ci` against it — so an index without one is an index
|
|
549
|
+
// whose first push fails. Declining is still allowed; the next steps say
|
|
550
|
+
// what to run.
|
|
551
|
+
// The combined layout was reachable only through `--with-skills`, so nobody
|
|
552
|
+
// who had not read the flag list knew an index can hold its own packages —
|
|
553
|
+
// which is the shape a team standing up its first index usually wants.
|
|
554
|
+
// Defaulted off, and the message says why: an announce opened by this repo's
|
|
555
|
+
// own CI token triggers no workflows on GitHub, so it arrives past the gate
|
|
556
|
+
// and wants a human. Separate repositories stay the recommendation.
|
|
557
|
+
const withSkills = flags.withSkills ??
|
|
558
|
+
(await ask(prompts.confirm({
|
|
559
|
+
message: "Publish your own skills from this repository too? (its announces are not gated)",
|
|
560
|
+
initialValue: prior.withSkills ?? false,
|
|
561
|
+
})));
|
|
562
|
+
// Only reachable once the layout is settled — a plain index has nothing to
|
|
563
|
+
// release, so the question would have no answer that changes anything. The
|
|
564
|
+
// two options are the two release habits, and `grim publish` skips versions
|
|
565
|
+
// the registry already has either way, which is what makes the every-push
|
|
566
|
+
// option reasonable rather than reckless.
|
|
567
|
+
const publish = !withSkills
|
|
568
|
+
? "never"
|
|
569
|
+
: (flags.publish ??
|
|
570
|
+
(await ask(prompts.select({
|
|
571
|
+
message: "Publish those packages from CI when?",
|
|
572
|
+
options: [
|
|
573
|
+
{ value: "tag", label: "On a v* tag", hint: "cut a release deliberately" },
|
|
574
|
+
{
|
|
575
|
+
value: "default-branch",
|
|
576
|
+
label: "On every push to the default branch",
|
|
577
|
+
hint: "a version bump is the release",
|
|
578
|
+
},
|
|
579
|
+
{ value: "never", label: "Never", hint: "publish by hand" },
|
|
580
|
+
],
|
|
581
|
+
initialValue: (prior.publish ?? "tag"),
|
|
582
|
+
}))));
|
|
583
|
+
const install = flags.install ??
|
|
584
|
+
(await ask(prompts.confirm({
|
|
585
|
+
message: "Install dependencies now? (writes package-lock.json)",
|
|
586
|
+
initialValue: true,
|
|
587
|
+
})));
|
|
246
588
|
return {
|
|
247
589
|
name,
|
|
248
590
|
title,
|
|
249
591
|
baseUrl,
|
|
250
592
|
registryAlias,
|
|
251
|
-
registryHost
|
|
593
|
+
registryHost,
|
|
252
594
|
logo,
|
|
253
595
|
forge,
|
|
254
596
|
git,
|
|
255
|
-
|
|
256
|
-
|
|
597
|
+
install,
|
|
598
|
+
withSkills,
|
|
599
|
+
publish,
|
|
600
|
+
description: keptDescription(prior, title),
|
|
601
|
+
// The Pages-URL fallback the `--quick` path has: someone who answered the
|
|
602
|
+
// base URL but left the repository blank has still said where this index
|
|
603
|
+
// lives, and dropping that left `repoUrl` empty — no header link, and a
|
|
604
|
+
// `publish.toml` that refuses to announce.
|
|
605
|
+
repoUrl: repoUrl || (repoUrlFromPages(baseUrl) ?? ""),
|
|
257
606
|
};
|
|
258
607
|
}
|
|
259
608
|
/** Unwrap a clack prompt, turning Ctrl-C into a clean non-error abort. */
|
|
@@ -277,13 +626,27 @@ function siteConfig(answers) {
|
|
|
277
626
|
return json({
|
|
278
627
|
site: answers.baseUrl,
|
|
279
628
|
brand: answers.title,
|
|
280
|
-
description:
|
|
281
|
-
|
|
629
|
+
description: answers.description,
|
|
630
|
+
// `logo`, not `favicon`. They are deliberately different keys (see
|
|
631
|
+
// `SiteConfig`): a favicon is drawn to read at 16px, a logo goes in the
|
|
632
|
+
// header and becomes the default `og:image`. The prompt asks for a brand
|
|
633
|
+
// logo, so that is where the answer belongs.
|
|
634
|
+
...(answers.logo ? { logo: answers.logo } : {}),
|
|
282
635
|
// The header's "github" link. Written whenever it could be derived —
|
|
283
636
|
// it has no default, because the only sane one would be somebody
|
|
284
637
|
// else's repository.
|
|
285
638
|
...(answers.repoUrl ? { repoUrl: answers.repoUrl } : {}),
|
|
286
639
|
registry: { alias: answers.registryAlias, index: answers.baseUrl },
|
|
640
|
+
// What the committed CI is rendered from. The remaining knobs
|
|
641
|
+
// (`nodeVersion`, `enrich`, `grimVersion`, `defaultBranch`,
|
|
642
|
+
// `allowManualEdits`) are left to their defaults rather than written out —
|
|
643
|
+
// `grim-indexer ci` resolves them the same way, so an absent key and its
|
|
644
|
+
// default render identically. Which renderer runs is not here at all:
|
|
645
|
+
// that is `package-lock.json`.
|
|
646
|
+
ci: {
|
|
647
|
+
forge: answers.forge,
|
|
648
|
+
...(answers.publish === "never" ? {} : { publish: answers.publish }),
|
|
649
|
+
},
|
|
287
650
|
});
|
|
288
651
|
}
|
|
289
652
|
/**
|
|
@@ -302,8 +665,19 @@ function indexPolicy(answers) {
|
|
|
302
665
|
trustedBots: [],
|
|
303
666
|
});
|
|
304
667
|
}
|
|
305
|
-
/**
|
|
306
|
-
|
|
668
|
+
/**
|
|
669
|
+
* The complete scaffold as (destination path, content) pairs — nothing
|
|
670
|
+
* touches disk yet.
|
|
671
|
+
*
|
|
672
|
+
* `ci` is the `ci` block that will be on disk when this returns, which is not
|
|
673
|
+
* always the one derived from the answers: `write` leaves an existing
|
|
674
|
+
* `index.config.json` alone unless `--force`. Rendering the workflows from
|
|
675
|
+
* the answers while the config kept its own values produced a tree whose
|
|
676
|
+
* committed CI did not match its committed config — and `ci --check`, which
|
|
677
|
+
* reads only the config, then failed on a tree `init` had just reported as
|
|
678
|
+
* successful.
|
|
679
|
+
*/
|
|
680
|
+
function plan(answers, version, ci) {
|
|
307
681
|
const vars = {
|
|
308
682
|
name: answers.name,
|
|
309
683
|
title: answers.title,
|
|
@@ -314,38 +688,38 @@ function plan(answers, version) {
|
|
|
314
688
|
announceRepo: answers.repoUrl || UNDERIVED,
|
|
315
689
|
announceNamespace: (answers.repoUrl && announceNamespace(answers.repoUrl)) || UNDERIVED,
|
|
316
690
|
version,
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
691
|
+
// `title` is free text and lands inside a JSON string literal in
|
|
692
|
+
// package.json. A quote in it would close that string and produce a
|
|
693
|
+
// manifest npm refuses to read. Every other template puts it in prose.
|
|
694
|
+
titleJson: JSON.stringify(answers.title).slice(1, -1),
|
|
320
695
|
};
|
|
321
|
-
const from = (rel, dest) => ({
|
|
322
|
-
path: dest,
|
|
323
|
-
content: render(readTemplate(rel), vars, rel),
|
|
324
|
-
});
|
|
696
|
+
const from = (rel, dest) => ({ path: dest, content: fromTemplate(rel, vars) });
|
|
325
697
|
const files = [
|
|
326
698
|
{ path: "index/.gitkeep", content: "" },
|
|
327
699
|
{ path: "index.config.json", content: siteConfig(answers) },
|
|
328
|
-
{ path:
|
|
700
|
+
{ path: POLICY_FILE, content: indexPolicy(answers) },
|
|
329
701
|
from("gitignore", ".gitignore"),
|
|
702
|
+
from("gitattributes", ".gitattributes"),
|
|
703
|
+
from("package.json", "package.json"),
|
|
330
704
|
from("README.md", "README.md"),
|
|
331
705
|
];
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
files.push(from("gitlab-ci.yml", ".gitlab-ci.yml"));
|
|
706
|
+
// The same renderer `grim-indexer ci` uses, driven by the `ci` block that
|
|
707
|
+
// will be on disk — so the scaffold is drift-free by construction and the
|
|
708
|
+
// guard it emits passes on the first push.
|
|
709
|
+
for (const [dest, content] of renderCi(resolveCi(ci))) {
|
|
710
|
+
files.push({ path: dest, content });
|
|
338
711
|
}
|
|
339
712
|
if (answers.withSkills) {
|
|
340
713
|
files.push({ path: "skills/.gitkeep", content: "" });
|
|
341
|
-
files.push(from(
|
|
714
|
+
files.push(from(MANIFEST_FILE, MANIFEST_FILE));
|
|
342
715
|
}
|
|
343
716
|
return files;
|
|
344
717
|
}
|
|
345
718
|
/**
|
|
346
719
|
* Write the scaffold. Re-running is safe: a file whose content already
|
|
347
720
|
* matches is reported `unchanged`, and one the user has edited is left
|
|
348
|
-
* alone as `skipped` unless `--force`.
|
|
721
|
+
* alone as `skipped` unless `--force`. A `NEVER_OVERWRITTEN` file that
|
|
722
|
+
* differs is reported `preserved` and left alone even with `--force`.
|
|
349
723
|
*/
|
|
350
724
|
function write(dir, files, force) {
|
|
351
725
|
const written = [];
|
|
@@ -357,6 +731,10 @@ function write(dir, files, force) {
|
|
|
357
731
|
written.push({ path: file.path, outcome: "unchanged" });
|
|
358
732
|
continue;
|
|
359
733
|
}
|
|
734
|
+
if (file.path in NEVER_OVERWRITTEN) {
|
|
735
|
+
written.push({ path: file.path, outcome: "preserved" });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
360
738
|
if (!force) {
|
|
361
739
|
written.push({ path: file.path, outcome: "skipped" });
|
|
362
740
|
continue;
|
|
@@ -384,6 +762,32 @@ function initGit(dir) {
|
|
|
384
762
|
return false;
|
|
385
763
|
}
|
|
386
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* Generate `package-lock.json` by installing. The lock is the load-bearing
|
|
767
|
+
* part: CI runs `npm ci` against it, so a repository without one cannot build,
|
|
768
|
+
* and which renderer runs would otherwise be resolved fresh on every runner.
|
|
769
|
+
*
|
|
770
|
+
* A failure is reported and survived — the scaffold is complete either way,
|
|
771
|
+
* and the next steps say what to run.
|
|
772
|
+
*/
|
|
773
|
+
function npmInstall(dir) {
|
|
774
|
+
try {
|
|
775
|
+
// `shell` on Windows: npm ships as `npm.cmd`, libuv's non-shell PATH
|
|
776
|
+
// search only tries `.com`/`.exe`, and Node refuses to spawn a `.cmd`
|
|
777
|
+
// without it — so without this branch the install could never succeed
|
|
778
|
+
// there, and every Windows scaffold silently shipped without a lockfile.
|
|
779
|
+
execFileSync("npm", ["install"], {
|
|
780
|
+
cwd: dir,
|
|
781
|
+
stdio: "ignore",
|
|
782
|
+
shell: process.platform === "win32",
|
|
783
|
+
});
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
console.error("warning: `npm install` failed - run it yourself to write package-lock.json");
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
387
791
|
function nextSteps(result, answers) {
|
|
388
792
|
const rel = path.relative(process.cwd(), result.dir);
|
|
389
793
|
// A relative path that climbs out of the working directory is worse than
|
|
@@ -391,18 +795,26 @@ function nextSteps(result, answers) {
|
|
|
391
795
|
const target = rel === "" ? "" : rel.startsWith("..") ? result.dir : rel;
|
|
392
796
|
const cd = target === "" ? "" : `cd ${target}\n`;
|
|
393
797
|
const steps = [
|
|
394
|
-
|
|
798
|
+
// The forge-host segment is mandatory: the gate refuses anything outside
|
|
799
|
+
// `index/<host>/<namespace>/<package>/metadata.json`, and a shallow first
|
|
800
|
+
// commit builds locally without complaint before failing every review.
|
|
801
|
+
"Add packages under index/<host>/<namespace>/<package>/metadata.json",
|
|
802
|
+
"Commit package-lock.json - CI installs from it, so it is not optional",
|
|
395
803
|
"Push to your forge — CI builds and publishes the site",
|
|
396
804
|
];
|
|
397
805
|
if (answers.baseUrl === "http://localhost:4321") {
|
|
398
|
-
|
|
806
|
+
// Both keys, because the placeholder lands in both: `site` drives the
|
|
807
|
+
// deployment URL, `registry.index` is the address this index hands its
|
|
808
|
+
// visitors to add it with. Naming only the first left the copy-paste
|
|
809
|
+
// block on the published site pointing at localhost.
|
|
810
|
+
steps.push("Set site and registry.index in index.config.json to the real site URL");
|
|
399
811
|
}
|
|
400
812
|
if (answers.withSkills && !answers.repoUrl) {
|
|
401
813
|
steps.push(`Set [announce] repository + namespace in publish.toml — both are ${UNDERIVED}, ` +
|
|
402
814
|
"and `grim publish --announce` refuses to run until they name this repo");
|
|
403
815
|
}
|
|
404
816
|
return [
|
|
405
|
-
`${cd}
|
|
817
|
+
`${cd}${result.installed ? "" : "npm install\n"}npm run dev # preview it locally`,
|
|
406
818
|
"",
|
|
407
819
|
"Then:",
|
|
408
820
|
...steps.map((step, i) => ` ${i + 1}. ${step}`),
|
|
@@ -412,16 +824,88 @@ export async function init(dir, flags, version) {
|
|
|
412
824
|
const target = path.resolve(dir);
|
|
413
825
|
const answers = await resolveAnswers(target, flags);
|
|
414
826
|
fs.mkdirSync(target, { recursive: true });
|
|
415
|
-
|
|
827
|
+
// Which `ci` block the workflows are rendered from depends on which one
|
|
828
|
+
// survives `write`: an existing `index.config.json` is kept unless
|
|
829
|
+
// `--force`, and its settings — not the answers — are what the committed
|
|
830
|
+
// CI has to match. A malformed one is a data error either way, and
|
|
831
|
+
// `loadCiConfig` raises it rather than papering over it with defaults.
|
|
832
|
+
const keepsExistingConfig = fs.existsSync(path.join(target, CONFIG_FILE)) && !(flags.force ?? false);
|
|
833
|
+
const ci = keepsExistingConfig
|
|
834
|
+
? await loadCiConfig(target)
|
|
835
|
+
: {
|
|
836
|
+
forge: answers.forge,
|
|
837
|
+
// Written only when it is not the default, so a plain index's `ci`
|
|
838
|
+
// block stays the one key it has always been.
|
|
839
|
+
...(answers.publish === "never" ? {} : { publish: answers.publish }),
|
|
840
|
+
};
|
|
841
|
+
const files = write(target, plan(answers, version, ci), flags.force ?? false);
|
|
416
842
|
const gitInitialized = answers.git ? initGit(target) : false;
|
|
417
|
-
const
|
|
843
|
+
const installed = answers.install ? npmInstall(target) : false;
|
|
844
|
+
const result = { dir: target, files, gitInitialized, installed };
|
|
418
845
|
for (const file of files) {
|
|
419
846
|
console.log(` ${file.outcome.padEnd(12)}${file.path}`);
|
|
420
847
|
}
|
|
848
|
+
// Reported whether or not `--force` was passed, because with it the line is
|
|
849
|
+
// the only signal that the flag stopped short of two files, and without it
|
|
850
|
+
// the reader still needs to know these two are never in play.
|
|
851
|
+
const preserved = files.filter((f) => f.outcome === "preserved");
|
|
852
|
+
if (preserved.length > 0) {
|
|
853
|
+
console.error(`\n${preserved.length} file(s) hold content no scaffold can regenerate and were kept as they are` +
|
|
854
|
+
`${flags.force ? " — `--force` does not rewrite these" : ""}:\n` +
|
|
855
|
+
preserved.map((f) => ` ${f.path} — ${NEVER_OVERWRITTEN[f.path]}`).join("\n") +
|
|
856
|
+
`\nEdit them directly.`);
|
|
857
|
+
// The registry host is answered at a prompt but committed to
|
|
858
|
+
// `index-policy.json`, so preserving that file makes the answer inert.
|
|
859
|
+
// Saying so beats a silently dropped answer — and beats merging it in,
|
|
860
|
+
// since widening the gate's allowlist is meant to be a reviewed edit.
|
|
861
|
+
const policy = preserved.some((f) => f.path === POLICY_FILE);
|
|
862
|
+
const hosts = policy ? readJson(path.join(target, POLICY_FILE)).registryHosts : undefined;
|
|
863
|
+
if (Array.isArray(hosts) && !hosts.includes(answers.registryHost)) {
|
|
864
|
+
console.error(`\nThe registry host you answered (${answers.registryHost}) is not in ${POLICY_FILE}, ` +
|
|
865
|
+
`and was not added — widening the gate's allowlist is a reviewed edit.\n` +
|
|
866
|
+
`Add it to \`registryHosts\` yourself if you meant to.`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
421
869
|
const skipped = files.filter((f) => f.outcome === "skipped");
|
|
422
870
|
if (skipped.length > 0) {
|
|
871
|
+
// What `--force` costs, per file, and only for the files that would lose
|
|
872
|
+
// something. It rewrites from the scaffold rather than merging, so on a
|
|
873
|
+
// used index it silently discards declared packages and the contribution
|
|
874
|
+
// gate's committed policy — advice worth spelling out, not shortening.
|
|
875
|
+
const costs = skipped
|
|
876
|
+
.map((file) => [file.path, USER_OWNED[file.path]])
|
|
877
|
+
.filter(([, cost]) => cost !== undefined)
|
|
878
|
+
.map(([name, cost]) => ` ${name} — ${cost}`);
|
|
423
879
|
console.error(`\n${skipped.length} file(s) differ from the scaffold and were left alone. ` +
|
|
424
|
-
`
|
|
880
|
+
`They are this index's, not the scaffold's.\n` +
|
|
881
|
+
`To change settings, edit ${CONFIG_FILE}` +
|
|
882
|
+
(costs.length > 0
|
|
883
|
+
? `.\n\n\`--force\` rewrites them from the scaffold instead, discarding:\n${costs.join("\n")}`
|
|
884
|
+
: `, then re-render the workflows with \`npm run ci\`.`));
|
|
885
|
+
}
|
|
886
|
+
// Same contract `grim-indexer ci` has: generated CI this config no longer
|
|
887
|
+
// renders is reported, never deleted. Silence here left an orphaned
|
|
888
|
+
// pipeline behind — and the drift guard failing on the next push with no
|
|
889
|
+
// hint of where it came from.
|
|
890
|
+
const orphaned = await staleCi(target, renderCi(resolveCi(ci)));
|
|
891
|
+
for (const relative of orphaned) {
|
|
892
|
+
console.log(` ${"stale".padEnd(12)}${relative}`);
|
|
893
|
+
}
|
|
894
|
+
if (orphaned.length > 0) {
|
|
895
|
+
console.error(`\n${orphaned.length} generated file(s) are no longer rendered by ${CONFIG_FILE} — ` +
|
|
896
|
+
`delete them, or \`npm run ci:check\` keeps failing`);
|
|
897
|
+
}
|
|
898
|
+
// `nextSteps` is a first-run script — add packages, commit the lock, push.
|
|
899
|
+
// Printing it to somebody who already did all three, above an outro claiming
|
|
900
|
+
// the index is "ready", said a run had accomplished something when it had
|
|
901
|
+
// changed nothing. A re-run reports what it actually did instead, and the
|
|
902
|
+
// clack boxes are skipped because no `intro` opened a session to close.
|
|
903
|
+
if (isTopUp(target, flags)) {
|
|
904
|
+
const created = files.filter((file) => file.outcome === "created").length;
|
|
905
|
+
console.log(created === 0
|
|
906
|
+
? `\nNothing to do — this index is already scaffolded.`
|
|
907
|
+
: `\nAdded ${created} missing file(s).`);
|
|
908
|
+
return EXIT.ok;
|
|
425
909
|
}
|
|
426
910
|
prompts.note(nextSteps(result, answers), "Next steps");
|
|
427
911
|
prompts.outro(`Index "${answers.name}" ready in ${target}`);
|