@cl3tus/planum 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clement Osternaud
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,52 @@
1
+ # @cl3tus/planum
2
+
3
+ Sync `.env` files with your [Planum](https://planum.cl3tusdev.com) vault.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm i -g @cl3tus/planum
9
+ # or
10
+ pnpm add -g @cl3tus/planum
11
+ # or
12
+ yarn global add @cl3tus/planum
13
+ ```
14
+
15
+ Requires Node.js **20+**.
16
+
17
+ ## Quick start
18
+
19
+ ```sh
20
+ planum login # authenticate via the browser
21
+ planum init --project my-app
22
+ planum pull --env dev # download .env from the vault
23
+ planum push --env dev # upload .env to the vault
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ | Command | Description |
29
+ |---|---|
30
+ | `planum login` | Authenticate via the web browser. |
31
+ | `planum logout` | Forget the stored token. |
32
+ | `planum init` | Create a `.planumrc` in the current directory. |
33
+ | `planum pull` | Download a `.env` from a project + environment. |
34
+ | `planum push` | Upload a `.env` to a project + environment. |
35
+
36
+ Each command supports `--help` for full option listings.
37
+
38
+ ### Common flags
39
+
40
+ - `--project <slug>` — project slug (read from `.planumrc` if omitted).
41
+ - `--env <name>` — environment name (e.g. `dev`, `staging`, `prod`).
42
+ - `--file <path>` — input/output file path (default: `.env`).
43
+
44
+ ## Configuration
45
+
46
+ `planum init` writes a `.planumrc` file at the project root with the default project and environment so later commands can be run without flags.
47
+
48
+ Credentials are stored in your OS user config directory after `planum login`.
49
+
50
+ ## License
51
+
52
+ [MIT](./LICENSE) © Clement Osternaud
package/bin/planum.mjs ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,790 @@
1
+ // src/index.ts
2
+ import { defineCommand as defineCommand6, runMain } from "citty";
3
+
4
+ // src/commands/init.ts
5
+ import { existsSync } from "fs";
6
+ import * as p2 from "@clack/prompts";
7
+ import { defineCommand } from "citty";
8
+
9
+ // src/lib/api.ts
10
+ var ApiError = class extends Error {
11
+ constructor(status, statusText, data) {
12
+ super(`${status} ${statusText}`);
13
+ this.status = status;
14
+ this.statusText = statusText;
15
+ this.data = data;
16
+ this.name = "ApiError";
17
+ }
18
+ status;
19
+ statusText;
20
+ data;
21
+ };
22
+ var request = async (auth, path, options = {}) => {
23
+ const { body, searchParams, headers, ...rest } = options;
24
+ const url = new URL(path.replace(/^\//, ""), `${auth.apiUrl}/`);
25
+ if (searchParams) {
26
+ for (const [key, value] of Object.entries(searchParams)) {
27
+ if (value !== void 0) {
28
+ url.searchParams.set(key, String(value));
29
+ }
30
+ }
31
+ }
32
+ const res = await fetch(url, {
33
+ ...rest,
34
+ headers: {
35
+ ...body !== void 0 && { "Content-Type": "application/json" },
36
+ Authorization: `Bearer ${auth.token}`,
37
+ ...headers
38
+ },
39
+ body: body !== void 0 ? JSON.stringify(body) : void 0
40
+ });
41
+ const isJson = res.headers.get("content-type")?.includes("application/json");
42
+ const data = isJson ? await res.json() : await res.text();
43
+ if (!res.ok) {
44
+ throw new ApiError(res.status, res.statusText, data);
45
+ }
46
+ return data;
47
+ };
48
+ var createApi = (auth) => ({
49
+ get: (path, options) => request(auth, path, { ...options, method: "GET" }),
50
+ post: (path, body, options) => request(auth, path, { ...options, method: "POST", body })
51
+ });
52
+
53
+ // src/lib/auth-store.ts
54
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
55
+ import { homedir } from "os";
56
+ import { dirname, join } from "path";
57
+ var configDir = () => process.env.PLANUM_CONFIG_DIR ?? join(homedir(), ".config", "planum");
58
+ var authFile = () => join(configDir(), "auth.json");
59
+ var loadAuth = async () => {
60
+ try {
61
+ const raw = await readFile(authFile(), "utf8");
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed.apiUrl || !parsed.token) return null;
64
+ return parsed;
65
+ } catch {
66
+ return null;
67
+ }
68
+ };
69
+ var saveAuth = async (data) => {
70
+ const file = authFile();
71
+ await mkdir(dirname(file), { recursive: true });
72
+ await writeFile(file, JSON.stringify(data, null, 2), "utf8");
73
+ await chmod(file, 384);
74
+ };
75
+ var clearAuth = async () => {
76
+ await rm(authFile(), { force: true });
77
+ };
78
+ var requireAuth = async () => {
79
+ const auth = await loadAuth();
80
+ if (!auth) {
81
+ throw new Error("Not logged in. Run `planum login` first.");
82
+ }
83
+ return auth;
84
+ };
85
+
86
+ // src/lib/config.ts
87
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
88
+ import { homedir as homedir2 } from "os";
89
+ import { dirname as dirname2, join as join2, resolve } from "path";
90
+ var CONFIG_FILENAME = ".planumrc";
91
+ var findConfigFile = async (startDir) => {
92
+ let dir = resolve(startDir);
93
+ const stop = resolve(homedir2());
94
+ while (true) {
95
+ const candidate = join2(dir, CONFIG_FILENAME);
96
+ try {
97
+ await readFile2(candidate, "utf8");
98
+ return candidate;
99
+ } catch {
100
+ }
101
+ const parent = dirname2(dir);
102
+ if (parent === dir || dir === stop) return null;
103
+ dir = parent;
104
+ }
105
+ };
106
+ var loadConfig = async (cwd = process.cwd()) => {
107
+ const path = await findConfigFile(cwd);
108
+ if (!path) return null;
109
+ try {
110
+ const raw = await readFile2(path, "utf8");
111
+ return { path, config: JSON.parse(raw) };
112
+ } catch {
113
+ return null;
114
+ }
115
+ };
116
+ var saveConfig = async (path, config) => {
117
+ await writeFile2(path, `${JSON.stringify(config, null, 2)}
118
+ `, "utf8");
119
+ };
120
+ var defaultConfigPath = (cwd = process.cwd()) => join2(cwd, CONFIG_FILENAME);
121
+
122
+ // src/lib/prompts.ts
123
+ import * as p from "@clack/prompts";
124
+ var exitOnCancel = (value) => {
125
+ if (p.isCancel(value)) {
126
+ p.cancel("Cancelled.");
127
+ process.exit(130);
128
+ }
129
+ return value;
130
+ };
131
+ var fetchProjects = async (api) => {
132
+ const res = await api.get("/projects", {
133
+ searchParams: { limit: 100 }
134
+ });
135
+ return res.data;
136
+ };
137
+ var resolveProject = async (api, preferredSlug) => {
138
+ const projects = await fetchProjects(api);
139
+ if (projects.length === 0) {
140
+ throw new Error("No projects found in your account.");
141
+ }
142
+ if (preferredSlug) {
143
+ const match = projects.find((p7) => p7.slug === preferredSlug);
144
+ if (match) return match;
145
+ p.log.warn(`Project "${preferredSlug}" not found \u2014 pick one below.`);
146
+ }
147
+ const choice = exitOnCancel(
148
+ await p.select({
149
+ message: "Pick a project",
150
+ options: projects.map((proj) => ({
151
+ value: proj.slug,
152
+ label: proj.name,
153
+ hint: proj.slug
154
+ }))
155
+ })
156
+ );
157
+ const found = projects.find((proj) => proj.slug === choice);
158
+ if (!found) throw new Error("Project not found");
159
+ return found;
160
+ };
161
+ var fetchProjectEnvItems = async (api, projectId) => {
162
+ const res = await api.get(
163
+ "/vault-items",
164
+ { searchParams: { projectId, category: "env" } }
165
+ );
166
+ return res.data;
167
+ };
168
+ var NEW_ENV = "__new__";
169
+ var resolveEnvironment = async (api, projectId, preferred, options) => {
170
+ const items = await fetchProjectEnvItems(api, projectId);
171
+ const envs = [
172
+ ...new Set(
173
+ items.map((i) => i.environment).filter((e) => Boolean(e && e.length > 0))
174
+ )
175
+ ].sort();
176
+ if (preferred && (envs.includes(preferred) || options.allowCreate)) {
177
+ return preferred;
178
+ }
179
+ if (envs.length === 0 && !options.allowCreate) {
180
+ throw new Error(
181
+ "No environments found for this project. Run `planum push` first."
182
+ );
183
+ }
184
+ const selectOptions = envs.map(
185
+ (e) => ({ value: e, label: e })
186
+ );
187
+ if (options.allowCreate || selectOptions.length === 0) {
188
+ selectOptions.push({ value: NEW_ENV, label: "+ New environment\u2026" });
189
+ }
190
+ const choice = exitOnCancel(
191
+ await p.select({
192
+ message: "Pick an environment",
193
+ options: selectOptions
194
+ })
195
+ );
196
+ if (choice === NEW_ENV) {
197
+ const name = exitOnCancel(
198
+ await p.text({
199
+ message: "Environment name",
200
+ placeholder: "dev, staging, prod\u2026",
201
+ validate: (v) => !v || v.trim().length === 0 ? "Required" : void 0
202
+ })
203
+ );
204
+ return name.trim();
205
+ }
206
+ return choice;
207
+ };
208
+ var NEW_SCOPE = "__new__";
209
+ var NONE_SCOPE = "__none__";
210
+ var resolveScope = async (api, projectId, preferred, options) => {
211
+ const items = await fetchProjectEnvItems(api, projectId);
212
+ const scopes = [
213
+ ...new Set(
214
+ items.map((i) => i.scope).filter((s) => Boolean(s && s.length > 0))
215
+ )
216
+ ].sort();
217
+ if (preferred === null && options.allowNone) return null;
218
+ if (preferred && (scopes.includes(preferred) || options.allowCreate)) {
219
+ return preferred;
220
+ }
221
+ if (scopes.length === 0 && !options.allowCreate) {
222
+ return null;
223
+ }
224
+ const selectOptions = [];
225
+ if (options.allowNone) {
226
+ selectOptions.push({ value: NONE_SCOPE, label: "(no scope)" });
227
+ }
228
+ for (const s of scopes) selectOptions.push({ value: s, label: s });
229
+ if (options.allowCreate) {
230
+ selectOptions.push({ value: NEW_SCOPE, label: "+ New scope\u2026" });
231
+ }
232
+ if (selectOptions.length === 0) return null;
233
+ const choice = exitOnCancel(
234
+ await p.select({ message: "Pick a scope", options: selectOptions })
235
+ );
236
+ if (choice === NONE_SCOPE) return null;
237
+ if (choice === NEW_SCOPE) {
238
+ const name = exitOnCancel(
239
+ await p.text({
240
+ message: "Scope name",
241
+ placeholder: "web, api\u2026",
242
+ validate: (v) => !v || v.trim().length === 0 ? "Required" : void 0
243
+ })
244
+ );
245
+ return name.trim();
246
+ }
247
+ return choice;
248
+ };
249
+ var confirm2 = async (message) => {
250
+ const v = exitOnCancel(await p.confirm({ message }));
251
+ return Boolean(v);
252
+ };
253
+
254
+ // src/commands/init.ts
255
+ var exitOnCancel2 = (value) => {
256
+ if (p2.isCancel(value)) {
257
+ p2.cancel("Cancelled.");
258
+ process.exit(130);
259
+ }
260
+ return value;
261
+ };
262
+ var promptScopes = async () => {
263
+ const entries = [];
264
+ while (true) {
265
+ const scope = exitOnCancel2(
266
+ await p2.text({
267
+ message: `Scope name #${entries.length + 1}`,
268
+ placeholder: "web, api, mobile\u2026",
269
+ validate: (v) => !v || v.trim().length === 0 ? "Required" : void 0
270
+ })
271
+ );
272
+ const file = exitOnCancel2(
273
+ await p2.text({
274
+ message: `File path for "${scope.trim()}" (relative to where you run planum)`,
275
+ placeholder: ".env",
276
+ initialValue: ".env",
277
+ validate: (v) => !v || v.trim().length === 0 ? "Required" : void 0
278
+ })
279
+ );
280
+ entries.push({ scope: scope.trim(), file: file.trim() });
281
+ const more = await confirm2("Add another scope?");
282
+ if (!more) break;
283
+ }
284
+ return entries;
285
+ };
286
+ var initCommand = defineCommand({
287
+ meta: {
288
+ name: "init",
289
+ description: "Create a .planumrc in the current directory"
290
+ },
291
+ args: {
292
+ project: { type: "string", description: "Project slug" }
293
+ },
294
+ run: async ({ args }) => {
295
+ const auth = await requireAuth();
296
+ const api = createApi(auth);
297
+ const path = defaultConfigPath();
298
+ if (existsSync(path)) {
299
+ const existing = await loadConfig();
300
+ const ok = await confirm2(
301
+ `.planumrc already exists${existing?.config.project ? ` (project: ${existing.config.project})` : ""}. Overwrite?`
302
+ );
303
+ if (!ok) {
304
+ p2.log.info("Aborted.");
305
+ return;
306
+ }
307
+ }
308
+ const project = await resolveProject(api, args.project);
309
+ const config = { project: project.slug };
310
+ const multiApp = await confirm2(
311
+ "Does this project have multiple apps with separate .env files (e.g. web, api)?"
312
+ );
313
+ if (multiApp) {
314
+ config.scopes = await promptScopes();
315
+ }
316
+ await saveConfig(path, config);
317
+ if (config.scopes?.length) {
318
+ p2.log.success(
319
+ `Wrote .planumrc \u2014 project: ${project.slug}, ${config.scopes.length} scope(s).`
320
+ );
321
+ } else {
322
+ p2.log.success(`Wrote .planumrc \u2014 project: ${project.slug}`);
323
+ }
324
+ }
325
+ });
326
+
327
+ // src/commands/login.ts
328
+ import {
329
+ createServer
330
+ } from "http";
331
+ import { hostname } from "os";
332
+ import * as p3 from "@clack/prompts";
333
+ import { defineCommand as defineCommand2 } from "citty";
334
+ import open from "open";
335
+ var DEFAULT_API_URL = "http://localhost:9000";
336
+ var DEFAULT_WEB_URL = "http://localhost:5173";
337
+ var POLL_INTERVAL_MS = 2e3;
338
+ var TIMEOUT_MS = 5 * 60 * 1e3;
339
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
340
+ var startCallbackServer = () => new Promise((resolve4, reject) => {
341
+ const server = createServer(
342
+ (_req, res) => {
343
+ res.statusCode = 200;
344
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
345
+ res.end(
346
+ '<html><body style="font-family:system-ui;padding:2rem"><h2>Planum CLI authorized</h2><p>You can close this tab and return to your terminal.</p></body></html>'
347
+ );
348
+ }
349
+ );
350
+ server.once("error", reject);
351
+ server.listen(0, "127.0.0.1", () => {
352
+ const address = server.address();
353
+ if (!address || typeof address === "string") {
354
+ reject(new Error("Failed to bind local server"));
355
+ return;
356
+ }
357
+ resolve4({
358
+ port: address.port,
359
+ close: () => server.close()
360
+ });
361
+ });
362
+ });
363
+ var loginCommand = defineCommand2({
364
+ meta: {
365
+ name: "login",
366
+ description: "Authenticate via the web browser"
367
+ },
368
+ args: {
369
+ label: {
370
+ type: "string",
371
+ description: "Friendly name for this token (default: hostname)"
372
+ },
373
+ apiUrl: {
374
+ type: "string",
375
+ description: "Override the API URL"
376
+ },
377
+ webUrl: {
378
+ type: "string",
379
+ description: "Override the web URL"
380
+ }
381
+ },
382
+ run: async ({ args }) => {
383
+ const apiUrl = args.apiUrl ?? process.env.PLANUM_API_URL ?? DEFAULT_API_URL;
384
+ const webUrl = args.webUrl ?? process.env.PLANUM_WEB_URL ?? DEFAULT_WEB_URL;
385
+ const label = args.label ?? hostname();
386
+ const spinner4 = p3.spinner();
387
+ spinner4.start("Starting local callback server\u2026");
388
+ const { port, close } = await startCallbackServer();
389
+ let sessionId;
390
+ let verifier;
391
+ try {
392
+ spinner4.message("Requesting a CLI session\u2026");
393
+ const res = await fetch(`${apiUrl}/cli-auth/sessions`, {
394
+ method: "POST",
395
+ headers: { "Content-Type": "application/json" },
396
+ body: JSON.stringify({ callbackPort: port, label })
397
+ });
398
+ if (!res.ok) {
399
+ const text3 = await res.text();
400
+ throw new Error(`Failed to create session: ${res.status} ${text3}`);
401
+ }
402
+ const body = await res.json();
403
+ sessionId = body.data.sessionId;
404
+ verifier = body.data.verifier;
405
+ } catch (err) {
406
+ spinner4.stop("Failed to start login.");
407
+ close();
408
+ throw err;
409
+ }
410
+ const authUrl = `${webUrl}/cli-auth?session=${encodeURIComponent(
411
+ sessionId
412
+ )}&verifier=${encodeURIComponent(verifier)}`;
413
+ spinner4.stop("Opening browser\u2026");
414
+ p3.log.info(`If your browser does not open, visit:
415
+ ${authUrl}`);
416
+ try {
417
+ await open(authUrl);
418
+ } catch {
419
+ }
420
+ const pollSpinner = p3.spinner();
421
+ pollSpinner.start("Waiting for approval in the browser\u2026");
422
+ const deadline = Date.now() + TIMEOUT_MS;
423
+ let token = null;
424
+ try {
425
+ while (Date.now() < deadline) {
426
+ await sleep(POLL_INTERVAL_MS);
427
+ const url = new URL(`${apiUrl}/cli-auth/sessions/${sessionId}/poll`);
428
+ url.searchParams.set("verifier", verifier);
429
+ const res = await fetch(url);
430
+ if (!res.ok) {
431
+ if (res.status === 404) {
432
+ throw new Error("CLI session expired. Run `planum login` again.");
433
+ }
434
+ continue;
435
+ }
436
+ const body = await res.json();
437
+ if (body.data.status === "approved") {
438
+ token = body.data.token;
439
+ break;
440
+ }
441
+ }
442
+ } finally {
443
+ close();
444
+ }
445
+ if (!token) {
446
+ pollSpinner.stop("Login timed out.");
447
+ throw new Error("Did not receive approval within 5 minutes.");
448
+ }
449
+ await saveAuth({ apiUrl, token, label });
450
+ pollSpinner.stop(`Logged in as "${label}".`);
451
+ }
452
+ });
453
+
454
+ // src/commands/logout.ts
455
+ import * as p4 from "@clack/prompts";
456
+ import { defineCommand as defineCommand3 } from "citty";
457
+ var logoutCommand = defineCommand3({
458
+ meta: {
459
+ name: "logout",
460
+ description: "Forget the stored token"
461
+ },
462
+ run: async () => {
463
+ const auth = await loadAuth();
464
+ if (!auth) {
465
+ p4.log.info("Not logged in.");
466
+ return;
467
+ }
468
+ await clearAuth();
469
+ p4.log.success("Logged out.");
470
+ }
471
+ });
472
+
473
+ // src/commands/pull.ts
474
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
475
+ import { dirname as dirname3, resolve as resolve2 } from "path";
476
+ import * as p5 from "@clack/prompts";
477
+ import { defineCommand as defineCommand4 } from "citty";
478
+
479
+ // src/lib/env-file.ts
480
+ import dotenv from "dotenv";
481
+ var parseEnv = (raw) => dotenv.parse(raw);
482
+ var needsQuoting = (value) => /[\s"'`$\\#=]/.test(value) || value === "";
483
+ var escapeValue = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
484
+ var serializeEnv = (entries) => {
485
+ const keys = Object.keys(entries).sort();
486
+ return `${keys.map((key) => {
487
+ const value = entries[key] ?? "";
488
+ const formatted = needsQuoting(value) ? `"${escapeValue(value)}"` : value;
489
+ return `${key}=${formatted}`;
490
+ }).join("\n")}
491
+ `;
492
+ };
493
+
494
+ // src/commands/pull.ts
495
+ var matchScope = (itemScope, want) => itemScope === want;
496
+ var writeBundle = async (api, items, project, environment, scope, outPath) => {
497
+ const filtered = items.filter(
498
+ (i) => i.environment === environment && matchScope(i.scope, scope)
499
+ );
500
+ const label = `${project.name} / ${environment}${scope ? ` / ${scope}` : ""}`;
501
+ if (filtered.length === 0) {
502
+ p5.log.warn(`No env vars for ${label}.`);
503
+ return 0;
504
+ }
505
+ const entries = {};
506
+ for (const item of filtered) {
507
+ const res = await api.post(
508
+ `/vault-items/${item.id}/reveal`
509
+ );
510
+ entries[item.name] = res.data.value;
511
+ }
512
+ const absolute = resolve2(process.cwd(), outPath);
513
+ await mkdir2(dirname3(absolute), { recursive: true });
514
+ await writeFile3(absolute, serializeEnv(entries), "utf8");
515
+ p5.log.success(
516
+ `Wrote ${Object.keys(entries).length} keys \u2192 ${outPath} (${label})`
517
+ );
518
+ return Object.keys(entries).length;
519
+ };
520
+ var pullCommand = defineCommand4({
521
+ meta: {
522
+ name: "pull",
523
+ description: "Download a .env from a project + environment"
524
+ },
525
+ args: {
526
+ project: { type: "string", description: "Project slug" },
527
+ env: { type: "string", description: "Environment name" },
528
+ scope: { type: "string", description: "Scope name (sub-app)" },
529
+ out: {
530
+ type: "string",
531
+ description: "Output file path (default: .env)",
532
+ default: ".env"
533
+ }
534
+ },
535
+ run: async ({ args }) => {
536
+ const auth = await requireAuth();
537
+ const api = createApi(auth);
538
+ const existingConfig = await loadConfig();
539
+ const preferredProject = args.project ?? existingConfig?.config.project;
540
+ const preferredEnv = args.env ?? existingConfig?.config.environment;
541
+ const scopeArg = args.scope;
542
+ const scopes = existingConfig?.config.scopes ?? [];
543
+ const outArgProvided = process.argv.includes("--out");
544
+ const project = await resolveProject(api, preferredProject);
545
+ if (!scopeArg && scopes.length > 0 && !outArgProvided) {
546
+ const spinner5 = p5.spinner();
547
+ spinner5.start(`Fetching ${project.name}\u2026`);
548
+ const items2 = await fetchProjectEnvItems(api, project.id);
549
+ spinner5.stop(`Fetched ${items2.length} env items.`);
550
+ let total = 0;
551
+ for (const entry of scopes) {
552
+ const environment2 = await resolveEnvironment(
553
+ api,
554
+ project.id,
555
+ entry.environment ?? preferredEnv,
556
+ { allowCreate: false }
557
+ );
558
+ const scope2 = entry.scope ?? null;
559
+ total += await writeBundle(
560
+ api,
561
+ items2,
562
+ project,
563
+ environment2,
564
+ scope2,
565
+ entry.file
566
+ );
567
+ }
568
+ p5.log.success(`Done \u2014 ${total} keys across ${scopes.length} scope(s).`);
569
+ return;
570
+ }
571
+ const environment = await resolveEnvironment(
572
+ api,
573
+ project.id,
574
+ preferredEnv,
575
+ { allowCreate: false }
576
+ );
577
+ let scope = null;
578
+ if (scopeArg !== void 0) {
579
+ scope = scopeArg;
580
+ } else {
581
+ scope = await resolveScope(api, project.id, void 0, {
582
+ allowCreate: false,
583
+ allowNone: true
584
+ });
585
+ }
586
+ const spinner4 = p5.spinner();
587
+ const label = `${project.name} / ${environment}${scope ? ` / ${scope}` : ""}`;
588
+ spinner4.start(`Fetching ${label}\u2026`);
589
+ const items = await fetchProjectEnvItems(api, project.id);
590
+ spinner4.stop(`Fetched ${items.length} env items.`);
591
+ await writeBundle(api, items, project, environment, scope, args.out);
592
+ if (!existingConfig) {
593
+ const save = await confirm2(
594
+ "Save project & environment to .planumrc for next time?"
595
+ );
596
+ if (save) {
597
+ const cfg = {
598
+ project: project.slug,
599
+ environment
600
+ };
601
+ await saveConfig(defaultConfigPath(), cfg);
602
+ p5.log.success("Wrote .planumrc");
603
+ }
604
+ }
605
+ }
606
+ });
607
+
608
+ // src/commands/push.ts
609
+ import { readFile as readFile3 } from "fs/promises";
610
+ import { resolve as resolve3 } from "path";
611
+ import * as p6 from "@clack/prompts";
612
+ import { defineCommand as defineCommand5 } from "citty";
613
+ var uploadFile = async (api, filePath, project, environment, scope, skipConfirm) => {
614
+ let raw;
615
+ try {
616
+ raw = await readFile3(filePath, "utf8");
617
+ } catch {
618
+ throw new Error(`Cannot read ${filePath}`);
619
+ }
620
+ const entries = parseEnv(raw);
621
+ const items = Object.entries(entries).map(([name, value]) => ({
622
+ name,
623
+ value
624
+ }));
625
+ if (items.length === 0) {
626
+ p6.log.warn(`No keys found in ${filePath}.`);
627
+ return;
628
+ }
629
+ const label = `${project.name} / ${environment}${scope ? ` / ${scope}` : ""}`;
630
+ if (!skipConfirm) {
631
+ const ok = await confirm2(`Push ${items.length} keys to ${label}?`);
632
+ if (!ok) {
633
+ p6.log.info(`Skipped ${filePath}.`);
634
+ return;
635
+ }
636
+ }
637
+ const spinner4 = p6.spinner();
638
+ spinner4.start(`Uploading ${filePath} \u2192 ${label}\u2026`);
639
+ const res = await api.post("/vault-items/bulk-upsert", {
640
+ projectId: project.id,
641
+ environment,
642
+ scope: scope ?? null,
643
+ items
644
+ });
645
+ spinner4.stop(
646
+ `${filePath} \u2192 created ${res.data.created}, updated ${res.data.updated}.`
647
+ );
648
+ };
649
+ var pushCommand = defineCommand5({
650
+ meta: {
651
+ name: "push",
652
+ description: "Upload a .env to a project + environment"
653
+ },
654
+ args: {
655
+ project: { type: "string", description: "Project slug" },
656
+ env: { type: "string", description: "Environment name" },
657
+ scope: { type: "string", description: "Scope name (sub-app)" },
658
+ file: {
659
+ type: "string",
660
+ description: "Input file path (default: .env)",
661
+ default: ".env"
662
+ }
663
+ },
664
+ run: async ({ args }) => {
665
+ const auth = await requireAuth();
666
+ const api = createApi(auth);
667
+ const existingConfig = await loadConfig();
668
+ const preferredProject = args.project ?? existingConfig?.config.project;
669
+ const preferredEnv = args.env ?? existingConfig?.config.environment;
670
+ const scopeArg = args.scope;
671
+ const scopes = existingConfig?.config.scopes ?? [];
672
+ const fileArgProvided = process.argv.includes("--file");
673
+ const project = await resolveProject(api, preferredProject);
674
+ if (!scopeArg && scopes.length > 0 && !fileArgProvided) {
675
+ for (const entry of scopes) {
676
+ const environment2 = await resolveEnvironment(
677
+ api,
678
+ project.id,
679
+ entry.environment ?? preferredEnv,
680
+ { allowCreate: true }
681
+ );
682
+ const scope2 = entry.scope ?? null;
683
+ const filePath2 = resolve3(process.cwd(), entry.file);
684
+ await uploadFile(api, filePath2, project, environment2, scope2, false);
685
+ }
686
+ return;
687
+ }
688
+ const environment = await resolveEnvironment(
689
+ api,
690
+ project.id,
691
+ preferredEnv,
692
+ { allowCreate: true }
693
+ );
694
+ let scope = null;
695
+ if (scopeArg !== void 0) {
696
+ scope = scopeArg;
697
+ } else {
698
+ scope = await resolveScope(api, project.id, void 0, {
699
+ allowCreate: true,
700
+ allowNone: true
701
+ });
702
+ }
703
+ const filePath = resolve3(process.cwd(), args.file);
704
+ await uploadFile(api, filePath, project, environment, scope, false);
705
+ }
706
+ });
707
+
708
+ // package.json
709
+ var package_default = {
710
+ name: "@cl3tus/planum",
711
+ version: "1.0.0",
712
+ description: "Sync .env files with your Planum vault",
713
+ type: "module",
714
+ license: "MIT",
715
+ author: "Clement Osternaud <osternaud.clement@pm.me>",
716
+ homepage: "https://planum.cl3tusdev.com",
717
+ repository: {
718
+ type: "git",
719
+ url: "https://git.cl3tusdev.com/Cletus/planum.git",
720
+ directory: "apps/cli"
721
+ },
722
+ bugs: {
723
+ url: "https://git.cl3tusdev.com/Cletus/planum/issues"
724
+ },
725
+ keywords: [
726
+ "planum",
727
+ "env",
728
+ "dotenv",
729
+ "secrets",
730
+ "vault",
731
+ "cli",
732
+ "sync"
733
+ ],
734
+ engines: {
735
+ node: ">=20"
736
+ },
737
+ bin: {
738
+ planum: "./bin/planum.mjs"
739
+ },
740
+ files: [
741
+ "bin",
742
+ "dist",
743
+ "README.md",
744
+ "LICENSE"
745
+ ],
746
+ publishConfig: {
747
+ access: "public",
748
+ registry: "https://registry.npmjs.org/"
749
+ },
750
+ scripts: {
751
+ start: "tsx src/index.ts",
752
+ build: "tsup src/index.ts --format esm --target node20 --clean",
753
+ lint: "biome lint src",
754
+ "type-check": "tsc --noEmit -p tsconfig.json",
755
+ prepublishOnly: "pnpm build"
756
+ },
757
+ dependencies: {
758
+ "@clack/prompts": "^0.7.0",
759
+ citty: "^0.1.6",
760
+ dotenv: "^16.4.5",
761
+ open: "^10.1.0"
762
+ },
763
+ devDependencies: {
764
+ "@repo/typescript-config": "workspace:*",
765
+ "@types/node": "^22.10.2",
766
+ tsup: "^8.3.5",
767
+ tsx: "^4.21.0",
768
+ typescript: "5.9.2"
769
+ }
770
+ };
771
+
772
+ // src/index.ts
773
+ if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) {
774
+ process.chdir(process.env.INIT_CWD);
775
+ }
776
+ var main = defineCommand6({
777
+ meta: {
778
+ name: "planum",
779
+ version: package_default.version,
780
+ description: package_default.description
781
+ },
782
+ subCommands: {
783
+ login: loginCommand,
784
+ logout: logoutCommand,
785
+ init: initCommand,
786
+ pull: pullCommand,
787
+ push: pushCommand
788
+ }
789
+ });
790
+ runMain(main);
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@cl3tus/planum",
3
+ "version": "1.0.0",
4
+ "description": "Sync .env files with your Planum vault",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Clement Osternaud <osternaud.clement@pm.me>",
8
+ "homepage": "https://planum.cl3tusdev.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://git.cl3tusdev.com/Cletus/planum.git",
12
+ "directory": "apps/cli"
13
+ },
14
+ "bugs": {
15
+ "url": "https://git.cl3tusdev.com/Cletus/planum/issues"
16
+ },
17
+ "keywords": [
18
+ "planum",
19
+ "env",
20
+ "dotenv",
21
+ "secrets",
22
+ "vault",
23
+ "cli",
24
+ "sync"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "bin": {
30
+ "planum": "./bin/planum.mjs"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "scripts": {
43
+ "start": "tsx src/index.ts",
44
+ "build": "tsup src/index.ts --format esm --target node20 --clean",
45
+ "lint": "biome lint src",
46
+ "type-check": "tsc --noEmit -p tsconfig.json",
47
+ "prepublishOnly": "pnpm build"
48
+ },
49
+ "dependencies": {
50
+ "@clack/prompts": "^0.7.0",
51
+ "citty": "^0.1.6",
52
+ "dotenv": "^16.4.5",
53
+ "open": "^10.1.0"
54
+ },
55
+ "devDependencies": {
56
+ "@repo/typescript-config": "workspace:*",
57
+ "@types/node": "^22.10.2",
58
+ "tsup": "^8.3.5",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "5.9.2"
61
+ }
62
+ }