@sentroy-co/client-sdk 2.13.8 → 2.14.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 (59) hide show
  1. package/README.md +24 -5
  2. package/dist/auth/admin/index.d.ts +111 -10
  3. package/dist/auth/admin/index.d.ts.map +1 -1
  4. package/dist/auth/admin/index.js +125 -20
  5. package/dist/auth/admin/index.js.map +1 -1
  6. package/dist/auth/client.d.ts +127 -1
  7. package/dist/auth/client.d.ts.map +1 -1
  8. package/dist/auth/client.js +361 -3
  9. package/dist/auth/client.js.map +1 -1
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js.map +1 -1
  13. package/dist/auth/react/index.d.ts +63 -4
  14. package/dist/auth/react/index.d.ts.map +1 -1
  15. package/dist/auth/react/index.js +180 -1
  16. package/dist/auth/react/index.js.map +1 -1
  17. package/dist/auth/types.d.ts +55 -0
  18. package/dist/auth/types.d.ts.map +1 -1
  19. package/dist/cli/ai.d.ts +35 -0
  20. package/dist/cli/ai.d.ts.map +1 -0
  21. package/dist/cli/ai.js +399 -0
  22. package/dist/cli/ai.js.map +1 -0
  23. package/dist/cli/args.d.ts +62 -0
  24. package/dist/cli/args.d.ts.map +1 -0
  25. package/dist/cli/args.js +199 -0
  26. package/dist/cli/args.js.map +1 -0
  27. package/dist/cli/env.d.ts.map +1 -1
  28. package/dist/cli/env.js +8 -2
  29. package/dist/cli/env.js.map +1 -1
  30. package/dist/cli/format.d.ts +37 -0
  31. package/dist/cli/format.d.ts.map +1 -0
  32. package/dist/cli/format.js +129 -0
  33. package/dist/cli/format.js.map +1 -0
  34. package/dist/cli/index.d.ts +8 -2
  35. package/dist/cli/index.d.ts.map +1 -1
  36. package/dist/cli/index.js +128 -25
  37. package/dist/cli/index.js.map +1 -1
  38. package/dist/cli/mail.d.ts +25 -0
  39. package/dist/cli/mail.d.ts.map +1 -0
  40. package/dist/cli/mail.js +253 -0
  41. package/dist/cli/mail.js.map +1 -0
  42. package/dist/cli/storage.d.ts +28 -0
  43. package/dist/cli/storage.d.ts.map +1 -0
  44. package/dist/cli/storage.js +189 -0
  45. package/dist/cli/storage.js.map +1 -0
  46. package/package.json +8 -2
  47. package/skill/SKILL.md +542 -0
  48. package/src/auth/admin/index.ts +227 -31
  49. package/src/auth/client.ts +438 -4
  50. package/src/auth/index.ts +9 -0
  51. package/src/auth/react/index.tsx +255 -4
  52. package/src/auth/types.ts +66 -0
  53. package/src/cli/ai.ts +440 -0
  54. package/src/cli/args.ts +225 -0
  55. package/src/cli/env.ts +10 -2
  56. package/src/cli/format.ts +147 -0
  57. package/src/cli/index.ts +147 -25
  58. package/src/cli/mail.ts +363 -0
  59. package/src/cli/storage.ts +307 -0
package/src/cli/ai.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * `sentroy ai install` — install the Sentroy SDK skill into AI coding
3
+ * assistants (Claude Code, Cursor, Windsurf) and/or a universal
4
+ * AGENTS.md file at the project root.
5
+ *
6
+ * Detection rules (autodetect mode, no explicit target flags):
7
+ * - `.claude/` dir OR `CLAUDE.md` file present → install Claude target
8
+ * - `.cursor/` dir present → install Cursor target
9
+ * - `.windsurfrules` file present → install Windsurf target
10
+ * - none of the above → fall back to AGENTS.md
11
+ *
12
+ * Explicit flags override detection:
13
+ * --claude / --cursor / --windsurf / --agents → install only those
14
+ * --all → install every target
15
+ * --no-agents → suppress AGENTS.md fallback
16
+ *
17
+ * Re-install / upgrade:
18
+ * Each merge-capable target wraps its body in a versioned sentinel block
19
+ * so future installs can find and replace it in-place. --upgrade refuses
20
+ * to write when the installed version already matches the bundled one.
21
+ *
22
+ * Dry-run:
23
+ * --check prints the plan (created / updated / unchanged / skipped) and
24
+ * exits without touching the filesystem.
25
+ *
26
+ * Source override:
27
+ * --source <path> loads SKILL.md from a local file instead of the
28
+ * bundled `<package>/skill/SKILL.md` shipped with the npm package.
29
+ */
30
+
31
+ import * as fs from "fs"
32
+ import * as path from "path"
33
+ import { parseFlags, fail, info, ok, warn, c } from "./args"
34
+
35
+ // ── Constants ────────────────────────────────────────────────────────────
36
+
37
+ const SENTINEL_BEGIN = "<!-- sentroy-skill-begin"
38
+ const SENTINEL_END = "<!-- sentroy-skill-end -->"
39
+
40
+ type Target = "claude" | "cursor" | "windsurf" | "agents"
41
+
42
+ interface BundledSkill {
43
+ raw: string
44
+ version: string
45
+ }
46
+
47
+ interface WriteResult {
48
+ status: "created" | "updated" | "unchanged" | "skipped"
49
+ path: string
50
+ note?: string
51
+ }
52
+
53
+ // ── Bundled skill loader ─────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Load the canonical SKILL.md. By default reads from the package's
57
+ * `skill/SKILL.md` (sibling of `dist/`). With --source, reads any
58
+ * local file the caller points at.
59
+ *
60
+ * Version resolution priority:
61
+ * 1. `version:` field in YAML frontmatter (preferred — single source of truth)
62
+ * 2. footer comment `<!-- skill-version: x.y.z -->`
63
+ * 3. package.json version of the SDK (fallback — always present)
64
+ */
65
+ function readBundledSkill(sourceFlag?: string): BundledSkill {
66
+ const resolved = sourceFlag
67
+ ? path.resolve(process.cwd(), sourceFlag)
68
+ : path.join(__dirname, "..", "..", "skill", "SKILL.md")
69
+
70
+ if (!fs.existsSync(resolved)) {
71
+ fail(
72
+ sourceFlag
73
+ ? `--source path not found: ${resolved}`
74
+ : `bundled SKILL.md missing at ${resolved}. Reinstall @sentroy-co/client-sdk.`,
75
+ )
76
+ }
77
+ const raw = fs.readFileSync(resolved, "utf8")
78
+ const version = parseSkillVersion(raw) ?? readPackageVersion()
79
+ return { raw, version }
80
+ }
81
+
82
+ function parseSkillVersion(raw: string): string | null {
83
+ // Try frontmatter first.
84
+ const fm = extractFrontmatter(raw)
85
+ if (fm) {
86
+ const m = fm.match(/^\s*version\s*:\s*['"]?([^'"\n]+)['"]?\s*$/m)
87
+ if (m) return m[1]!.trim()
88
+ }
89
+ // Fallback: footer/anywhere comment.
90
+ const c2 = raw.match(/<!--\s*skill-version:\s*([^\s>]+)\s*-->/i)
91
+ if (c2) return c2[1]!.trim()
92
+ return null
93
+ }
94
+
95
+ function readPackageVersion(): string {
96
+ try {
97
+ // From dist/cli/ai.js → ../../package.json. From src during tests too.
98
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
99
+ const pkg = require(path.join(__dirname, "..", "..", "package.json")) as {
100
+ version: string
101
+ }
102
+ return pkg.version
103
+ } catch {
104
+ return "0.0.0"
105
+ }
106
+ }
107
+
108
+ // ── Frontmatter helpers ──────────────────────────────────────────────────
109
+
110
+ function extractFrontmatter(raw: string): string | null {
111
+ if (!raw.startsWith("---")) return null
112
+ // Match a leading `---\n...\n---` block. Use a non-greedy capture.
113
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
114
+ return m ? m[1]! : null
115
+ }
116
+
117
+ function stripFrontmatter(raw: string): string {
118
+ if (!raw.startsWith("---")) return raw
119
+ const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
120
+ if (!m) return raw
121
+ return raw.slice(m[0].length).replace(/^\r?\n+/, "")
122
+ }
123
+
124
+ // ── Sentinel block helpers (load-bearing for re-installs) ────────────────
125
+
126
+ function makeSentinelBlock(body: string, version: string): string {
127
+ const header = `${SENTINEL_BEGIN} v${version} -->`
128
+ // Always terminate with a single trailing newline so concatenation in
129
+ // merged files stays tidy across re-installs.
130
+ return `${header}\n${body.trimEnd()}\n${SENTINEL_END}\n`
131
+ }
132
+
133
+ function findSentinelBlock(
134
+ contents: string,
135
+ ): { start: number; end: number; version: string | null } | null {
136
+ const beginRe = /<!--\s*sentroy-skill-begin(?:\s+v([^\s>]+))?\s*-->/
137
+ const beginMatch = beginRe.exec(contents)
138
+ if (!beginMatch) return null
139
+ const start = beginMatch.index
140
+ const endIdx = contents.indexOf(SENTINEL_END, start)
141
+ if (endIdx < 0) return null
142
+ const end = endIdx + SENTINEL_END.length
143
+ return { start, end, version: beginMatch[1] ?? null }
144
+ }
145
+
146
+ function getInstalledVersion(filePath: string): string | null {
147
+ if (!fs.existsSync(filePath)) return null
148
+ const raw = fs.readFileSync(filePath, "utf8")
149
+ const block = findSentinelBlock(raw)
150
+ return block?.version ?? null
151
+ }
152
+
153
+ /**
154
+ * Append-or-replace a sentinel-wrapped block in a merge-capable file.
155
+ * Creates the file (and parent dirs) when missing.
156
+ *
157
+ * Returns:
158
+ * "created" — file did not exist before
159
+ * "updated" — block existed but body/version differed
160
+ * "unchanged" — block existed and content matched exactly
161
+ */
162
+ function writeSentinelBlock(
163
+ filePath: string,
164
+ body: string,
165
+ version: string,
166
+ opts: { check: boolean; upgrade: boolean },
167
+ ): WriteResult {
168
+ const block = makeSentinelBlock(body, version)
169
+ const exists = fs.existsSync(filePath)
170
+
171
+ if (!exists) {
172
+ if (opts.check) return { status: "created", path: filePath }
173
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
174
+ fs.writeFileSync(filePath, block, "utf8")
175
+ return { status: "created", path: filePath }
176
+ }
177
+
178
+ const current = fs.readFileSync(filePath, "utf8")
179
+ const found = findSentinelBlock(current)
180
+
181
+ let next: string
182
+ if (found) {
183
+ const existingBlock = current.slice(found.start, found.end)
184
+ if (existingBlock.trimEnd() === block.trimEnd()) {
185
+ return { status: "unchanged", path: filePath }
186
+ }
187
+ if (opts.upgrade && found.version === version) {
188
+ // Same version but body drifted (manual edit?). Don't clobber unless
189
+ // explicitly asked via --all/non-upgrade install path.
190
+ return {
191
+ status: "unchanged",
192
+ path: filePath,
193
+ note: "version matches; pass --all to force overwrite",
194
+ }
195
+ }
196
+ next =
197
+ current.slice(0, found.start) +
198
+ block.trimEnd() +
199
+ "\n" +
200
+ current.slice(found.end).replace(/^\r?\n+/, "")
201
+ } else {
202
+ // Append at end with a separating blank line.
203
+ const sep = current.endsWith("\n") ? "\n" : "\n\n"
204
+ next = current + sep + block
205
+ }
206
+
207
+ if (opts.check) return { status: "updated", path: filePath }
208
+ fs.writeFileSync(filePath, next, "utf8")
209
+ return { status: "updated", path: filePath }
210
+ }
211
+
212
+ /**
213
+ * Write a standalone file (Claude / Cursor targets) without sentinel.
214
+ * Compares bytes to detect unchanged. Always creates parent dirs.
215
+ */
216
+ function writeStandalone(
217
+ filePath: string,
218
+ contents: string,
219
+ opts: { check: boolean; upgrade: boolean; installedVersion: string | null; nextVersion: string },
220
+ ): WriteResult {
221
+ const exists = fs.existsSync(filePath)
222
+ if (exists) {
223
+ const current = fs.readFileSync(filePath, "utf8")
224
+ if (current === contents) return { status: "unchanged", path: filePath }
225
+ if (opts.upgrade && opts.installedVersion === opts.nextVersion) {
226
+ return {
227
+ status: "unchanged",
228
+ path: filePath,
229
+ note: "version matches; pass --all to force overwrite",
230
+ }
231
+ }
232
+ }
233
+ if (opts.check) {
234
+ return { status: exists ? "updated" : "created", path: filePath }
235
+ }
236
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
237
+ fs.writeFileSync(filePath, contents, "utf8")
238
+ return { status: exists ? "updated" : "created", path: filePath }
239
+ }
240
+
241
+ // ── Target detection ─────────────────────────────────────────────────────
242
+
243
+ interface DetectFlags {
244
+ claude: boolean
245
+ cursor: boolean
246
+ windsurf: boolean
247
+ agents: boolean
248
+ all: boolean
249
+ noAgents: boolean
250
+ }
251
+
252
+ function detectTargets(cwd: string, flags: DetectFlags): Target[] {
253
+ if (flags.all) return ["claude", "cursor", "windsurf", "agents"]
254
+
255
+ const explicit: Target[] = []
256
+ if (flags.claude) explicit.push("claude")
257
+ if (flags.cursor) explicit.push("cursor")
258
+ if (flags.windsurf) explicit.push("windsurf")
259
+ if (flags.agents) explicit.push("agents")
260
+ if (explicit.length > 0) return explicit
261
+
262
+ // Autodetect.
263
+ const detected: Target[] = []
264
+ if (
265
+ fs.existsSync(path.join(cwd, ".claude")) ||
266
+ fs.existsSync(path.join(cwd, "CLAUDE.md"))
267
+ ) {
268
+ detected.push("claude")
269
+ }
270
+ if (fs.existsSync(path.join(cwd, ".cursor"))) detected.push("cursor")
271
+ if (fs.existsSync(path.join(cwd, ".windsurfrules"))) detected.push("windsurf")
272
+
273
+ if (detected.length === 0 && !flags.noAgents) {
274
+ detected.push("agents")
275
+ } else if (!flags.noAgents && !detected.includes("agents")) {
276
+ // Also drop an AGENTS.md alongside detected tools so non-Claude/Cursor
277
+ // agents (Aider, Cline, Codex, …) still pick up the skill.
278
+ detected.push("agents")
279
+ }
280
+ return detected
281
+ }
282
+
283
+ // ── Per-target installers ────────────────────────────────────────────────
284
+
285
+ function installClaude(
286
+ cwd: string,
287
+ skill: BundledSkill,
288
+ opts: { check: boolean; upgrade: boolean },
289
+ ): WriteResult {
290
+ // Claude SKILL spec requires the file as-is with frontmatter.
291
+ const target = path.join(cwd, ".claude", "skills", "sentroy", "SKILL.md")
292
+ const installed = parseSkillVersion(
293
+ fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "",
294
+ )
295
+ return writeStandalone(target, skill.raw, {
296
+ check: opts.check,
297
+ upgrade: opts.upgrade,
298
+ installedVersion: installed,
299
+ nextVersion: skill.version,
300
+ })
301
+ }
302
+
303
+ function installCursor(
304
+ cwd: string,
305
+ skill: BundledSkill,
306
+ opts: { check: boolean; upgrade: boolean },
307
+ ): WriteResult {
308
+ const target = path.join(cwd, ".cursor", "rules", "sentroy.mdc")
309
+ const body = stripFrontmatter(skill.raw)
310
+ const mdc =
311
+ `---\n` +
312
+ `description: Sentroy SDK reference\n` +
313
+ `globs: "**/*"\n` +
314
+ `alwaysApply: false\n` +
315
+ `version: ${skill.version}\n` +
316
+ `---\n\n` +
317
+ body
318
+ // Parse installed version from the .mdc frontmatter we wrote previously.
319
+ let installed: string | null = null
320
+ if (fs.existsSync(target)) {
321
+ const fm = extractFrontmatter(fs.readFileSync(target, "utf8"))
322
+ if (fm) {
323
+ const m = fm.match(/^\s*version\s*:\s*['"]?([^'"\n]+)['"]?\s*$/m)
324
+ if (m) installed = m[1]!.trim()
325
+ }
326
+ }
327
+ return writeStandalone(target, mdc, {
328
+ check: opts.check,
329
+ upgrade: opts.upgrade,
330
+ installedVersion: installed,
331
+ nextVersion: skill.version,
332
+ })
333
+ }
334
+
335
+ function installWindsurf(
336
+ cwd: string,
337
+ skill: BundledSkill,
338
+ opts: { check: boolean; upgrade: boolean },
339
+ ): WriteResult {
340
+ const target = path.join(cwd, ".windsurfrules")
341
+ const body = stripFrontmatter(skill.raw)
342
+ return writeSentinelBlock(target, body, skill.version, opts)
343
+ }
344
+
345
+ function installAgents(
346
+ cwd: string,
347
+ skill: BundledSkill,
348
+ opts: { check: boolean; upgrade: boolean },
349
+ ): WriteResult {
350
+ const target = path.join(cwd, "AGENTS.md")
351
+ // Keep frontmatter for AGENTS.md too — many tools (Codex, Cline) read it
352
+ // verbatim, and the YAML block is harmless markdown to those that don't.
353
+ const body = skill.raw
354
+ return writeSentinelBlock(target, body, skill.version, opts)
355
+ }
356
+
357
+ // ── Main handler ─────────────────────────────────────────────────────────
358
+
359
+ async function cmdInstall(args: string[]): Promise<void> {
360
+ const { flags } = parseFlags(args)
361
+
362
+ const detectFlags: DetectFlags = {
363
+ claude: flags.claude === true,
364
+ cursor: flags.cursor === true,
365
+ windsurf: flags.windsurf === true,
366
+ agents: flags.agents === true,
367
+ all: flags.all === true,
368
+ noAgents: flags["no-agents"] === true,
369
+ }
370
+ const check = flags.check === true
371
+ const upgrade = flags.upgrade === true
372
+ const sourceFlag =
373
+ typeof flags.source === "string" ? (flags.source as string) : undefined
374
+
375
+ const skill = readBundledSkill(sourceFlag)
376
+ const cwd = process.cwd()
377
+ const targets = detectTargets(cwd, detectFlags)
378
+
379
+ if (targets.length === 0) {
380
+ fail(
381
+ "no install targets resolved. Pass --claude/--cursor/--windsurf/--agents or --all.",
382
+ )
383
+ }
384
+
385
+ info(
386
+ `Sentroy skill v${c.bold(skill.version)} ${check ? c.dim("(dry-run)") : ""}`.trim(),
387
+ )
388
+ info(
389
+ `source: ${c.dim(sourceFlag ?? "bundled (" + path.join(__dirname, "..", "..", "skill", "SKILL.md") + ")")}`,
390
+ )
391
+ info(`targets: ${c.cyan(targets.join(", "))}`)
392
+ process.stdout.write("\n")
393
+
394
+ const results: WriteResult[] = []
395
+ for (const t of targets) {
396
+ try {
397
+ let r: WriteResult
398
+ if (t === "claude") r = installClaude(cwd, skill, { check, upgrade })
399
+ else if (t === "cursor") r = installCursor(cwd, skill, { check, upgrade })
400
+ else if (t === "windsurf") r = installWindsurf(cwd, skill, { check, upgrade })
401
+ else r = installAgents(cwd, skill, { check, upgrade })
402
+ results.push(r)
403
+ printResult(t, r, check)
404
+ } catch (err) {
405
+ const msg = err instanceof Error ? err.message : String(err)
406
+ warn(`${t}: ${msg}`)
407
+ results.push({ status: "skipped", path: "(error)", note: msg })
408
+ }
409
+ }
410
+
411
+ process.stdout.write("\n")
412
+ const created = results.filter((r) => r.status === "created").length
413
+ const updated = results.filter((r) => r.status === "updated").length
414
+ const unchanged = results.filter((r) => r.status === "unchanged").length
415
+ const skipped = results.filter((r) => r.status === "skipped").length
416
+ const verb = check ? "would" : ""
417
+ ok(
418
+ `${verb ? verb + " " : ""}created: ${c.bold(String(created))} ` +
419
+ `${verb ? verb + " " : ""}updated: ${c.bold(String(updated))} ` +
420
+ `unchanged: ${c.bold(String(unchanged))} ` +
421
+ `skipped: ${c.bold(String(skipped))}`,
422
+ )
423
+ if (check) info("dry-run — no files written. Re-run without --check to apply.")
424
+ }
425
+
426
+ function printResult(target: Target, r: WriteResult, check: boolean): void {
427
+ const verb = check ? "would " : ""
428
+ const rel = path.relative(process.cwd(), r.path) || r.path
429
+ if (r.status === "created") ok(`${target}: ${verb}create ${c.dim(rel)}`)
430
+ else if (r.status === "updated") ok(`${target}: ${verb}update ${c.dim(rel)}`)
431
+ else if (r.status === "unchanged")
432
+ info(`${target}: unchanged ${c.dim(rel)}${r.note ? c.dim(" — " + r.note) : ""}`)
433
+ else warn(`${target}: skipped ${r.note ? c.dim(r.note) : ""}`)
434
+ }
435
+
436
+ // ── Export ───────────────────────────────────────────────────────────────
437
+
438
+ export const AI_HANDLERS = {
439
+ install: cmdInstall,
440
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Shared CLI utilities — flag parser, ANSI helpers, auth resolver, HTTP
3
+ * client, fail/info/ok/warn loggers. Reused by every subcommand
4
+ * (env, mail, storage, ai).
5
+ *
6
+ * `env.ts` historically inlined these; new subcommands import them from
7
+ * here to stay DRY and to allow extending `VALUE_FLAGS` in one place.
8
+ */
9
+
10
+ export interface ParsedArgs {
11
+ positional: string[]
12
+ flags: Record<string, string | boolean>
13
+ }
14
+
15
+ export interface SharedOpts {
16
+ token: string
17
+ baseUrl: string
18
+ /** Company slug — mail/storage commands need it; env doesn't (token is
19
+ * vault-scoped). Resolved from --company-slug, $SENTROY_COMPANY_SLUG,
20
+ * or thrown error. */
21
+ companySlug?: string
22
+ }
23
+
24
+ // ── ANSI helpers (no deps) ──────────────────────────────────────────────
25
+ // Honor https://no-color.org and FORCE_COLOR override before falling back
26
+ // to TTY detection. Captured at module load — wrappers/tests that need to
27
+ // override should set the env var before requiring this module.
28
+ const supportsColor = (() => {
29
+ if (typeof process === "undefined") return false
30
+ if (process.env.NO_COLOR) return false
31
+ if (process.env.FORCE_COLOR) return process.env.FORCE_COLOR !== "0"
32
+ return Boolean(process.stdout?.isTTY) && process.env.TERM !== "dumb"
33
+ })()
34
+
35
+ export const c = {
36
+ bold: (s: string) => (supportsColor ? `\x1b[1m${s}\x1b[0m` : s),
37
+ dim: (s: string) => (supportsColor ? `\x1b[2m${s}\x1b[0m` : s),
38
+ red: (s: string) => (supportsColor ? `\x1b[31m${s}\x1b[0m` : s),
39
+ green: (s: string) => (supportsColor ? `\x1b[32m${s}\x1b[0m` : s),
40
+ yellow: (s: string) => (supportsColor ? `\x1b[33m${s}\x1b[0m` : s),
41
+ cyan: (s: string) => (supportsColor ? `\x1b[36m${s}\x1b[0m` : s),
42
+ magenta: (s: string) => (supportsColor ? `\x1b[35m${s}\x1b[0m` : s),
43
+ }
44
+
45
+ export function fail(msg: string): never {
46
+ process.stderr.write(`${c.red("✗")} ${msg}\n`)
47
+ process.exit(1)
48
+ }
49
+
50
+ export function info(msg: string): void {
51
+ process.stdout.write(`${c.cyan("→")} ${msg}\n`)
52
+ }
53
+
54
+ export function ok(msg: string): void {
55
+ process.stdout.write(`${c.green("✓")} ${msg}\n`)
56
+ }
57
+
58
+ export function warn(msg: string): void {
59
+ process.stdout.write(`${c.yellow("⚠")} ${msg}\n`)
60
+ }
61
+
62
+ /**
63
+ * Flags that take a value as a separate token (`--flag value`). All other
64
+ * `--flag` tokens are booleans. The `--flag=value` form always works
65
+ * regardless of this list.
66
+ */
67
+ const VALUE_FLAGS = new Set([
68
+ "token",
69
+ "url",
70
+ "company-slug",
71
+ "output",
72
+ "page",
73
+ "limit",
74
+ "skip",
75
+ "mailbox",
76
+ "folder",
77
+ "q",
78
+ "domain",
79
+ "reason",
80
+ "status",
81
+ "from",
82
+ "to",
83
+ "days",
84
+ "type",
85
+ "sort",
86
+ "dir",
87
+ "out",
88
+ "source",
89
+ "pin",
90
+ ])
91
+
92
+ export function parseFlags(args: string[]): ParsedArgs {
93
+ const positional: string[] = []
94
+ const flags: Record<string, string | boolean> = {}
95
+ for (let i = 0; i < args.length; i++) {
96
+ const a = args[i]!
97
+ if (!a.startsWith("--")) {
98
+ positional.push(a)
99
+ continue
100
+ }
101
+ const body = a.slice(2)
102
+ const eq = body.indexOf("=")
103
+ if (eq >= 0) {
104
+ flags[body.slice(0, eq)] = body.slice(eq + 1)
105
+ continue
106
+ }
107
+ if (VALUE_FLAGS.has(body)) {
108
+ const next = args[i + 1]
109
+ if (next === undefined || next.startsWith("--")) {
110
+ fail(
111
+ `flag --${body} requires a value (use --${body}=value or --${body} value)`,
112
+ )
113
+ }
114
+ flags[body] = next
115
+ i++
116
+ } else {
117
+ flags[body] = true
118
+ }
119
+ }
120
+ return { positional, flags }
121
+ }
122
+
123
+ /**
124
+ * Resolve shared opts for general (non-vault) Sentroy CLI commands.
125
+ *
126
+ * Token resolution priority:
127
+ * 1. --token=stk_... flag
128
+ * 2. SENTROY_API_KEY env var
129
+ * 3. SENTROY_ENV_API_KEY env var (back-compat — same token namespace works)
130
+ *
131
+ * Base URL priority:
132
+ * 1. --url=https://... flag
133
+ * 2. SENTROY_API_URL env var
134
+ * 3. SENTROY_ENV_API_URL env var
135
+ * 4. https://sentroy.com
136
+ *
137
+ * Company slug priority (REQUIRED for mail/storage):
138
+ * 1. --company-slug=<slug> flag
139
+ * 2. SENTROY_COMPANY_SLUG env var
140
+ */
141
+ export function resolveSharedOpts(
142
+ flags: Record<string, string | boolean>,
143
+ opts: { requireCompanySlug?: boolean } = {},
144
+ ): SharedOpts {
145
+ const token =
146
+ (typeof flags.token === "string" ? flags.token : null) ??
147
+ process.env.SENTROY_API_KEY ??
148
+ process.env.SENTROY_ENV_API_KEY ??
149
+ null
150
+ if (!token) {
151
+ fail(
152
+ "no token. Pass --token=stk_... or set SENTROY_API_KEY (or SENTROY_ENV_API_KEY for vault) in your environment.",
153
+ )
154
+ }
155
+ const baseUrl = (
156
+ (typeof flags.url === "string" ? flags.url : null) ??
157
+ process.env.SENTROY_API_URL ??
158
+ process.env.SENTROY_ENV_API_URL ??
159
+ "https://sentroy.com"
160
+ ).replace(/\/+$/, "")
161
+
162
+ const companySlug =
163
+ (typeof flags["company-slug"] === "string"
164
+ ? (flags["company-slug"] as string)
165
+ : null) ??
166
+ process.env.SENTROY_COMPANY_SLUG ??
167
+ undefined
168
+ if (opts.requireCompanySlug && !companySlug) {
169
+ fail(
170
+ "no company slug. Pass --company-slug=<slug> or set SENTROY_COMPANY_SLUG in your environment.",
171
+ )
172
+ }
173
+ return { token, baseUrl, companySlug }
174
+ }
175
+
176
+ /**
177
+ * Thin fetch wrapper used by all subcommands that hit the Sentroy REST
178
+ * API. Strips trailing slashes from baseUrl, adds Bearer auth, unwraps
179
+ * `{success, data}` envelope, raises on non-2xx with a useful message.
180
+ */
181
+ export async function apiFetch<T = unknown>(
182
+ shared: SharedOpts,
183
+ path: string,
184
+ init?: RequestInit,
185
+ ): Promise<T> {
186
+ const res = await fetch(`${shared.baseUrl}${path}`, {
187
+ ...init,
188
+ headers: {
189
+ Authorization: `Bearer ${shared.token}`,
190
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
191
+ ...(init?.headers ?? {}),
192
+ },
193
+ })
194
+ const text = await res.text()
195
+ let body: unknown
196
+ try {
197
+ body = text ? JSON.parse(text) : null
198
+ } catch {
199
+ body = text
200
+ }
201
+ if (!res.ok) {
202
+ // Sentroy returns different envelopes depending on handler; try the
203
+ // common shapes in priority order before falling back to HTTP status.
204
+ let errMsg = `HTTP ${res.status} ${res.statusText}`.trim()
205
+ if (body && typeof body === "object") {
206
+ const b = body as Record<string, unknown>
207
+ if (typeof b.error === "string") errMsg = b.error
208
+ else if (typeof b.message === "string") errMsg = b.message
209
+ else if (
210
+ b.error &&
211
+ typeof b.error === "object" &&
212
+ typeof (b.error as Record<string, unknown>).message === "string"
213
+ ) {
214
+ errMsg = (b.error as Record<string, string>).message
215
+ }
216
+ }
217
+ throw new Error(`${path}: ${errMsg}`)
218
+ }
219
+ // Envelope: {success: true, data: T} (most endpoints) or raw {data: T}.
220
+ // Some return body directly without envelope; fall back to body.
221
+ if (body && typeof body === "object" && "data" in body) {
222
+ return (body as { data: T }).data
223
+ }
224
+ return body as T
225
+ }
package/src/cli/env.ts CHANGED
@@ -283,6 +283,7 @@ export async function cmdPull(args: string[]): Promise<void> {
283
283
  const { positional, flags } = parseFlags(args)
284
284
  const file = positional[0] ?? DEFAULT_FILE
285
285
  const force = !!flags.force
286
+ const publicOnly = !!flags["public-only"]
286
287
  const shared = resolveSharedOpts(flags)
287
288
 
288
289
  const target = path.resolve(process.cwd(), file)
@@ -291,7 +292,12 @@ export async function cmdPull(args: string[]): Promise<void> {
291
292
  }
292
293
 
293
294
  info(`fetching from ${shared.baseUrl}…`)
294
- const remote = await http<FetchResponse>(shared, "/api/env-vault/fetch")
295
+ // Browser-safe subset (public-only) hits a dedicated endpoint that
296
+ // strips secrets — useful for `.env.public` files committed to repos.
297
+ const endpoint = publicOnly
298
+ ? "/api/env-vault/public"
299
+ : "/api/env-vault/fetch"
300
+ const remote = await http<FetchResponse>(shared, endpoint)
295
301
  const entries: DotenvEntry[] = remote.variables.map((v) => ({
296
302
  key: v.key,
297
303
  value: v.value,
@@ -301,7 +307,9 @@ export async function cmdPull(args: string[]): Promise<void> {
301
307
  const text = serializeDotenv(entries)
302
308
  fs.writeFileSync(target, text, "utf8")
303
309
  ok(
304
- `wrote ${entries.length} variable(s) to ${file} (${remote.project}/${remote.environment})`,
310
+ `wrote ${entries.length} variable(s) to ${file} (${remote.project}/${remote.environment})${
311
+ publicOnly ? " [public-only]" : ""
312
+ }`,
305
313
  )
306
314
  }
307
315