@theholocron/cli 2.0.0-alpha.20 → 2.0.0-alpha.22

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
@@ -1,3 +1,5 @@
1
+ <!-- editorconfig-checker-disable-file -->
2
+
1
3
  # `@theholocron/cli`
2
4
 
3
5
  The Holocron CLI — a pluggable, capability-based orchestrator for
@@ -16,29 +18,31 @@ Holocron reads `holocron.config.{json,js,ts}` from the project root
16
18
  (priority: json → js → ts).
17
19
 
18
20
  **JSON** (simplest):
21
+
19
22
  ```jsonc
20
23
  // holocron.config.json
21
24
  {
22
- "project": { "name": "my-app" },
23
- "providers": {
24
- "vault": ["1password", { "vault": "my-app" }],
25
- "source": "github"
26
- }
25
+ "project": { "name": "my-app" },
26
+ "providers": {
27
+ "vault": ["1password", { "vault": "my-app" }],
28
+ "source": "github",
29
+ },
27
30
  }
28
31
  ```
29
32
 
30
33
  **JS/TS** — use `defineConfig` for autocomplete and type-checking:
34
+
31
35
  ```ts
32
36
  // holocron.config.ts
33
- import { defineConfig } from '@theholocron/cli'
37
+ import { defineConfig } from "@theholocron/cli";
34
38
 
35
39
  export default defineConfig({
36
- project: { name: 'my-app' },
37
- providers: {
38
- vault: ['1password', { vault: 'my-app' }],
39
- source: 'github',
40
- },
41
- })
40
+ project: { name: "my-app" },
41
+ providers: {
42
+ vault: ["1password", { vault: "my-app" }],
43
+ source: "github",
44
+ },
45
+ });
42
46
  ```
43
47
 
44
48
  ### Shareable configs
@@ -50,18 +54,19 @@ top (project wins):
50
54
 
51
55
  ```ts
52
56
  providers: {
53
- vault: '@acme/holocron-vault', // preset only
54
- source: ['@acme/holocron-github', { repo: 'x' }], // preset + override
57
+ vault: '@acme/holocron-vault', // preset only
58
+ source: ['@acme/holocron-github', { repo: 'x' }], // preset + override
55
59
  }
56
60
  ```
57
61
 
58
62
  A capability config package exports a `CapabilityConfigPackage` default:
63
+
59
64
  ```ts
60
- import type { CapabilityConfigPackage } from '@theholocron/cli'
65
+ import type { CapabilityConfigPackage } from "@theholocron/cli";
61
66
  export default {
62
- provider: '1password',
63
- options: { vault: 'acme-app' },
64
- } satisfies CapabilityConfigPackage
67
+ provider: "1password",
68
+ options: { vault: "acme-app" },
69
+ } satisfies CapabilityConfigPackage;
65
70
  ```
66
71
 
67
72
  **Level 2 — whole-config presets.** Because the config file can be
@@ -69,23 +74,23 @@ JS/TS, a shared base is just an import:
69
74
 
70
75
  ```ts
71
76
  // holocron.config.ts
72
- import { acmeConfig } from '@acme/holocron-config'
73
- export default acmeConfig
77
+ import { acmeConfig } from "@acme/holocron-config";
78
+ export default acmeConfig;
74
79
  ```
75
80
 
76
81
  ## What's in here
77
82
 
78
83
  - `src/capabilities/` — the 14 capability interfaces that providers
79
- implement
84
+ implement
80
85
  - `src/config.ts` — config schema, `defineConfig`, `resolveConfig`,
81
- `CapabilityConfigPackage`
86
+ `CapabilityConfigPackage`
82
87
  - `src/load-config.ts` — `loadConfig` — reads JSON/JS/TS config files
83
88
  - `src/define-config.ts` — `defineConfig` typed pass-through
84
89
  - `src/loader.ts` — `PluginLoader` — dynamic-imports plugins, resolves
85
- capability config packages, builds the capability registry
90
+ capability config packages, builds the capability registry
86
91
  - `src/cli.ts` — yargs entry, dispatches subcommands
87
92
  - `src/commands/` — `setup`, `doctor`, `deploy`, `secret set`,
88
- `secrets sync`, `npm publish-initial`
93
+ `secrets sync`, `npm publish-initial`
89
94
 
90
95
  ## Status
91
96
 
@@ -1,3 +1,5 @@
1
+ import { ProviderApiError } from "@theholocron/http-client";
2
+
1
3
  //#region src/capabilities/index.d.ts
2
4
  /**
3
5
  * Capability interfaces — the contracts that providers implement.
@@ -39,18 +41,6 @@ interface ProviderIdentity {
39
41
  readonly key: CapabilityKey;
40
42
  readonly providerName: string;
41
43
  }
42
- /**
43
- * Surfaced from every capability call that hits a vendor API. Wraps
44
- * the underlying error with `status` (HTTP) and `details` so
45
- * orchestrators (`holocron setup`, `doctor`) can soft-skip rather
46
- * than abort.
47
- */
48
- declare class ProviderApiError extends Error {
49
- readonly status: number | undefined;
50
- readonly details?: unknown | undefined;
51
- name: string;
52
- constructor(message: string, status: number | undefined, details?: unknown | undefined);
53
- }
54
44
  interface Ruleset {
55
45
  id: number;
56
46
  name: string;
@@ -1,3 +1,4 @@
1
+ import { ProviderApiError } from "@theholocron/http-client";
1
2
  //#region src/capabilities/index.ts
2
3
  const CARDINALITY = {
3
4
  source: "single",
@@ -21,22 +22,6 @@ const CARDINALITY = {
21
22
  * own requirements at call time.
22
23
  */
23
24
  const REQUIRED_CAPABILITIES = [];
24
- /**
25
- * Surfaced from every capability call that hits a vendor API. Wraps
26
- * the underlying error with `status` (HTTP) and `details` so
27
- * orchestrators (`holocron setup`, `doctor`) can soft-skip rather
28
- * than abort.
29
- */
30
- var ProviderApiError = class extends Error {
31
- status;
32
- details;
33
- name = "ProviderApiError";
34
- constructor(message, status, details) {
35
- super(message);
36
- this.status = status;
37
- this.details = details;
38
- }
39
- };
40
25
  var WebhookVerificationError = class extends Error {
41
26
  name = "WebhookVerificationError";
42
27
  };
package/dist/cli.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import path, { basename, dirname, join } from "node:path";
3
4
  import yargs from "yargs";
4
5
  import { hideBin } from "yargs/helpers";
6
+ import { ProviderApiError } from "@theholocron/http-client";
5
7
  import { Entry, findCredentials } from "@napi-rs/keyring";
6
- import path, { dirname, join } from "node:path";
7
8
  import { createHash } from "node:crypto";
8
9
  import { spawnSync } from "node:child_process";
9
10
  import { readFile, stat } from "node:fs/promises";
@@ -31,22 +32,6 @@ const CARDINALITY = {
31
32
  * own requirements at call time.
32
33
  */
33
34
  const REQUIRED_CAPABILITIES = [];
34
- /**
35
- * Surfaced from every capability call that hits a vendor API. Wraps
36
- * the underlying error with `status` (HTTP) and `details` so
37
- * orchestrators (`holocron setup`, `doctor`) can soft-skip rather
38
- * than abort.
39
- */
40
- var ProviderApiError = class extends Error {
41
- status;
42
- details;
43
- name = "ProviderApiError";
44
- constructor(message, status, details) {
45
- super(message);
46
- this.status = status;
47
- this.details = details;
48
- }
49
- };
50
35
  //#endregion
51
36
  //#region src/config.ts
52
37
  /**
@@ -687,6 +672,161 @@ async function runNpmBumpVersions(input) {
687
672
  };
688
673
  }
689
674
  //#endregion
675
+ //#region src/commands/upgrade-node.ts
676
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
677
+ "node_modules",
678
+ ".git",
679
+ "dist",
680
+ "coverage",
681
+ "build",
682
+ ".turbo",
683
+ ".next",
684
+ "out"
685
+ ]);
686
+ function patchPackageJson(content, from, to) {
687
+ let pkg;
688
+ try {
689
+ pkg = JSON.parse(content);
690
+ } catch {
691
+ return null;
692
+ }
693
+ let changed = false;
694
+ const engines = pkg.engines;
695
+ if (engines?.node === `>=${from}.0.0`) {
696
+ engines.node = `>=${to}.0.0`;
697
+ changed = true;
698
+ }
699
+ for (const field of ["devDependencies", "dependencies"]) {
700
+ const deps = pkg[field];
701
+ if (!deps?.["@types/node"]) continue;
702
+ if (deps["@types/node"] === `^${from}.0.0`) {
703
+ deps["@types/node"] = `^${to}.0.0`;
704
+ changed = true;
705
+ }
706
+ }
707
+ return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
708
+ }
709
+ function patchYaml(content, from, to) {
710
+ const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
711
+ return updated !== content ? updated : null;
712
+ }
713
+ function patchPinFile(content, from, to) {
714
+ const trimmed = content.trim();
715
+ if (trimmed === String(from) || trimmed.startsWith(`${from}.`)) return `${to}\n`;
716
+ return null;
717
+ }
718
+ function patchDockerfile(content, from, to) {
719
+ const updated = content.replace(/^(FROM\s+node:)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
720
+ return updated !== content ? updated : null;
721
+ }
722
+ function patchToolVersions(content, from, to) {
723
+ const updated = content.replace(/^(nodejs\s+)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
724
+ return updated !== content ? updated : null;
725
+ }
726
+ const PATTERNS = [
727
+ {
728
+ matches: (n) => n === "package.json",
729
+ patch: patchPackageJson
730
+ },
731
+ {
732
+ matches: (n) => n.endsWith(".yml") || n.endsWith(".yaml"),
733
+ patch: patchYaml
734
+ },
735
+ {
736
+ matches: (n) => n === ".nvmrc" || n === ".node-version",
737
+ patch: patchPinFile
738
+ },
739
+ {
740
+ matches: (n) => n === "Dockerfile" || n.startsWith("Dockerfile."),
741
+ patch: patchDockerfile
742
+ },
743
+ {
744
+ matches: (n) => n === ".tool-versions",
745
+ patch: patchToolVersions
746
+ }
747
+ ];
748
+ function detectFrom(cwd, _readFile) {
749
+ for (const name of [".nvmrc", ".node-version"]) try {
750
+ const major = parseInt(_readFile(join(cwd, name)).trim(), 10);
751
+ if (!isNaN(major)) return major;
752
+ } catch {}
753
+ try {
754
+ const node = JSON.parse(_readFile(join(cwd, "package.json"))).engines?.node;
755
+ if (node) {
756
+ const m = node.match(/(\d+)/);
757
+ if (m) return parseInt(m[1], 10);
758
+ }
759
+ } catch {}
760
+ return null;
761
+ }
762
+ function defaultWalkFiles(dir) {
763
+ const results = [];
764
+ function walk(current) {
765
+ let entries;
766
+ try {
767
+ entries = readdirSync(current);
768
+ } catch {
769
+ return;
770
+ }
771
+ for (const entry of entries) {
772
+ if (SKIP_DIRS.has(entry)) continue;
773
+ const abs = join(current, entry);
774
+ try {
775
+ if (statSync(abs).isDirectory()) walk(abs);
776
+ else results.push(abs);
777
+ } catch {}
778
+ }
779
+ }
780
+ walk(dir);
781
+ return results;
782
+ }
783
+ async function runUpgradeNode(input) {
784
+ const print = input.print ?? ((line) => console.log(line));
785
+ const cwd = input.cwd ?? process.cwd();
786
+ const { to, dryRun = false, extra = [] } = input;
787
+ const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
788
+ const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
789
+ const _walkFiles = input.walkFiles ?? defaultWalkFiles;
790
+ const from = input.from ?? detectFrom(cwd, _readFile);
791
+ if (from === null) return {
792
+ status: "fail",
793
+ updated: [],
794
+ message: "could not detect current Node version — pass --from <major>"
795
+ };
796
+ if (from === to) {
797
+ print(`Already at Node.js ${to} — nothing to do.`);
798
+ return {
799
+ status: "ok",
800
+ updated: []
801
+ };
802
+ }
803
+ print(`Upgrading Node.js ${from} → ${to}${dryRun ? " (dry-run)" : ""}…`);
804
+ const updated = [];
805
+ const scanned = [..._walkFiles(cwd), ...extra.map((p) => join(cwd, p))];
806
+ for (const abs of scanned) {
807
+ const name = basename(abs);
808
+ const pattern = PATTERNS.find((p) => p.matches(name));
809
+ if (!pattern) continue;
810
+ let content;
811
+ try {
812
+ content = _readFile(abs);
813
+ } catch {
814
+ continue;
815
+ }
816
+ const patched = pattern.patch(content, from, to);
817
+ if (patched === null) continue;
818
+ const rel = abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs;
819
+ if (!dryRun) _writeFile(abs, patched);
820
+ print(` ${dryRun ? "~" : "✓"} ${rel}`);
821
+ updated.push(rel);
822
+ }
823
+ if (updated.length === 0) print(` · no files contained Node.js ${from} pins`);
824
+ return {
825
+ status: dryRun ? "dry-run" : "ok",
826
+ updated
827
+ };
828
+ }
829
+ //#endregion
690
830
  //#region src/templates/index.ts
691
831
  /**
692
832
  * All reusable workflow and composite action content bundled as string
@@ -1027,6 +1167,7 @@ jobs:
1027
1167
  name: Run Super Linter
1028
1168
  env:
1029
1169
  GITHUB_TOKEN: \${{ github.token }}
1170
+ DEFAULT_BRANCH: \${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
1030
1171
  ANNOTATE_ONLY: true
1031
1172
  DISABLE_COMMENTS: false
1032
1173
  IGNORE_GITIGNORED_FILES: true
@@ -2253,48 +2394,19 @@ function deriveDefaults(input) {
2253
2394
  //#endregion
2254
2395
  //#region src/commands/plugin-create/templates/auth.ts
2255
2396
  function render$17(inputs) {
2256
- return `/**
2257
- * Token resolution for the ${inputs.vendorName} plugin.
2258
- *
2259
- * Resolution order (matches the standard 4-step precedence set by
2260
- * \`.notes/tech-auth-bootstrap.spec.md\`):
2261
- * 1. explicit \`cliToken\` argument (from \`--token\` flag)
2262
- * 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
2263
- * 3. ${inputs.vendorEnv} env var (vendor-native)
2264
- * 4. keyring (com.theholocron.cli / "${inputs.slug}")
2265
- * 5. AuthError naming all four options + the bootstrap hint
2266
- */
2267
-
2268
- import { getToken as getKeyringToken } from "@theholocron/cli";
2269
-
2270
- export class AuthError extends Error {
2271
- override name = "AuthError";
2272
- }
2273
-
2274
- export interface ResolveTokenInput {
2275
- /** From \`--token\` CLI flag. */
2276
- cliToken?: string;
2277
- /** Env vars; passed in for testability. Defaults to \`process.env\`. */
2278
- env?: NodeJS.ProcessEnv;
2279
- /** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
2280
- keyring?: (provider: string) => string | null;
2281
- }
2282
-
2283
- export function resolveToken(input: ResolveTokenInput = {}): string {
2284
- const env = input.env ?? process.env;
2285
- const keyring = input.keyring ?? getKeyringToken;
2286
- // Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
2287
- // which is invalid JS) still produce syntactically valid code.
2288
- const token =
2289
- input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
2290
- if (!token) {
2291
- throw new AuthError(
2292
- "no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
2293
- "or run: holocron auth set ${inputs.slug} <TOKEN>"
2294
- );
2295
- }
2296
- return token;
2297
- }
2397
+ return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
2398
+
2399
+ export { AuthError };
2400
+ export type { ResolveTokenInput };
2401
+
2402
+ export const resolveToken = createResolveToken({
2403
+ \tenvName: "${inputs.tokenEnv}",
2404
+ \tvendorEnvName: "${inputs.vendorEnv}",
2405
+ \tkeyringService: "${inputs.slug}",
2406
+ \terrorMessage:
2407
+ \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
2408
+ \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
2409
+ });
2298
2410
  `;
2299
2411
  }
2300
2412
  //#endregion
@@ -2720,148 +2832,79 @@ Not yet published; capability methods are stubs.
2720
2832
  //#endregion
2721
2833
  //#region src/commands/plugin-create/templates/rest.ts
2722
2834
  function render$7(inputs) {
2723
- const clientClass = `${inputs.vendorName}RestClient`;
2724
- return `/**
2725
- * Thin REST wrapper around ${inputs.baseUrl}.
2726
- *
2727
- * Bearer auth, JSON-only bodies, transport-failure wrapping with
2728
- * \`status: 0\` so orchestrator soft-skip paths see a clear message
2729
- * instead of a generic \`TypeError: fetch failed\`.
2730
- */
2731
-
2732
- import { ProviderApiError } from "@theholocron/cli";
2733
-
2734
- export interface RestClientOptions {
2735
- token: string;
2736
- fetch?: typeof fetch;
2737
- baseUrl?: string;
2738
- }
2739
-
2740
- export interface RequestOptions {
2741
- method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
2742
- body?: unknown;
2743
- query?: Record<string, string>;
2744
- /** Treat this response as void even if 200 is returned. */
2745
- expectNoContent?: boolean;
2746
- }
2747
-
2748
- export class ${clientClass} {
2749
- private readonly token: string;
2750
- private readonly fetchImpl: typeof fetch;
2751
- readonly baseUrl: string;
2752
-
2753
- constructor(opts: RestClientOptions) {
2754
- this.token = opts.token;
2755
- this.fetchImpl = opts.fetch ?? globalThis.fetch;
2756
- // Manual trailing-slash trim — CodeQL flags regex on library
2757
- // input as polynomial ReDoS. O(n) loop, no backtracking risk.
2758
- let url = opts.baseUrl ?? "${inputs.baseUrl}";
2759
- while (url.endsWith("/")) url = url.slice(0, -1);
2760
- this.baseUrl = url;
2761
- }
2762
-
2763
- async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
2764
- const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
2765
- for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
2766
- const fullUrl = url.toString();
2767
-
2768
- const headers: Record<string, string> = {
2769
- authorization: \`Bearer \${this.token}\`,
2770
- accept: "application/json",
2771
- };
2772
- const init: RequestInit = {
2773
- method: opts.method ?? "GET",
2774
- headers,
2775
- };
2776
- if (opts.body !== undefined) {
2777
- headers["content-type"] = "application/json";
2778
- init.body = JSON.stringify(opts.body);
2779
- }
2780
-
2781
- let res: Response;
2782
- try {
2783
- res = await this.fetchImpl(fullUrl, init);
2784
- } catch (err) {
2785
- const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
2786
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
2787
- }
2788
- if (!res.ok) {
2789
- const body = await res.text().catch(() => "");
2790
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
2791
- }
2792
- if (opts.expectNoContent || res.status === 204) return undefined as T;
2793
- const text = await res.text();
2794
- if (!text) return undefined as T;
2795
- return JSON.parse(text) as T;
2796
- }
2835
+ return `import { createRestClient, type RequestOptions, type RestClient } from "@theholocron/cli";
2836
+
2837
+ export type { RequestOptions, RestClient };
2838
+
2839
+ export function ${`create${inputs.vendorName}RestClient`}(opts: {
2840
+ \ttoken: string;
2841
+ \tbaseUrl?: string;
2842
+ \tfetch?: typeof fetch;
2843
+ }): RestClient {
2844
+ \treturn createRestClient({
2845
+ \t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
2846
+ \t\ttoken: opts.token,
2847
+ \t\tvendor: "${inputs.vendorName}",
2848
+ \t\tfetch: opts.fetch,
2849
+ \t});
2797
2850
  }
2798
2851
  `;
2799
2852
  }
2800
2853
  //#endregion
2801
2854
  //#region src/commands/plugin-create/templates/rest-test.ts
2802
2855
  function render$6(inputs) {
2803
- const clientClass = `${inputs.vendorName}RestClient`;
2856
+ const factoryName = `create${inputs.vendorName}RestClient`;
2804
2857
  return `import { ProviderApiError } from "@theholocron/cli";
2805
2858
  import { describe, expect, it } from "vitest";
2806
2859
 
2807
- import { ${clientClass} } from "../rest.js";
2860
+ import { ${factoryName} } from "../rest.js";
2808
2861
  import { stubFetch } from "./helpers.js";
2809
2862
 
2810
- describe("${clientClass}", () => {
2811
- it("sends bearer + accept headers and returns the parsed body", async () => {
2812
- const stub = stubFetch([{ status: 200, body: { ok: true } }]);
2813
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2814
- const res = await client.request<{ ok: boolean }>("/me");
2815
- expect(res.ok).toBe(true);
2816
- expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2817
- expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2818
- });
2819
-
2820
- it("serializes body as JSON and sets content-type when present", async () => {
2821
- const stub = stubFetch([{ status: 200, body: {} }]);
2822
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2823
- await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2824
- expect(stub.calls[0]?.method).toBe("POST");
2825
- expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2826
- expect(stub.calls[0]?.body).toEqual({ name: "demo" });
2827
- });
2828
-
2829
- it("returns undefined on 204", async () => {
2830
- const stub = stubFetch([{ status: 204 }]);
2831
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2832
- expect(await client.request<unknown>("/whatever")).toBeUndefined();
2833
- });
2834
-
2835
- it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2836
- const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2837
- const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
2838
- try {
2839
- await client.request<unknown>("/me");
2840
- throw new Error("should have thrown");
2841
- } catch (err) {
2842
- expect(err).toBeInstanceOf(ProviderApiError);
2843
- expect((err as ProviderApiError).status).toBe(401);
2844
- }
2845
- });
2846
-
2847
- it("wraps transport-level failures with status 0", async () => {
2848
- const throwing: typeof fetch = async () => {
2849
- throw new TypeError("fetch failed");
2850
- };
2851
- const client = new ${clientClass}({ token: "t", fetch: throwing });
2852
- try {
2853
- await client.request<unknown>("/me");
2854
- throw new Error("should have thrown");
2855
- } catch (err) {
2856
- expect(err).toBeInstanceOf(ProviderApiError);
2857
- expect((err as ProviderApiError).status).toBe(0);
2858
- }
2859
- });
2860
-
2861
- it("trims trailing slashes from the base URL", () => {
2862
- const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2863
- expect(client.baseUrl).toBe("${inputs.baseUrl}");
2864
- });
2863
+ describe("${factoryName}", () => {
2864
+ \tit("sends bearer + accept headers and returns the parsed body", async () => {
2865
+ \t\tconst stub = stubFetch([{ status: 200, body: { ok: true } }]);
2866
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2867
+ \t\tconst res = await client.request<{ ok: boolean }>("/me");
2868
+ \t\texpect(res.ok).toBe(true);
2869
+ \t\texpect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2870
+ \t\texpect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2871
+ \t});
2872
+
2873
+ \tit("serializes body as JSON and sets content-type when present", async () => {
2874
+ \t\tconst stub = stubFetch([{ status: 200, body: {} }]);
2875
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2876
+ \t\tawait client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2877
+ \t\texpect(stub.calls[0]?.method).toBe("POST");
2878
+ \t\texpect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2879
+ \t\texpect(stub.calls[0]?.body).toEqual({ name: "demo" });
2880
+ \t});
2881
+
2882
+ \tit("returns undefined on 204", async () => {
2883
+ \t\tconst stub = stubFetch([{ status: 204 }]);
2884
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2885
+ \t\texpect(await client.request<unknown>("/whatever")).toBeUndefined();
2886
+ \t});
2887
+
2888
+ \tit("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2889
+ \t\tconst stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2890
+ \t\tconst client = ${factoryName}({ token: "bad", fetch: stub.fetch });
2891
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2892
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2893
+ \t\texpect((err as ProviderApiError).status).toBe(401);
2894
+ \t});
2895
+
2896
+ \tit("wraps transport-level failures with status 0", async () => {
2897
+ \t\tconst throwing: typeof fetch = async () => { throw new TypeError("fetch failed"); };
2898
+ \t\tconst client = ${factoryName}({ token: "t", fetch: throwing });
2899
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2900
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2901
+ \t\texpect((err as ProviderApiError).status).toBe(0);
2902
+ \t});
2903
+
2904
+ \tit("trims trailing slashes from the base URL", () => {
2905
+ \t\tconst client = ${factoryName}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2906
+ \t\texpect(client.baseUrl).toBe("${inputs.baseUrl}");
2907
+ \t});
2865
2908
  });
2866
2909
  `;
2867
2910
  }
@@ -3523,6 +3566,15 @@ function vaultProviderName(loader) {
3523
3566
  }
3524
3567
  //#endregion
3525
3568
  //#region src/commands/setup.ts
3569
+ const ALEX_CONFIG = JSON.stringify({ allow: [
3570
+ "dead",
3571
+ "failure",
3572
+ "failures",
3573
+ "hook",
3574
+ "hooks",
3575
+ "husky",
3576
+ "period"
3577
+ ] }, null, 2) + "\n";
3526
3578
  const DEPENDABOT_CONFIG = `\
3527
3579
  # AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
3528
3580
  version: 2
@@ -3746,6 +3798,13 @@ async function runSetup(input) {
3746
3798
  }));
3747
3799
  print(formatStep(steps[steps.length - 1]));
3748
3800
  }
3801
+ if (loader.has("source")) {
3802
+ const source = loader.get("source");
3803
+ steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
3804
+ await source.writeRepoFile(".alexrc.json", ALEX_CONFIG);
3805
+ }));
3806
+ print(formatStep(steps[steps.length - 1]));
3807
+ }
3749
3808
  if (loader.has("environments")) {
3750
3809
  const envs = loader.get("environments");
3751
3810
  print(" → environments");
@@ -4183,7 +4242,32 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
4183
4242
  }
4184
4243
  throw err;
4185
4244
  }
4186
- }).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [token]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
4245
+ }).command("upgrade", "Upgrade toolchain version pins across the repo", (y) => y.command("node <to>", "Scan the repo and update every Node.js version pin to a new major", (yy) => yy.positional("to", {
4246
+ type: "number",
4247
+ demandOption: true,
4248
+ describe: "Target Node.js major version (e.g., 22)"
4249
+ }).option("from", {
4250
+ type: "number",
4251
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
4252
+ }), async (argv) => {
4253
+ let extra = [];
4254
+ try {
4255
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
4256
+ const upgradeNode = JSON.parse(raw).upgrade?.node;
4257
+ if (Array.isArray(upgradeNode?.extra)) extra = upgradeNode.extra;
4258
+ } catch {}
4259
+ const report = await runUpgradeNode({
4260
+ to: argv.to,
4261
+ ...argv.from != null ? { from: argv.from } : {},
4262
+ cwd: argv.cwd,
4263
+ dryRun: argv.dryRun,
4264
+ extra
4265
+ });
4266
+ if (report.status === "fail") {
4267
+ if (report.message) console.error(`upgrade node: ${report.message}`);
4268
+ process.exitCode = 1;
4269
+ }
4270
+ }).demandCommand(1, "Run `holocron upgrade --help` to see available upgrade subcommands."), () => {}).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [token]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
4187
4271
  type: "string",
4188
4272
  demandOption: true
4189
4273
  }).positional("token", { type: "string" }), async (argv) => {
package/dist/index.d.mts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { Analytics, Auth, AuthDescription, AuthEvent, AuthEventType, AuthIdentity, AuthUser, CARDINALITY, CapabilityImpls, CapabilityKey, Cardinality, CardinalityFor, Ci, CiRun, CiRunFilter, CiRunStatus, ConnectionStringOptions, CreateAuthUserInput, Deployment, DeploymentProject, DeploymentProjectSettings, DeploymentRecord, DeploymentTarget, DeploymentTrigger, Dns, DnsRecord, DnsRecordType, EnsureResult, Environment, EnvironmentReviewer, Environments, Issue, IssueSearchFilter, Issues, LifecycleResult, LifecycleSlot, NormalizedAuthUser, Notifications, Observability, ParseWebhookInput, ProviderApiError, ProviderIdentity, REQUIRED_CAPABILITIES, RepoRef, RepoSettings, ResolvedCapability, Ruleset, SecretScope, Secrets, Source, StatusCategory, Storage, StorageBranch, Tooling, ToolingDoctorReport, TrackerDoctorReport, TrackerUser, Vault, WebhookDashboardInfo, WebhookVerificationError, isMulti } from "./capabilities/index.mjs";
2
+ import { AuthError, RequestOptions, ResolveTokenConfig as ResolveTokenConfig$1, ResolveTokenInput, RestClient, RestClientConfig, createRestClient } from "@theholocron/http-client";
2
3
 
4
+ //#region src/auth-resolver.d.ts
5
+ type ResolveTokenConfig = Omit<ResolveTokenConfig$1, "getKeyringToken">;
6
+ /** Wraps `createResolveToken` from `@theholocron/http` and injects the
7
+ * system keyring so plugins stay at a one-liner call site. */
8
+ declare function createResolveToken(config: ResolveTokenConfig): (input?: import("@theholocron/http-client").ResolveTokenInput) => string;
9
+ //#endregion
3
10
  //#region src/config.d.ts
4
11
  type ProviderOptions = Record<string, unknown>;
5
12
  /**
@@ -183,4 +190,4 @@ interface LoadedConfig {
183
190
  */
184
191
  declare function loadConfig(cwd: string): Promise<LoadedConfig>;
185
192
  //#endregion
186
- export { Analytics, AppConfig, Auth, AuthDescription, AuthEvent, AuthEventType, AuthIdentity, AuthUser, CARDINALITY, CapabilityConfigPackage, CapabilityImpls, CapabilityKey, Cardinality, CardinalityFor, Ci, CiRun, CiRunFilter, CiRunStatus, ConfigError, ConfigFileError, ConnectionStringOptions, CreateAuthUserInput, Deployment, DeploymentProject, DeploymentProjectSettings, DeploymentRecord, DeploymentTarget, DeploymentTrigger, Dns, DnsRecord, DnsRecordType, DoctorConfig, EnsureResult, Environment, EnvironmentReviewer, Environments, HolocronConfig, Issue, IssueSearchFilter, Issues, LifecycleResult, LifecycleSlot, LoadedConfig, MultiEntry, NormalizedAuthUser, Notifications, Observability, ParseWebhookInput, ProviderApiError, ProviderIdentity, ProviderOptions, REQUIRED_CAPABILITIES, RawProviderEntry, RawProvidersConfig, RepoPolicyConfig, RepoRef, RepoSettings, ResolvedCapability, ResolvedHolocronConfig, ResolvedProviderEntry, ResolvedProvidersConfig, ResolvedTuple, Ruleset, SecretScope, Secrets, SingleEntry, Source, StatusCategory, Storage, StorageBranch, Tooling, ToolingDoctorReport, TrackerDoctorReport, TrackerUser, Vault, WebhookDashboardInfo, WebhookVerificationError, defineConfig, deleteToken, getToken, isMulti, listStoredProviders, loadConfig, resolveConfig, resolveEntry, resolvePluginPackage, setToken };
193
+ export { Analytics, AppConfig, Auth, AuthDescription, AuthError, AuthEvent, AuthEventType, AuthIdentity, AuthUser, CARDINALITY, CapabilityConfigPackage, CapabilityImpls, CapabilityKey, Cardinality, CardinalityFor, Ci, CiRun, CiRunFilter, CiRunStatus, ConfigError, ConfigFileError, ConnectionStringOptions, CreateAuthUserInput, Deployment, DeploymentProject, DeploymentProjectSettings, DeploymentRecord, DeploymentTarget, DeploymentTrigger, Dns, DnsRecord, DnsRecordType, DoctorConfig, EnsureResult, Environment, EnvironmentReviewer, Environments, HolocronConfig, Issue, IssueSearchFilter, Issues, LifecycleResult, LifecycleSlot, LoadedConfig, MultiEntry, NormalizedAuthUser, Notifications, Observability, ParseWebhookInput, ProviderApiError, ProviderIdentity, ProviderOptions, REQUIRED_CAPABILITIES, RawProviderEntry, RawProvidersConfig, RepoPolicyConfig, RepoRef, RepoSettings, type RequestOptions, ResolveTokenConfig, type ResolveTokenInput, ResolvedCapability, ResolvedHolocronConfig, ResolvedProviderEntry, ResolvedProvidersConfig, ResolvedTuple, type RestClient, type RestClientConfig, Ruleset, SecretScope, Secrets, SingleEntry, Source, StatusCategory, Storage, StorageBranch, Tooling, ToolingDoctorReport, TrackerDoctorReport, TrackerUser, Vault, WebhookDashboardInfo, WebhookVerificationError, createResolveToken, createRestClient, defineConfig, deleteToken, getToken, isMulti, listStoredProviders, loadConfig, resolveConfig, resolveEntry, resolvePluginPackage, setToken };
package/dist/index.mjs CHANGED
@@ -1,8 +1,89 @@
1
1
  import { CARDINALITY, ProviderApiError, REQUIRED_CAPABILITIES, WebhookVerificationError, isMulti } from "./capabilities/index.mjs";
2
+ import { AuthError, createResolveToken as createResolveToken$1, createRestClient } from "@theholocron/http-client";
2
3
  import { Entry, findCredentials } from "@napi-rs/keyring";
3
4
  import { readFile, stat } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
7
+ //#region src/keyring.ts
8
+ /**
9
+ * Keyring-backed bootstrap credential store.
10
+ *
11
+ * Every holocron plugin's bootstrap token (the one it needs before it
12
+ * can talk to its vendor's API) can be stored in the OS keyring under
13
+ * a single reverse-DNS service scope. Managed via `holocron auth`
14
+ * subcommands; consulted at position 4 in every plugin's auth
15
+ * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
16
+ *
17
+ * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
18
+ *
19
+ * Failure model: keyring access is best-effort. Platforms without a
20
+ * supported credential store (some Linux CI images, sandboxed
21
+ * environments) will throw from the underlying library. Every export
22
+ * here catches and returns a null/empty result rather than propagating
23
+ * — the plugin's precedence chain then falls through to
24
+ * env-var-only paths, which is exactly how CI is meant to work.
25
+ */
26
+ const SERVICE = "com.theholocron.cli";
27
+ /**
28
+ * Store or overwrite a bootstrap token for a provider. Returns true on
29
+ * success, false when the underlying keyring is unsupported or errored.
30
+ */
31
+ function setToken(provider, token) {
32
+ try {
33
+ new Entry(SERVICE, provider).setPassword(token);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Read the bootstrap token for a provider. Returns `null` for both
41
+ * "not stored" and "keyring unavailable" — callers can treat them the
42
+ * same way (fall through to env-var precedence).
43
+ */
44
+ function getToken(provider) {
45
+ try {
46
+ return new Entry(SERVICE, provider).getPassword();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Delete a stored token. Returns true when a token was removed, false
53
+ * when there was nothing to delete or the keyring is unavailable.
54
+ * Distinguishing the two cases isn't worth the surface area — the
55
+ * command output makes the situation clear either way.
56
+ */
57
+ function deleteToken(provider) {
58
+ try {
59
+ return new Entry(SERVICE, provider).deletePassword();
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * List provider slugs with a stored token in this service scope.
66
+ * Uses the library's `findCredentials(service)` — supported on all
67
+ * platforms the underlying credential store supports.
68
+ */
69
+ function listStoredProviders() {
70
+ try {
71
+ return findCredentials(SERVICE).map((c) => c.account);
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+ //#endregion
77
+ //#region src/auth-resolver.ts
78
+ /** Wraps `createResolveToken` from `@theholocron/http` and injects the
79
+ * system keyring so plugins stay at a one-liner call site. */
80
+ function createResolveToken(config) {
81
+ return createResolveToken$1({
82
+ ...config,
83
+ getKeyringToken: getToken
84
+ });
85
+ }
86
+ //#endregion
6
87
  //#region src/config.ts
7
88
  /**
8
89
  * `holocron.config.json` schema, parser, and provider resolution.
@@ -119,76 +200,6 @@ function defineConfig(config) {
119
200
  return config;
120
201
  }
121
202
  //#endregion
122
- //#region src/keyring.ts
123
- /**
124
- * Keyring-backed bootstrap credential store.
125
- *
126
- * Every holocron plugin's bootstrap token (the one it needs before it
127
- * can talk to its vendor's API) can be stored in the OS keyring under
128
- * a single reverse-DNS service scope. Managed via `holocron auth`
129
- * subcommands; consulted at position 4 in every plugin's auth
130
- * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
131
- *
132
- * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
133
- *
134
- * Failure model: keyring access is best-effort. Platforms without a
135
- * supported credential store (some Linux CI images, sandboxed
136
- * environments) will throw from the underlying library. Every export
137
- * here catches and returns a null/empty result rather than propagating
138
- * — the plugin's precedence chain then falls through to
139
- * env-var-only paths, which is exactly how CI is meant to work.
140
- */
141
- const SERVICE = "com.theholocron.cli";
142
- /**
143
- * Store or overwrite a bootstrap token for a provider. Returns true on
144
- * success, false when the underlying keyring is unsupported or errored.
145
- */
146
- function setToken(provider, token) {
147
- try {
148
- new Entry(SERVICE, provider).setPassword(token);
149
- return true;
150
- } catch {
151
- return false;
152
- }
153
- }
154
- /**
155
- * Read the bootstrap token for a provider. Returns `null` for both
156
- * "not stored" and "keyring unavailable" — callers can treat them the
157
- * same way (fall through to env-var precedence).
158
- */
159
- function getToken(provider) {
160
- try {
161
- return new Entry(SERVICE, provider).getPassword();
162
- } catch {
163
- return null;
164
- }
165
- }
166
- /**
167
- * Delete a stored token. Returns true when a token was removed, false
168
- * when there was nothing to delete or the keyring is unavailable.
169
- * Distinguishing the two cases isn't worth the surface area — the
170
- * command output makes the situation clear either way.
171
- */
172
- function deleteToken(provider) {
173
- try {
174
- return new Entry(SERVICE, provider).deletePassword();
175
- } catch {
176
- return false;
177
- }
178
- }
179
- /**
180
- * List provider slugs with a stored token in this service scope.
181
- * Uses the library's `findCredentials(service)` — supported on all
182
- * platforms the underlying credential store supports.
183
- */
184
- function listStoredProviders() {
185
- try {
186
- return findCredentials(SERVICE).map((c) => c.account);
187
- } catch {
188
- return [];
189
- }
190
- }
191
- //#endregion
192
203
  //#region src/load-config.ts
193
204
  /**
194
205
  * `holocron.config.{json,js,ts}` file loader.
@@ -265,4 +276,4 @@ async function fileExists(path) {
265
276
  }
266
277
  }
267
278
  //#endregion
268
- export { CARDINALITY, ConfigError, ConfigFileError, ProviderApiError, REQUIRED_CAPABILITIES, WebhookVerificationError, defineConfig, deleteToken, getToken, isMulti, listStoredProviders, loadConfig, resolveConfig, resolveEntry, resolvePluginPackage, setToken };
279
+ export { AuthError, CARDINALITY, ConfigError, ConfigFileError, ProviderApiError, REQUIRED_CAPABILITIES, WebhookVerificationError, createResolveToken, createRestClient, defineConfig, deleteToken, getToken, isMulti, listStoredProviders, loadConfig, resolveConfig, resolveEntry, resolvePluginPackage, setToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/cli",
3
- "version": "2.0.0-alpha.20",
3
+ "version": "2.0.0-alpha.22",
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,22 +34,23 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "@napi-rs/keyring": "^1.3.0",
37
+ "@theholocron/http-client": "^0.1.0",
37
38
  "tsx": "^4.22.4",
38
39
  "yargs": "^18.0.0"
39
40
  },
40
41
  "devDependencies": {
41
- "@theholocron/eslint-config": "^5.1.2",
42
- "@theholocron/tsconfig": "^5.1.2",
43
- "@tsconfig/node-lts": "^24.0.0",
42
+ "@theholocron/eslint-config": "^5.2.0",
43
+ "@theholocron/tsconfig": "^6.0.0",
44
+ "@theholocron/vitest-config": "^5.2.0",
45
+ "@types/node": "^26",
44
46
  "@types/yargs": "^17.0.35",
45
- "@vitest/coverage-v8": "^3.2.6",
46
47
  "@vitest/eslint-plugin": "^1.6.23",
47
48
  "eslint": "^10.7.0",
48
49
  "eslint-plugin-n": "^18.2.2",
49
50
  "globals": "^17.7.0",
50
51
  "tsdown": "^0.22.3",
51
52
  "typescript": "^5.9.3",
52
- "vitest": "^3.2.6",
53
+ "vitest": "^4.1.10",
53
54
  "@theholocron/cli-utils": "0.0.0"
54
55
  },
55
56
  "publishConfig": {