@sentroy-co/client-sdk 2.13.9 → 2.15.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 (49) hide show
  1. package/README.md +53 -5
  2. package/dist/auth/client.d.ts.map +1 -1
  3. package/dist/auth/client.js +8 -1
  4. package/dist/auth/client.js.map +1 -1
  5. package/dist/auth/react/index.js +1 -1
  6. package/dist/auth/react/index.js.map +1 -1
  7. package/dist/auth/react-native/index.d.ts +93 -0
  8. package/dist/auth/react-native/index.d.ts.map +1 -0
  9. package/dist/auth/react-native/index.js +106 -0
  10. package/dist/auth/react-native/index.js.map +1 -0
  11. package/dist/cli/ai.d.ts +35 -0
  12. package/dist/cli/ai.d.ts.map +1 -0
  13. package/dist/cli/ai.js +399 -0
  14. package/dist/cli/ai.js.map +1 -0
  15. package/dist/cli/args.d.ts +62 -0
  16. package/dist/cli/args.d.ts.map +1 -0
  17. package/dist/cli/args.js +199 -0
  18. package/dist/cli/args.js.map +1 -0
  19. package/dist/cli/env.d.ts.map +1 -1
  20. package/dist/cli/env.js +8 -2
  21. package/dist/cli/env.js.map +1 -1
  22. package/dist/cli/format.d.ts +37 -0
  23. package/dist/cli/format.d.ts.map +1 -0
  24. package/dist/cli/format.js +129 -0
  25. package/dist/cli/format.js.map +1 -0
  26. package/dist/cli/index.d.ts +8 -2
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +128 -25
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/mail.d.ts +25 -0
  31. package/dist/cli/mail.d.ts.map +1 -0
  32. package/dist/cli/mail.js +253 -0
  33. package/dist/cli/mail.js.map +1 -0
  34. package/dist/cli/storage.d.ts +28 -0
  35. package/dist/cli/storage.d.ts.map +1 -0
  36. package/dist/cli/storage.js +189 -0
  37. package/dist/cli/storage.js.map +1 -0
  38. package/package.json +9 -2
  39. package/skill/SKILL.md +577 -0
  40. package/src/auth/client.ts +8 -1
  41. package/src/auth/react/index.tsx +1 -1
  42. package/src/auth/react-native/index.ts +157 -0
  43. package/src/cli/ai.ts +440 -0
  44. package/src/cli/args.ts +225 -0
  45. package/src/cli/env.ts +10 -2
  46. package/src/cli/format.ts +147 -0
  47. package/src/cli/index.ts +147 -25
  48. package/src/cli/mail.ts +363 -0
  49. package/src/cli/storage.ts +307 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * `sentroy mail …` — read-only subcommands for the mail product.
3
+ *
4
+ * Every handler resolves shared opts (token + base URL + company slug),
5
+ * builds an optional query string from CLI flags, calls the platform
6
+ * REST API under `/api/companies/<slug>/…`, then prints the result via
7
+ * the shared table/json formatter (`printRows` for lists, `printDetail`
8
+ * for single-resource gets).
9
+ *
10
+ * Auth: Bearer `stk_…` access token (resolved by `resolveSharedOpts`).
11
+ * Output: table by default, `--output=json` for scripting.
12
+ *
13
+ * The handler map is exported as `MAIL_HANDLERS` so `cli/index.ts` can
14
+ * dispatch `sentroy mail <group> <action>` without each handler having
15
+ * to know about the router.
16
+ */
17
+ import { apiFetch, parseFlags, resolveSharedOpts, type SharedOpts } from "./args"
18
+ import { loc, printDetail, printRows, type Column } from "./format"
19
+
20
+ // ── Types (loose — matches platform JSON shape, not Mongoose models) ────
21
+
22
+ type LocalizedString = string | Record<string, string>
23
+
24
+ interface TemplateRow {
25
+ id: string
26
+ name: LocalizedString
27
+ subject: LocalizedString
28
+ domainId: string
29
+ thumbnailUrl?: string
30
+ category?: string
31
+ createdAt: string
32
+ }
33
+
34
+ interface DomainRow {
35
+ id: string
36
+ domain: string
37
+ status: string
38
+ isAssigned: boolean
39
+ catchAll?: boolean
40
+ createdAt: string
41
+ }
42
+
43
+ interface MailboxRow {
44
+ id: string
45
+ email: string
46
+ domainId: string
47
+ isCatchAll: boolean
48
+ createdAt: string
49
+ }
50
+
51
+ interface InboxRow {
52
+ id: string
53
+ mailbox: string
54
+ folder: string
55
+ subject: string
56
+ from: string
57
+ to: string | string[]
58
+ snippet?: string
59
+ unread: boolean
60
+ receivedAt: string
61
+ }
62
+
63
+ interface SuppressionRow {
64
+ id: string
65
+ email: string
66
+ reason: string
67
+ domainId?: string
68
+ createdAt: string
69
+ }
70
+
71
+ interface LogRow {
72
+ id: string
73
+ messageId: string
74
+ to: string | string[]
75
+ from: string
76
+ subject: string
77
+ status: string
78
+ domainId?: string
79
+ createdAt: string
80
+ }
81
+
82
+ interface WebhookRow {
83
+ id: string
84
+ url: string
85
+ events: string[]
86
+ domainId?: string
87
+ active: boolean
88
+ }
89
+
90
+ // ── Query string builder ────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Build a query string from a flag map, copying only the keys listed in
94
+ * `keys`. Skips booleans (those are switches, not values) and undefined.
95
+ * Returns "" if nothing to encode — caller can append safely.
96
+ */
97
+ function buildQuery(
98
+ flags: Record<string, string | boolean>,
99
+ keys: readonly { flag: string; param?: string }[],
100
+ ): string {
101
+ const params = new URLSearchParams()
102
+ for (const { flag, param } of keys) {
103
+ const v = flags[flag]
104
+ if (v === undefined) continue
105
+ if (typeof v === "boolean") {
106
+ if (v) params.set(param ?? flag, "true")
107
+ continue
108
+ }
109
+ params.set(param ?? flag, v)
110
+ }
111
+ const s = params.toString()
112
+ return s ? `?${s}` : ""
113
+ }
114
+
115
+ /** Slice ISO timestamps to "YYYY-MM-DD" or "YYYY-MM-DD HH:mm". */
116
+ function sliceDate(value: string | undefined | null, len: 10 | 16): string {
117
+ if (!value) return ""
118
+ const s = String(value)
119
+ if (len === 16) return s.slice(0, 10) + " " + s.slice(11, 16)
120
+ return s.slice(0, 10)
121
+ }
122
+
123
+ function yesNo(value: unknown): string {
124
+ if (value === true) return "yes"
125
+ if (value === false) return "no"
126
+ return ""
127
+ }
128
+
129
+ function ctx(args: string[]): { positional: string[]; flags: Record<string, string | boolean>; shared: SharedOpts } {
130
+ const { positional, flags } = parseFlags(args)
131
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
132
+ return { positional, flags, shared }
133
+ }
134
+
135
+ function companyPath(shared: SharedOpts, suffix: string): string {
136
+ return `/api/companies/${encodeURIComponent(shared.companySlug as string)}${suffix}`
137
+ }
138
+
139
+ // ── Templates ───────────────────────────────────────────────────────────
140
+
141
+ async function templatesList(args: string[]): Promise<void> {
142
+ const { flags, shared } = ctx(args)
143
+ const qs = buildQuery(flags, [
144
+ { flag: "page" },
145
+ { flag: "limit" },
146
+ ])
147
+ const data = await apiFetch<TemplateRow[]>(shared, companyPath(shared, `/templates${qs}`))
148
+ const cols: Column<TemplateRow>[] = [
149
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
150
+ { header: "NAME", get: (r) => loc(r.name) },
151
+ { header: "SUBJECT", get: (r) => loc(r.subject), maxWidth: 50 },
152
+ { header: "DOMAIN", get: (r) => r.domainId, maxWidth: 24 },
153
+ { header: "CATEGORY", get: (r) => r.category ?? "" },
154
+ { header: "CREATED", get: (r) => sliceDate(r.createdAt, 10) },
155
+ ]
156
+ printRows(data ?? [], cols, flags)
157
+ }
158
+
159
+ async function templatesGet(args: string[]): Promise<void> {
160
+ const { positional, flags, shared } = ctx(args)
161
+ const id = positional[0]
162
+ if (!id) {
163
+ process.stderr.write("usage: sentroy mail templates get <id>\n")
164
+ process.exit(1)
165
+ }
166
+ const data = await apiFetch<Record<string, unknown>>(
167
+ shared,
168
+ companyPath(shared, `/templates/${encodeURIComponent(id)}`),
169
+ )
170
+ printDetail(data ?? {}, flags)
171
+ }
172
+
173
+ // ── Domains ─────────────────────────────────────────────────────────────
174
+
175
+ async function domainsList(args: string[]): Promise<void> {
176
+ const { flags, shared } = ctx(args)
177
+ const qs = buildQuery(flags, [
178
+ { flag: "page" },
179
+ { flag: "limit" },
180
+ ])
181
+ const data = await apiFetch<DomainRow[]>(shared, companyPath(shared, `/domains${qs}`))
182
+ const cols: Column<DomainRow>[] = [
183
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
184
+ { header: "DOMAIN", get: (r) => r.domain },
185
+ { header: "STATUS", get: (r) => r.status },
186
+ { header: "ASSIGNED", get: (r) => yesNo(r.isAssigned) },
187
+ { header: "CREATED", get: (r) => sliceDate(r.createdAt, 10) },
188
+ ]
189
+ printRows(data ?? [], cols, flags)
190
+ }
191
+
192
+ // ── Mailboxes ───────────────────────────────────────────────────────────
193
+
194
+ async function mailboxesList(args: string[]): Promise<void> {
195
+ const { flags, shared } = ctx(args)
196
+ const qs = buildQuery(flags, [
197
+ { flag: "page" },
198
+ { flag: "limit" },
199
+ { flag: "domain", param: "domainId" },
200
+ ])
201
+ const data = await apiFetch<MailboxRow[]>(shared, companyPath(shared, `/mailboxes${qs}`))
202
+ const cols: Column<MailboxRow>[] = [
203
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
204
+ { header: "EMAIL", get: (r) => r.email },
205
+ { header: "DOMAIN", get: (r) => r.domainId, maxWidth: 24 },
206
+ { header: "CATCHALL", get: (r) => yesNo(r.isCatchAll) },
207
+ { header: "CREATED", get: (r) => sliceDate(r.createdAt, 10) },
208
+ ]
209
+ printRows(data ?? [], cols, flags)
210
+ }
211
+
212
+ // ── Inbox ───────────────────────────────────────────────────────────────
213
+
214
+ async function inboxList(args: string[]): Promise<void> {
215
+ const { flags, shared } = ctx(args)
216
+ const qs = buildQuery(flags, [
217
+ { flag: "mailbox" },
218
+ { flag: "folder" },
219
+ { flag: "unread" },
220
+ { flag: "page" },
221
+ { flag: "limit" },
222
+ { flag: "q" },
223
+ ])
224
+ const data = await apiFetch<InboxRow[]>(shared, companyPath(shared, `/inbox${qs}`))
225
+ const cols: Column<InboxRow>[] = [
226
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
227
+ { header: "MAILBOX", get: (r) => r.mailbox },
228
+ { header: "FOLDER", get: (r) => r.folder },
229
+ { header: "FROM", get: (r) => r.from, maxWidth: 40 },
230
+ { header: "SUBJECT", get: (r) => r.subject, maxWidth: 40 },
231
+ { header: "UNREAD", get: (r) => yesNo(r.unread) },
232
+ { header: "RECEIVED", get: (r) => sliceDate(r.receivedAt, 16) },
233
+ ]
234
+ printRows(data ?? [], cols, flags)
235
+ }
236
+
237
+ // ── Suppressions ────────────────────────────────────────────────────────
238
+
239
+ async function suppressionsList(args: string[]): Promise<void> {
240
+ const { flags, shared } = ctx(args)
241
+ const qs = buildQuery(flags, [
242
+ { flag: "page" },
243
+ { flag: "limit" },
244
+ { flag: "domain", param: "domainId" },
245
+ { flag: "reason" },
246
+ ])
247
+ const data = await apiFetch<SuppressionRow[]>(shared, companyPath(shared, `/suppressions${qs}`))
248
+ const cols: Column<SuppressionRow>[] = [
249
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
250
+ { header: "EMAIL", get: (r) => r.email },
251
+ { header: "REASON", get: (r) => r.reason, maxWidth: 20 },
252
+ { header: "DOMAIN", get: (r) => r.domainId ?? "", maxWidth: 24 },
253
+ { header: "CREATED", get: (r) => sliceDate(r.createdAt, 10) },
254
+ ]
255
+ printRows(data ?? [], cols, flags)
256
+ }
257
+
258
+ // ── Logs ────────────────────────────────────────────────────────────────
259
+
260
+ async function logsList(args: string[]): Promise<void> {
261
+ const { flags, shared } = ctx(args)
262
+ const qs = buildQuery(flags, [
263
+ { flag: "page" },
264
+ { flag: "limit" },
265
+ { flag: "status" },
266
+ { flag: "domain", param: "domainId" },
267
+ { flag: "from" },
268
+ { flag: "to" },
269
+ ])
270
+ const data = await apiFetch<LogRow[]>(shared, companyPath(shared, `/logs${qs}`))
271
+ const cols: Column<LogRow>[] = [
272
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
273
+ { header: "MESSAGE", get: (r) => r.messageId, maxWidth: 24 },
274
+ { header: "TO", get: (r) => Array.isArray(r.to) ? r.to.join(",") : r.to },
275
+ { header: "STATUS", get: (r) => r.status },
276
+ { header: "DOMAIN", get: (r) => r.domainId ?? "", maxWidth: 24 },
277
+ { header: "CREATED", get: (r) => sliceDate(r.createdAt, 16) },
278
+ ]
279
+ printRows(data ?? [], cols, flags)
280
+ }
281
+
282
+ async function logsGet(args: string[]): Promise<void> {
283
+ const { positional, flags, shared } = ctx(args)
284
+ const id = positional[0]
285
+ if (!id) {
286
+ process.stderr.write("usage: sentroy mail logs get <id>\n")
287
+ process.exit(1)
288
+ }
289
+ const data = await apiFetch<Record<string, unknown>>(
290
+ shared,
291
+ companyPath(shared, `/logs/${encodeURIComponent(id)}`),
292
+ )
293
+ printDetail(data ?? {}, flags)
294
+ }
295
+
296
+ // ── Webhooks ────────────────────────────────────────────────────────────
297
+
298
+ async function webhooksList(args: string[]): Promise<void> {
299
+ const { flags, shared } = ctx(args)
300
+ const qs = buildQuery(flags, [
301
+ { flag: "domain", param: "domainId" },
302
+ ])
303
+ const data = await apiFetch<WebhookRow[]>(shared, companyPath(shared, `/webhooks${qs}`))
304
+ const cols: Column<WebhookRow>[] = [
305
+ { header: "ID", get: (r) => r.id, maxWidth: 24 },
306
+ { header: "URL", get: (r) => r.url, maxWidth: 50 },
307
+ { header: "DOMAIN", get: (r) => r.domainId ?? "", maxWidth: 24 },
308
+ { header: "ACTIVE", get: (r) => yesNo(r.active) },
309
+ ]
310
+ printRows(data ?? [], cols, flags)
311
+ }
312
+
313
+ // ── Analytics ───────────────────────────────────────────────────────────
314
+
315
+ interface AnalyticsResponse {
316
+ windowDays?: number
317
+ overview?: Record<string, unknown>
318
+ prevOverview?: Record<string, unknown>
319
+ daily?: unknown[]
320
+ domains?: unknown[]
321
+ recentLogs?: unknown[]
322
+ }
323
+
324
+ async function analytics(args: string[]): Promise<void> {
325
+ const { flags, shared } = ctx(args)
326
+ const qs = buildQuery(flags, [
327
+ { flag: "days" },
328
+ { flag: "domain", param: "domainId" },
329
+ ])
330
+ const data = await apiFetch<AnalyticsResponse>(shared, companyPath(shared, `/analytics${qs}`))
331
+ const output =
332
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
333
+ if (output === "json") {
334
+ process.stdout.write(JSON.stringify(data ?? {}, null, 2) + "\n")
335
+ return
336
+ }
337
+ const overview = (data?.overview ?? {}) as Record<string, unknown>
338
+ const summary: Record<string, unknown> = {
339
+ windowDays: data?.windowDays ?? "",
340
+ ...overview,
341
+ }
342
+ printDetail(summary, flags)
343
+ process.stdout.write(
344
+ "\x1b[2m(overview shown — pass --output=json for full daily/domain/recentLogs breakdown)\x1b[0m\n",
345
+ )
346
+ }
347
+
348
+ // ── Router export ───────────────────────────────────────────────────────
349
+
350
+ export const MAIL_HANDLERS = {
351
+ templatesList,
352
+ templatesGet,
353
+ domainsList,
354
+ mailboxesList,
355
+ inboxList,
356
+ suppressionsList,
357
+ logsList,
358
+ logsGet,
359
+ webhooksList,
360
+ analytics,
361
+ } as const
362
+
363
+ export type MailHandlerName = keyof typeof MAIL_HANDLERS
@@ -0,0 +1,307 @@
1
+ /**
2
+ * `sentroy storage <subcommand>` — bucket + media browsing for the
3
+ * Sentroy storage product.
4
+ *
5
+ * Mirrors the dashboard at https://storage.sentroy.com but stays
6
+ * read-only: list/get buckets and media, surface usage + quota. Mutating
7
+ * ops (upload/delete) are intentionally not in this CLI — use the SDK or
8
+ * dashboard, which give you progress + crop UX.
9
+ *
10
+ * Auth: Bearer stk_… via `resolveSharedOpts({requireCompanySlug: true})`.
11
+ * Every path is prefixed `/api/companies/<slug>`.
12
+ */
13
+
14
+ import {
15
+ parseFlags,
16
+ resolveSharedOpts,
17
+ apiFetch,
18
+ fail,
19
+ info,
20
+ c,
21
+ type SharedOpts,
22
+ } from "./args"
23
+ import { printRows, printDetail, type Column } from "./format"
24
+
25
+ // ── Types (loose — match server response shape) ─────────────────────────
26
+
27
+ interface Bucket {
28
+ id: string
29
+ name: string
30
+ slug: string
31
+ description?: string | null
32
+ isPublic: boolean
33
+ storageUsed: number
34
+ fileCount: number
35
+ createdAt: string
36
+ updatedAt?: string
37
+ [k: string]: unknown
38
+ }
39
+
40
+ interface Media {
41
+ id: string
42
+ bucketId?: string
43
+ originalName: string
44
+ fileName?: string
45
+ type: string
46
+ mimeType: string
47
+ size: number
48
+ folder?: string | null
49
+ isPublic: boolean
50
+ url?: string
51
+ imageMeta?: Record<string, unknown> | null
52
+ audioMeta?: Record<string, unknown> | null
53
+ videoMeta?: Record<string, unknown> | null
54
+ tags?: string[]
55
+ createdAt?: string
56
+ [k: string]: unknown
57
+ }
58
+
59
+ interface MediaListResponse {
60
+ items: Media[]
61
+ total: number
62
+ limit: number
63
+ skip: number
64
+ sort?: string
65
+ dir?: string
66
+ }
67
+
68
+ interface QuotaResponse {
69
+ used: number
70
+ limit: number
71
+ remaining: number
72
+ }
73
+
74
+ interface UsageResponse {
75
+ quota: QuotaResponse
76
+ buckets: Array<{
77
+ id: string
78
+ name: string
79
+ slug: string
80
+ storageUsed: number
81
+ fileCount: number
82
+ }>
83
+ byType: Record<string, { count: number; size: number }>
84
+ timeSeries: Array<{ date: string; size: number; count: number }>
85
+ recent: Media[]
86
+ }
87
+
88
+ // ── Helpers ─────────────────────────────────────────────────────────────
89
+
90
+ function humanBytes(n: number | null | undefined): string {
91
+ if (n === null || n === undefined || !Number.isFinite(n)) return "—"
92
+ if (n < 1024) return `${n} B`
93
+ const units = ["KB", "MB", "GB", "TB"]
94
+ let v = n / 1024
95
+ let i = 0
96
+ while (v >= 1024 && i < units.length - 1) {
97
+ v /= 1024
98
+ i++
99
+ }
100
+ return `${v.toFixed(1)} ${units[i]}`
101
+ }
102
+
103
+ function yesNo(v: unknown): string {
104
+ return v ? "yes" : "no"
105
+ }
106
+
107
+ function shortDate(v: unknown): string {
108
+ if (typeof v !== "string" || v.length < 10) return ""
109
+ return v.slice(0, 10)
110
+ }
111
+
112
+ function companyPrefix(shared: SharedOpts): string {
113
+ if (!shared.companySlug) {
114
+ fail("internal: company slug missing after resolveSharedOpts guard")
115
+ }
116
+ return `/api/companies/${encodeURIComponent(shared.companySlug)}`
117
+ }
118
+
119
+ /** Append flag-derived query params; only emits keys whose flag is set. */
120
+ function buildQuery(
121
+ flags: Record<string, string | boolean>,
122
+ keys: string[],
123
+ ): string {
124
+ const params: string[] = []
125
+ for (const k of keys) {
126
+ const raw = flags[k]
127
+ if (raw === undefined || raw === false) continue
128
+ const value = raw === true ? "" : String(raw)
129
+ if (!value) continue
130
+ params.push(`${encodeURIComponent(k)}=${encodeURIComponent(value)}`)
131
+ }
132
+ return params.length ? `?${params.join("&")}` : ""
133
+ }
134
+
135
+ // ── Column specs ────────────────────────────────────────────────────────
136
+
137
+ const BUCKET_COLUMNS: Column<Bucket>[] = [
138
+ { header: "ID", get: (b) => b.id, maxWidth: 24 },
139
+ { header: "NAME", get: (b) => b.name, maxWidth: 32 },
140
+ { header: "SLUG", get: (b) => b.slug, maxWidth: 32 },
141
+ { header: "PUBLIC", get: (b) => yesNo(b.isPublic), maxWidth: 6 },
142
+ { header: "USED", get: (b) => humanBytes(b.storageUsed), alignRight: true },
143
+ { header: "FILES", get: (b) => b.fileCount, alignRight: true },
144
+ { header: "CREATED", get: (b) => shortDate(b.createdAt), maxWidth: 10 },
145
+ ]
146
+
147
+ const MEDIA_COLUMNS: Column<Media>[] = [
148
+ { header: "ID", get: (m) => m.id, maxWidth: 24 },
149
+ { header: "NAME", get: (m) => m.originalName, maxWidth: 40 },
150
+ { header: "TYPE", get: (m) => m.type, maxWidth: 12 },
151
+ { header: "MIME", get: (m) => m.mimeType, maxWidth: 24 },
152
+ { header: "SIZE", get: (m) => humanBytes(m.size), alignRight: true },
153
+ { header: "FOLDER", get: (m) => m.folder ?? "", maxWidth: 24 },
154
+ { header: "PUBLIC", get: (m) => yesNo(m.isPublic), maxWidth: 6 },
155
+ ]
156
+
157
+ // ── Subcommand handlers ─────────────────────────────────────────────────
158
+
159
+ async function bucketsList(argv: string[]): Promise<void> {
160
+ const { flags } = parseFlags(argv)
161
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
162
+ const data = await apiFetch<Bucket[] | { items: Bucket[] }>(
163
+ shared,
164
+ `${companyPrefix(shared)}/buckets`,
165
+ )
166
+ const rows = Array.isArray(data) ? data : (data?.items ?? [])
167
+ printRows(rows, BUCKET_COLUMNS, flags)
168
+ }
169
+
170
+ async function bucketsGet(argv: string[]): Promise<void> {
171
+ const { positional, flags } = parseFlags(argv)
172
+ const bucketSlug = positional[0]
173
+ if (!bucketSlug) {
174
+ fail("usage: sentroy storage buckets get <bucketSlug>")
175
+ }
176
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
177
+ const data = await apiFetch<Bucket>(
178
+ shared,
179
+ `${companyPrefix(shared)}/buckets/${encodeURIComponent(bucketSlug)}`,
180
+ )
181
+ printDetail(data as unknown as Record<string, unknown>, flags)
182
+ }
183
+
184
+ async function mediaList(argv: string[]): Promise<void> {
185
+ const { positional, flags } = parseFlags(argv)
186
+ const bucketSlug = positional[0]
187
+ if (!bucketSlug) {
188
+ fail(
189
+ "usage: sentroy storage media list <bucketSlug> [--type= --folder= --q= --sort= --dir= --limit= --skip=]",
190
+ )
191
+ }
192
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
193
+ const query = buildQuery(flags, [
194
+ "type",
195
+ "folder",
196
+ "q",
197
+ "sort",
198
+ "dir",
199
+ "limit",
200
+ "skip",
201
+ ])
202
+ const data = await apiFetch<MediaListResponse>(
203
+ shared,
204
+ `${companyPrefix(shared)}/buckets/${encodeURIComponent(bucketSlug)}/media${query}`,
205
+ )
206
+ const items = data?.items ?? []
207
+ printRows(items, MEDIA_COLUMNS, flags)
208
+
209
+ // Footer only in table mode — JSON consumers get total/limit/skip in payload.
210
+ const output =
211
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
212
+ if (output !== "json") {
213
+ const total = data?.total ?? items.length
214
+ const limit = data?.limit ?? items.length
215
+ const skip = data?.skip ?? 0
216
+ const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1
217
+ const pages = limit > 0 ? Math.max(1, Math.ceil(total / limit)) : 1
218
+ info(`Total: ${total} item${total === 1 ? "" : "s"}, page ${page}/${pages}`)
219
+ }
220
+ }
221
+
222
+ async function mediaGet(argv: string[]): Promise<void> {
223
+ const { positional, flags } = parseFlags(argv)
224
+ const bucketSlug = positional[0]
225
+ const mediaId = positional[1]
226
+ if (!bucketSlug || !mediaId) {
227
+ fail("usage: sentroy storage media get <bucketSlug> <mediaId>")
228
+ }
229
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
230
+ const data = await apiFetch<Media>(
231
+ shared,
232
+ `${companyPrefix(shared)}/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}`,
233
+ )
234
+ printDetail(data as unknown as Record<string, unknown>, flags)
235
+ }
236
+
237
+ async function usage(argv: string[]): Promise<void> {
238
+ const { flags } = parseFlags(argv)
239
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
240
+ const data = await apiFetch<UsageResponse>(
241
+ shared,
242
+ `${companyPrefix(shared)}/usage`,
243
+ )
244
+
245
+ const output =
246
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
247
+ if (output === "json") {
248
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n")
249
+ return
250
+ }
251
+
252
+ // Table mode renders quota summary only — the nested arrays
253
+ // (buckets, byType, timeSeries, recent) bloat the terminal; nudge to
254
+ // --output=json for the full picture.
255
+ const quota = data?.quota ?? { used: 0, limit: 0, remaining: 0 }
256
+ const pct =
257
+ quota.limit > 0 ? ((quota.used / quota.limit) * 100).toFixed(1) + "%" : "—"
258
+ printDetail(
259
+ {
260
+ used: humanBytes(quota.used),
261
+ limit: humanBytes(quota.limit),
262
+ remaining: humanBytes(quota.remaining),
263
+ utilization: pct,
264
+ buckets: data?.buckets?.length ?? 0,
265
+ recentItems: data?.recent?.length ?? 0,
266
+ },
267
+ flags,
268
+ )
269
+ info(`Pass ${c.cyan("--output=json")} for the full byType / timeSeries / recent payload.`)
270
+ }
271
+
272
+ async function quota(argv: string[]): Promise<void> {
273
+ const { flags } = parseFlags(argv)
274
+ const shared = resolveSharedOpts(flags, { requireCompanySlug: true })
275
+ const data = await apiFetch<QuotaResponse>(
276
+ shared,
277
+ `${companyPrefix(shared)}/storage-quota`,
278
+ )
279
+ const output =
280
+ typeof flags.output === "string" ? flags.output.toLowerCase() : "table"
281
+ if (output === "json") {
282
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n")
283
+ return
284
+ }
285
+ const pct =
286
+ data.limit > 0 ? ((data.used / data.limit) * 100).toFixed(1) + "%" : "—"
287
+ printDetail(
288
+ {
289
+ used: humanBytes(data.used),
290
+ limit: humanBytes(data.limit),
291
+ remaining: humanBytes(data.remaining),
292
+ utilization: pct,
293
+ },
294
+ flags,
295
+ )
296
+ }
297
+
298
+ // ── Export ──────────────────────────────────────────────────────────────
299
+
300
+ export const STORAGE_HANDLERS = {
301
+ bucketsList,
302
+ bucketsGet,
303
+ mediaList,
304
+ mediaGet,
305
+ usage,
306
+ quota,
307
+ }