@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 +21 -0
- package/README.md +158 -0
- package/dist/index.d.mts +181 -0
- package/dist/index.mjs +366 -0
- package/package.json +55 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|