@sentroy-co/client-sdk 2.13.9 → 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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Output formatters — table (human-friendly) + json (script/automation).
3
+ *
4
+ * CLI commands accept `--output=json|table` (default: table) and call
5
+ * `printRows(rows, columns, flags)`.
6
+ */
7
+
8
+ import { c } from "./args"
9
+
10
+ export interface Column<T> {
11
+ /** Header text shown in table mode (uppercase recommended). */
12
+ header: string
13
+ /** Value extractor; should return a primitive that .toString()'s cleanly. */
14
+ get: (row: T) => string | number | null | undefined
15
+ /** Max display width — longer values get truncated with `…`. Default 40. */
16
+ maxWidth?: number
17
+ /** Right-align numeric columns. Default false. */
18
+ alignRight?: boolean
19
+ }
20
+
21
+ /**
22
+ * Print rows as a formatted table (or JSON if --output=json).
23
+ *
24
+ * Table mode uses 2-space gap between columns, dim header line, no
25
+ * vertical separators (keeps copy-paste clean). Empty cells render as
26
+ * `c.dim("—")`.
27
+ */
28
+ export function printRows<T>(
29
+ rows: T[],
30
+ columns: Column<T>[],
31
+ flags: Record<string, string | boolean>,
32
+ ): void {
33
+ const output =
34
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
35
+ if (output === "json") {
36
+ // For JSON we dump the raw row objects, not the column projection —
37
+ // scripts may want fields beyond what we show in the table.
38
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n")
39
+ return
40
+ }
41
+ if (rows.length === 0) {
42
+ process.stdout.write(c.dim("(no results)") + "\n")
43
+ return
44
+ }
45
+ // Compute string cells + widths
46
+ const cells: string[][] = rows.map((row) =>
47
+ columns.map((col) => {
48
+ const v = col.get(row)
49
+ if (v === null || v === undefined || v === "") return ""
50
+ const s = String(v)
51
+ const max = col.maxWidth ?? 40
52
+ if (s.length > max) return s.slice(0, max - 1) + "…"
53
+ return s
54
+ }),
55
+ )
56
+ const widths = columns.map((col, i) => {
57
+ const headerWidth = col.header.length
58
+ const rowMax = cells.reduce((m, r) => Math.max(m, (r[i] ?? "").length), 0)
59
+ return Math.max(headerWidth, rowMax)
60
+ })
61
+
62
+ // Header
63
+ const headerLine = columns
64
+ .map((col, i) => padCell(col.header, widths[i]!, col.alignRight ?? false))
65
+ .join(" ")
66
+ process.stdout.write(c.dim(headerLine) + "\n")
67
+
68
+ // Underline
69
+ const underline = widths.map((w) => "─".repeat(w)).join(" ")
70
+ process.stdout.write(c.dim(underline) + "\n")
71
+
72
+ // Rows
73
+ for (const cellRow of cells) {
74
+ const line = columns
75
+ .map((col, i) => {
76
+ const v = cellRow[i] ?? ""
77
+ const padded = padCell(v || c.dim("—"), widths[i]!, col.alignRight ?? false)
78
+ return padded
79
+ })
80
+ .join(" ")
81
+ process.stdout.write(line + "\n")
82
+ }
83
+ process.stdout.write("\n" + c.dim(`${rows.length} row${rows.length === 1 ? "" : "s"}`) + "\n")
84
+ }
85
+
86
+ function padCell(text: string, width: number, alignRight: boolean): string {
87
+ // ANSI escape sequences mess up String.prototype.padEnd length math;
88
+ // strip color codes for width calculation only.
89
+ const visible = text.replace(/\x1b\[[0-9;]*m/g, "")
90
+ const pad = " ".repeat(Math.max(0, width - visible.length))
91
+ return alignRight ? pad + text : text + pad
92
+ }
93
+
94
+ /**
95
+ * Print a single object as a key/value detail panel.
96
+ * Used by `<group> <resource> get <id>` commands.
97
+ */
98
+ export function printDetail<T extends Record<string, unknown>>(
99
+ obj: T,
100
+ flags: Record<string, string | boolean>,
101
+ ): void {
102
+ const output =
103
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
104
+ if (output === "json") {
105
+ process.stdout.write(JSON.stringify(obj, null, 2) + "\n")
106
+ return
107
+ }
108
+ const keys = Object.keys(obj)
109
+ if (keys.length === 0) {
110
+ process.stdout.write(c.dim("(empty)") + "\n")
111
+ return
112
+ }
113
+ const longest = keys.reduce((m, k) => Math.max(m, k.length), 0)
114
+ for (const k of keys) {
115
+ const v = obj[k]
116
+ const valStr =
117
+ v === null || v === undefined
118
+ ? c.dim("—")
119
+ : typeof v === "object"
120
+ ? JSON.stringify(v)
121
+ : String(v)
122
+ process.stdout.write(`${c.dim(k.padEnd(longest))} ${valStr}\n`)
123
+ }
124
+ process.stdout.write("\n")
125
+ }
126
+
127
+ /**
128
+ * Localized string helper — Sentroy mail/templates fields can be either a
129
+ * plain string or `Record<locale, string>` (e.g. `{tr, en}`). For table
130
+ * display, pick "en" fallback "tr" fallback first available, otherwise
131
+ * the raw string.
132
+ */
133
+ export function loc(value: unknown, preferred = "en"): string {
134
+ if (value === null || value === undefined) return ""
135
+ if (typeof value === "string") return value
136
+ if (typeof value === "object") {
137
+ const obj = value as Record<string, unknown>
138
+ const direct = obj[preferred]
139
+ if (typeof direct === "string") return direct
140
+ const tr = obj.tr
141
+ if (typeof tr === "string") return tr
142
+ for (const k of Object.keys(obj)) {
143
+ if (typeof obj[k] === "string") return obj[k] as string
144
+ }
145
+ }
146
+ return ""
147
+ }
package/src/cli/index.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * `sentroy` — CLI entry point.
3
3
  *
4
- * Subcommand router with no third-party deps. Today only `env` exists;
5
- * future subcommands (`mail`, `media`, etc.) hook in here.
4
+ * Subcommand router with no third-party deps. Groups:
5
+ * - env Vault sync (push/pull/list/diff) vault-scoped token
6
+ * - mail Mail resource queries (templates/domains/mailboxes/...)
7
+ * - storage Storage resource queries (buckets/media/usage)
8
+ * - ai AI Skill install (Claude/Cursor/Windsurf/AGENTS.md)
9
+ *
10
+ * Shared helpers live in `./args` (parseFlags, resolveSharedOpts, apiFetch,
11
+ * c, fail, info, ok, warn) and `./format` (printRows, printDetail, loc).
6
12
  */
7
13
 
8
14
  import { cmdPush, cmdPull, cmdList, cmdDiff } from "./env"
15
+ import { MAIL_HANDLERS } from "./mail"
16
+ import { STORAGE_HANDLERS } from "./storage"
17
+ import { AI_HANDLERS } from "./ai"
9
18
 
10
19
  const VERSION = "__VERSION__" // replaced at runtime via package.json read
11
20
 
@@ -33,9 +42,41 @@ const ENV_SUBCOMMANDS: Record<string, SubCommand> = {
33
42
  },
34
43
  }
35
44
 
45
+ // MAIL/STORAGE/AI subcommands use sub-sub-command pattern:
46
+ // `sentroy mail templates list` → MAIL_HANDLERS["templates.list"]
47
+ // `sentroy mail logs get <id>` → MAIL_HANDLERS["logs.get"]
48
+ //
49
+ // Router joins argv[1] + argv[2] with "." for resource+verb lookup.
50
+ // `sentroy mail analytics` has no verb → MAIL_HANDLERS["analytics"].
51
+ // `sentroy ai install` has no resource → AI_HANDLERS["install"].
52
+
53
+ /**
54
+ * Maps `mail templates list` → `templatesList`, `storage buckets get` →
55
+ * `bucketsGet`. If verb missing, tries plain resource key (e.g. `analytics`
56
+ * or `usage`). Returns the handler fn + how many argv tokens it consumed
57
+ * past the group (2 = resource+verb, 1 = resource only) so caller can
58
+ * slice the rest as args.
59
+ */
60
+ function resolveHandler(
61
+ handlers: Record<string, (args: string[]) => Promise<void> | void>,
62
+ resource: string,
63
+ verb: string | undefined,
64
+ ): { fn: (args: string[]) => Promise<void> | void; consumed: number } | null {
65
+ // Flag tokens are NOT verbs (e.g. `mail analytics --days=7`).
66
+ const verbToken = verb && !verb.startsWith("--") ? verb : undefined
67
+ if (verbToken) {
68
+ const camel =
69
+ resource + verbToken.charAt(0).toUpperCase() + verbToken.slice(1)
70
+ const h = handlers[camel]
71
+ if (h) return { fn: h, consumed: 3 }
72
+ }
73
+ const direct = handlers[resource]
74
+ if (direct) return { fn: direct, consumed: 2 }
75
+ return null
76
+ }
77
+
36
78
  function readPackageVersion(): string {
37
79
  if (VERSION !== "__VERSION__") return VERSION
38
- // Walk up from this file: dist/cli/index.js → dist/cli → dist → package.
39
80
  try {
40
81
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
82
  const { version } = require("../../package.json") as { version: string }
@@ -50,31 +91,53 @@ function showHelp(): void {
50
91
  process.stdout.write(
51
92
  `\nsentroy ${v} — Sentroy CLI\n\n` +
52
93
  `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` +
94
+ ` sentroy <group> <subcommand> [args] [flags]\n\n` +
95
+ `ENV (vault sync — requires SENTROY_ENV_API_KEY)\n` +
96
+ ` sentroy env push [<file>] ${ENV_SUBCOMMANDS.push!.description}\n` +
97
+ ` sentroy env pull [<file>] ${ENV_SUBCOMMANDS.pull!.description}\n` +
98
+ ` sentroy env list ${ENV_SUBCOMMANDS.list!.description}\n` +
99
+ ` sentroy env diff [<file>] ${ENV_SUBCOMMANDS.diff!.description}\n\n` +
100
+ `MAIL (requires SENTROY_API_KEY + SENTROY_COMPANY_SLUG)\n` +
101
+ ` sentroy mail templates list List email templates\n` +
102
+ ` sentroy mail templates get <id> Template detail\n` +
103
+ ` sentroy mail domains list List sending domains\n` +
104
+ ` sentroy mail mailboxes list List inbox mailboxes\n` +
105
+ ` sentroy mail inbox list Recent inbox messages (--mailbox, --folder, --unread, --q, --page, --limit)\n` +
106
+ ` sentroy mail suppressions list Bounce/complaint suppressions (--domain, --reason)\n` +
107
+ ` sentroy mail logs list Delivery logs (--status, --domain, --from, --to)\n` +
108
+ ` sentroy mail logs get <id> Log detail (event timeline)\n` +
109
+ ` sentroy mail webhooks list Webhook endpoints (--domain)\n` +
110
+ ` sentroy mail analytics Aggregate stats (--days=7|30|90, --domain)\n\n` +
111
+ `STORAGE (requires SENTROY_API_KEY + SENTROY_COMPANY_SLUG)\n` +
112
+ ` sentroy storage buckets list List storage buckets\n` +
113
+ ` sentroy storage buckets get <slug> Bucket detail\n` +
114
+ ` sentroy storage media list <bucketSlug> List media in bucket (--type, --folder, --q, --sort, --dir, --limit, --skip)\n` +
115
+ ` sentroy storage media get <bucketSlug> <mediaId> Media detail\n` +
116
+ ` sentroy storage usage Quota + per-bucket breakdown\n` +
117
+ ` sentroy storage quota Lightweight quota only\n\n` +
118
+ `AI SKILL (install the Sentroy AI agent skill into your project)\n` +
119
+ ` sentroy ai install Autodetect tools (Claude, Cursor, Windsurf) + AGENTS.md\n` +
120
+ ` sentroy ai install --claude Install only into .claude/skills/sentroy/\n` +
121
+ ` sentroy ai install --cursor Install only into .cursor/rules/sentroy.mdc\n` +
122
+ ` sentroy ai install --windsurf Merge into .windsurfrules\n` +
123
+ ` sentroy ai install --agents Merge into AGENTS.md at cwd\n` +
124
+ ` sentroy ai install --all Install into every supported target\n` +
125
+ ` sentroy ai install --upgrade Re-install latest, replacing existing sentinel block\n` +
126
+ ` sentroy ai install --check Dry-run, show what would change\n\n` +
59
127
  `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` +
128
+ ` --token=stk_... Override $SENTROY_API_KEY (env vault uses $SENTROY_ENV_API_KEY)\n` +
129
+ ` --url=https://... Override base URL (default https://sentroy.com)\n` +
130
+ ` --company-slug=<slug> Override $SENTROY_COMPANY_SLUG (required for mail/storage)\n` +
131
+ ` --output=json|table Output format (default table)\n\n` +
71
132
  `EXAMPLES\n` +
72
133
  ` 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`,
134
+ ` sentroy mail templates list --output=json | jq '.[].name'\n` +
135
+ ` sentroy mail logs list --status=bounced --days=7\n` +
136
+ ` sentroy storage buckets list\n` +
137
+ ` sentroy ai install\n\n` +
138
+ `DOCS\n` +
139
+ ` https://docs.sentroy.com/cli Full CLI reference\n` +
140
+ ` https://docs.sentroy.com/ai-skills AI Skill install guide\n\n`,
78
141
  )
79
142
  }
80
143
 
@@ -95,6 +158,7 @@ async function main(): Promise<void> {
95
158
  }
96
159
 
97
160
  const cmd = argv[0]
161
+
98
162
  if (cmd === "env") {
99
163
  const sub = argv[1]
100
164
  const handler = sub ? ENV_SUBCOMMANDS[sub] : undefined
@@ -109,6 +173,64 @@ async function main(): Promise<void> {
109
173
  return
110
174
  }
111
175
 
176
+ if (cmd === "mail") {
177
+ // `mail <resource> [verb] [args]` → handler key = resource + Capitalize(verb).
178
+ // Exception: `mail analytics` has no verb → handler key = "analytics".
179
+ const resource = argv[1]
180
+ const verb = argv[2]
181
+ if (!resource) {
182
+ process.stderr.write(
183
+ `usage: sentroy mail <resource> [verb] [args]\nresources: templates, domains, mailboxes, inbox, suppressions, logs, webhooks, analytics\n`,
184
+ )
185
+ process.exit(1)
186
+ }
187
+ const handler = resolveHandler(MAIL_HANDLERS, resource, verb)
188
+ if (!handler) {
189
+ process.stderr.write(
190
+ `unknown mail command: \`sentroy mail ${resource}${verb ? " " + verb : ""}\`\n` +
191
+ `available: ${Object.keys(MAIL_HANDLERS).join(", ")}\n`,
192
+ )
193
+ process.exit(1)
194
+ }
195
+ await handler.fn(argv.slice(handler.consumed))
196
+ return
197
+ }
198
+
199
+ if (cmd === "storage") {
200
+ const resource = argv[1]
201
+ const verb = argv[2]
202
+ if (!resource) {
203
+ process.stderr.write(
204
+ `usage: sentroy storage <resource> [verb] [args]\nresources: buckets, media, usage, quota\n`,
205
+ )
206
+ process.exit(1)
207
+ }
208
+ const handler = resolveHandler(STORAGE_HANDLERS, resource, verb)
209
+ if (!handler) {
210
+ process.stderr.write(
211
+ `unknown storage command: \`sentroy storage ${resource}${verb ? " " + verb : ""}\`\n` +
212
+ `available: ${Object.keys(STORAGE_HANDLERS).join(", ")}\n`,
213
+ )
214
+ process.exit(1)
215
+ }
216
+ await handler.fn(argv.slice(handler.consumed))
217
+ return
218
+ }
219
+
220
+ if (cmd === "ai") {
221
+ const sub = argv[1]
222
+ const handler = sub ? AI_HANDLERS[sub as keyof typeof AI_HANDLERS] : undefined
223
+ if (!handler) {
224
+ process.stderr.write(
225
+ `unknown ai subcommand: ${sub ?? "<missing>"}\n` +
226
+ `available: ${Object.keys(AI_HANDLERS).join(", ")}\n`,
227
+ )
228
+ process.exit(1)
229
+ }
230
+ await handler(argv.slice(2))
231
+ return
232
+ }
233
+
112
234
  process.stderr.write(
113
235
  `unknown command: ${cmd}\nrun \`sentroy --help\` for usage.\n`,
114
236
  )