@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.
- package/README.md +13 -5
- package/dist/ci.d.ts +34 -0
- package/dist/ci.d.ts.map +1 -1
- package/dist/ci.js +53 -1
- package/dist/ci.js.map +1 -1
- package/dist/cli/init.d.ts +13 -2
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +319 -55
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +10 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/renderer/astro/components/BrandMark.js +0 -1
- package/dist/renderer/astro/components/Catalog.js +0 -1
- package/dist/renderer/astro/lib/base.js +0 -1
- package/dist/renderer/astro/lib/catalog.js +0 -1
- package/dist/renderer/astro/lib/code.js +0 -1
- package/dist/renderer/astro/lib/data.js +0 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +16 -0
- package/dist/renderer/index.js.map +1 -1
- package/package.json +2 -2
- package/templates/ci/github-pages.yml +1 -1
- package/templates/ci/github-publish.yml +89 -0
- package/templates/ci/github-verify-ci.yml +1 -1
- package/templates/ci/gitlab-ci.yml +1 -0
- package/templates/ci/gitlab-publish.yml +50 -0
- package/dist/renderer/astro/components/BrandMark.d.ts.map +0 -1
- package/dist/renderer/astro/components/BrandMark.js.map +0 -1
- package/dist/renderer/astro/components/Catalog.d.ts.map +0 -1
- package/dist/renderer/astro/components/Catalog.js.map +0 -1
- package/dist/renderer/astro/lib/base.d.ts.map +0 -1
- package/dist/renderer/astro/lib/base.js.map +0 -1
- package/dist/renderer/astro/lib/catalog.d.ts.map +0 -1
- package/dist/renderer/astro/lib/catalog.js.map +0 -1
- package/dist/renderer/astro/lib/code.d.ts.map +0 -1
- package/dist/renderer/astro/lib/code.js.map +0 -1
- package/dist/renderer/astro/lib/data.d.ts.map +0 -1
- package/dist/renderer/astro/lib/data.js.map +0 -1
package/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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
304
|
-
defaultValue:
|
|
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
|
-
|
|
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:
|
|
340
|
-
defaultValue:
|
|
341
|
-
validate: (value) => badName(value ||
|
|
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
|
-
|
|
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:
|
|
356
|
-
defaultValue:
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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 =
|
|
375
|
-
|
|
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
|
|
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:
|
|
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`, `
|
|
437
|
-
// to their defaults rather than written out —
|
|
438
|
-
// them the same way, so an absent key and its
|
|
439
|
-
// Which renderer runs is not here at all:
|
|
440
|
-
|
|
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:
|
|
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(
|
|
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
|
-
: {
|
|
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
|
-
`
|
|
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;
|