shopstack 0.1.0 → 0.2.1

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/src/cli.js ADDED
@@ -0,0 +1,516 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createInterface } from "node:readline/promises";
3
+
4
+ import { ShopstackClient } from "./client.js";
5
+ import { ConfigStore } from "./config.js";
6
+
7
+ const HELP = `Shopstack
8
+
9
+ Account setup:
10
+ shopstack signup
11
+ shopstack signup user --email EMAIL
12
+ shopstack signup developer --email EMAIL
13
+ shopstack signup resume SIGNUP_ID
14
+ shopstack users create --external-id ID [--profile NAME]
15
+ shopstack profiles list
16
+ shopstack profiles use NAME
17
+
18
+ Payment connection:
19
+ shopstack connect list
20
+ shopstack connect link
21
+
22
+ Checkout:
23
+ shopstack checkout create --file checkout.json
24
+ shopstack checkout run --file checkout.json
25
+ shopstack checkout get CHECKOUT_ID
26
+ shopstack checkout cancel CHECKOUT_ID
27
+ `;
28
+
29
+ function parseOptions(args, allowed) {
30
+ const positional = [];
31
+ const options = {};
32
+ for (let index = 0; index < args.length; index += 1) {
33
+ const value = args[index];
34
+ if (!value.startsWith("--")) {
35
+ positional.push(value);
36
+ continue;
37
+ }
38
+ const name = value.slice(2);
39
+ if (!allowed.has(name)) throw new Error(`Unknown option: --${name}`);
40
+ const optionValue = args[index + 1];
41
+ if (optionValue === undefined || optionValue.startsWith("--")) {
42
+ throw new Error(`Option --${name} requires a value.`);
43
+ }
44
+ options[name] = optionValue;
45
+ index += 1;
46
+ }
47
+ return { options, positional };
48
+ }
49
+
50
+ function required(options, name) {
51
+ const value = options[name];
52
+ if (typeof value !== "string" || value.length === 0) {
53
+ throw new Error(`Missing required option: --${name}`);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ function isAffirmative(value) {
59
+ const answer = String(value).trim().toLowerCase();
60
+ return answer === "y" || answer === "yes";
61
+ }
62
+
63
+ function writeJson(stream, value) {
64
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
65
+ }
66
+
67
+ async function readJsonFile(path) {
68
+ return JSON.parse(await readFile(path, "utf8"));
69
+ }
70
+
71
+ async function visiblePrompt(message, dependencies) {
72
+ if (dependencies.prompt) return dependencies.prompt(message);
73
+ const input = dependencies.stdin ?? process.stdin;
74
+ const output = dependencies.stdout ?? process.stdout;
75
+ const interface_ = createInterface({ input, output });
76
+ try {
77
+ return await interface_.question(message);
78
+ } finally {
79
+ interface_.close();
80
+ }
81
+ }
82
+
83
+ async function hiddenPrompt(message, dependencies) {
84
+ if (dependencies.secretPrompt) return dependencies.secretPrompt(message);
85
+ const input = dependencies.stdin ?? process.stdin;
86
+ const output = dependencies.stderr ?? process.stderr;
87
+ if (!input.isTTY || typeof input.setRawMode !== "function") {
88
+ throw new Error(
89
+ "Protected payment input requires an interactive terminal.",
90
+ );
91
+ }
92
+ output.write(message);
93
+ return new Promise((resolve, reject) => {
94
+ let value = "";
95
+ const restore = () => {
96
+ input.off("data", onData);
97
+ input.setRawMode(false);
98
+ input.pause();
99
+ output.write("\n");
100
+ };
101
+ const onData = (chunk) => {
102
+ const text = String(chunk);
103
+ for (const character of text) {
104
+ if (character === "\r" || character === "\n") {
105
+ restore();
106
+ resolve(value);
107
+ return;
108
+ }
109
+ if (character === "\u0003") {
110
+ restore();
111
+ reject(new Error("Payment input cancelled."));
112
+ return;
113
+ }
114
+ if (character === "\u007f" || character === "\b") {
115
+ value = value.slice(0, -1);
116
+ } else {
117
+ value += character;
118
+ }
119
+ }
120
+ };
121
+ input.setRawMode(true);
122
+ input.resume();
123
+ input.on("data", onData);
124
+ });
125
+ }
126
+
127
+ function sanitizedAccountResult(result) {
128
+ return {
129
+ account: result.account,
130
+ api_key_saved: true,
131
+ key_type: result.key_type,
132
+ ...(result.user === undefined ? {} : { user: result.user }),
133
+ };
134
+ }
135
+
136
+ function sanitizedUserResult(result, profile) {
137
+ const { api_key: _apiKey, ...user } = result;
138
+ return { api_key_saved: true, profile, user };
139
+ }
140
+
141
+ async function saveVerifiedSignup(result, profileName, configStore) {
142
+ await configStore.saveProfile(
143
+ profileName,
144
+ {
145
+ accountId: result.account.id,
146
+ apiKey: result.api_key,
147
+ keyType: result.key_type,
148
+ ...(result.user?.id === undefined ? {} : { userId: result.user.id }),
149
+ },
150
+ { activate: true },
151
+ );
152
+ }
153
+
154
+ function signupPersistence(configStore, profileName) {
155
+ return {
156
+ async loadPending(input) {
157
+ return typeof configStore.findPendingSignup === "function"
158
+ ? configStore.findPendingSignup(input)
159
+ : undefined;
160
+ },
161
+ async savePending(state) {
162
+ await configStore.savePendingSignup({ ...state, profile: profileName });
163
+ },
164
+ async completePending(state, result) {
165
+ const pendingId = state.attemptId ?? state.signupId ?? state.id;
166
+ if (typeof configStore.completePendingSignup === "function") {
167
+ await configStore.completePendingSignup(
168
+ pendingId,
169
+ profileName,
170
+ result,
171
+ );
172
+ return;
173
+ }
174
+ await saveVerifiedSignup(result, profileName, configStore);
175
+ await configStore.deletePendingSignup(pendingId);
176
+ },
177
+ async deletePending(state) {
178
+ await configStore.deletePendingSignup(
179
+ state.attemptId ?? state.signupId ?? state.id,
180
+ );
181
+ },
182
+ };
183
+ }
184
+
185
+ async function reportSignupProgress(progress, stream) {
186
+ switch (progress.state) {
187
+ case "email_sent":
188
+ stream.write("Verification email sent.\n");
189
+ return;
190
+ case "waiting":
191
+ stream.write("Waiting for verification...\n");
192
+ return;
193
+ case "verified":
194
+ stream.write("✓ Email verified\n");
195
+ return;
196
+ case "complete":
197
+ stream.write("✓ Shopstack account created\n");
198
+ stream.write("✓ Credentials saved securely\n");
199
+ return;
200
+ case "expired":
201
+ stream.write("Signup expired. Start again to receive a new email.\n");
202
+ return;
203
+ case "rate_limited":
204
+ stream.write("Signup is rate-limited. Retry after the indicated delay.\n");
205
+ return;
206
+ case "conflict":
207
+ stream.write("Signup retry state conflicted and was cleared.\n");
208
+ return;
209
+ case "retryable_failure":
210
+ stream.write("Signup paused after a retryable failure. Run signup again to resume.\n");
211
+ return;
212
+ default:
213
+ return;
214
+ }
215
+ }
216
+
217
+ async function activeClient(dependencies, requiredKind = "user") {
218
+ const profile = await dependencies.configStore.activeProfile();
219
+ const environmentKey = process.env.SHOPSTACK_API_KEY;
220
+ const apiKey = environmentKey || profile?.apiKey;
221
+ const keyType = environmentKey ? requiredKind : profile?.keyType;
222
+ if (!apiKey)
223
+ throw new Error("No active Shopstack API key. Run signup first.");
224
+ if (requiredKind && keyType !== requiredKind) {
225
+ throw new Error(`This command requires an active ${requiredKind} profile.`);
226
+ }
227
+ return {
228
+ client: dependencies.clientFactory({
229
+ apiKey,
230
+ baseUrl: process.env.SHOPSTACK_API_URL,
231
+ }),
232
+ profile,
233
+ };
234
+ }
235
+
236
+ export async function runCli(args, supplied = {}) {
237
+ const dependencies = {
238
+ clientFactory: (options) => new ShopstackClient(options),
239
+ configStore: new ConfigStore(),
240
+ confirm: undefined,
241
+ prompt: undefined,
242
+ readJsonFile,
243
+ secretPrompt: undefined,
244
+ stderr: process.stderr,
245
+ stdin: process.stdin,
246
+ stdout: process.stdout,
247
+ ...supplied,
248
+ };
249
+ const [group, action, ...rest] = args;
250
+ if (group === undefined || group === "help" || group === "--help") {
251
+ dependencies.stdout.write(HELP);
252
+ return;
253
+ }
254
+
255
+ if (group === "signup") {
256
+ if (action === "resume") {
257
+ if (rest.length !== 1) {
258
+ throw new Error("Use `shopstack signup resume SIGNUP_ID`.");
259
+ }
260
+ const pending = await dependencies.configStore.pendingSignup(rest[0]);
261
+ if (pending === undefined) {
262
+ throw new Error("That signup is not present in the local profile store.");
263
+ }
264
+ const client = dependencies.clientFactory({
265
+ baseUrl: process.env.SHOPSTACK_API_URL,
266
+ });
267
+ const result = await client.signup({
268
+ accountType: pending.accountType,
269
+ email: pending.email,
270
+ onProgress: (progress) =>
271
+ reportSignupProgress(progress, dependencies.stderr),
272
+ persistence: signupPersistence(
273
+ dependencies.configStore,
274
+ pending.profile,
275
+ ),
276
+ });
277
+ writeJson(dependencies.stdout, {
278
+ ...sanitizedAccountResult(result),
279
+ profile: pending.profile,
280
+ });
281
+ return;
282
+ }
283
+ if (action !== undefined && action !== "user" && action !== "developer") {
284
+ throw new Error(
285
+ "Use `shopstack signup user` or `shopstack signup developer`.",
286
+ );
287
+ }
288
+ const signupArgs = action === undefined ? [] : rest;
289
+ const { options, positional } = parseOptions(signupArgs, new Set(["email", "profile"]));
290
+ if (positional.length > 0) throw new Error("Unexpected signup argument.");
291
+ const pendingCandidates =
292
+ action === undefined &&
293
+ typeof dependencies.configStore.pendingSignups === "function"
294
+ ? await dependencies.configStore.pendingSignups()
295
+ : [];
296
+ const resumable = pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
297
+ let accountType;
298
+ let email;
299
+ if (resumable !== undefined) {
300
+ accountType = resumable.accountType;
301
+ email = resumable.email;
302
+ dependencies.stderr.write("Resuming pending signup.\n");
303
+ } else if (action === undefined) {
304
+ email = String(
305
+ await visiblePrompt("Email: ", dependencies),
306
+ ).trim();
307
+ if (email.length === 0) throw new Error("Email is required.");
308
+ const selected = String(
309
+ await visiblePrompt("Account type (Personal / Developer): ", dependencies),
310
+ )
311
+ .trim()
312
+ .toLowerCase();
313
+ if (selected === "personal" || selected === "user") {
314
+ accountType = "personal";
315
+ } else if (selected === "developer") {
316
+ accountType = "developer";
317
+ } else {
318
+ throw new Error("Account type must be Personal or Developer.");
319
+ }
320
+ } else {
321
+ accountType = action === "user" ? "personal" : "developer";
322
+ email = required(options, "email");
323
+ }
324
+ const client = dependencies.clientFactory({
325
+ baseUrl: process.env.SHOPSTACK_API_URL,
326
+ });
327
+ const profileName =
328
+ options.profile ??
329
+ resumable?.profile ??
330
+ (accountType === "developer" ? "developer" : "default");
331
+ const result = await client.signup({
332
+ accountType,
333
+ email,
334
+ onProgress: (progress) =>
335
+ reportSignupProgress(progress, dependencies.stderr),
336
+ persistence: signupPersistence(dependencies.configStore, profileName),
337
+ });
338
+ writeJson(dependencies.stdout, {
339
+ ...sanitizedAccountResult(result),
340
+ profile: profileName,
341
+ });
342
+ if (accountType === "developer") {
343
+ dependencies.stderr.write(
344
+ "The developer management credential cannot run user checkouts. Create a user profile with `shopstack users create --external-id ID`.\n",
345
+ );
346
+ if (
347
+ action === undefined &&
348
+ isAffirmative(
349
+ await visiblePrompt(
350
+ "Create the first developer-owned user now? (y/N): ",
351
+ dependencies,
352
+ ),
353
+ )
354
+ ) {
355
+ const externalId = String(
356
+ await visiblePrompt("User external ID: ", dependencies),
357
+ ).trim();
358
+ if (externalId.length === 0) {
359
+ throw new Error("User external ID is required.");
360
+ }
361
+ const { client: developerClient, profile } = await activeClient(
362
+ dependencies,
363
+ "developer",
364
+ );
365
+ const user = await developerClient.createUser({ externalId });
366
+ await dependencies.configStore.saveProfile(
367
+ externalId,
368
+ {
369
+ accountId: profile.accountId,
370
+ apiKey: user.api_key,
371
+ keyType: "user",
372
+ userId: user.id,
373
+ },
374
+ { activate: true },
375
+ );
376
+ writeJson(
377
+ dependencies.stdout,
378
+ sanitizedUserResult(user, externalId),
379
+ );
380
+ }
381
+ }
382
+ return;
383
+ }
384
+
385
+ if (group === "users" && action === "create") {
386
+ const { options, positional } = parseOptions(
387
+ rest,
388
+ new Set(["external-id", "profile"]),
389
+ );
390
+ if (positional.length > 0) throw new Error("Unexpected user argument.");
391
+ const externalId = required(options, "external-id");
392
+ const { client, profile } = await activeClient(dependencies, "developer");
393
+ const result = await client.createUser({ externalId });
394
+ const profileName = options.profile ?? externalId;
395
+ await dependencies.configStore.saveProfile(
396
+ profileName,
397
+ {
398
+ accountId: profile.accountId,
399
+ apiKey: result.api_key,
400
+ keyType: "user",
401
+ userId: result.id,
402
+ },
403
+ { activate: true },
404
+ );
405
+ writeJson(dependencies.stdout, sanitizedUserResult(result, profileName));
406
+ return;
407
+ }
408
+
409
+ if (group === "profiles" && action === "list") {
410
+ const config = await dependencies.configStore.load();
411
+ writeJson(dependencies.stdout, {
412
+ active: config.active,
413
+ profiles: Object.entries(config.profiles).map(([name, profile]) => ({
414
+ account_id: profile.accountId,
415
+ key_type: profile.keyType,
416
+ name,
417
+ ...(profile.userId === undefined ? {} : { user_id: profile.userId }),
418
+ })),
419
+ });
420
+ return;
421
+ }
422
+ if (group === "profiles" && action === "use") {
423
+ if (rest.length !== 1) throw new Error("A profile name is required.");
424
+ await dependencies.configStore.useProfile(rest[0]);
425
+ writeJson(dependencies.stdout, { active: rest[0] });
426
+ return;
427
+ }
428
+
429
+ if (group === "connect" && (action === "list" || action === "link")) {
430
+ if (rest.length > 0) throw new Error("Unexpected connection argument.");
431
+ const { client } = await activeClient(dependencies, "user");
432
+ const result =
433
+ action === "list"
434
+ ? await client.listConnections()
435
+ : await client.connect("link");
436
+ writeJson(dependencies.stdout, result);
437
+ return;
438
+ }
439
+
440
+ if (group === "checkout" && action === "create") {
441
+ const { options, positional } = parseOptions(rest, new Set(["file"]));
442
+ if (positional.length > 0) throw new Error("Unexpected checkout argument.");
443
+ const { client } = await activeClient(dependencies, "user");
444
+ const result = await client.createCheckout(
445
+ await dependencies.readJsonFile(required(options, "file")),
446
+ );
447
+ writeJson(dependencies.stdout, result);
448
+ return;
449
+ }
450
+
451
+ if (group === "checkout" && action === "run") {
452
+ const { options, positional } = parseOptions(rest, new Set(["file"]));
453
+ if (positional.length > 0) throw new Error("Unexpected checkout argument.");
454
+ const { client } = await activeClient(dependencies, "user");
455
+ const request = await dependencies.readJsonFile(required(options, "file"));
456
+ const result = await client.runCheckout(request, {
457
+ onProgress: (checkout) => {
458
+ const intent =
459
+ checkout.intent?.name === undefined
460
+ ? ""
461
+ : ` ${checkout.intent.name}:${checkout.intent.phase}`;
462
+ dependencies.stderr.write(
463
+ `[${checkout.status}]${intent} ${checkout.activity ?? ""}`.trimEnd() +
464
+ "\n",
465
+ );
466
+ },
467
+ approve: async (approval) => {
468
+ const summary = [
469
+ `Subtotal: ${approval.subtotal ?? "unavailable"} ${approval.currency}`,
470
+ `Shipping: ${approval.shipping ?? "unavailable"} ${approval.currency}`,
471
+ `Tax: ${approval.tax ?? "unavailable"} ${approval.currency}`,
472
+ `Total: ${approval.total} ${approval.currency}`,
473
+ ].join("\n");
474
+ dependencies.stderr.write(`${summary}\n`);
475
+ if (dependencies.confirm) return dependencies.confirm(approval);
476
+ const answer = await visiblePrompt(
477
+ "Approve this exact payment? [y/N] ",
478
+ dependencies,
479
+ );
480
+ return /^y(?:es)?$/iu.test(answer.trim());
481
+ },
482
+ message: (checkout) =>
483
+ visiblePrompt(
484
+ `${checkout.activity || "Shopstack needs more information"}: `,
485
+ dependencies,
486
+ ),
487
+ paymentDetails: async () => ({
488
+ number: await hiddenPrompt("Card number: ", dependencies),
489
+ exp_month: Number(
490
+ await hiddenPrompt("Expiry month (1-12): ", dependencies),
491
+ ),
492
+ exp_year: Number(
493
+ await hiddenPrompt("Expiry year (YYYY): ", dependencies),
494
+ ),
495
+ cvc: await hiddenPrompt("CVC: ", dependencies),
496
+ }),
497
+ });
498
+ writeJson(dependencies.stdout, result);
499
+ return;
500
+ }
501
+
502
+ if (group === "checkout" && (action === "get" || action === "cancel")) {
503
+ if (rest.length !== 1) throw new Error("A checkout ID is required.");
504
+ const { client } = await activeClient(dependencies, "user");
505
+ const result =
506
+ action === "get"
507
+ ? await client.getCheckout(rest[0])
508
+ : await client.cancelCheckout(rest[0]);
509
+ writeJson(dependencies.stdout, result);
510
+ return;
511
+ }
512
+
513
+ throw new Error("Unknown command. Run `shopstack help`.");
514
+ }
515
+
516
+ export { HELP };