@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.
Files changed (82) hide show
  1. package/README.md +59 -18
  2. package/dist/ci.d.ts +149 -0
  3. package/dist/ci.d.ts.map +1 -0
  4. package/dist/ci.js +338 -0
  5. package/dist/ci.js.map +1 -0
  6. package/dist/cli/build.d.ts.map +1 -1
  7. package/dist/cli/build.js +3 -9
  8. package/dist/cli/build.js.map +1 -1
  9. package/dist/cli/ci.d.ts +6 -0
  10. package/dist/cli/ci.d.ts.map +1 -0
  11. package/dist/cli/ci.js +43 -0
  12. package/dist/cli/ci.js.map +1 -0
  13. package/dist/cli/dev.d.ts +7 -0
  14. package/dist/cli/dev.d.ts.map +1 -0
  15. package/dist/cli/dev.js +57 -0
  16. package/dist/cli/dev.js.map +1 -0
  17. package/dist/cli/init.d.ts +18 -2
  18. package/dist/cli/init.d.ts.map +1 -1
  19. package/dist/cli/init.js +584 -100
  20. package/dist/cli/init.js.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/main.js +71 -10
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/cli/out_dir.d.ts +11 -0
  25. package/dist/cli/out_dir.d.ts.map +1 -0
  26. package/dist/cli/out_dir.js +30 -0
  27. package/dist/cli/out_dir.js.map +1 -0
  28. package/dist/cli/validate.d.ts +1 -0
  29. package/dist/cli/validate.d.ts.map +1 -1
  30. package/dist/cli/validate.js +47 -1
  31. package/dist/cli/validate.js.map +1 -1
  32. package/dist/renderer/astro/components/BrandMark.js +0 -1
  33. package/dist/renderer/astro/components/Catalog.js +0 -1
  34. package/dist/renderer/astro/lib/base.js +0 -1
  35. package/dist/renderer/astro/lib/catalog.js +0 -1
  36. package/dist/renderer/astro/lib/code.js +0 -1
  37. package/dist/renderer/astro/lib/data.js +0 -1
  38. package/dist/renderer/index.d.ts.map +1 -1
  39. package/dist/renderer/index.js +16 -0
  40. package/dist/renderer/index.js.map +1 -1
  41. package/dist/templates.d.ts +12 -0
  42. package/dist/templates.d.ts.map +1 -0
  43. package/dist/templates.js +40 -0
  44. package/dist/templates.js.map +1 -0
  45. package/dist/validate/adapters/files.d.ts.map +1 -1
  46. package/dist/validate/adapters/files.js +13 -3
  47. package/dist/validate/adapters/files.js.map +1 -1
  48. package/dist/validate/index.js +17 -2
  49. package/dist/validate/index.js.map +1 -1
  50. package/package.json +2 -2
  51. package/templates/README.md +115 -19
  52. package/templates/ci/github-automerge.yml +36 -0
  53. package/templates/ci/github-enrich.yml +27 -0
  54. package/templates/ci/github-pages.yml +64 -0
  55. package/templates/ci/github-publish.yml +89 -0
  56. package/templates/ci/github-validate.yml +157 -0
  57. package/templates/ci/github-verify-ci.yml +47 -0
  58. package/templates/ci/gitlab-ci.yml +149 -0
  59. package/templates/ci/gitlab-enrich.sh +26 -0
  60. package/templates/ci/gitlab-publish.yml +50 -0
  61. package/templates/ci/gitlab-verify.yml +16 -0
  62. package/templates/ci/header.txt +19 -0
  63. package/templates/gitattributes +4 -0
  64. package/templates/gitignore +7 -2
  65. package/templates/package.json +18 -0
  66. package/templates/publish.toml +2 -2
  67. package/dist/renderer/astro/components/BrandMark.d.ts.map +0 -1
  68. package/dist/renderer/astro/components/BrandMark.js.map +0 -1
  69. package/dist/renderer/astro/components/Catalog.d.ts.map +0 -1
  70. package/dist/renderer/astro/components/Catalog.js.map +0 -1
  71. package/dist/renderer/astro/lib/base.d.ts.map +0 -1
  72. package/dist/renderer/astro/lib/base.js.map +0 -1
  73. package/dist/renderer/astro/lib/catalog.d.ts.map +0 -1
  74. package/dist/renderer/astro/lib/catalog.js.map +0 -1
  75. package/dist/renderer/astro/lib/code.d.ts.map +0 -1
  76. package/dist/renderer/astro/lib/code.js.map +0 -1
  77. package/dist/renderer/astro/lib/data.d.ts.map +0 -1
  78. package/dist/renderer/astro/lib/data.js.map +0 -1
  79. package/templates/github-pages.yml +0 -24
  80. package/templates/github-validate.yml +0 -51
  81. package/templates/gitlab-ci.yml +0 -13
  82. 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
- * Resolve flags into a complete answer set, prompting for whatever is
147
- * missing. `--quick` never prompts, so CI and tests get a deterministic
148
- * tree from flags alone.
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 defaultName = slug(path.basename(path.resolve(dir))) || "my-index";
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
- if (flags.quick) {
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
- const baseUrl = flags.baseUrl ?? "http://localhost:4321";
169
- const withSkills = flags.withSkills ?? false;
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
- // Derived either way now: the combined layout needs an announce
181
- // target, and every layout wants the header's "github" link, which
182
- // has no default to fall back on. Empty stays empty — nothing is
183
- // guessed, and a `publish.toml` with no target refuses to publish.
184
- repoUrl: flags.repoUrl ?? gitRemoteUrl(dir) ?? repoUrlFromPages(baseUrl) ?? "",
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
- prompts.intro("grim-indexer new package index");
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: titleCase(name),
199
- defaultValue: titleCase(name),
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: "Base URL the index is served from",
204
- placeholder: "https://index.example.com",
205
- validate: (value) => badUrl(value ?? "") ?? undefined,
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
- // Prompted only for the combined layout — the standalone one writes no
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: name,
226
- defaultValue: name,
227
- validate: (value) => badName(value || name) ?? undefined,
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, blank for none)",
232
- defaultValue: "",
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 = flags.git ??
245
- (await ask(prompts.confirm({ message: "Initialize a git repository?", initialValue: true })));
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: flags.registryHost ?? "ghcr.io",
593
+ registryHost,
252
594
  logo,
253
595
  forge,
254
596
  git,
255
- withSkills: flags.withSkills ?? false,
256
- repoUrl,
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: `${answers.title}, a Grimoire package index.`,
281
- ...(answers.logo ? { favicon: answers.logo } : {}),
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
- /** The complete scaffold as (destination path, content) pairs — nothing touches disk yet. */
306
- function plan(answers, version) {
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
- // The reusable workflows are pinned by tag; Renovate's `github-actions`
318
- // manager bumps this in every scaffolded repo.
319
- ref: `v${version}`,
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: "index-policy.json", content: indexPolicy(answers) },
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
- if (answers.forge === "github" || answers.forge === "both") {
333
- files.push(from("github-pages.yml", ".github/workflows/pages.yml"));
334
- files.push(from("github-validate.yml", ".github/workflows/validate.yml"));
335
- }
336
- if (answers.forge === "gitlab" || answers.forge === "both") {
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("publish.toml", "publish.toml"));
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
- "Add packages under index/<namespace>/<package>/metadata.json",
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
- steps.push("Set baseUrl in index.config.json to the real site URL");
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}npx @grimoire-rs/indexer build`,
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
- const files = write(target, plan(answers, version), flags.force ?? false);
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 result = { dir: target, files, gitInitialized };
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
- `Re-run with --force to overwrite them.`);
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}`);