@penvhq/varlock-plugin 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +89 -0
  2. package/dist/plugin.cjs +330 -0
  3. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @penvhq/varlock-plugin
2
+
3
+ A [varlock](https://varlock.dev) plugin that loads secrets from [penv.cloud](https://penv.cloud). It reads the same `@penv=org/project` header and `penv(...)` addresses as the [penv CLI](https://github.com/penvhq/penvhq), so one `.env.schema` runs under both tools.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -D @penvhq/varlock-plugin
9
+ ```
10
+
11
+ Or load it by name and version from the schema; varlock fetches it (see varlock's [plugins guide](https://varlock.dev/guides/plugins/)):
12
+
13
+ ```env-spec
14
+ # @plugin(@penvhq/varlock-plugin@0.1.0)
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ ```env-spec
20
+ # @plugin(@penvhq/varlock-plugin)
21
+ # @penv=acme/api
22
+ # @initPenv(environment=$APP_ENV, token=$PENV_TOKEN)
23
+ # @currentEnv=$APP_ENV
24
+ # ---
25
+
26
+ # @type=enum(development, staging, production)
27
+ APP_ENV=development
28
+
29
+ # @type=penvToken
30
+ PENV_TOKEN=
31
+ ```
32
+
33
+ `PENV_TOKEN` is a penv.cloud machine token (`pck_…`) scoped to the environments the app reads. `penvToken` is sensitive and internal: varlock uses it and does not pass it to your app.
34
+
35
+ | `@initPenv()` option | Default |
36
+ |---|---|
37
+ | `token` | none; required before any value is read |
38
+ | `environment` | `development`. Pointing it at the `@currentEnv` item keeps them in step. Taken whole, so `feature/foo` is one environment |
39
+ | `url` | `https://penv.cloud`. Any other root is used only when `PENV_URL` names the same root, so a changed schema cannot send the token elsewhere |
40
+ | `org`, `project` | from `@penv=` |
41
+ | `cacheTtl` | no cache. `"5m"`, `"1h"`, `"1d"` or `"forever"` keeps each environment's values in varlock's local cache for that long |
42
+
43
+ ## Usage
44
+
45
+ ```env-spec
46
+ DATABASE_URL=penv() # the item key, default environment
47
+ STRIPE_SECRET_KEY=penv(STRIPE_KEY) # another key
48
+ PROD_DATABASE_URL=penv(production/DATABASE_URL) # another environment
49
+ BILLING_TOKEN=penv(billing/production/API_TOKEN) # another project
50
+ SENTRY_DSN=penv(acme-shared/observability/production/SENTRY_DSN) # another org
51
+ ```
52
+
53
+ An environment whose name holds a `/` cannot be written in a `penv(...)` address; name it in `@initPenv(environment=...)` or `penvBulk(...)`.
54
+
55
+ Bulk-load an environment:
56
+
57
+ ```env-spec
58
+ # @setValuesBulk(penvBulk())
59
+ # @setValuesBulk(penvBulk(production))
60
+ # @setValuesBulk(penvBulk("feature/foo"))
61
+ ```
62
+
63
+ ## The same schema under the penv CLI
64
+
65
+ ```bash
66
+ varlock run -- npm run dev # through this plugin
67
+ penv run -- npm run dev # native; penv ignores @plugin and @initPenv
68
+ ```
69
+
70
+ ## Errors
71
+
72
+ | Error | Fix |
73
+ |---|---|
74
+ | `penv.cloud rejected the token` | create a machine token and set `PENV_TOKEN` |
75
+ | `the token may not read org/project/env` | give the machine identity that project and environment |
76
+ | `org/project/env does not exist on penv.cloud` | check the names; `penv project ls` |
77
+ | `KEY is not in org/project/env` | `penv set KEY --env env` |
78
+ | `KEY has no stored value` | `penv set KEY --env env` |
79
+ | `penv url … is not penv.cloud, so the token is not sent there` | set `PENV_URL` to that root, or drop `url=` |
80
+
81
+ Requests are https only (`http://localhost` for tests), follow no redirects, time out after 15 seconds, and each environment is read once per load. No error prints a token or a value.
82
+
83
+ ## Develop
84
+
85
+ ```bash
86
+ npm install
87
+ npm test # builds, then runs the published varlock CLI against a fake penv.cloud
88
+ npm run typecheck
89
+ ```
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+
3
+ // src/plugin.ts
4
+ var import_plugin_lib = require("varlock/plugin-lib");
5
+ var import_node_crypto = require("node:crypto");
6
+ var { SchemaError, ResolutionError } = import_plugin_lib.plugin.ERRORS;
7
+ var PENV_ICON = "mdi:shield-key-outline";
8
+ var DEFAULT_URL = "https://penv.cloud";
9
+ var TIMEOUT_MS = 15e3;
10
+ import_plugin_lib.plugin.name = "penv";
11
+ var { debug } = import_plugin_lib.plugin;
12
+ var VERSION = import_plugin_lib.plugin.version;
13
+ debug("init - version =", VERSION);
14
+ var pluginCache;
15
+ try {
16
+ pluginCache = import_plugin_lib.plugin.cache;
17
+ } catch {
18
+ }
19
+ import_plugin_lib.plugin.icon = PENV_ICON;
20
+ import_plugin_lib.plugin.standardVars = {
21
+ initDecorator: "@initPenv",
22
+ params: {
23
+ token: { key: "PENV_TOKEN", dataType: "penvToken" }
24
+ }
25
+ };
26
+ function valuesMap() {
27
+ return /* @__PURE__ */ Object.create(null);
28
+ }
29
+ function toValues(source) {
30
+ const out = valuesMap();
31
+ if (source && typeof source === "object") {
32
+ for (const [name, value] of Object.entries(source)) {
33
+ out[name] = typeof value === "string" ? value : void 0;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ var header;
39
+ function checkedUrl(raw) {
40
+ let url;
41
+ try {
42
+ url = new URL(raw);
43
+ } catch {
44
+ throw new SchemaError(`penv url "${raw}" is not a URL`);
45
+ }
46
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
47
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
48
+ throw new SchemaError(`penv url must be https (http only for localhost): ${url.origin}`);
49
+ }
50
+ if (url.username || url.password) {
51
+ throw new SchemaError("penv url must not carry a user name or password");
52
+ }
53
+ return url.origin + url.pathname.replace(/\/+$/, "");
54
+ }
55
+ function label(at) {
56
+ return `${at.org}/${at.project}/${at.environment}`;
57
+ }
58
+ var PenvInstance = class {
59
+ environment = "development";
60
+ url = DEFAULT_URL;
61
+ cacheTtl;
62
+ token;
63
+ org;
64
+ project;
65
+ reads = /* @__PURE__ */ new Map();
66
+ set(opts) {
67
+ if (typeof opts.environment === "string" && opts.environment) this.environment = opts.environment;
68
+ if (typeof opts.url === "string" && opts.url) {
69
+ const url = checkedUrl(opts.url);
70
+ const chosen = process.env.PENV_URL ? checkedUrl(process.env.PENV_URL) : void 0;
71
+ if (url !== DEFAULT_URL && url !== chosen) {
72
+ throw new SchemaError(`penv url ${url} is not penv.cloud, so the token is not sent there`, {
73
+ tip: `Set PENV_URL=${url} as well to send it on purpose, or remove url= to use penv.cloud`
74
+ });
75
+ }
76
+ this.url = url;
77
+ }
78
+ if (typeof opts.token === "string" && opts.token.trim()) this.token = opts.token.trim();
79
+ if (typeof opts.org === "string" && opts.org) this.org = opts.org;
80
+ if (typeof opts.project === "string" && opts.project) this.project = opts.project;
81
+ }
82
+ /** The org and project `@initPenv()` or `@penv=` names; `written` is only for the error. */
83
+ home(written) {
84
+ const org = this.org ?? header?.org;
85
+ const project = this.project ?? header?.project;
86
+ if (!org || !project) {
87
+ throw new SchemaError(`${written} needs a project`, {
88
+ tip: "Add # @penv=org/project to the header, or pass org and project to @initPenv()"
89
+ });
90
+ }
91
+ return { org, project };
92
+ }
93
+ /**
94
+ * `KEY`, `env/KEY`, `project/env/KEY` or `org/project/env/KEY`, as the penv CLI
95
+ * reads them. An environment whose name holds a `/` cannot be written here;
96
+ * name it in `@initPenv(environment=...)` or `penvBulk(...)`, which take it whole.
97
+ */
98
+ address(written) {
99
+ const parts = written.split("/").map((p) => p.trim());
100
+ if (parts.some((p) => !p) || parts.length > 4) {
101
+ throw new SchemaError(`penv(${written}) is not an address`, {
102
+ tip: "Write penv(), penv(KEY), penv(env/KEY), penv(project/env/KEY) or penv(org/project/env/KEY)"
103
+ });
104
+ }
105
+ const key = parts[parts.length - 1];
106
+ if (parts.length === 4) return { org: parts[0], project: parts[1], environment: parts[2], key };
107
+ const { org, project } = this.home(`penv(${written})`);
108
+ if (parts.length === 3) return { org, project: parts[0], environment: parts[1], key };
109
+ if (parts.length === 2) return { org, project, environment: parts[0], key };
110
+ return { org, project, environment: this.environment, key };
111
+ }
112
+ /** One environment of the home project, its name taken whole. */
113
+ environmentOf(name) {
114
+ return { ...this.home(`penvBulk(${name})`), environment: name };
115
+ }
116
+ /** Short hash naming the token, so a cache is never shared between credentials. */
117
+ scope(at) {
118
+ const who = (0, import_node_crypto.createHash)("sha256").update(this.token ?? "").digest("hex").slice(0, 12);
119
+ return `${who}:${this.url}:${label(at)}`;
120
+ }
121
+ /** Every value of one environment: once per load, and through the cache when `cacheTtl` is set. */
122
+ async read(at) {
123
+ if (this.cacheTtl !== void 0 && pluginCache) {
124
+ const cached = await pluginCache.getOrSet(`penv:${this.scope(at)}`, this.cacheTtl, async () => {
125
+ const values = await this.once(at);
126
+ return Object.fromEntries(Object.entries(values));
127
+ });
128
+ return toValues(cached);
129
+ }
130
+ return this.once(at);
131
+ }
132
+ once(at) {
133
+ const key = label(at);
134
+ let pending = this.reads.get(key);
135
+ if (!pending) {
136
+ pending = this.fetchEnvironment(at);
137
+ this.reads.set(key, pending);
138
+ pending.catch(() => this.reads.delete(key));
139
+ }
140
+ return pending;
141
+ }
142
+ async fetchEnvironment(at) {
143
+ if (!this.token) {
144
+ throw new SchemaError("penv token is required", {
145
+ tip: "Pass token=$PENV_TOKEN to @initPenv() and set PENV_TOKEN"
146
+ });
147
+ }
148
+ const where = label(at);
149
+ const path = [at.org, at.project, at.environment].map(encodeURIComponent).join("/");
150
+ let response;
151
+ try {
152
+ response = await fetch(`${this.url}/api/v1/envs/${path}`, {
153
+ headers: {
154
+ authorization: `Bearer ${this.token}`,
155
+ accept: "application/json",
156
+ "user-agent": `penvhq-varlock-plugin/${VERSION}`
157
+ },
158
+ // A redirect could carry the token to another host.
159
+ redirect: "error",
160
+ signal: AbortSignal.timeout(TIMEOUT_MS)
161
+ });
162
+ } catch (err) {
163
+ const why = err?.name === "TimeoutError" ? "timed out" : "network error or redirect";
164
+ throw new ResolutionError(`penv.cloud could not be read for ${where}: ${why}`, {
165
+ tip: `Check the network, and that ${this.url} is the right url`
166
+ });
167
+ }
168
+ if (response.status === 401) {
169
+ throw new ResolutionError(`penv.cloud rejected the token for ${where}`, {
170
+ tip: "Create a machine token in the penv.cloud console and set PENV_TOKEN"
171
+ });
172
+ }
173
+ if (response.status === 403) {
174
+ throw new ResolutionError(`the token may not read ${where}`, {
175
+ tip: "Give the machine identity this project and environment in the penv.cloud console"
176
+ });
177
+ }
178
+ if (response.status === 404) {
179
+ throw new ResolutionError(`${where} does not exist on penv.cloud`, {
180
+ tip: "Check the org, project and environment names; `penv project ls` lists them"
181
+ });
182
+ }
183
+ if (!response.ok) {
184
+ throw new ResolutionError(`penv.cloud answered ${response.status} for ${where}`);
185
+ }
186
+ let body;
187
+ try {
188
+ body = await response.json();
189
+ } catch {
190
+ throw new ResolutionError(`penv.cloud answered ${where} with something that is not JSON`);
191
+ }
192
+ const values = valuesMap();
193
+ for (const key of Array.isArray(body?.keys) ? body.keys : []) {
194
+ if (!key || key.path || typeof key.name !== "string") continue;
195
+ values[key.name] = typeof key.value === "string" ? key.value : void 0;
196
+ }
197
+ debug(`read ${Object.keys(values).length} keys from ${where}`);
198
+ return values;
199
+ }
200
+ async value(at) {
201
+ const values = await this.read(at);
202
+ const where = label(at);
203
+ if (!Object.hasOwn(values, at.key)) {
204
+ throw new ResolutionError(`${at.key} is not in ${where}`, {
205
+ tip: `Add it with: penv set ${at.key} --env ${at.environment}`
206
+ });
207
+ }
208
+ const value = values[at.key];
209
+ if (value === void 0) {
210
+ throw new ResolutionError(`${at.key} has no stored value in ${where}`, {
211
+ tip: `Set one with: penv set ${at.key} --env ${at.environment}`
212
+ });
213
+ }
214
+ return value;
215
+ }
216
+ /** Every stored value, as the JSON `@setValuesBulk` reads. */
217
+ async bulk(environment) {
218
+ const values = await this.read(this.environmentOf(environment));
219
+ return JSON.stringify(Object.fromEntries(Object.entries(values).filter(([, v]) => v !== void 0)));
220
+ }
221
+ };
222
+ var instance = new PenvInstance();
223
+ var initialised = false;
224
+ import_plugin_lib.plugin.registerRootDecorator({
225
+ name: "penv",
226
+ description: "The penv.cloud org and project this schema reads: org/project",
227
+ process(decVal) {
228
+ if (!decVal.isStatic || typeof decVal.staticValue !== "string") {
229
+ throw new SchemaError("@penv= takes a static org/project");
230
+ }
231
+ let value = decVal.staticValue.trim();
232
+ const colon = value.indexOf(":");
233
+ if (colon !== -1) {
234
+ const provider = value.slice(0, colon);
235
+ if (provider !== "penv") {
236
+ throw new SchemaError(`@penv=${value} names provider "${provider}"; this plugin reads penv.cloud only`);
237
+ }
238
+ value = value.slice(colon + 1);
239
+ }
240
+ const [org, project, ...rest] = value.split("/");
241
+ if (!org || !project || rest.length) {
242
+ throw new SchemaError(`@penv=${value} is not org/project`);
243
+ }
244
+ header = { org, project };
245
+ return {};
246
+ }
247
+ });
248
+ import_plugin_lib.plugin.registerRootDecorator({
249
+ name: "initPenv",
250
+ description: "Configure penv.cloud access for penv() and penvBulk()",
251
+ isFunction: true,
252
+ async process(argsVal) {
253
+ if (initialised) throw new SchemaError("@initPenv() is already set");
254
+ initialised = true;
255
+ const objArgs = argsVal.objArgs ?? {};
256
+ return {
257
+ environmentResolver: objArgs.environment,
258
+ tokenResolver: objArgs.token,
259
+ urlResolver: objArgs.url,
260
+ orgResolver: objArgs.org,
261
+ projectResolver: objArgs.project,
262
+ cacheTtlResolver: objArgs.cacheTtl
263
+ };
264
+ },
265
+ async execute({
266
+ environmentResolver,
267
+ tokenResolver,
268
+ urlResolver,
269
+ orgResolver,
270
+ projectResolver,
271
+ cacheTtlResolver
272
+ }) {
273
+ instance.set({
274
+ environment: await environmentResolver?.resolve(),
275
+ token: await tokenResolver?.resolve(),
276
+ url: await urlResolver?.resolve(),
277
+ org: await orgResolver?.resolve(),
278
+ project: await projectResolver?.resolve()
279
+ });
280
+ const cacheTtl = await (0, import_plugin_lib.resolveCacheTtl)(cacheTtlResolver);
281
+ if (cacheTtl !== void 0) instance.cacheTtl = cacheTtl;
282
+ }
283
+ });
284
+ import_plugin_lib.plugin.registerDataType({
285
+ name: "penvToken",
286
+ sensitive: true,
287
+ internal: true,
288
+ typeDescription: "penv.cloud machine token (pck_...)",
289
+ icon: PENV_ICON,
290
+ docs: [{ description: "penv.cloud API", url: "https://github.com/penvhq/penvhq/blob/main/docs/Cloud-API.md" }]
291
+ });
292
+ import_plugin_lib.plugin.registerResolverFunction({
293
+ name: "penv",
294
+ label: "Read a value from penv.cloud",
295
+ icon: PENV_ICON,
296
+ argsSchema: { type: "array", arrayMinLength: 0, arrayMaxLength: 1 },
297
+ process() {
298
+ let written;
299
+ let itemKey;
300
+ if (this.arrArgs?.length) {
301
+ written = this.arrArgs[0];
302
+ } else {
303
+ const parent = this.parent;
304
+ if (!parent || typeof parent.key !== "string") {
305
+ throw new SchemaError("penv() with no argument reads the key it is on, so it must be on a config item");
306
+ }
307
+ itemKey = parent.key;
308
+ }
309
+ return { written, itemKey };
310
+ },
311
+ async resolve({ written, itemKey }) {
312
+ const address = written ? await written.resolve() : itemKey;
313
+ if (typeof address !== "string") throw new SchemaError("penv() takes an address written as text");
314
+ return instance.value(instance.address(address));
315
+ }
316
+ });
317
+ import_plugin_lib.plugin.registerResolverFunction({
318
+ name: "penvBulk",
319
+ label: "Load every value of a penv.cloud environment",
320
+ icon: PENV_ICON,
321
+ argsSchema: { type: "array", arrayMaxLength: 1 },
322
+ process() {
323
+ return { environment: this.arrArgs?.[0] };
324
+ },
325
+ async resolve({ environment }) {
326
+ const name = environment ? await environment.resolve() : instance.environment;
327
+ if (typeof name !== "string" || !name) throw new SchemaError("penvBulk() takes an environment name");
328
+ return instance.bulk(name);
329
+ }
330
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@penvhq/varlock-plugin",
3
+ "version": "0.1.0",
4
+ "description": "varlock plugin that loads secrets from penv.cloud",
5
+ "type": "module",
6
+ "homepage": "https://github.com/penvhq/penvhq/tree/main/packages/varlock-plugin#readme",
7
+ "bugs": "https://github.com/penvhq/penvhq/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/penvhq/penvhq.git",
11
+ "directory": "packages/varlock-plugin"
12
+ },
13
+ "license": "MIT",
14
+ "exports": {
15
+ "./plugin": "./dist/plugin.cjs"
16
+ },
17
+ "files": ["dist"],
18
+ "scripts": {
19
+ "build": "esbuild src/plugin.ts --bundle --platform=node --format=cjs --target=node22 --external:varlock --outfile=dist/plugin.cjs",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "npm run build && node --test test/plugin.test.mjs"
22
+ },
23
+ "keywords": ["varlock", "varlock-plugin", "penv", "penv.cloud", "secrets", "env", ".env"],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "peerDependencies": {
28
+ "varlock": ">=1.20.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "esbuild": "^0.25.0",
33
+ "typescript": "^5.9.0",
34
+ "varlock": "1.20.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ }
40
+ }