@sentroy-co/client-sdk 2.8.0 → 2.9.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.
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * `sentroy` — CLI entry point.
4
+ *
5
+ * Subcommand router with no third-party deps. Today only `env` exists;
6
+ * future subcommands (`mail`, `media`, etc.) hook in here.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const env_1 = require("./env");
10
+ const VERSION = "__VERSION__"; // replaced at runtime via package.json read
11
+ const ENV_SUBCOMMANDS = {
12
+ push: {
13
+ description: "Push a local .env file to the vault (full sync if --delete-missing)",
14
+ handler: env_1.cmdPush,
15
+ },
16
+ pull: {
17
+ description: "Fetch the vault scope and write to a local .env file",
18
+ handler: env_1.cmdPull,
19
+ },
20
+ list: {
21
+ description: "Print every key in the vault scope (--values to include values, --public-only)",
22
+ handler: env_1.cmdList,
23
+ },
24
+ diff: {
25
+ description: "Show what would change if you pushed the local .env file",
26
+ handler: env_1.cmdDiff,
27
+ },
28
+ };
29
+ function readPackageVersion() {
30
+ if (VERSION !== "__VERSION__")
31
+ return VERSION;
32
+ // Walk up from this file: dist/cli/index.js → dist/cli → dist → package.
33
+ try {
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const { version } = require("../../package.json");
36
+ return version;
37
+ }
38
+ catch {
39
+ return "unknown";
40
+ }
41
+ }
42
+ function showHelp() {
43
+ const v = readPackageVersion();
44
+ process.stdout.write(`\nsentroy ${v} — Sentroy CLI\n\n` +
45
+ `USAGE\n` +
46
+ ` sentroy <command> [args] [flags]\n\n` +
47
+ `COMMANDS\n` +
48
+ ` env push [<file>] ${ENV_SUBCOMMANDS.push.description}\n` +
49
+ ` env pull [<file>] ${ENV_SUBCOMMANDS.pull.description}\n` +
50
+ ` env list ${ENV_SUBCOMMANDS.list.description}\n` +
51
+ ` env diff [<file>] ${ENV_SUBCOMMANDS.diff.description}\n\n` +
52
+ `GLOBAL FLAGS\n` +
53
+ ` --token=stk_env_... Vault token (default: $SENTROY_ENV_API_KEY)\n` +
54
+ ` --url=https://... Sentroy core URL (default: $SENTROY_ENV_API_URL or https://sentroy.com)\n\n` +
55
+ `ENV PUSH FLAGS\n` +
56
+ ` --delete-missing Remove vault keys not present in the local file (full sync)\n` +
57
+ ` --dry-run Print the diff but do not write\n` +
58
+ ` --yes Skip the delete-confirmation prompt (CI-friendly)\n\n` +
59
+ `ENV PULL FLAGS\n` +
60
+ ` --force Overwrite the file if it already exists\n\n` +
61
+ `ENV LIST FLAGS\n` +
62
+ ` --values Include values (default: keys only)\n` +
63
+ ` --public-only Only variables marked public\n\n` +
64
+ `EXAMPLES\n` +
65
+ ` sentroy env push .env.production --delete-missing\n` +
66
+ ` sentroy env pull .env --force\n` +
67
+ ` sentroy env diff .env.production --delete-missing\n` +
68
+ ` sentroy env list --values --public-only\n\n` +
69
+ `Token scope (project + environment) is implicit — generate one in the\n` +
70
+ `Sentroy vault dashboard.\n\n`);
71
+ }
72
+ async function main() {
73
+ const argv = process.argv.slice(2);
74
+ if (argv.length === 0 ||
75
+ argv[0] === "-h" ||
76
+ argv[0] === "--help" ||
77
+ argv[0] === "help") {
78
+ showHelp();
79
+ return;
80
+ }
81
+ if (argv[0] === "-v" || argv[0] === "--version") {
82
+ process.stdout.write(`${readPackageVersion()}\n`);
83
+ return;
84
+ }
85
+ const cmd = argv[0];
86
+ if (cmd === "env") {
87
+ const sub = argv[1];
88
+ const handler = sub ? ENV_SUBCOMMANDS[sub] : undefined;
89
+ if (!handler) {
90
+ process.stderr.write(`unknown env subcommand: ${sub ?? "<missing>"}\n` +
91
+ `available: ${Object.keys(ENV_SUBCOMMANDS).join(", ")}\n`);
92
+ process.exit(1);
93
+ }
94
+ await handler.handler(argv.slice(2));
95
+ return;
96
+ }
97
+ process.stderr.write(`unknown command: ${cmd}\nrun \`sentroy --help\` for usage.\n`);
98
+ process.exit(1);
99
+ }
100
+ main().catch((err) => {
101
+ const msg = err instanceof Error ? err.message : String(err);
102
+ process.stderr.write(`\n\x1b[31m✗\x1b[0m ${msg}\n`);
103
+ process.exit(1);
104
+ });
105
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@sentroy-co/client-sdk",
3
- "version": "2.8.0",
4
- "description": "TypeScript SDK for the Sentroy platform — mail, storage, env vault + React components.",
3
+ "version": "2.9.0",
4
+ "description": "TypeScript SDK + CLI for the Sentroy platform — mail, storage, env vault + React components.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "sentroy": "./bin/sentroy.js"
9
+ },
7
10
  "exports": {
8
11
  ".": {
9
12
  "types": "./dist/index.d.ts",
@@ -39,6 +42,7 @@
39
42
  "files": [
40
43
  "dist",
41
44
  "src",
45
+ "bin",
42
46
  "AGENTS.md"
43
47
  ],
44
48
  "scripts": {
@@ -57,7 +61,9 @@
57
61
  "env",
58
62
  "vault",
59
63
  "secrets",
60
- "config"
64
+ "config",
65
+ "cli",
66
+ "dotenv"
61
67
  ],
62
68
  "repository": {
63
69
  "type": "git",
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Minimal .env parser + serializer used by the CLI.
3
+ *
4
+ * Format conventions (mirrors apps/core/components/admin/env-vault-content.tsx
5
+ * developer mode):
6
+ * - blank line resets pending description/public flag
7
+ * - `# @public` on its own line marks the next variable as browser-readable
8
+ * - `# any other text` becomes the next variable's description
9
+ * - `KEY=value` (unquoted)
10
+ * - `KEY="value with spaces"` (double-quoted; supports \n, \", \\ escapes)
11
+ * - `KEY='single quotes'` (single-quoted; literal)
12
+ * - `export KEY=value` (export prefix stripped)
13
+ *
14
+ * Anything else is reported as a parse error with the offending line number.
15
+ */
16
+
17
+ export interface DotenvEntry {
18
+ key: string
19
+ value: string
20
+ public: boolean
21
+ description: string | null
22
+ }
23
+
24
+ export interface DotenvParseResult {
25
+ entries: DotenvEntry[]
26
+ errors: { line: number; message: string }[]
27
+ }
28
+
29
+ const KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/
30
+
31
+ export function parseDotenv(text: string): DotenvParseResult {
32
+ const lines = text.split(/\r?\n/)
33
+ const entries: DotenvEntry[] = []
34
+ const errors: DotenvParseResult["errors"] = []
35
+ const seen = new Set<string>()
36
+
37
+ let pendingDescription: string[] = []
38
+ let pendingPublic = false
39
+
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const raw = lines[i] ?? ""
42
+ const line = raw.trim()
43
+
44
+ if (line === "") {
45
+ pendingDescription = []
46
+ pendingPublic = false
47
+ continue
48
+ }
49
+
50
+ if (line.startsWith("#")) {
51
+ const body = line.slice(1).trim()
52
+ if (body === "@public") {
53
+ pendingPublic = true
54
+ } else if (body) {
55
+ pendingDescription.push(body)
56
+ }
57
+ continue
58
+ }
59
+
60
+ const work = line.startsWith("export ") ? line.slice(7).trimStart() : line
61
+ const eq = work.indexOf("=")
62
+ if (eq <= 0) {
63
+ errors.push({
64
+ line: i + 1,
65
+ message: "invalid syntax (expected KEY=value)",
66
+ })
67
+ pendingDescription = []
68
+ pendingPublic = false
69
+ continue
70
+ }
71
+
72
+ const key = work.slice(0, eq).trim()
73
+ let value = work.slice(eq + 1)
74
+
75
+ if (!KEY_PATTERN.test(key)) {
76
+ errors.push({
77
+ line: i + 1,
78
+ message: "key must match [A-Z_][A-Z0-9_]*",
79
+ })
80
+ pendingDescription = []
81
+ pendingPublic = false
82
+ continue
83
+ }
84
+
85
+ const trimmed = value.trim()
86
+ if (
87
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
88
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
89
+ ) {
90
+ value = trimmed.slice(1, -1)
91
+ if (trimmed.startsWith('"')) {
92
+ value = value
93
+ .replace(/\\n/g, "\n")
94
+ .replace(/\\"/g, '"')
95
+ .replace(/\\\\/g, "\\")
96
+ }
97
+ } else {
98
+ value = trimmed
99
+ }
100
+
101
+ if (seen.has(key)) {
102
+ errors.push({ line: i + 1, message: `duplicate key ${key}` })
103
+ pendingDescription = []
104
+ pendingPublic = false
105
+ continue
106
+ }
107
+ seen.add(key)
108
+
109
+ entries.push({
110
+ key,
111
+ value,
112
+ public: pendingPublic,
113
+ description:
114
+ pendingDescription.length > 0 ? pendingDescription.join(" ") : null,
115
+ })
116
+
117
+ pendingDescription = []
118
+ pendingPublic = false
119
+ }
120
+
121
+ return { entries, errors }
122
+ }
123
+
124
+ /**
125
+ * Inverse of parseDotenv — emit a .env document that round-trips through it.
126
+ * Quotes values that contain whitespace or shell-special characters.
127
+ */
128
+ export function serializeDotenv(entries: DotenvEntry[]): string {
129
+ const blocks: string[] = []
130
+ for (const e of entries) {
131
+ const parts: string[] = []
132
+ if (e.description) parts.push(`# ${e.description}`)
133
+ if (e.public) parts.push("# @public")
134
+ const value = e.value
135
+ const needsQuote = /[\s"'#$`\\]/.test(value) || value === ""
136
+ const escaped = needsQuote
137
+ ? `"${value
138
+ .replace(/\\/g, "\\\\")
139
+ .replace(/"/g, '\\"')
140
+ .replace(/\n/g, "\\n")}"`
141
+ : value
142
+ parts.push(`${e.key}=${escaped}`)
143
+ blocks.push(parts.join("\n"))
144
+ }
145
+ return blocks.join("\n\n") + (blocks.length > 0 ? "\n" : "")
146
+ }
package/src/cli/env.ts ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * `sentroy env <subcommand>` — vault sync from a local .env file.
3
+ *
4
+ * The token's (project, environment) scope is implicit; the CLI never
5
+ * asks for either since it can't change them.
6
+ */
7
+
8
+ import * as fs from "fs"
9
+ import * as path from "path"
10
+ import * as readline from "readline"
11
+ import {
12
+ parseDotenv,
13
+ serializeDotenv,
14
+ type DotenvEntry,
15
+ } from "./dotenv"
16
+
17
+ const DEFAULT_FILE = ".env"
18
+ const DEFAULT_BASE_URL = "https://sentroy.com"
19
+
20
+ interface SharedOpts {
21
+ token: string
22
+ baseUrl: string
23
+ }
24
+
25
+ interface RemoteVariable {
26
+ key: string
27
+ value: string
28
+ type: string
29
+ public: boolean
30
+ }
31
+
32
+ interface PushResponse {
33
+ project: string
34
+ environment: string
35
+ added: number
36
+ updated: number
37
+ unchanged: number
38
+ deleted: number
39
+ total: number
40
+ }
41
+
42
+ interface FetchResponse {
43
+ project: string
44
+ environment: string
45
+ variables: RemoteVariable[]
46
+ }
47
+
48
+ // ── ANSI helpers (no deps) ───────────────────────────────────────────────
49
+ const supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb"
50
+ const c = {
51
+ bold: (s: string) => (supportsColor ? `\x1b[1m${s}\x1b[0m` : s),
52
+ dim: (s: string) => (supportsColor ? `\x1b[2m${s}\x1b[0m` : s),
53
+ red: (s: string) => (supportsColor ? `\x1b[31m${s}\x1b[0m` : s),
54
+ green: (s: string) => (supportsColor ? `\x1b[32m${s}\x1b[0m` : s),
55
+ yellow: (s: string) => (supportsColor ? `\x1b[33m${s}\x1b[0m` : s),
56
+ cyan: (s: string) => (supportsColor ? `\x1b[36m${s}\x1b[0m` : s),
57
+ magenta: (s: string) => (supportsColor ? `\x1b[35m${s}\x1b[0m` : s),
58
+ }
59
+
60
+ function fail(msg: string): never {
61
+ process.stderr.write(`${c.red("✗")} ${msg}\n`)
62
+ process.exit(1)
63
+ }
64
+
65
+ function info(msg: string): void {
66
+ process.stdout.write(`${c.cyan("→")} ${msg}\n`)
67
+ }
68
+
69
+ function ok(msg: string): void {
70
+ process.stdout.write(`${c.green("✓")} ${msg}\n`)
71
+ }
72
+
73
+ function warn(msg: string): void {
74
+ process.stdout.write(`${c.yellow("⚠")} ${msg}\n`)
75
+ }
76
+
77
+ function resolveSharedOpts(rest: Record<string, string | boolean>): SharedOpts {
78
+ const token =
79
+ (typeof rest.token === "string" ? rest.token : null) ??
80
+ process.env.SENTROY_ENV_API_KEY ??
81
+ null
82
+ if (!token) {
83
+ fail(
84
+ "no token. Pass --token=stk_env_... or set SENTROY_ENV_API_KEY in your environment.",
85
+ )
86
+ }
87
+ const baseUrl =
88
+ (typeof rest.url === "string" ? rest.url : null) ??
89
+ process.env.SENTROY_ENV_API_URL ??
90
+ DEFAULT_BASE_URL
91
+ return { token, baseUrl: baseUrl.replace(/\/+$/, "") }
92
+ }
93
+
94
+ async function http<T>(
95
+ shared: SharedOpts,
96
+ path: string,
97
+ init?: RequestInit,
98
+ ): Promise<T> {
99
+ const res = await fetch(`${shared.baseUrl}${path}`, {
100
+ ...init,
101
+ headers: {
102
+ Authorization: `Bearer ${shared.token}`,
103
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
104
+ ...(init?.headers ?? {}),
105
+ },
106
+ })
107
+ const text = await res.text()
108
+ let body: unknown
109
+ try {
110
+ body = text ? JSON.parse(text) : null
111
+ } catch {
112
+ body = text
113
+ }
114
+ if (!res.ok) {
115
+ const errMsg =
116
+ body && typeof body === "object" && "error" in body
117
+ ? String((body as { error: unknown }).error)
118
+ : `HTTP ${res.status}`
119
+ throw new Error(`${path}: ${errMsg}`)
120
+ }
121
+ return (body as { data?: T })?.data as T
122
+ }
123
+
124
+ function readFileOrFail(file: string): string {
125
+ if (!fs.existsSync(file)) {
126
+ fail(`file not found: ${file}`)
127
+ }
128
+ return fs.readFileSync(file, "utf8")
129
+ }
130
+
131
+ interface Diff {
132
+ added: DotenvEntry[]
133
+ updated: { entry: DotenvEntry; remote: RemoteVariable }[]
134
+ unchanged: { entry: DotenvEntry; remote: RemoteVariable }[]
135
+ deleted: RemoteVariable[]
136
+ }
137
+
138
+ function computeDiff(
139
+ local: DotenvEntry[],
140
+ remote: RemoteVariable[],
141
+ ): Diff {
142
+ const remoteByKey = new Map(remote.map((v) => [v.key, v]))
143
+ const added: DotenvEntry[] = []
144
+ const updated: Diff["updated"] = []
145
+ const unchanged: Diff["unchanged"] = []
146
+ for (const e of local) {
147
+ const r = remoteByKey.get(e.key)
148
+ if (!r) {
149
+ added.push(e)
150
+ continue
151
+ }
152
+ if (r.value !== e.value || r.public !== e.public) {
153
+ updated.push({ entry: e, remote: r })
154
+ } else {
155
+ unchanged.push({ entry: e, remote: r })
156
+ }
157
+ }
158
+ const localKeys = new Set(local.map((e) => e.key))
159
+ const deleted = remote.filter((v) => !localKeys.has(v.key))
160
+ return { added, updated, unchanged, deleted }
161
+ }
162
+
163
+ function printDiff(diff: Diff, deleteMissing: boolean): void {
164
+ for (const e of diff.added) {
165
+ process.stdout.write(` ${c.green("+")} ${e.key}\n`)
166
+ }
167
+ for (const u of diff.updated) {
168
+ process.stdout.write(` ${c.yellow("~")} ${u.entry.key}\n`)
169
+ }
170
+ if (deleteMissing) {
171
+ for (const d of diff.deleted) {
172
+ process.stdout.write(` ${c.red("-")} ${d.key}\n`)
173
+ }
174
+ } else if (diff.deleted.length > 0) {
175
+ process.stdout.write(
176
+ ` ${c.dim(`(${diff.deleted.length} key(s) only in vault, kept — pass --delete-missing to remove)`)}\n`,
177
+ )
178
+ }
179
+ }
180
+
181
+ async function confirm(question: string): Promise<boolean> {
182
+ if (!process.stdin.isTTY) return false
183
+ const rl = readline.createInterface({
184
+ input: process.stdin,
185
+ output: process.stdout,
186
+ })
187
+ const answer = await new Promise<string>((resolve) => {
188
+ rl.question(`${question} [y/N] `, (ans) => {
189
+ rl.close()
190
+ resolve(ans.trim().toLowerCase())
191
+ })
192
+ })
193
+ return answer === "y" || answer === "yes"
194
+ }
195
+
196
+ // ── push ─────────────────────────────────────────────────────────────────
197
+
198
+ export async function cmdPush(args: string[]): Promise<void> {
199
+ const { positional, flags } = parseFlags(args)
200
+ const file = positional[0] ?? DEFAULT_FILE
201
+ const dryRun = !!flags["dry-run"]
202
+ const deleteMissing = !!flags["delete-missing"]
203
+ const shared = resolveSharedOpts(flags)
204
+
205
+ const text = readFileOrFail(path.resolve(process.cwd(), file))
206
+ const parsed = parseDotenv(text)
207
+ if (parsed.errors.length > 0) {
208
+ process.stderr.write(`${c.red("✗")} parse errors in ${file}:\n`)
209
+ for (const err of parsed.errors) {
210
+ process.stderr.write(` line ${err.line}: ${err.message}\n`)
211
+ }
212
+ process.exit(1)
213
+ }
214
+
215
+ info(`fetching current vault snapshot…`)
216
+ const remote = await http<FetchResponse>(shared, "/api/env-vault/fetch")
217
+ const diff = computeDiff(parsed.entries, remote.variables)
218
+
219
+ process.stdout.write(
220
+ `\n${c.bold(remote.project)} ${c.dim("/")} ${c.magenta(remote.environment)} ${c.dim(
221
+ `(${file} → ${shared.baseUrl})`,
222
+ )}\n`,
223
+ )
224
+ printDiff(diff, deleteMissing)
225
+ process.stdout.write(
226
+ `\n ${c.dim("summary:")} ${diff.added.length} new · ${diff.updated.length} updated · ${diff.unchanged.length} unchanged${
227
+ deleteMissing ? ` · ${diff.deleted.length} to delete` : ""
228
+ }\n\n`,
229
+ )
230
+
231
+ if (
232
+ diff.added.length === 0 &&
233
+ diff.updated.length === 0 &&
234
+ (!deleteMissing || diff.deleted.length === 0)
235
+ ) {
236
+ ok("nothing to push.")
237
+ return
238
+ }
239
+
240
+ if (dryRun) {
241
+ info("dry run — no changes pushed.")
242
+ return
243
+ }
244
+
245
+ if (deleteMissing && diff.deleted.length > 0) {
246
+ if (flags.yes) {
247
+ info(`--yes provided, skipping confirmation for ${diff.deleted.length} delete(s).`)
248
+ } else if (!process.stdin.isTTY) {
249
+ fail(
250
+ `refusing to delete ${diff.deleted.length} key(s) without --yes in a non-interactive shell.`,
251
+ )
252
+ } else {
253
+ const proceed = await confirm(
254
+ `${c.yellow("⚠")} this will delete ${diff.deleted.length} key(s) from the vault. continue?`,
255
+ )
256
+ if (!proceed) {
257
+ warn("aborted.")
258
+ return
259
+ }
260
+ }
261
+ }
262
+
263
+ const result = await http<PushResponse>(shared, "/api/env-vault/push", {
264
+ method: "POST",
265
+ body: JSON.stringify({
266
+ entries: parsed.entries.map((e) => ({
267
+ key: e.key,
268
+ value: e.value,
269
+ public: e.public,
270
+ description: e.description,
271
+ })),
272
+ deleteMissing,
273
+ }),
274
+ })
275
+ ok(
276
+ `${result.added} added · ${result.updated} updated · ${result.unchanged} unchanged · ${result.deleted} deleted`,
277
+ )
278
+ }
279
+
280
+ // ── pull ─────────────────────────────────────────────────────────────────
281
+
282
+ export async function cmdPull(args: string[]): Promise<void> {
283
+ const { positional, flags } = parseFlags(args)
284
+ const file = positional[0] ?? DEFAULT_FILE
285
+ const force = !!flags.force
286
+ const shared = resolveSharedOpts(flags)
287
+
288
+ const target = path.resolve(process.cwd(), file)
289
+ if (fs.existsSync(target) && !force) {
290
+ fail(`${file} already exists. Pass --force to overwrite.`)
291
+ }
292
+
293
+ info(`fetching from ${shared.baseUrl}…`)
294
+ const remote = await http<FetchResponse>(shared, "/api/env-vault/fetch")
295
+ const entries: DotenvEntry[] = remote.variables.map((v) => ({
296
+ key: v.key,
297
+ value: v.value,
298
+ public: v.public,
299
+ description: null,
300
+ }))
301
+ const text = serializeDotenv(entries)
302
+ fs.writeFileSync(target, text, "utf8")
303
+ ok(
304
+ `wrote ${entries.length} variable(s) to ${file} (${remote.project}/${remote.environment})`,
305
+ )
306
+ }
307
+
308
+ // ── list ─────────────────────────────────────────────────────────────────
309
+
310
+ export async function cmdList(args: string[]): Promise<void> {
311
+ const { flags } = parseFlags(args)
312
+ const showValues = !!flags.values
313
+ const publicOnly = !!flags["public-only"]
314
+ const shared = resolveSharedOpts(flags)
315
+
316
+ const endpoint = publicOnly ? "/api/env-vault/public" : "/api/env-vault/fetch"
317
+ const remote = await http<FetchResponse>(shared, endpoint)
318
+ process.stdout.write(
319
+ `${c.bold(remote.project)} ${c.dim("/")} ${c.magenta(remote.environment)} ${c.dim(`(${remote.variables.length} variable(s))`)}\n`,
320
+ )
321
+ for (const v of remote.variables) {
322
+ const tag = v.public ? c.dim(" [public]") : ""
323
+ if (showValues) {
324
+ process.stdout.write(` ${c.cyan(v.key)}=${v.value}${tag}\n`)
325
+ } else {
326
+ process.stdout.write(` ${c.cyan(v.key)}${tag}\n`)
327
+ }
328
+ }
329
+ }
330
+
331
+ // ── diff ─────────────────────────────────────────────────────────────────
332
+
333
+ export async function cmdDiff(args: string[]): Promise<void> {
334
+ const { positional, flags } = parseFlags(args)
335
+ const file = positional[0] ?? DEFAULT_FILE
336
+ const deleteMissing = !!flags["delete-missing"]
337
+ const shared = resolveSharedOpts(flags)
338
+
339
+ const text = readFileOrFail(path.resolve(process.cwd(), file))
340
+ const parsed = parseDotenv(text)
341
+ if (parsed.errors.length > 0) {
342
+ process.stderr.write(`${c.red("✗")} parse errors in ${file}:\n`)
343
+ for (const err of parsed.errors) {
344
+ process.stderr.write(` line ${err.line}: ${err.message}\n`)
345
+ }
346
+ process.exit(1)
347
+ }
348
+ const remote = await http<FetchResponse>(shared, "/api/env-vault/fetch")
349
+ const diff = computeDiff(parsed.entries, remote.variables)
350
+
351
+ process.stdout.write(
352
+ `${c.bold(remote.project)} ${c.dim("/")} ${c.magenta(remote.environment)} ${c.dim(`(${file} vs vault)`)}\n`,
353
+ )
354
+ printDiff(diff, deleteMissing)
355
+ process.stdout.write(
356
+ `\n ${c.dim("summary:")} ${diff.added.length} new · ${diff.updated.length} updated · ${diff.unchanged.length} unchanged · ${diff.deleted.length} only in vault\n`,
357
+ )
358
+ }
359
+
360
+ // ── flag parser ──────────────────────────────────────────────────────────
361
+
362
+ interface ParsedArgs {
363
+ positional: string[]
364
+ flags: Record<string, string | boolean>
365
+ }
366
+
367
+ /**
368
+ * Flags that take a value as a separate token (`--flag value`). All other
369
+ * `--flag` tokens are booleans. The `--flag=value` form always works
370
+ * regardless of this list. Keeping this explicit avoids the classic argv
371
+ * bug where `--dry-run /path/to/file` swallows the positional.
372
+ */
373
+ const VALUE_FLAGS = new Set(["token", "url"])
374
+
375
+ function parseFlags(args: string[]): ParsedArgs {
376
+ const positional: string[] = []
377
+ const flags: Record<string, string | boolean> = {}
378
+ for (let i = 0; i < args.length; i++) {
379
+ const a = args[i]
380
+ if (!a.startsWith("--")) {
381
+ positional.push(a)
382
+ continue
383
+ }
384
+ const body = a.slice(2)
385
+ const eq = body.indexOf("=")
386
+ if (eq >= 0) {
387
+ flags[body.slice(0, eq)] = body.slice(eq + 1)
388
+ continue
389
+ }
390
+ if (VALUE_FLAGS.has(body)) {
391
+ const next = args[i + 1]
392
+ if (next === undefined || next.startsWith("--")) {
393
+ fail(`flag --${body} requires a value (use --${body}=value or --${body} value)`)
394
+ }
395
+ flags[body] = next
396
+ i++
397
+ } else {
398
+ flags[body] = true
399
+ }
400
+ }
401
+ return { positional, flags }
402
+ }