@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.
- package/README.md +53 -5
- package/dist/auth/client.d.ts.map +1 -1
- package/dist/auth/client.js +8 -1
- package/dist/auth/client.js.map +1 -1
- package/dist/auth/react/index.js +1 -1
- package/dist/auth/react/index.js.map +1 -1
- package/dist/auth/react-native/index.d.ts +93 -0
- package/dist/auth/react-native/index.d.ts.map +1 -0
- package/dist/auth/react-native/index.js +106 -0
- package/dist/auth/react-native/index.js.map +1 -0
- package/dist/cli/ai.d.ts +35 -0
- package/dist/cli/ai.d.ts.map +1 -0
- package/dist/cli/ai.js +399 -0
- package/dist/cli/ai.js.map +1 -0
- package/dist/cli/args.d.ts +62 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +199 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/env.d.ts.map +1 -1
- package/dist/cli/env.js +8 -2
- package/dist/cli/env.js.map +1 -1
- package/dist/cli/format.d.ts +37 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/format.js +129 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +128 -25
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mail.d.ts +25 -0
- package/dist/cli/mail.d.ts.map +1 -0
- package/dist/cli/mail.js +253 -0
- package/dist/cli/mail.js.map +1 -0
- package/dist/cli/storage.d.ts +28 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/cli/storage.js +189 -0
- package/dist/cli/storage.js.map +1 -0
- package/package.json +9 -2
- package/skill/SKILL.md +577 -0
- package/src/auth/client.ts +8 -1
- package/src/auth/react/index.tsx +1 -1
- package/src/auth/react-native/index.ts +157 -0
- package/src/cli/ai.ts +440 -0
- package/src/cli/args.ts +225 -0
- package/src/cli/env.ts +10 -2
- package/src/cli/format.ts +147 -0
- package/src/cli/index.ts +147 -25
- package/src/cli/mail.ts +363 -0
- package/src/cli/storage.ts +307 -0
package/src/cli/mail.ts
ADDED
|
@@ -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
|
+
}
|