glyphpay 0.2.0 → 0.3.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 +38 -1
- package/dist/cli.d.ts +65 -11
- package/dist/cli.js +635 -25
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,6 +8,16 @@ Drop-in connector that lets a website take payments through [GlyphPay](https://g
|
|
|
8
8
|
| `glyphpay/react` | Browser | `<GlyphPayCheckout />` |
|
|
9
9
|
| `glyphpay/browser` | Browser, any site | `mountCheckout(el, options)` for sites without React |
|
|
10
10
|
|
|
11
|
+
The CLI covers setup, diagnostics, and credential lifecycle:
|
|
12
|
+
|
|
13
|
+
| Command | What it does |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `glyphpay connect` | Pair a site through the browser and write the server credential. |
|
|
16
|
+
| `glyphpay doctor` | Check env, token, Stripe readiness, webhook URL, and an optional signed test event. |
|
|
17
|
+
| `glyphpay rotate` | Re-pair the site and replace the credential. |
|
|
18
|
+
| `glyphpay disconnect` | Revoke the credential and remove the local keys. |
|
|
19
|
+
| `glyphpay init` | Scaffold the Next.js App Router routes and env placeholders. |
|
|
20
|
+
|
|
11
21
|
## Connect a site from the CLI
|
|
12
22
|
|
|
13
23
|
```sh
|
|
@@ -29,13 +39,40 @@ GLYPHPAY_CALLBACK_SECRET=dgcb_...
|
|
|
29
39
|
|
|
30
40
|
The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
|
|
31
41
|
|
|
42
|
+
## Check, rotate, and disconnect
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npx glyphpay doctor
|
|
46
|
+
npx glyphpay doctor --json
|
|
47
|
+
npx glyphpay rotate
|
|
48
|
+
npx glyphpay disconnect
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`doctor` verifies the local env file, checks for credentials leaked into public-prefixed variables, validates the token with Glyph Pay, checks Stripe charge readiness and the registered webhook, and optionally sends a signed test event:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npx glyphpay doctor --send-test-event
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The test event has type `webhook.test` and a zero-dollar `data` payload. Ignore event types you do not handle in `onEvent`; do not treat the test event as a real payment.
|
|
58
|
+
|
|
59
|
+
`rotate` re-runs browser approval and reuses the registered callback URL when the current token is still valid. `disconnect` revokes the credential immediately and removes `GLYPHPAY_TOKEN` and `GLYPHPAY_CALLBACK_SECRET` from the env file; pass `--yes` in scripts or `--keep-env` to leave the local values in place.
|
|
60
|
+
|
|
61
|
+
## Scaffold a Next.js site
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npx glyphpay init
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The command detects `app` or `src/app`, creates `lib/glyphpay.ts` (or `.js`), adds the checkout and webhook route handlers, and appends placeholders to `.env.example` without changing existing values. It refuses to overwrite existing files unless you pass `--force`; use `--dry-run` to preview. Then run `glyphpay connect` and wire `priceOrder()` and `onEvent()` to your order database.
|
|
68
|
+
|
|
32
69
|
## Manual setup: connect the client in Design Glyph admin
|
|
33
70
|
|
|
34
71
|
1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
|
|
35
72
|
2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
|
|
36
73
|
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
|
|
37
74
|
|
|
38
|
-
##
|
|
75
|
+
## Add to the client site
|
|
39
76
|
|
|
40
77
|
```sh
|
|
41
78
|
pnpm add glyphpay
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Glyph Pay command line.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* `connect` creates a short-lived pairing request, opens the Glyph Pay site,
|
|
6
|
+
* and waits while the signed-in owner approves it in the browser. The service
|
|
7
|
+
* credential and webhook signing secret are returned only to this process,
|
|
8
|
+
* then written to the site's server environment file.
|
|
9
|
+
*
|
|
10
|
+
* `doctor` verifies the local setup and, optionally, delivers a signed test
|
|
11
|
+
* webhook. `disconnect` revokes the credential. `rotate` re-pairs and replaces
|
|
12
|
+
* it. `init` scaffolds the Next.js App Router integration.
|
|
10
13
|
*
|
|
11
14
|
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
15
|
*/
|
|
@@ -18,10 +21,10 @@ export declare class CliApiError extends Error {
|
|
|
18
21
|
readonly code: string;
|
|
19
22
|
constructor(status: number, code: string, message: string);
|
|
20
23
|
}
|
|
21
|
-
export interface
|
|
24
|
+
export interface CliOptions {
|
|
22
25
|
/** Site URL (a webhook path is appended when the URL has no path), or the exact callback URL. */
|
|
23
26
|
url?: string;
|
|
24
|
-
/** Env file to update. Defaults to `.env.local`. */
|
|
27
|
+
/** Env file to read or update. Defaults to `.env.local`. */
|
|
25
28
|
envFile?: string;
|
|
26
29
|
/** Print the credentials instead of writing them to a file. */
|
|
27
30
|
print?: boolean;
|
|
@@ -31,6 +34,20 @@ export interface ConnectOptions {
|
|
|
31
34
|
baseUrl?: string;
|
|
32
35
|
/** How long to wait for browser approval. */
|
|
33
36
|
timeoutSeconds?: number;
|
|
37
|
+
/** Machine-readable doctor output. */
|
|
38
|
+
json?: boolean;
|
|
39
|
+
/** Skip the interactive disconnect confirmation. */
|
|
40
|
+
yes?: boolean;
|
|
41
|
+
/** Keep local env values after a successful disconnect. */
|
|
42
|
+
keepEnv?: boolean;
|
|
43
|
+
/** Doctor: deliver a signed test event to the registered webhook. */
|
|
44
|
+
sendTestEvent?: boolean;
|
|
45
|
+
/** Init: overwrite generated files. */
|
|
46
|
+
force?: boolean;
|
|
47
|
+
/** Init: print planned files without writing. */
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
/** Init: project directory. Defaults to the current directory. */
|
|
50
|
+
dir?: string;
|
|
34
51
|
}
|
|
35
52
|
export interface ConnectResult {
|
|
36
53
|
businessName: string;
|
|
@@ -38,9 +55,16 @@ export interface ConnectResult {
|
|
|
38
55
|
callbackUrl: string | null;
|
|
39
56
|
writtenTo: string | null;
|
|
40
57
|
}
|
|
58
|
+
export interface DisconnectResult {
|
|
59
|
+
businessName: string | null;
|
|
60
|
+
removedKeys: boolean;
|
|
61
|
+
alreadyRevoked: boolean;
|
|
62
|
+
canceled: boolean;
|
|
63
|
+
}
|
|
64
|
+
type Command = "connect" | "rotate" | "disconnect" | "doctor" | "init" | "help" | "version";
|
|
41
65
|
interface ParsedCommand {
|
|
42
|
-
command:
|
|
43
|
-
options:
|
|
66
|
+
command: Command;
|
|
67
|
+
options: CliOptions;
|
|
44
68
|
}
|
|
45
69
|
export declare function parseArgs(argv: string[]): ParsedCommand;
|
|
46
70
|
export declare function helpText(): string;
|
|
@@ -52,7 +76,37 @@ export declare function toCallbackUrl(raw: string): string;
|
|
|
52
76
|
* so the file keeps a single source of truth.
|
|
53
77
|
*/
|
|
54
78
|
export declare function upsertEnv(contents: string, values: Record<string, string>): string;
|
|
79
|
+
/** Add missing `KEY=` placeholders without changing an existing value. */
|
|
80
|
+
export declare function ensureEnvKeys(contents: string, keys: string[]): string;
|
|
81
|
+
/** Remove managed keys, preserving every unrelated line. */
|
|
82
|
+
export declare function removeEnvKeys(contents: string, keys: string[]): string;
|
|
83
|
+
/** Read a simple KEY=value line without executing or expanding the file. */
|
|
84
|
+
export declare function readEnvValue(contents: string, key: string): string | null;
|
|
85
|
+
/** Public-prefixed variables must never contain a Glyph Pay credential. */
|
|
86
|
+
export declare function findPublicCredentialKeys(contents: string): string[];
|
|
55
87
|
export declare function openBrowser(url: string): boolean;
|
|
56
|
-
export declare function runConnect(options?:
|
|
88
|
+
export declare function runConnect(options?: CliOptions, mode?: "connect" | "rotate"): Promise<ConnectResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Re-pair the site. If the current env token is still valid, reuse the
|
|
91
|
+
* callback URL registered on the server so a rotation does not drop webhooks.
|
|
92
|
+
*/
|
|
93
|
+
export declare function runRotate(options?: CliOptions): Promise<ConnectResult>;
|
|
94
|
+
export declare function runDisconnect(options?: CliOptions): Promise<DisconnectResult>;
|
|
95
|
+
export type DoctorCheckStatus = "ok" | "warn" | "error";
|
|
96
|
+
export interface DoctorCheck {
|
|
97
|
+
id: string;
|
|
98
|
+
status: DoctorCheckStatus;
|
|
99
|
+
label: string;
|
|
100
|
+
detail?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface DoctorReport {
|
|
103
|
+
ok: boolean;
|
|
104
|
+
envFile: string;
|
|
105
|
+
environment: string | null;
|
|
106
|
+
businessName: string | null;
|
|
107
|
+
checks: DoctorCheck[];
|
|
108
|
+
}
|
|
109
|
+
export declare function runDoctor(options?: CliOptions): Promise<DoctorReport>;
|
|
110
|
+
export declare function runInit(options?: CliOptions): Promise<void>;
|
|
57
111
|
export declare function main(argv?: string[]): Promise<number>;
|
|
58
112
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Glyph Pay command line.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* `connect` creates a short-lived pairing request, opens the Glyph Pay site,
|
|
6
|
+
* and waits while the signed-in owner approves it in the browser. The service
|
|
7
|
+
* credential and webhook signing secret are returned only to this process,
|
|
8
|
+
* then written to the site's server environment file.
|
|
9
|
+
*
|
|
10
|
+
* `doctor` verifies the local setup and, optionally, delivers a signed test
|
|
11
|
+
* webhook. `disconnect` revokes the credential. `rotate` re-pairs and replaces
|
|
12
|
+
* it. `init` scaffolds the Next.js App Router integration.
|
|
10
13
|
*
|
|
11
14
|
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
15
|
*/
|
|
13
16
|
import { spawn } from "node:child_process";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
17
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
18
|
+
import { access, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
16
21
|
import { pathToFileURL } from "node:url";
|
|
17
|
-
const VERSION = "0.
|
|
22
|
+
const VERSION = "0.3.0";
|
|
18
23
|
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
19
24
|
const DEFAULT_ENV_FILE = ".env.local";
|
|
20
25
|
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
@@ -56,6 +61,19 @@ function takeValue(argv, index, flag) {
|
|
|
56
61
|
throw new CliUsageError(`${flag} needs a value.`);
|
|
57
62
|
return [value, index + 1];
|
|
58
63
|
}
|
|
64
|
+
function commandFor(value) {
|
|
65
|
+
if (value === "connect" || value === "login")
|
|
66
|
+
return "connect";
|
|
67
|
+
if (value === "rotate")
|
|
68
|
+
return "rotate";
|
|
69
|
+
if (value === "disconnect" || value === "logout")
|
|
70
|
+
return "disconnect";
|
|
71
|
+
if (value === "doctor" || value === "check")
|
|
72
|
+
return "doctor";
|
|
73
|
+
if (value === "init")
|
|
74
|
+
return "init";
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
59
77
|
export function parseArgs(argv) {
|
|
60
78
|
const options = {};
|
|
61
79
|
let command = "connect";
|
|
@@ -66,11 +84,12 @@ export function parseArgs(argv) {
|
|
|
66
84
|
return { command: "help", options };
|
|
67
85
|
if (argument === "--version" || argument === "-v")
|
|
68
86
|
return { command: "version", options };
|
|
69
|
-
|
|
87
|
+
const namedCommand = commandFor(argument);
|
|
88
|
+
if (namedCommand) {
|
|
70
89
|
if (sawCommand)
|
|
71
90
|
throw new CliUsageError("Only one command can run at a time.");
|
|
72
91
|
sawCommand = true;
|
|
73
|
-
command =
|
|
92
|
+
command = namedCommand;
|
|
74
93
|
continue;
|
|
75
94
|
}
|
|
76
95
|
if (argument === "--print") {
|
|
@@ -81,6 +100,30 @@ export function parseArgs(argv) {
|
|
|
81
100
|
options.noOpen = true;
|
|
82
101
|
continue;
|
|
83
102
|
}
|
|
103
|
+
if (argument === "--json") {
|
|
104
|
+
options.json = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (argument === "--yes" || argument === "-y") {
|
|
108
|
+
options.yes = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (argument === "--keep-env") {
|
|
112
|
+
options.keepEnv = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (argument === "--send-test-event") {
|
|
116
|
+
options.sendTestEvent = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (argument === "--force") {
|
|
120
|
+
options.force = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (argument === "--dry-run") {
|
|
124
|
+
options.dryRun = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
84
127
|
const [flag, inlineValue] = argument.includes("=") ? argument.split(/=(.*)/s, 2) : [argument, undefined];
|
|
85
128
|
if (flag === "--url" || flag === "--callback-url") {
|
|
86
129
|
if (inlineValue !== undefined) {
|
|
@@ -121,6 +164,19 @@ export function parseArgs(argv) {
|
|
|
121
164
|
}
|
|
122
165
|
continue;
|
|
123
166
|
}
|
|
167
|
+
if (flag === "--dir") {
|
|
168
|
+
if (inlineValue !== undefined) {
|
|
169
|
+
if (!inlineValue)
|
|
170
|
+
throw new CliUsageError("--dir needs a path.");
|
|
171
|
+
options.dir = inlineValue;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
175
|
+
options.dir = value;
|
|
176
|
+
index = next;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
124
180
|
if (flag === "--timeout") {
|
|
125
181
|
const raw = inlineValue ?? takeValue(argv, index, flag)[0];
|
|
126
182
|
if (inlineValue === undefined)
|
|
@@ -142,19 +198,34 @@ export function helpText() {
|
|
|
142
198
|
return `glyphpay - take payments on your site through Glyph Pay
|
|
143
199
|
|
|
144
200
|
Usage
|
|
145
|
-
glyphpay connect [options]
|
|
201
|
+
glyphpay connect [options] Connect a site (alias: login)
|
|
202
|
+
glyphpay rotate [options] Re-pair and rotate the current credential
|
|
203
|
+
glyphpay disconnect [options] Revoke the credential and remove local keys
|
|
204
|
+
glyphpay doctor [options] Check env, token, Stripe and webhook setup
|
|
205
|
+
glyphpay init [options] Scaffold a Next.js App Router integration
|
|
146
206
|
glyphpay --version
|
|
147
207
|
|
|
148
|
-
|
|
149
|
-
--url <site-url> Site to connect. Adds
|
|
150
|
-
|
|
208
|
+
Connect / rotate
|
|
209
|
+
--url <site-url> Site to connect. Adds ${WEBHOOK_PATH} when the URL
|
|
210
|
+
has no path. Or pass the exact callback URL.
|
|
151
211
|
--env <path> Env file to update (default: ${DEFAULT_ENV_FILE})
|
|
152
212
|
--print Print the credentials instead of writing a file
|
|
153
213
|
--no-open Do not open a browser automatically
|
|
154
214
|
--base-url <url> Glyph Pay API origin (default: ${DEFAULT_BASE_URL})
|
|
155
215
|
--timeout <seconds> Wait for approval (default: ${DEFAULT_TIMEOUT_SECONDS})
|
|
156
|
-
|
|
157
|
-
|
|
216
|
+
|
|
217
|
+
Doctor
|
|
218
|
+
--json Print a machine-readable report
|
|
219
|
+
--send-test-event POST a signed webhook.test event to the callback URL
|
|
220
|
+
|
|
221
|
+
Disconnect
|
|
222
|
+
--yes, -y Do not ask for confirmation
|
|
223
|
+
--keep-env Keep the local env values after revocation
|
|
224
|
+
|
|
225
|
+
Init
|
|
226
|
+
--dir <path> Project directory (default: current directory)
|
|
227
|
+
--dry-run Show planned files without writing them
|
|
228
|
+
--force Overwrite generated files
|
|
158
229
|
|
|
159
230
|
The browser step signs in to Glyph Pay, lets you pick the client account, and
|
|
160
231
|
approves this machine. The service credential goes only to this terminal.`;
|
|
@@ -216,6 +287,50 @@ export function upsertEnv(contents, values) {
|
|
|
216
287
|
output.push(`${key}=${value}`);
|
|
217
288
|
return `${output.join(eol)}${eol}`;
|
|
218
289
|
}
|
|
290
|
+
/** Add missing `KEY=` placeholders without changing an existing value. */
|
|
291
|
+
export function ensureEnvKeys(contents, keys) {
|
|
292
|
+
const missing = keys.filter((key) => !new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`, "m").test(contents));
|
|
293
|
+
if (missing.length === 0)
|
|
294
|
+
return contents;
|
|
295
|
+
const base = contents.length && !contents.endsWith("\n") ? `${contents}\n` : contents;
|
|
296
|
+
return `${base}${missing.map((key) => `${key}=`).join("\n")}\n`;
|
|
297
|
+
}
|
|
298
|
+
/** Remove managed keys, preserving every unrelated line. */
|
|
299
|
+
export function removeEnvKeys(contents, keys) {
|
|
300
|
+
if (!contents.length)
|
|
301
|
+
return "";
|
|
302
|
+
const managed = new Set(keys);
|
|
303
|
+
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
304
|
+
const lines = contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n");
|
|
305
|
+
const output = lines.filter((line) => {
|
|
306
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
307
|
+
return !(match && managed.has(match[1]));
|
|
308
|
+
});
|
|
309
|
+
return output.length ? `${output.join(eol)}${eol}` : "";
|
|
310
|
+
}
|
|
311
|
+
/** Read a simple KEY=value line without executing or expanding the file. */
|
|
312
|
+
export function readEnvValue(contents, key) {
|
|
313
|
+
const match = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)$`, "m").exec(contents);
|
|
314
|
+
if (!match)
|
|
315
|
+
return null;
|
|
316
|
+
const raw = match[1].trim();
|
|
317
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) ||
|
|
318
|
+
(raw.startsWith("'") && raw.endsWith("'"))) {
|
|
319
|
+
return raw.slice(1, -1);
|
|
320
|
+
}
|
|
321
|
+
return raw;
|
|
322
|
+
}
|
|
323
|
+
/** Public-prefixed variables must never contain a Glyph Pay credential. */
|
|
324
|
+
export function findPublicCredentialKeys(contents) {
|
|
325
|
+
const exposed = [];
|
|
326
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
327
|
+
const match = /^\s*(?:export\s+)?((?:NEXT_PUBLIC_|VITE_|PUBLIC_)[A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
328
|
+
if (match && /(?:dgsvc_|dgcb_|GLYPHPAY_(?:TOKEN|CALLBACK_SECRET))/.test(match[2])) {
|
|
329
|
+
exposed.push(match[1]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return exposed;
|
|
333
|
+
}
|
|
219
334
|
function isNotFound(error) {
|
|
220
335
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
221
336
|
}
|
|
@@ -239,6 +354,23 @@ async function writeEnvFile(path, contents) {
|
|
|
239
354
|
// Windows does not use POSIX modes; the file still lives in the project.
|
|
240
355
|
}
|
|
241
356
|
}
|
|
357
|
+
async function fileExists(path) {
|
|
358
|
+
try {
|
|
359
|
+
await access(path);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function directoryExists(path) {
|
|
367
|
+
try {
|
|
368
|
+
return (await stat(path)).isDirectory();
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
242
374
|
/* ------------------------------------------------------------------ */
|
|
243
375
|
/* Browser and API */
|
|
244
376
|
/* ------------------------------------------------------------------ */
|
|
@@ -255,7 +387,7 @@ export function openBrowser(url) {
|
|
|
255
387
|
return false;
|
|
256
388
|
}
|
|
257
389
|
}
|
|
258
|
-
async function api(baseUrl, path, body) {
|
|
390
|
+
async function api(baseUrl, path, body, token) {
|
|
259
391
|
let response;
|
|
260
392
|
try {
|
|
261
393
|
response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/cli/v1${path}`, {
|
|
@@ -263,6 +395,7 @@ async function api(baseUrl, path, body) {
|
|
|
263
395
|
headers: {
|
|
264
396
|
accept: "application/json",
|
|
265
397
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
398
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
266
399
|
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
267
400
|
},
|
|
268
401
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
@@ -279,6 +412,12 @@ async function api(baseUrl, path, body) {
|
|
|
279
412
|
}
|
|
280
413
|
return payload.data;
|
|
281
414
|
}
|
|
415
|
+
function baseUrlFor(options) {
|
|
416
|
+
return options.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
|
|
417
|
+
}
|
|
418
|
+
function fetchCredential(baseUrl, token) {
|
|
419
|
+
return api(baseUrl, "/credential", undefined, token);
|
|
420
|
+
}
|
|
282
421
|
async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
283
422
|
const deadline = Date.now() + timeoutMs;
|
|
284
423
|
let interval = Math.max(1, firstInterval);
|
|
@@ -306,15 +445,15 @@ async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
|
306
445
|
"Run the command again for a fresh code.");
|
|
307
446
|
}
|
|
308
447
|
/* ------------------------------------------------------------------ */
|
|
309
|
-
/* Connect
|
|
448
|
+
/* Connect and rotate */
|
|
310
449
|
/* ------------------------------------------------------------------ */
|
|
311
|
-
export async function runConnect(options = {}) {
|
|
312
|
-
const baseUrl = options
|
|
450
|
+
export async function runConnect(options = {}, mode = "connect") {
|
|
451
|
+
const baseUrl = baseUrlFor(options);
|
|
313
452
|
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
314
453
|
const callbackUrl = options.url ? toCallbackUrl(options.url) : null;
|
|
315
454
|
const created = await api(baseUrl, "/device", { callbackUrl });
|
|
316
455
|
say();
|
|
317
|
-
say(style(1, "Glyph Pay - connect a site"));
|
|
456
|
+
say(style(1, mode === "rotate" ? "Glyph Pay - rotate credential" : "Glyph Pay - connect a site"));
|
|
318
457
|
say();
|
|
319
458
|
say(`1. Open ${style(4, created.verificationUrl)}`);
|
|
320
459
|
say(`2. Sign in and approve pairing code ${style(1, created.userCode)}.`);
|
|
@@ -329,13 +468,13 @@ export async function runConnect(options = {}) {
|
|
|
329
468
|
say(" Could not open a browser. Open the link above manually.");
|
|
330
469
|
}
|
|
331
470
|
say();
|
|
332
|
-
say("Waiting for approval...");
|
|
471
|
+
say(mode === "rotate" ? "Waiting for approval (the previous token stays valid until then)..." : "Waiting for approval...");
|
|
333
472
|
const approved = await waitForApproval(baseUrl, created.deviceCode, created.interval, timeoutSeconds * 1000);
|
|
334
473
|
const values = { [TOKEN_KEY]: approved.token };
|
|
335
474
|
if (approved.callbackSecret)
|
|
336
475
|
values[CALLBACK_SECRET_KEY] = approved.callbackSecret;
|
|
337
476
|
say();
|
|
338
|
-
say(style(1,
|
|
477
|
+
say(style(1, `${mode === "rotate" ? "Rotated" : "Connected"} ${approved.businessName} (${approved.environment} mode).`));
|
|
339
478
|
if (approved.callbackUrl)
|
|
340
479
|
say(`Webhook registered: ${approved.callbackUrl}`);
|
|
341
480
|
else
|
|
@@ -366,7 +505,9 @@ export async function runConnect(options = {}) {
|
|
|
366
505
|
}
|
|
367
506
|
}
|
|
368
507
|
say();
|
|
369
|
-
say(
|
|
508
|
+
say(mode === "rotate"
|
|
509
|
+
? "The previous token is already invalid. Deploy the new values before the next request."
|
|
510
|
+
: "The token is shown only once. Run this command again to rotate it.");
|
|
370
511
|
return {
|
|
371
512
|
businessName: approved.businessName,
|
|
372
513
|
environment: approved.environment,
|
|
@@ -374,6 +515,459 @@ export async function runConnect(options = {}) {
|
|
|
374
515
|
writtenTo,
|
|
375
516
|
};
|
|
376
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Re-pair the site. If the current env token is still valid, reuse the
|
|
520
|
+
* callback URL registered on the server so a rotation does not drop webhooks.
|
|
521
|
+
*/
|
|
522
|
+
export async function runRotate(options = {}) {
|
|
523
|
+
const baseUrl = baseUrlFor(options);
|
|
524
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
525
|
+
const contents = await readEnvFile(envFile);
|
|
526
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
527
|
+
let url = options.url;
|
|
528
|
+
if (token) {
|
|
529
|
+
try {
|
|
530
|
+
const info = await fetchCredential(baseUrl, token);
|
|
531
|
+
say();
|
|
532
|
+
say(`Rotating ${info.businessName} (${info.environment} mode).`);
|
|
533
|
+
if (!url && info.callbackUrl)
|
|
534
|
+
url = info.callbackUrl;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (error instanceof CliApiError && error.status === 401) {
|
|
538
|
+
say(style(33, "The current token is no longer valid; starting a fresh connection."));
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return runConnect({ ...options, url }, "rotate");
|
|
546
|
+
}
|
|
547
|
+
/* ------------------------------------------------------------------ */
|
|
548
|
+
/* Disconnect */
|
|
549
|
+
/* ------------------------------------------------------------------ */
|
|
550
|
+
export async function runDisconnect(options = {}) {
|
|
551
|
+
const baseUrl = baseUrlFor(options);
|
|
552
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
553
|
+
const contents = await readEnvFile(envFile);
|
|
554
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
555
|
+
const displayEnv = relative(process.cwd(), envFile) || envFile;
|
|
556
|
+
if (!token) {
|
|
557
|
+
throw new CliUsageError(`No ${TOKEN_KEY} found in ${displayEnv}. Nothing to disconnect.`);
|
|
558
|
+
}
|
|
559
|
+
if (!options.yes) {
|
|
560
|
+
if (!process.stdin.isTTY) {
|
|
561
|
+
throw new CliUsageError("Use --yes when running disconnect non-interactively.");
|
|
562
|
+
}
|
|
563
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
564
|
+
let answer = "";
|
|
565
|
+
try {
|
|
566
|
+
answer = await prompt.question(`Revoke this site's Glyph Pay credential? The site stops working immediately. [y/N] `);
|
|
567
|
+
}
|
|
568
|
+
finally {
|
|
569
|
+
prompt.close();
|
|
570
|
+
}
|
|
571
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
572
|
+
say("Canceled.");
|
|
573
|
+
return { businessName: null, removedKeys: false, alreadyRevoked: false, canceled: true };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
let businessName = null;
|
|
577
|
+
let alreadyRevoked = false;
|
|
578
|
+
try {
|
|
579
|
+
const result = await api(baseUrl, "/credential/revoke", {}, token);
|
|
580
|
+
businessName = result.businessName;
|
|
581
|
+
alreadyRevoked = result.alreadyRevoked;
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (error instanceof CliApiError && error.status === 401) {
|
|
585
|
+
alreadyRevoked = true;
|
|
586
|
+
say(style(33, "The token is already invalid; removing the local credentials."));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
let removedKeys = false;
|
|
593
|
+
if (!options.keepEnv) {
|
|
594
|
+
await writeEnvFile(envFile, removeEnvKeys(contents, [TOKEN_KEY, CALLBACK_SECRET_KEY]));
|
|
595
|
+
removedKeys = true;
|
|
596
|
+
}
|
|
597
|
+
say();
|
|
598
|
+
say(style(1, alreadyRevoked
|
|
599
|
+
? "That credential was already disconnected."
|
|
600
|
+
: `Disconnected ${businessName ?? "the site"}.`));
|
|
601
|
+
if (removedKeys)
|
|
602
|
+
say(`Removed ${TOKEN_KEY} and ${CALLBACK_SECRET_KEY} from ${displayEnv}.`);
|
|
603
|
+
else
|
|
604
|
+
say(`The credential is revoked. Local values in ${displayEnv} were kept.`);
|
|
605
|
+
return { businessName, removedKeys, alreadyRevoked, canceled: false };
|
|
606
|
+
}
|
|
607
|
+
const TOKEN_PATTERN = /^dgsvc_(test|live)_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9_-]{43}$/;
|
|
608
|
+
function addDoctorCheck(report, check, json) {
|
|
609
|
+
report.checks.push(check);
|
|
610
|
+
if (json)
|
|
611
|
+
return;
|
|
612
|
+
const symbol = check.status === "ok" ? style(32, "✓") : check.status === "warn" ? style(33, "!") : style(31, "✗");
|
|
613
|
+
say(`${symbol} ${check.label}`);
|
|
614
|
+
if (check.detail)
|
|
615
|
+
say(` ${check.detail}`);
|
|
616
|
+
}
|
|
617
|
+
function signedTestEvent(secret, environment) {
|
|
618
|
+
const createdAt = new Date().toISOString();
|
|
619
|
+
const eventId = `evt_test_${randomBytes(8).toString("hex")}`;
|
|
620
|
+
const body = JSON.stringify({
|
|
621
|
+
id: eventId,
|
|
622
|
+
type: "webhook.test",
|
|
623
|
+
createdAt,
|
|
624
|
+
livemode: environment === "live",
|
|
625
|
+
data: {
|
|
626
|
+
id: "glyphpay_doctor",
|
|
627
|
+
orderId: "glyphpay-doctor",
|
|
628
|
+
status: "succeeded",
|
|
629
|
+
amountCents: 0,
|
|
630
|
+
applicationFeeCents: 0,
|
|
631
|
+
currency: "usd",
|
|
632
|
+
refundedCents: 0,
|
|
633
|
+
refunds: [],
|
|
634
|
+
disputes: [],
|
|
635
|
+
stale: false,
|
|
636
|
+
updatedAt: createdAt,
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
640
|
+
const signature = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
641
|
+
return { body, header: `t=${timestamp},v1=${signature}`, eventId };
|
|
642
|
+
}
|
|
643
|
+
async function deliverTestEvent(callbackUrl, secret, environment) {
|
|
644
|
+
const { body, header } = signedTestEvent(secret, environment);
|
|
645
|
+
const response = await fetch(callbackUrl, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
headers: {
|
|
648
|
+
"content-type": "application/json",
|
|
649
|
+
"designglyph-signature": header,
|
|
650
|
+
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
651
|
+
},
|
|
652
|
+
body,
|
|
653
|
+
signal: AbortSignal.timeout(10_000),
|
|
654
|
+
});
|
|
655
|
+
const text = await response.text().catch(() => "");
|
|
656
|
+
return { status: response.status, ok: response.ok, text: text.slice(0, 200) };
|
|
657
|
+
}
|
|
658
|
+
export async function runDoctor(options = {}) {
|
|
659
|
+
const baseUrl = baseUrlFor(options);
|
|
660
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
661
|
+
const displayEnv = relative(process.cwd(), envFile) || envFile;
|
|
662
|
+
const json = !!options.json;
|
|
663
|
+
const report = {
|
|
664
|
+
ok: true,
|
|
665
|
+
envFile: displayEnv,
|
|
666
|
+
environment: null,
|
|
667
|
+
businessName: null,
|
|
668
|
+
checks: [],
|
|
669
|
+
};
|
|
670
|
+
if (!json) {
|
|
671
|
+
say();
|
|
672
|
+
say(style(1, "Glyph Pay doctor"));
|
|
673
|
+
say(`Env file: ${displayEnv}`);
|
|
674
|
+
say();
|
|
675
|
+
}
|
|
676
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
677
|
+
addDoctorCheck(report, major >= 18
|
|
678
|
+
? { id: "node", status: "ok", label: `Node ${process.versions.node}` }
|
|
679
|
+
: { id: "node", status: "error", label: `Node ${process.versions.node} is too old`, detail: "The CLI requires Node 18 or newer." }, json);
|
|
680
|
+
const exists = await fileExists(envFile);
|
|
681
|
+
addDoctorCheck(report, exists
|
|
682
|
+
? { id: "env", status: "ok", label: `Found ${displayEnv}` }
|
|
683
|
+
: { id: "env", status: "error", label: `${displayEnv} was not found`, detail: "Run `glyphpay connect` first or pass --env <path>." }, json);
|
|
684
|
+
const contents = await readEnvFile(envFile);
|
|
685
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
686
|
+
const callbackSecret = readEnvValue(contents, CALLBACK_SECRET_KEY);
|
|
687
|
+
const tokenMatch = token ? TOKEN_PATTERN.exec(token) : null;
|
|
688
|
+
addDoctorCheck(report, token
|
|
689
|
+
? { id: "token-present", status: "ok", label: `${TOKEN_KEY} is present` }
|
|
690
|
+
: { id: "token-present", status: "error", label: `${TOKEN_KEY} is missing`, detail: "Run `glyphpay connect` to issue a credential." }, json);
|
|
691
|
+
if (token) {
|
|
692
|
+
addDoctorCheck(report, tokenMatch
|
|
693
|
+
? { id: "token-format", status: "ok", label: "Token format is valid", detail: `${tokenMatch[1]} mode` }
|
|
694
|
+
: { id: "token-format", status: "error", label: "Token format is not a dgsvc_ service credential" }, json);
|
|
695
|
+
}
|
|
696
|
+
const exposed = findPublicCredentialKeys(contents);
|
|
697
|
+
addDoctorCheck(report, exposed.length
|
|
698
|
+
? {
|
|
699
|
+
id: "public-env",
|
|
700
|
+
status: "error",
|
|
701
|
+
label: `${exposed.join(", ")} expose a Glyph Pay credential`,
|
|
702
|
+
detail: "Move these values to server-only variables and rotate the credential.",
|
|
703
|
+
}
|
|
704
|
+
: { id: "public-env", status: "ok", label: "No credential is in a public-prefixed variable" }, json);
|
|
705
|
+
if (callbackSecret) {
|
|
706
|
+
addDoctorCheck(report, /^dgcb_[A-Za-z0-9_-]{20,}$/.test(callbackSecret)
|
|
707
|
+
? { id: "callback-secret-format", status: "ok", label: "Callback signing secret format is valid" }
|
|
708
|
+
: { id: "callback-secret-format", status: "warn", label: "Callback signing secret has an unexpected format" }, json);
|
|
709
|
+
}
|
|
710
|
+
let info = null;
|
|
711
|
+
if (token && tokenMatch) {
|
|
712
|
+
try {
|
|
713
|
+
info = await fetchCredential(baseUrl, token);
|
|
714
|
+
report.environment = info.environment;
|
|
715
|
+
report.businessName = info.businessName;
|
|
716
|
+
addDoctorCheck(report, {
|
|
717
|
+
id: "token-valid",
|
|
718
|
+
status: "ok",
|
|
719
|
+
label: "Glyph Pay accepted the token",
|
|
720
|
+
detail: `${info.businessName} · ${info.environment} mode`,
|
|
721
|
+
}, json);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
addDoctorCheck(report, {
|
|
725
|
+
id: "token-valid",
|
|
726
|
+
status: "error",
|
|
727
|
+
label: "Glyph Pay rejected the token",
|
|
728
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
729
|
+
}, json);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (info) {
|
|
733
|
+
addDoctorCheck(report, info.chargesEnabled
|
|
734
|
+
? { id: "stripe", status: "ok", label: "Stripe connected account can accept charges" }
|
|
735
|
+
: {
|
|
736
|
+
id: "stripe",
|
|
737
|
+
status: "error",
|
|
738
|
+
label: "Stripe connected account is not ready for charges",
|
|
739
|
+
detail: info.connectedAccountId
|
|
740
|
+
? "Complete Stripe onboarding in Design Glyph admin, then re-check."
|
|
741
|
+
: "No connected account is linked to this client yet.",
|
|
742
|
+
}, json);
|
|
743
|
+
if (info.callbackUrl) {
|
|
744
|
+
addDoctorCheck(report, { id: "callback-url", status: "ok", label: "Webhook URL is registered", detail: info.callbackUrl }, json);
|
|
745
|
+
addDoctorCheck(report, callbackSecret
|
|
746
|
+
? { id: "callback-secret", status: "ok", label: "Callback signing secret is present locally" }
|
|
747
|
+
: {
|
|
748
|
+
id: "callback-secret",
|
|
749
|
+
status: "error",
|
|
750
|
+
label: "Callback signing secret is missing locally",
|
|
751
|
+
detail: "Re-register the webhook in Glyph Pay admin and update GLYPHPAY_CALLBACK_SECRET.",
|
|
752
|
+
}, json);
|
|
753
|
+
if (callbackSecret) {
|
|
754
|
+
try {
|
|
755
|
+
const reachable = await fetch(info.callbackUrl, {
|
|
756
|
+
method: "HEAD",
|
|
757
|
+
redirect: "manual",
|
|
758
|
+
signal: AbortSignal.timeout(8_000),
|
|
759
|
+
});
|
|
760
|
+
addDoctorCheck(report, reachable.status >= 500 || reachable.status === 404
|
|
761
|
+
? {
|
|
762
|
+
id: "callback-reachable",
|
|
763
|
+
status: "warn",
|
|
764
|
+
label: `Webhook URL returned HTTP ${reachable.status} to a HEAD request`,
|
|
765
|
+
detail: "GlyphPay sends POST requests. A custom route may reject HEAD.",
|
|
766
|
+
}
|
|
767
|
+
: {
|
|
768
|
+
id: "callback-reachable",
|
|
769
|
+
status: "ok",
|
|
770
|
+
label: `Webhook URL is reachable (HTTP ${reachable.status})`,
|
|
771
|
+
}, json);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
addDoctorCheck(report, {
|
|
775
|
+
id: "callback-reachable",
|
|
776
|
+
status: "warn",
|
|
777
|
+
label: "Webhook URL was not reachable from this machine",
|
|
778
|
+
detail: "A firewall may block this terminal; Glyph Pay may still reach the public URL.",
|
|
779
|
+
}, json);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (options.sendTestEvent) {
|
|
783
|
+
if (!callbackSecret) {
|
|
784
|
+
addDoctorCheck(report, { id: "test-event", status: "error", label: "Test event not sent", detail: "No callback signing secret is available." }, json);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
try {
|
|
788
|
+
const delivered = await deliverTestEvent(info.callbackUrl, callbackSecret, info.environment);
|
|
789
|
+
addDoctorCheck(report, delivered.ok
|
|
790
|
+
? {
|
|
791
|
+
id: "test-event",
|
|
792
|
+
status: "ok",
|
|
793
|
+
label: `Signed test event accepted (HTTP ${delivered.status})`,
|
|
794
|
+
}
|
|
795
|
+
: {
|
|
796
|
+
id: "test-event",
|
|
797
|
+
status: "error",
|
|
798
|
+
label: `Signed test event rejected (HTTP ${delivered.status})`,
|
|
799
|
+
detail: delivered.text || "Check the site's webhook route and callback secret.",
|
|
800
|
+
}, json);
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
addDoctorCheck(report, {
|
|
804
|
+
id: "test-event",
|
|
805
|
+
status: "error",
|
|
806
|
+
label: "Could not deliver the signed test event",
|
|
807
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
808
|
+
}, json);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
addDoctorCheck(report, {
|
|
815
|
+
id: "callback-url",
|
|
816
|
+
status: "warn",
|
|
817
|
+
label: "No webhook URL is registered",
|
|
818
|
+
detail: "Checkout works, but payment.succeeded events will not reach this site.",
|
|
819
|
+
}, json);
|
|
820
|
+
if (callbackSecret) {
|
|
821
|
+
addDoctorCheck(report, {
|
|
822
|
+
id: "callback-secret",
|
|
823
|
+
status: "warn",
|
|
824
|
+
label: "A callback secret is present locally but no webhook URL is registered",
|
|
825
|
+
}, json);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
report.ok = !report.checks.some((check) => check.status === "error");
|
|
830
|
+
if (json) {
|
|
831
|
+
say(JSON.stringify(report, null, 2));
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
say();
|
|
835
|
+
say(report.ok
|
|
836
|
+
? style(32, "No blocking problems found.")
|
|
837
|
+
: style(31, "Fix the errors above, then run `glyphpay doctor` again."));
|
|
838
|
+
}
|
|
839
|
+
return report;
|
|
840
|
+
}
|
|
841
|
+
function libTemplate(ts) {
|
|
842
|
+
const cartId = ts
|
|
843
|
+
? `const cartId = (body as { cartId?: string } | null)?.cartId;`
|
|
844
|
+
: `const cartId = body?.cartId;`;
|
|
845
|
+
return `import { glyphPay, CheckoutError } from "glyphpay/server";
|
|
846
|
+
|
|
847
|
+
export const glyphpay = glyphPay({
|
|
848
|
+
token: process.env.GLYPHPAY_TOKEN,
|
|
849
|
+
callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
|
|
850
|
+
|
|
851
|
+
async priceOrder(body) {
|
|
852
|
+
// Load the cart/order from YOUR database and return its server-side price.
|
|
853
|
+
// Never trust an amount sent by the browser.
|
|
854
|
+
${cartId}
|
|
855
|
+
if (!cartId) throw new CheckoutError("Your cart is empty.");
|
|
856
|
+
throw new CheckoutError("Connect priceOrder() to your order database before accepting payments.");
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
async onEvent(event) {
|
|
860
|
+
// At-least-once delivery: dedupe on event.id before changing an order.
|
|
861
|
+
if (event.type === "payment.succeeded") {
|
|
862
|
+
// TODO: mark the order paid and store event.id.
|
|
863
|
+
console.log(\`GlyphPay payment succeeded for \${event.data.orderId}\`);
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
`;
|
|
868
|
+
}
|
|
869
|
+
function routeTemplate() {
|
|
870
|
+
return `import { glyphpay } from "../../../../lib/glyphpay";
|
|
871
|
+
|
|
872
|
+
export const POST = glyphpay.checkout;
|
|
873
|
+
`;
|
|
874
|
+
}
|
|
875
|
+
function webhookRouteTemplate() {
|
|
876
|
+
return `import { glyphpay } from "../../../../lib/glyphpay";
|
|
877
|
+
|
|
878
|
+
export const POST = glyphpay.webhook;
|
|
879
|
+
`;
|
|
880
|
+
}
|
|
881
|
+
function installCommand(hasPnpmLock, hasYarnLock, hasBunLock) {
|
|
882
|
+
if (hasPnpmLock)
|
|
883
|
+
return "pnpm add glyphpay";
|
|
884
|
+
if (hasYarnLock)
|
|
885
|
+
return "yarn add glyphpay";
|
|
886
|
+
if (hasBunLock)
|
|
887
|
+
return "bun add glyphpay";
|
|
888
|
+
return "npm install glyphpay";
|
|
889
|
+
}
|
|
890
|
+
export async function runInit(options = {}) {
|
|
891
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
892
|
+
const packagePath = join(projectRoot, "package.json");
|
|
893
|
+
if (!(await fileExists(packagePath))) {
|
|
894
|
+
throw new CliUsageError(`No package.json found in ${projectRoot}. Run init in a Next.js project.`);
|
|
895
|
+
}
|
|
896
|
+
let pkg;
|
|
897
|
+
try {
|
|
898
|
+
pkg = JSON.parse(await readFile(packagePath, "utf8"));
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
throw new CliUsageError("package.json is not valid JSON.");
|
|
902
|
+
}
|
|
903
|
+
const dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
904
|
+
if (!dependencies.next) {
|
|
905
|
+
throw new CliUsageError("glyphpay init currently supports Next.js projects only.");
|
|
906
|
+
}
|
|
907
|
+
const hasSrcApp = await directoryExists(join(projectRoot, "src", "app"));
|
|
908
|
+
const hasRootApp = await directoryExists(join(projectRoot, "app"));
|
|
909
|
+
const hasPages = (await directoryExists(join(projectRoot, "pages"))) ||
|
|
910
|
+
(await directoryExists(join(projectRoot, "src", "pages")));
|
|
911
|
+
if (!hasSrcApp && !hasRootApp && hasPages) {
|
|
912
|
+
throw new CliUsageError("This project uses the Next.js Pages Router. glyphpay init supports the App Router; see the README for the two route files.");
|
|
913
|
+
}
|
|
914
|
+
const appDir = hasSrcApp ? join("src", "app") : join("app");
|
|
915
|
+
const libDir = hasSrcApp ? join("src", "lib") : join("lib");
|
|
916
|
+
const isTs = await fileExists(join(projectRoot, "tsconfig.json"));
|
|
917
|
+
const extension = isTs ? "ts" : "js";
|
|
918
|
+
const libPath = join(projectRoot, libDir, `glyphpay.${extension}`);
|
|
919
|
+
const checkoutPath = join(projectRoot, appDir, "api", "glyphpay", "checkout", `route.${extension}`);
|
|
920
|
+
const webhookPath = join(projectRoot, appDir, "api", "glyphpay", "webhook", `route.${extension}`);
|
|
921
|
+
const envPath = join(projectRoot, ".env.example");
|
|
922
|
+
const generated = [
|
|
923
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
924
|
+
{ path: checkoutPath, contents: routeTemplate() },
|
|
925
|
+
{ path: webhookPath, contents: webhookRouteTemplate() },
|
|
926
|
+
];
|
|
927
|
+
const existing = [];
|
|
928
|
+
for (const file of generated) {
|
|
929
|
+
if (await fileExists(file.path))
|
|
930
|
+
existing.push(relative(projectRoot, file.path).replace(/\\/g, "/"));
|
|
931
|
+
}
|
|
932
|
+
if (existing.length > 0 && !options.force) {
|
|
933
|
+
throw new CliUsageError(`Refusing to overwrite existing files:\n ${existing.join("\n ")}\nRe-run with --force to overwrite them.`);
|
|
934
|
+
}
|
|
935
|
+
const envExisting = await readEnvFile(envPath);
|
|
936
|
+
const envBase = envExisting.trim().length > 0
|
|
937
|
+
? envExisting
|
|
938
|
+
: "# Glyph Pay — server-side only. Never prefix these with NEXT_PUBLIC_.\n";
|
|
939
|
+
const envNext = ensureEnvKeys(envBase, [TOKEN_KEY, CALLBACK_SECRET_KEY]);
|
|
940
|
+
const envChanged = envNext !== envExisting;
|
|
941
|
+
const display = (path) => relative(projectRoot, path).replace(/\\/g, "/");
|
|
942
|
+
if (!options.dryRun) {
|
|
943
|
+
for (const file of generated) {
|
|
944
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
945
|
+
await writeFile(file.path, file.contents, "utf8");
|
|
946
|
+
}
|
|
947
|
+
if (envChanged)
|
|
948
|
+
await writeFile(envPath, envNext, "utf8");
|
|
949
|
+
}
|
|
950
|
+
say();
|
|
951
|
+
say(style(1, "Glyph Pay init"));
|
|
952
|
+
say();
|
|
953
|
+
for (const file of generated) {
|
|
954
|
+
const verb = existing.includes(display(file.path)) ? "Updated" : options.dryRun ? "Would create" : "Created";
|
|
955
|
+
say(` ${verb} ${display(file.path)}`);
|
|
956
|
+
}
|
|
957
|
+
say(` ${envChanged ? (options.dryRun ? "Would update" : "Updated") : "Unchanged"} .env.example`);
|
|
958
|
+
const pnpm = await fileExists(join(projectRoot, "pnpm-lock.yaml"));
|
|
959
|
+
const yarn = await fileExists(join(projectRoot, "yarn.lock"));
|
|
960
|
+
const bun = (await fileExists(join(projectRoot, "bun.lockb"))) || (await fileExists(join(projectRoot, "bun.lock")));
|
|
961
|
+
say();
|
|
962
|
+
say("Next steps:");
|
|
963
|
+
say(` 1. ${installCommand(pnpm, yarn, bun)}`);
|
|
964
|
+
say(" 2. npx glyphpay connect --url https://your-site.com");
|
|
965
|
+
say(` 3. Wire priceOrder() and onEvent() in ${display(libPath)}.`);
|
|
966
|
+
if (options.dryRun) {
|
|
967
|
+
say();
|
|
968
|
+
say("Dry run: no files were written.");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
377
971
|
/* ------------------------------------------------------------------ */
|
|
378
972
|
/* Entry point */
|
|
379
973
|
/* ------------------------------------------------------------------ */
|
|
@@ -388,6 +982,22 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
388
982
|
say(VERSION);
|
|
389
983
|
return 0;
|
|
390
984
|
}
|
|
985
|
+
if (parsed.command === "doctor") {
|
|
986
|
+
const report = await runDoctor(parsed.options);
|
|
987
|
+
return report.ok ? 0 : 1;
|
|
988
|
+
}
|
|
989
|
+
if (parsed.command === "init") {
|
|
990
|
+
await runInit(parsed.options);
|
|
991
|
+
return 0;
|
|
992
|
+
}
|
|
993
|
+
if (parsed.command === "disconnect") {
|
|
994
|
+
await runDisconnect(parsed.options);
|
|
995
|
+
return 0;
|
|
996
|
+
}
|
|
997
|
+
if (parsed.command === "rotate") {
|
|
998
|
+
await runRotate(parsed.options);
|
|
999
|
+
return 0;
|
|
1000
|
+
}
|
|
391
1001
|
await runConnect(parsed.options);
|
|
392
1002
|
return 0;
|
|
393
1003
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glyphpay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drop-in GlyphPay checkout for websites,
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Drop-in GlyphPay checkout for websites, with a CLI for setup, diagnostics, and credential lifecycle.",
|
|
5
5
|
"homepage": "https://glyphpay.net",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"glyphpay",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"license": "UNLICENSED",
|
|
15
15
|
"type": "module",
|
|
16
16
|
"bin": {
|
|
17
|
-
"glyphpay": "
|
|
17
|
+
"glyphpay": "dist/cli.js"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"dist"
|