@sentroy-co/client-sdk 2.8.0 → 2.12.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/AGENTS.md +97 -0
- package/README.md +34 -1
- package/bin/sentroy.js +4 -0
- package/dist/cli/dotenv.d.ts +35 -0
- package/dist/cli/dotenv.d.ts.map +1 -0
- package/dist/cli/dotenv.js +125 -0
- package/dist/cli/dotenv.js.map +1 -0
- package/dist/cli/env.d.ts +11 -0
- package/dist/cli/env.d.ts.map +1 -0
- package/dist/cli/env.js +331 -0
- package/dist/cli/env.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +105 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/vault/index.d.ts +60 -0
- package/dist/vault/index.d.ts.map +1 -1
- package/dist/vault/index.js +116 -0
- package/dist/vault/index.js.map +1 -1
- package/package.json +9 -3
- package/src/cli/dotenv.ts +146 -0
- package/src/cli/env.ts +402 -0
- package/src/cli/index.ts +122 -0
- package/src/vault/index.ts +160 -0
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
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sentroy` — CLI entry point.
|
|
3
|
+
*
|
|
4
|
+
* Subcommand router with no third-party deps. Today only `env` exists;
|
|
5
|
+
* future subcommands (`mail`, `media`, etc.) hook in here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cmdPush, cmdPull, cmdList, cmdDiff } from "./env"
|
|
9
|
+
|
|
10
|
+
const VERSION = "__VERSION__" // replaced at runtime via package.json read
|
|
11
|
+
|
|
12
|
+
interface SubCommand {
|
|
13
|
+
description: string
|
|
14
|
+
handler: (args: string[]) => Promise<void> | void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ENV_SUBCOMMANDS: Record<string, SubCommand> = {
|
|
18
|
+
push: {
|
|
19
|
+
description: "Push a local .env file to the vault (full sync if --delete-missing)",
|
|
20
|
+
handler: cmdPush,
|
|
21
|
+
},
|
|
22
|
+
pull: {
|
|
23
|
+
description: "Fetch the vault scope and write to a local .env file",
|
|
24
|
+
handler: cmdPull,
|
|
25
|
+
},
|
|
26
|
+
list: {
|
|
27
|
+
description: "Print every key in the vault scope (--values to include values, --public-only)",
|
|
28
|
+
handler: cmdList,
|
|
29
|
+
},
|
|
30
|
+
diff: {
|
|
31
|
+
description: "Show what would change if you pushed the local .env file",
|
|
32
|
+
handler: cmdDiff,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPackageVersion(): string {
|
|
37
|
+
if (VERSION !== "__VERSION__") return VERSION
|
|
38
|
+
// Walk up from this file: dist/cli/index.js → dist/cli → dist → package.
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const { version } = require("../../package.json") as { version: string }
|
|
42
|
+
return version
|
|
43
|
+
} catch {
|
|
44
|
+
return "unknown"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showHelp(): void {
|
|
49
|
+
const v = readPackageVersion()
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
`\nsentroy ${v} — Sentroy CLI\n\n` +
|
|
52
|
+
`USAGE\n` +
|
|
53
|
+
` sentroy <command> [args] [flags]\n\n` +
|
|
54
|
+
`COMMANDS\n` +
|
|
55
|
+
` env push [<file>] ${ENV_SUBCOMMANDS.push.description}\n` +
|
|
56
|
+
` env pull [<file>] ${ENV_SUBCOMMANDS.pull.description}\n` +
|
|
57
|
+
` env list ${ENV_SUBCOMMANDS.list.description}\n` +
|
|
58
|
+
` env diff [<file>] ${ENV_SUBCOMMANDS.diff.description}\n\n` +
|
|
59
|
+
`GLOBAL FLAGS\n` +
|
|
60
|
+
` --token=stk_env_... Vault token (default: $SENTROY_ENV_API_KEY)\n` +
|
|
61
|
+
` --url=https://... Sentroy core URL (default: $SENTROY_ENV_API_URL or https://sentroy.com)\n\n` +
|
|
62
|
+
`ENV PUSH FLAGS\n` +
|
|
63
|
+
` --delete-missing Remove vault keys not present in the local file (full sync)\n` +
|
|
64
|
+
` --dry-run Print the diff but do not write\n` +
|
|
65
|
+
` --yes Skip the delete-confirmation prompt (CI-friendly)\n\n` +
|
|
66
|
+
`ENV PULL FLAGS\n` +
|
|
67
|
+
` --force Overwrite the file if it already exists\n\n` +
|
|
68
|
+
`ENV LIST FLAGS\n` +
|
|
69
|
+
` --values Include values (default: keys only)\n` +
|
|
70
|
+
` --public-only Only variables marked public\n\n` +
|
|
71
|
+
`EXAMPLES\n` +
|
|
72
|
+
` sentroy env push .env.production --delete-missing\n` +
|
|
73
|
+
` sentroy env pull .env --force\n` +
|
|
74
|
+
` sentroy env diff .env.production --delete-missing\n` +
|
|
75
|
+
` sentroy env list --values --public-only\n\n` +
|
|
76
|
+
`Token scope (project + environment) is implicit — generate one in the\n` +
|
|
77
|
+
`Sentroy vault dashboard.\n\n`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main(): Promise<void> {
|
|
82
|
+
const argv = process.argv.slice(2)
|
|
83
|
+
if (
|
|
84
|
+
argv.length === 0 ||
|
|
85
|
+
argv[0] === "-h" ||
|
|
86
|
+
argv[0] === "--help" ||
|
|
87
|
+
argv[0] === "help"
|
|
88
|
+
) {
|
|
89
|
+
showHelp()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (argv[0] === "-v" || argv[0] === "--version") {
|
|
93
|
+
process.stdout.write(`${readPackageVersion()}\n`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cmd = argv[0]
|
|
98
|
+
if (cmd === "env") {
|
|
99
|
+
const sub = argv[1]
|
|
100
|
+
const handler = sub ? ENV_SUBCOMMANDS[sub] : undefined
|
|
101
|
+
if (!handler) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`unknown env subcommand: ${sub ?? "<missing>"}\n` +
|
|
104
|
+
`available: ${Object.keys(ENV_SUBCOMMANDS).join(", ")}\n`,
|
|
105
|
+
)
|
|
106
|
+
process.exit(1)
|
|
107
|
+
}
|
|
108
|
+
await handler.handler(argv.slice(2))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`unknown command: ${cmd}\nrun \`sentroy --help\` for usage.\n`,
|
|
114
|
+
)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((err: unknown) => {
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
120
|
+
process.stderr.write(`\n\x1b[31m✗\x1b[0m ${msg}\n`)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
})
|
package/src/vault/index.ts
CHANGED
|
@@ -179,6 +179,38 @@ export async function getEnvOrThrow(key: string): Promise<string> {
|
|
|
179
179
|
return v
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Migration helper — vault'tan oku, yoksa `process.env` fallback.
|
|
184
|
+
*
|
|
185
|
+
* Sentroy app'lerini kademeli olarak `process.env` → vault'a çevirirken
|
|
186
|
+
* "her ikisi de çalışsın" senaryosu için. Vault doldurulmamış / token
|
|
187
|
+
* eksik / fetch fail dönerse sessizce `process.env[key]`'e döner — eski
|
|
188
|
+
* deploy ile yeni kod bir arada çalışabilir.
|
|
189
|
+
*
|
|
190
|
+
* **Migration tamamlandıktan sonra** çağrı sitelerini `getEnv()` ya da
|
|
191
|
+
* `getEnvOrThrow()`'a çevir; fallback'i bırakmak silently process.env
|
|
192
|
+
* sızıntısı riskini taşır (kullanıcı vault'tan key'i sildi sansa bile
|
|
193
|
+
* eski process.env değeri etkili olur).
|
|
194
|
+
*
|
|
195
|
+
* Bootstrap path için (`SENTROY_ENV_API_KEY` set değil) doğrudan
|
|
196
|
+
* `process.env`'e döner — vault fetch denemez. Bu önemli: Sentroy app'i
|
|
197
|
+
* vault'sız boot edilebilir.
|
|
198
|
+
*/
|
|
199
|
+
export async function getEnvWithFallback(
|
|
200
|
+
key: string,
|
|
201
|
+
): Promise<string | undefined> {
|
|
202
|
+
// Token yoksa bypass — vault fetch denemeyelim, log spam etmeyelim.
|
|
203
|
+
const apiKey = resolvedApiKey ?? readEnv("SENTROY_ENV_API_KEY")
|
|
204
|
+
if (!apiKey) return readEnv(key)
|
|
205
|
+
try {
|
|
206
|
+
const v = await getEnv(key)
|
|
207
|
+
if (v !== undefined) return v
|
|
208
|
+
} catch {
|
|
209
|
+
// Fetch fail / network down / 401 → sessizce fallback
|
|
210
|
+
}
|
|
211
|
+
return readEnv(key)
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
/** Tüm env'leri map olarak döner (dump için kullanışlı). */
|
|
183
215
|
export async function getAllEnvs(): Promise<Record<string, string>> {
|
|
184
216
|
const c = await ensureCache()
|
|
@@ -196,3 +228,131 @@ export async function getPublicEnvs(): Promise<Record<string, string>> {
|
|
|
196
228
|
}
|
|
197
229
|
return out
|
|
198
230
|
}
|
|
231
|
+
|
|
232
|
+
// ── Webhook handler ─────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export interface VaultWebhookPayload {
|
|
235
|
+
event: "vault.variable.changed"
|
|
236
|
+
project: string
|
|
237
|
+
environment: string
|
|
238
|
+
action: "create" | "update" | "delete"
|
|
239
|
+
/** Etkilenen key'ler — bulk push'ta birden fazla. */
|
|
240
|
+
keys: string[]
|
|
241
|
+
/** Unix ms. */
|
|
242
|
+
timestamp: number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface CreateVaultWebhookHandlerOptions {
|
|
246
|
+
/**
|
|
247
|
+
* Sentroy vault dashboard'dan aldığın webhook secret (`whsec_...`).
|
|
248
|
+
* Receiver bu secret'la HMAC-SHA256 imzayı doğrular; hatalıysa 401 döner.
|
|
249
|
+
*/
|
|
250
|
+
secret: string
|
|
251
|
+
/**
|
|
252
|
+
* Imzayı doğruladıktan sonra çağrılır. Default davranış:
|
|
253
|
+
* `await refreshEnvCache()` — bir sonraki getEnv() taze değerleri çeker.
|
|
254
|
+
* Custom logic için override et (örn. tek bir key'i targeted invalidate).
|
|
255
|
+
*/
|
|
256
|
+
onChange?: (payload: VaultWebhookPayload) => Promise<void> | void
|
|
257
|
+
/**
|
|
258
|
+
* Replay attack'lere karşı body'nin timestamp'i ile şu an arasındaki
|
|
259
|
+
* maksimum tolerans (ms). Default 5 dk. Sıfır ise check kapalı.
|
|
260
|
+
*/
|
|
261
|
+
maxAgeMs?: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000
|
|
265
|
+
|
|
266
|
+
async function timingSafeEqualHex(a: string, b: string): Promise<boolean> {
|
|
267
|
+
if (a.length !== b.length) return false
|
|
268
|
+
let diff = 0
|
|
269
|
+
for (let i = 0; i < a.length; i++) {
|
|
270
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
271
|
+
}
|
|
272
|
+
return diff === 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function hmacSha256Hex(secret: string, body: string): Promise<string> {
|
|
276
|
+
// Web Crypto — Node 18+ + browser ikisi de destekler.
|
|
277
|
+
const encoder = new TextEncoder()
|
|
278
|
+
const key = await crypto.subtle.importKey(
|
|
279
|
+
"raw",
|
|
280
|
+
encoder.encode(secret),
|
|
281
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
282
|
+
false,
|
|
283
|
+
["sign"],
|
|
284
|
+
)
|
|
285
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body))
|
|
286
|
+
const bytes = new Uint8Array(sig)
|
|
287
|
+
let hex = ""
|
|
288
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0")
|
|
289
|
+
return hex
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bir Sentroy vault webhook receiver'ı için Request → Response handler
|
|
294
|
+
* üretir. Next.js App Router'da:
|
|
295
|
+
*
|
|
296
|
+
* // app/api/sentroy/vault-webhook/route.ts
|
|
297
|
+
* import { createVaultWebhookHandler } from "@sentroy-co/client-sdk/vault"
|
|
298
|
+
* export const POST = createVaultWebhookHandler({
|
|
299
|
+
* secret: process.env.SENTROY_VAULT_WEBHOOK_SECRET!,
|
|
300
|
+
* })
|
|
301
|
+
*
|
|
302
|
+
* Default davranış: imza doğruysa cache'i invalidate eder ve 200 döner.
|
|
303
|
+
* Hatalı/eksik imza → 401, eski timestamp → 401, body parse hatası → 400.
|
|
304
|
+
*/
|
|
305
|
+
export function createVaultWebhookHandler(
|
|
306
|
+
options: CreateVaultWebhookHandlerOptions,
|
|
307
|
+
): (request: Request) => Promise<Response> {
|
|
308
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS
|
|
309
|
+
return async (request: Request) => {
|
|
310
|
+
const sigHeader = request.headers.get("x-sentroy-signature") || ""
|
|
311
|
+
const match = sigHeader.match(/^sha256=([a-f0-9]+)$/i)
|
|
312
|
+
if (!match) {
|
|
313
|
+
return new Response("missing or malformed X-Sentroy-Signature header", {
|
|
314
|
+
status: 401,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
const providedSig = match[1].toLowerCase()
|
|
318
|
+
const body = await request.text()
|
|
319
|
+
const expected = await hmacSha256Hex(options.secret, body)
|
|
320
|
+
if (!(await timingSafeEqualHex(providedSig, expected))) {
|
|
321
|
+
return new Response("signature mismatch", { status: 401 })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let payload: VaultWebhookPayload
|
|
325
|
+
try {
|
|
326
|
+
payload = JSON.parse(body) as VaultWebhookPayload
|
|
327
|
+
} catch {
|
|
328
|
+
return new Response("invalid JSON body", { status: 400 })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (maxAgeMs > 0) {
|
|
332
|
+
const age = Date.now() - (payload.timestamp ?? 0)
|
|
333
|
+
if (!Number.isFinite(age) || age < 0 || age > maxAgeMs) {
|
|
334
|
+
return new Response("payload timestamp outside acceptable window", {
|
|
335
|
+
status: 401,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (options.onChange) {
|
|
342
|
+
await options.onChange(payload)
|
|
343
|
+
} else {
|
|
344
|
+
await refreshEnvCache()
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return new Response(
|
|
348
|
+
`handler error: ${err instanceof Error ? err.message : String(err)}`,
|
|
349
|
+
{ status: 500 },
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
354
|
+
status: 200,
|
|
355
|
+
headers: { "Content-Type": "application/json" },
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|