@theholocron/cli 2.0.0-alpha.19 → 2.0.0-alpha.21

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
@@ -19,11 +19,11 @@ Holocron reads `holocron.config.{json,js,ts}` from the project root
19
19
  ```jsonc
20
20
  // holocron.config.json
21
21
  {
22
- "project": { "name": "my-app" },
23
- "providers": {
24
- "vault": ["1password", { "vault": "my-app" }],
25
- "source": "github"
26
- }
22
+ "project": { "name": "my-app" },
23
+ "providers": {
24
+ "vault": ["1password", { "vault": "my-app" }],
25
+ "source": "github"
26
+ }
27
27
  }
28
28
  ```
29
29
 
@@ -33,11 +33,11 @@ Holocron reads `holocron.config.{json,js,ts}` from the project root
33
33
  import { defineConfig } from '@theholocron/cli'
34
34
 
35
35
  export default defineConfig({
36
- project: { name: 'my-app' },
37
- providers: {
38
- vault: ['1password', { vault: 'my-app' }],
39
- source: 'github',
40
- },
36
+ project: { name: 'my-app' },
37
+ providers: {
38
+ vault: ['1password', { vault: 'my-app' }],
39
+ source: 'github',
40
+ },
41
41
  })
42
42
  ```
43
43
 
@@ -50,8 +50,8 @@ top (project wins):
50
50
 
51
51
  ```ts
52
52
  providers: {
53
- vault: '@acme/holocron-vault', // preset only
54
- source: ['@acme/holocron-github', { repo: 'x' }], // preset + override
53
+ vault: '@acme/holocron-vault', // preset only
54
+ source: ['@acme/holocron-github', { repo: 'x' }], // preset + override
55
55
  }
56
56
  ```
57
57
 
@@ -59,8 +59,8 @@ A capability config package exports a `CapabilityConfigPackage` default:
59
59
  ```ts
60
60
  import type { CapabilityConfigPackage } from '@theholocron/cli'
61
61
  export default {
62
- provider: '1password',
63
- options: { vault: 'acme-app' },
62
+ provider: '1password',
63
+ options: { vault: 'acme-app' },
64
64
  } satisfies CapabilityConfigPackage
65
65
  ```
66
66
 
@@ -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
@@ -2253,48 +2393,19 @@ function deriveDefaults(input) {
2253
2393
  //#endregion
2254
2394
  //#region src/commands/plugin-create/templates/auth.ts
2255
2395
  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
- }
2396
+ return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
2397
+
2398
+ export { AuthError };
2399
+ export type { ResolveTokenInput };
2400
+
2401
+ export const resolveToken = createResolveToken({
2402
+ \tenvName: "${inputs.tokenEnv}",
2403
+ \tvendorEnvName: "${inputs.vendorEnv}",
2404
+ \tkeyringService: "${inputs.slug}",
2405
+ \terrorMessage:
2406
+ \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
2407
+ \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
2408
+ });
2298
2409
  `;
2299
2410
  }
2300
2411
  //#endregion
@@ -2720,148 +2831,79 @@ Not yet published; capability methods are stubs.
2720
2831
  //#endregion
2721
2832
  //#region src/commands/plugin-create/templates/rest.ts
2722
2833
  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
- }
2834
+ return `import { createRestClient, type RequestOptions, type RestClient } from "@theholocron/cli";
2835
+
2836
+ export type { RequestOptions, RestClient };
2837
+
2838
+ export function ${`create${inputs.vendorName}RestClient`}(opts: {
2839
+ \ttoken: string;
2840
+ \tbaseUrl?: string;
2841
+ \tfetch?: typeof fetch;
2842
+ }): RestClient {
2843
+ \treturn createRestClient({
2844
+ \t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
2845
+ \t\ttoken: opts.token,
2846
+ \t\tvendor: "${inputs.vendorName}",
2847
+ \t\tfetch: opts.fetch,
2848
+ \t});
2797
2849
  }
2798
2850
  `;
2799
2851
  }
2800
2852
  //#endregion
2801
2853
  //#region src/commands/plugin-create/templates/rest-test.ts
2802
2854
  function render$6(inputs) {
2803
- const clientClass = `${inputs.vendorName}RestClient`;
2855
+ const factoryName = `create${inputs.vendorName}RestClient`;
2804
2856
  return `import { ProviderApiError } from "@theholocron/cli";
2805
2857
  import { describe, expect, it } from "vitest";
2806
2858
 
2807
- import { ${clientClass} } from "../rest.js";
2859
+ import { ${factoryName} } from "../rest.js";
2808
2860
  import { stubFetch } from "./helpers.js";
2809
2861
 
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
- });
2862
+ describe("${factoryName}", () => {
2863
+ \tit("sends bearer + accept headers and returns the parsed body", async () => {
2864
+ \t\tconst stub = stubFetch([{ status: 200, body: { ok: true } }]);
2865
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2866
+ \t\tconst res = await client.request<{ ok: boolean }>("/me");
2867
+ \t\texpect(res.ok).toBe(true);
2868
+ \t\texpect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2869
+ \t\texpect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2870
+ \t});
2871
+
2872
+ \tit("serializes body as JSON and sets content-type when present", async () => {
2873
+ \t\tconst stub = stubFetch([{ status: 200, body: {} }]);
2874
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2875
+ \t\tawait client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2876
+ \t\texpect(stub.calls[0]?.method).toBe("POST");
2877
+ \t\texpect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2878
+ \t\texpect(stub.calls[0]?.body).toEqual({ name: "demo" });
2879
+ \t});
2880
+
2881
+ \tit("returns undefined on 204", async () => {
2882
+ \t\tconst stub = stubFetch([{ status: 204 }]);
2883
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2884
+ \t\texpect(await client.request<unknown>("/whatever")).toBeUndefined();
2885
+ \t});
2886
+
2887
+ \tit("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2888
+ \t\tconst stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2889
+ \t\tconst client = ${factoryName}({ token: "bad", fetch: stub.fetch });
2890
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2891
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2892
+ \t\texpect((err as ProviderApiError).status).toBe(401);
2893
+ \t});
2894
+
2895
+ \tit("wraps transport-level failures with status 0", async () => {
2896
+ \t\tconst throwing: typeof fetch = async () => { throw new TypeError("fetch failed"); };
2897
+ \t\tconst client = ${factoryName}({ token: "t", fetch: throwing });
2898
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2899
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2900
+ \t\texpect((err as ProviderApiError).status).toBe(0);
2901
+ \t});
2902
+
2903
+ \tit("trims trailing slashes from the base URL", () => {
2904
+ \t\tconst client = ${factoryName}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2905
+ \t\texpect(client.baseUrl).toBe("${inputs.baseUrl}");
2906
+ \t});
2865
2907
  });
2866
2908
  `;
2867
2909
  }
@@ -4183,7 +4225,32 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
4183
4225
  }
4184
4226
  throw err;
4185
4227
  }
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", {
4228
+ }).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", {
4229
+ type: "number",
4230
+ demandOption: true,
4231
+ describe: "Target Node.js major version (e.g., 22)"
4232
+ }).option("from", {
4233
+ type: "number",
4234
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
4235
+ }), async (argv) => {
4236
+ let extra = [];
4237
+ try {
4238
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
4239
+ const upgradeNode = JSON.parse(raw).upgrade?.node;
4240
+ if (Array.isArray(upgradeNode?.extra)) extra = upgradeNode.extra;
4241
+ } catch {}
4242
+ const report = await runUpgradeNode({
4243
+ to: argv.to,
4244
+ ...argv.from != null ? { from: argv.from } : {},
4245
+ cwd: argv.cwd,
4246
+ dryRun: argv.dryRun,
4247
+ extra
4248
+ });
4249
+ if (report.status === "fail") {
4250
+ if (report.message) console.error(`upgrade node: ${report.message}`);
4251
+ process.exitCode = 1;
4252
+ }
4253
+ }).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
4254
  type: "string",
4188
4255
  demandOption: true
4189
4256
  }).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.19",
3
+ "version": "2.0.0-alpha.21",
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.1",
42
- "@theholocron/tsconfig": "^4.1.0",
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": {