@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 +25 -2
- package/dist/capabilities/index.d.mts +5 -0
- package/dist/cli.mjs +157 -30
- package/dist/index.d.mts +37 -32
- package/dist/index.mjs +55 -8
- package/package.json +6 -6
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 `
|
|
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"))?.
|
|
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.
|
|
4016
|
+
const repo = config.repo;
|
|
4013
4017
|
const effectivePreset = repo?.protection;
|
|
4014
|
-
print(`Holocron setup — ${config.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
4146
|
-
await deploy.ensureProject({ name: config.
|
|
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.
|
|
4173
|
-
return `project ${(await vault.ensureProject(config.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
38
|
-
"@theholocron/http-client": "^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.
|
|
44
|
-
"@theholocron/tsconfig": "^7.
|
|
45
|
-
"@theholocron/vitest-config": "^7.
|
|
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",
|