@theholocron/cli 5.0.0-alpha.10 → 5.0.0-alpha.12

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
@@ -112,6 +112,36 @@ Additional `repo` fields recognised by `holocron setup`:
112
112
  | `repo.protection` | `"balanced" \| "strict" \| "none"` | Branch-protection preset applied by `holocron setup`. For `"strict"`, the required status checks are derived from the task manifest — see below. |
113
113
  | `repo.properties` | `RepoProperties` | Org-level custom property values synced to the GitHub dashboard. |
114
114
 
115
+ ### Custom properties synced to GitHub
116
+
117
+ `holocron setup` and `holocron sync` both call `syncProperties()` with two
118
+ kinds of fields — always one-way (`holocron.config.ts` → resolved →
119
+ properties; properties are never a second editable source of truth):
120
+
121
+ **Manual** — `repo.properties` states these explicitly:
122
+
123
+ | Property | Value |
124
+ | ------------------------ | ---------------------------------------------- |
125
+ | `lifecycle` | `"active" \| "experimental" \| "deprecated"` |
126
+ | `open_source` | `boolean` |
127
+ | `runtime_environment` | `"node" \| "browser" \| "universal" \| "none"` |
128
+ | `uses_external_packages` | `boolean` |
129
+
130
+ **Derived** — computed from resolved config + `package.json`, not a config field:
131
+
132
+ | Property | Value |
133
+ | ------------------------- | --------------------------------------------------------------------------------------- |
134
+ | `monorepo` | `boolean` — whether `pnpm-workspace.yaml` exists |
135
+ | `branch_protection_level` | the active `repo.protection` preset |
136
+ | `holocron_profile` | repo archetype: `library` / `cli` / `plugin` / `template` / `app` / `docs` / `platform` |
137
+ | `holocron_capabilities` | comma-joined provider capability keys actually wired in `providers: {}` |
138
+ | `holocron_stack` | comma-joined detected build/framework tooling (`next`, `vite`, `astro`, `tsdown`, …) |
139
+ | `holocron_compliance` | `"compliant" \| "non-compliant"` against a minimal `source` + `ci` baseline |
140
+
141
+ Field definitions and derivation heuristics:
142
+ `.notes/tech-holocron-platform.spec.md` → "Custom-properties sync — field
143
+ definitions (#677)".
144
+
115
145
  ### Required status checks (`protection: "strict"`)
116
146
 
117
147
  `holocron setup` builds the branch-protection required-check list from the
package/dist/cli.mjs CHANGED
@@ -3796,6 +3796,94 @@ function vaultProviderName(loader) {
3796
3796
  return loader.get("vault").providerName;
3797
3797
  }
3798
3798
  //#endregion
3799
+ //#region src/commands/setup/derived-properties.ts
3800
+ /**
3801
+ * Repo archetype, in priority order:
3802
+ * 1. `*-template` repo name → `template` (independent of package shape).
3803
+ * 2. Monorepo whose primary published artifact is a CLI (some workspace
3804
+ * package has `bin`) → `platform` — matches `holocron` itself: the CLI is
3805
+ * the deliverable, the plugin packages are supporting infrastructure for
3806
+ * it, not separate products.
3807
+ * 3. Single-package repo with `package.json#bin` → `cli`.
3808
+ * 4. Docs-only site (astro/starlight present, root package private, no
3809
+ * publishable exports) → `docs`.
3810
+ * 5. Private, non-publishable, non-docs → `app`.
3811
+ * 6. Otherwise, a single publishable package → `library`.
3812
+ */
3813
+ function deriveProfile(input) {
3814
+ if (/-template$/.test(input.repoName)) return "template";
3815
+ if (input.isMonorepo) {
3816
+ if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
3817
+ }
3818
+ const pkg = input.rootPackageJson;
3819
+ if (pkg?.bin) return "cli";
3820
+ const deps = {
3821
+ ...pkg?.dependencies,
3822
+ ...pkg?.devDependencies
3823
+ };
3824
+ const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
3825
+ if (pkg?.private && isDocsSite) return "docs";
3826
+ if (pkg?.private) return "app";
3827
+ return "library";
3828
+ }
3829
+ const KNOWN_STACK_DEPS = [
3830
+ "next",
3831
+ "vite",
3832
+ "astro",
3833
+ "tsdown",
3834
+ "rollup",
3835
+ "webpack",
3836
+ "storybook",
3837
+ "vitest",
3838
+ "playwright",
3839
+ "turbo"
3840
+ ];
3841
+ /**
3842
+ * Detected build/framework tooling — the literal "does this repo need
3843
+ * next/vite/tsdown" signal `holocron.config.ts` has no field for today
3844
+ * (framework choice isn't a config concept, unlike `providers`/`tasks`).
3845
+ * Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
3846
+ * frameworks show up rather than trying to detect anything on npm.
3847
+ */
3848
+ function deriveStack(pkg) {
3849
+ const deps = {
3850
+ ...pkg?.dependencies,
3851
+ ...pkg?.devDependencies
3852
+ };
3853
+ return KNOWN_STACK_DEPS.filter((name) => name in deps);
3854
+ }
3855
+ /** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
3856
+ function deriveCapabilities(providers) {
3857
+ return Object.keys(providers ?? {}).sort();
3858
+ }
3859
+ /**
3860
+ * `source` + `ci` are the minimal baseline every repo `holocron setup`
3861
+ * touches wires — every `holocron.config.ts` in this org already satisfies
3862
+ * this, so `non-compliant` here means drift (a provider manually removed
3863
+ * after setup), not an unmet aspirational policy. Deliberately not a richer
3864
+ * per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
3865
+ * a Phase B (GitHub App) refinement once it can post *why* on a check run.
3866
+ */
3867
+ const REQUIRED_BASELINE = ["source", "ci"];
3868
+ function deriveCompliance(capabilities) {
3869
+ return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
3870
+ }
3871
+ /**
3872
+ * Reads each workspace package's actual `package.json` content (not just
3873
+ * `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
3874
+ * `platform` detection needs `bin`, which that helper doesn't carry).
3875
+ * Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
3876
+ * own soft-fail behavior.
3877
+ */
3878
+ async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
3879
+ const results = [];
3880
+ for (const { slug, dir = "packages" } of workspacePackages) try {
3881
+ const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
3882
+ results.push(JSON.parse(raw));
3883
+ } catch {}
3884
+ return results;
3885
+ }
3886
+ //#endregion
3799
3887
  //#region src/commands/setup/labels.ts
3800
3888
  const CANONICAL_LABELS = [
3801
3889
  {
@@ -5118,20 +5206,31 @@ async function runSetup(input) {
5118
5206
  print(formatStep(steps[steps.length - 1]));
5119
5207
  }
5120
5208
  {
5121
- const content = createAstromech({
5209
+ const astromechTurbo = createAstromech({
5122
5210
  cwd: input.context.repoRoot,
5123
5211
  config: { tasks: config.tasks },
5124
5212
  logger
5125
- }).turboConfig();
5213
+ });
5214
+ const content = astromechTurbo.turboConfig();
5126
5215
  if (content === null) steps.push({
5127
5216
  capability: "source",
5128
5217
  step: "write turbo.json",
5129
5218
  status: "skip",
5130
5219
  message: "no fan-out-eligible tasks configured"
5131
5220
  });
5132
- else steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5133
- await source.writeRepoFile("turbo.json", content);
5134
- }));
5221
+ else {
5222
+ steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5223
+ await source.writeRepoFile("turbo.json", content);
5224
+ }));
5225
+ print(formatStep(steps[steps.length - 1]));
5226
+ const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5227
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5228
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5229
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5230
+ if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5231
+ await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5232
+ }));
5233
+ }
5135
5234
  print(formatStep(steps[steps.length - 1]));
5136
5235
  }
5137
5236
  if (source.syncLabels) {
@@ -5149,6 +5248,21 @@ async function runSetup(input) {
5149
5248
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5150
5249
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5151
5250
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5251
+ {
5252
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5253
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5254
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5255
+ properties["holocron_profile"] = deriveProfile({
5256
+ rootPackageJson,
5257
+ repoName: basename(input.context.repoRoot),
5258
+ isMonorepo,
5259
+ workspacePackageJsons
5260
+ });
5261
+ properties["holocron_stack"] = deriveStack(rootPackageJson).join(", ");
5262
+ const capabilities = deriveCapabilities(config.providers);
5263
+ properties["holocron_capabilities"] = capabilities.join(", ");
5264
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5265
+ }
5152
5266
  if (source.syncProperties) {
5153
5267
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5154
5268
  print(formatStep(steps[steps.length - 1]));
@@ -6075,6 +6189,21 @@ async function runSync(input) {
6075
6189
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6076
6190
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6077
6191
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6192
+ {
6193
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6194
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6195
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6196
+ properties["holocron_profile"] = deriveProfile({
6197
+ rootPackageJson,
6198
+ repoName: basename(input.context.repoRoot),
6199
+ isMonorepo,
6200
+ workspacePackageJsons
6201
+ });
6202
+ properties["holocron_stack"] = deriveStack(rootPackageJson).join(", ");
6203
+ const capabilities = deriveCapabilities(config.providers);
6204
+ properties["holocron_capabilities"] = capabilities.join(", ");
6205
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6206
+ }
6078
6207
  steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6079
6208
  print(formatSyncStep(steps[steps.length - 1]));
6080
6209
  } else {
@@ -6321,11 +6450,12 @@ async function runSync(input) {
6321
6450
  print(formatSyncStep(steps[steps.length - 1]));
6322
6451
  }
6323
6452
  if (stepName === "turbo") {
6324
- const content = createAstromech({
6453
+ const astromechTurbo = createAstromech({
6325
6454
  cwd: input.context.repoRoot,
6326
6455
  config: { tasks: config.tasks },
6327
6456
  logger
6328
- }).turboConfig();
6457
+ });
6458
+ const content = astromechTurbo.turboConfig();
6329
6459
  if (content === null) {
6330
6460
  steps.push({
6331
6461
  capability: "local",
@@ -6339,6 +6469,16 @@ async function runSync(input) {
6339
6469
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6340
6470
  }));
6341
6471
  print(formatSyncStep(steps[steps.length - 1]));
6472
+ const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6473
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6474
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
6475
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6476
+ if (workspaceFix.changed) {
6477
+ steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
6478
+ await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
6479
+ }));
6480
+ print(formatSyncStep(steps[steps.length - 1]));
6481
+ }
6342
6482
  }
6343
6483
  }
6344
6484
  if (stepName === "wiki") {