@theholocron/holocron-plugin-infisical 2.0.0-alpha.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Newton Koumantzelis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ <!-- editorconfig-checker-disable-file -->
2
+
3
+ # `@theholocron/holocron-plugin-infisical`
4
+
5
+ Infisical plugin for [Holocron](../cli). Implements the `vault`
6
+ capability against [Infisical's REST API](https://infisical.com/docs/api-reference/overview/introduction),
7
+ plus exports `verifyToken` + `AUTH_HINT` for use by `holocron auth`.
8
+
9
+ ## One of several vault providers
10
+
11
+ `vault` is a REQUIRED capability but Infisical is one of several
12
+ providers you can pick. Peer plugins:
13
+
14
+ - **[`@theholocron/holocron-plugin-doppler`](../holocron-plugin-doppler)**
15
+ — this repo's own default vault since `2.0.0-alpha.4`.
16
+ - **[`@theholocron/holocron-plugin-1password`](../holocron-plugin-1password)**
17
+ — for teams already invested in 1Password's biometric UX.
18
+ - **This plugin** — for teams that want an open-source vault they
19
+ can self-host later if desired.
20
+
21
+ Switch by editing `holocron.config.json`.
22
+
23
+ ### When to choose Infisical
24
+
25
+ - You want an open-source vault with a clear path to self-hosting
26
+ (the plugin's `baseUrl` option points at cloud today; swap it for
27
+ your self-hosted URL later, no code changes).
28
+ - Machine-identity + Universal Auth token model matches your
29
+ security posture better than dashboard-generated Personal Tokens.
30
+ - Your team already uses Infisical for other projects.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pnpm add -D @theholocron/holocron-plugin-infisical@alpha
36
+ ```
37
+
38
+ ## Auth
39
+
40
+ Token resolution order (matches the standard 4-step precedence set
41
+ by `.notes/tech-auth-bootstrap.spec.md`):
42
+
43
+ 1. `--token <TOKEN>` flag on the holocron invocation
44
+ 2. `HOLOCRON_INFISICAL_TOKEN` env var (preferred — explicit intent)
45
+ 3. `INFISICAL_TOKEN` env var (Infisical-native, works in CI)
46
+ 4. **Keyring** — `com.theholocron.cli` service, account `infisical`
47
+ 5. `AuthError` naming all four options + the bootstrap hint
48
+
49
+ ## Setup
50
+
51
+ **Important**: this plugin sends the stored value as
52
+ `Authorization: Bearer <token>` directly. That means you need a token
53
+ that IS a bearer, not a credential-pair that needs an exchange.
54
+
55
+ Two token types work out of the box:
56
+
57
+ - **Personal API Token** — inherits your user permissions, simplest
58
+ path.
59
+ - **Token Auth** on a machine identity — long-lived, single-string
60
+ token. In Infisical: **organization access control → identities →
61
+ your identity → add auth method → Token Auth → create token**.
62
+
63
+ **Universal Auth's Client Secret does NOT work directly** —
64
+ Universal Auth is a two-step flow (client id + client secret → login
65
+ endpoint → short-lived access token). Support for that exchange is
66
+ tracked as a follow-up; for now, use one of the two above.
67
+
68
+ ```bash
69
+ # 1. Generate the token per the note above.
70
+ # 2. Hand it off to holocron's keyring (one-shot):
71
+ holocron auth set infisical <TOKEN>
72
+ # 3. Verify (calls GET /v1/workspace and reports accessible workspaces):
73
+ holocron auth check infisical
74
+ ```
75
+
76
+ **If you have a machine identity that's Universal-Auth-only**: add a
77
+ Token Auth authentication method to the SAME identity — you don't
78
+ have to create a new identity. Machine identities can have multiple
79
+ auth methods attached.
80
+
81
+ **CI**: the keyring is not available in headless containers. Expose
82
+ the token as a GitHub Actions secret and set `HOLOCRON_INFISICAL_TOKEN`
83
+ (or `INFISICAL_TOKEN`) in the workflow env. Steps 1–3 of the auth
84
+ precedence still work; step 4 quietly falls through.
85
+
86
+ ## Config
87
+
88
+ ```jsonc
89
+ {
90
+ "providers": {
91
+ "vault": ["infisical", { "workspace": "<workspace-id>", "environment": "dev" }],
92
+ },
93
+ }
94
+ ```
95
+
96
+ - `workspace` (required) — Infisical workspace (project) id. Find via
97
+ the workspace URL or the API. **Not the workspace slug** — Infisical's
98
+ API rejects slug in this position ([Infisical#1894](https://github.com/Infisical/infisical/issues/1894)).
99
+ - `environment` (required) — Environment slug (usually `dev`, `stg`,
100
+ or `prd`). `list()` reads secrets from this environment.
101
+
102
+ Individual `read` / `write` calls take a fully-qualified reference:
103
+
104
+ ```
105
+ infisical://<workspaceId>/<environment>/<name>
106
+ ```
107
+
108
+ The default `workspace` + `environment` in options apply to `list()`,
109
+ `environments()`, and `readEnvironment()` where a three-part reference
110
+ doesn't make sense.
111
+
112
+ ## What's implemented
113
+
114
+ | Method | Behavior |
115
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------- |
116
+ | `read` | `GET /v3/secrets/raw/{name}?workspaceId&environment&secretPath=/`. |
117
+ | `write` | `POST /v3/secrets/raw/{name}` (create); falls back to `PATCH` on "already exists" body for upsert semantics. |
118
+ | `list` | `GET /v3/secrets/raw` at the default `workspace + environment + secretPath=/`. |
119
+ | `environments` | `GET /v1/workspace/{workspaceId}`, returns each environment's `slug`. |
120
+ | `readEnvironment` | `GET /v3/secrets/raw?workspaceId&environment=<id>` — bulk KEY=VALUE dump for `holocron secrets sync`. |
121
+ | `ensureProject` | `POST /v2/workspace` with `{ projectName, slug }`. Treats 400/409/422 "already exists" as idempotent no-op. |
122
+ | `ensureEnvironment` | `POST /v1/workspace/{project}/environments` with `{ environmentName, environmentSlug }`. Same idempotency semantics. |
123
+
124
+ Plugin-level exports (not capability methods, per the auth-bootstrap
125
+ convention):
126
+
127
+ | Export | Purpose |
128
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `verifyToken` | `GET /v1/workspace` — returns `{ok: true, subject: "<n> workspaces · first: …"}` or `{ok: false, message}`. Works for both Personal Tokens and Universal Auth machine identities. |
130
+ | `AUTH_HINT` | Points at Infisical's docs for token generation (rather than a specific click path — dashboard UI shifts). |
131
+
132
+ ## Self-hosted Infisical
133
+
134
+ Set the `baseUrl` option in `holocron.config.json`:
135
+
136
+ ```jsonc
137
+ {
138
+ "providers": {
139
+ "vault": [
140
+ "infisical",
141
+ {
142
+ "workspace": "<id>",
143
+ "environment": "dev",
144
+ "baseUrl": "https://infisical.internal.example.com/api",
145
+ },
146
+ ],
147
+ },
148
+ }
149
+ ```
150
+
151
+ ## Status
152
+
153
+ **`v2.0.0-alpha.1`** — scaffolded via `holocron plugin create` (see
154
+ `.notes/tool-plugin-create.spec.md` — this is the first real
155
+ production use of that command, doubling as its acceptance test in
156
+ a live scenario). Not yet published on npm; capability methods are
157
+ implemented but not yet validated against a live Infisical account.
158
+ APIs may still shift before stable v2.0.0.
@@ -0,0 +1,181 @@
1
+ import { EnsureResult, Vault } from "@theholocron/cli";
2
+
3
+ //#region src/auth.d.ts
4
+ /**
5
+ * Token resolution for the Infisical plugin.
6
+ *
7
+ * Resolution order (matches the standard 4-step precedence set by
8
+ * `.notes/tech-auth-bootstrap.spec.md`):
9
+ * 1. explicit `cliToken` argument (from `--token` flag)
10
+ * 2. HOLOCRON_INFISICAL_TOKEN env var (preferred — explicit intent)
11
+ * 3. INFISICAL_TOKEN env var (vendor-native)
12
+ * 4. keyring (com.theholocron.cli / "infisical")
13
+ * 5. AuthError naming all four options + the bootstrap hint
14
+ */
15
+ declare class AuthError extends Error {
16
+ name: string;
17
+ }
18
+ interface ResolveTokenInput {
19
+ /** From `--token` CLI flag. */
20
+ cliToken?: string;
21
+ /** Env vars; passed in for testability. Defaults to `process.env`. */
22
+ env?: NodeJS.ProcessEnv;
23
+ /** Keyring lookup fn; passed in for testability. Defaults to `getToken(provider)`. */
24
+ keyring?: (provider: string) => string | null;
25
+ }
26
+ declare function resolveToken(input?: ResolveTokenInput): string;
27
+ //#endregion
28
+ //#region src/rest.d.ts
29
+ /**
30
+ * Thin REST wrapper around https://app.infisical.com/api.
31
+ *
32
+ * Bearer auth, JSON-only bodies, transport-failure wrapping with
33
+ * `status: 0` so orchestrator soft-skip paths see a clear message
34
+ * instead of a generic `TypeError: fetch failed`.
35
+ */
36
+ interface RestClientOptions {
37
+ token: string;
38
+ fetch?: typeof fetch;
39
+ baseUrl?: string;
40
+ }
41
+ interface RequestOptions {
42
+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
43
+ body?: unknown;
44
+ query?: Record<string, string>;
45
+ /** Treat this response as void even if 200 is returned. */
46
+ expectNoContent?: boolean;
47
+ }
48
+ declare class InfisicalRestClient {
49
+ private readonly token;
50
+ private readonly fetchImpl;
51
+ readonly baseUrl: string;
52
+ constructor(opts: RestClientOptions);
53
+ request<T>(path: string, opts?: RequestOptions): Promise<T>;
54
+ }
55
+ //#endregion
56
+ //#region src/capabilities/vault.d.ts
57
+ interface InfisicalVaultOptions {
58
+ /** Default workspace (project) id — read/list/etc. operate here unless overridden. */
59
+ workspace: string;
60
+ /** Default environment slug — e.g., "dev", "stg", "prd". */
61
+ environment: string;
62
+ }
63
+ declare class InfisicalVault implements Vault {
64
+ private readonly rest;
65
+ readonly key: "vault";
66
+ readonly providerName = "infisical";
67
+ private readonly workspace;
68
+ private readonly environment;
69
+ /**
70
+ * Cache of resolved workspace name/slug → id. `ensureEnvironment`
71
+ * is called once per environment during `holocron setup` (dev / stg
72
+ * / prd), so a single \`GET /v1/workspace\` lookup covers all three
73
+ * calls in the same run.
74
+ */
75
+ private readonly workspaceIdCache;
76
+ constructor(rest: InfisicalRestClient, opts: InfisicalVaultOptions);
77
+ read(reference: string): Promise<string>;
78
+ write(reference: string, value: string): Promise<void>;
79
+ list(): Promise<string[]>;
80
+ environments(): Promise<string[]>;
81
+ readEnvironment(environmentId: string): Promise<Record<string, string>>;
82
+ ensureProject(name: string): Promise<EnsureResult>;
83
+ ensureEnvironment(project: string, name: string): Promise<EnsureResult>;
84
+ /**
85
+ * Resolve a workspace name / slug to its Infisical workspace id.
86
+ *
87
+ * `runSetup` calls `ensureEnvironment(config.project.name, envName)`
88
+ * — for Doppler that's directly the API's input, but Infisical's
89
+ * environments endpoint takes the workspace **id** (UUID/nanoid),
90
+ * not the name. This helper does the translation via
91
+ * `GET /v1/workspace` (which the token needs org-level list scope
92
+ * to call).
93
+ *
94
+ * Graceful degrade: for workspace-scoped tokens that 403 on the
95
+ * org-level list, we fall back to treating the input as an id
96
+ * directly. If the operator's `holocron.config.json` names a real
97
+ * id, the subsequent POST works. If they named a slug, the POST
98
+ * 404s and the operator gets a clear "workspace not found" error
99
+ * from the API — better than swallowing the 403 silently.
100
+ *
101
+ * Results are cached per-instance so the 3 back-to-back
102
+ * `ensureEnvironment` calls in `runSetup` share one lookup.
103
+ */
104
+ private resolveWorkspaceId;
105
+ }
106
+ //#endregion
107
+ //#region src/verify-token.d.ts
108
+ /**
109
+ * `verifyToken` — plugin-level export used by `holocron auth set` +
110
+ * `holocron auth check`. Hits `GET /v1/workspace` and interprets the
111
+ * response permissively:
112
+ *
113
+ * - 200 → token is valid AND has workspace-list scope; report the
114
+ * workspace count + first workspace name.
115
+ * - 403 → token is valid (authenticated) but doesn't have scope
116
+ * to list workspaces at the org level. This is normal for
117
+ * Universal Auth machine-identity tokens that are scoped to a
118
+ * specific workspace rather than org-wide. Return ok with a
119
+ * "scope-limited" subject — the operator's `holocron.config.json`
120
+ * will name the specific workspace + environment the token
121
+ * actually has access to.
122
+ * - 401 or other → token is invalid.
123
+ *
124
+ * The distinction matters: verifyToken answers "is this a real
125
+ * Infisical token?", not "does it have every possible permission?"
126
+ * — per-capability permission failures surface at the actual read /
127
+ * write / list call sites.
128
+ *
129
+ * Kept as a standalone function (not a capability method) so the auth
130
+ * command can call it without initializing the full plugin — plugin
131
+ * construction requires an already-resolved token, which is exactly
132
+ * what we don't have yet at bootstrap time.
133
+ */
134
+ interface VerifyTokenSuccess {
135
+ ok: true;
136
+ subject: string;
137
+ }
138
+ interface VerifyTokenFailure {
139
+ ok: false;
140
+ message: string;
141
+ }
142
+ type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
143
+ interface VerifyTokenOptions {
144
+ baseUrl?: string;
145
+ fetch?: typeof fetch;
146
+ }
147
+ declare function verifyToken(token: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult>;
148
+ //#endregion
149
+ //#region src/index.d.ts
150
+ interface InfisicalPluginOptions extends ResolveTokenInput, InfisicalVaultOptions {
151
+ /** Override base URL for tests (or self-hosted Infisical). */
152
+ baseUrl?: string;
153
+ /** Override `fetch` for tests. */
154
+ fetch?: typeof fetch;
155
+ }
156
+ interface PluginContext {
157
+ options: InfisicalPluginOptions;
158
+ rest: InfisicalRestClient;
159
+ }
160
+ declare function createContext(options: InfisicalPluginOptions): PluginContext;
161
+ declare function vault(ctx: PluginContext): Vault;
162
+ declare function createPlugin(options: InfisicalPluginOptions): {
163
+ name: string;
164
+ capabilities: {
165
+ vault: () => Vault;
166
+ };
167
+ };
168
+ /**
169
+ * One-line hint printed by `holocron auth set infisical` when no
170
+ * token is supplied or the supplied token is rejected.
171
+ *
172
+ * IMPORTANT — the token must be usable as a `Authorization: Bearer`
173
+ * directly. Two token types work: **Personal API Token** (inherits
174
+ * user perms) or a **Token Auth** token on a machine identity (a
175
+ * single long-lived string). Universal Auth's Client Secret is NOT
176
+ * a bearer — it needs a `/v1/auth/universal-auth/login` exchange
177
+ * step this plugin doesn't yet do (Phase 2 follow-up).
178
+ */
179
+ declare const AUTH_HINT: string;
180
+ //#endregion
181
+ export { AUTH_HINT, AuthError, InfisicalPluginOptions, InfisicalRestClient, InfisicalVault, PluginContext, ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createPlugin, resolveToken, vault, verifyToken };
package/dist/index.mjs ADDED
@@ -0,0 +1,366 @@
1
+ import { ProviderApiError, getToken } from "@theholocron/cli";
2
+ //#region src/auth.ts
3
+ /**
4
+ * Token resolution for the Infisical plugin.
5
+ *
6
+ * Resolution order (matches the standard 4-step precedence set by
7
+ * `.notes/tech-auth-bootstrap.spec.md`):
8
+ * 1. explicit `cliToken` argument (from `--token` flag)
9
+ * 2. HOLOCRON_INFISICAL_TOKEN env var (preferred — explicit intent)
10
+ * 3. INFISICAL_TOKEN env var (vendor-native)
11
+ * 4. keyring (com.theholocron.cli / "infisical")
12
+ * 5. AuthError naming all four options + the bootstrap hint
13
+ */
14
+ var AuthError = class extends Error {
15
+ name = "AuthError";
16
+ };
17
+ function resolveToken(input = {}) {
18
+ const env = input.env ?? process.env;
19
+ const keyring = input.keyring ?? getToken;
20
+ const token = input.cliToken || env["HOLOCRON_INFISICAL_TOKEN"] || env["INFISICAL_TOKEN"] || keyring("infisical");
21
+ if (!token) throw new AuthError("no Infisical token found. Pass --token <TOKEN>, set HOLOCRON_INFISICAL_TOKEN / INFISICAL_TOKEN, or run: holocron auth set infisical <TOKEN>");
22
+ return token;
23
+ }
24
+ //#endregion
25
+ //#region src/capabilities/vault.ts
26
+ /**
27
+ * `vault` capability for Infisical.
28
+ *
29
+ * Reference format: `infisical://<workspaceId>/<environment>/<name>`
30
+ * — three parts, mirrors Doppler's `doppler://<project>/<config>/<name>`.
31
+ * The plugin options carry a default `workspace` (workspace id) +
32
+ * `environment` (slug, typically `dev`/`stg`/`prd`) so `list()` /
33
+ * `environments()` / `readEnvironment()` don't need a three-part ref.
34
+ *
35
+ * Infisical's data model:
36
+ * - Workspace (aka project) — top-level container
37
+ * - Environments — dev / stg / prd, scoped to a workspace
38
+ * - Secrets — scoped to a workspace + environment, with a path
39
+ * (defaults to root `/` in this plugin; a `secretPath` option
40
+ * would extend it but Phase 1 keeps it flat)
41
+ *
42
+ * Write semantics: Infisical's REST API separates CREATE (`POST`) and
43
+ * UPDATE (`PATCH`). We attempt POST first; on the vendor's "already
44
+ * exists" response we PATCH to upsert. The isConflict helper follows
45
+ * the same pattern as the Doppler plugin.
46
+ *
47
+ * Bootstrap semantics: `ensureProject` / `ensureEnvironment` treat
48
+ * "already exists" as success. Both use the standard Infisical
49
+ * workspace / environment create endpoints.
50
+ */
51
+ var InfisicalVault = class {
52
+ rest;
53
+ key = "vault";
54
+ providerName = "infisical";
55
+ workspace;
56
+ environment;
57
+ /**
58
+ * Cache of resolved workspace name/slug → id. `ensureEnvironment`
59
+ * is called once per environment during `holocron setup` (dev / stg
60
+ * / prd), so a single \`GET /v1/workspace\` lookup covers all three
61
+ * calls in the same run.
62
+ */
63
+ workspaceIdCache = /* @__PURE__ */ new Map();
64
+ constructor(rest, opts) {
65
+ this.rest = rest;
66
+ if (!opts.workspace) throw new Error("InfisicalVault requires `workspace` in options");
67
+ if (!opts.environment) throw new Error("InfisicalVault requires `environment` in options");
68
+ this.workspace = opts.workspace;
69
+ this.environment = opts.environment;
70
+ }
71
+ async read(reference) {
72
+ const parsed = parseReference(reference);
73
+ return (await this.rest.request(`/v3/secrets/raw/${encodeURIComponent(parsed.name)}`, { query: {
74
+ workspaceId: parsed.workspace,
75
+ environment: parsed.environment,
76
+ secretPath: "/"
77
+ } })).secret?.secretValue ?? "";
78
+ }
79
+ async write(reference, value) {
80
+ const parsed = parseReference(reference);
81
+ const scope = {
82
+ workspaceId: parsed.workspace,
83
+ environment: parsed.environment,
84
+ secretPath: "/",
85
+ secretValue: value
86
+ };
87
+ try {
88
+ await this.rest.request(`/v3/secrets/raw/${encodeURIComponent(parsed.name)}`, {
89
+ method: "POST",
90
+ body: {
91
+ ...scope,
92
+ type: "shared"
93
+ }
94
+ });
95
+ return;
96
+ } catch (err) {
97
+ if (!isConflict(err)) throw err;
98
+ await this.rest.request(`/v3/secrets/raw/${encodeURIComponent(parsed.name)}`, {
99
+ method: "PATCH",
100
+ body: scope
101
+ });
102
+ }
103
+ }
104
+ async list() {
105
+ return ((await this.rest.request("/v3/secrets/raw", { query: {
106
+ workspaceId: this.workspace,
107
+ environment: this.environment,
108
+ secretPath: "/"
109
+ } })).secrets ?? []).map((s) => s.secretKey ?? "").filter(Boolean);
110
+ }
111
+ async environments() {
112
+ return ((await this.rest.request(`/v1/workspace/${encodeURIComponent(this.workspace)}`)).workspace?.environments ?? []).map((e) => e.slug ?? e.name ?? "").filter(Boolean);
113
+ }
114
+ async readEnvironment(environmentId) {
115
+ const res = await this.rest.request("/v3/secrets/raw", { query: {
116
+ workspaceId: this.workspace,
117
+ environment: environmentId,
118
+ secretPath: "/"
119
+ } });
120
+ const out = {};
121
+ for (const s of res.secrets ?? []) if (s.secretKey && typeof s.secretValue === "string") out[s.secretKey] = s.secretValue;
122
+ return out;
123
+ }
124
+ async ensureProject(name) {
125
+ try {
126
+ await this.rest.request("/v2/workspace", {
127
+ method: "POST",
128
+ body: {
129
+ projectName: name,
130
+ slug: name
131
+ }
132
+ });
133
+ return { alreadyExists: false };
134
+ } catch (err) {
135
+ if (isConflict(err)) return { alreadyExists: true };
136
+ throw err;
137
+ }
138
+ }
139
+ async ensureEnvironment(project, name) {
140
+ const workspaceId = await this.resolveWorkspaceId(project);
141
+ try {
142
+ await this.rest.request(`/v1/workspace/${encodeURIComponent(workspaceId)}/environments`, {
143
+ method: "POST",
144
+ body: {
145
+ environmentName: name,
146
+ environmentSlug: name
147
+ }
148
+ });
149
+ return { alreadyExists: false };
150
+ } catch (err) {
151
+ if (isConflict(err)) return { alreadyExists: true };
152
+ throw err;
153
+ }
154
+ }
155
+ /**
156
+ * Resolve a workspace name / slug to its Infisical workspace id.
157
+ *
158
+ * `runSetup` calls `ensureEnvironment(config.project.name, envName)`
159
+ * — for Doppler that's directly the API's input, but Infisical's
160
+ * environments endpoint takes the workspace **id** (UUID/nanoid),
161
+ * not the name. This helper does the translation via
162
+ * `GET /v1/workspace` (which the token needs org-level list scope
163
+ * to call).
164
+ *
165
+ * Graceful degrade: for workspace-scoped tokens that 403 on the
166
+ * org-level list, we fall back to treating the input as an id
167
+ * directly. If the operator's `holocron.config.json` names a real
168
+ * id, the subsequent POST works. If they named a slug, the POST
169
+ * 404s and the operator gets a clear "workspace not found" error
170
+ * from the API — better than swallowing the 403 silently.
171
+ *
172
+ * Results are cached per-instance so the 3 back-to-back
173
+ * `ensureEnvironment` calls in `runSetup` share one lookup.
174
+ */
175
+ async resolveWorkspaceId(nameOrId) {
176
+ const cached = this.workspaceIdCache.get(nameOrId);
177
+ if (cached) return cached;
178
+ try {
179
+ const match = ((await this.rest.request("/v1/workspace"))?.workspaces ?? []).find((w) => w.name === nameOrId || w.slug === nameOrId);
180
+ const resolvedId = match?._id ?? match?.id;
181
+ if (resolvedId) {
182
+ this.workspaceIdCache.set(nameOrId, resolvedId);
183
+ return resolvedId;
184
+ }
185
+ } catch (err) {
186
+ if (!(err instanceof ProviderApiError) || err.status !== 403) throw err;
187
+ }
188
+ return nameOrId;
189
+ }
190
+ };
191
+ /**
192
+ * Parse `infisical://<workspaceId>/<environment>/<name>` into its
193
+ * parts. Throws when the shape doesn't match.
194
+ */
195
+ function parseReference(reference) {
196
+ if (!reference.startsWith("infisical://")) throw new ProviderApiError(`Infisical references must start with "infisical://": got "${reference}"`, 400, void 0);
197
+ const parts = reference.slice(12).split("/");
198
+ if (parts.length < 3) throw new ProviderApiError(`Infisical reference "${reference}" missing parts; expected infisical://<workspaceId>/<environment>/<name>`, 400, void 0);
199
+ const [workspace, environment, ...nameParts] = parts;
200
+ return {
201
+ workspace,
202
+ environment,
203
+ name: nameParts.join("/")
204
+ };
205
+ }
206
+ /**
207
+ * Infisical returns duplicate-create errors as 400 or 409 depending
208
+ * on the endpoint, with a body message that includes "already exists"
209
+ * (paraphrased). The REST client wraps both as ProviderApiError. We
210
+ * accept 409 outright and treat 400/422 with an "already exists" body
211
+ * as idempotent conflict — same pattern as the Doppler plugin.
212
+ */
213
+ function isConflict(err) {
214
+ if (!(err instanceof ProviderApiError)) return false;
215
+ if (err.status === 409) return true;
216
+ if ((err.status === 400 || err.status === 422) && hasAlreadyExistsBody(err.details)) return true;
217
+ return false;
218
+ }
219
+ function hasAlreadyExistsBody(details) {
220
+ if (typeof details !== "string") return false;
221
+ return /already exists|duplicate/i.test(details);
222
+ }
223
+ //#endregion
224
+ //#region src/rest.ts
225
+ /**
226
+ * Thin REST wrapper around https://app.infisical.com/api.
227
+ *
228
+ * Bearer auth, JSON-only bodies, transport-failure wrapping with
229
+ * `status: 0` so orchestrator soft-skip paths see a clear message
230
+ * instead of a generic `TypeError: fetch failed`.
231
+ */
232
+ var InfisicalRestClient = class {
233
+ token;
234
+ fetchImpl;
235
+ baseUrl;
236
+ constructor(opts) {
237
+ this.token = opts.token;
238
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
239
+ let url = opts.baseUrl ?? "https://app.infisical.com/api";
240
+ while (url.endsWith("/")) url = url.slice(0, -1);
241
+ this.baseUrl = url;
242
+ }
243
+ async request(path, opts = {}) {
244
+ const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : "/" + path}`);
245
+ for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
246
+ const fullUrl = url.toString();
247
+ const headers = {
248
+ authorization: `Bearer ${this.token}`,
249
+ accept: "application/json"
250
+ };
251
+ const init = {
252
+ method: opts.method ?? "GET",
253
+ headers
254
+ };
255
+ if (opts.body !== void 0) {
256
+ headers["content-type"] = "application/json";
257
+ init.body = JSON.stringify(opts.body);
258
+ }
259
+ let res;
260
+ try {
261
+ res = await this.fetchImpl(fullUrl, init);
262
+ } catch (err) {
263
+ const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
264
+ throw new ProviderApiError(`Infisical ${init.method} ${path} failed: ${detail}`, 0, void 0);
265
+ }
266
+ if (!res.ok) {
267
+ const body = await res.text().catch(() => "");
268
+ throw new ProviderApiError(`Infisical ${init.method} ${path} → ${res.status}`, res.status, body);
269
+ }
270
+ if (opts.expectNoContent || res.status === 204) return void 0;
271
+ const text = await res.text();
272
+ if (!text) return void 0;
273
+ return JSON.parse(text);
274
+ }
275
+ };
276
+ //#endregion
277
+ //#region src/verify-token.ts
278
+ /**
279
+ * `verifyToken` — plugin-level export used by `holocron auth set` +
280
+ * `holocron auth check`. Hits `GET /v1/workspace` and interprets the
281
+ * response permissively:
282
+ *
283
+ * - 200 → token is valid AND has workspace-list scope; report the
284
+ * workspace count + first workspace name.
285
+ * - 403 → token is valid (authenticated) but doesn't have scope
286
+ * to list workspaces at the org level. This is normal for
287
+ * Universal Auth machine-identity tokens that are scoped to a
288
+ * specific workspace rather than org-wide. Return ok with a
289
+ * "scope-limited" subject — the operator's `holocron.config.json`
290
+ * will name the specific workspace + environment the token
291
+ * actually has access to.
292
+ * - 401 or other → token is invalid.
293
+ *
294
+ * The distinction matters: verifyToken answers "is this a real
295
+ * Infisical token?", not "does it have every possible permission?"
296
+ * — per-capability permission failures surface at the actual read /
297
+ * write / list call sites.
298
+ *
299
+ * Kept as a standalone function (not a capability method) so the auth
300
+ * command can call it without initializing the full plugin — plugin
301
+ * construction requires an already-resolved token, which is exactly
302
+ * what we don't have yet at bootstrap time.
303
+ */
304
+ async function verifyToken(token, opts = {}) {
305
+ const restOpts = { token };
306
+ if (opts.baseUrl !== void 0) restOpts.baseUrl = opts.baseUrl;
307
+ if (opts.fetch !== void 0) restOpts.fetch = opts.fetch;
308
+ const rest = new InfisicalRestClient(restOpts);
309
+ try {
310
+ const res = await rest.request("/v1/workspace");
311
+ const count = res?.workspaces?.length ?? 0;
312
+ const first = res?.workspaces?.[0];
313
+ const label = first?.name ?? first?.slug ?? "no accessible workspaces";
314
+ return {
315
+ ok: true,
316
+ subject: `${count} workspace${count === 1 ? "" : "s"} · first: ${label}`
317
+ };
318
+ } catch (err) {
319
+ if (err instanceof ProviderApiError && err.status === 403) return {
320
+ ok: true,
321
+ subject: "scope-limited (token valid, can't list workspaces at org level)"
322
+ };
323
+ return {
324
+ ok: false,
325
+ message: err instanceof Error ? err.message : String(err)
326
+ };
327
+ }
328
+ }
329
+ //#endregion
330
+ //#region src/index.ts
331
+ function createContext(options) {
332
+ const restOpts = { token: resolveToken(options) };
333
+ if (options.baseUrl !== void 0) restOpts.baseUrl = options.baseUrl;
334
+ if (options.fetch !== void 0) restOpts.fetch = options.fetch;
335
+ return {
336
+ options,
337
+ rest: new InfisicalRestClient(restOpts)
338
+ };
339
+ }
340
+ function vault(ctx) {
341
+ return new InfisicalVault(ctx.rest, {
342
+ workspace: ctx.options.workspace,
343
+ environment: ctx.options.environment
344
+ });
345
+ }
346
+ function createPlugin(options) {
347
+ const ctx = createContext(options);
348
+ return {
349
+ name: "@theholocron/holocron-plugin-infisical",
350
+ capabilities: { vault: () => vault(ctx) }
351
+ };
352
+ }
353
+ /**
354
+ * One-line hint printed by `holocron auth set infisical` when no
355
+ * token is supplied or the supplied token is rejected.
356
+ *
357
+ * IMPORTANT — the token must be usable as a `Authorization: Bearer`
358
+ * directly. Two token types work: **Personal API Token** (inherits
359
+ * user perms) or a **Token Auth** token on a machine identity (a
360
+ * single long-lived string). Universal Auth's Client Secret is NOT
361
+ * a bearer — it needs a `/v1/auth/universal-auth/login` exchange
362
+ * step this plugin doesn't yet do (Phase 2 follow-up).
363
+ */
364
+ const AUTH_HINT = "generate a Token Auth token on your machine identity (organization → access control → identities → your identity → add auth method → Token Auth → create token) OR a Personal API Token, then: holocron auth set infisical <TOKEN>. NOT Universal Auth's Client Secret — that needs a login exchange this plugin doesn't do yet.";
365
+ //#endregion
366
+ export { AUTH_HINT, AuthError, InfisicalRestClient, InfisicalVault, createContext, createPlugin, resolveToken, vault, verifyToken };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@theholocron/holocron-plugin-infisical",
3
+ "version": "2.0.0-alpha.5",
4
+ "description": "Holocron plugin for Infisical. Implements the vault capability against Infisical's REST API, plus exports verifyToken + AUTH_HINT for `holocron auth`.",
5
+ "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-infisical#readme",
6
+ "bugs": "https://github.com/theholocron/holocron/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/theholocron/holocron.git",
10
+ "directory": "packages/holocron-plugin-infisical"
11
+ },
12
+ "license": "MIT",
13
+ "author": "Newton Koumantzelis",
14
+ "type": "module",
15
+ "main": "./dist/index.mjs",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "import": "./dist/index.mjs",
20
+ "default": "./dist/index.mjs"
21
+ }
22
+ },
23
+ "peerDependencies": {
24
+ "@theholocron/cli": "2.0.0-alpha.5"
25
+ },
26
+ "devDependencies": {
27
+ "@theholocron/tsconfig": "^4.1.0",
28
+ "@tsconfig/node-lts": "^24.0.0",
29
+ "@vitest/coverage-v8": "^3.2.6",
30
+ "eslint": "^9.36.0",
31
+ "globals": "^16.5.0",
32
+ "tsdown": "^0.22.3",
33
+ "tsx": "^4.22.4",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^3.2.6",
36
+ "@theholocron/cli": "2.0.0-alpha.5"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "lint": "eslint .",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "test:coverage": "vitest run --coverage",
52
+ "validate": "tsx scripts/validate.mjs"
53
+ },
54
+ "types": "./dist/index.d.mts"
55
+ }