@dotlabs-hq/env-cli 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.
Files changed (3) hide show
  1. package/README.md +93 -0
  2. package/dist/index.js +936 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @dotlabs-hq/env-cli
2
+
3
+ Node.js CLI for managing projects, env files, secrets, and imports against `https://env.wpsadi.dev`.
4
+
5
+ ## Install
6
+
7
+ - `npm install`
8
+
9
+ ## Development
10
+
11
+ - `npm run dev -- --help`
12
+ - `npm run typecheck`
13
+ - `npm run build`
14
+
15
+ ## Publishing
16
+
17
+ - the package is built to `dist/`
18
+ - the published executable is `dist/index.js`
19
+ - `npm publish` runs `prepublishOnly`, which typechecks and builds first
20
+ - GitHub Actions can publish automatically using npm trusted publishing
21
+ - if you prefer token-based publishing, you can adapt the workflow to provide `NODE_AUTH_TOKEN`
22
+
23
+ ## Command groups
24
+
25
+ - `env auth <login|whoami|logout|doctor>`
26
+ - `env project <list|create|get|update|delete>`
27
+ - `env file <list|create|show|upload|delete>`
28
+ - `env secret <list|create|get|update|delete>`
29
+ - `env import file <projectName> <fileName> --from <path>`
30
+ - `env config <get|set|clear>`
31
+
32
+ ## Global flags
33
+
34
+ - `--endpoint <url>`
35
+ - `--username <name>`
36
+ - `--api-key <token>`
37
+ - `--json`
38
+ - `--yes`
39
+ - `--quiet`
40
+ - `--help`
41
+
42
+ ## Environment variables
43
+
44
+ - `ENV_ENDPOINT`
45
+ - `ENV_API_TOKEN`
46
+ - `ENV_API_KEY`
47
+ - `ENV_USERNAME`
48
+
49
+ Resolution order:
50
+
51
+ 1. command flags
52
+ 2. environment variables
53
+ 3. local config file
54
+ 4. built-in defaults
55
+
56
+ ## Local config
57
+
58
+ Config is stored at:
59
+
60
+ - Windows: `%USERPROFILE%\.env-cli\config.json`
61
+
62
+ Example:
63
+
64
+ ```json
65
+ {
66
+ "endpoint": "https://env.wpsadi.dev",
67
+ "username": "wpsadi",
68
+ "apiKey": "token"
69
+ }
70
+ ```
71
+
72
+ ## Examples
73
+
74
+ - `env auth login`
75
+ - `env project create my-app --description "Production config"`
76
+ - `env file upload my-app .env --from .env.local`
77
+ - `env secret create my-app .env DATABASE_URL --value postgres://localhost/db`
78
+ - `env import file my-app .env.production --from .env.production --mode overwrite --yes`
79
+ - `env config get --json`
80
+
81
+ ## API surface expected by the CLI
82
+
83
+ The CLI is implemented against the REST shape defined in [Plan.md](Plan.md), including:
84
+
85
+ - `/api/:username/me`
86
+ - `/api/:username/projects`
87
+ - `/api/:username/projects/:projectName`
88
+ - `/api/:username/projects/:projectName/files`
89
+ - `/api/:username/projects/:projectName/files/:fileName/secrets`
90
+ - `/api/:username/projects/:projectName/files/:fileName/import`
91
+ - existing file-level endpoints at `/api/:username/:projectName/:fileName`
92
+
93
+ If the backend has not exposed those routes yet, the CLI will build correctly but related commands will fail at runtime until the API is available.
package/dist/index.js ADDED
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/constants.ts
4
+ var DEFAULT_ENDPOINT = "https://env.wpsadi.dev";
5
+ var CONFIG_DIR_NAME = ".env-cli";
6
+ var CONFIG_FILE_NAME = "config.json";
7
+ var REQUEST_TIMEOUT_MS = 15e3;
8
+ var EXIT_CODES = {
9
+ success: 0,
10
+ generic: 1,
11
+ usage: 2,
12
+ auth: 3,
13
+ network: 4
14
+ };
15
+ var IMPORT_MODES = ["merge", "overwrite"];
16
+
17
+ // src/errors.ts
18
+ var EXIT_CODE_BY_TYPE = {
19
+ api_error: EXIT_CODES.generic,
20
+ auth_error: EXIT_CODES.auth,
21
+ config_error: EXIT_CODES.auth,
22
+ network_error: EXIT_CODES.network,
23
+ usage_error: EXIT_CODES.usage,
24
+ user_cancelled: EXIT_CODES.generic,
25
+ validation_error: EXIT_CODES.usage
26
+ };
27
+ var CliError = class extends Error {
28
+ exitCode;
29
+ type;
30
+ constructor(type, message, exitCode) {
31
+ super(message);
32
+ this.name = "CliError";
33
+ this.type = type;
34
+ this.exitCode = exitCode ?? EXIT_CODE_BY_TYPE[type];
35
+ }
36
+ };
37
+ var isCliError = (error) => error instanceof CliError;
38
+ var toCliError = (error) => {
39
+ if (isCliError(error)) {
40
+ return error;
41
+ }
42
+ if (error instanceof Error) {
43
+ return new CliError("api_error", error.message);
44
+ }
45
+ return new CliError("api_error", "Unknown error");
46
+ };
47
+
48
+ // src/output.ts
49
+ import pc from "picocolors";
50
+ import { intro, outro, spinner as createSpinner } from "@clack/prompts";
51
+ var SilentSpinner = class {
52
+ fail(_message) {
53
+ }
54
+ start(_message) {
55
+ }
56
+ stop(_message) {
57
+ }
58
+ };
59
+ var Output = class {
60
+ constructor(mode, quiet) {
61
+ this.mode = mode;
62
+ this.quiet = quiet;
63
+ }
64
+ intro(message) {
65
+ if (this.mode === "human" && !this.quiet) {
66
+ intro(message);
67
+ }
68
+ }
69
+ outro(message) {
70
+ if (this.mode === "human" && !this.quiet) {
71
+ outro(message);
72
+ }
73
+ }
74
+ info(message) {
75
+ if (this.mode === "human" && !this.quiet) {
76
+ console.log(pc.cyan(message));
77
+ }
78
+ }
79
+ muted(message) {
80
+ if (this.mode === "human" && !this.quiet) {
81
+ console.log(pc.dim(message));
82
+ }
83
+ }
84
+ success(message) {
85
+ if (this.mode === "human" && !this.quiet) {
86
+ console.log(pc.green(`\u2714 ${message}`));
87
+ }
88
+ }
89
+ error(message) {
90
+ if (this.mode === "human") {
91
+ console.error(pc.red(`\u2716 ${message}`));
92
+ }
93
+ }
94
+ list(title, items) {
95
+ if (this.mode !== "human" || this.quiet) {
96
+ return;
97
+ }
98
+ console.log(pc.bold(title));
99
+ for (const item of items) {
100
+ console.log(` \u2022 ${item}`);
101
+ }
102
+ }
103
+ spinner() {
104
+ if (this.mode !== "human" || this.quiet) {
105
+ return new SilentSpinner();
106
+ }
107
+ const instance = createSpinner();
108
+ return {
109
+ fail(message) {
110
+ instance.stop(pc.red(message), 1);
111
+ },
112
+ start(message) {
113
+ instance.start(message);
114
+ },
115
+ stop(message) {
116
+ instance.stop(pc.green(message));
117
+ }
118
+ };
119
+ }
120
+ json(result) {
121
+ console.log(JSON.stringify({ success: true, ...result }, null, 2));
122
+ }
123
+ };
124
+ var printJsonError = (message, type) => {
125
+ console.error(
126
+ JSON.stringify(
127
+ {
128
+ success: false,
129
+ error: { message, type }
130
+ },
131
+ null,
132
+ 2
133
+ )
134
+ );
135
+ };
136
+
137
+ // src/client.ts
138
+ var asMessage = (payload) => {
139
+ if (!payload || typeof payload !== "object") {
140
+ return void 0;
141
+ }
142
+ const record = payload;
143
+ if (typeof record.message === "string") {
144
+ return record.message;
145
+ }
146
+ const errorValue = record.error;
147
+ if (typeof errorValue === "string") {
148
+ return errorValue;
149
+ }
150
+ if (errorValue && typeof errorValue === "object") {
151
+ const errorRecord = errorValue;
152
+ if (typeof errorRecord.message === "string") {
153
+ return errorRecord.message;
154
+ }
155
+ }
156
+ return void 0;
157
+ };
158
+ var EnvApiClient = class {
159
+ constructor(endpoint, apiKey) {
160
+ this.endpoint = endpoint;
161
+ this.apiKey = apiKey;
162
+ }
163
+ async request(path, init) {
164
+ const headers = new Headers(init?.headers);
165
+ headers.set("accept", "application/json");
166
+ if (init?.body) {
167
+ headers.set("content-type", "application/json");
168
+ }
169
+ if (this.apiKey) {
170
+ headers.set("authorization", `Bearer ${this.apiKey}`);
171
+ }
172
+ let response;
173
+ try {
174
+ response = await fetch(`${this.endpoint}${path}`, {
175
+ ...init,
176
+ headers,
177
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
178
+ });
179
+ } catch (error) {
180
+ if (error instanceof Error) {
181
+ throw new CliError("network_error", error.message);
182
+ }
183
+ throw new CliError("network_error", "Request failed");
184
+ }
185
+ const text2 = await response.text();
186
+ const payload = text2 ? JSON.parse(text2) : null;
187
+ if (!response.ok) {
188
+ const message = asMessage(payload) ?? `${response.status} ${response.statusText}`;
189
+ const type = response.status === 401 || response.status === 403 ? "auth_error" : "api_error";
190
+ throw new CliError(type, message);
191
+ }
192
+ return payload;
193
+ }
194
+ getMe(username) {
195
+ return this.request(`/api/${username}/me`);
196
+ }
197
+ listProjects(username) {
198
+ return this.request(`/api/${username}/projects`);
199
+ }
200
+ createProject(username, projectName, description) {
201
+ return this.request(`/api/${username}/projects`, {
202
+ body: JSON.stringify({ description, projectName }),
203
+ method: "POST"
204
+ });
205
+ }
206
+ getProject(username, projectName) {
207
+ return this.request(`/api/${username}/projects/${projectName}`);
208
+ }
209
+ updateProject(username, projectName, description) {
210
+ return this.request(`/api/${username}/projects/${projectName}`, {
211
+ body: JSON.stringify({ description }),
212
+ method: "PATCH"
213
+ });
214
+ }
215
+ deleteProject(username, projectName) {
216
+ return this.request(`/api/${username}/projects/${projectName}`, { method: "DELETE" });
217
+ }
218
+ listFiles(username, projectName) {
219
+ return this.request(`/api/${username}/projects/${projectName}/files`);
220
+ }
221
+ createFile(username, projectName, fileName) {
222
+ return this.request(`/api/${username}/projects/${projectName}/files`, {
223
+ body: JSON.stringify({ fileName }),
224
+ method: "POST"
225
+ });
226
+ }
227
+ showFile(username, projectName, fileName) {
228
+ return this.request(`/api/${username}/${projectName}/${fileName}`);
229
+ }
230
+ uploadFile(username, projectName, fileName, content) {
231
+ return this.request(`/api/${username}/${projectName}/${fileName}`, {
232
+ body: JSON.stringify({ content }),
233
+ method: "PUT"
234
+ });
235
+ }
236
+ deleteFile(username, projectName, fileName) {
237
+ return this.request(`/api/${username}/${projectName}/${fileName}`, { method: "DELETE" });
238
+ }
239
+ listSecrets(username, projectName, fileName) {
240
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/secrets`);
241
+ }
242
+ createSecret(username, projectName, fileName, keyName, value) {
243
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/secrets`, {
244
+ body: JSON.stringify({ keyName, value }),
245
+ method: "POST"
246
+ });
247
+ }
248
+ getSecret(username, projectName, fileName, keyName) {
249
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/secrets/${keyName}`);
250
+ }
251
+ updateSecret(username, projectName, fileName, keyName, value) {
252
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/secrets/${keyName}`, {
253
+ body: JSON.stringify({ value }),
254
+ method: "PATCH"
255
+ });
256
+ }
257
+ deleteSecret(username, projectName, fileName, keyName) {
258
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/secrets/${keyName}`, {
259
+ method: "DELETE"
260
+ });
261
+ }
262
+ importFile(username, projectName, fileName, content, mode) {
263
+ return this.request(`/api/${username}/projects/${projectName}/files/${fileName}/import`, {
264
+ body: JSON.stringify({ content, mode }),
265
+ method: "POST"
266
+ });
267
+ }
268
+ };
269
+
270
+ // src/config.ts
271
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
272
+ import { homedir } from "os";
273
+ import { dirname, join } from "path";
274
+
275
+ // src/validation.ts
276
+ import { z } from "zod";
277
+ var ProjectNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/, "Use letters, numbers, '_' or '-'");
278
+ var FileNameSchema = z.string().min(1).max(128).regex(/^\.?[A-Za-z0-9._-]+$/, "Use .env-style file names only").refine((value) => !value.includes("/") && !value.includes("\\"), {
279
+ message: "Paths are not allowed in file names"
280
+ }).refine((value) => !value.includes(".."), {
281
+ message: "Path traversal is not allowed"
282
+ });
283
+ var SecretKeySchema = z.string().min(1).max(128).regex(/^[A-Z0-9_]+$/, "Use uppercase letters, numbers, and underscores");
284
+ var NonEmptyStringSchema = z.string().min(1);
285
+ var EndpointSchema = z.string().url();
286
+ var ImportModeSchema = z.enum(IMPORT_MODES);
287
+ var StoredConfigSchema = z.object({
288
+ apiKey: z.string().min(1).optional(),
289
+ endpoint: EndpointSchema.optional(),
290
+ username: z.string().min(1).optional()
291
+ });
292
+ var parseRequired = (schema, value, label) => {
293
+ if (!value) {
294
+ throw new CliError("validation_error", `${label} is required`);
295
+ }
296
+ const result = schema.safeParse(value);
297
+ if (!result.success) {
298
+ throw new CliError("validation_error", `${label}: ${result.error.issues[0]?.message ?? "Invalid value"}`);
299
+ }
300
+ return result.data;
301
+ };
302
+ var parseOptionalMode = (value) => {
303
+ if (!value) {
304
+ return void 0;
305
+ }
306
+ const result = ImportModeSchema.safeParse(value);
307
+ if (!result.success) {
308
+ throw new CliError("validation_error", "Mode must be merge or overwrite");
309
+ }
310
+ return result.data;
311
+ };
312
+
313
+ // src/config.ts
314
+ var configFilePath = join(homedir(), CONFIG_DIR_NAME, CONFIG_FILE_NAME);
315
+ var getConfigPath = () => configFilePath;
316
+ var readConfig = async () => {
317
+ try {
318
+ const content = await readFile(configFilePath, "utf8");
319
+ const parsed = JSON.parse(content);
320
+ return StoredConfigSchema.parse(parsed);
321
+ } catch (error) {
322
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
323
+ return {};
324
+ }
325
+ throw new CliError("config_error", "Failed to read local config");
326
+ }
327
+ };
328
+ var writeConfig = async (config) => {
329
+ const parsed = StoredConfigSchema.safeParse(config);
330
+ if (!parsed.success) {
331
+ throw new CliError("config_error", "Refusing to write invalid config");
332
+ }
333
+ await mkdir(dirname(configFilePath), { recursive: true });
334
+ await writeFile(configFilePath, `${JSON.stringify(parsed.data, null, 2)}
335
+ `, "utf8");
336
+ };
337
+ var clearConfig = async () => {
338
+ await rm(configFilePath, { force: true });
339
+ };
340
+ var resolveConfig = (flags, storedConfig) => {
341
+ const endpoint = flags.endpoint ?? process.env.ENV_ENDPOINT ?? storedConfig.endpoint ?? DEFAULT_ENDPOINT;
342
+ const endpointCheck = EndpointSchema.safeParse(endpoint);
343
+ if (!endpointCheck.success) {
344
+ throw new CliError("config_error", "Configured endpoint is not a valid URL");
345
+ }
346
+ return {
347
+ apiKey: flags.apiKey ?? process.env.ENV_API_TOKEN ?? process.env.ENV_API_KEY ?? storedConfig.apiKey,
348
+ endpoint: endpointCheck.data.replace(/\/+$/, ""),
349
+ username: flags.username ?? process.env.ENV_USERNAME ?? storedConfig.username
350
+ };
351
+ };
352
+
353
+ // src/help.ts
354
+ var GLOBAL_OPTIONS = [
355
+ { description: "Use a custom API endpoint", name: "--endpoint <url>" },
356
+ { description: "Override the configured username", name: "--username <name>" },
357
+ { description: "Override the configured API key", name: "--api-key <token>" },
358
+ { description: "Print machine-readable JSON", name: "--json" },
359
+ { description: "Skip confirmation prompts", name: "--yes" },
360
+ { description: "Suppress non-error human output", name: "--quiet" }
361
+ ];
362
+ var HELP = {
363
+ root: {
364
+ description: "Manage env.wpsadi.dev projects, files, secrets, and imports.",
365
+ examples: ["env auth login", "env project list", "env import file my-app .env --from .env.production --mode merge"],
366
+ options: [...GLOBAL_OPTIONS],
367
+ usage: ["env <command>", "env help <command>"]
368
+ },
369
+ auth: {
370
+ description: "Authenticate and inspect CLI configuration.",
371
+ examples: ["env auth login", "env auth whoami", "env auth doctor"],
372
+ usage: ["env auth <login|whoami|logout|doctor>"]
373
+ },
374
+ config: {
375
+ description: "Manage local CLI config stored in the user profile.",
376
+ examples: ["env config get", "env config set endpoint https://env.wpsadi.dev", "env config clear --yes"],
377
+ usage: ["env config <get|set|clear>"]
378
+ },
379
+ file: {
380
+ description: "Manage env files inside a project.",
381
+ examples: ["env file list my-app", "env file create my-app .env", "env file upload my-app .env --from .env.local"],
382
+ usage: ["env file <list|create|delete|show|upload> [args]"]
383
+ },
384
+ import: {
385
+ description: "Bulk import a local env file.",
386
+ examples: ["env import file my-app .env --from .env --mode overwrite"],
387
+ usage: ["env import file <projectName> <fileName> --from <path> [--mode merge|overwrite]"]
388
+ },
389
+ project: {
390
+ description: "Manage projects.",
391
+ examples: ["env project list", "env project create my-app", "env project delete my-app --yes"],
392
+ usage: ["env project <list|create|get|update|delete> [args]"]
393
+ },
394
+ secret: {
395
+ description: "Manage one secret at a time.",
396
+ examples: ["env secret list my-app .env", "env secret create my-app .env DATABASE_URL --value postgres://..."],
397
+ usage: ["env secret <list|create|get|update|delete> [args]"]
398
+ }
399
+ };
400
+ var renderHelp = (topic) => {
401
+ const help = HELP[topic ?? "root"] ?? HELP.root;
402
+ const lines = [help.description, "", "Usage:"];
403
+ for (const usage of help.usage) {
404
+ lines.push(` ${usage}`);
405
+ }
406
+ if (help.options?.length) {
407
+ lines.push("", "Options:");
408
+ for (const option of help.options) {
409
+ lines.push(` ${option.name.padEnd(22)} ${option.description}`);
410
+ }
411
+ }
412
+ lines.push("", "Examples:");
413
+ for (const example of help.examples) {
414
+ lines.push(` ${example}`);
415
+ }
416
+ if (!topic || topic === "root") {
417
+ lines.push("", "Commands:", " auth", " config", " file", " import", " project", " secret");
418
+ }
419
+ lines.push("", "Environment variables:", " ENV_ENDPOINT", " ENV_API_TOKEN or ENV_API_KEY", " ENV_USERNAME");
420
+ return `${lines.join("\n")}
421
+ `;
422
+ };
423
+
424
+ // src/utils/prompts.ts
425
+ import { cancel, confirm, isCancel, password, select, text } from "@clack/prompts";
426
+ var unwrap = (value) => {
427
+ if (isCancel(value)) {
428
+ cancel("Cancelled");
429
+ throw new CliError("user_cancelled", "Operation cancelled by user");
430
+ }
431
+ return value;
432
+ };
433
+ var promptText = async (message, initialValue, placeholder, required = true) => {
434
+ const value = await text({
435
+ message,
436
+ initialValue,
437
+ placeholder,
438
+ validate(input) {
439
+ if (!required) {
440
+ return void 0;
441
+ }
442
+ return input.trim() ? void 0 : "Value is required";
443
+ }
444
+ });
445
+ return unwrap(value).trim();
446
+ };
447
+ var promptPassword = async (message) => {
448
+ const value = await password({
449
+ message,
450
+ validate(input) {
451
+ return input.trim() ? void 0 : "Value is required";
452
+ }
453
+ });
454
+ return unwrap(value).trim();
455
+ };
456
+ var promptConfirm = async (message, initialValue = false) => {
457
+ const value = await confirm({ message, initialValue });
458
+ return unwrap(value);
459
+ };
460
+ var promptSelect = async (message, options) => {
461
+ const value = await select({
462
+ message,
463
+ options: options.map((option) => ({
464
+ label: option.label,
465
+ value: option.value
466
+ }))
467
+ });
468
+ return unwrap(value);
469
+ };
470
+
471
+ // src/utils/command.ts
472
+ var requireUsername = (context) => {
473
+ if (!context.config.username) {
474
+ throw new CliError("config_error", "Username is required. Run 'env auth login' or pass --username.");
475
+ }
476
+ return context.config.username;
477
+ };
478
+ var requireApiKey = (context) => {
479
+ if (!context.config.apiKey) {
480
+ throw new CliError("config_error", "API key is required. Run 'env auth login' or pass --api-key.");
481
+ }
482
+ return context.config.apiKey;
483
+ };
484
+ var asRecord = (value) => {
485
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
486
+ return {};
487
+ }
488
+ return value;
489
+ };
490
+ var asRecordArray = (value, keys) => {
491
+ if (Array.isArray(value)) {
492
+ return value.filter((item) => Boolean(item) && typeof item === "object");
493
+ }
494
+ const record = asRecord(value);
495
+ for (const key of keys) {
496
+ const candidate = record[key];
497
+ if (Array.isArray(candidate)) {
498
+ return candidate.filter((item) => Boolean(item) && typeof item === "object");
499
+ }
500
+ }
501
+ return [];
502
+ };
503
+ var recordToJson = (value) => {
504
+ try {
505
+ return JSON.parse(JSON.stringify(value));
506
+ } catch {
507
+ return { value: String(value) };
508
+ }
509
+ };
510
+ var pickString = (record, keys) => {
511
+ for (const key of keys) {
512
+ const value = record[key];
513
+ if (typeof value === "string" && value.length > 0) {
514
+ return value;
515
+ }
516
+ }
517
+ return void 0;
518
+ };
519
+
520
+ // src/commands/auth.ts
521
+ var validateAuth = async (endpoint, username, apiKey) => {
522
+ const client = new EnvApiClient(endpoint, apiKey);
523
+ try {
524
+ return await client.getMe(username);
525
+ } catch (error) {
526
+ if (error instanceof CliError) {
527
+ throw error;
528
+ }
529
+ throw new CliError("auth_error", "Authentication failed");
530
+ }
531
+ };
532
+ var runAuth = async (context, args) => {
533
+ const subcommand = args[0] ?? "login";
534
+ if (subcommand === "login") {
535
+ context.output.intro("Env CLI login");
536
+ const endpoint = context.flags.endpoint ? parseRequired(EndpointSchema, context.flags.endpoint, "Endpoint") : context.canPrompt ? parseRequired(
537
+ EndpointSchema,
538
+ await promptText("API endpoint", context.config.endpoint),
539
+ "Endpoint"
540
+ ) : context.config.endpoint;
541
+ const username = context.flags.username ?? (context.canPrompt ? await promptText("Username", context.config.username) : context.config.username);
542
+ const apiKey = context.flags.apiKey ?? (context.canPrompt ? await promptPassword("API key") : context.config.apiKey);
543
+ const safeUsername = parseRequired(NonEmptyStringSchema, username, "Username");
544
+ const safeApiKey = parseRequired(NonEmptyStringSchema, apiKey, "API key");
545
+ const spin = context.output.spinner();
546
+ spin.start("Validating credentials");
547
+ const profile = await validateAuth(endpoint, safeUsername, safeApiKey);
548
+ spin.stop("Credentials verified");
549
+ await writeConfig({ apiKey: safeApiKey, endpoint, username: safeUsername });
550
+ context.output.outro("Saved local config");
551
+ return { data: recordToJson(profile), message: "Logged in successfully" };
552
+ }
553
+ if (subcommand === "whoami" || subcommand === "doctor") {
554
+ const username = context.config.username;
555
+ const apiKey = requireApiKey(context);
556
+ if (!username) {
557
+ throw new CliError("config_error", "Username is not configured");
558
+ }
559
+ const spin = context.output.spinner();
560
+ spin.start(subcommand === "doctor" ? "Checking configuration" : "Fetching profile");
561
+ const profile = await validateAuth(context.config.endpoint, username, apiKey);
562
+ spin.stop("Configuration looks good");
563
+ const profileRecord = typeof profile === "object" && profile ? profile : {};
564
+ const userRecord = typeof profileRecord.user === "object" && profileRecord.user ? profileRecord.user : profileRecord;
565
+ if (subcommand === "doctor") {
566
+ context.output.list("Resolved configuration", [
567
+ `endpoint: ${context.config.endpoint}`,
568
+ `username: ${username}`,
569
+ `api key: ${context.config.apiKey ? "set" : "missing"}`
570
+ ]);
571
+ return {
572
+ data: {
573
+ endpoint: context.config.endpoint,
574
+ profile: recordToJson(profile),
575
+ username
576
+ },
577
+ message: "Configuration verified"
578
+ };
579
+ }
580
+ context.output.list("Authenticated user", [
581
+ `username: ${pickString(userRecord, ["username", "login", "name"]) ?? username}`,
582
+ `endpoint: ${context.config.endpoint}`
583
+ ]);
584
+ return { data: recordToJson(profile), message: "Authenticated user" };
585
+ }
586
+ if (subcommand === "logout") {
587
+ if (!context.flags.yes && context.canPrompt) {
588
+ const confirmed = await promptConfirm("Remove local credentials?", false);
589
+ if (!confirmed) {
590
+ throw new CliError("user_cancelled", "Operation cancelled by user");
591
+ }
592
+ }
593
+ await clearConfig();
594
+ return { data: { cleared: true }, message: "Logged out" };
595
+ }
596
+ throw new CliError("usage_error", `Unknown auth command: ${subcommand}`);
597
+ };
598
+
599
+ // src/commands/config.ts
600
+ var runConfig = async (context, args) => {
601
+ const subcommand = args[0] ?? "get";
602
+ if (subcommand === "get") {
603
+ context.output.list("Config", [
604
+ `path: ${getConfigPath()}`,
605
+ `endpoint: ${context.config.endpoint}`,
606
+ `username: ${context.rawConfig.username ?? "<unset>"}`,
607
+ `api key: ${context.rawConfig.apiKey ? "set" : "<unset>"}`
608
+ ]);
609
+ return {
610
+ data: recordToJson({ path: getConfigPath(), resolved: context.config, stored: context.rawConfig }),
611
+ message: "Loaded config"
612
+ };
613
+ }
614
+ if (subcommand === "set") {
615
+ const key = args[1];
616
+ const value = args[2] ?? (context.canPrompt ? await promptText(`Value for ${key ?? "setting"}`) : void 0);
617
+ if (!key || !value) {
618
+ throw new CliError("usage_error", "Usage: env config set <endpoint|username> <value>");
619
+ }
620
+ const nextConfig = { ...context.rawConfig };
621
+ if (key === "endpoint") {
622
+ nextConfig.endpoint = EndpointSchema.parse(value);
623
+ } else if (key === "username") {
624
+ nextConfig.username = value;
625
+ } else {
626
+ throw new CliError("usage_error", "Only endpoint and username can be set directly");
627
+ }
628
+ await writeConfig(nextConfig);
629
+ return { data: recordToJson(nextConfig), message: `Updated ${key}` };
630
+ }
631
+ if (subcommand === "clear") {
632
+ if (!context.flags.yes && context.canPrompt) {
633
+ const confirmed = await promptConfirm("Clear local config?", false);
634
+ if (!confirmed) {
635
+ throw new CliError("user_cancelled", "Operation cancelled by user");
636
+ }
637
+ }
638
+ await clearConfig();
639
+ return { data: { cleared: true }, message: "Cleared local config" };
640
+ }
641
+ throw new CliError("usage_error", `Unknown config command: ${subcommand}`);
642
+ };
643
+
644
+ // src/utils/env-file.ts
645
+ import { readFile as readFile2, stat } from "fs/promises";
646
+ import { resolve } from "path";
647
+ var readLocalEnvFile = async (filePath) => {
648
+ const resolvedPath = resolve(filePath);
649
+ let fileStat;
650
+ try {
651
+ fileStat = await stat(resolvedPath);
652
+ } catch {
653
+ throw new CliError("validation_error", `File not found: ${resolvedPath}`);
654
+ }
655
+ if (!fileStat.isFile()) {
656
+ throw new CliError("validation_error", `Not a file: ${resolvedPath}`);
657
+ }
658
+ const content = await readFile2(resolvedPath, "utf8");
659
+ if (!content.trim()) {
660
+ throw new CliError("validation_error", "Import file is empty");
661
+ }
662
+ return { content, path: resolvedPath };
663
+ };
664
+
665
+ // src/commands/file.ts
666
+ var runFile = async (context, args) => {
667
+ requireApiKey(context);
668
+ const username = requireUsername(context);
669
+ const client = new EnvApiClient(context.config.endpoint, context.config.apiKey);
670
+ const subcommand = args[0] ?? "list";
671
+ const projectName = args[1] ?? (context.canPrompt ? await promptText("Project name") : void 0);
672
+ if (subcommand === "list") {
673
+ const safeProjectName2 = parseRequired(ProjectNameSchema, projectName, "Project name");
674
+ const data = await client.listFiles(username, safeProjectName2);
675
+ const files = asRecordArray(data, ["files", "data"]);
676
+ context.output.list("Files", files.map((file) => pickString(file, ["fileName", "name"]) ?? JSON.stringify(file)));
677
+ return { data: recordToJson(files), message: `Listed files for ${safeProjectName2}` };
678
+ }
679
+ const safeProjectName = parseRequired(ProjectNameSchema, projectName, "Project name");
680
+ const fileName = args[2] ?? (context.canPrompt ? await promptText("File name", ".env") : void 0);
681
+ const safeFileName = parseRequired(FileNameSchema, fileName, "File name");
682
+ if (subcommand === "create") {
683
+ const created = await client.createFile(username, safeProjectName, safeFileName);
684
+ return { data: recordToJson(created), message: `Created file ${safeFileName}` };
685
+ }
686
+ if (subcommand === "show") {
687
+ const data = await client.showFile(username, safeProjectName, safeFileName);
688
+ context.output.info(JSON.stringify(recordToJson(data), null, 2));
689
+ return { data: recordToJson(data), message: `Fetched file ${safeFileName}` };
690
+ }
691
+ if (subcommand === "upload") {
692
+ const sourcePath = context.flags.from ?? (context.canPrompt ? await promptText("Path to local env file") : void 0);
693
+ const localFile = await readLocalEnvFile(parseRequired(NonEmptyStringSchema, sourcePath, "Source path"));
694
+ const result = await client.uploadFile(username, safeProjectName, safeFileName, localFile.content);
695
+ return { data: recordToJson(result), message: `Uploaded ${localFile.path}` };
696
+ }
697
+ if (subcommand === "delete") {
698
+ if (!context.flags.yes && context.canPrompt) {
699
+ const confirmed = await promptConfirm(`Delete file ${safeFileName}?`, false);
700
+ if (!confirmed) {
701
+ throw new CliError("user_cancelled", "Operation cancelled by user");
702
+ }
703
+ }
704
+ const result = await client.deleteFile(username, safeProjectName, safeFileName);
705
+ return { data: recordToJson(asRecord(result)), message: `Deleted file ${safeFileName}` };
706
+ }
707
+ throw new CliError("usage_error", `Unknown file command: ${subcommand}`);
708
+ };
709
+
710
+ // src/commands/import.ts
711
+ var runImport = async (context, args) => {
712
+ requireApiKey(context);
713
+ const username = requireUsername(context);
714
+ const client = new EnvApiClient(context.config.endpoint, context.config.apiKey);
715
+ const subcommand = args[0] ?? "file";
716
+ if (subcommand !== "file") {
717
+ throw new CliError("usage_error", "Usage: env import file <projectName> <fileName> --from <path>");
718
+ }
719
+ const projectName = parseRequired(ProjectNameSchema, args[1] ?? (context.canPrompt ? await promptText("Project name") : void 0), "Project name");
720
+ const fileName = parseRequired(FileNameSchema, args[2] ?? (context.canPrompt ? await promptText("File name", ".env") : void 0), "File name");
721
+ const sourcePath = context.flags.from ?? (context.canPrompt ? await promptText("Path to local env file") : void 0);
722
+ if (!sourcePath) {
723
+ throw new CliError("usage_error", "--from is required");
724
+ }
725
+ const localFile = await readLocalEnvFile(sourcePath);
726
+ const mode = parseOptionalMode(context.flags.mode) ?? (context.canPrompt ? await promptSelect("Import mode", [
727
+ { label: "Merge existing values", value: "merge" },
728
+ { label: "Overwrite file contents", value: "overwrite" }
729
+ ]) : "merge");
730
+ if (mode === "overwrite" && !context.flags.yes && context.canPrompt) {
731
+ const confirmed = await promptConfirm(`Overwrite ${projectName}/${fileName}?`, false);
732
+ if (!confirmed) {
733
+ throw new CliError("user_cancelled", "Operation cancelled by user");
734
+ }
735
+ }
736
+ const result = await client.importFile(username, projectName, fileName, localFile.content, mode);
737
+ return {
738
+ data: recordToJson({ mode, path: localFile.path, result }),
739
+ message: `Imported ${localFile.path} into ${projectName}/${fileName}`
740
+ };
741
+ };
742
+
743
+ // src/commands/project.ts
744
+ var runProject = async (context, args) => {
745
+ requireApiKey(context);
746
+ const username = requireUsername(context);
747
+ const client = new EnvApiClient(context.config.endpoint, context.config.apiKey);
748
+ const subcommand = args[0] ?? "list";
749
+ if (subcommand === "list") {
750
+ const data = await client.listProjects(username);
751
+ const projects = asRecordArray(data, ["projects", "data"]);
752
+ context.output.list("Projects", projects.map((project) => pickString(project, ["projectName", "name", "slug"]) ?? JSON.stringify(project)));
753
+ return { data: recordToJson(projects), message: "Listed projects" };
754
+ }
755
+ const projectName = args[1] ?? (context.canPrompt ? await promptText("Project name") : void 0);
756
+ const safeProjectName = parseRequired(ProjectNameSchema, projectName, "Project name");
757
+ if (subcommand === "create") {
758
+ const description = context.flags.description ?? (context.canPrompt ? await promptText("Description (optional)", "", "optional", false) : void 0);
759
+ const created = await client.createProject(username, safeProjectName, description || void 0);
760
+ return { data: recordToJson(created), message: `Created project ${safeProjectName}` };
761
+ }
762
+ if (subcommand === "get") {
763
+ const project = await client.getProject(username, safeProjectName);
764
+ context.output.info(JSON.stringify(recordToJson(project), null, 2));
765
+ return { data: recordToJson(project), message: `Fetched project ${safeProjectName}` };
766
+ }
767
+ if (subcommand === "update") {
768
+ const description = context.flags.description ?? (context.canPrompt ? await promptText("New description", "", "optional", false) : void 0);
769
+ const project = await client.updateProject(username, safeProjectName, description || void 0);
770
+ return { data: recordToJson(project), message: `Updated project ${safeProjectName}` };
771
+ }
772
+ if (subcommand === "delete") {
773
+ if (!context.flags.yes && context.canPrompt) {
774
+ const confirmed = await promptConfirm(`Delete project ${safeProjectName}?`, false);
775
+ if (!confirmed) {
776
+ throw new CliError("user_cancelled", "Operation cancelled by user");
777
+ }
778
+ }
779
+ const result = await client.deleteProject(username, safeProjectName);
780
+ return { data: recordToJson(asRecord(result)), message: `Deleted project ${safeProjectName}` };
781
+ }
782
+ throw new CliError("usage_error", `Unknown project command: ${subcommand}`);
783
+ };
784
+
785
+ // src/commands/secret.ts
786
+ import { z as z2 } from "zod";
787
+ var NonEmptySchema = z2.string().min(1);
788
+ var runSecret = async (context, args) => {
789
+ requireApiKey(context);
790
+ const username = requireUsername(context);
791
+ const client = new EnvApiClient(context.config.endpoint, context.config.apiKey);
792
+ const subcommand = args[0] ?? "list";
793
+ const projectName = parseRequired(ProjectNameSchema, args[1] ?? (context.canPrompt ? await promptText("Project name") : void 0), "Project name");
794
+ const fileName = parseRequired(FileNameSchema, args[2] ?? (context.canPrompt ? await promptText("File name", ".env") : void 0), "File name");
795
+ if (subcommand === "list") {
796
+ const data = await client.listSecrets(username, projectName, fileName);
797
+ const secrets = asRecordArray(data, ["secrets", "data"]);
798
+ context.output.list("Secrets", secrets.map((secret) => pickString(secret, ["keyName", "key", "name"]) ?? JSON.stringify(secret)));
799
+ return { data: recordToJson(secrets), message: `Listed secrets for ${fileName}` };
800
+ }
801
+ const keyName = parseRequired(SecretKeySchema, args[3] ?? (context.canPrompt ? await promptText("Secret key") : void 0), "Secret key");
802
+ if (subcommand === "get") {
803
+ const result = await client.getSecret(username, projectName, fileName, keyName);
804
+ context.output.info(JSON.stringify(recordToJson(result), null, 2));
805
+ return { data: recordToJson(result), message: `Fetched secret ${keyName}` };
806
+ }
807
+ if (subcommand === "create" || subcommand === "update") {
808
+ const value = context.flags.value ?? (context.canPrompt ? await promptPassword("Secret value") : void 0);
809
+ const safeValue = parseRequired(NonEmptySchema, value, "Secret value");
810
+ const result = subcommand === "create" ? await client.createSecret(username, projectName, fileName, keyName, safeValue) : await client.updateSecret(username, projectName, fileName, keyName, safeValue);
811
+ return { data: recordToJson(result), message: `${subcommand === "create" ? "Created" : "Updated"} secret ${keyName}` };
812
+ }
813
+ if (subcommand === "delete") {
814
+ if (!context.flags.yes && context.canPrompt) {
815
+ const confirmed = await promptConfirm(`Delete secret ${keyName}?`, false);
816
+ if (!confirmed) {
817
+ throw new CliError("user_cancelled", "Operation cancelled by user");
818
+ }
819
+ }
820
+ const result = await client.deleteSecret(username, projectName, fileName, keyName);
821
+ return { data: recordToJson(asRecord(result)), message: `Deleted secret ${keyName}` };
822
+ }
823
+ throw new CliError("usage_error", `Unknown secret command: ${subcommand}`);
824
+ };
825
+
826
+ // src/cli.ts
827
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["api-key", "description", "endpoint", "from", "mode", "username", "value"]);
828
+ var parseFlags = (argv) => {
829
+ const flags = { help: false, json: false, quiet: false, yes: false };
830
+ const positionals = [];
831
+ for (let index = 0; index < argv.length; index += 1) {
832
+ const token = argv[index];
833
+ if (!token?.startsWith("--")) {
834
+ positionals.push(token ?? "");
835
+ continue;
836
+ }
837
+ const [rawName, inlineValue] = token.slice(2).split("=", 2);
838
+ const name = rawName ?? "";
839
+ if (name === "help") {
840
+ flags.help = true;
841
+ continue;
842
+ }
843
+ if (name === "json") {
844
+ flags.json = true;
845
+ continue;
846
+ }
847
+ if (name === "quiet") {
848
+ flags.quiet = true;
849
+ continue;
850
+ }
851
+ if (name === "yes") {
852
+ flags.yes = true;
853
+ continue;
854
+ }
855
+ if (!VALUE_FLAGS.has(name)) {
856
+ throw new CliError("usage_error", `Unknown flag: --${name}`);
857
+ }
858
+ const value = inlineValue ?? argv[index + 1];
859
+ if (!value) {
860
+ throw new CliError("usage_error", `Flag --${name} requires a value`);
861
+ }
862
+ index += inlineValue ? 0 : 1;
863
+ if (name === "mode") {
864
+ flags.mode = parseOptionalMode(value);
865
+ continue;
866
+ }
867
+ if (name === "api-key") flags.apiKey = value;
868
+ if (name === "description") flags.description = value;
869
+ if (name === "endpoint") flags.endpoint = value;
870
+ if (name === "from") flags.from = value;
871
+ if (name === "username") flags.username = value;
872
+ if (name === "value") flags.value = value;
873
+ }
874
+ return { flags, positionals };
875
+ };
876
+ var dispatch = async (context, positionals) => {
877
+ const [command, ...args] = positionals;
878
+ if (!command || command === "help") {
879
+ return { data: { help: renderHelp(args[0]) }, message: renderHelp(args[0]) };
880
+ }
881
+ if (context.flags.help) {
882
+ return { data: { help: renderHelp(command) }, message: renderHelp(command) };
883
+ }
884
+ void new EnvApiClient(context.config.endpoint, context.config.apiKey);
885
+ if (command === "auth") return runAuth(context, args);
886
+ if (command === "config") return runConfig(context, args);
887
+ if (command === "file") return runFile(context, args);
888
+ if (command === "import") return runImport(context, args);
889
+ if (command === "project") return runProject(context, args);
890
+ if (command === "secret") return runSecret(context, args);
891
+ throw new CliError("usage_error", `Unknown command: ${command}`);
892
+ };
893
+ var runCli = async (argv) => {
894
+ const { flags, positionals } = parseFlags(argv);
895
+ const rawConfig = await readConfig();
896
+ const config = resolveConfig(flags, rawConfig);
897
+ const output = new Output(flags.json ? "json" : "human", flags.quiet);
898
+ const context = {
899
+ canPrompt: !flags.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY),
900
+ config,
901
+ cwd: process.cwd(),
902
+ flags,
903
+ output,
904
+ rawConfig
905
+ };
906
+ if (!positionals.length || flags.help) {
907
+ return { output, result: { data: { help: renderHelp(positionals[0]) }, message: renderHelp(positionals[0]) } };
908
+ }
909
+ return { output, result: await dispatch(context, positionals) };
910
+ };
911
+
912
+ // src/index.ts
913
+ var main = async () => {
914
+ try {
915
+ const { output, result } = await runCli(process.argv.slice(2));
916
+ if (process.argv.includes("--json")) {
917
+ output.json(result);
918
+ } else if (result.message) {
919
+ if (result.message.startsWith("Manage ") || result.message.includes("Usage:")) {
920
+ console.log(result.message);
921
+ } else {
922
+ output.success(result.message);
923
+ }
924
+ }
925
+ process.exit(EXIT_CODES.success);
926
+ } catch (error) {
927
+ const cliError = toCliError(error);
928
+ if (process.argv.includes("--json")) {
929
+ printJsonError(cliError.message, cliError.type);
930
+ } else if (isCliError(cliError)) {
931
+ console.error(cliError.message);
932
+ }
933
+ process.exit(cliError.exitCode);
934
+ }
935
+ };
936
+ void main();
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@dotlabs-hq/env-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for managing environment variables",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "env": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "url": "https://github.com/dotlab-hq/env-cli"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "scripts": {
24
+ "dev": "tsx src/index.ts",
25
+ "build": "tsup src/index.ts --format esm --platform node --target node20 --out-dir dist --clean",
26
+ "start": "node dist/index.js",
27
+ "typecheck": "tsc --noEmit",
28
+ "prepublishOnly": "npm run typecheck && npm run build"
29
+ },
30
+ "dependencies": {
31
+ "@clack/prompts": "^0.7.0",
32
+ "picocolors": "^1.0.1",
33
+ "zod": "^4.3.6"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.0.0",
37
+ "tsup": "^8.5.0",
38
+ "tsx": "^4.7.0",
39
+ "typescript": "^5.9.2"
40
+ }
41
+ }