@grimoire-rs/indexer 0.3.0 → 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 (39) hide show
  1. package/README.md +13 -5
  2. package/dist/ci.d.ts +34 -0
  3. package/dist/ci.d.ts.map +1 -1
  4. package/dist/ci.js +53 -1
  5. package/dist/ci.js.map +1 -1
  6. package/dist/cli/init.d.ts +13 -2
  7. package/dist/cli/init.d.ts.map +1 -1
  8. package/dist/cli/init.js +319 -55
  9. package/dist/cli/init.js.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +10 -2
  12. package/dist/cli/main.js.map +1 -1
  13. package/dist/renderer/astro/components/BrandMark.js +0 -1
  14. package/dist/renderer/astro/components/Catalog.js +0 -1
  15. package/dist/renderer/astro/lib/base.js +0 -1
  16. package/dist/renderer/astro/lib/catalog.js +0 -1
  17. package/dist/renderer/astro/lib/code.js +0 -1
  18. package/dist/renderer/astro/lib/data.js +0 -1
  19. package/dist/renderer/index.d.ts.map +1 -1
  20. package/dist/renderer/index.js +16 -0
  21. package/dist/renderer/index.js.map +1 -1
  22. package/package.json +2 -2
  23. package/templates/ci/github-pages.yml +1 -1
  24. package/templates/ci/github-publish.yml +89 -0
  25. package/templates/ci/github-verify-ci.yml +1 -1
  26. package/templates/ci/gitlab-ci.yml +1 -0
  27. package/templates/ci/gitlab-publish.yml +50 -0
  28. package/dist/renderer/astro/components/BrandMark.d.ts.map +0 -1
  29. package/dist/renderer/astro/components/BrandMark.js.map +0 -1
  30. package/dist/renderer/astro/components/Catalog.d.ts.map +0 -1
  31. package/dist/renderer/astro/components/Catalog.js.map +0 -1
  32. package/dist/renderer/astro/lib/base.d.ts.map +0 -1
  33. package/dist/renderer/astro/lib/base.js.map +0 -1
  34. package/dist/renderer/astro/lib/catalog.d.ts.map +0 -1
  35. package/dist/renderer/astro/lib/catalog.js.map +0 -1
  36. package/dist/renderer/astro/lib/code.d.ts.map +0 -1
  37. package/dist/renderer/astro/lib/code.js.map +0 -1
  38. package/dist/renderer/astro/lib/data.d.ts.map +0 -1
  39. package/dist/renderer/astro/lib/data.js.map +0 -1
package/dist/cli/init.js CHANGED
@@ -7,11 +7,57 @@ import { execFileSync } from "node:child_process";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import * as prompts from "@clack/prompts";
10
- import { loadCiConfig, renderCi, resolveCi, staleCi } from "../ci.js";
10
+ import { FORGES, loadCiConfig, PUBLISH_TRIGGERS, renderCi, resolveCi, staleCi, } from "../ci.js";
11
11
  import { CONFIG_FILE } from "../config.js";
12
12
  import { fromTemplate } from "../templates.js";
13
13
  import { CliError, EXIT } from "./exit.js";
14
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
+ }
15
61
  /**
16
62
  * What `[announce]` gets when nothing knew this repo's URL. It is not a
17
63
  * locator, so `grim publish --announce` fails on it before any network call —
@@ -23,6 +69,26 @@ const UNDERIVED = "REPLACE-ME";
23
69
  const PAGES_HOST = /^([\w-]+)\.(github|gitlab)\.io$/;
24
70
  /** scp-like `[user@]host:owner/repo` — the one git remote shape that is not a URL. */
25
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
+ }
26
92
  function slug(value) {
27
93
  return value
28
94
  .trim()
@@ -144,17 +210,22 @@ function reportDerivation(what) {
144
210
  return;
145
211
  }
146
212
  say(what.fromRemote ? "from origin" : "repository", what.remoteUrl);
147
- if (what.forge)
213
+ if (what.kept.forge)
214
+ say("forge", `${what.kept.forge} (kept from ${CONFIG_FILE})`);
215
+ else if (what.forge)
148
216
  say("forge", what.forge);
149
- if (what.forgeFallback) {
217
+ else if (what.forgeFallback) {
150
218
  say("forge", "github (the host is neither github nor gitlab - pass --forge)");
151
219
  }
152
- if (what.baseUrl) {
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)
153
226
  say("site", what.baseUrl);
154
- }
155
- else {
227
+ else
156
228
  say("site", "not derivable from this host - set `site` in index.config.json");
157
- }
158
229
  }
159
230
  /**
160
231
  * Which forge hosts `repoUrl`. Read off the host, so a self-hosted
@@ -225,13 +296,59 @@ function announceNamespace(repoUrl) {
225
296
  }
226
297
  return segments.length > 1 ? segments.slice(0, -1).join("/") : undefined;
227
298
  }
228
- /**
229
- * Resolve flags into a complete answer set, prompting for whatever is
230
- * missing. `--quick` never prompts, so CI and tests get a deterministic
231
- * tree from flags alone.
232
- */
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
+ }
233
344
  async function resolveAnswers(dir, flags) {
234
- 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"));
235
352
  // Flags always win, and any flag value is validated whether or not a
236
353
  // prompt would have caught it — `--quick` skips the prompt, not the check.
237
354
  for (const [flag, value, check] of [
@@ -252,14 +369,34 @@ async function resolveAnswers(dir, flags) {
252
369
  // the CI, where Pages will serve the site, and what `--announce` targets. A
253
370
  // `--repo-url` overrides it, for scaffolding before the remote exists.
254
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.
255
376
  const derivedBaseUrl = remoteUrl ? pagesUrlFromRepo(remoteUrl) : undefined;
256
377
  const derivedForge = remoteUrl ? forgeFromRepoUrl(remoteUrl) : undefined;
257
- if (flags.quick) {
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) {
258
391
  const name = flags.name ?? defaultName;
259
392
  // Falls back to localhost rather than a guess: `--quick` is the
260
393
  // non-interactive path, and a wrong absolute URL is worse than an obvious
261
394
  // placeholder the next step tells you to replace.
262
- const baseUrl = flags.baseUrl ?? derivedBaseUrl ?? "http://localhost:4321";
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";
263
400
  // `--quick` asks nothing, so every one of these decisions is otherwise
264
401
  // invisible — including the two that quietly did not happen: a self-hosted
265
402
  // forge has no predictable Pages URL, and an unrecognised host falls back
@@ -267,29 +404,47 @@ async function resolveAnswers(dir, flags) {
267
404
  reportDerivation({
268
405
  remoteUrl,
269
406
  fromRemote: flags.repoUrl === undefined && remoteUrl !== undefined,
270
- baseUrl: flags.baseUrl === undefined ? derivedBaseUrl : undefined,
271
- forge: flags.forge === undefined ? derivedForge : undefined,
272
- forgeFallback: flags.forge === undefined && derivedForge === 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
+ },
273
416
  });
417
+ const withSkills = flags.withSkills ?? prior.withSkills ?? false;
274
418
  return {
275
419
  name,
276
- title: flags.title ?? titleCase(name),
420
+ title: flags.title ?? prior.title ?? titleCase(name),
277
421
  baseUrl,
278
- registryAlias: flags.registry ?? name,
279
- registryHost: flags.registryHost ?? "ghcr.io",
280
- logo: flags.logo ?? "",
281
- forge: flags.forge ?? derivedForge ?? "github",
282
- 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),
283
427
  install: flags.install ?? true,
284
- withSkills: flags.withSkills ?? false,
428
+ withSkills,
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)),
285
434
  // The combined layout needs an announce target, and every layout wants
286
435
  // the header's repository link, which has no default to fall back on.
287
436
  // Empty stays empty — nothing is guessed, and a `publish.toml` with no
288
437
  // target refuses to publish.
289
- repoUrl: remoteUrl ?? repoUrlFromPages(baseUrl) ?? "",
438
+ repoUrl: remoteUrl ?? prior.repoUrl ?? repoUrlFromPages(baseUrl) ?? "",
290
439
  };
291
440
  }
292
- 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
+ }
293
448
  const name = flags.name ??
294
449
  (await ask(prompts.text({
295
450
  message: "Index name (used as the identifier)",
@@ -297,11 +452,12 @@ async function resolveAnswers(dir, flags) {
297
452
  defaultValue: defaultName,
298
453
  validate: (value) => badName(value || defaultName) ?? undefined,
299
454
  })));
455
+ const titleDefault = prior.title ?? titleCase(name);
300
456
  const title = flags.title ??
301
457
  (await ask(prompts.text({
302
458
  message: "Display title",
303
- placeholder: titleCase(name),
304
- defaultValue: titleCase(name),
459
+ placeholder: titleDefault,
460
+ defaultValue: titleDefault,
305
461
  })));
306
462
  if (remoteUrl) {
307
463
  prompts.log.step(`Read from ${flags.repoUrl ? "--repo-url" : "the `origin` remote"}: ${remoteUrl}`);
@@ -311,8 +467,8 @@ async function resolveAnswers(dir, flags) {
311
467
  const repoUrl = remoteUrl ??
312
468
  (await ask(prompts.text({
313
469
  message: "Repository URL this index lives in",
314
- placeholder: "https://github.com/you/your-index",
315
- defaultValue: "",
470
+ placeholder: prior.repoUrl ?? "https://github.com/you/your-index",
471
+ defaultValue: prior.repoUrl ?? "",
316
472
  // Blank is allowed: it writes a placeholder that refuses to publish,
317
473
  // which beats forcing a URL the user does not have yet.
318
474
  validate: (value) => (value ? (badUrl(value) ?? undefined) : undefined),
@@ -323,7 +479,10 @@ async function resolveAnswers(dir, flags) {
323
479
  // GitLab Pages domain) is a fact this command cannot observe. The message
324
480
  // names where the default came from, so accepting it is a decision rather
325
481
  // than a shrug.
326
- const pagesUrl = repoUrl === remoteUrl ? derivedBaseUrl : pagesUrlFromRepo(repoUrl);
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));
327
486
  const baseUrl = flags.baseUrl ??
328
487
  (await ask(prompts.text({
329
488
  message: pagesUrl
@@ -333,36 +492,46 @@ async function resolveAnswers(dir, flags) {
333
492
  defaultValue: pagesUrl ?? "",
334
493
  validate: (value) => badUrl(value || (pagesUrl ?? "")) ?? undefined,
335
494
  })));
495
+ const aliasDefault = prior.registryAlias ?? name;
336
496
  const registryAlias = flags.registry ??
337
497
  (await ask(prompts.text({
338
498
  message: "Registry alias packages are published under",
339
- placeholder: name,
340
- defaultValue: name,
341
- validate: (value) => badName(value || name) ?? undefined,
499
+ placeholder: aliasDefault,
500
+ defaultValue: aliasDefault,
501
+ validate: (value) => badName(value || aliasDefault) ?? undefined,
342
502
  })));
343
503
  const logo = flags.logo ??
344
504
  (await ask(prompts.text({
345
505
  message: "Brand logo (site-root path like /logo.svg, or a URL; blank for none)",
346
- defaultValue: "",
506
+ placeholder: prior.logo ?? "",
507
+ defaultValue: prior.logo ?? "",
347
508
  validate: (value) => badLogo(value ?? "") ?? undefined,
348
509
  })));
349
510
  // Prompted, not assumed. This is the committed allowlist the contribution
350
511
  // gate bounds every entry's `ref` by, and defaulting it silently left every
351
512
  // index refusing anything not on ghcr.io - including its own packages.
513
+ const hostDefault = prior.registryHost ?? "ghcr.io";
352
514
  const registryHost = flags.registryHost ??
353
515
  (await ask(prompts.text({
354
516
  message: "OCI registry host packages are pulled from (the gate's allowlist)",
355
- placeholder: "ghcr.io",
356
- defaultValue: "ghcr.io",
517
+ placeholder: hostDefault,
518
+ defaultValue: hostDefault,
357
519
  })));
358
520
  // Derived from the repository's host when that is recognisable, so the
359
521
  // common case never sees this question. One repository runs on one forge —
360
522
  // rendering both left every index carrying a pipeline it would never run.
361
523
  const detectedForge = repoUrl === remoteUrl ? derivedForge : forgeFromRepoUrl(repoUrl);
362
- if (detectedForge)
363
- prompts.log.step(`CI: ${detectedForge}, from the repository host`);
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
+ }
364
533
  const forge = flags.forge ??
365
- detectedForge ??
534
+ settledForge ??
366
535
  (await ask(prompts.select({
367
536
  message: "CI to scaffold",
368
537
  options: [
@@ -371,12 +540,46 @@ async function resolveAnswers(dir, flags) {
371
540
  ],
372
541
  initialValue: "github",
373
542
  })));
374
- const git = flags.git ??
375
- (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 }))));
376
547
  // The lock is what pins the renderer this index builds and validates with,
377
548
  // and CI runs `npm ci` against it — so an index without one is an index
378
549
  // whose first push fails. Declining is still allowed; the next steps say
379
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
+ }))));
380
583
  const install = flags.install ??
381
584
  (await ask(prompts.confirm({
382
585
  message: "Install dependencies now? (writes package-lock.json)",
@@ -392,7 +595,9 @@ async function resolveAnswers(dir, flags) {
392
595
  forge,
393
596
  git,
394
597
  install,
395
- withSkills: flags.withSkills ?? false,
598
+ withSkills,
599
+ publish,
600
+ description: keptDescription(prior, title),
396
601
  // The Pages-URL fallback the `--quick` path has: someone who answered the
397
602
  // base URL but left the repository blank has still said where this index
398
603
  // lives, and dropping that left `repoUrl` empty — no header link, and a
@@ -421,7 +626,7 @@ function siteConfig(answers) {
421
626
  return json({
422
627
  site: answers.baseUrl,
423
628
  brand: answers.title,
424
- description: `${answers.title}, a Grimoire package index.`,
629
+ description: answers.description,
425
630
  // `logo`, not `favicon`. They are deliberately different keys (see
426
631
  // `SiteConfig`): a favicon is drawn to read at 16px, a logo goes in the
427
632
  // header and becomes the default `og:image`. The prompt asks for a brand
@@ -433,11 +638,15 @@ function siteConfig(answers) {
433
638
  ...(answers.repoUrl ? { repoUrl: answers.repoUrl } : {}),
434
639
  registry: { alias: answers.registryAlias, index: answers.baseUrl },
435
640
  // What the committed CI is rendered from. The remaining knobs
436
- // (`nodeVersion`, `enrich`, `grimVersion`, `allowManualEdits`) are left
437
- // to their defaults rather than written out — `grim-indexer ci` resolves
438
- // them the same way, so an absent key and its default render identically.
439
- // Which renderer runs is not here at all: that is `package-lock.json`.
440
- ci: { forge: answers.forge },
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
+ },
441
650
  });
442
651
  }
443
652
  /**
@@ -488,7 +697,7 @@ function plan(answers, version, ci) {
488
697
  const files = [
489
698
  { path: "index/.gitkeep", content: "" },
490
699
  { path: "index.config.json", content: siteConfig(answers) },
491
- { path: "index-policy.json", content: indexPolicy(answers) },
700
+ { path: POLICY_FILE, content: indexPolicy(answers) },
492
701
  from("gitignore", ".gitignore"),
493
702
  from("gitattributes", ".gitattributes"),
494
703
  from("package.json", "package.json"),
@@ -502,14 +711,15 @@ function plan(answers, version, ci) {
502
711
  }
503
712
  if (answers.withSkills) {
504
713
  files.push({ path: "skills/.gitkeep", content: "" });
505
- files.push(from("publish.toml", "publish.toml"));
714
+ files.push(from(MANIFEST_FILE, MANIFEST_FILE));
506
715
  }
507
716
  return files;
508
717
  }
509
718
  /**
510
719
  * Write the scaffold. Re-running is safe: a file whose content already
511
720
  * matches is reported `unchanged`, and one the user has edited is left
512
- * 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`.
513
723
  */
514
724
  function write(dir, files, force) {
515
725
  const written = [];
@@ -521,6 +731,10 @@ function write(dir, files, force) {
521
731
  written.push({ path: file.path, outcome: "unchanged" });
522
732
  continue;
523
733
  }
734
+ if (file.path in NEVER_OVERWRITTEN) {
735
+ written.push({ path: file.path, outcome: "preserved" });
736
+ continue;
737
+ }
524
738
  if (!force) {
525
739
  written.push({ path: file.path, outcome: "skipped" });
526
740
  continue;
@@ -618,7 +832,12 @@ export async function init(dir, flags, version) {
618
832
  const keepsExistingConfig = fs.existsSync(path.join(target, CONFIG_FILE)) && !(flags.force ?? false);
619
833
  const ci = keepsExistingConfig
620
834
  ? await loadCiConfig(target)
621
- : { forge: answers.forge };
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
+ };
622
841
  const files = write(target, plan(answers, version, ci), flags.force ?? false);
623
842
  const gitInitialized = answers.git ? initGit(target) : false;
624
843
  const installed = answers.install ? npmInstall(target) : false;
@@ -626,10 +845,43 @@ export async function init(dir, flags, version) {
626
845
  for (const file of files) {
627
846
  console.log(` ${file.outcome.padEnd(12)}${file.path}`);
628
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
+ }
629
869
  const skipped = files.filter((f) => f.outcome === "skipped");
630
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}`);
631
879
  console.error(`\n${skipped.length} file(s) differ from the scaffold and were left alone. ` +
632
- `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\`.`));
633
885
  }
634
886
  // Same contract `grim-indexer ci` has: generated CI this config no longer
635
887
  // renders is reported, never deleted. Silence here left an orphaned
@@ -643,6 +895,18 @@ export async function init(dir, flags, version) {
643
895
  console.error(`\n${orphaned.length} generated file(s) are no longer rendered by ${CONFIG_FILE} — ` +
644
896
  `delete them, or \`npm run ci:check\` keeps failing`);
645
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;
909
+ }
646
910
  prompts.note(nextSteps(result, answers), "Next steps");
647
911
  prompts.outro(`Index "${answers.name}" ready in ${target}`);
648
912
  return EXIT.ok;