@inboxychat/cli 0.0.0-stage → 0.2.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 +178 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +76 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +624 -0
- package/dist/cli.js.map +1 -0
- package/dist/device-login.d.ts +9 -0
- package/dist/device-login.d.ts.map +1 -0
- package/dist/device-login.js +214 -0
- package/dist/device-login.js.map +1 -0
- package/dist/doctor.d.ts +32 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +127 -0
- package/dist/doctor.js.map +1 -0
- package/dist/output.d.ts +67 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +161 -0
- package/dist/output.js.map +1 -0
- package/package.json +33 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `inboxy` command line (local, scoped).
|
|
3
|
+
*
|
|
4
|
+
* Inputs are parsed with node:util parseArgs and only ever reach the network
|
|
5
|
+
* as validated, percent-encoded path segments or URLSearchParams values.
|
|
6
|
+
* Nothing here spawns a shell; the only child processes are the OS keychain
|
|
7
|
+
* tools, started with argument arrays by @inboxychat/sdk.
|
|
8
|
+
*/
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { describeOperations, prepareOperation, CONTACT_STATUSES, FULFILLMENT_STATUSES, InboxyClient, InboxyError, LIFECYCLE_STAGES, ORDER_SOURCES, ORDER_STATUSES, PAGE_LIMIT_MAX, PAYMENT_STATUSES, PUBLIC_CAPABILITIES, collectPages, loadConfig, looksLikeInboxyKey, normalizeBaseUrl, resolveCredentials, saveConfig, systemKeychain, validateProfileName, } from '@inboxychat/sdk';
|
|
11
|
+
import { loginWithDeviceFlow } from './device-login.js';
|
|
12
|
+
import { runDoctor } from './doctor.js';
|
|
13
|
+
import { EXIT, Output, UsageError, table } from './output.js';
|
|
14
|
+
export const CLI_VERSION = '0.2.0';
|
|
15
|
+
const HELP = `inboxy ${CLI_VERSION} — scoped command line for Inboxy
|
|
16
|
+
|
|
17
|
+
Usage: inboxy <command> [options]
|
|
18
|
+
|
|
19
|
+
Account
|
|
20
|
+
auth login [--store keychain|file] [--no-verify] Sign in (browser on a terminal; a key on stdin otherwise)
|
|
21
|
+
auth logout Remove the stored key of the profile
|
|
22
|
+
auth status [--offline] Where the key comes from, and whether Inboxy accepts it
|
|
23
|
+
whoami Company, key name, scopes, expiry, rate limits
|
|
24
|
+
usage Plan usage against limits
|
|
25
|
+
stores list Connected-store count against the plan limit
|
|
26
|
+
doctor Check URL, key, scopes, clock skew and config permissions
|
|
27
|
+
|
|
28
|
+
Data
|
|
29
|
+
orders list [filters] [paging] --status --payment-status --fulfillment-status --source
|
|
30
|
+
--store <id> --contact <id> --search <text>
|
|
31
|
+
--after <iso> --before <iso>
|
|
32
|
+
orders get <id>
|
|
33
|
+
contacts search [text] [paging] --status --stage --tags <ids> --created-after --created-before
|
|
34
|
+
|
|
35
|
+
Operations
|
|
36
|
+
operations list
|
|
37
|
+
operations inspect <operation>
|
|
38
|
+
operations preview <operation> --input <json> --idempotency-key <key>
|
|
39
|
+
operations run <operation> --input <json> --idempotency-key <key> --confirm <preview-hash>
|
|
40
|
+
campaigns send <operation> --input <json> --idempotency-key <key> --confirm <preview-hash>
|
|
41
|
+
campaigns schedule --input <json> --idempotency-key <key> --confirm <preview-hash>
|
|
42
|
+
lifecycle activate <operation> --input <json> --idempotency-key <key> --confirm <preview-hash>
|
|
43
|
+
messages send --input <json> --idempotency-key <key> --confirm <preview-hash>
|
|
44
|
+
Mutations need explicit approval of the exact preview. No automatic retries.
|
|
45
|
+
|
|
46
|
+
Paging
|
|
47
|
+
--limit <1-${PAGE_LIMIT_MAX}> --cursor <nextCursor> --all [--max-pages <n>, default 10]
|
|
48
|
+
|
|
49
|
+
Global options
|
|
50
|
+
--json One JSON document on stdout (schemaVersion 1)
|
|
51
|
+
--profile <name> Use a stored profile (one per company; default "default", or INBOXY_PROFILE)
|
|
52
|
+
--url <origin> API origin (default INBOXY_API_URL, the profile, or https://api.inboxy.chat)
|
|
53
|
+
--timeout <ms> Per-request timeout (default 20000)
|
|
54
|
+
-h, --help This help --version Print the version
|
|
55
|
+
|
|
56
|
+
Credentials: INBOXY_API_KEY, else the OS keychain entry written by \`auth login\`,
|
|
57
|
+
else (machines without a keychain) the 0600 config file. The key is never printed.
|
|
58
|
+
Exit codes: 0 ok, 2 usage, 3 config, 4 key refused, 5 forbidden, 6 not found,
|
|
59
|
+
7 rate limited, 8 network/timeout, 9 server, 10 doctor checks failed, 1 unexpected.`;
|
|
60
|
+
const KEY_ON_COMMAND_LINE = 'The API key is never passed as an argument (it would stay in shell history). On a terminal, `inboxy auth login` opens the browser. Otherwise paste it at the hidden prompt, or pipe it: `pbpaste | inboxy auth login`.';
|
|
61
|
+
const GLOBAL_OPTIONS = {
|
|
62
|
+
json: { type: 'boolean' },
|
|
63
|
+
profile: { type: 'string' },
|
|
64
|
+
url: { type: 'string' },
|
|
65
|
+
timeout: { type: 'string' },
|
|
66
|
+
help: { type: 'boolean', short: 'h' },
|
|
67
|
+
version: { type: 'boolean' },
|
|
68
|
+
};
|
|
69
|
+
const PAGING_OPTIONS = {
|
|
70
|
+
limit: { type: 'string' },
|
|
71
|
+
cursor: { type: 'string' },
|
|
72
|
+
all: { type: 'boolean' },
|
|
73
|
+
'max-pages': { type: 'string' },
|
|
74
|
+
};
|
|
75
|
+
function intOption(value, name, min, max) {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!/^\d+$/.test(value))
|
|
79
|
+
throw new UsageError(`--${name} must be a whole number.`);
|
|
80
|
+
const n = Number(value);
|
|
81
|
+
if (n < min || n > max)
|
|
82
|
+
throw new UsageError(`--${name} must be between ${min} and ${max}.`);
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
function enumOption(value, name, allowed) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!allowed.includes(value)) {
|
|
89
|
+
throw new UsageError(`--${name} must be one of: ${allowed.join(', ')}.`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
function isoOption(value, name) {
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (Number.isNaN(Date.parse(value)))
|
|
97
|
+
throw new UsageError(`--${name} must be an ISO-8601 date or date-time.`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Splits `argv` into the command words and the rest, without interpreting
|
|
102
|
+
* values. Global options may come before the command (`inboxy --profile a whoami`).
|
|
103
|
+
*/
|
|
104
|
+
const GLOBAL_VALUE_FLAGS = new Set(['--profile', '--url', '--timeout']);
|
|
105
|
+
function commandWords(argv) {
|
|
106
|
+
const words = [];
|
|
107
|
+
const rest = [];
|
|
108
|
+
let i = 0;
|
|
109
|
+
while (i < argv.length && words.length < 2) {
|
|
110
|
+
const arg = argv[i];
|
|
111
|
+
if (GLOBAL_VALUE_FLAGS.has(arg)) {
|
|
112
|
+
rest.push(arg, ...(i + 1 < argv.length ? [argv[i + 1]] : []));
|
|
113
|
+
i += 2;
|
|
114
|
+
}
|
|
115
|
+
else if (arg === '--json') {
|
|
116
|
+
rest.push(arg);
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
else if (arg.startsWith('-')) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
words.push(arg);
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { words, rest: [...rest, ...argv.slice(i)] };
|
|
128
|
+
}
|
|
129
|
+
/** Commands that only touch local configuration (no capability behind them). */
|
|
130
|
+
export const LOCAL_COMMANDS = ['auth login', 'auth logout', 'auth status', 'doctor'];
|
|
131
|
+
/**
|
|
132
|
+
* Data commands: each is the adapter of one registered capability (UX26-F,
|
|
133
|
+
* apps/api/src/lib/capabilities). A command without one is not offered.
|
|
134
|
+
*/
|
|
135
|
+
export const DATA_COMMANDS = PUBLIC_CAPABILITIES.flatMap((c) => c.cliCommand ? [c.cliCommand] : []);
|
|
136
|
+
export const OPERATION_COMMANDS = [
|
|
137
|
+
'operations list',
|
|
138
|
+
'operations inspect',
|
|
139
|
+
'operations preview',
|
|
140
|
+
'operations run',
|
|
141
|
+
'messages send',
|
|
142
|
+
'lifecycle activate',
|
|
143
|
+
'campaigns send',
|
|
144
|
+
'campaigns schedule',
|
|
145
|
+
];
|
|
146
|
+
const COMMANDS = new Set([...LOCAL_COMMANDS, ...DATA_COMMANDS, ...OPERATION_COMMANDS]);
|
|
147
|
+
function matchCommand(words) {
|
|
148
|
+
if (words.length >= 2 && COMMANDS.has(`${words[0]} ${words[1]}`)) {
|
|
149
|
+
return { name: `${words[0]} ${words[1]}`, positionals: [] };
|
|
150
|
+
}
|
|
151
|
+
if (words.length >= 1 && COMMANDS.has(words[0])) {
|
|
152
|
+
return { name: words[0], positionals: words.slice(1) };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
export async function runCli(argv, io, deps = {}) {
|
|
157
|
+
const wantsJson = argv.includes('--json');
|
|
158
|
+
const { words, rest } = commandWords(argv);
|
|
159
|
+
const matched = matchCommand(words);
|
|
160
|
+
const commandName = matched?.name ?? words.join(' ');
|
|
161
|
+
let activeKey = null;
|
|
162
|
+
const out = new Output(io, wantsJson, commandName || 'help', () => activeKey ? [activeKey] : []);
|
|
163
|
+
try {
|
|
164
|
+
if (!matched) {
|
|
165
|
+
if (argv.includes('--version')) {
|
|
166
|
+
io.stdout(`${CLI_VERSION}\n`);
|
|
167
|
+
return EXIT.OK;
|
|
168
|
+
}
|
|
169
|
+
if (words.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
170
|
+
io.stdout(`${HELP}\n`);
|
|
171
|
+
return words.length === 0 && !argv.includes('--help') && !argv.includes('-h')
|
|
172
|
+
? EXIT.USAGE
|
|
173
|
+
: EXIT.OK;
|
|
174
|
+
}
|
|
175
|
+
throw new UsageError(`Unknown command "${words.join(' ')}".`);
|
|
176
|
+
}
|
|
177
|
+
const extra = {};
|
|
178
|
+
const name = matched.name;
|
|
179
|
+
if (name.startsWith('operations ') ||
|
|
180
|
+
name === 'messages send' ||
|
|
181
|
+
name === 'lifecycle activate' ||
|
|
182
|
+
name === 'campaigns send' ||
|
|
183
|
+
name === 'campaigns schedule') {
|
|
184
|
+
Object.assign(extra, {
|
|
185
|
+
input: { type: 'string' },
|
|
186
|
+
'idempotency-key': { type: 'string' },
|
|
187
|
+
confirm: { type: 'string' },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else if (name === 'orders list') {
|
|
191
|
+
Object.assign(extra, PAGING_OPTIONS, {
|
|
192
|
+
status: { type: 'string' },
|
|
193
|
+
'payment-status': { type: 'string' },
|
|
194
|
+
'fulfillment-status': { type: 'string' },
|
|
195
|
+
source: { type: 'string' },
|
|
196
|
+
store: { type: 'string' },
|
|
197
|
+
contact: { type: 'string' },
|
|
198
|
+
search: { type: 'string' },
|
|
199
|
+
after: { type: 'string' },
|
|
200
|
+
before: { type: 'string' },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else if (name === 'contacts search') {
|
|
204
|
+
Object.assign(extra, PAGING_OPTIONS, {
|
|
205
|
+
status: { type: 'string' },
|
|
206
|
+
stage: { type: 'string' },
|
|
207
|
+
tags: { type: 'string' },
|
|
208
|
+
'created-after': { type: 'string' },
|
|
209
|
+
'created-before': { type: 'string' },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else if (name === 'auth login') {
|
|
213
|
+
Object.assign(extra, { store: { type: 'string' }, 'no-verify': { type: 'boolean' } });
|
|
214
|
+
}
|
|
215
|
+
else if (name === 'auth status') {
|
|
216
|
+
Object.assign(extra, { offline: { type: 'boolean' } });
|
|
217
|
+
}
|
|
218
|
+
// A key typed as an argument stays in shell history and process lists:
|
|
219
|
+
// refuse it before parsing, without echoing what was typed.
|
|
220
|
+
const KEY_FLAG = /^--(key|token|api-key|apikey|secret)(=|$)/i;
|
|
221
|
+
if (rest.some((arg) => KEY_FLAG.test(arg))) {
|
|
222
|
+
throw new UsageError(KEY_ON_COMMAND_LINE);
|
|
223
|
+
}
|
|
224
|
+
let parsed;
|
|
225
|
+
try {
|
|
226
|
+
parsed = parseArgs({
|
|
227
|
+
args: rest,
|
|
228
|
+
options: { ...GLOBAL_OPTIONS, ...extra },
|
|
229
|
+
allowPositionals: true,
|
|
230
|
+
strict: true,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
throw new UsageError(error.message.replace(/\. To specify.*$/s, '.'));
|
|
235
|
+
}
|
|
236
|
+
const opts = parsed.values;
|
|
237
|
+
const positionals = [...matched.positionals, ...parsed.positionals];
|
|
238
|
+
if (opts.help) {
|
|
239
|
+
io.stdout(`${HELP}\n`);
|
|
240
|
+
return EXIT.OK;
|
|
241
|
+
}
|
|
242
|
+
const profile = typeof opts.profile === 'string' ? validateProfileName(opts.profile) : undefined;
|
|
243
|
+
const timeoutMs = intOption(opts.timeout, 'timeout', 100, 120_000);
|
|
244
|
+
const env = typeof opts.url === 'string' ? { ...io.env, INBOXY_API_URL: opts.url } : io.env;
|
|
245
|
+
const keychainFor = async () => deps.keychain !== undefined ? deps.keychain : systemKeychain(env);
|
|
246
|
+
const credentials = async () => {
|
|
247
|
+
const creds = await resolveCredentials({ env, profile, keychain: await keychainFor() });
|
|
248
|
+
activeKey = creds.apiKey;
|
|
249
|
+
return creds;
|
|
250
|
+
};
|
|
251
|
+
const clientFrom = (creds) => {
|
|
252
|
+
if (!creds.apiKey) {
|
|
253
|
+
throw new InboxyError({
|
|
254
|
+
kind: 'config',
|
|
255
|
+
code: 'API_KEY_MISSING',
|
|
256
|
+
message: `No API key for profile "${creds.profile}". Set INBOXY_API_KEY or run \`inboxy auth login\`.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return new InboxyClient({
|
|
260
|
+
apiKey: creds.apiKey,
|
|
261
|
+
baseUrl: creds.apiUrl,
|
|
262
|
+
timeoutMs,
|
|
263
|
+
userAgent: `inboxy-cli/${CLI_VERSION}`,
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
const noPositionals = () => {
|
|
267
|
+
if (!positionals.length)
|
|
268
|
+
return;
|
|
269
|
+
// `auth login <key>`: never echo the argument back (it is probably the key).
|
|
270
|
+
if (name === 'auth login')
|
|
271
|
+
throw new UsageError(KEY_ON_COMMAND_LINE);
|
|
272
|
+
throw new UsageError(`Unexpected argument "${positionals[0]}".`);
|
|
273
|
+
};
|
|
274
|
+
if (name.startsWith('operations ') ||
|
|
275
|
+
name === 'messages send' ||
|
|
276
|
+
name === 'lifecycle activate' ||
|
|
277
|
+
name === 'campaigns send' ||
|
|
278
|
+
name === 'campaigns schedule') {
|
|
279
|
+
if (name === 'operations list') {
|
|
280
|
+
noPositionals();
|
|
281
|
+
return out.success(describeOperations(), undefined, () => JSON.stringify(describeOperations(), null, 2));
|
|
282
|
+
}
|
|
283
|
+
if (positionals.length !== (['messages send', 'campaigns schedule'].includes(name) ? 0 : 1))
|
|
284
|
+
throw new UsageError('Supply one operation id, or use messages send with --input.');
|
|
285
|
+
const operation = name === 'messages send'
|
|
286
|
+
? 'inbox.message.send'
|
|
287
|
+
: name === 'campaigns schedule'
|
|
288
|
+
? 'campaigns.schedule'
|
|
289
|
+
: positionals[0];
|
|
290
|
+
if (name === 'operations inspect') {
|
|
291
|
+
const descriptor = describeOperations().find((op) => op.id === operation);
|
|
292
|
+
if (!descriptor)
|
|
293
|
+
throw new UsageError('Unknown operation.');
|
|
294
|
+
return out.success(descriptor, undefined, () => JSON.stringify(descriptor, null, 2));
|
|
295
|
+
}
|
|
296
|
+
let input;
|
|
297
|
+
try {
|
|
298
|
+
input = JSON.parse(typeof opts.input === 'string' ? opts.input : '{}');
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
throw new UsageError('--input must be valid JSON.');
|
|
302
|
+
}
|
|
303
|
+
const idempotencyKey = opts['idempotency-key'];
|
|
304
|
+
const preview = prepareOperation(operation, input, idempotencyKey);
|
|
305
|
+
if (name === 'operations preview')
|
|
306
|
+
return out.success(preview, undefined, () => JSON.stringify(preview, null, 2));
|
|
307
|
+
if (name === 'campaigns send' && !['campaigns.send', 'campaigns.resume'].includes(operation))
|
|
308
|
+
throw new UsageError('Only campaigns.send and campaigns.resume are accepted.');
|
|
309
|
+
if (name === 'lifecycle activate' && preview.effect !== 'activate')
|
|
310
|
+
throw new UsageError('Only activate-effect operations are accepted.');
|
|
311
|
+
if (name === 'operations run' && ['send', 'activate', 'schedule'].includes(preview.effect))
|
|
312
|
+
throw new UsageError('Use messages send, lifecycle activate, campaigns send or campaigns schedule after explicit approval of the exact preview.');
|
|
313
|
+
const result = await clientFrom(await credentials()).executeOperation(operation, input, {
|
|
314
|
+
idempotencyKey,
|
|
315
|
+
confirmation: opts.confirm,
|
|
316
|
+
});
|
|
317
|
+
return out.success(result, undefined, () => JSON.stringify(result, null, 2));
|
|
318
|
+
}
|
|
319
|
+
switch (name) {
|
|
320
|
+
// ------------------------------------------------------------------
|
|
321
|
+
case 'whoami': {
|
|
322
|
+
noPositionals();
|
|
323
|
+
const { data } = await clientFrom(await credentials()).getCompany();
|
|
324
|
+
return out.success(data, undefined, () => [
|
|
325
|
+
`Company ${data.company.name} (${data.company.id})`,
|
|
326
|
+
`Key ${data.token.name} · ${data.token.prefix}…`,
|
|
327
|
+
`Scopes ${data.token.scopes.join(', ') || 'none'}`,
|
|
328
|
+
`Expires ${data.token.expiresAt ?? 'never'}`,
|
|
329
|
+
`Limits ${data.rateLimits.perKey.limit}/min per key · ${data.rateLimits.perCompany.limit}/min per company`,
|
|
330
|
+
].join('\n'));
|
|
331
|
+
}
|
|
332
|
+
// ------------------------------------------------------------------
|
|
333
|
+
case 'usage': {
|
|
334
|
+
noPositionals();
|
|
335
|
+
const { data } = await clientFrom(await credentials()).getUsage();
|
|
336
|
+
return out.success(data, undefined, () => {
|
|
337
|
+
const rows = Object.entries(data.usage).map(([k, c]) => [
|
|
338
|
+
k,
|
|
339
|
+
c.used,
|
|
340
|
+
c.limit === null ? 'unlimited' : c.limit,
|
|
341
|
+
]);
|
|
342
|
+
return [
|
|
343
|
+
`Plan ${data.plan.name} (${data.plan.status}${data.plan.isTrial ? ', trial' : ''}) · period ends ${data.plan.currentPeriodEnd}`,
|
|
344
|
+
table(['counter', 'used', 'limit'], rows),
|
|
345
|
+
`API requests in the last 24 h: ${data.api.requestsLast24h}`,
|
|
346
|
+
].join('\n');
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// ------------------------------------------------------------------
|
|
350
|
+
case 'stores list': {
|
|
351
|
+
noPositionals();
|
|
352
|
+
const { data } = await clientFrom(await credentials()).getUsage();
|
|
353
|
+
const result = {
|
|
354
|
+
stores: data.usage.stores,
|
|
355
|
+
items: null,
|
|
356
|
+
note: 'Public API v1 has no stores route yet; this is the connected-store count from plan usage. Per-store details and health are in the dashboard.',
|
|
357
|
+
};
|
|
358
|
+
return out.success(result, undefined, () => [
|
|
359
|
+
`Connected stores: ${data.usage.stores.used} of ${data.usage.stores.limit ?? 'unlimited'}`,
|
|
360
|
+
result.note,
|
|
361
|
+
].join('\n'));
|
|
362
|
+
}
|
|
363
|
+
// ------------------------------------------------------------------
|
|
364
|
+
case 'orders list': {
|
|
365
|
+
noPositionals();
|
|
366
|
+
const query = {
|
|
367
|
+
status: enumOption(opts.status, 'status', ORDER_STATUSES),
|
|
368
|
+
paymentStatus: enumOption(opts['payment-status'], 'payment-status', PAYMENT_STATUSES),
|
|
369
|
+
fulfillmentStatus: enumOption(opts['fulfillment-status'], 'fulfillment-status', FULFILLMENT_STATUSES),
|
|
370
|
+
source: enumOption(opts.source, 'source', ORDER_SOURCES),
|
|
371
|
+
storeId: opts.store,
|
|
372
|
+
contactId: opts.contact,
|
|
373
|
+
search: opts.search,
|
|
374
|
+
dateAfter: isoOption(opts.after, 'after'),
|
|
375
|
+
dateBefore: isoOption(opts.before, 'before'),
|
|
376
|
+
limit: intOption(opts.limit, 'limit', 1, PAGE_LIMIT_MAX),
|
|
377
|
+
cursor: opts.cursor,
|
|
378
|
+
};
|
|
379
|
+
const client = clientFrom(await credentials());
|
|
380
|
+
const page = await listPages((q) => client.listOrders(q), query, opts);
|
|
381
|
+
return out.success(page.items, page.meta, () => [
|
|
382
|
+
table(['id', 'number', 'status', 'payment', 'total', 'customer', 'date'], page.items.map((o) => [
|
|
383
|
+
o.id,
|
|
384
|
+
o.orderNumber,
|
|
385
|
+
o.status,
|
|
386
|
+
o.paymentStatus,
|
|
387
|
+
`${o.total} ${o.currency}`,
|
|
388
|
+
o.contact?.name,
|
|
389
|
+
o.orderDate.slice(0, 10),
|
|
390
|
+
])),
|
|
391
|
+
pagingHint(page.meta),
|
|
392
|
+
]
|
|
393
|
+
.filter(Boolean)
|
|
394
|
+
.join('\n'));
|
|
395
|
+
}
|
|
396
|
+
case 'orders get': {
|
|
397
|
+
if (positionals.length !== 1)
|
|
398
|
+
throw new UsageError('Usage: inboxy orders get <id>');
|
|
399
|
+
const { data: o } = await clientFrom(await credentials()).getOrder(positionals[0]);
|
|
400
|
+
return out.success(o, undefined, () => [
|
|
401
|
+
`Order ${o.orderNumber} (${o.id}) · ${o.source}`,
|
|
402
|
+
`Status ${o.status} · payment ${o.paymentStatus} · fulfillment ${o.fulfillmentStatus} · confirmation ${o.confirmationStatus}`,
|
|
403
|
+
`Total ${o.total} ${o.currency} (subtotal ${o.subtotal}, shipping ${o.shipping ?? '-'}, discount ${o.discount ?? '-'})`,
|
|
404
|
+
`Customer ${o.contact ? `${o.contact.name} ${o.contact.phone ?? ''}`.trim() : '-'}`,
|
|
405
|
+
`Date ${o.orderDate}`,
|
|
406
|
+
table(['item', 'sku', 'qty', 'total'], o.items.map((i) => [
|
|
407
|
+
i.variant ? `${i.name} (${i.variant})` : i.name,
|
|
408
|
+
i.sku,
|
|
409
|
+
i.quantity,
|
|
410
|
+
i.totalPrice,
|
|
411
|
+
])),
|
|
412
|
+
].join('\n'));
|
|
413
|
+
}
|
|
414
|
+
// ------------------------------------------------------------------
|
|
415
|
+
case 'contacts search': {
|
|
416
|
+
if (positionals.length > 1)
|
|
417
|
+
throw new UsageError('Usage: inboxy contacts search [text]');
|
|
418
|
+
const query = {
|
|
419
|
+
search: positionals[0],
|
|
420
|
+
status: enumOption(opts.status, 'status', CONTACT_STATUSES),
|
|
421
|
+
lifecycleStage: enumOption(opts.stage, 'stage', LIFECYCLE_STAGES),
|
|
422
|
+
tagIds: opts.tags,
|
|
423
|
+
createdAfter: isoOption(opts['created-after'], 'created-after'),
|
|
424
|
+
createdBefore: isoOption(opts['created-before'], 'created-before'),
|
|
425
|
+
limit: intOption(opts.limit, 'limit', 1, PAGE_LIMIT_MAX),
|
|
426
|
+
cursor: opts.cursor,
|
|
427
|
+
};
|
|
428
|
+
const client = clientFrom(await credentials());
|
|
429
|
+
const page = await listPages((q) => client.listContacts(q), query, opts);
|
|
430
|
+
return out.success(page.items, page.meta, () => [
|
|
431
|
+
table(['id', 'name', 'phone', 'email', 'stage', 'orders'], page.items.map((c) => [
|
|
432
|
+
c.id,
|
|
433
|
+
c.name,
|
|
434
|
+
c.phone,
|
|
435
|
+
c.email,
|
|
436
|
+
c.lifecycleStage,
|
|
437
|
+
c.totalOrders,
|
|
438
|
+
])),
|
|
439
|
+
pagingHint(page.meta),
|
|
440
|
+
]
|
|
441
|
+
.filter(Boolean)
|
|
442
|
+
.join('\n'));
|
|
443
|
+
}
|
|
444
|
+
// ------------------------------------------------------------------
|
|
445
|
+
case 'doctor': {
|
|
446
|
+
noPositionals();
|
|
447
|
+
let creds;
|
|
448
|
+
let credentialError;
|
|
449
|
+
try {
|
|
450
|
+
creds = await credentials();
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
// A refused config (e.g. a key in a world-readable file) is a failed
|
|
454
|
+
// check, not a crash.
|
|
455
|
+
if (!(error instanceof InboxyError) || error.kind !== 'config')
|
|
456
|
+
throw error;
|
|
457
|
+
credentialError = `${error.code}: ${error.message}`;
|
|
458
|
+
creds = {
|
|
459
|
+
profile: profile ?? env.INBOXY_PROFILE ?? 'default',
|
|
460
|
+
apiKey: null,
|
|
461
|
+
keySource: 'none',
|
|
462
|
+
apiUrl: env.INBOXY_API_URL ?? 'https://api.inboxy.chat',
|
|
463
|
+
urlSource: env.INBOXY_API_URL ? 'env' : 'default',
|
|
464
|
+
configPath: '',
|
|
465
|
+
warnings: [],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const report = await runDoctor(creds, { env, timeoutMs, now: io.now, credentialError });
|
|
469
|
+
const code = out.success(report, undefined, () => [
|
|
470
|
+
`Profile "${report.profile}" · ${report.apiUrl}`,
|
|
471
|
+
...report.checks.map((c) => `${c.status.toUpperCase().padEnd(4)} ${c.id.padEnd(14)} ${c.message}`),
|
|
472
|
+
report.ok ? 'All checks passed.' : 'Some checks failed.',
|
|
473
|
+
].join('\n'));
|
|
474
|
+
return report.ok ? code : EXIT.CHECKS_FAILED;
|
|
475
|
+
}
|
|
476
|
+
// ------------------------------------------------------------------
|
|
477
|
+
case 'auth status': {
|
|
478
|
+
noPositionals();
|
|
479
|
+
const creds = await credentials();
|
|
480
|
+
const status = {
|
|
481
|
+
profile: creds.profile,
|
|
482
|
+
apiUrl: creds.apiUrl,
|
|
483
|
+
urlSource: creds.urlSource,
|
|
484
|
+
keySource: creds.keySource,
|
|
485
|
+
keyPrefix: creds.apiKey ? `${creds.apiKey.slice(0, 8)}…` : null,
|
|
486
|
+
verified: null,
|
|
487
|
+
company: null,
|
|
488
|
+
};
|
|
489
|
+
if (!creds.apiKey) {
|
|
490
|
+
throw new InboxyError({
|
|
491
|
+
kind: 'config',
|
|
492
|
+
code: 'API_KEY_MISSING',
|
|
493
|
+
message: `Not signed in (profile "${creds.profile}"). Run \`inboxy auth login\` or set INBOXY_API_KEY.`,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (!opts.offline) {
|
|
497
|
+
const { data } = await clientFrom(creds).getCompany();
|
|
498
|
+
status.verified = true;
|
|
499
|
+
status.company = { id: data.company.id, name: data.company.name };
|
|
500
|
+
}
|
|
501
|
+
return out.success(status, undefined, () => [
|
|
502
|
+
`Profile ${creds.profile}`,
|
|
503
|
+
`API ${creds.apiUrl} (from ${creds.urlSource})`,
|
|
504
|
+
`Key ${status.keyPrefix} (from ${creds.keySource})`,
|
|
505
|
+
status.company
|
|
506
|
+
? `Verified ${status.company.name} accepts this key`
|
|
507
|
+
: 'Verified skipped (--offline)',
|
|
508
|
+
].join('\n'));
|
|
509
|
+
}
|
|
510
|
+
case 'auth login': {
|
|
511
|
+
noPositionals();
|
|
512
|
+
const profileName = profile ?? io.env.INBOXY_PROFILE ?? 'default';
|
|
513
|
+
validateProfileName(profileName);
|
|
514
|
+
const storeOpt = opts.store;
|
|
515
|
+
if (storeOpt !== undefined && storeOpt !== 'keychain' && storeOpt !== 'file') {
|
|
516
|
+
throw new UsageError('--store must be keychain or file.');
|
|
517
|
+
}
|
|
518
|
+
const apiUrl = normalizeBaseUrl(opts.url ?? io.env.INBOXY_API_URL ?? 'https://api.inboxy.chat');
|
|
519
|
+
const key = io.interactive
|
|
520
|
+
? await loginWithDeviceFlow(apiUrl, {
|
|
521
|
+
stderr: (text) => io.stderr(text),
|
|
522
|
+
openUrl: io.openUrl,
|
|
523
|
+
sleep: io.sleep,
|
|
524
|
+
fetch: io.fetch,
|
|
525
|
+
now: () => io.now(),
|
|
526
|
+
})
|
|
527
|
+
: (await io.readSecret('Inboxy API key (input hidden): ')).trim();
|
|
528
|
+
activeKey = key;
|
|
529
|
+
if (!key)
|
|
530
|
+
throw new UsageError('No API key received on stdin.');
|
|
531
|
+
if (!looksLikeInboxyKey(key)) {
|
|
532
|
+
throw new InboxyError({
|
|
533
|
+
kind: 'config',
|
|
534
|
+
code: 'API_KEY_INVALID_FORMAT',
|
|
535
|
+
message: 'That does not look like an Inboxy API key (inboxy_ followed by 64 hex characters).',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
let company = null;
|
|
539
|
+
if (!opts['no-verify']) {
|
|
540
|
+
const client = new InboxyClient({
|
|
541
|
+
apiKey: key,
|
|
542
|
+
baseUrl: apiUrl,
|
|
543
|
+
timeoutMs,
|
|
544
|
+
userAgent: `inboxy-cli/${CLI_VERSION}`,
|
|
545
|
+
});
|
|
546
|
+
const { data } = await client.getCompany();
|
|
547
|
+
company = { id: data.company.id, name: data.company.name };
|
|
548
|
+
}
|
|
549
|
+
const keychain = storeOpt === 'file' ? null : await keychainFor();
|
|
550
|
+
if (storeOpt === 'keychain' && !keychain) {
|
|
551
|
+
throw new InboxyError({
|
|
552
|
+
kind: 'config',
|
|
553
|
+
code: 'KEYCHAIN_UNAVAILABLE',
|
|
554
|
+
message: 'No OS keychain is available on this machine. Use --store file, or set INBOXY_API_KEY.',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const loaded = await loadConfig(env);
|
|
558
|
+
const config = loaded.config;
|
|
559
|
+
if (keychain) {
|
|
560
|
+
await keychain.set(profileName, key);
|
|
561
|
+
config.profiles[profileName] = { apiUrl, keyStorage: 'keychain' };
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
config.profiles[profileName] = { apiUrl, keyStorage: 'file', apiKey: key };
|
|
565
|
+
}
|
|
566
|
+
if (!config.defaultProfile)
|
|
567
|
+
config.defaultProfile = profileName;
|
|
568
|
+
const file = await saveConfig(config, env);
|
|
569
|
+
const result = {
|
|
570
|
+
profile: profileName,
|
|
571
|
+
apiUrl,
|
|
572
|
+
keyStorage: keychain ? 'keychain' : 'file',
|
|
573
|
+
keychain: keychain?.name ?? null,
|
|
574
|
+
configPath: file,
|
|
575
|
+
verified: company !== null,
|
|
576
|
+
company,
|
|
577
|
+
};
|
|
578
|
+
if (!keychain)
|
|
579
|
+
out.note(`No OS keychain in use: the key is stored in ${file} (mode 600).`);
|
|
580
|
+
return out.success(result, undefined, () => `Saved profile "${profileName}" (${keychain ? keychain.name : `file ${file}`}).${company ? ` Company: ${company.name}.` : ' Not verified (--no-verify).'}`);
|
|
581
|
+
}
|
|
582
|
+
case 'auth logout': {
|
|
583
|
+
noPositionals();
|
|
584
|
+
const loaded = await loadConfig(env);
|
|
585
|
+
const profileName = profile ?? io.env.INBOXY_PROFILE ?? loaded.config.defaultProfile ?? 'default';
|
|
586
|
+
validateProfileName(profileName);
|
|
587
|
+
const keychain = await keychainFor();
|
|
588
|
+
let removedFromKeychain = false;
|
|
589
|
+
if (keychain)
|
|
590
|
+
removedFromKeychain = await keychain.delete(profileName).catch(() => false);
|
|
591
|
+
const had = profileName in loaded.config.profiles;
|
|
592
|
+
if (had) {
|
|
593
|
+
delete loaded.config.profiles[profileName];
|
|
594
|
+
if (loaded.config.defaultProfile === profileName)
|
|
595
|
+
delete loaded.config.defaultProfile;
|
|
596
|
+
await saveConfig(loaded.config, env);
|
|
597
|
+
}
|
|
598
|
+
const result = { profile: profileName, removedFromKeychain, removedFromConfig: had };
|
|
599
|
+
return out.success(result, undefined, () => had || removedFromKeychain
|
|
600
|
+
? `Removed profile "${profileName}".`
|
|
601
|
+
: `Profile "${profileName}" was not stored.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
throw new UsageError(`Unknown command "${name}".`);
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
return out.failure(error);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function listPages(fetchPage, query, opts) {
|
|
611
|
+
const maxPages = intOption(opts['max-pages'], 'max-pages', 1, 1000);
|
|
612
|
+
if (maxPages !== undefined && !opts.all)
|
|
613
|
+
throw new UsageError('--max-pages needs --all.');
|
|
614
|
+
if (!opts.all) {
|
|
615
|
+
const page = await fetchPage(query);
|
|
616
|
+
return { items: page.data, meta: { ...page.meta, pages: 1 } };
|
|
617
|
+
}
|
|
618
|
+
const all = await collectPages(fetchPage, query, maxPages ?? 10);
|
|
619
|
+
return { items: all.items, meta: { ...all.meta, pages: all.pages } };
|
|
620
|
+
}
|
|
621
|
+
function pagingHint(meta) {
|
|
622
|
+
return meta.hasMore && meta.nextCursor ? `More results: --cursor ${meta.nextCursor}` : '';
|
|
623
|
+
}
|
|
624
|
+
//# sourceMappingURL=cli.js.map
|