@theholocron/holocron-plugin-doppler 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 +101 -0
- package/dist/index.d.mts +131 -0
- package/dist/index.mjs +288 -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,101 @@
|
|
|
1
|
+
<!-- editorconfig-checker-disable-file -->
|
|
2
|
+
|
|
3
|
+
# `@theholocron/holocron-plugin-doppler`
|
|
4
|
+
|
|
5
|
+
Doppler plugin for [Holocron](../cli). Implements the `vault`
|
|
6
|
+
capability against [Doppler's REST API](https://docs.doppler.com/reference/api),
|
|
7
|
+
plus exports `verifyToken` + `AUTH_HINT` for use by `holocron auth`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add -D @theholocron/holocron-plugin-doppler@alpha
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Auth
|
|
16
|
+
|
|
17
|
+
Token resolution order (matches the standard 4-step precedence set by
|
|
18
|
+
`.notes/tech-auth-bootstrap.spec.md`):
|
|
19
|
+
|
|
20
|
+
1. `--token <TOKEN>` flag on the holocron invocation
|
|
21
|
+
2. `HOLOCRON_DOPPLER_TOKEN` env var (preferred — explicit intent)
|
|
22
|
+
3. `DOPPLER_TOKEN` env var (Doppler-native, works in CI)
|
|
23
|
+
4. **Keyring** — `com.theholocron.cli` service, account `doppler`
|
|
24
|
+
5. `AuthError` naming all four options + the bootstrap hint
|
|
25
|
+
|
|
26
|
+
## Manual setup (one-time, per operator)
|
|
27
|
+
|
|
28
|
+
Doppler's free tier does not expose Service Accounts (Team+ only). Use
|
|
29
|
+
a Personal Token or CLI token instead.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Install the Doppler CLI
|
|
33
|
+
brew install dopplerhq/cli/doppler
|
|
34
|
+
|
|
35
|
+
# 2. Log in (opens a browser). Token lands in ~/.doppler/.doppler.yaml
|
|
36
|
+
# → OS keychain, managed by the Doppler CLI.
|
|
37
|
+
doppler login
|
|
38
|
+
|
|
39
|
+
# 3. Hand the token off to holocron's keyring (one-shot). After this,
|
|
40
|
+
# every plugin call reads the token from the keyring — no env vars
|
|
41
|
+
# to remember, no dotfiles to sync.
|
|
42
|
+
holocron auth set doppler $(doppler configure get token --plain)
|
|
43
|
+
|
|
44
|
+
# 4. Verify:
|
|
45
|
+
holocron auth check doppler
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**CI**: the keyring is not available in headless containers. Expose
|
|
49
|
+
the token as a GitHub Actions secret and set `HOLOCRON_DOPPLER_TOKEN`
|
|
50
|
+
(or `DOPPLER_TOKEN`) in the workflow env. Steps 1–3 of the auth
|
|
51
|
+
precedence still work; step 4 quietly falls through.
|
|
52
|
+
|
|
53
|
+
## Config
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
{
|
|
57
|
+
"providers": {
|
|
58
|
+
"vault": ["doppler", { "project": "my-app", "config": "dev" }],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `project` (required) — Doppler project name. `read` / `list` /
|
|
64
|
+
bootstrap operations default to this project.
|
|
65
|
+
- `config` (required) — Doppler config name (usually `dev`, `stg`, or
|
|
66
|
+
`prd`). `list()` reads secrets from this config.
|
|
67
|
+
|
|
68
|
+
Individual `read` / `write` calls take a fully-qualified reference:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
doppler://<project>/<config>/<name>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The default project + config in options apply to `list()`,
|
|
75
|
+
`environments()`, and `readEnvironment()` where a three-part
|
|
76
|
+
reference doesn't make sense.
|
|
77
|
+
|
|
78
|
+
## What's implemented
|
|
79
|
+
|
|
80
|
+
| Method | Behavior |
|
|
81
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `read` | `GET /v3/configs/config/secret?project&config&name`. Returns `value.computed` when present, else `value.raw`. |
|
|
83
|
+
| `write` | `POST /v3/configs/config/secrets` with `{ project, config, secrets: {name: value} }`. Doppler's native upsert — no probe. |
|
|
84
|
+
| `list` | `GET /v3/configs/config/secrets` on the default project + config. |
|
|
85
|
+
| `environments` | `GET /v3/environments?project`. Returns environment slugs (`dev`/`stg`/`prd`). |
|
|
86
|
+
| `readEnvironment` | `GET /v3/configs/config/secrets/download?format=json` — bulk KEY=VALUE dump for `holocron secrets sync`. |
|
|
87
|
+
| `ensureProject` | `POST /v3/projects`, treats 409/422 "already exists" as idempotent no-op. |
|
|
88
|
+
| `ensureEnvironment` | `POST /v3/environments`, same idempotency semantics. |
|
|
89
|
+
|
|
90
|
+
Plugin-level exports (not capability methods, per the auth-bootstrap
|
|
91
|
+
convention):
|
|
92
|
+
|
|
93
|
+
| Export | Purpose |
|
|
94
|
+
| ------------- | ------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `verifyToken` | `GET /v3/me` — returns `{ok: true, subject: "personal @ acme"}` or `{ok: false, message}`. |
|
|
96
|
+
| `AUTH_HINT` | One-line hint printed by `holocron auth set` when no token is supplied or the token is bad. |
|
|
97
|
+
|
|
98
|
+
## Status
|
|
99
|
+
|
|
100
|
+
**`v2.0.0-alpha.1`** — published on npm under the `alpha` dist-tag.
|
|
101
|
+
APIs may still shift before stable v2.0.0.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { EnsureResult, Vault } from "@theholocron/cli";
|
|
2
|
+
|
|
3
|
+
//#region src/auth.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Token resolution for the Doppler 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_DOPPLER_TOKEN env var (preferred — explicit intent)
|
|
11
|
+
* 3. DOPPLER_TOKEN env var (Doppler-native, works in CI)
|
|
12
|
+
* 4. keyring (com.theholocron.cli / "doppler")
|
|
13
|
+
* 5. AuthError with a hint pointing at `doppler configure`
|
|
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 api.doppler.com/v3.
|
|
31
|
+
*
|
|
32
|
+
* Same pattern as the GitHub / Vercel / Neon REST clients — bearer
|
|
33
|
+
* auth, JSON-only bodies, transport-failure wrapping with `status: 0`
|
|
34
|
+
* so the orchestrator's soft-skip path sees a clear "Doppler GET /path
|
|
35
|
+
* failed" message instead of a generic `TypeError: fetch failed`.
|
|
36
|
+
*/
|
|
37
|
+
interface RestClientOptions {
|
|
38
|
+
token: string;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
baseUrl?: string;
|
|
41
|
+
}
|
|
42
|
+
interface RequestOptions {
|
|
43
|
+
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
44
|
+
body?: unknown;
|
|
45
|
+
query?: Record<string, string>;
|
|
46
|
+
/** Treat this response as void even if 200 is returned. */
|
|
47
|
+
expectNoContent?: boolean;
|
|
48
|
+
}
|
|
49
|
+
declare class DopplerRestClient {
|
|
50
|
+
private readonly token;
|
|
51
|
+
private readonly fetchImpl;
|
|
52
|
+
readonly baseUrl: string;
|
|
53
|
+
constructor(opts: RestClientOptions);
|
|
54
|
+
request<T>(path: string, opts?: RequestOptions): Promise<T>;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/capabilities/vault.d.ts
|
|
58
|
+
interface DopplerVaultOptions {
|
|
59
|
+
/** Default project — read/list/etc. operate here unless overridden. */
|
|
60
|
+
project: string;
|
|
61
|
+
/** Default config name — dev / stg / prd, etc. */
|
|
62
|
+
config: string;
|
|
63
|
+
}
|
|
64
|
+
declare class DopplerVault implements Vault {
|
|
65
|
+
private readonly rest;
|
|
66
|
+
readonly key: "vault";
|
|
67
|
+
readonly providerName = "doppler";
|
|
68
|
+
private readonly project;
|
|
69
|
+
private readonly config;
|
|
70
|
+
constructor(rest: DopplerRestClient, opts: DopplerVaultOptions);
|
|
71
|
+
read(reference: string): Promise<string>;
|
|
72
|
+
write(reference: string, value: string): Promise<void>;
|
|
73
|
+
list(): Promise<string[]>;
|
|
74
|
+
environments(): Promise<string[]>;
|
|
75
|
+
readEnvironment(environmentId: string): Promise<Record<string, string>>;
|
|
76
|
+
ensureProject(name: string): Promise<EnsureResult>;
|
|
77
|
+
ensureEnvironment(project: string, name: string): Promise<EnsureResult>;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/verify-token.d.ts
|
|
81
|
+
/**
|
|
82
|
+
* `verifyToken` — plugin-level export used by `holocron auth set` +
|
|
83
|
+
* `holocron auth check`. Hits `GET /v3/me` and translates the response
|
|
84
|
+
* into the normalized `VerifyTokenResult` shape.
|
|
85
|
+
*
|
|
86
|
+
* Kept as a standalone function (not a capability method) so the auth
|
|
87
|
+
* command can call it without initializing the full plugin — plugin
|
|
88
|
+
* construction requires an already-resolved token, which is exactly
|
|
89
|
+
* what we don't have yet at bootstrap time.
|
|
90
|
+
*/
|
|
91
|
+
interface VerifyTokenSuccess {
|
|
92
|
+
ok: true;
|
|
93
|
+
subject: string;
|
|
94
|
+
}
|
|
95
|
+
interface VerifyTokenFailure {
|
|
96
|
+
ok: false;
|
|
97
|
+
message: string;
|
|
98
|
+
}
|
|
99
|
+
type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
|
|
100
|
+
interface VerifyTokenOptions {
|
|
101
|
+
baseUrl?: string;
|
|
102
|
+
fetch?: typeof fetch;
|
|
103
|
+
}
|
|
104
|
+
declare function verifyToken(token: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult>;
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/index.d.ts
|
|
107
|
+
interface DopplerPluginOptions extends ResolveTokenInput, DopplerVaultOptions {
|
|
108
|
+
/** Override base URL for tests. */
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
/** Override `fetch` for tests. */
|
|
111
|
+
fetch?: typeof fetch;
|
|
112
|
+
}
|
|
113
|
+
interface PluginContext {
|
|
114
|
+
options: DopplerPluginOptions;
|
|
115
|
+
rest: DopplerRestClient;
|
|
116
|
+
}
|
|
117
|
+
declare function createContext(options: DopplerPluginOptions): PluginContext;
|
|
118
|
+
declare function vault(ctx: PluginContext): Vault;
|
|
119
|
+
declare function createPlugin(options: DopplerPluginOptions): {
|
|
120
|
+
name: string;
|
|
121
|
+
capabilities: {
|
|
122
|
+
vault: () => Vault;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* One-line hint shown by `holocron auth set doppler` when no token is
|
|
127
|
+
* supplied or when the supplied token is rejected.
|
|
128
|
+
*/
|
|
129
|
+
declare const AUTH_HINT: string;
|
|
130
|
+
//#endregion
|
|
131
|
+
export { AUTH_HINT, AuthError, DopplerPluginOptions, DopplerRestClient, DopplerVault, PluginContext, ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createPlugin, resolveToken, vault, verifyToken };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { ProviderApiError, getToken } from "@theholocron/cli";
|
|
2
|
+
//#region src/auth.ts
|
|
3
|
+
/**
|
|
4
|
+
* Token resolution for the Doppler 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_DOPPLER_TOKEN env var (preferred — explicit intent)
|
|
10
|
+
* 3. DOPPLER_TOKEN env var (Doppler-native, works in CI)
|
|
11
|
+
* 4. keyring (com.theholocron.cli / "doppler")
|
|
12
|
+
* 5. AuthError with a hint pointing at `doppler configure`
|
|
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_DOPPLER_TOKEN || env.DOPPLER_TOKEN || keyring("doppler");
|
|
21
|
+
if (!token) throw new AuthError("no Doppler token found. Pass --token <TOKEN>, set HOLOCRON_DOPPLER_TOKEN / DOPPLER_TOKEN, or run: holocron auth set doppler $(doppler configure get token --plain)");
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/capabilities/vault.ts
|
|
26
|
+
/**
|
|
27
|
+
* `vault` capability for Doppler.
|
|
28
|
+
*
|
|
29
|
+
* Reference format: `doppler://<project>/<config>/<name>` — three
|
|
30
|
+
* parts, mirrors 1P's `op://Vault/Item/field`. The plugin options can
|
|
31
|
+
* carry a default project + config so `list()` / `environments()` /
|
|
32
|
+
* `readEnvironment()` don't need a three-part ref.
|
|
33
|
+
*
|
|
34
|
+
* Doppler's data model:
|
|
35
|
+
* - Projects (top-level)
|
|
36
|
+
* - Environments — dev / stg / prd (each has a root config with the
|
|
37
|
+
* same name)
|
|
38
|
+
* - Configs — the root configs plus optional branch configs (e.g.,
|
|
39
|
+
* `dev_local`, `dev_ci`). `holocron secrets sync` reads from
|
|
40
|
+
* configs, not environments.
|
|
41
|
+
*
|
|
42
|
+
* Write semantics: `POST /v3/configs/config/secrets` accepts a
|
|
43
|
+
* `{name: value}` map and upserts. No probe-then-act dance needed.
|
|
44
|
+
*
|
|
45
|
+
* Bootstrap semantics: `ensureProject` / `ensureEnvironment` treat
|
|
46
|
+
* "already exists" (409) as success. Doppler returns 409 with a
|
|
47
|
+
* `messages` array; the plugin's REST client wraps it as
|
|
48
|
+
* ProviderApiError with `status: 409` which we catch and swallow.
|
|
49
|
+
*/
|
|
50
|
+
var DopplerVault = class {
|
|
51
|
+
rest;
|
|
52
|
+
key = "vault";
|
|
53
|
+
providerName = "doppler";
|
|
54
|
+
project;
|
|
55
|
+
config;
|
|
56
|
+
constructor(rest, opts) {
|
|
57
|
+
this.rest = rest;
|
|
58
|
+
if (!opts.project) throw new Error("DopplerVault requires `project` in options");
|
|
59
|
+
if (!opts.config) throw new Error("DopplerVault requires `config` in options");
|
|
60
|
+
this.project = opts.project;
|
|
61
|
+
this.config = opts.config;
|
|
62
|
+
}
|
|
63
|
+
async read(reference) {
|
|
64
|
+
const parsed = parseReference(reference);
|
|
65
|
+
const res = await this.rest.request("/configs/config/secret", { query: {
|
|
66
|
+
project: parsed.project,
|
|
67
|
+
config: parsed.config,
|
|
68
|
+
name: parsed.name
|
|
69
|
+
} });
|
|
70
|
+
return res.value?.computed ?? res.value?.raw ?? "";
|
|
71
|
+
}
|
|
72
|
+
async write(reference, value) {
|
|
73
|
+
const parsed = parseReference(reference);
|
|
74
|
+
await this.rest.request("/configs/config/secrets", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: {
|
|
77
|
+
project: parsed.project,
|
|
78
|
+
config: parsed.config,
|
|
79
|
+
secrets: { [parsed.name]: value }
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async list() {
|
|
84
|
+
const res = await this.rest.request("/configs/config/secrets", { query: {
|
|
85
|
+
project: this.project,
|
|
86
|
+
config: this.config
|
|
87
|
+
} });
|
|
88
|
+
return Object.keys(res.secrets ?? {});
|
|
89
|
+
}
|
|
90
|
+
async environments() {
|
|
91
|
+
return ((await this.rest.request("/environments", { query: { project: this.project } })).environments ?? []).map((e) => e.slug ?? e.name ?? "").filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
async readEnvironment(environmentId) {
|
|
94
|
+
const res = await this.rest.request("/configs/config/secrets/download", { query: {
|
|
95
|
+
project: this.project,
|
|
96
|
+
config: environmentId,
|
|
97
|
+
format: "json"
|
|
98
|
+
} });
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [k, v] of Object.entries(res)) if (typeof v === "string") out[k] = v;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
async ensureProject(name) {
|
|
104
|
+
try {
|
|
105
|
+
await this.rest.request("/projects", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
body: {
|
|
108
|
+
name,
|
|
109
|
+
description: `Managed by holocron`
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return { alreadyExists: false };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (isConflict(err)) return { alreadyExists: true };
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async ensureEnvironment(project, name) {
|
|
119
|
+
try {
|
|
120
|
+
await this.rest.request("/environments", {
|
|
121
|
+
method: "POST",
|
|
122
|
+
body: {
|
|
123
|
+
project,
|
|
124
|
+
name,
|
|
125
|
+
slug: name
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return { alreadyExists: false };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (isConflict(err)) return { alreadyExists: true };
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Parse `doppler://project/config/name` into its parts. Throws when
|
|
137
|
+
* the shape doesn't match — surfacing bad inputs at the boundary keeps
|
|
138
|
+
* downstream REST calls from emitting cryptic errors.
|
|
139
|
+
*/
|
|
140
|
+
function parseReference(reference) {
|
|
141
|
+
if (!reference.startsWith("doppler://")) throw new ProviderApiError(`Doppler references must start with "doppler://": got "${reference}"`, 400, void 0);
|
|
142
|
+
const parts = reference.slice(10).split("/");
|
|
143
|
+
if (parts.length < 3) throw new ProviderApiError(`Doppler reference "${reference}" missing parts; expected doppler://project/config/name`, 400, void 0);
|
|
144
|
+
const [project, config, ...nameParts] = parts;
|
|
145
|
+
return {
|
|
146
|
+
project,
|
|
147
|
+
config,
|
|
148
|
+
name: nameParts.join("/")
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Doppler returns duplicate-create errors inconsistently across
|
|
153
|
+
* endpoints:
|
|
154
|
+
*
|
|
155
|
+
* - POST /projects → 409 (or 422 with "already exists" body)
|
|
156
|
+
* - POST /environments → 400 with body
|
|
157
|
+
* `{"messages":["Environment with identifier dev already exists"],"success":false}`
|
|
158
|
+
*
|
|
159
|
+
* The REST client wraps all of these as ProviderApiError. We accept 409
|
|
160
|
+
* outright + treat 400/422 as "already exists" when the response body
|
|
161
|
+
* says so — matches Doppler's actual behavior.
|
|
162
|
+
*/
|
|
163
|
+
function isConflict(err) {
|
|
164
|
+
if (!(err instanceof ProviderApiError)) return false;
|
|
165
|
+
if (err.status === 409) return true;
|
|
166
|
+
if ((err.status === 400 || err.status === 422) && hasAlreadyExistsBody(err.details)) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
function hasAlreadyExistsBody(details) {
|
|
170
|
+
if (typeof details !== "string") return false;
|
|
171
|
+
return /already exists/i.test(details);
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/rest.ts
|
|
175
|
+
/**
|
|
176
|
+
* Thin REST wrapper around api.doppler.com/v3.
|
|
177
|
+
*
|
|
178
|
+
* Same pattern as the GitHub / Vercel / Neon REST clients — bearer
|
|
179
|
+
* auth, JSON-only bodies, transport-failure wrapping with `status: 0`
|
|
180
|
+
* so the orchestrator's soft-skip path sees a clear "Doppler GET /path
|
|
181
|
+
* failed" message instead of a generic `TypeError: fetch failed`.
|
|
182
|
+
*/
|
|
183
|
+
var DopplerRestClient = class {
|
|
184
|
+
token;
|
|
185
|
+
fetchImpl;
|
|
186
|
+
baseUrl;
|
|
187
|
+
constructor(opts) {
|
|
188
|
+
this.token = opts.token;
|
|
189
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
190
|
+
let url = opts.baseUrl ?? "https://api.doppler.com/v3";
|
|
191
|
+
while (url.endsWith("/")) url = url.slice(0, -1);
|
|
192
|
+
this.baseUrl = url;
|
|
193
|
+
}
|
|
194
|
+
async request(path, opts = {}) {
|
|
195
|
+
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : "/" + path}`);
|
|
196
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
|
|
197
|
+
const fullUrl = url.toString();
|
|
198
|
+
const headers = {
|
|
199
|
+
authorization: `Bearer ${this.token}`,
|
|
200
|
+
accept: "application/json"
|
|
201
|
+
};
|
|
202
|
+
const init = {
|
|
203
|
+
method: opts.method ?? "GET",
|
|
204
|
+
headers
|
|
205
|
+
};
|
|
206
|
+
if (opts.body !== void 0) {
|
|
207
|
+
headers["content-type"] = "application/json";
|
|
208
|
+
init.body = JSON.stringify(opts.body);
|
|
209
|
+
}
|
|
210
|
+
let res;
|
|
211
|
+
try {
|
|
212
|
+
res = await this.fetchImpl(fullUrl, init);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
215
|
+
throw new ProviderApiError(`Doppler ${init.method} ${path} failed: ${detail}`, 0, void 0);
|
|
216
|
+
}
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
const body = await res.text().catch(() => "");
|
|
219
|
+
throw new ProviderApiError(`Doppler ${init.method} ${path} → ${res.status}`, res.status, body);
|
|
220
|
+
}
|
|
221
|
+
if (opts.expectNoContent || res.status === 204) return void 0;
|
|
222
|
+
const text = await res.text();
|
|
223
|
+
if (!text) return void 0;
|
|
224
|
+
return JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/verify-token.ts
|
|
229
|
+
/**
|
|
230
|
+
* `verifyToken` — plugin-level export used by `holocron auth set` +
|
|
231
|
+
* `holocron auth check`. Hits `GET /v3/me` and translates the response
|
|
232
|
+
* into the normalized `VerifyTokenResult` shape.
|
|
233
|
+
*
|
|
234
|
+
* Kept as a standalone function (not a capability method) so the auth
|
|
235
|
+
* command can call it without initializing the full plugin — plugin
|
|
236
|
+
* construction requires an already-resolved token, which is exactly
|
|
237
|
+
* what we don't have yet at bootstrap time.
|
|
238
|
+
*/
|
|
239
|
+
async function verifyToken(token, opts = {}) {
|
|
240
|
+
const restOpts = { token };
|
|
241
|
+
if (opts.baseUrl !== void 0) restOpts.baseUrl = opts.baseUrl;
|
|
242
|
+
if (opts.fetch !== void 0) restOpts.fetch = opts.fetch;
|
|
243
|
+
const rest = new DopplerRestClient(restOpts);
|
|
244
|
+
try {
|
|
245
|
+
const me = await rest.request("/me");
|
|
246
|
+
const workplace = me.workplace?.name ?? me.slug ?? "unknown";
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
subject: `${me.type ?? "token"} @ ${workplace}`
|
|
250
|
+
};
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
message: err instanceof Error ? err.message : String(err)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/index.ts
|
|
260
|
+
function createContext(options) {
|
|
261
|
+
const restOpts = { token: resolveToken(options) };
|
|
262
|
+
if (options.baseUrl !== void 0) restOpts.baseUrl = options.baseUrl;
|
|
263
|
+
if (options.fetch !== void 0) restOpts.fetch = options.fetch;
|
|
264
|
+
return {
|
|
265
|
+
options,
|
|
266
|
+
rest: new DopplerRestClient(restOpts)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function vault(ctx) {
|
|
270
|
+
return new DopplerVault(ctx.rest, {
|
|
271
|
+
project: ctx.options.project,
|
|
272
|
+
config: ctx.options.config
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function createPlugin(options) {
|
|
276
|
+
const ctx = createContext(options);
|
|
277
|
+
return {
|
|
278
|
+
name: "@theholocron/holocron-plugin-doppler",
|
|
279
|
+
capabilities: { vault: () => vault(ctx) }
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* One-line hint shown by `holocron auth set doppler` when no token is
|
|
284
|
+
* supplied or when the supplied token is rejected.
|
|
285
|
+
*/
|
|
286
|
+
const AUTH_HINT = "install the Doppler CLI (`brew install dopplerhq/cli/doppler`), run `doppler login`, then `holocron auth set doppler $(doppler configure get token --plain)`";
|
|
287
|
+
//#endregion
|
|
288
|
+
export { AUTH_HINT, AuthError, DopplerRestClient, DopplerVault, createContext, createPlugin, resolveToken, vault, verifyToken };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theholocron/holocron-plugin-doppler",
|
|
3
|
+
"version": "2.0.0-alpha.5",
|
|
4
|
+
"description": "Holocron plugin for Doppler. Implements the vault capability against Doppler's REST API — projects, configs, secrets, plus verifyToken + AUTH_HINT for `holocron auth`.",
|
|
5
|
+
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-doppler#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-doppler"
|
|
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
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"vitest": "^3.2.6",
|
|
34
|
+
"tsdown": "^0.22.3",
|
|
35
|
+
"tsx": "^4.22.4",
|
|
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
|
+
}
|