@theholocron/cli 2.0.0-alpha.55 → 2.0.0-alpha.57

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 CHANGED
@@ -25,7 +25,7 @@ Holocron reads `holocron.config.{json,js,ts}` from the project root
25
25
  ```jsonc
26
26
  // holocron.config.json
27
27
  {
28
- "project": { "name": "my-app" },
28
+ "name": "my-app",
29
29
  "providers": {
30
30
  "vault": ["1password", { "vault": "my-app" }],
31
31
  "source": "github",
@@ -42,7 +42,7 @@ Holocron reads `holocron.config.{json,js,ts}` from the project root
42
42
  import { defineConfig } from "@theholocron/cli";
43
43
 
44
44
  export default defineConfig({
45
- project: { name: "my-app" },
45
+ name: "my-app",
46
46
  providers: {
47
47
  vault: ["1password", { vault: "my-app" }],
48
48
  source: "github",
@@ -51,6 +51,29 @@ export default defineConfig({
51
51
 
52
52
  ```
53
53
 
54
+ ### Auto-derived fields
55
+
56
+ `name` and `repo.name` are optional. When absent, Holocron fills them
57
+ in at load time:
58
+
59
+ | Field | Derived from | Fallback |
60
+ | ----------- | ---------------------------------------------------- | ------------------ |
61
+ | `name` | `package.json` → `name` field (scope stripped) | directory basename |
62
+ | `repo.name` | `git remote get-url origin` (parsed to `owner/repo`) | not set |
63
+
64
+ A minimal config — for repos with a `package.json` and a GitHub remote
65
+ — only needs `providers`:
66
+
67
+ <!-- prettier-ignore -->
68
+ ```jsonc
69
+ { "providers": { "source": "github" } }
70
+ ```
71
+
72
+ Set `name` explicitly whenever the derived value would be wrong: content
73
+ repos without a `package.json` (e.g. `.github`) will fall back to the
74
+ directory basename, which may not match what your vault or deployment
75
+ provider expects as a project identifier.
76
+
54
77
  ### Shareable configs
55
78
 
56
79
  **Level 1 — per-capability config packages.** Reference a published
@@ -137,6 +137,11 @@ interface Source extends ProviderIdentity {
137
137
  * Optional — providers that don't support topics omit this.
138
138
  */
139
139
  syncTopics?(topics: string[]): Promise<string>;
140
+ /**
141
+ * Set the repository description.
142
+ * Optional — providers that don't support setting descriptions omit this.
143
+ */
144
+ syncDescription?(description: string): Promise<string>;
140
145
  }
141
146
  type CiRunStatus = "queued" | "in_progress" | "completed" | "cancelled" | "failure" | "success" | "skipped";
142
147
  interface CiRun {
package/dist/cli.mjs CHANGED
@@ -7,9 +7,10 @@ import { ProviderApiError, ProviderApiError as ProviderApiError$1 } from "@theho
7
7
  import { Entry, findCredentials } from "@napi-rs/keyring";
8
8
  import { createHash } from "node:crypto";
9
9
  import { createGitHubClient } from "@theholocron/github-client";
10
- import { spawnSync } from "node:child_process";
11
- import { access, readFile, stat } from "node:fs/promises";
10
+ import { execFile, spawnSync } from "node:child_process";
11
+ import { access, readFile, stat, writeFile } from "node:fs/promises";
12
12
  import { pathToFileURL } from "node:url";
13
+ import { promisify } from "node:util";
13
14
  //#region src/capabilities/index.ts
14
15
  const CARDINALITY = {
15
16
  source: "single",
@@ -129,7 +130,7 @@ function resolveEntry(key, raw) {
129
130
  };
130
131
  }
131
132
  function resolveConfig(raw) {
132
- if (!raw.project?.name) throw new ConfigError("`project.name` is required");
133
+ if (!raw.name) throw new ConfigError("`name` is required");
133
134
  if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
134
135
  const providers = {};
135
136
  for (const [key, entry] of Object.entries(raw.providers)) {
@@ -138,7 +139,10 @@ function resolveConfig(raw) {
138
139
  }
139
140
  for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
140
141
  return {
141
- project: raw.project,
142
+ name: raw.name,
143
+ description: raw.description,
144
+ repo: raw.repo,
145
+ workflows: raw.workflows,
142
146
  providers,
143
147
  apps: raw.apps ?? [],
144
148
  doctor: raw.doctor ?? {}
@@ -465,7 +469,7 @@ var PluginLoader = class {
465
469
  */
466
470
  projectDefaults() {
467
471
  const defaults = {};
468
- if (this.config.project.repo) defaults.repo = this.config.project.repo.name;
472
+ if (this.config.repo?.name) defaults.repo = this.config.repo.name;
469
473
  return defaults;
470
474
  }
471
475
  };
@@ -527,7 +531,7 @@ async function runDoctor(input) {
527
531
  await loader.load();
528
532
  const rows = [];
529
533
  const config = input.loaded.resolved;
530
- print(`Holocron doctor — ${config.project.name}`);
534
+ print(`Holocron doctor — ${config.name}`);
531
535
  print(` config: ${input.loaded.filepath}`);
532
536
  print("");
533
537
  for (const key of loader.loadedKeys()) {
@@ -2009,7 +2013,7 @@ function generateThinCallerContent(name, withOverrides) {
2009
2013
  //#region src/commands/sync-github.ts
2010
2014
  const DEFAULT_REPO = "theholocron/.github";
2011
2015
  /**
2012
- * Extracts the `project.workflows` array from a `holocron.config.ts` source string.
2016
+ * Extracts the `workflows` array from a `holocron.config.ts` source string.
2013
2017
  * Handles both plain string entries and `{ name, with }` object entries.
2014
2018
  * Falls back to an empty array if the array cannot be found or parsed.
2015
2019
  */
@@ -2180,7 +2184,7 @@ async function runSyncGithub(input) {
2180
2184
  let entries = [];
2181
2185
  try {
2182
2186
  const data = await client.git.getContents(repo, "holocron.config.json");
2183
- entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.project?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
2187
+ entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
2184
2188
  } catch (err) {
2185
2189
  if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
2186
2190
  try {
@@ -2449,7 +2453,7 @@ const defaultExec = async (cmd, args, opts) => {
2449
2453
  //#endregion
2450
2454
  //#region src/commands/plugin-create/template-inputs.ts
2451
2455
  /** Derive the standard defaults from a slug + vendor name. */
2452
- function deriveDefaults(input) {
2456
+ function deriveDefaults$1(input) {
2453
2457
  const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
2454
2458
  const capability = input.capability;
2455
2459
  return {
@@ -3380,7 +3384,7 @@ function runPluginCreate(input) {
3380
3384
  const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
3381
3385
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
3382
3386
  validateCapability(input.capability, print);
3383
- const derived = deriveDefaults({
3387
+ const derived = deriveDefaults$1({
3384
3388
  slug: input.slug,
3385
3389
  vendorName: input.vendorName,
3386
3390
  capability: input.capability
@@ -4009,9 +4013,9 @@ async function runSetup(input) {
4009
4013
  const config = input.loaded.resolved;
4010
4014
  const dryRun = input.context.dryRun ?? false;
4011
4015
  const steps = [];
4012
- const repo = config.project.repo;
4016
+ const repo = config.repo;
4013
4017
  const effectivePreset = repo?.protection;
4014
- print(`Holocron setup — ${config.project.name}${dryRun ? " (dry-run)" : ""}`);
4018
+ print(`Holocron setup — ${config.name}${dryRun ? " (dry-run)" : ""}`);
4015
4019
  print(` config: ${input.loaded.filepath}`);
4016
4020
  print("");
4017
4021
  if (loader.has("source")) {
@@ -4029,7 +4033,7 @@ async function runSetup(input) {
4029
4033
  }));
4030
4034
  print(formatStep(steps[steps.length - 1]));
4031
4035
  }
4032
- const usesAdvancedCodeQL = (config.project.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("codeql");
4036
+ const usesAdvancedCodeQL = (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("codeql");
4033
4037
  steps.push(await runStep("source", usesAdvancedCodeQL ? "disableDefaultCodeScanning" : "enableCodeScanning", dryRun, async () => {
4034
4038
  if (usesAdvancedCodeQL) await source.disableDefaultCodeScanning();
4035
4039
  else return await source.enableCodeScanning();
@@ -4040,7 +4044,7 @@ async function runSetup(input) {
4040
4044
  await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
4041
4045
  }));
4042
4046
  print(formatStep(steps[steps.length - 1]));
4043
- const configuredWorkflowNames = (config.project.workflows ?? []).map((entry) => typeof entry === "string" ? entry : entry.name);
4047
+ const configuredWorkflowNames = (config.workflows ?? []).map((entry) => typeof entry === "string" ? entry : entry.name);
4044
4048
  const requiredChecks = effectivePreset === "strict" ? [
4045
4049
  "DCO",
4046
4050
  ...configuredWorkflowNames.flatMap((name) => {
@@ -4053,7 +4057,7 @@ async function runSetup(input) {
4053
4057
  print(formatStep(steps[steps.length - 1]));
4054
4058
  }
4055
4059
  }
4056
- const workflows = config.project.workflows;
4060
+ const workflows = config.workflows;
4057
4061
  if (loader.has("source") && workflows && workflows.length > 0) {
4058
4062
  const source = loader.get("source");
4059
4063
  print(" → workflows");
@@ -4076,7 +4080,7 @@ async function runSetup(input) {
4076
4080
  print(formatStep(steps[steps.length - 1]));
4077
4081
  }
4078
4082
  }
4079
- if (loader.has("source") && (config.project.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping-pr")) {
4083
+ if (loader.has("source") && (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping-pr")) {
4080
4084
  const source = loader.get("source");
4081
4085
  steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
4082
4086
  await source.writeRepoFile(".github/labeler.yml", labelerConfig());
@@ -4142,8 +4146,8 @@ async function runSetup(input) {
4142
4146
  if (loader.has("deployment")) {
4143
4147
  const deploy = loader.get("deployment");
4144
4148
  print(" → deployment");
4145
- steps.push(await runStep("deployment", `ensureProject ${config.project.name}`, dryRun, async () => {
4146
- await deploy.ensureProject({ name: config.project.name });
4149
+ steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
4150
+ await deploy.ensureProject({ name: config.name });
4147
4151
  }));
4148
4152
  print(formatStep(steps[steps.length - 1]));
4149
4153
  }
@@ -4169,8 +4173,8 @@ async function runSetup(input) {
4169
4173
  const vault = loader.get("vault");
4170
4174
  print(" → vault");
4171
4175
  if (vault.ensureProject) {
4172
- steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
4173
- return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
4176
+ steps.push(await runStep("vault", `ensureProject ${config.name}`, dryRun, async () => {
4177
+ return `project ${(await vault.ensureProject(config.name)).alreadyExists ? "exists" : "created"}`;
4174
4178
  }));
4175
4179
  print(formatStep(steps[steps.length - 1]));
4176
4180
  }
@@ -4180,7 +4184,7 @@ async function runSetup(input) {
4180
4184
  "prd"
4181
4185
  ]) {
4182
4186
  steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
4183
- return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
4187
+ return `${envName} ${(await vault.ensureEnvironment(config.name, envName)).alreadyExists ? "exists" : "created"}`;
4184
4188
  }));
4185
4189
  print(formatStep(steps[steps.length - 1]));
4186
4190
  }
@@ -4265,7 +4269,9 @@ function formatStep(step) {
4265
4269
  const SYNC_STEPS = [
4266
4270
  "labels",
4267
4271
  "properties",
4268
- "topics"
4272
+ "topics",
4273
+ "keywords",
4274
+ "description"
4269
4275
  ];
4270
4276
  async function runSync(input) {
4271
4277
  const print = input.print ?? ((line) => console.log(line));
@@ -4275,7 +4281,7 @@ async function runSync(input) {
4275
4281
  const dryRun = input.context.dryRun ?? false;
4276
4282
  const requestedSteps = input.steps;
4277
4283
  const steps = [];
4278
- print(`Holocron sync — ${config.project.name}${dryRun ? " (dry-run)" : ""}`);
4284
+ print(`Holocron sync — ${config.name}${dryRun ? " (dry-run)" : ""}`);
4279
4285
  print(` config: ${input.loaded.filepath}`);
4280
4286
  print("");
4281
4287
  if (loader.has("source")) {
@@ -4296,7 +4302,7 @@ async function runSync(input) {
4296
4302
  print(formatSyncStep(steps[steps.length - 1]));
4297
4303
  }
4298
4304
  if (stepName === "properties") if (source.syncProperties) {
4299
- const repo = config.project.repo;
4305
+ const repo = config.repo;
4300
4306
  const properties = {};
4301
4307
  const effectivePreset = repo?.protection;
4302
4308
  if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
@@ -4319,7 +4325,7 @@ async function runSync(input) {
4319
4325
  print(formatSyncStep(steps[steps.length - 1]));
4320
4326
  }
4321
4327
  if (stepName === "topics") {
4322
- const topics = config.project.repo?.topics ?? [];
4328
+ const topics = config.repo?.topics ?? [];
4323
4329
  if (topics.length === 0) {
4324
4330
  steps.push({
4325
4331
  capability: "source",
@@ -4341,6 +4347,47 @@ async function runSync(input) {
4341
4347
  print(formatSyncStep(steps[steps.length - 1]));
4342
4348
  }
4343
4349
  }
4350
+ if (stepName === "keywords") {
4351
+ const topics = config.repo?.topics ?? [];
4352
+ if (topics.length === 0) {
4353
+ steps.push({
4354
+ capability: "source",
4355
+ step: "sync keywords",
4356
+ status: "skip",
4357
+ message: "no topics configured"
4358
+ });
4359
+ print(formatSyncStep(steps[steps.length - 1]));
4360
+ } else {
4361
+ steps.push(await runSyncStep("source", "sync keywords", dryRun, async () => {
4362
+ return await writePackageJsonField(input.context.repoRoot, "keywords", topics) ? `${topics.length} keywords written` : `${topics.length} topics (no package.json)`;
4363
+ }));
4364
+ print(formatSyncStep(steps[steps.length - 1]));
4365
+ }
4366
+ }
4367
+ if (stepName === "description") {
4368
+ const description = config.description;
4369
+ if (!description) {
4370
+ steps.push({
4371
+ capability: "source",
4372
+ step: "sync description",
4373
+ status: "skip",
4374
+ message: "no description configured"
4375
+ });
4376
+ print(formatSyncStep(steps[steps.length - 1]));
4377
+ } else {
4378
+ steps.push(await runSyncStep("source", "sync description", dryRun, async () => {
4379
+ const pkgWrote = await writePackageJsonField(input.context.repoRoot, "description", description);
4380
+ const readmeWrote = await updateReadmeDescription(input.context.repoRoot, description);
4381
+ if (source.syncDescription) await source.syncDescription(description);
4382
+ const parts = [];
4383
+ if (pkgWrote) parts.push("package.json");
4384
+ if (readmeWrote) parts.push("README.md");
4385
+ if (source.syncDescription) parts.push("GitHub");
4386
+ return parts.length > 0 ? parts.join(", ") + " updated" : "description synced";
4387
+ }));
4388
+ print(formatSyncStep(steps[steps.length - 1]));
4389
+ }
4390
+ }
4344
4391
  }
4345
4392
  if (requestedSteps) {
4346
4393
  for (const name of requestedSteps) if (!SYNC_STEPS.includes(name)) {
@@ -4402,6 +4449,44 @@ function formatSyncStep(step) {
4402
4449
  const detail = step.message ? ` (${step.message})` : "";
4403
4450
  return ` ${icon} ${step.step}${detail}`;
4404
4451
  }
4452
+ async function writePackageJsonField(repoRoot, field, value) {
4453
+ const pkgPath = join(repoRoot, "package.json");
4454
+ let content;
4455
+ try {
4456
+ content = await readFile(pkgPath, "utf8");
4457
+ } catch {
4458
+ return false;
4459
+ }
4460
+ const pkg = JSON.parse(content);
4461
+ pkg[field] = value;
4462
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
4463
+ return true;
4464
+ }
4465
+ const README_DESC_START = "<!-- holocron:description -->";
4466
+ const README_DESC_END = "<!-- /holocron:description -->";
4467
+ async function updateReadmeDescription(repoRoot, description) {
4468
+ const readmePath = join(repoRoot, "README.md");
4469
+ let content;
4470
+ try {
4471
+ content = await readFile(readmePath, "utf8");
4472
+ } catch {
4473
+ return false;
4474
+ }
4475
+ const lines = content.split("\n");
4476
+ const startIdx = lines.findIndex((l) => l.trim() === README_DESC_START);
4477
+ const endIdx = lines.findIndex((l) => l.trim() === README_DESC_END);
4478
+ if (startIdx !== -1) {
4479
+ if (endIdx === -1 || endIdx <= startIdx) return false;
4480
+ lines.splice(startIdx + 1, endIdx - startIdx - 1, description);
4481
+ await writeFile(readmePath, lines.join("\n"), "utf8");
4482
+ return true;
4483
+ }
4484
+ const h1Index = lines.findIndex((l) => /^# /.test(l));
4485
+ if (h1Index === -1) return false;
4486
+ lines.splice(h1Index + 1, 0, "", README_DESC_START, description, README_DESC_END);
4487
+ await writeFile(readmePath, lines.join("\n"), "utf8");
4488
+ return true;
4489
+ }
4405
4490
  //#endregion
4406
4491
  //#region src/load-config.ts
4407
4492
  /**
@@ -4415,6 +4500,7 @@ function formatSyncStep(step) {
4415
4500
  * All three forms are validated through the same `resolveConfig` path.
4416
4501
  * Implements the lookup-order contract from issue #75 / #81.
4417
4502
  */
4503
+ const execFileAsync = promisify(execFile);
4418
4504
  const CANDIDATE_FILENAMES = [
4419
4505
  "holocron.config.json",
4420
4506
  "holocron.config.js",
@@ -4456,20 +4542,61 @@ async function loadJson(filepath) {
4456
4542
  } catch (err) {
4457
4543
  throw new ConfigError(`${filepath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
4458
4544
  }
4459
- return resolveConfig(parsed);
4545
+ return resolveConfig(await deriveDefaults(dirname(filepath), parsed));
4460
4546
  }
4461
4547
  async function loadJs(filepath) {
4462
- return extractAndResolve(filepath, await import(pathToFileURL(filepath).href));
4548
+ const mod = await import(pathToFileURL(filepath).href);
4549
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
4463
4550
  }
4464
4551
  async function loadTs(filepath) {
4465
4552
  const { tsImport } = await import("tsx/esm/api");
4466
- return extractAndResolve(filepath, await tsImport(pathToFileURL(filepath).href, import.meta.url));
4553
+ const mod = await tsImport(pathToFileURL(filepath).href, import.meta.url);
4554
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
4467
4555
  }
4468
- function extractAndResolve(filepath, mod) {
4556
+ function extractRaw(filepath, mod) {
4469
4557
  const outer = mod.default;
4470
4558
  const raw = outer?.__esModule === true ? outer.default : outer;
4471
4559
  if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`);
4472
- return resolveConfig(raw);
4560
+ return raw;
4561
+ }
4562
+ async function deriveDefaults(configDir, raw) {
4563
+ const result = { ...raw };
4564
+ if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
4565
+ if (result.repo && !result.repo.name) {
4566
+ const repoName = await readGitRemote(configDir);
4567
+ if (repoName) result.repo = {
4568
+ ...result.repo,
4569
+ name: repoName
4570
+ };
4571
+ }
4572
+ return result;
4573
+ }
4574
+ async function readPackageJsonName(dir) {
4575
+ try {
4576
+ const content = await readFile(join(dir, "package.json"), "utf8");
4577
+ const pkg = JSON.parse(content);
4578
+ return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
4579
+ } catch {
4580
+ return;
4581
+ }
4582
+ }
4583
+ async function readGitRemote(dir) {
4584
+ try {
4585
+ const { stdout } = await execFileAsync("git", [
4586
+ "remote",
4587
+ "get-url",
4588
+ "origin"
4589
+ ], { cwd: dir });
4590
+ return parseGitRemoteUrl(stdout.trim());
4591
+ } catch {
4592
+ return;
4593
+ }
4594
+ }
4595
+ function parseGitRemoteUrl(url) {
4596
+ const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
4597
+ if (httpsMatch) return httpsMatch[1];
4598
+ const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
4599
+ if (sshMatch) return sshMatch[1];
4473
4600
  }
4474
4601
  async function fileExists(path) {
4475
4602
  try {
package/dist/index.d.mts CHANGED
@@ -38,8 +38,8 @@ interface RepoProperties {
38
38
  uses_external_packages?: boolean;
39
39
  }
40
40
  interface RepoConfig {
41
- /** "owner/name" — the GitHub repository coordinate. */
42
- name: string;
41
+ /** "owner/name" — the GitHub repository coordinate. Derived from the git remote when absent. */
42
+ name?: string;
43
43
  /**
44
44
  * Branch protection preset applied by `holocron setup`. When omitted,
45
45
  * no protection is applied and no `branch_protection_level` property is set.
@@ -61,36 +61,35 @@ interface DoctorConfig {
61
61
  checks?: string[];
62
62
  }
63
63
  interface HolocronConfig {
64
- project: {
64
+ /** Project name. Derived from package.json when absent. */
65
+ name?: string;
66
+ description?: string;
67
+ /**
68
+ * Repository identity and metadata. When set, `PluginLoader` injects
69
+ * `repo.name` into every plugin's `RuntimeContext.repo` so plugins that
70
+ * need a repo (github, etc.) don't require `--repo` on every invocation.
71
+ * `--repo` on the command line still overrides.
72
+ */
73
+ repo?: RepoConfig;
74
+ /**
75
+ * CI workflow names to install as thin wrappers during `holocron setup`.
76
+ * Each name maps to a reusable workflow in `theholocron/.github`.
77
+ * Use the object form to pass `with:` inputs to the reusable workflow.
78
+ *
79
+ * Supported values: "lint" | "test" | "typecheck" | "codeql" | "review" |
80
+ * "release" | "stale" | "greetings" | "dependencies" | "bookkeeping-pr" | "audit"
81
+ *
82
+ * `holocron setup` writes `.github/workflows/<name>.yml` for each entry,
83
+ * calling the corresponding `ci-<name>.yml@main` reusable workflow.
84
+ * Files are overwritten on each run — they are generated artifacts.
85
+ *
86
+ * @example
87
+ * ["lint", { "name": "release", "with": { "run-build": false } }]
88
+ */
89
+ workflows?: Array<string | {
65
90
  name: string;
66
- description?: string;
67
- /**
68
- * Repository identity and metadata. When set, `PluginLoader` injects
69
- * `repo.name` into every plugin's `RuntimeContext.repo` so plugins that
70
- * need a repo (github, etc.) don't require `--repo` on every invocation.
71
- * `--repo` on the command line still overrides.
72
- */
73
- repo?: RepoConfig;
74
- /**
75
- * CI workflow names to install as thin wrappers during `holocron setup`.
76
- * Each name maps to a reusable workflow in `theholocron/.github`.
77
- * Use the object form to pass `with:` inputs to the reusable workflow.
78
- *
79
- * Supported values: "lint" | "test" | "typecheck" | "codeql" | "review" |
80
- * "release" | "stale" | "greetings" | "dependencies" | "bookkeeping-pr" | "audit"
81
- *
82
- * `holocron setup` writes `.github/workflows/<name>.yml` for each entry,
83
- * calling the corresponding `ci-<name>.yml@main` reusable workflow.
84
- * Files are overwritten on each run — they are generated artifacts.
85
- *
86
- * @example
87
- * ["lint", { "name": "release", "with": { "run-build": false } }]
88
- */
89
- workflows?: Array<string | {
90
- name: string;
91
- with?: Record<string, unknown>;
92
- }>;
93
- };
91
+ with?: Record<string, unknown>;
92
+ }>;
94
93
  providers: RawProvidersConfig;
95
94
  apps?: AppConfig[];
96
95
  doctor?: DoctorConfig;
@@ -110,7 +109,13 @@ type ResolvedProviderEntry = {
110
109
  };
111
110
  type ResolvedProvidersConfig = Partial<Record<CapabilityKey, ResolvedProviderEntry>>;
112
111
  interface ResolvedHolocronConfig {
113
- project: HolocronConfig["project"];
112
+ name: string;
113
+ description?: string;
114
+ repo?: RepoConfig;
115
+ workflows?: Array<string | {
116
+ name: string;
117
+ with?: Record<string, unknown>;
118
+ }>;
114
119
  providers: ResolvedProvidersConfig;
115
120
  apps: AppConfig[];
116
121
  doctor: DoctorConfig;
package/dist/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  import { CARDINALITY, ProviderApiError, REQUIRED_CAPABILITIES, WebhookVerificationError, isMulti } from "./capabilities/index.mjs";
2
2
  import { AuthError, createResolveToken as createResolveToken$1, createRestClient } from "@theholocron/http-client";
3
3
  import { Entry, findCredentials } from "@napi-rs/keyring";
4
+ import { execFile } from "node:child_process";
4
5
  import { readFile, stat } from "node:fs/promises";
5
- import { join } from "node:path";
6
+ import { basename, dirname, join } from "node:path";
6
7
  import { pathToFileURL } from "node:url";
8
+ import { promisify } from "node:util";
7
9
  //#region src/keyring.ts
8
10
  /**
9
11
  * Keyring-backed bootstrap credential store.
@@ -179,7 +181,7 @@ function resolveEntry(key, raw) {
179
181
  };
180
182
  }
181
183
  function resolveConfig(raw) {
182
- if (!raw.project?.name) throw new ConfigError("`project.name` is required");
184
+ if (!raw.name) throw new ConfigError("`name` is required");
183
185
  if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
184
186
  const providers = {};
185
187
  for (const [key, entry] of Object.entries(raw.providers)) {
@@ -188,7 +190,10 @@ function resolveConfig(raw) {
188
190
  }
189
191
  for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
190
192
  return {
191
- project: raw.project,
193
+ name: raw.name,
194
+ description: raw.description,
195
+ repo: raw.repo,
196
+ workflows: raw.workflows,
192
197
  providers,
193
198
  apps: raw.apps ?? [],
194
199
  doctor: raw.doctor ?? {}
@@ -212,6 +217,7 @@ function defineConfig(config) {
212
217
  * All three forms are validated through the same `resolveConfig` path.
213
218
  * Implements the lookup-order contract from issue #75 / #81.
214
219
  */
220
+ const execFileAsync = promisify(execFile);
215
221
  const CANDIDATE_FILENAMES = [
216
222
  "holocron.config.json",
217
223
  "holocron.config.js",
@@ -253,20 +259,61 @@ async function loadJson(filepath) {
253
259
  } catch (err) {
254
260
  throw new ConfigError(`${filepath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
255
261
  }
256
- return resolveConfig(parsed);
262
+ return resolveConfig(await deriveDefaults(dirname(filepath), parsed));
257
263
  }
258
264
  async function loadJs(filepath) {
259
- return extractAndResolve(filepath, await import(pathToFileURL(filepath).href));
265
+ const mod = await import(pathToFileURL(filepath).href);
266
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
260
267
  }
261
268
  async function loadTs(filepath) {
262
269
  const { tsImport } = await import("tsx/esm/api");
263
- return extractAndResolve(filepath, await tsImport(pathToFileURL(filepath).href, import.meta.url));
270
+ const mod = await tsImport(pathToFileURL(filepath).href, import.meta.url);
271
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
264
272
  }
265
- function extractAndResolve(filepath, mod) {
273
+ function extractRaw(filepath, mod) {
266
274
  const outer = mod.default;
267
275
  const raw = outer?.__esModule === true ? outer.default : outer;
268
276
  if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`);
269
- return resolveConfig(raw);
277
+ return raw;
278
+ }
279
+ async function deriveDefaults(configDir, raw) {
280
+ const result = { ...raw };
281
+ if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
282
+ if (result.repo && !result.repo.name) {
283
+ const repoName = await readGitRemote(configDir);
284
+ if (repoName) result.repo = {
285
+ ...result.repo,
286
+ name: repoName
287
+ };
288
+ }
289
+ return result;
290
+ }
291
+ async function readPackageJsonName(dir) {
292
+ try {
293
+ const content = await readFile(join(dir, "package.json"), "utf8");
294
+ const pkg = JSON.parse(content);
295
+ return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
296
+ } catch {
297
+ return;
298
+ }
299
+ }
300
+ async function readGitRemote(dir) {
301
+ try {
302
+ const { stdout } = await execFileAsync("git", [
303
+ "remote",
304
+ "get-url",
305
+ "origin"
306
+ ], { cwd: dir });
307
+ return parseGitRemoteUrl(stdout.trim());
308
+ } catch {
309
+ return;
310
+ }
311
+ }
312
+ function parseGitRemoteUrl(url) {
313
+ const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
314
+ if (httpsMatch) return httpsMatch[1];
315
+ const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
316
+ if (sshMatch) return sshMatch[1];
270
317
  }
271
318
  async function fileExists(path) {
272
319
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/cli",
3
- "version": "2.0.0-alpha.55",
3
+ "version": "2.0.0-alpha.57",
4
4
  "description": "The Holocron CLI — a pluggable, capability-based orchestrator for spinning up and operating software projects.",
5
5
  "homepage": "https://github.com/theholocron/holocron/tree/main/packages/cli#readme",
6
6
  "bugs": "https://github.com/theholocron/holocron/issues",
@@ -34,15 +34,15 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "@napi-rs/keyring": "^1.3.0",
37
- "@theholocron/github-client": "^0.7.0",
38
- "@theholocron/http-client": "^0.7.0",
37
+ "@theholocron/github-client": "^0.11.3",
38
+ "@theholocron/http-client": "^0.11.3",
39
39
  "tsx": "^4.22.4",
40
40
  "yargs": "^18.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@theholocron/eslint-config": "^7.0.0",
44
- "@theholocron/tsconfig": "^7.0.0",
45
- "@theholocron/vitest-config": "^7.2.3",
43
+ "@theholocron/eslint-config": "^7.3.0",
44
+ "@theholocron/tsconfig": "^7.3.0",
45
+ "@theholocron/vitest-config": "^7.3.0",
46
46
  "@types/node": "^26",
47
47
  "@types/yargs": "^17.0.35",
48
48
  "@vitest/coverage-v8": "^4.1.10",