shopstack 0.1.0 → 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 +129 -10
- package/SKILL.md +126 -0
- package/bin/shopstack +8 -1
- package/package.json +24 -6
- package/src/cli.js +386 -0
- package/src/client.d.ts +177 -0
- package/src/client.js +358 -0
- package/src/config.d.ts +36 -0
- package/src/config.js +101 -0
- package/scripts/postinstall.js +0 -6
package/README.md
CHANGED
|
@@ -1,25 +1,144 @@
|
|
|
1
1
|
# shopstack
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
When installed, it tells users:
|
|
6
|
-
|
|
7
|
-
> Wow you're fast! Apply for access at https://shopstack.ai
|
|
3
|
+
Dependency-free JavaScript client and CLI for Shopstack's asynchronous checkout API.
|
|
8
4
|
|
|
9
5
|
## Install
|
|
10
6
|
|
|
11
7
|
```bash
|
|
8
|
+
npm install shopstack
|
|
9
|
+
# or
|
|
12
10
|
npm install -g shopstack
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
Node.js 18 or newer is required.
|
|
14
|
+
|
|
15
|
+
Version 0.2.0 defaults to Shopstack's currently deployed public staging API at
|
|
16
|
+
`https://shopstack-staging.shopstack.workers.dev/v1`. Set
|
|
17
|
+
`SHOPSTACK_API_URL` only when targeting a different Shopstack environment.
|
|
18
|
+
|
|
19
|
+
## Personal account
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
shopstack signup user --email you@example.com
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This starts a 15-minute signup and waits while you open the verification email.
|
|
26
|
+
No account or API key exists until the link is consumed. The CLI then retrieves
|
|
27
|
+
the first key with its private polling capability and stores it in
|
|
28
|
+
`~/.config/shopstack/config.json` with file mode `0600`; it never prints the
|
|
29
|
+
key or polling capability. It checkpoints the pending signup before polling. If
|
|
30
|
+
the command is interrupted, resume it with:
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
```bash
|
|
33
|
+
shopstack signup resume sup_...
|
|
34
|
+
```
|
|
18
35
|
|
|
19
|
-
|
|
36
|
+
## Developer account and users
|
|
20
37
|
|
|
21
38
|
```bash
|
|
22
|
-
shopstack
|
|
39
|
+
shopstack signup developer --email developer@example.com
|
|
40
|
+
shopstack users create --external-id customer-123 --profile customer-123
|
|
23
41
|
```
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
The developer key administers users but cannot run their checkouts. Each created
|
|
44
|
+
user receives an independently scoped API key and CLI profile.
|
|
45
|
+
|
|
46
|
+
## MCP
|
|
47
|
+
|
|
48
|
+
Configure any stdio MCP client to run:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"shopstack": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "shopstack-mcp"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The MCP server can start and poll verified signup, manage local profiles,
|
|
62
|
+
create developer-owned users, list/connect Link, and create/poll/message/cancel
|
|
63
|
+
checkouts. It exposes status, activity, and the latest bounded mechanical intent
|
|
64
|
+
phase. It deliberately has no card-input or payment-approval tool.
|
|
65
|
+
|
|
66
|
+
The canonical agent skill ships as `SKILL.md` in this package. After
|
|
67
|
+
publication it is available at
|
|
68
|
+
`https://unpkg.com/shopstack@0.2.0/SKILL.md` with the release-pinned package.
|
|
69
|
+
|
|
70
|
+
## Connections
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
shopstack connect list
|
|
74
|
+
shopstack connect link
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Link is currently the only persistent payment provider. Connecting it is
|
|
78
|
+
optional.
|
|
79
|
+
|
|
80
|
+
## Run a checkout
|
|
81
|
+
|
|
82
|
+
Create `checkout.json` using the canonical API body:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"item_url": "https://merchant.example/product",
|
|
87
|
+
"instructions": "Buy one in blue",
|
|
88
|
+
"customer": {
|
|
89
|
+
"email": "buyer@example.com",
|
|
90
|
+
"name": "Example Buyer",
|
|
91
|
+
"phone": "+12125550100",
|
|
92
|
+
"shipping_address": {
|
|
93
|
+
"line1": "1 Example Street",
|
|
94
|
+
"line2": "Apartment 2",
|
|
95
|
+
"suburb": "Manhattan",
|
|
96
|
+
"city": "New York",
|
|
97
|
+
"region": "NY",
|
|
98
|
+
"postal_code": "10001",
|
|
99
|
+
"country": "US"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"maximum_amount": "75.00",
|
|
103
|
+
"currency": "USD"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Then run:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
shopstack checkout run --file checkout.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When no provider is selected, the checkout runs normally until the payment
|
|
114
|
+
form, then asks for card details through a no-echo terminal prompt. Card data is
|
|
115
|
+
sent only to the protected payment-details endpoint. The CLI separately shows
|
|
116
|
+
the exact final amount and asks for approval before Shopstack can submit.
|
|
117
|
+
|
|
118
|
+
To use an active Link connection, add `"payment_provider": "link"` to the
|
|
119
|
+
request.
|
|
120
|
+
|
|
121
|
+
Card values are never accepted as command-line flags.
|
|
122
|
+
|
|
123
|
+
## JavaScript client
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
import { ShopstackClient } from "shopstack";
|
|
127
|
+
|
|
128
|
+
const shopstack = new ShopstackClient({
|
|
129
|
+
apiKey: process.env.SHOPSTACK_API_KEY,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const result = await shopstack.runCheckout(checkoutRequest, {
|
|
133
|
+
paymentDetails: async () => secureCardSource(),
|
|
134
|
+
approve: async (approval) => askYourUserToApprove(approval),
|
|
135
|
+
message: async (checkout) => askYourUser(checkout.activity),
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`runCheckout` returns at a required input if its corresponding callback is
|
|
140
|
+
omitted. Final payment approval is never inferred from a message or from
|
|
141
|
+
supplying a card.
|
|
142
|
+
|
|
143
|
+
Set `SHOPSTACK_API_URL` to target a different Shopstack API and
|
|
144
|
+
`SHOPSTACK_CONFIG_FILE` to relocate CLI profiles.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shopstack-checkout
|
|
3
|
+
description: Use Shopstack to onboard a verified personal account or a developer with independently scoped end users, inspect optional Link connectivity, and run asynchronous online checkouts while displaying status, activity, and mechanical intent. Trigger for buying products, automating checkout, integrating Shopstack into an agent, or managing developer-owned shopping users.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Shopstack Checkout
|
|
7
|
+
|
|
8
|
+
Use Shopstack as the checkout execution layer. Keep account verification, user ownership, protected payment input, and final payment approval at their typed boundaries.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
For the JavaScript client and CLI:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install shopstack
|
|
16
|
+
npm install -g shopstack
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For MCP clients, configure the local stdio server:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"shopstack": {
|
|
25
|
+
"command": "npx",
|
|
26
|
+
"args": ["-y", "shopstack-mcp"]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Never ask the model to print or relay a Shopstack API key. The CLI and MCP server save verified keys in the private local Shopstack profile file.
|
|
33
|
+
|
|
34
|
+
## Choose the account shape
|
|
35
|
+
|
|
36
|
+
- Use a personal account for `Shopstack -> user`.
|
|
37
|
+
- Use a developer account for `Shopstack -> developer -> user1, user2, ...`.
|
|
38
|
+
- A developer management key creates and manages users but cannot run a user's checkout.
|
|
39
|
+
- Every developer-owned user receives an independent user API key and profile.
|
|
40
|
+
|
|
41
|
+
Start verified CLI signup:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
shopstack signup user --email user@example.com
|
|
45
|
+
shopstack signup developer --email developer@example.com
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The user must open the time-limited verification link. No account or API key exists before verification. The CLI polls the signup, saves the verified key with mode `0600`, and does not print it.
|
|
49
|
+
It checkpoints the pending polling capability before waiting. If the process is
|
|
50
|
+
interrupted, use `shopstack signup resume SIGNUP_ID`; the capability remains
|
|
51
|
+
private in the same local profile store.
|
|
52
|
+
|
|
53
|
+
After developer signup, create an end user:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
shopstack users create --external-id customer-123 --profile customer-123
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use `shopstack profiles list` and `shopstack profiles use NAME` to select a local profile without exposing its key.
|
|
60
|
+
|
|
61
|
+
## Choose payment connectivity
|
|
62
|
+
|
|
63
|
+
Link is currently the only persistent payment provider, and it is optional:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
shopstack connect list
|
|
67
|
+
shopstack connect link
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Include `payment_provider: "link"` only when the active user's Link connection reports checkout-ready. Otherwise omit `payment_provider`. Shopstack will run until card entry and request one protected checkout-scoped card through the SDK or no-echo CLI prompt.
|
|
71
|
+
|
|
72
|
+
Never put card data in MCP arguments, natural-language messages, model output, logs, or ordinary CLI flags.
|
|
73
|
+
|
|
74
|
+
## Create and monitor a checkout
|
|
75
|
+
|
|
76
|
+
Supply the canonical customer schema unchanged:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"item_url": "https://merchant.example/product",
|
|
81
|
+
"instructions": "Buy one in blue",
|
|
82
|
+
"customer": {
|
|
83
|
+
"email": "buyer@example.com",
|
|
84
|
+
"name": "Example Buyer",
|
|
85
|
+
"phone": "+12125550100",
|
|
86
|
+
"shipping_address": {
|
|
87
|
+
"line1": "1 Example Street",
|
|
88
|
+
"line2": "Apartment 2",
|
|
89
|
+
"suburb": "Manhattan",
|
|
90
|
+
"city": "New York",
|
|
91
|
+
"region": "NY",
|
|
92
|
+
"postal_code": "10001",
|
|
93
|
+
"country": "US"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"maximum_amount": "75.00",
|
|
97
|
+
"currency": "USD"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run it with:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
shopstack checkout run --file checkout.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Or create with MCP, then call `poll_checkout`. Display these authoritative fields to the user when they change:
|
|
108
|
+
|
|
109
|
+
- `status`: one of exactly `queued`, `started`, `help_required`, `approval_required`, `submitting`, `complete`, `failed`, `cancelled`;
|
|
110
|
+
- `activity`: bounded present-tense display text;
|
|
111
|
+
- `intent.name`, `intent.phase`, and `intent.updated_at`: bounded mechanical action progress with no arguments or reasoning;
|
|
112
|
+
- `required_input`: a typed handoff such as protected payment-card input;
|
|
113
|
+
- `approval`: the exact amount-bound approval request when present.
|
|
114
|
+
|
|
115
|
+
Poll every two seconds for `queued`, `started`, and `submitting`; every five seconds for `help_required` and `approval_required`; stop at `complete`, `failed`, or `cancelled`.
|
|
116
|
+
|
|
117
|
+
## Respond at typed boundaries
|
|
118
|
+
|
|
119
|
+
- Use `GET/POST /v1/checkout/{id}/messages` only for ordinary missing information.
|
|
120
|
+
- Use protected SDK/CLI payment input when `required_input.type` is `payment_card`.
|
|
121
|
+
- Use only the dedicated payment-approval endpoint from a separately scoped trusted backend.
|
|
122
|
+
- MCP and messages cannot approve payment and expose no approval tool.
|
|
123
|
+
- Never infer approval from a user's conversational message.
|
|
124
|
+
- Cancel with the typed cancellation operation if the user withdraws the request.
|
|
125
|
+
|
|
126
|
+
Use the checkout's canonical current representation as state truth. Events are ordered evidence, not a replacement state machine. Preserve idempotency keys on retried mutations.
|
package/bin/shopstack
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import { runCli } from "../src/cli.js";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
await runCli(process.argv.slice(2));
|
|
7
|
+
} catch (error) {
|
|
8
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopstack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shopstack
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shopstack API client and command-line checkout tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/client.d.ts",
|
|
9
|
+
"import": "./src/client.js"
|
|
10
|
+
},
|
|
11
|
+
"./config": {
|
|
12
|
+
"types": "./src/config.d.ts",
|
|
13
|
+
"import": "./src/config.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"types": "./src/client.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"SKILL.md",
|
|
19
|
+
"bin",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
5
22
|
"bin": {
|
|
6
|
-
"shopstack": "
|
|
23
|
+
"shopstack": "bin/shopstack"
|
|
7
24
|
},
|
|
8
25
|
"scripts": {
|
|
9
|
-
"
|
|
10
|
-
"
|
|
26
|
+
"shopstack": "node ./bin/shopstack",
|
|
27
|
+
"test": "node --test"
|
|
11
28
|
},
|
|
12
29
|
"engines": {
|
|
13
30
|
"node": ">=18"
|
|
@@ -16,6 +33,7 @@
|
|
|
16
33
|
"keywords": [
|
|
17
34
|
"shopstack",
|
|
18
35
|
"cli",
|
|
19
|
-
"
|
|
36
|
+
"checkout",
|
|
37
|
+
"payments"
|
|
20
38
|
]
|
|
21
39
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
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 user --email EMAIL
|
|
11
|
+
shopstack signup developer --email EMAIL
|
|
12
|
+
shopstack signup resume SIGNUP_ID
|
|
13
|
+
shopstack users create --external-id ID [--profile NAME]
|
|
14
|
+
shopstack profiles list
|
|
15
|
+
shopstack profiles use NAME
|
|
16
|
+
|
|
17
|
+
Payment connection:
|
|
18
|
+
shopstack connect list
|
|
19
|
+
shopstack connect link
|
|
20
|
+
|
|
21
|
+
Checkout:
|
|
22
|
+
shopstack checkout create --file checkout.json
|
|
23
|
+
shopstack checkout run --file checkout.json
|
|
24
|
+
shopstack checkout get CHECKOUT_ID
|
|
25
|
+
shopstack checkout cancel CHECKOUT_ID
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function parseOptions(args, allowed) {
|
|
29
|
+
const positional = [];
|
|
30
|
+
const options = {};
|
|
31
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
+
const value = args[index];
|
|
33
|
+
if (!value.startsWith("--")) {
|
|
34
|
+
positional.push(value);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const name = value.slice(2);
|
|
38
|
+
if (!allowed.has(name)) throw new Error(`Unknown option: --${name}`);
|
|
39
|
+
const optionValue = args[index + 1];
|
|
40
|
+
if (optionValue === undefined || optionValue.startsWith("--")) {
|
|
41
|
+
throw new Error(`Option --${name} requires a value.`);
|
|
42
|
+
}
|
|
43
|
+
options[name] = optionValue;
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
return { options, positional };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function required(options, name) {
|
|
50
|
+
const value = options[name];
|
|
51
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
52
|
+
throw new Error(`Missing required option: --${name}`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeJson(stream, value) {
|
|
58
|
+
stream.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readJsonFile(path) {
|
|
62
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function visiblePrompt(message, dependencies) {
|
|
66
|
+
if (dependencies.prompt) return dependencies.prompt(message);
|
|
67
|
+
const input = dependencies.stdin ?? process.stdin;
|
|
68
|
+
const output = dependencies.stdout ?? process.stdout;
|
|
69
|
+
const interface_ = createInterface({ input, output });
|
|
70
|
+
try {
|
|
71
|
+
return await interface_.question(message);
|
|
72
|
+
} finally {
|
|
73
|
+
interface_.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function hiddenPrompt(message, dependencies) {
|
|
78
|
+
if (dependencies.secretPrompt) return dependencies.secretPrompt(message);
|
|
79
|
+
const input = dependencies.stdin ?? process.stdin;
|
|
80
|
+
const output = dependencies.stderr ?? process.stderr;
|
|
81
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"Protected payment input requires an interactive terminal.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
output.write(message);
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let value = "";
|
|
89
|
+
const restore = () => {
|
|
90
|
+
input.off("data", onData);
|
|
91
|
+
input.setRawMode(false);
|
|
92
|
+
input.pause();
|
|
93
|
+
output.write("\n");
|
|
94
|
+
};
|
|
95
|
+
const onData = (chunk) => {
|
|
96
|
+
const text = String(chunk);
|
|
97
|
+
for (const character of text) {
|
|
98
|
+
if (character === "\r" || character === "\n") {
|
|
99
|
+
restore();
|
|
100
|
+
resolve(value);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (character === "\u0003") {
|
|
104
|
+
restore();
|
|
105
|
+
reject(new Error("Payment input cancelled."));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (character === "\u007f" || character === "\b") {
|
|
109
|
+
value = value.slice(0, -1);
|
|
110
|
+
} else {
|
|
111
|
+
value += character;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
input.setRawMode(true);
|
|
116
|
+
input.resume();
|
|
117
|
+
input.on("data", onData);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sanitizedAccountResult(result) {
|
|
122
|
+
return {
|
|
123
|
+
account: result.account,
|
|
124
|
+
api_key_saved: true,
|
|
125
|
+
key_type: result.key_type,
|
|
126
|
+
...(result.user === undefined ? {} : { user: result.user }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sanitizedUserResult(result, profile) {
|
|
131
|
+
const { api_key: _apiKey, ...user } = result;
|
|
132
|
+
return { api_key_saved: true, profile, user };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function saveVerifiedSignup(result, profileName, configStore) {
|
|
136
|
+
await configStore.saveProfile(
|
|
137
|
+
profileName,
|
|
138
|
+
{
|
|
139
|
+
accountId: result.account.id,
|
|
140
|
+
apiKey: result.api_key,
|
|
141
|
+
keyType: result.key_type,
|
|
142
|
+
...(result.user?.id === undefined ? {} : { userId: result.user.id }),
|
|
143
|
+
},
|
|
144
|
+
{ activate: true },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function activeClient(dependencies, requiredKind = "user") {
|
|
149
|
+
const profile = await dependencies.configStore.activeProfile();
|
|
150
|
+
const environmentKey = process.env.SHOPSTACK_API_KEY;
|
|
151
|
+
const apiKey = environmentKey || profile?.apiKey;
|
|
152
|
+
const keyType = environmentKey ? requiredKind : profile?.keyType;
|
|
153
|
+
if (!apiKey)
|
|
154
|
+
throw new Error("No active Shopstack API key. Run signup first.");
|
|
155
|
+
if (requiredKind && keyType !== requiredKind) {
|
|
156
|
+
throw new Error(`This command requires an active ${requiredKind} profile.`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
client: dependencies.clientFactory({
|
|
160
|
+
apiKey,
|
|
161
|
+
baseUrl: process.env.SHOPSTACK_API_URL,
|
|
162
|
+
}),
|
|
163
|
+
profile,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runCli(args, supplied = {}) {
|
|
168
|
+
const dependencies = {
|
|
169
|
+
clientFactory: (options) => new ShopstackClient(options),
|
|
170
|
+
configStore: new ConfigStore(),
|
|
171
|
+
confirm: undefined,
|
|
172
|
+
prompt: undefined,
|
|
173
|
+
readJsonFile,
|
|
174
|
+
secretPrompt: undefined,
|
|
175
|
+
stderr: process.stderr,
|
|
176
|
+
stdin: process.stdin,
|
|
177
|
+
stdout: process.stdout,
|
|
178
|
+
...supplied,
|
|
179
|
+
};
|
|
180
|
+
const [group, action, ...rest] = args;
|
|
181
|
+
if (group === undefined || group === "help" || group === "--help") {
|
|
182
|
+
dependencies.stdout.write(HELP);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (group === "signup") {
|
|
187
|
+
if (action === "resume") {
|
|
188
|
+
if (rest.length !== 1) {
|
|
189
|
+
throw new Error("Use `shopstack signup resume SIGNUP_ID`.");
|
|
190
|
+
}
|
|
191
|
+
const pending = await dependencies.configStore.pendingSignup(rest[0]);
|
|
192
|
+
if (pending === undefined) {
|
|
193
|
+
throw new Error("That signup is not present in the local profile store.");
|
|
194
|
+
}
|
|
195
|
+
const client = dependencies.clientFactory({
|
|
196
|
+
baseUrl: process.env.SHOPSTACK_API_URL,
|
|
197
|
+
});
|
|
198
|
+
const result = await client.waitForAccountSignup(
|
|
199
|
+
pending.id,
|
|
200
|
+
pending.pollToken,
|
|
201
|
+
);
|
|
202
|
+
await saveVerifiedSignup(result, pending.profile, dependencies.configStore);
|
|
203
|
+
await dependencies.configStore.deletePendingSignup(pending.id);
|
|
204
|
+
writeJson(dependencies.stdout, {
|
|
205
|
+
...sanitizedAccountResult(result),
|
|
206
|
+
profile: pending.profile,
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (action !== "user" && action !== "developer") {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"Use `shopstack signup user` or `shopstack signup developer`.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const { options, positional } = parseOptions(
|
|
216
|
+
rest,
|
|
217
|
+
new Set(["email", "profile"]),
|
|
218
|
+
);
|
|
219
|
+
if (positional.length > 0) throw new Error("Unexpected signup argument.");
|
|
220
|
+
const email = required(options, "email");
|
|
221
|
+
const client = dependencies.clientFactory({
|
|
222
|
+
baseUrl: process.env.SHOPSTACK_API_URL,
|
|
223
|
+
});
|
|
224
|
+
const accountType = action === "user" ? "personal" : "developer";
|
|
225
|
+
const profileName =
|
|
226
|
+
options.profile ?? (action === "developer" ? "developer" : "default");
|
|
227
|
+
const signup = await client.startAccountSignup({
|
|
228
|
+
accountType,
|
|
229
|
+
email,
|
|
230
|
+
});
|
|
231
|
+
await dependencies.configStore.savePendingSignup({
|
|
232
|
+
accountType: signup.account_type,
|
|
233
|
+
email: signup.email,
|
|
234
|
+
expiresAt: signup.expires_at,
|
|
235
|
+
id: signup.id,
|
|
236
|
+
pollToken: signup.poll_token,
|
|
237
|
+
profile: profileName,
|
|
238
|
+
});
|
|
239
|
+
dependencies.stderr.write(
|
|
240
|
+
`Verification email sent to ${signup.email}. Open the link before ${signup.expires_at}. If interrupted, resume signup ${signup.id}.\n`,
|
|
241
|
+
);
|
|
242
|
+
const result = await client.waitForAccountSignup(
|
|
243
|
+
signup.id,
|
|
244
|
+
signup.poll_token,
|
|
245
|
+
);
|
|
246
|
+
await saveVerifiedSignup(result, profileName, dependencies.configStore);
|
|
247
|
+
await dependencies.configStore.deletePendingSignup(signup.id);
|
|
248
|
+
writeJson(dependencies.stdout, {
|
|
249
|
+
...sanitizedAccountResult(result),
|
|
250
|
+
profile: profileName,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (group === "users" && action === "create") {
|
|
256
|
+
const { options, positional } = parseOptions(
|
|
257
|
+
rest,
|
|
258
|
+
new Set(["external-id", "profile"]),
|
|
259
|
+
);
|
|
260
|
+
if (positional.length > 0) throw new Error("Unexpected user argument.");
|
|
261
|
+
const externalId = required(options, "external-id");
|
|
262
|
+
const { client, profile } = await activeClient(dependencies, "developer");
|
|
263
|
+
const result = await client.createUser({ externalId });
|
|
264
|
+
const profileName = options.profile ?? externalId;
|
|
265
|
+
await dependencies.configStore.saveProfile(
|
|
266
|
+
profileName,
|
|
267
|
+
{
|
|
268
|
+
accountId: profile.accountId,
|
|
269
|
+
apiKey: result.api_key,
|
|
270
|
+
keyType: "user",
|
|
271
|
+
userId: result.id,
|
|
272
|
+
},
|
|
273
|
+
{ activate: true },
|
|
274
|
+
);
|
|
275
|
+
writeJson(dependencies.stdout, sanitizedUserResult(result, profileName));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (group === "profiles" && action === "list") {
|
|
280
|
+
const config = await dependencies.configStore.load();
|
|
281
|
+
writeJson(dependencies.stdout, {
|
|
282
|
+
active: config.active,
|
|
283
|
+
profiles: Object.entries(config.profiles).map(([name, profile]) => ({
|
|
284
|
+
account_id: profile.accountId,
|
|
285
|
+
key_type: profile.keyType,
|
|
286
|
+
name,
|
|
287
|
+
...(profile.userId === undefined ? {} : { user_id: profile.userId }),
|
|
288
|
+
})),
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (group === "profiles" && action === "use") {
|
|
293
|
+
if (rest.length !== 1) throw new Error("A profile name is required.");
|
|
294
|
+
await dependencies.configStore.useProfile(rest[0]);
|
|
295
|
+
writeJson(dependencies.stdout, { active: rest[0] });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (group === "connect" && (action === "list" || action === "link")) {
|
|
300
|
+
if (rest.length > 0) throw new Error("Unexpected connection argument.");
|
|
301
|
+
const { client } = await activeClient(dependencies, "user");
|
|
302
|
+
const result =
|
|
303
|
+
action === "list"
|
|
304
|
+
? await client.listConnections()
|
|
305
|
+
: await client.connect("link");
|
|
306
|
+
writeJson(dependencies.stdout, result);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (group === "checkout" && action === "create") {
|
|
311
|
+
const { options, positional } = parseOptions(rest, new Set(["file"]));
|
|
312
|
+
if (positional.length > 0) throw new Error("Unexpected checkout argument.");
|
|
313
|
+
const { client } = await activeClient(dependencies, "user");
|
|
314
|
+
const result = await client.createCheckout(
|
|
315
|
+
await dependencies.readJsonFile(required(options, "file")),
|
|
316
|
+
);
|
|
317
|
+
writeJson(dependencies.stdout, result);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (group === "checkout" && action === "run") {
|
|
322
|
+
const { options, positional } = parseOptions(rest, new Set(["file"]));
|
|
323
|
+
if (positional.length > 0) throw new Error("Unexpected checkout argument.");
|
|
324
|
+
const { client } = await activeClient(dependencies, "user");
|
|
325
|
+
const request = await dependencies.readJsonFile(required(options, "file"));
|
|
326
|
+
const result = await client.runCheckout(request, {
|
|
327
|
+
onProgress: (checkout) => {
|
|
328
|
+
const intent =
|
|
329
|
+
checkout.intent?.name === undefined
|
|
330
|
+
? ""
|
|
331
|
+
: ` ${checkout.intent.name}:${checkout.intent.phase}`;
|
|
332
|
+
dependencies.stderr.write(
|
|
333
|
+
`[${checkout.status}]${intent} ${checkout.activity ?? ""}`.trimEnd() +
|
|
334
|
+
"\n",
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
approve: async (approval) => {
|
|
338
|
+
const summary = [
|
|
339
|
+
`Subtotal: ${approval.subtotal ?? "unavailable"} ${approval.currency}`,
|
|
340
|
+
`Shipping: ${approval.shipping ?? "unavailable"} ${approval.currency}`,
|
|
341
|
+
`Tax: ${approval.tax ?? "unavailable"} ${approval.currency}`,
|
|
342
|
+
`Total: ${approval.total} ${approval.currency}`,
|
|
343
|
+
].join("\n");
|
|
344
|
+
dependencies.stderr.write(`${summary}\n`);
|
|
345
|
+
if (dependencies.confirm) return dependencies.confirm(approval);
|
|
346
|
+
const answer = await visiblePrompt(
|
|
347
|
+
"Approve this exact payment? [y/N] ",
|
|
348
|
+
dependencies,
|
|
349
|
+
);
|
|
350
|
+
return /^y(?:es)?$/iu.test(answer.trim());
|
|
351
|
+
},
|
|
352
|
+
message: (checkout) =>
|
|
353
|
+
visiblePrompt(
|
|
354
|
+
`${checkout.activity || "Shopstack needs more information"}: `,
|
|
355
|
+
dependencies,
|
|
356
|
+
),
|
|
357
|
+
paymentDetails: async () => ({
|
|
358
|
+
number: await hiddenPrompt("Card number: ", dependencies),
|
|
359
|
+
exp_month: Number(
|
|
360
|
+
await hiddenPrompt("Expiry month (1-12): ", dependencies),
|
|
361
|
+
),
|
|
362
|
+
exp_year: Number(
|
|
363
|
+
await hiddenPrompt("Expiry year (YYYY): ", dependencies),
|
|
364
|
+
),
|
|
365
|
+
cvc: await hiddenPrompt("CVC: ", dependencies),
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
writeJson(dependencies.stdout, result);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (group === "checkout" && (action === "get" || action === "cancel")) {
|
|
373
|
+
if (rest.length !== 1) throw new Error("A checkout ID is required.");
|
|
374
|
+
const { client } = await activeClient(dependencies, "user");
|
|
375
|
+
const result =
|
|
376
|
+
action === "get"
|
|
377
|
+
? await client.getCheckout(rest[0])
|
|
378
|
+
: await client.cancelCheckout(rest[0]);
|
|
379
|
+
writeJson(dependencies.stdout, result);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
throw new Error("Unknown command. Run `shopstack help`.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export { HELP };
|
package/src/client.d.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export interface ShopstackClientOptions {
|
|
2
|
+
apiKey?: string;
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PaymentCard {
|
|
8
|
+
number: string;
|
|
9
|
+
exp_month: number;
|
|
10
|
+
exp_year: number;
|
|
11
|
+
cvc: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PaymentApproval {
|
|
15
|
+
id: string;
|
|
16
|
+
subtotal: string | null;
|
|
17
|
+
shipping: string | null;
|
|
18
|
+
tax: string | null;
|
|
19
|
+
total: string;
|
|
20
|
+
currency: string;
|
|
21
|
+
expires_at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Checkout {
|
|
25
|
+
id: string;
|
|
26
|
+
status:
|
|
27
|
+
| "queued"
|
|
28
|
+
| "started"
|
|
29
|
+
| "help_required"
|
|
30
|
+
| "approval_required"
|
|
31
|
+
| "submitting"
|
|
32
|
+
| "complete"
|
|
33
|
+
| "failed"
|
|
34
|
+
| "cancelled";
|
|
35
|
+
revision: number;
|
|
36
|
+
activity: string;
|
|
37
|
+
intent?: {
|
|
38
|
+
name: string;
|
|
39
|
+
phase: "selected" | "completed" | "uncertain" | "failed";
|
|
40
|
+
updated_at: string;
|
|
41
|
+
};
|
|
42
|
+
item_url: string;
|
|
43
|
+
required_input?: { type: "payment_card" };
|
|
44
|
+
approval?: PaymentApproval;
|
|
45
|
+
result?: Record<string, unknown>;
|
|
46
|
+
failure?: Record<string, unknown>;
|
|
47
|
+
created_at: string;
|
|
48
|
+
updated_at: string;
|
|
49
|
+
expires_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RunCheckoutOptions {
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
pollIntervalMs?: number;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
onProgress?(checkout: Checkout): void | Promise<void>;
|
|
57
|
+
paymentDetails?(
|
|
58
|
+
checkout: Checkout,
|
|
59
|
+
): PaymentCard | Promise<PaymentCard | undefined> | undefined;
|
|
60
|
+
approve?(
|
|
61
|
+
approval: PaymentApproval,
|
|
62
|
+
checkout: Checkout,
|
|
63
|
+
): boolean | Promise<boolean | undefined> | undefined;
|
|
64
|
+
message?(
|
|
65
|
+
checkout: Checkout,
|
|
66
|
+
): string | Promise<string | undefined> | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AccountSignupStarted {
|
|
70
|
+
id: string;
|
|
71
|
+
account_type: "personal" | "developer";
|
|
72
|
+
email: string;
|
|
73
|
+
status: "verification_required";
|
|
74
|
+
created_at: string;
|
|
75
|
+
expires_at: string;
|
|
76
|
+
poll_token: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface VerifiedAccountSignup {
|
|
80
|
+
id: string;
|
|
81
|
+
status: "verified";
|
|
82
|
+
verified_at: string;
|
|
83
|
+
account: Record<string, unknown>;
|
|
84
|
+
api_key: string;
|
|
85
|
+
key_type: "user" | "developer";
|
|
86
|
+
user?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SignupOptions {
|
|
90
|
+
accountType: "personal" | "developer";
|
|
91
|
+
email: string;
|
|
92
|
+
idempotencyKey?: string;
|
|
93
|
+
pollIntervalMs?: number;
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
onVerificationRequired?(signup: Omit<AccountSignupStarted, "poll_token">):
|
|
96
|
+
| void
|
|
97
|
+
| Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class ShopstackApiError extends Error {
|
|
101
|
+
code: string;
|
|
102
|
+
requestId?: string;
|
|
103
|
+
status?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class ShopstackClient {
|
|
107
|
+
constructor(options?: ShopstackClientOptions);
|
|
108
|
+
startAccountSignup(input: {
|
|
109
|
+
accountType: "personal" | "developer";
|
|
110
|
+
email: string;
|
|
111
|
+
idempotencyKey?: string;
|
|
112
|
+
}): Promise<AccountSignupStarted>;
|
|
113
|
+
pollAccountSignup(
|
|
114
|
+
signupId: string,
|
|
115
|
+
pollToken: string,
|
|
116
|
+
): Promise<
|
|
117
|
+
Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup
|
|
118
|
+
>;
|
|
119
|
+
waitForAccountSignup(
|
|
120
|
+
signupId: string,
|
|
121
|
+
pollToken: string,
|
|
122
|
+
options?: { pollIntervalMs?: number; timeoutMs?: number },
|
|
123
|
+
): Promise<VerifiedAccountSignup>;
|
|
124
|
+
signup(input: SignupOptions): Promise<VerifiedAccountSignup>;
|
|
125
|
+
createUser(input: {
|
|
126
|
+
externalId: string;
|
|
127
|
+
idempotencyKey?: string;
|
|
128
|
+
}): Promise<Record<string, unknown>>;
|
|
129
|
+
listUsers(): Promise<Record<string, unknown>>;
|
|
130
|
+
getUser(userId: string): Promise<Record<string, unknown>>;
|
|
131
|
+
rotateUserApiKey(
|
|
132
|
+
userId: string,
|
|
133
|
+
options?: { idempotencyKey?: string },
|
|
134
|
+
): Promise<Record<string, unknown>>;
|
|
135
|
+
updateUser(
|
|
136
|
+
userId: string,
|
|
137
|
+
status: "active" | "suspended",
|
|
138
|
+
options?: { idempotencyKey?: string },
|
|
139
|
+
): Promise<Record<string, unknown>>;
|
|
140
|
+
listConnections(): Promise<Record<string, unknown>>;
|
|
141
|
+
connect(
|
|
142
|
+
paymentProvider?: "link",
|
|
143
|
+
options?: { idempotencyKey?: string },
|
|
144
|
+
): Promise<Record<string, unknown>>;
|
|
145
|
+
createCheckout(
|
|
146
|
+
checkout: Record<string, unknown>,
|
|
147
|
+
options?: { idempotencyKey?: string },
|
|
148
|
+
): Promise<Checkout>;
|
|
149
|
+
getCheckout(checkoutId: string): Promise<Checkout>;
|
|
150
|
+
cancelCheckout(
|
|
151
|
+
checkoutId: string,
|
|
152
|
+
options?: { idempotencyKey?: string },
|
|
153
|
+
): Promise<Checkout>;
|
|
154
|
+
sendMessage(
|
|
155
|
+
checkoutId: string,
|
|
156
|
+
content: string,
|
|
157
|
+
options?: { idempotencyKey?: string },
|
|
158
|
+
): Promise<Record<string, unknown>>;
|
|
159
|
+
listMessages(checkoutId: string): Promise<Record<string, unknown>>;
|
|
160
|
+
listEvents(checkoutId: string): Promise<Record<string, unknown>>;
|
|
161
|
+
listArtifacts(checkoutId: string): Promise<Record<string, unknown>>;
|
|
162
|
+
providePaymentDetails(
|
|
163
|
+
checkoutId: string,
|
|
164
|
+
card: PaymentCard,
|
|
165
|
+
options?: { idempotencyKey?: string },
|
|
166
|
+
): Promise<Checkout>;
|
|
167
|
+
decidePaymentApproval(
|
|
168
|
+
checkoutId: string,
|
|
169
|
+
approvalId: string,
|
|
170
|
+
approved: boolean,
|
|
171
|
+
options?: { idempotencyKey?: string },
|
|
172
|
+
): Promise<Checkout>;
|
|
173
|
+
runCheckout(
|
|
174
|
+
checkout: Record<string, unknown>,
|
|
175
|
+
options?: RunCheckoutOptions,
|
|
176
|
+
): Promise<Checkout>;
|
|
177
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
|
|
4
|
+
|
|
5
|
+
export class ShopstackApiError extends Error {
|
|
6
|
+
constructor(message, { code = "api_error", requestId, status } = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ShopstackApiError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.requestId = requestId;
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function idempotencyKey(prefix) {
|
|
16
|
+
return `${prefix}-${randomUUID()}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function delay(milliseconds) {
|
|
20
|
+
return milliseconds <= 0
|
|
21
|
+
? Promise.resolve()
|
|
22
|
+
: new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ShopstackClient {
|
|
26
|
+
constructor({
|
|
27
|
+
apiKey,
|
|
28
|
+
baseUrl = "https://shopstack-staging.shopstack.workers.dev/v1",
|
|
29
|
+
fetch: fetchImplementation = globalThis.fetch,
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
|
|
32
|
+
throw new TypeError("baseUrl must be an HTTP(S) URL");
|
|
33
|
+
}
|
|
34
|
+
if (typeof fetchImplementation !== "function") {
|
|
35
|
+
throw new TypeError("a fetch implementation is required");
|
|
36
|
+
}
|
|
37
|
+
this.apiKey = apiKey;
|
|
38
|
+
this.baseUrl = baseUrl.replace(/\/+$/u, "");
|
|
39
|
+
this.fetch = fetchImplementation;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async request(
|
|
43
|
+
path,
|
|
44
|
+
{
|
|
45
|
+
bearerToken,
|
|
46
|
+
body,
|
|
47
|
+
idempotencyKey: mutationKey,
|
|
48
|
+
method = "GET",
|
|
49
|
+
} = {},
|
|
50
|
+
) {
|
|
51
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
52
|
+
const authorization = bearerToken ?? this.apiKey;
|
|
53
|
+
if (authorization) {
|
|
54
|
+
headers.set("Authorization", `Bearer ${authorization}`);
|
|
55
|
+
}
|
|
56
|
+
if (body !== undefined) headers.set("Content-Type", "application/json");
|
|
57
|
+
if (mutationKey) headers.set("Idempotency-Key", mutationKey);
|
|
58
|
+
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
59
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
|
+
headers,
|
|
61
|
+
method,
|
|
62
|
+
});
|
|
63
|
+
const raw = await response.text();
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
payload = raw ? JSON.parse(raw) : undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
throw new ShopstackApiError("Shopstack returned invalid JSON.", {
|
|
69
|
+
status: response.status,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const error = payload?.error;
|
|
74
|
+
throw new ShopstackApiError(
|
|
75
|
+
typeof error?.message === "string"
|
|
76
|
+
? error.message
|
|
77
|
+
: `Shopstack request failed with status ${response.status}.`,
|
|
78
|
+
{
|
|
79
|
+
code: typeof error?.code === "string" ? error.code : "api_error",
|
|
80
|
+
requestId:
|
|
81
|
+
typeof error?.request_id === "string"
|
|
82
|
+
? error.request_id
|
|
83
|
+
: undefined,
|
|
84
|
+
status: response.status,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
startAccountSignup({ accountType, email, idempotencyKey: key } = {}) {
|
|
92
|
+
return this.request("/accounts", {
|
|
93
|
+
body: { account_type: accountType, email },
|
|
94
|
+
idempotencyKey: key ?? idempotencyKey("account"),
|
|
95
|
+
method: "POST",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pollAccountSignup(signupId, pollToken) {
|
|
100
|
+
return this.request(`/accounts/${encodeURIComponent(signupId)}`, {
|
|
101
|
+
bearerToken: pollToken,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async waitForAccountSignup(
|
|
106
|
+
signupId,
|
|
107
|
+
pollToken,
|
|
108
|
+
{ pollIntervalMs = 2_000, timeoutMs = 15 * 60 * 1_000 } = {},
|
|
109
|
+
) {
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
while (true) {
|
|
112
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
113
|
+
throw new ShopstackApiError("Email verification timed out.", {
|
|
114
|
+
code: "signup_timeout",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const current = await this.pollAccountSignup(signupId, pollToken);
|
|
118
|
+
if (current.status === "verified") return current;
|
|
119
|
+
await delay(pollIntervalMs);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async signup({
|
|
124
|
+
accountType,
|
|
125
|
+
email,
|
|
126
|
+
idempotencyKey: key,
|
|
127
|
+
onVerificationRequired,
|
|
128
|
+
pollIntervalMs = 2_000,
|
|
129
|
+
timeoutMs = 15 * 60 * 1_000,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const signup = await this.startAccountSignup({
|
|
132
|
+
accountType,
|
|
133
|
+
email,
|
|
134
|
+
idempotencyKey: key,
|
|
135
|
+
});
|
|
136
|
+
if (typeof onVerificationRequired === "function") {
|
|
137
|
+
await onVerificationRequired({
|
|
138
|
+
account_type: signup.account_type,
|
|
139
|
+
email: signup.email,
|
|
140
|
+
expires_at: signup.expires_at,
|
|
141
|
+
id: signup.id,
|
|
142
|
+
status: signup.status,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return this.waitForAccountSignup(signup.id, signup.poll_token, {
|
|
146
|
+
pollIntervalMs,
|
|
147
|
+
timeoutMs,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
createUser({ externalId, idempotencyKey: key } = {}) {
|
|
152
|
+
return this.request("/users", {
|
|
153
|
+
body: { external_id: externalId },
|
|
154
|
+
idempotencyKey: key ?? idempotencyKey("user"),
|
|
155
|
+
method: "POST",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
listUsers() {
|
|
160
|
+
return this.request("/users");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getUser(userId) {
|
|
164
|
+
return this.request(`/users/${encodeURIComponent(userId)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
rotateUserApiKey(userId, { idempotencyKey: key } = {}) {
|
|
168
|
+
return this.request(`/users/${encodeURIComponent(userId)}/api-key/rotate`, {
|
|
169
|
+
idempotencyKey: key ?? idempotencyKey("rotate"),
|
|
170
|
+
method: "POST",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
updateUser(userId, status, { idempotencyKey: key } = {}) {
|
|
175
|
+
return this.request(`/users/${encodeURIComponent(userId)}`, {
|
|
176
|
+
body: { status },
|
|
177
|
+
idempotencyKey: key ?? idempotencyKey("user-update"),
|
|
178
|
+
method: "PATCH",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
listConnections() {
|
|
183
|
+
return this.request("/connect");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
connect(paymentProvider = "link", { idempotencyKey: key } = {}) {
|
|
187
|
+
return this.request("/connect", {
|
|
188
|
+
body: { payment_provider: paymentProvider },
|
|
189
|
+
idempotencyKey: key ?? idempotencyKey("connect"),
|
|
190
|
+
method: "POST",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
createCheckout(checkout, { idempotencyKey: key } = {}) {
|
|
195
|
+
return this.request("/checkout", {
|
|
196
|
+
body: checkout,
|
|
197
|
+
idempotencyKey: key ?? idempotencyKey("checkout"),
|
|
198
|
+
method: "POST",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
getCheckout(checkoutId) {
|
|
203
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cancelCheckout(checkoutId, { idempotencyKey: key } = {}) {
|
|
207
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/cancel`, {
|
|
208
|
+
idempotencyKey: key ?? idempotencyKey("cancel"),
|
|
209
|
+
method: "POST",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
sendMessage(checkoutId, content, { idempotencyKey: key } = {}) {
|
|
214
|
+
return this.request(
|
|
215
|
+
`/checkout/${encodeURIComponent(checkoutId)}/messages`,
|
|
216
|
+
{
|
|
217
|
+
body: { content },
|
|
218
|
+
idempotencyKey: key ?? idempotencyKey("message"),
|
|
219
|
+
method: "POST",
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
listMessages(checkoutId) {
|
|
225
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/messages`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
listEvents(checkoutId) {
|
|
229
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/events`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
listArtifacts(checkoutId) {
|
|
233
|
+
return this.request(
|
|
234
|
+
`/checkout/${encodeURIComponent(checkoutId)}/artifacts`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
providePaymentDetails(checkoutId, card, { idempotencyKey: key } = {}) {
|
|
239
|
+
return this.request(
|
|
240
|
+
`/checkout/${encodeURIComponent(checkoutId)}/payment-details`,
|
|
241
|
+
{
|
|
242
|
+
body: { card },
|
|
243
|
+
idempotencyKey: key ?? idempotencyKey("payment-details"),
|
|
244
|
+
method: "POST",
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
decidePaymentApproval(
|
|
250
|
+
checkoutId,
|
|
251
|
+
approvalId,
|
|
252
|
+
approved,
|
|
253
|
+
{ idempotencyKey: key } = {},
|
|
254
|
+
) {
|
|
255
|
+
return this.request(
|
|
256
|
+
`/checkout/${encodeURIComponent(checkoutId)}/payment-approval`,
|
|
257
|
+
{
|
|
258
|
+
body: { approval_id: approvalId, approved },
|
|
259
|
+
idempotencyKey: key ?? idempotencyKey("approval"),
|
|
260
|
+
method: "POST",
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async runCheckout(checkoutRequest, options = {}) {
|
|
266
|
+
const timeoutMs = options.timeoutMs ?? 30 * 60 * 1_000;
|
|
267
|
+
const startedAt = Date.now();
|
|
268
|
+
let checkout = await this.createCheckout(checkoutRequest, {
|
|
269
|
+
idempotencyKey: options.idempotencyKey,
|
|
270
|
+
});
|
|
271
|
+
let progressFingerprint;
|
|
272
|
+
const publishProgress = async (current) => {
|
|
273
|
+
if (typeof options.onProgress !== "function") return;
|
|
274
|
+
const fingerprint = JSON.stringify([
|
|
275
|
+
current.status,
|
|
276
|
+
current.revision,
|
|
277
|
+
current.activity,
|
|
278
|
+
current.intent?.name,
|
|
279
|
+
current.intent?.phase,
|
|
280
|
+
current.intent?.updated_at,
|
|
281
|
+
current.required_input?.type,
|
|
282
|
+
current.approval?.id,
|
|
283
|
+
]);
|
|
284
|
+
if (fingerprint === progressFingerprint) return;
|
|
285
|
+
progressFingerprint = fingerprint;
|
|
286
|
+
await options.onProgress(current);
|
|
287
|
+
};
|
|
288
|
+
await publishProgress(checkout);
|
|
289
|
+
let handledPaymentRevision;
|
|
290
|
+
let handledApprovalId;
|
|
291
|
+
let handledMessageRevision;
|
|
292
|
+
while (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
293
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
294
|
+
throw new ShopstackApiError("Checkout monitoring timed out.", {
|
|
295
|
+
code: "checkout_timeout",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
checkout = await this.getCheckout(checkout.id);
|
|
299
|
+
await publishProgress(checkout);
|
|
300
|
+
if (
|
|
301
|
+
checkout.required_input?.type === "payment_card" &&
|
|
302
|
+
handledPaymentRevision !== checkout.revision
|
|
303
|
+
) {
|
|
304
|
+
if (typeof options.paymentDetails !== "function") return checkout;
|
|
305
|
+
handledPaymentRevision = checkout.revision;
|
|
306
|
+
const card = await options.paymentDetails(checkout);
|
|
307
|
+
if (card === undefined) return checkout;
|
|
308
|
+
await this.providePaymentDetails(checkout.id, card, {
|
|
309
|
+
idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
checkout.status === "approval_required" &&
|
|
315
|
+
checkout.approval &&
|
|
316
|
+
handledApprovalId !== checkout.approval.id
|
|
317
|
+
) {
|
|
318
|
+
if (typeof options.approve !== "function") return checkout;
|
|
319
|
+
handledApprovalId = checkout.approval.id;
|
|
320
|
+
const approved = await options.approve(checkout.approval, checkout);
|
|
321
|
+
if (typeof approved !== "boolean") return checkout;
|
|
322
|
+
await this.decidePaymentApproval(
|
|
323
|
+
checkout.id,
|
|
324
|
+
checkout.approval.id,
|
|
325
|
+
approved,
|
|
326
|
+
{ idempotencyKey: `approval-${checkout.approval.id}` },
|
|
327
|
+
);
|
|
328
|
+
if (!approved) return this.getCheckout(checkout.id);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
checkout.status === "help_required" &&
|
|
333
|
+
checkout.required_input === undefined &&
|
|
334
|
+
handledMessageRevision !== checkout.revision
|
|
335
|
+
) {
|
|
336
|
+
if (typeof options.message !== "function") return checkout;
|
|
337
|
+
handledMessageRevision = checkout.revision;
|
|
338
|
+
const content = await options.message(checkout);
|
|
339
|
+
if (typeof content !== "string" || content.length === 0)
|
|
340
|
+
return checkout;
|
|
341
|
+
await this.sendMessage(checkout.id, content, {
|
|
342
|
+
idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
347
|
+
const pollIntervalMs =
|
|
348
|
+
options.pollIntervalMs ??
|
|
349
|
+
(checkout.status === "help_required" ||
|
|
350
|
+
checkout.status === "approval_required"
|
|
351
|
+
? 5_000
|
|
352
|
+
: 2_000);
|
|
353
|
+
await delay(pollIntervalMs);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return checkout;
|
|
357
|
+
}
|
|
358
|
+
}
|
package/src/config.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ShopstackProfile {
|
|
2
|
+
accountId: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
keyType: "developer" | "user";
|
|
5
|
+
userId?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PendingSignup {
|
|
9
|
+
accountType: "developer" | "personal";
|
|
10
|
+
email: string;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
id: string;
|
|
13
|
+
pollToken: string;
|
|
14
|
+
profile: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ConfigStore {
|
|
18
|
+
constructor(path?: string);
|
|
19
|
+
activeProfile(): Promise<ShopstackProfile | undefined>;
|
|
20
|
+
saveProfile(
|
|
21
|
+
name: string,
|
|
22
|
+
profile: ShopstackProfile,
|
|
23
|
+
options?: { activate?: boolean },
|
|
24
|
+
): Promise<ShopstackProfile>;
|
|
25
|
+
useProfile(name: string): Promise<ShopstackProfile>;
|
|
26
|
+
savePendingSignup(signup: PendingSignup): Promise<PendingSignup>;
|
|
27
|
+
pendingSignup(signupId: string): Promise<PendingSignup | undefined>;
|
|
28
|
+
deletePendingSignup(signupId: string): Promise<void>;
|
|
29
|
+
load(): Promise<{
|
|
30
|
+
active: string | null;
|
|
31
|
+
pendingSignups: Record<string, PendingSignup>;
|
|
32
|
+
profiles: Record<string, ShopstackProfile>;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function defaultConfigPath(): string;
|
package/src/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function defaultConfigPath() {
|
|
6
|
+
return (
|
|
7
|
+
process.env.SHOPSTACK_CONFIG_FILE ??
|
|
8
|
+
join(homedir(), ".config", "shopstack", "config.json")
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ConfigStore {
|
|
13
|
+
constructor(path = defaultConfigPath()) {
|
|
14
|
+
this.path = path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async load() {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(await readFile(this.path, "utf8"));
|
|
20
|
+
if (
|
|
21
|
+
typeof parsed !== "object" ||
|
|
22
|
+
parsed === null ||
|
|
23
|
+
typeof parsed.profiles !== "object" ||
|
|
24
|
+
parsed.profiles === null
|
|
25
|
+
) {
|
|
26
|
+
throw new Error("Shopstack configuration is invalid.");
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
parsed.pendingSignups !== undefined &&
|
|
30
|
+
(typeof parsed.pendingSignups !== "object" ||
|
|
31
|
+
parsed.pendingSignups === null ||
|
|
32
|
+
Array.isArray(parsed.pendingSignups))
|
|
33
|
+
) {
|
|
34
|
+
throw new Error("Shopstack configuration is invalid.");
|
|
35
|
+
}
|
|
36
|
+
parsed.pendingSignups ??= {};
|
|
37
|
+
return parsed;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === "ENOENT") {
|
|
40
|
+
return { active: null, pendingSignups: {}, profiles: {} };
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async write(config) {
|
|
47
|
+
const directory = dirname(this.path);
|
|
48
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
49
|
+
const temporary = `${this.path}.${process.pid}.tmp`;
|
|
50
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {
|
|
51
|
+
mode: 0o600,
|
|
52
|
+
});
|
|
53
|
+
await rename(temporary, this.path);
|
|
54
|
+
await chmod(this.path, 0o600);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async saveProfile(name, profile, { activate = false } = {}) {
|
|
58
|
+
if (!/^[A-Za-z0-9._-]{1,80}$/u.test(name)) {
|
|
59
|
+
throw new Error("Profile name is invalid.");
|
|
60
|
+
}
|
|
61
|
+
const config = await this.load();
|
|
62
|
+
config.profiles[name] = { ...profile };
|
|
63
|
+
if (activate || config.active === null) config.active = name;
|
|
64
|
+
await this.write(config);
|
|
65
|
+
return config.profiles[name];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async useProfile(name) {
|
|
69
|
+
const config = await this.load();
|
|
70
|
+
if (!Object.hasOwn(config.profiles, name)) {
|
|
71
|
+
throw new Error(`Unknown Shopstack profile: ${name}`);
|
|
72
|
+
}
|
|
73
|
+
config.active = name;
|
|
74
|
+
await this.write(config);
|
|
75
|
+
return config.profiles[name];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async activeProfile() {
|
|
79
|
+
const config = await this.load();
|
|
80
|
+
const name = config.active;
|
|
81
|
+
return typeof name === "string" ? config.profiles[name] : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async savePendingSignup(signup) {
|
|
85
|
+
const config = await this.load();
|
|
86
|
+
config.pendingSignups[signup.id] = { ...signup };
|
|
87
|
+
await this.write(config);
|
|
88
|
+
return config.pendingSignups[signup.id];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async pendingSignup(signupId) {
|
|
92
|
+
const config = await this.load();
|
|
93
|
+
return config.pendingSignups[signupId];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async deletePendingSignup(signupId) {
|
|
97
|
+
const config = await this.load();
|
|
98
|
+
delete config.pendingSignups[signupId];
|
|
99
|
+
await this.write(config);
|
|
100
|
+
}
|
|
101
|
+
}
|