@grimoire-rs/indexer 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +51 -18
  2. package/dist/ci.d.ts +115 -0
  3. package/dist/ci.d.ts.map +1 -0
  4. package/dist/ci.js +286 -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 +6 -1
  18. package/dist/cli/init.d.ts.map +1 -1
  19. package/dist/cli/init.js +298 -74
  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 +61 -8
  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/config.d.ts.map +1 -1
  33. package/dist/config.js +12 -2
  34. package/dist/config.js.map +1 -1
  35. package/dist/templates.d.ts +12 -0
  36. package/dist/templates.d.ts.map +1 -0
  37. package/dist/templates.js +40 -0
  38. package/dist/templates.js.map +1 -0
  39. package/dist/validate/adapters/files.d.ts.map +1 -1
  40. package/dist/validate/adapters/files.js +13 -3
  41. package/dist/validate/adapters/files.js.map +1 -1
  42. package/dist/validate/index.js +17 -2
  43. package/dist/validate/index.js.map +1 -1
  44. package/package.json +1 -1
  45. package/templates/README.md +115 -19
  46. package/templates/ci/github-automerge.yml +36 -0
  47. package/templates/ci/github-enrich.yml +27 -0
  48. package/templates/ci/github-pages.yml +64 -0
  49. package/templates/ci/github-validate.yml +157 -0
  50. package/templates/ci/github-verify-ci.yml +47 -0
  51. package/templates/ci/gitlab-ci.yml +148 -0
  52. package/templates/ci/gitlab-enrich.sh +26 -0
  53. package/templates/ci/gitlab-verify.yml +16 -0
  54. package/templates/ci/header.txt +19 -0
  55. package/templates/gitattributes +4 -0
  56. package/templates/gitignore +7 -2
  57. package/templates/package.json +18 -0
  58. package/templates/publish.toml +2 -2
  59. package/templates/github-pages.yml +0 -24
  60. package/templates/github-validate.yml +0 -51
  61. package/templates/gitlab-ci.yml +0 -13
  62. package/templates/reusable/gitlab-index-ci.yml +0 -125
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Grimoire Authors
3
+ // `grim-indexer dev` — serve this index locally, so a change to an entry, the
4
+ // branding, or the custom CSS can be looked at before it is pushed. It renders
5
+ // through the same code path `build` does, so what is on screen is what the
6
+ // deploy will publish.
7
+ import path from "node:path";
8
+ import { CliError, EXIT } from "./exit.js";
9
+ import { resolveOutDir } from "./out_dir.js";
10
+ /**
11
+ * A bad `--port` is a usage error (64), like every other bad flag. Passed
12
+ * through unchecked it reached Astro as `NaN` and surfaced as a raw zod issue
13
+ * dump naming no flag at all, exiting 1.
14
+ */
15
+ function resolvePort(port) {
16
+ if (port === undefined)
17
+ return undefined;
18
+ const value = Number(port);
19
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
20
+ throw new CliError(`--port ${JSON.stringify(port)}: must be a port number (1-65535)`, EXIT.usage);
21
+ }
22
+ return value;
23
+ }
24
+ export async function dev(root, flags) {
25
+ const rootDir = path.resolve(root);
26
+ const outDir = resolveOutDir(rootDir, flags.outDir);
27
+ const port = resolvePort(flags.port);
28
+ const [{ loadConfig }, { compileIndex }, { devSite }] = await Promise.all([
29
+ import("../config.js"),
30
+ import("../data/index.js"),
31
+ import("../renderer/index.js"),
32
+ ]);
33
+ const config = await loadConfig(rootDir);
34
+ const { count, namespaces } = await compileIndex({ root: rootDir, outDir });
35
+ const server = await devSite({ root: rootDir, outDir, config, port });
36
+ console.log(`\n ${server.url}\n`);
37
+ console.log(` ${count} package(s) across ${namespaces.length} namespace(s)`);
38
+ // `index.config.json` is read once, at boot, and baked into the bundle — the
39
+ // same thing a build does. Say so, or an edit that appears to do nothing
40
+ // reads as a bug in the renderer.
41
+ console.log(" editing index/ or index.config.json? restart to pick it up\n");
42
+ // The server owns the process from here. Astro's dev server keeps the event
43
+ // loop alive on its own, but the promise is what stops `run` from returning
44
+ // and letting the CLI set an exit code out from under it.
45
+ await new Promise((resolve) => {
46
+ for (const signal of ["SIGINT", "SIGTERM"]) {
47
+ process.on(signal, () => {
48
+ void server
49
+ .stop()
50
+ .catch(() => { })
51
+ .finally(resolve);
52
+ });
53
+ }
54
+ });
55
+ return EXIT.ok;
56
+ }
57
+ //# sourceMappingURL=dev.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev.js","sourceRoot":"","sources":["../../src/cli/dev.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,sCAAsC;AAEtC,8EAA8E;AAC9E,+EAA+E;AAC/E,4EAA4E;AAC5E,uBAAuB;AACvB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAiB,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAO7C;;;;GAIG;AACH,SAAS,WAAW,CAAC,IAAwB;IAC3C,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,KAAK,EAAE,CAAC;QAC3D,MAAM,IAAI,QAAQ,CAAC,UAAU,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,mCAAmC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IACpG,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,IAAY,EAAE,KAAe;IACrD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,MAAM,GAAG,aAAa,CAAC,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAErC,MAAM,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QACxE,MAAM,CAAC,cAAc,CAAC;QACtB,MAAM,CAAC,kBAAkB,CAAC;QAC1B,MAAM,CAAC,sBAAsB,CAAC;KAC/B,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,MAAM,YAAY,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IAC5E,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IAEtE,OAAO,CAAC,GAAG,CAAC,OAAO,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC;IACnC,OAAO,CAAC,GAAG,CAAC,KAAK,KAAK,sBAAsB,UAAU,CAAC,MAAM,eAAe,CAAC,CAAC;IAC9E,6EAA6E;IAC7E,yEAAyE;IACzE,kCAAkC;IAClC,OAAO,CAAC,GAAG,CAAC,gEAAgE,CAAC,CAAC;IAE9E,4EAA4E;IAC5E,4EAA4E;IAC5E,0DAA0D;IAC1D,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QAClC,KAAK,MAAM,MAAM,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAU,EAAE,CAAC;YACpD,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE;gBACtB,KAAK,MAAM;qBACR,IAAI,EAAE;qBACN,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC;qBACf,OAAO,CAAC,OAAO,CAAC,CAAC;YACtB,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,EAAE,CAAC;AACjB,CAAC"}
@@ -1,5 +1,6 @@
1
+ import { type Forge } from "../ci.js";
1
2
  import { type ExitCode } from "./exit.js";
2
- export type Forge = "github" | "gitlab" | "both";
3
+ export type { Forge };
3
4
  /** Everything `init` needs to render the scaffold. Flags and prompts both resolve into this. */
4
5
  export interface InitAnswers {
5
6
  name: string;
@@ -10,6 +11,7 @@ export interface InitAnswers {
10
11
  logo: string;
11
12
  forge: Forge;
12
13
  git: boolean;
14
+ install: boolean;
13
15
  withSkills: boolean;
14
16
  /** This repo's own https URL — the announce target. `""` when nothing could derive it. */
15
17
  repoUrl: string;
@@ -25,6 +27,7 @@ export interface InitFlags {
25
27
  logo?: string;
26
28
  forge?: Forge;
27
29
  git?: boolean;
30
+ install?: boolean;
28
31
  withSkills?: boolean;
29
32
  repoUrl?: string;
30
33
  force?: boolean;
@@ -38,6 +41,8 @@ export interface InitResult {
38
41
  outcome: FileOutcome;
39
42
  }>;
40
43
  gitInitialized: boolean;
44
+ /** Whether `npm install` ran and wrote a lock. */
45
+ installed: boolean;
41
46
  }
42
47
  export declare function init(dir: string, flags: InitFlags, version: string): Promise<ExitCode>;
43
48
  //# sourceMappingURL=init.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AAaA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAwB1D,MAAM,MAAM,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEjD,gGAAgG;AAChG,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,KAAK,CAAC;IACb,GAAG,EAAE,OAAO,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,gDAAgD;AAChD,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,4CAA4C;AAC5C,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AAE9E,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACrD,cAAc,EAAE,OAAO,CAAC;CACzB;AAubD,wBAAsB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwB5F"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AAYA,OAAO,EAA6D,KAAK,KAAK,EAAE,MAAM,UAAU,CAAC;AAGjG,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAkB1D,YAAY,EAAE,KAAK,EAAE,CAAC;AAEtB,gGAAgG;AAChG,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,KAAK,CAAC;IACb,GAAG,EAAE,OAAO,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,gDAAgD;AAChD,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,4CAA4C;AAC5C,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AAE9E,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACrD,cAAc,EAAE,OAAO,CAAC;IACxB,kDAAkD;IAClD,SAAS,EAAE,OAAO,CAAC;CACpB;AAopBD,wBAAsB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAmD5F"}
package/dist/cli/init.js CHANGED
@@ -6,13 +6,11 @@
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 { loadCiConfig, 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._-]*$/;
17
15
  /**
18
16
  * What `[announce]` gets when nothing knew this repo's URL. It is not a
@@ -39,23 +37,6 @@ function titleCase(name) {
39
37
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
40
38
  .join(" ");
41
39
  }
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
40
  /** Validate a URL the user typed. Returns the reason it is bad, or `null`. */
60
41
  function badUrl(value) {
61
42
  let url;
@@ -76,6 +57,12 @@ function badName(value) {
76
57
  }
77
58
  return null;
78
59
  }
60
+ /** What `SiteConfig.logo` accepts — checked here so a typo fails now, not at build time. */
61
+ function badLogo(value) {
62
+ if (value === "" || /^(\/|https?:\/\/)/i.test(value))
63
+ return null;
64
+ return "must be a site-root path (/logo.svg) or an http(s) URL";
65
+ }
79
66
  /**
80
67
  * The target dir's `origin` remote as an https URL — set when the repo was
81
68
  * created on the forge and cloned before scaffolding into it. Any remote
@@ -92,6 +79,19 @@ function gitRemoteUrl(dir) {
92
79
  catch {
93
80
  return undefined; // no git, no repo, or no `origin`
94
81
  }
82
+ return normalizeRepoUrl(remote);
83
+ }
84
+ /**
85
+ * Reduce any repository URL to the https form the rest of this file derives
86
+ * from: Pages URL, forge, announce target and the site's own header link.
87
+ *
88
+ * Applied to `--repo-url` as well as to the git remote, because the obvious
89
+ * thing to paste into it is GitHub's clone box — `https://github.com/acme/idx.git`
90
+ * — and used verbatim that trailing `.git` became `https://acme.github.io/idx.git`
91
+ * as `site`, hence `/idx.git` as Astro's `base`, on a Pages deployment that
92
+ * serves `/idx`. Every link on the built site pointed one path segment wrong.
93
+ */
94
+ function normalizeRepoUrl(remote) {
95
95
  const bare = remote.replace(/\.git$/, "");
96
96
  const scp = SCP_REMOTE.exec(bare);
97
97
  if (scp)
@@ -128,6 +128,89 @@ function repoUrlFromPages(baseUrl) {
128
128
  const project = url.pathname.split("/").filter(Boolean)[0] ?? url.hostname;
129
129
  return `https://${pages[2]}.com/${pages[1]}/${project}`;
130
130
  }
131
+ /**
132
+ * Say out loud what was read off the git remote, and what could not be.
133
+ *
134
+ * Everything below is inferred rather than asked, and an inference nobody
135
+ * sees is one nobody checks — a wrong `site` is only noticed once the deploy
136
+ * serves 404s from a subpath that does not exist. The silences matter as much
137
+ * as the hits: a self-hosted forge can serve Pages from anywhere, so there is
138
+ * no URL to guess, and an unrecognised host is not evidence of GitHub.
139
+ */
140
+ function reportDerivation(what) {
141
+ const say = (label, value) => console.log(` ${label.padEnd(12)}${value}`);
142
+ if (!what.remoteUrl) {
143
+ say("no remote", "nothing to derive - set `site` in index.config.json yourself");
144
+ return;
145
+ }
146
+ say(what.fromRemote ? "from origin" : "repository", what.remoteUrl);
147
+ if (what.forge)
148
+ say("forge", what.forge);
149
+ if (what.forgeFallback) {
150
+ say("forge", "github (the host is neither github nor gitlab - pass --forge)");
151
+ }
152
+ if (what.baseUrl) {
153
+ say("site", what.baseUrl);
154
+ }
155
+ else {
156
+ say("site", "not derivable from this host - set `site` in index.config.json");
157
+ }
158
+ }
159
+ /**
160
+ * Which forge hosts `repoUrl`. Read off the host, so a self-hosted
161
+ * `gitlab.example.com` or `github.acme.internal` lands on the right pipeline
162
+ * without anyone having to answer a question about it. Anything unrecognised
163
+ * returns `undefined` and the prompt asks.
164
+ */
165
+ function forgeFromRepoUrl(repoUrl) {
166
+ let host;
167
+ try {
168
+ host = new URL(repoUrl).hostname.toLowerCase();
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ if (host === "github.com" || host.startsWith("github."))
174
+ return "github";
175
+ if (host === "gitlab.com" || host.startsWith("gitlab."))
176
+ return "gitlab";
177
+ return undefined;
178
+ }
179
+ /**
180
+ * The Pages URL a forge serves `repoUrl` from — the inverse of
181
+ * [`repoUrlFromPages`], and the reason nobody has to know their own Pages URL
182
+ * to scaffold an index.
183
+ *
184
+ * `github.com/acme/idx` -> `https://acme.github.io/idx`, and the repository
185
+ * *named after* the Pages host is served from its root rather than a
186
+ * subpath. GitLab keeps everything below the top-level group in the path, so
187
+ * `gitlab.com/acme/team/idx` -> `https://acme.gitlab.io/team/idx`.
188
+ *
189
+ * Only the two public hosts are answered. A self-hosted instance can serve
190
+ * Pages from anywhere, and a custom domain is a fact this command cannot
191
+ * observe — both are why the value is a prompt default and not a decision.
192
+ */
193
+ function pagesUrlFromRepo(repoUrl) {
194
+ let url;
195
+ try {
196
+ url = new URL(repoUrl);
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ const host = url.hostname.toLowerCase();
202
+ const forge = host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : undefined;
203
+ if (!forge)
204
+ return undefined;
205
+ const segments = url.pathname.split("/").filter(Boolean);
206
+ if (segments.length < 2)
207
+ return undefined;
208
+ const pagesHost = `${segments[0]}.${forge}.io`.toLowerCase();
209
+ const project = segments.slice(1).join("/");
210
+ return project.toLowerCase() === pagesHost
211
+ ? `https://${pagesHost}`
212
+ : `https://${pagesHost}/${project}`;
213
+ }
131
214
  /**
132
215
  * The `index/<host>/<namespace>/` this repo's entries land under: its path
133
216
  * minus the repo itself — one segment on GitHub, possibly nested on GitLab.
@@ -156,6 +239,7 @@ async function resolveAnswers(dir, flags) {
156
239
  ["--registry", flags.registry, badName],
157
240
  ["--base-url", flags.baseUrl, badUrl],
158
241
  ["--repo-url", flags.repoUrl, badUrl],
242
+ ["--logo", flags.logo, badLogo],
159
243
  ]) {
160
244
  if (value !== undefined) {
161
245
  const reason = check(value);
@@ -163,10 +247,30 @@ async function resolveAnswers(dir, flags) {
163
247
  throw new CliError(`${flag} ${JSON.stringify(value)}: ${reason}`, EXIT.data);
164
248
  }
165
249
  }
250
+ // The git remote is the one thing a cloned-then-scaffolded repo already
251
+ // knows about itself, and everything else falls out of it: which forge runs
252
+ // the CI, where Pages will serve the site, and what `--announce` targets. A
253
+ // `--repo-url` overrides it, for scaffolding before the remote exists.
254
+ const remoteUrl = flags.repoUrl ? normalizeRepoUrl(flags.repoUrl) : gitRemoteUrl(dir);
255
+ const derivedBaseUrl = remoteUrl ? pagesUrlFromRepo(remoteUrl) : undefined;
256
+ const derivedForge = remoteUrl ? forgeFromRepoUrl(remoteUrl) : undefined;
166
257
  if (flags.quick) {
167
258
  const name = flags.name ?? defaultName;
168
- const baseUrl = flags.baseUrl ?? "http://localhost:4321";
169
- const withSkills = flags.withSkills ?? false;
259
+ // Falls back to localhost rather than a guess: `--quick` is the
260
+ // non-interactive path, and a wrong absolute URL is worse than an obvious
261
+ // placeholder the next step tells you to replace.
262
+ const baseUrl = flags.baseUrl ?? derivedBaseUrl ?? "http://localhost:4321";
263
+ // `--quick` asks nothing, so every one of these decisions is otherwise
264
+ // invisible — including the two that quietly did not happen: a self-hosted
265
+ // forge has no predictable Pages URL, and an unrecognised host falls back
266
+ // to GitHub Actions rather than guessing.
267
+ reportDerivation({
268
+ remoteUrl,
269
+ 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,
273
+ });
170
274
  return {
171
275
  name,
172
276
  title: flags.title ?? titleCase(name),
@@ -174,15 +278,15 @@ async function resolveAnswers(dir, flags) {
174
278
  registryAlias: flags.registry ?? name,
175
279
  registryHost: flags.registryHost ?? "ghcr.io",
176
280
  logo: flags.logo ?? "",
177
- forge: flags.forge ?? "github",
281
+ forge: flags.forge ?? derivedForge ?? "github",
178
282
  git: flags.git ?? true,
179
- withSkills,
180
- // Only the combined layout writes an announce target, so only it pays
181
- // for the derivation. An empty answer stays empty a guessed index
182
- // repo is worse than a placeholder that refuses to publish.
183
- repoUrl: withSkills
184
- ? (flags.repoUrl ?? gitRemoteUrl(dir) ?? repoUrlFromPages(baseUrl) ?? "")
185
- : "",
283
+ install: flags.install ?? true,
284
+ withSkills: flags.withSkills ?? false,
285
+ // The combined layout needs an announce target, and every layout wants
286
+ // the header's repository link, which has no default to fall back on.
287
+ // Empty stays empty — nothing is guessed, and a `publish.toml` with no
288
+ // target refuses to publish.
289
+ repoUrl: remoteUrl ?? repoUrlFromPages(baseUrl) ?? "",
186
290
  };
187
291
  }
188
292
  prompts.intro("grim-indexer — new package index");
@@ -199,26 +303,36 @@ async function resolveAnswers(dir, flags) {
199
303
  placeholder: titleCase(name),
200
304
  defaultValue: titleCase(name),
201
305
  })));
306
+ if (remoteUrl) {
307
+ prompts.log.step(`Read from ${flags.repoUrl ? "--repo-url" : "the `origin` remote"}: ${remoteUrl}`);
308
+ }
309
+ // Asked before the base URL, because it is what the base URL defaults from.
310
+ // A repo cloned from its forge answers this without the user typing.
311
+ const repoUrl = remoteUrl ??
312
+ (await ask(prompts.text({
313
+ message: "Repository URL this index lives in",
314
+ placeholder: "https://github.com/you/your-index",
315
+ defaultValue: "",
316
+ // Blank is allowed: it writes a placeholder that refuses to publish,
317
+ // which beats forcing a URL the user does not have yet.
318
+ validate: (value) => (value ? (badUrl(value) ?? undefined) : undefined),
319
+ })));
320
+ // The whole point of asking for the repository first: on github.com and
321
+ // gitlab.com the Pages URL follows from it, so the answer is usually Enter.
322
+ // It stays a prompt because a custom domain (a CNAME on GitHub Pages, a
323
+ // GitLab Pages domain) is a fact this command cannot observe. The message
324
+ // names where the default came from, so accepting it is a decision rather
325
+ // than a shrug.
326
+ const pagesUrl = repoUrl === remoteUrl ? derivedBaseUrl : pagesUrlFromRepo(repoUrl);
202
327
  const baseUrl = flags.baseUrl ??
203
328
  (await ask(prompts.text({
204
- message: "Base URL the index is served from",
205
- placeholder: "https://index.example.com",
206
- validate: (value) => badUrl(value ?? "") ?? undefined,
329
+ message: pagesUrl
330
+ ? `Base URL the index is served from (Enter for ${pagesUrl}, or type a custom domain)`
331
+ : "Base URL the index is served from",
332
+ placeholder: pagesUrl ?? "https://index.example.com",
333
+ defaultValue: pagesUrl ?? "",
334
+ validate: (value) => badUrl(value || (pagesUrl ?? "")) ?? undefined,
207
335
  })));
208
- // Asked only for the combined layout — the standalone one writes no
209
- // `publish.toml` and so announces nothing.
210
- const derivedRepoUrl = gitRemoteUrl(dir) ?? repoUrlFromPages(baseUrl);
211
- const repoUrl = !flags.withSkills
212
- ? ""
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
- }))));
222
336
  const registryAlias = flags.registry ??
223
337
  (await ask(prompts.text({
224
338
  message: "Registry alias packages are published under",
@@ -228,32 +342,62 @@ async function resolveAnswers(dir, flags) {
228
342
  })));
229
343
  const logo = flags.logo ??
230
344
  (await ask(prompts.text({
231
- message: "Brand logo (path or URL, blank for none)",
345
+ message: "Brand logo (site-root path like /logo.svg, or a URL; blank for none)",
232
346
  defaultValue: "",
347
+ validate: (value) => badLogo(value ?? "") ?? undefined,
233
348
  })));
349
+ // Prompted, not assumed. This is the committed allowlist the contribution
350
+ // gate bounds every entry's `ref` by, and defaulting it silently left every
351
+ // index refusing anything not on ghcr.io - including its own packages.
352
+ const registryHost = flags.registryHost ??
353
+ (await ask(prompts.text({
354
+ message: "OCI registry host packages are pulled from (the gate's allowlist)",
355
+ placeholder: "ghcr.io",
356
+ defaultValue: "ghcr.io",
357
+ })));
358
+ // Derived from the repository's host when that is recognisable, so the
359
+ // common case never sees this question. One repository runs on one forge —
360
+ // rendering both left every index carrying a pipeline it would never run.
361
+ const detectedForge = repoUrl === remoteUrl ? derivedForge : forgeFromRepoUrl(repoUrl);
362
+ if (detectedForge)
363
+ prompts.log.step(`CI: ${detectedForge}, from the repository host`);
234
364
  const forge = flags.forge ??
365
+ detectedForge ??
235
366
  (await ask(prompts.select({
236
367
  message: "CI to scaffold",
237
368
  options: [
238
369
  { value: "github", label: "GitHub Actions" },
239
370
  { value: "gitlab", label: "GitLab CI" },
240
- { value: "both", label: "Both" },
241
371
  ],
242
372
  initialValue: "github",
243
373
  })));
244
374
  const git = flags.git ??
245
375
  (await ask(prompts.confirm({ message: "Initialize a git repository?", initialValue: true })));
376
+ // The lock is what pins the renderer this index builds and validates with,
377
+ // and CI runs `npm ci` against it — so an index without one is an index
378
+ // whose first push fails. Declining is still allowed; the next steps say
379
+ // what to run.
380
+ const install = flags.install ??
381
+ (await ask(prompts.confirm({
382
+ message: "Install dependencies now? (writes package-lock.json)",
383
+ initialValue: true,
384
+ })));
246
385
  return {
247
386
  name,
248
387
  title,
249
388
  baseUrl,
250
389
  registryAlias,
251
- registryHost: flags.registryHost ?? "ghcr.io",
390
+ registryHost,
252
391
  logo,
253
392
  forge,
254
393
  git,
394
+ install,
255
395
  withSkills: flags.withSkills ?? false,
256
- repoUrl,
396
+ // The Pages-URL fallback the `--quick` path has: someone who answered the
397
+ // base URL but left the repository blank has still said where this index
398
+ // lives, and dropping that left `repoUrl` empty — no header link, and a
399
+ // `publish.toml` that refuses to announce.
400
+ repoUrl: repoUrl || (repoUrlFromPages(baseUrl) ?? ""),
257
401
  };
258
402
  }
259
403
  /** Unwrap a clack prompt, turning Ctrl-C into a clean non-error abort. */
@@ -278,8 +422,22 @@ function siteConfig(answers) {
278
422
  site: answers.baseUrl,
279
423
  brand: answers.title,
280
424
  description: `${answers.title}, a Grimoire package index.`,
281
- ...(answers.logo ? { favicon: answers.logo } : {}),
425
+ // `logo`, not `favicon`. They are deliberately different keys (see
426
+ // `SiteConfig`): a favicon is drawn to read at 16px, a logo goes in the
427
+ // header and becomes the default `og:image`. The prompt asks for a brand
428
+ // logo, so that is where the answer belongs.
429
+ ...(answers.logo ? { logo: answers.logo } : {}),
430
+ // The header's "github" link. Written whenever it could be derived —
431
+ // it has no default, because the only sane one would be somebody
432
+ // else's repository.
433
+ ...(answers.repoUrl ? { repoUrl: answers.repoUrl } : {}),
282
434
  registry: { alias: answers.registryAlias, index: answers.baseUrl },
435
+ // 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 },
283
441
  });
284
442
  }
285
443
  /**
@@ -298,8 +456,19 @@ function indexPolicy(answers) {
298
456
  trustedBots: [],
299
457
  });
300
458
  }
301
- /** The complete scaffold as (destination path, content) pairs — nothing touches disk yet. */
302
- function plan(answers, version) {
459
+ /**
460
+ * The complete scaffold as (destination path, content) pairs — nothing
461
+ * touches disk yet.
462
+ *
463
+ * `ci` is the `ci` block that will be on disk when this returns, which is not
464
+ * always the one derived from the answers: `write` leaves an existing
465
+ * `index.config.json` alone unless `--force`. Rendering the workflows from
466
+ * the answers while the config kept its own values produced a tree whose
467
+ * committed CI did not match its committed config — and `ci --check`, which
468
+ * reads only the config, then failed on a tree `init` had just reported as
469
+ * successful.
470
+ */
471
+ function plan(answers, version, ci) {
303
472
  const vars = {
304
473
  name: answers.name,
305
474
  title: answers.title,
@@ -310,27 +479,26 @@ function plan(answers, version) {
310
479
  announceRepo: answers.repoUrl || UNDERIVED,
311
480
  announceNamespace: (answers.repoUrl && announceNamespace(answers.repoUrl)) || UNDERIVED,
312
481
  version,
313
- // The reusable workflows are pinned by tag; Renovate's `github-actions`
314
- // manager bumps this in every scaffolded repo.
315
- ref: `v${version}`,
482
+ // `title` is free text and lands inside a JSON string literal in
483
+ // package.json. A quote in it would close that string and produce a
484
+ // manifest npm refuses to read. Every other template puts it in prose.
485
+ titleJson: JSON.stringify(answers.title).slice(1, -1),
316
486
  };
317
- const from = (rel, dest) => ({
318
- path: dest,
319
- content: render(readTemplate(rel), vars, rel),
320
- });
487
+ const from = (rel, dest) => ({ path: dest, content: fromTemplate(rel, vars) });
321
488
  const files = [
322
489
  { path: "index/.gitkeep", content: "" },
323
490
  { path: "index.config.json", content: siteConfig(answers) },
324
491
  { path: "index-policy.json", content: indexPolicy(answers) },
325
492
  from("gitignore", ".gitignore"),
493
+ from("gitattributes", ".gitattributes"),
494
+ from("package.json", "package.json"),
326
495
  from("README.md", "README.md"),
327
496
  ];
328
- if (answers.forge === "github" || answers.forge === "both") {
329
- files.push(from("github-pages.yml", ".github/workflows/pages.yml"));
330
- files.push(from("github-validate.yml", ".github/workflows/validate.yml"));
331
- }
332
- if (answers.forge === "gitlab" || answers.forge === "both") {
333
- files.push(from("gitlab-ci.yml", ".gitlab-ci.yml"));
497
+ // The same renderer `grim-indexer ci` uses, driven by the `ci` block that
498
+ // will be on disk — so the scaffold is drift-free by construction and the
499
+ // guard it emits passes on the first push.
500
+ for (const [dest, content] of renderCi(resolveCi(ci))) {
501
+ files.push({ path: dest, content });
334
502
  }
335
503
  if (answers.withSkills) {
336
504
  files.push({ path: "skills/.gitkeep", content: "" });
@@ -380,6 +548,32 @@ function initGit(dir) {
380
548
  return false;
381
549
  }
382
550
  }
551
+ /**
552
+ * Generate `package-lock.json` by installing. The lock is the load-bearing
553
+ * part: CI runs `npm ci` against it, so a repository without one cannot build,
554
+ * and which renderer runs would otherwise be resolved fresh on every runner.
555
+ *
556
+ * A failure is reported and survived — the scaffold is complete either way,
557
+ * and the next steps say what to run.
558
+ */
559
+ function npmInstall(dir) {
560
+ try {
561
+ // `shell` on Windows: npm ships as `npm.cmd`, libuv's non-shell PATH
562
+ // search only tries `.com`/`.exe`, and Node refuses to spawn a `.cmd`
563
+ // without it — so without this branch the install could never succeed
564
+ // there, and every Windows scaffold silently shipped without a lockfile.
565
+ execFileSync("npm", ["install"], {
566
+ cwd: dir,
567
+ stdio: "ignore",
568
+ shell: process.platform === "win32",
569
+ });
570
+ return true;
571
+ }
572
+ catch {
573
+ console.error("warning: `npm install` failed - run it yourself to write package-lock.json");
574
+ return false;
575
+ }
576
+ }
383
577
  function nextSteps(result, answers) {
384
578
  const rel = path.relative(process.cwd(), result.dir);
385
579
  // A relative path that climbs out of the working directory is worse than
@@ -387,18 +581,26 @@ function nextSteps(result, answers) {
387
581
  const target = rel === "" ? "" : rel.startsWith("..") ? result.dir : rel;
388
582
  const cd = target === "" ? "" : `cd ${target}\n`;
389
583
  const steps = [
390
- "Add packages under index/<namespace>/<package>/metadata.json",
584
+ // The forge-host segment is mandatory: the gate refuses anything outside
585
+ // `index/<host>/<namespace>/<package>/metadata.json`, and a shallow first
586
+ // commit builds locally without complaint before failing every review.
587
+ "Add packages under index/<host>/<namespace>/<package>/metadata.json",
588
+ "Commit package-lock.json - CI installs from it, so it is not optional",
391
589
  "Push to your forge — CI builds and publishes the site",
392
590
  ];
393
591
  if (answers.baseUrl === "http://localhost:4321") {
394
- steps.push("Set baseUrl in index.config.json to the real site URL");
592
+ // Both keys, because the placeholder lands in both: `site` drives the
593
+ // deployment URL, `registry.index` is the address this index hands its
594
+ // visitors to add it with. Naming only the first left the copy-paste
595
+ // block on the published site pointing at localhost.
596
+ steps.push("Set site and registry.index in index.config.json to the real site URL");
395
597
  }
396
598
  if (answers.withSkills && !answers.repoUrl) {
397
599
  steps.push(`Set [announce] repository + namespace in publish.toml — both are ${UNDERIVED}, ` +
398
600
  "and `grim publish --announce` refuses to run until they name this repo");
399
601
  }
400
602
  return [
401
- `${cd}npx @grimoire-rs/indexer build`,
603
+ `${cd}${result.installed ? "" : "npm install\n"}npm run dev # preview it locally`,
402
604
  "",
403
605
  "Then:",
404
606
  ...steps.map((step, i) => ` ${i + 1}. ${step}`),
@@ -408,9 +610,19 @@ export async function init(dir, flags, version) {
408
610
  const target = path.resolve(dir);
409
611
  const answers = await resolveAnswers(target, flags);
410
612
  fs.mkdirSync(target, { recursive: true });
411
- const files = write(target, plan(answers, version), flags.force ?? false);
613
+ // Which `ci` block the workflows are rendered from depends on which one
614
+ // survives `write`: an existing `index.config.json` is kept unless
615
+ // `--force`, and its settings — not the answers — are what the committed
616
+ // CI has to match. A malformed one is a data error either way, and
617
+ // `loadCiConfig` raises it rather than papering over it with defaults.
618
+ const keepsExistingConfig = fs.existsSync(path.join(target, CONFIG_FILE)) && !(flags.force ?? false);
619
+ const ci = keepsExistingConfig
620
+ ? await loadCiConfig(target)
621
+ : { forge: answers.forge };
622
+ const files = write(target, plan(answers, version, ci), flags.force ?? false);
412
623
  const gitInitialized = answers.git ? initGit(target) : false;
413
- const result = { dir: target, files, gitInitialized };
624
+ const installed = answers.install ? npmInstall(target) : false;
625
+ const result = { dir: target, files, gitInitialized, installed };
414
626
  for (const file of files) {
415
627
  console.log(` ${file.outcome.padEnd(12)}${file.path}`);
416
628
  }
@@ -419,6 +631,18 @@ export async function init(dir, flags, version) {
419
631
  console.error(`\n${skipped.length} file(s) differ from the scaffold and were left alone. ` +
420
632
  `Re-run with --force to overwrite them.`);
421
633
  }
634
+ // Same contract `grim-indexer ci` has: generated CI this config no longer
635
+ // renders is reported, never deleted. Silence here left an orphaned
636
+ // pipeline behind — and the drift guard failing on the next push with no
637
+ // hint of where it came from.
638
+ const orphaned = await staleCi(target, renderCi(resolveCi(ci)));
639
+ for (const relative of orphaned) {
640
+ console.log(` ${"stale".padEnd(12)}${relative}`);
641
+ }
642
+ if (orphaned.length > 0) {
643
+ console.error(`\n${orphaned.length} generated file(s) are no longer rendered by ${CONFIG_FILE} — ` +
644
+ `delete them, or \`npm run ci:check\` keeps failing`);
645
+ }
422
646
  prompts.note(nextSteps(result, answers), "Next steps");
423
647
  prompts.outro(`Index "${answers.name}" ready in ${target}`);
424
648
  return EXIT.ok;