glyphpay 0.2.0 → 0.4.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 +58 -2
- package/dist/cli.d.ts +90 -11
- package/dist/cli.js +828 -25
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,6 +8,17 @@ 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 deliveries` | List recent webhook deliveries and retry one with `--retry <id>`. |
|
|
20
|
+
| `glyphpay init` | Scaffold routes for Next.js, Remix, Astro, SvelteKit, Hono, or Express. |
|
|
21
|
+
|
|
11
22
|
## Connect a site from the CLI
|
|
12
23
|
|
|
13
24
|
```sh
|
|
@@ -29,13 +40,58 @@ GLYPHPAY_CALLBACK_SECRET=dgcb_...
|
|
|
29
40
|
|
|
30
41
|
The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
|
|
31
42
|
|
|
43
|
+
## Check, rotate, and disconnect
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npx glyphpay doctor
|
|
47
|
+
npx glyphpay doctor --json
|
|
48
|
+
npx glyphpay rotate
|
|
49
|
+
npx glyphpay disconnect
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`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:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npx glyphpay doctor --send-test-event
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
`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.
|
|
61
|
+
|
|
62
|
+
## Inspect and retry webhook deliveries
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx glyphpay deliveries
|
|
66
|
+
npx glyphpay deliveries --retry evt_...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`deliveries` lists the most recent callback outbox records for the credential's client and environment: event type, delivery state, attempt count, timestamp, and the last error. A retry sends the exact stored signed event again and persists the result. Retry is one attempt; fix the endpoint first. The admin **Connections** tab exposes the same operations.
|
|
70
|
+
|
|
71
|
+
## Scaffold a site
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npx glyphpay init
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The command detects the project framework from `package.json`. It creates a server connector plus the framework-native checkout and webhook entry points, and appends placeholders to `.env.example` without changing existing values. Supported targets:
|
|
78
|
+
|
|
79
|
+
- **Next.js App Router** — `app/api/glyphpay/*` or `src/app/api/glyphpay/*` route handlers.
|
|
80
|
+
- **SvelteKit** — `src/routes/api/glyphpay/*/+server.ts` with `src/lib/server/glyphpay.ts`.
|
|
81
|
+
- **Astro** — `src/pages/api/glyphpay/*.ts`; use server/SSR output and an adapter.
|
|
82
|
+
- **Remix** — `app/routes/api.glyphpay.*.ts` with `app/lib/glyphpay.server.ts`.
|
|
83
|
+
- **Hono** — `src/glyphpay-routes.ts`; mount it with `app.route("/api/glyphpay", glyphpayRoutes)`.
|
|
84
|
+
- **Express** — `src/glyphpay-routes.ts`; mount it with `app.use("/api/glyphpay", glyphpayRoutes)`. The webhook route uses `express.raw` so signatures verify against the original bytes.
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
32
88
|
## Manual setup: connect the client in Design Glyph admin
|
|
33
89
|
|
|
34
90
|
1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
|
|
35
91
|
2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
|
|
36
92
|
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
|
|
37
93
|
|
|
38
|
-
##
|
|
94
|
+
## Add to the client site
|
|
39
95
|
|
|
40
96
|
```sh
|
|
41
97
|
pnpm add glyphpay
|
|
@@ -106,7 +162,7 @@ Without React:
|
|
|
106
162
|
</script>
|
|
107
163
|
```
|
|
108
164
|
|
|
109
|
-
The route handlers take a standard `Request` and return a `Response`, so the same two lines work in Remix, Astro, Hono, SvelteKit, and Cloudflare Workers. In Express,
|
|
165
|
+
The route handlers take a standard `Request` and return a `Response`, so the same two lines work in Remix, Astro, Hono, SvelteKit, and Cloudflare Workers. In Express, `glyphpay init` generates a small adapter that preserves the raw webhook body.
|
|
110
166
|
|
|
111
167
|
## How a payment flows
|
|
112
168
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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 a framework integration. `deliveries` lists callback
|
|
13
|
+
* attempts and replays one on demand.
|
|
10
14
|
*
|
|
11
15
|
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
16
|
*/
|
|
@@ -18,10 +22,10 @@ export declare class CliApiError extends Error {
|
|
|
18
22
|
readonly code: string;
|
|
19
23
|
constructor(status: number, code: string, message: string);
|
|
20
24
|
}
|
|
21
|
-
export interface
|
|
25
|
+
export interface CliOptions {
|
|
22
26
|
/** Site URL (a webhook path is appended when the URL has no path), or the exact callback URL. */
|
|
23
27
|
url?: string;
|
|
24
|
-
/** Env file to update. Defaults to `.env.local`. */
|
|
28
|
+
/** Env file to read or update. Defaults to `.env.local`. */
|
|
25
29
|
envFile?: string;
|
|
26
30
|
/** Print the credentials instead of writing them to a file. */
|
|
27
31
|
print?: boolean;
|
|
@@ -31,6 +35,22 @@ export interface ConnectOptions {
|
|
|
31
35
|
baseUrl?: string;
|
|
32
36
|
/** How long to wait for browser approval. */
|
|
33
37
|
timeoutSeconds?: number;
|
|
38
|
+
/** Machine-readable doctor output. */
|
|
39
|
+
json?: boolean;
|
|
40
|
+
/** Skip the interactive disconnect confirmation. */
|
|
41
|
+
yes?: boolean;
|
|
42
|
+
/** Keep local env values after a successful disconnect. */
|
|
43
|
+
keepEnv?: boolean;
|
|
44
|
+
/** Doctor: deliver a signed test event to the registered webhook. */
|
|
45
|
+
sendTestEvent?: boolean;
|
|
46
|
+
/** Init: overwrite generated files. */
|
|
47
|
+
force?: boolean;
|
|
48
|
+
/** Init: print planned files without writing. */
|
|
49
|
+
dryRun?: boolean;
|
|
50
|
+
/** Init: project directory. Defaults to the current directory. */
|
|
51
|
+
dir?: string;
|
|
52
|
+
/** Deliveries: replay one failed or delivered callback by id. */
|
|
53
|
+
retry?: string;
|
|
34
54
|
}
|
|
35
55
|
export interface ConnectResult {
|
|
36
56
|
businessName: string;
|
|
@@ -38,9 +58,37 @@ export interface ConnectResult {
|
|
|
38
58
|
callbackUrl: string | null;
|
|
39
59
|
writtenTo: string | null;
|
|
40
60
|
}
|
|
61
|
+
export interface DisconnectResult {
|
|
62
|
+
businessName: string | null;
|
|
63
|
+
removedKeys: boolean;
|
|
64
|
+
alreadyRevoked: boolean;
|
|
65
|
+
canceled: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface CallbackDeliveryView {
|
|
68
|
+
id: string;
|
|
69
|
+
paymentId: string;
|
|
70
|
+
type: string;
|
|
71
|
+
state: "pending" | "delivered" | "failed";
|
|
72
|
+
attempts: number;
|
|
73
|
+
lastError: string | null;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
}
|
|
77
|
+
export interface CallbackDeliveriesResponse {
|
|
78
|
+
businessId: string;
|
|
79
|
+
businessName: string;
|
|
80
|
+
environment: "test" | "live";
|
|
81
|
+
deliveries: CallbackDeliveryView[];
|
|
82
|
+
}
|
|
83
|
+
export interface CallbackReplayResponse {
|
|
84
|
+
outcome: "delivered" | "retry" | "failed";
|
|
85
|
+
detail?: string;
|
|
86
|
+
delivery: CallbackDeliveryView;
|
|
87
|
+
}
|
|
88
|
+
type Command = "connect" | "rotate" | "disconnect" | "doctor" | "init" | "deliveries" | "help" | "version";
|
|
41
89
|
interface ParsedCommand {
|
|
42
|
-
command:
|
|
43
|
-
options:
|
|
90
|
+
command: Command;
|
|
91
|
+
options: CliOptions;
|
|
44
92
|
}
|
|
45
93
|
export declare function parseArgs(argv: string[]): ParsedCommand;
|
|
46
94
|
export declare function helpText(): string;
|
|
@@ -52,7 +100,38 @@ export declare function toCallbackUrl(raw: string): string;
|
|
|
52
100
|
* so the file keeps a single source of truth.
|
|
53
101
|
*/
|
|
54
102
|
export declare function upsertEnv(contents: string, values: Record<string, string>): string;
|
|
103
|
+
/** Add missing `KEY=` placeholders without changing an existing value. */
|
|
104
|
+
export declare function ensureEnvKeys(contents: string, keys: string[]): string;
|
|
105
|
+
/** Remove managed keys, preserving every unrelated line. */
|
|
106
|
+
export declare function removeEnvKeys(contents: string, keys: string[]): string;
|
|
107
|
+
/** Read a simple KEY=value line without executing or expanding the file. */
|
|
108
|
+
export declare function readEnvValue(contents: string, key: string): string | null;
|
|
109
|
+
/** Public-prefixed variables must never contain a Glyph Pay credential. */
|
|
110
|
+
export declare function findPublicCredentialKeys(contents: string): string[];
|
|
55
111
|
export declare function openBrowser(url: string): boolean;
|
|
56
|
-
export declare function runConnect(options?:
|
|
112
|
+
export declare function runConnect(options?: CliOptions, mode?: "connect" | "rotate"): Promise<ConnectResult>;
|
|
113
|
+
/**
|
|
114
|
+
* Re-pair the site. If the current env token is still valid, reuse the
|
|
115
|
+
* callback URL registered on the server so a rotation does not drop webhooks.
|
|
116
|
+
*/
|
|
117
|
+
export declare function runRotate(options?: CliOptions): Promise<ConnectResult>;
|
|
118
|
+
export declare function runDisconnect(options?: CliOptions): Promise<DisconnectResult>;
|
|
119
|
+
export type DoctorCheckStatus = "ok" | "warn" | "error";
|
|
120
|
+
export interface DoctorCheck {
|
|
121
|
+
id: string;
|
|
122
|
+
status: DoctorCheckStatus;
|
|
123
|
+
label: string;
|
|
124
|
+
detail?: string;
|
|
125
|
+
}
|
|
126
|
+
export interface DoctorReport {
|
|
127
|
+
ok: boolean;
|
|
128
|
+
envFile: string;
|
|
129
|
+
environment: string | null;
|
|
130
|
+
businessName: string | null;
|
|
131
|
+
checks: DoctorCheck[];
|
|
132
|
+
}
|
|
133
|
+
export declare function runDoctor(options?: CliOptions): Promise<DoctorReport>;
|
|
134
|
+
export declare function runDeliveries(options?: CliOptions): Promise<CallbackDeliveriesResponse | CallbackReplayResponse>;
|
|
135
|
+
export declare function runInit(options?: CliOptions): Promise<void>;
|
|
57
136
|
export declare function main(argv?: string[]): Promise<number>;
|
|
58
137
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
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 a framework integration. `deliveries` lists callback
|
|
13
|
+
* attempts and replays one on demand.
|
|
10
14
|
*
|
|
11
15
|
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
16
|
*/
|
|
13
17
|
import { spawn } from "node:child_process";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
18
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
19
|
+
import { access, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
21
|
+
import { createInterface } from "node:readline/promises";
|
|
16
22
|
import { pathToFileURL } from "node:url";
|
|
17
|
-
const VERSION = "0.
|
|
23
|
+
const VERSION = "0.4.0";
|
|
18
24
|
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
19
25
|
const DEFAULT_ENV_FILE = ".env.local";
|
|
20
26
|
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
@@ -56,6 +62,21 @@ function takeValue(argv, index, flag) {
|
|
|
56
62
|
throw new CliUsageError(`${flag} needs a value.`);
|
|
57
63
|
return [value, index + 1];
|
|
58
64
|
}
|
|
65
|
+
function commandFor(value) {
|
|
66
|
+
if (value === "connect" || value === "login")
|
|
67
|
+
return "connect";
|
|
68
|
+
if (value === "rotate")
|
|
69
|
+
return "rotate";
|
|
70
|
+
if (value === "disconnect" || value === "logout")
|
|
71
|
+
return "disconnect";
|
|
72
|
+
if (value === "doctor" || value === "check")
|
|
73
|
+
return "doctor";
|
|
74
|
+
if (value === "init")
|
|
75
|
+
return "init";
|
|
76
|
+
if (value === "deliveries" || value === "webhooks")
|
|
77
|
+
return "deliveries";
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
59
80
|
export function parseArgs(argv) {
|
|
60
81
|
const options = {};
|
|
61
82
|
let command = "connect";
|
|
@@ -66,11 +87,12 @@ export function parseArgs(argv) {
|
|
|
66
87
|
return { command: "help", options };
|
|
67
88
|
if (argument === "--version" || argument === "-v")
|
|
68
89
|
return { command: "version", options };
|
|
69
|
-
|
|
90
|
+
const namedCommand = commandFor(argument);
|
|
91
|
+
if (namedCommand) {
|
|
70
92
|
if (sawCommand)
|
|
71
93
|
throw new CliUsageError("Only one command can run at a time.");
|
|
72
94
|
sawCommand = true;
|
|
73
|
-
command =
|
|
95
|
+
command = namedCommand;
|
|
74
96
|
continue;
|
|
75
97
|
}
|
|
76
98
|
if (argument === "--print") {
|
|
@@ -81,6 +103,30 @@ export function parseArgs(argv) {
|
|
|
81
103
|
options.noOpen = true;
|
|
82
104
|
continue;
|
|
83
105
|
}
|
|
106
|
+
if (argument === "--json") {
|
|
107
|
+
options.json = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (argument === "--yes" || argument === "-y") {
|
|
111
|
+
options.yes = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (argument === "--keep-env") {
|
|
115
|
+
options.keepEnv = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (argument === "--send-test-event") {
|
|
119
|
+
options.sendTestEvent = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (argument === "--force") {
|
|
123
|
+
options.force = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (argument === "--dry-run") {
|
|
127
|
+
options.dryRun = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
84
130
|
const [flag, inlineValue] = argument.includes("=") ? argument.split(/=(.*)/s, 2) : [argument, undefined];
|
|
85
131
|
if (flag === "--url" || flag === "--callback-url") {
|
|
86
132
|
if (inlineValue !== undefined) {
|
|
@@ -121,6 +167,32 @@ export function parseArgs(argv) {
|
|
|
121
167
|
}
|
|
122
168
|
continue;
|
|
123
169
|
}
|
|
170
|
+
if (flag === "--retry") {
|
|
171
|
+
if (inlineValue !== undefined) {
|
|
172
|
+
if (!inlineValue)
|
|
173
|
+
throw new CliUsageError("--retry needs a delivery id.");
|
|
174
|
+
options.retry = inlineValue;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
178
|
+
options.retry = value;
|
|
179
|
+
index = next;
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (flag === "--dir") {
|
|
184
|
+
if (inlineValue !== undefined) {
|
|
185
|
+
if (!inlineValue)
|
|
186
|
+
throw new CliUsageError("--dir needs a path.");
|
|
187
|
+
options.dir = inlineValue;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
191
|
+
options.dir = value;
|
|
192
|
+
index = next;
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
124
196
|
if (flag === "--timeout") {
|
|
125
197
|
const raw = inlineValue ?? takeValue(argv, index, flag)[0];
|
|
126
198
|
if (inlineValue === undefined)
|
|
@@ -142,19 +214,39 @@ export function helpText() {
|
|
|
142
214
|
return `glyphpay - take payments on your site through Glyph Pay
|
|
143
215
|
|
|
144
216
|
Usage
|
|
145
|
-
glyphpay connect [options]
|
|
217
|
+
glyphpay connect [options] Connect a site (alias: login)
|
|
218
|
+
glyphpay rotate [options] Re-pair and rotate the current credential
|
|
219
|
+
glyphpay disconnect [options] Revoke the credential and remove local keys
|
|
220
|
+
glyphpay doctor [options] Check env, token, Stripe and webhook setup
|
|
221
|
+
glyphpay deliveries [options] List recent webhook deliveries
|
|
222
|
+
glyphpay init [options] Scaffold an integration for your framework
|
|
146
223
|
glyphpay --version
|
|
147
224
|
|
|
148
|
-
|
|
149
|
-
--url <site-url> Site to connect. Adds
|
|
150
|
-
|
|
225
|
+
Connect / rotate
|
|
226
|
+
--url <site-url> Site to connect. Adds ${WEBHOOK_PATH} when the URL
|
|
227
|
+
has no path. Or pass the exact callback URL.
|
|
151
228
|
--env <path> Env file to update (default: ${DEFAULT_ENV_FILE})
|
|
152
229
|
--print Print the credentials instead of writing a file
|
|
153
230
|
--no-open Do not open a browser automatically
|
|
154
231
|
--base-url <url> Glyph Pay API origin (default: ${DEFAULT_BASE_URL})
|
|
155
232
|
--timeout <seconds> Wait for approval (default: ${DEFAULT_TIMEOUT_SECONDS})
|
|
156
|
-
|
|
157
|
-
|
|
233
|
+
|
|
234
|
+
Doctor
|
|
235
|
+
--json Print a machine-readable report
|
|
236
|
+
--send-test-event POST a signed webhook.test event to the callback URL
|
|
237
|
+
|
|
238
|
+
Disconnect
|
|
239
|
+
--yes, -y Do not ask for confirmation
|
|
240
|
+
--keep-env Keep the local env values after revocation
|
|
241
|
+
|
|
242
|
+
Deliveries
|
|
243
|
+
--retry <id> Send one stored webhook again (alias: webhooks)
|
|
244
|
+
--json Print a machine-readable response
|
|
245
|
+
|
|
246
|
+
Init
|
|
247
|
+
--dir <path> Project directory (default: current directory)
|
|
248
|
+
--dry-run Show planned files without writing them
|
|
249
|
+
--force Overwrite generated files
|
|
158
250
|
|
|
159
251
|
The browser step signs in to Glyph Pay, lets you pick the client account, and
|
|
160
252
|
approves this machine. The service credential goes only to this terminal.`;
|
|
@@ -216,6 +308,50 @@ export function upsertEnv(contents, values) {
|
|
|
216
308
|
output.push(`${key}=${value}`);
|
|
217
309
|
return `${output.join(eol)}${eol}`;
|
|
218
310
|
}
|
|
311
|
+
/** Add missing `KEY=` placeholders without changing an existing value. */
|
|
312
|
+
export function ensureEnvKeys(contents, keys) {
|
|
313
|
+
const missing = keys.filter((key) => !new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`, "m").test(contents));
|
|
314
|
+
if (missing.length === 0)
|
|
315
|
+
return contents;
|
|
316
|
+
const base = contents.length && !contents.endsWith("\n") ? `${contents}\n` : contents;
|
|
317
|
+
return `${base}${missing.map((key) => `${key}=`).join("\n")}\n`;
|
|
318
|
+
}
|
|
319
|
+
/** Remove managed keys, preserving every unrelated line. */
|
|
320
|
+
export function removeEnvKeys(contents, keys) {
|
|
321
|
+
if (!contents.length)
|
|
322
|
+
return "";
|
|
323
|
+
const managed = new Set(keys);
|
|
324
|
+
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
325
|
+
const lines = contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n");
|
|
326
|
+
const output = lines.filter((line) => {
|
|
327
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
328
|
+
return !(match && managed.has(match[1]));
|
|
329
|
+
});
|
|
330
|
+
return output.length ? `${output.join(eol)}${eol}` : "";
|
|
331
|
+
}
|
|
332
|
+
/** Read a simple KEY=value line without executing or expanding the file. */
|
|
333
|
+
export function readEnvValue(contents, key) {
|
|
334
|
+
const match = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)$`, "m").exec(contents);
|
|
335
|
+
if (!match)
|
|
336
|
+
return null;
|
|
337
|
+
const raw = match[1].trim();
|
|
338
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) ||
|
|
339
|
+
(raw.startsWith("'") && raw.endsWith("'"))) {
|
|
340
|
+
return raw.slice(1, -1);
|
|
341
|
+
}
|
|
342
|
+
return raw;
|
|
343
|
+
}
|
|
344
|
+
/** Public-prefixed variables must never contain a Glyph Pay credential. */
|
|
345
|
+
export function findPublicCredentialKeys(contents) {
|
|
346
|
+
const exposed = [];
|
|
347
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
348
|
+
const match = /^\s*(?:export\s+)?((?:NEXT_PUBLIC_|VITE_|PUBLIC_)[A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
349
|
+
if (match && /(?:dgsvc_|dgcb_|GLYPHPAY_(?:TOKEN|CALLBACK_SECRET))/.test(match[2])) {
|
|
350
|
+
exposed.push(match[1]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return exposed;
|
|
354
|
+
}
|
|
219
355
|
function isNotFound(error) {
|
|
220
356
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
221
357
|
}
|
|
@@ -239,6 +375,23 @@ async function writeEnvFile(path, contents) {
|
|
|
239
375
|
// Windows does not use POSIX modes; the file still lives in the project.
|
|
240
376
|
}
|
|
241
377
|
}
|
|
378
|
+
async function fileExists(path) {
|
|
379
|
+
try {
|
|
380
|
+
await access(path);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function directoryExists(path) {
|
|
388
|
+
try {
|
|
389
|
+
return (await stat(path)).isDirectory();
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
242
395
|
/* ------------------------------------------------------------------ */
|
|
243
396
|
/* Browser and API */
|
|
244
397
|
/* ------------------------------------------------------------------ */
|
|
@@ -255,7 +408,7 @@ export function openBrowser(url) {
|
|
|
255
408
|
return false;
|
|
256
409
|
}
|
|
257
410
|
}
|
|
258
|
-
async function api(baseUrl, path, body) {
|
|
411
|
+
async function api(baseUrl, path, body, token) {
|
|
259
412
|
let response;
|
|
260
413
|
try {
|
|
261
414
|
response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/cli/v1${path}`, {
|
|
@@ -263,6 +416,7 @@ async function api(baseUrl, path, body) {
|
|
|
263
416
|
headers: {
|
|
264
417
|
accept: "application/json",
|
|
265
418
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
419
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
266
420
|
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
267
421
|
},
|
|
268
422
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
@@ -279,6 +433,12 @@ async function api(baseUrl, path, body) {
|
|
|
279
433
|
}
|
|
280
434
|
return payload.data;
|
|
281
435
|
}
|
|
436
|
+
function baseUrlFor(options) {
|
|
437
|
+
return options.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
|
|
438
|
+
}
|
|
439
|
+
function fetchCredential(baseUrl, token) {
|
|
440
|
+
return api(baseUrl, "/credential", undefined, token);
|
|
441
|
+
}
|
|
282
442
|
async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
283
443
|
const deadline = Date.now() + timeoutMs;
|
|
284
444
|
let interval = Math.max(1, firstInterval);
|
|
@@ -306,15 +466,15 @@ async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
|
306
466
|
"Run the command again for a fresh code.");
|
|
307
467
|
}
|
|
308
468
|
/* ------------------------------------------------------------------ */
|
|
309
|
-
/* Connect
|
|
469
|
+
/* Connect and rotate */
|
|
310
470
|
/* ------------------------------------------------------------------ */
|
|
311
|
-
export async function runConnect(options = {}) {
|
|
312
|
-
const baseUrl = options
|
|
471
|
+
export async function runConnect(options = {}, mode = "connect") {
|
|
472
|
+
const baseUrl = baseUrlFor(options);
|
|
313
473
|
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
314
474
|
const callbackUrl = options.url ? toCallbackUrl(options.url) : null;
|
|
315
475
|
const created = await api(baseUrl, "/device", { callbackUrl });
|
|
316
476
|
say();
|
|
317
|
-
say(style(1, "Glyph Pay - connect a site"));
|
|
477
|
+
say(style(1, mode === "rotate" ? "Glyph Pay - rotate credential" : "Glyph Pay - connect a site"));
|
|
318
478
|
say();
|
|
319
479
|
say(`1. Open ${style(4, created.verificationUrl)}`);
|
|
320
480
|
say(`2. Sign in and approve pairing code ${style(1, created.userCode)}.`);
|
|
@@ -329,13 +489,13 @@ export async function runConnect(options = {}) {
|
|
|
329
489
|
say(" Could not open a browser. Open the link above manually.");
|
|
330
490
|
}
|
|
331
491
|
say();
|
|
332
|
-
say("Waiting for approval...");
|
|
492
|
+
say(mode === "rotate" ? "Waiting for approval (the previous token stays valid until then)..." : "Waiting for approval...");
|
|
333
493
|
const approved = await waitForApproval(baseUrl, created.deviceCode, created.interval, timeoutSeconds * 1000);
|
|
334
494
|
const values = { [TOKEN_KEY]: approved.token };
|
|
335
495
|
if (approved.callbackSecret)
|
|
336
496
|
values[CALLBACK_SECRET_KEY] = approved.callbackSecret;
|
|
337
497
|
say();
|
|
338
|
-
say(style(1,
|
|
498
|
+
say(style(1, `${mode === "rotate" ? "Rotated" : "Connected"} ${approved.businessName} (${approved.environment} mode).`));
|
|
339
499
|
if (approved.callbackUrl)
|
|
340
500
|
say(`Webhook registered: ${approved.callbackUrl}`);
|
|
341
501
|
else
|
|
@@ -366,7 +526,9 @@ export async function runConnect(options = {}) {
|
|
|
366
526
|
}
|
|
367
527
|
}
|
|
368
528
|
say();
|
|
369
|
-
say(
|
|
529
|
+
say(mode === "rotate"
|
|
530
|
+
? "The previous token is already invalid. Deploy the new values before the next request."
|
|
531
|
+
: "The token is shown only once. Run this command again to rotate it.");
|
|
370
532
|
return {
|
|
371
533
|
businessName: approved.businessName,
|
|
372
534
|
environment: approved.environment,
|
|
@@ -374,6 +536,627 @@ export async function runConnect(options = {}) {
|
|
|
374
536
|
writtenTo,
|
|
375
537
|
};
|
|
376
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Re-pair the site. If the current env token is still valid, reuse the
|
|
541
|
+
* callback URL registered on the server so a rotation does not drop webhooks.
|
|
542
|
+
*/
|
|
543
|
+
export async function runRotate(options = {}) {
|
|
544
|
+
const baseUrl = baseUrlFor(options);
|
|
545
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
546
|
+
const contents = await readEnvFile(envFile);
|
|
547
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
548
|
+
let url = options.url;
|
|
549
|
+
if (token) {
|
|
550
|
+
try {
|
|
551
|
+
const info = await fetchCredential(baseUrl, token);
|
|
552
|
+
say();
|
|
553
|
+
say(`Rotating ${info.businessName} (${info.environment} mode).`);
|
|
554
|
+
if (!url && info.callbackUrl)
|
|
555
|
+
url = info.callbackUrl;
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error instanceof CliApiError && error.status === 401) {
|
|
559
|
+
say(style(33, "The current token is no longer valid; starting a fresh connection."));
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return runConnect({ ...options, url }, "rotate");
|
|
567
|
+
}
|
|
568
|
+
/* ------------------------------------------------------------------ */
|
|
569
|
+
/* Disconnect */
|
|
570
|
+
/* ------------------------------------------------------------------ */
|
|
571
|
+
export async function runDisconnect(options = {}) {
|
|
572
|
+
const baseUrl = baseUrlFor(options);
|
|
573
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
574
|
+
const contents = await readEnvFile(envFile);
|
|
575
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
576
|
+
const displayEnv = relative(process.cwd(), envFile) || envFile;
|
|
577
|
+
if (!token) {
|
|
578
|
+
throw new CliUsageError(`No ${TOKEN_KEY} found in ${displayEnv}. Nothing to disconnect.`);
|
|
579
|
+
}
|
|
580
|
+
if (!options.yes) {
|
|
581
|
+
if (!process.stdin.isTTY) {
|
|
582
|
+
throw new CliUsageError("Use --yes when running disconnect non-interactively.");
|
|
583
|
+
}
|
|
584
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
585
|
+
let answer = "";
|
|
586
|
+
try {
|
|
587
|
+
answer = await prompt.question(`Revoke this site's Glyph Pay credential? The site stops working immediately. [y/N] `);
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
prompt.close();
|
|
591
|
+
}
|
|
592
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
593
|
+
say("Canceled.");
|
|
594
|
+
return { businessName: null, removedKeys: false, alreadyRevoked: false, canceled: true };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
let businessName = null;
|
|
598
|
+
let alreadyRevoked = false;
|
|
599
|
+
try {
|
|
600
|
+
const result = await api(baseUrl, "/credential/revoke", {}, token);
|
|
601
|
+
businessName = result.businessName;
|
|
602
|
+
alreadyRevoked = result.alreadyRevoked;
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (error instanceof CliApiError && error.status === 401) {
|
|
606
|
+
alreadyRevoked = true;
|
|
607
|
+
say(style(33, "The token is already invalid; removing the local credentials."));
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
let removedKeys = false;
|
|
614
|
+
if (!options.keepEnv) {
|
|
615
|
+
await writeEnvFile(envFile, removeEnvKeys(contents, [TOKEN_KEY, CALLBACK_SECRET_KEY]));
|
|
616
|
+
removedKeys = true;
|
|
617
|
+
}
|
|
618
|
+
say();
|
|
619
|
+
say(style(1, alreadyRevoked
|
|
620
|
+
? "That credential was already disconnected."
|
|
621
|
+
: `Disconnected ${businessName ?? "the site"}.`));
|
|
622
|
+
if (removedKeys)
|
|
623
|
+
say(`Removed ${TOKEN_KEY} and ${CALLBACK_SECRET_KEY} from ${displayEnv}.`);
|
|
624
|
+
else
|
|
625
|
+
say(`The credential is revoked. Local values in ${displayEnv} were kept.`);
|
|
626
|
+
return { businessName, removedKeys, alreadyRevoked, canceled: false };
|
|
627
|
+
}
|
|
628
|
+
const TOKEN_PATTERN = /^dgsvc_(test|live)_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9_-]{43}$/;
|
|
629
|
+
function addDoctorCheck(report, check, json) {
|
|
630
|
+
report.checks.push(check);
|
|
631
|
+
if (json)
|
|
632
|
+
return;
|
|
633
|
+
const symbol = check.status === "ok" ? style(32, "✓") : check.status === "warn" ? style(33, "!") : style(31, "✗");
|
|
634
|
+
say(`${symbol} ${check.label}`);
|
|
635
|
+
if (check.detail)
|
|
636
|
+
say(` ${check.detail}`);
|
|
637
|
+
}
|
|
638
|
+
function signedTestEvent(secret, environment) {
|
|
639
|
+
const createdAt = new Date().toISOString();
|
|
640
|
+
const eventId = `evt_test_${randomBytes(8).toString("hex")}`;
|
|
641
|
+
const body = JSON.stringify({
|
|
642
|
+
id: eventId,
|
|
643
|
+
type: "webhook.test",
|
|
644
|
+
createdAt,
|
|
645
|
+
livemode: environment === "live",
|
|
646
|
+
data: {
|
|
647
|
+
id: "glyphpay_doctor",
|
|
648
|
+
orderId: "glyphpay-doctor",
|
|
649
|
+
status: "succeeded",
|
|
650
|
+
amountCents: 0,
|
|
651
|
+
applicationFeeCents: 0,
|
|
652
|
+
currency: "usd",
|
|
653
|
+
refundedCents: 0,
|
|
654
|
+
refunds: [],
|
|
655
|
+
disputes: [],
|
|
656
|
+
stale: false,
|
|
657
|
+
updatedAt: createdAt,
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
661
|
+
const signature = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
662
|
+
return { body, header: `t=${timestamp},v1=${signature}`, eventId };
|
|
663
|
+
}
|
|
664
|
+
async function deliverTestEvent(callbackUrl, secret, environment) {
|
|
665
|
+
const { body, header } = signedTestEvent(secret, environment);
|
|
666
|
+
const response = await fetch(callbackUrl, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: {
|
|
669
|
+
"content-type": "application/json",
|
|
670
|
+
"designglyph-signature": header,
|
|
671
|
+
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
672
|
+
},
|
|
673
|
+
body,
|
|
674
|
+
signal: AbortSignal.timeout(10_000),
|
|
675
|
+
});
|
|
676
|
+
const text = await response.text().catch(() => "");
|
|
677
|
+
return { status: response.status, ok: response.ok, text: text.slice(0, 200) };
|
|
678
|
+
}
|
|
679
|
+
export async function runDoctor(options = {}) {
|
|
680
|
+
const baseUrl = baseUrlFor(options);
|
|
681
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
682
|
+
const displayEnv = relative(process.cwd(), envFile) || envFile;
|
|
683
|
+
const json = !!options.json;
|
|
684
|
+
const report = {
|
|
685
|
+
ok: true,
|
|
686
|
+
envFile: displayEnv,
|
|
687
|
+
environment: null,
|
|
688
|
+
businessName: null,
|
|
689
|
+
checks: [],
|
|
690
|
+
};
|
|
691
|
+
if (!json) {
|
|
692
|
+
say();
|
|
693
|
+
say(style(1, "Glyph Pay doctor"));
|
|
694
|
+
say(`Env file: ${displayEnv}`);
|
|
695
|
+
say();
|
|
696
|
+
}
|
|
697
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
698
|
+
addDoctorCheck(report, major >= 18
|
|
699
|
+
? { id: "node", status: "ok", label: `Node ${process.versions.node}` }
|
|
700
|
+
: { id: "node", status: "error", label: `Node ${process.versions.node} is too old`, detail: "The CLI requires Node 18 or newer." }, json);
|
|
701
|
+
const exists = await fileExists(envFile);
|
|
702
|
+
addDoctorCheck(report, exists
|
|
703
|
+
? { id: "env", status: "ok", label: `Found ${displayEnv}` }
|
|
704
|
+
: { id: "env", status: "error", label: `${displayEnv} was not found`, detail: "Run `glyphpay connect` first or pass --env <path>." }, json);
|
|
705
|
+
const contents = await readEnvFile(envFile);
|
|
706
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
707
|
+
const callbackSecret = readEnvValue(contents, CALLBACK_SECRET_KEY);
|
|
708
|
+
const tokenMatch = token ? TOKEN_PATTERN.exec(token) : null;
|
|
709
|
+
addDoctorCheck(report, token
|
|
710
|
+
? { id: "token-present", status: "ok", label: `${TOKEN_KEY} is present` }
|
|
711
|
+
: { id: "token-present", status: "error", label: `${TOKEN_KEY} is missing`, detail: "Run `glyphpay connect` to issue a credential." }, json);
|
|
712
|
+
if (token) {
|
|
713
|
+
addDoctorCheck(report, tokenMatch
|
|
714
|
+
? { id: "token-format", status: "ok", label: "Token format is valid", detail: `${tokenMatch[1]} mode` }
|
|
715
|
+
: { id: "token-format", status: "error", label: "Token format is not a dgsvc_ service credential" }, json);
|
|
716
|
+
}
|
|
717
|
+
const exposed = findPublicCredentialKeys(contents);
|
|
718
|
+
addDoctorCheck(report, exposed.length
|
|
719
|
+
? {
|
|
720
|
+
id: "public-env",
|
|
721
|
+
status: "error",
|
|
722
|
+
label: `${exposed.join(", ")} expose a Glyph Pay credential`,
|
|
723
|
+
detail: "Move these values to server-only variables and rotate the credential.",
|
|
724
|
+
}
|
|
725
|
+
: { id: "public-env", status: "ok", label: "No credential is in a public-prefixed variable" }, json);
|
|
726
|
+
if (callbackSecret) {
|
|
727
|
+
addDoctorCheck(report, /^dgcb_[A-Za-z0-9_-]{20,}$/.test(callbackSecret)
|
|
728
|
+
? { id: "callback-secret-format", status: "ok", label: "Callback signing secret format is valid" }
|
|
729
|
+
: { id: "callback-secret-format", status: "warn", label: "Callback signing secret has an unexpected format" }, json);
|
|
730
|
+
}
|
|
731
|
+
let info = null;
|
|
732
|
+
if (token && tokenMatch) {
|
|
733
|
+
try {
|
|
734
|
+
info = await fetchCredential(baseUrl, token);
|
|
735
|
+
report.environment = info.environment;
|
|
736
|
+
report.businessName = info.businessName;
|
|
737
|
+
addDoctorCheck(report, {
|
|
738
|
+
id: "token-valid",
|
|
739
|
+
status: "ok",
|
|
740
|
+
label: "Glyph Pay accepted the token",
|
|
741
|
+
detail: `${info.businessName} · ${info.environment} mode`,
|
|
742
|
+
}, json);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
addDoctorCheck(report, {
|
|
746
|
+
id: "token-valid",
|
|
747
|
+
status: "error",
|
|
748
|
+
label: "Glyph Pay rejected the token",
|
|
749
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
750
|
+
}, json);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (info) {
|
|
754
|
+
addDoctorCheck(report, info.chargesEnabled
|
|
755
|
+
? { id: "stripe", status: "ok", label: "Stripe connected account can accept charges" }
|
|
756
|
+
: {
|
|
757
|
+
id: "stripe",
|
|
758
|
+
status: "error",
|
|
759
|
+
label: "Stripe connected account is not ready for charges",
|
|
760
|
+
detail: info.connectedAccountId
|
|
761
|
+
? "Complete Stripe onboarding in Design Glyph admin, then re-check."
|
|
762
|
+
: "No connected account is linked to this client yet.",
|
|
763
|
+
}, json);
|
|
764
|
+
if (info.callbackUrl) {
|
|
765
|
+
addDoctorCheck(report, { id: "callback-url", status: "ok", label: "Webhook URL is registered", detail: info.callbackUrl }, json);
|
|
766
|
+
addDoctorCheck(report, callbackSecret
|
|
767
|
+
? { id: "callback-secret", status: "ok", label: "Callback signing secret is present locally" }
|
|
768
|
+
: {
|
|
769
|
+
id: "callback-secret",
|
|
770
|
+
status: "error",
|
|
771
|
+
label: "Callback signing secret is missing locally",
|
|
772
|
+
detail: "Re-register the webhook in Glyph Pay admin and update GLYPHPAY_CALLBACK_SECRET.",
|
|
773
|
+
}, json);
|
|
774
|
+
if (callbackSecret) {
|
|
775
|
+
try {
|
|
776
|
+
const reachable = await fetch(info.callbackUrl, {
|
|
777
|
+
method: "HEAD",
|
|
778
|
+
redirect: "manual",
|
|
779
|
+
signal: AbortSignal.timeout(8_000),
|
|
780
|
+
});
|
|
781
|
+
addDoctorCheck(report, reachable.status >= 500 || reachable.status === 404
|
|
782
|
+
? {
|
|
783
|
+
id: "callback-reachable",
|
|
784
|
+
status: "warn",
|
|
785
|
+
label: `Webhook URL returned HTTP ${reachable.status} to a HEAD request`,
|
|
786
|
+
detail: "GlyphPay sends POST requests. A custom route may reject HEAD.",
|
|
787
|
+
}
|
|
788
|
+
: {
|
|
789
|
+
id: "callback-reachable",
|
|
790
|
+
status: "ok",
|
|
791
|
+
label: `Webhook URL is reachable (HTTP ${reachable.status})`,
|
|
792
|
+
}, json);
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
addDoctorCheck(report, {
|
|
796
|
+
id: "callback-reachable",
|
|
797
|
+
status: "warn",
|
|
798
|
+
label: "Webhook URL was not reachable from this machine",
|
|
799
|
+
detail: "A firewall may block this terminal; Glyph Pay may still reach the public URL.",
|
|
800
|
+
}, json);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (options.sendTestEvent) {
|
|
804
|
+
if (!callbackSecret) {
|
|
805
|
+
addDoctorCheck(report, { id: "test-event", status: "error", label: "Test event not sent", detail: "No callback signing secret is available." }, json);
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
try {
|
|
809
|
+
const delivered = await deliverTestEvent(info.callbackUrl, callbackSecret, info.environment);
|
|
810
|
+
addDoctorCheck(report, delivered.ok
|
|
811
|
+
? {
|
|
812
|
+
id: "test-event",
|
|
813
|
+
status: "ok",
|
|
814
|
+
label: `Signed test event accepted (HTTP ${delivered.status})`,
|
|
815
|
+
}
|
|
816
|
+
: {
|
|
817
|
+
id: "test-event",
|
|
818
|
+
status: "error",
|
|
819
|
+
label: `Signed test event rejected (HTTP ${delivered.status})`,
|
|
820
|
+
detail: delivered.text || "Check the site's webhook route and callback secret.",
|
|
821
|
+
}, json);
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
addDoctorCheck(report, {
|
|
825
|
+
id: "test-event",
|
|
826
|
+
status: "error",
|
|
827
|
+
label: "Could not deliver the signed test event",
|
|
828
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
829
|
+
}, json);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
addDoctorCheck(report, {
|
|
836
|
+
id: "callback-url",
|
|
837
|
+
status: "warn",
|
|
838
|
+
label: "No webhook URL is registered",
|
|
839
|
+
detail: "Checkout works, but payment.succeeded events will not reach this site.",
|
|
840
|
+
}, json);
|
|
841
|
+
if (callbackSecret) {
|
|
842
|
+
addDoctorCheck(report, {
|
|
843
|
+
id: "callback-secret",
|
|
844
|
+
status: "warn",
|
|
845
|
+
label: "A callback secret is present locally but no webhook URL is registered",
|
|
846
|
+
}, json);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
report.ok = !report.checks.some((check) => check.status === "error");
|
|
851
|
+
if (json) {
|
|
852
|
+
say(JSON.stringify(report, null, 2));
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
say();
|
|
856
|
+
say(report.ok
|
|
857
|
+
? style(32, "No blocking problems found.")
|
|
858
|
+
: style(31, "Fix the errors above, then run `glyphpay doctor` again."));
|
|
859
|
+
}
|
|
860
|
+
return report;
|
|
861
|
+
}
|
|
862
|
+
/* ------------------------------------------------------------------ */
|
|
863
|
+
/* Webhook deliveries */
|
|
864
|
+
/* ------------------------------------------------------------------ */
|
|
865
|
+
function printDelivery(delivery) {
|
|
866
|
+
say(` ${delivery.id} ${delivery.type} ${delivery.state.toUpperCase()} ` +
|
|
867
|
+
`attempt ${delivery.attempts} ${delivery.updatedAt}`);
|
|
868
|
+
if (delivery.lastError)
|
|
869
|
+
say(` ${delivery.lastError}`);
|
|
870
|
+
}
|
|
871
|
+
export async function runDeliveries(options = {}) {
|
|
872
|
+
const baseUrl = baseUrlFor(options);
|
|
873
|
+
const envFile = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
874
|
+
const contents = await readEnvFile(envFile);
|
|
875
|
+
const token = readEnvValue(contents, TOKEN_KEY);
|
|
876
|
+
if (!token) {
|
|
877
|
+
throw new CliUsageError(`No ${TOKEN_KEY} found in ${relative(process.cwd(), envFile) || envFile}. Run glyphpay connect first.`);
|
|
878
|
+
}
|
|
879
|
+
if (options.retry) {
|
|
880
|
+
const result = await api(baseUrl, `/credential/deliveries/${encodeURIComponent(options.retry)}/replay`, {}, token);
|
|
881
|
+
if (options.json) {
|
|
882
|
+
say(JSON.stringify(result, null, 2));
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
say();
|
|
886
|
+
say(style(1, `Webhook ${result.outcome === "delivered" ? "delivered" : "not delivered"}.`));
|
|
887
|
+
printDelivery(result.delivery);
|
|
888
|
+
if (result.detail)
|
|
889
|
+
say(` ${result.detail}`);
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
892
|
+
}
|
|
893
|
+
const result = await api(baseUrl, "/credential/deliveries", undefined, token);
|
|
894
|
+
if (options.json) {
|
|
895
|
+
say(JSON.stringify(result, null, 2));
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
say();
|
|
899
|
+
say(style(1, `Webhook deliveries - ${result.businessName} (${result.environment} mode)`));
|
|
900
|
+
say();
|
|
901
|
+
if (result.deliveries.length === 0) {
|
|
902
|
+
say("No deliveries recorded yet.");
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
for (const delivery of result.deliveries)
|
|
906
|
+
printDelivery(delivery);
|
|
907
|
+
say();
|
|
908
|
+
say("Retry one with `glyphpay deliveries --retry <id>`.");
|
|
909
|
+
}
|
|
910
|
+
return result;
|
|
911
|
+
}
|
|
912
|
+
function libTemplate(ts) {
|
|
913
|
+
const cartId = ts
|
|
914
|
+
? `const cartId = (body as { cartId?: string } | null)?.cartId;`
|
|
915
|
+
: `const cartId = body?.cartId;`;
|
|
916
|
+
return `import { glyphPay, CheckoutError } from "glyphpay/server";
|
|
917
|
+
|
|
918
|
+
export const glyphpay = glyphPay({
|
|
919
|
+
token: process.env.GLYPHPAY_TOKEN,
|
|
920
|
+
callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
|
|
921
|
+
|
|
922
|
+
async priceOrder(body) {
|
|
923
|
+
// Load the cart/order from YOUR database and return its server-side price.
|
|
924
|
+
// Never trust an amount sent by the browser.
|
|
925
|
+
${cartId}
|
|
926
|
+
if (!cartId) throw new CheckoutError("Your cart is empty.");
|
|
927
|
+
throw new CheckoutError("Connect priceOrder() to your order database before accepting payments.");
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
async onEvent(event) {
|
|
931
|
+
// At-least-once delivery: dedupe on event.id before changing an order.
|
|
932
|
+
if (event.type === "payment.succeeded") {
|
|
933
|
+
// TODO: mark the order paid and store event.id.
|
|
934
|
+
console.log(\`GlyphPay payment succeeded for \${event.data.orderId}\`);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
`;
|
|
939
|
+
}
|
|
940
|
+
function routeTemplate(libImport) {
|
|
941
|
+
return `import { glyphpay } from "${libImport}";\n\nexport const POST = glyphpay.checkout;\n`;
|
|
942
|
+
}
|
|
943
|
+
function webhookRouteTemplate(libImport) {
|
|
944
|
+
return `import { glyphpay } from "${libImport}";\n\nexport const POST = glyphpay.webhook;\n`;
|
|
945
|
+
}
|
|
946
|
+
function detectFramework(dependencies) {
|
|
947
|
+
if (dependencies.next)
|
|
948
|
+
return "next";
|
|
949
|
+
if (dependencies["@sveltejs/kit"])
|
|
950
|
+
return "sveltekit";
|
|
951
|
+
if (dependencies.astro)
|
|
952
|
+
return "astro";
|
|
953
|
+
if (dependencies["@remix-run/node"] || dependencies["@remix-run/react"])
|
|
954
|
+
return "remix";
|
|
955
|
+
if (dependencies.hono)
|
|
956
|
+
return "hono";
|
|
957
|
+
if (dependencies.express)
|
|
958
|
+
return "express";
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
function svelteRoute(kind, isTs) {
|
|
962
|
+
const typeImport = isTs ? `import type { RequestHandler } from "./$types";\n\n` : "\n";
|
|
963
|
+
const annotation = isTs ? ": RequestHandler" : "";
|
|
964
|
+
return `import { glyphpay } from "$lib/server/glyphpay";\n${typeImport}export const POST${annotation} = ({ request }) => glyphpay.${kind}(request);\n`;
|
|
965
|
+
}
|
|
966
|
+
function astroRoute(kind, isTs) {
|
|
967
|
+
const typeImport = isTs ? `import type { APIRoute } from "astro";\n\n` : "\n";
|
|
968
|
+
const annotation = isTs ? ": APIRoute" : "";
|
|
969
|
+
return `import { glyphpay } from "../../../lib/glyphpay";\n${typeImport}export const POST${annotation} = ({ request }) => glyphpay.${kind}(request);\n`;
|
|
970
|
+
}
|
|
971
|
+
function remixRoute(kind, isTs) {
|
|
972
|
+
const typeImport = isTs ? `import type { ActionFunctionArgs } from "@remix-run/node";\n\n` : "\n";
|
|
973
|
+
const signature = isTs ? "{ request }: ActionFunctionArgs" : "{ request }";
|
|
974
|
+
return `import { glyphpay } from "~/lib/glyphpay.server";\n${typeImport}export async function action(${signature}) {\n return glyphpay.${kind}(request);\n}\n`;
|
|
975
|
+
}
|
|
976
|
+
function honoRoutes() {
|
|
977
|
+
return `import { Hono } from "hono";\nimport { glyphpay } from "./lib/glyphpay";\n\nexport const glyphpayRoutes = new Hono();\n\nglyphpayRoutes.post("/checkout", (c) => glyphpay.checkout(c.req.raw));\nglyphpayRoutes.post("/webhook", (c) => glyphpay.webhook(c.req.raw));\n`;
|
|
978
|
+
}
|
|
979
|
+
function expressRoutes(isTs) {
|
|
980
|
+
const typeImport = isTs
|
|
981
|
+
? `import type { Request as ExpressRequest, Response as ExpressResponse } from "express";\n`
|
|
982
|
+
: "";
|
|
983
|
+
const signature = isTs ? ": ExpressRequest" : "";
|
|
984
|
+
const responseType = isTs ? ": ExpressResponse" : "";
|
|
985
|
+
const handlerType = isTs ? "(request: Request) => Promise<Response>" : "handler";
|
|
986
|
+
return `import express from "express";\nimport { glyphpay } from "./lib/glyphpay";\n${typeImport}\nexport const glyphpayRoutes = express.Router();\n\nasync function adapt(handler${isTs ? `: ${handlerType}` : ""}, req${signature}, res${responseType}) {\n const body =\n req.method === "GET" || req.method === "HEAD"\n ? undefined\n : Buffer.isBuffer(req.body)\n ? req.body\n : typeof req.body === "string"\n ? req.body\n : JSON.stringify(req.body ?? {});\n const protocol = req.protocol || "http";\n const host = req.get("host") || "localhost";\n const request = new Request(\`\${protocol}://\${host}\${req.originalUrl}\`, {\n method: req.method,\n headers: req.headers as unknown as HeadersInit,\n body,\n });\n const response = await handler(request);\n res.status(response.status);\n response.headers.forEach((value, key) => res.setHeader(key, value));\n res.send(Buffer.from(await response.arrayBuffer()));\n}\n\nglyphpayRoutes.post("/checkout", express.json(), (req, res) => adapt(glyphpay.checkout, req, res));\nglyphpayRoutes.post("/webhook", express.raw({ type: "application/json" }), (req, res) => adapt(glyphpay.webhook, req, res));\n`;
|
|
987
|
+
}
|
|
988
|
+
async function frameworkPlan(projectRoot, framework) {
|
|
989
|
+
const isTs = await fileExists(join(projectRoot, "tsconfig.json"));
|
|
990
|
+
const extension = isTs ? "ts" : "js";
|
|
991
|
+
const envComment = "# Glyph Pay — server-side only. Keep these in the server environment.\n";
|
|
992
|
+
if (framework === "next") {
|
|
993
|
+
const hasSrcApp = await directoryExists(join(projectRoot, "src", "app"));
|
|
994
|
+
const hasRootApp = await directoryExists(join(projectRoot, "app"));
|
|
995
|
+
const hasPages = (await directoryExists(join(projectRoot, "pages"))) ||
|
|
996
|
+
(await directoryExists(join(projectRoot, "src", "pages")));
|
|
997
|
+
if (!hasSrcApp && !hasRootApp && hasPages) {
|
|
998
|
+
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.");
|
|
999
|
+
}
|
|
1000
|
+
const appDir = hasSrcApp ? join("src", "app") : join("app");
|
|
1001
|
+
const libDir = hasSrcApp ? join("src", "lib") : join("lib");
|
|
1002
|
+
const importPath = "../../../../lib/glyphpay";
|
|
1003
|
+
return {
|
|
1004
|
+
framework,
|
|
1005
|
+
libPath: join(projectRoot, libDir, `glyphpay.${extension}`),
|
|
1006
|
+
files: [
|
|
1007
|
+
{ path: join(projectRoot, libDir, `glyphpay.${extension}`), contents: libTemplate(isTs) },
|
|
1008
|
+
{ path: join(projectRoot, appDir, "api", "glyphpay", "checkout", `route.${extension}`), contents: routeTemplate(importPath) },
|
|
1009
|
+
{ path: join(projectRoot, appDir, "api", "glyphpay", "webhook", `route.${extension}`), contents: webhookRouteTemplate(importPath) },
|
|
1010
|
+
],
|
|
1011
|
+
envComment,
|
|
1012
|
+
notes: ["The generated routes live at /api/glyphpay/checkout and /api/glyphpay/webhook."],
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
if (framework === "sveltekit") {
|
|
1016
|
+
const libPath = join(projectRoot, "src", "lib", "server", `glyphpay.${extension}`);
|
|
1017
|
+
return {
|
|
1018
|
+
framework,
|
|
1019
|
+
libPath,
|
|
1020
|
+
files: [
|
|
1021
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
1022
|
+
{ path: join(projectRoot, "src", "routes", "api", "glyphpay", "checkout", `+server.${extension}`), contents: svelteRoute("checkout", isTs) },
|
|
1023
|
+
{ path: join(projectRoot, "src", "routes", "api", "glyphpay", "webhook", `+server.${extension}`), contents: svelteRoute("webhook", isTs) },
|
|
1024
|
+
],
|
|
1025
|
+
envComment,
|
|
1026
|
+
notes: ["Use adapter-node or another server adapter; static SvelteKit builds cannot run checkout."],
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
if (framework === "astro") {
|
|
1030
|
+
const libPath = join(projectRoot, "src", "lib", `glyphpay.${extension}`);
|
|
1031
|
+
return {
|
|
1032
|
+
framework,
|
|
1033
|
+
libPath,
|
|
1034
|
+
files: [
|
|
1035
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
1036
|
+
{ path: join(projectRoot, "src", "pages", "api", "glyphpay", `checkout.${extension}`), contents: astroRoute("checkout", isTs) },
|
|
1037
|
+
{ path: join(projectRoot, "src", "pages", "api", "glyphpay", `webhook.${extension}`), contents: astroRoute("webhook", isTs) },
|
|
1038
|
+
],
|
|
1039
|
+
envComment,
|
|
1040
|
+
notes: ["Set output to server/SSR in astro.config and install a server adapter before deploying."],
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
if (framework === "remix") {
|
|
1044
|
+
const libPath = join(projectRoot, "app", "lib", `glyphpay.server.${extension}`);
|
|
1045
|
+
return {
|
|
1046
|
+
framework,
|
|
1047
|
+
libPath,
|
|
1048
|
+
files: [
|
|
1049
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
1050
|
+
{ path: join(projectRoot, "app", "routes", `api.glyphpay.checkout.${extension}`), contents: remixRoute("checkout", isTs) },
|
|
1051
|
+
{ path: join(projectRoot, "app", "routes", `api.glyphpay.webhook.${extension}`), contents: remixRoute("webhook", isTs) },
|
|
1052
|
+
],
|
|
1053
|
+
envComment,
|
|
1054
|
+
notes: ["Routes assume the standard ~ alias; adjust the import if your tsconfig uses another alias."],
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
if (framework === "hono") {
|
|
1058
|
+
const libPath = join(projectRoot, "src", "lib", `glyphpay.${extension}`);
|
|
1059
|
+
return {
|
|
1060
|
+
framework,
|
|
1061
|
+
libPath,
|
|
1062
|
+
files: [
|
|
1063
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
1064
|
+
{ path: join(projectRoot, "src", `glyphpay-routes.${extension}`), contents: honoRoutes() },
|
|
1065
|
+
],
|
|
1066
|
+
envComment,
|
|
1067
|
+
notes: ["Mount the router in your app: app.route(\"/api/glyphpay\", glyphpayRoutes)."],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
const libPath = join(projectRoot, "src", "lib", `glyphpay.${extension}`);
|
|
1071
|
+
return {
|
|
1072
|
+
framework: "express",
|
|
1073
|
+
libPath,
|
|
1074
|
+
files: [
|
|
1075
|
+
{ path: libPath, contents: libTemplate(isTs) },
|
|
1076
|
+
{ path: join(projectRoot, "src", `glyphpay-routes.${extension}`), contents: expressRoutes(isTs) },
|
|
1077
|
+
],
|
|
1078
|
+
envComment,
|
|
1079
|
+
notes: [
|
|
1080
|
+
"Mount the router with app.use(\"/api/glyphpay\", glyphpayRoutes).",
|
|
1081
|
+
"The webhook route uses express.raw so the signature is verified against the original bytes.",
|
|
1082
|
+
],
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
function installCommand(hasPnpmLock, hasYarnLock, hasBunLock) {
|
|
1086
|
+
if (hasPnpmLock)
|
|
1087
|
+
return "pnpm add glyphpay";
|
|
1088
|
+
if (hasYarnLock)
|
|
1089
|
+
return "yarn add glyphpay";
|
|
1090
|
+
if (hasBunLock)
|
|
1091
|
+
return "bun add glyphpay";
|
|
1092
|
+
return "npm install glyphpay";
|
|
1093
|
+
}
|
|
1094
|
+
export async function runInit(options = {}) {
|
|
1095
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
1096
|
+
const packagePath = join(projectRoot, "package.json");
|
|
1097
|
+
if (!(await fileExists(packagePath))) {
|
|
1098
|
+
throw new CliUsageError(`No package.json found in ${projectRoot}. Run init in a web project.`);
|
|
1099
|
+
}
|
|
1100
|
+
let pkg;
|
|
1101
|
+
try {
|
|
1102
|
+
pkg = JSON.parse(await readFile(packagePath, "utf8"));
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
throw new CliUsageError("package.json is not valid JSON.");
|
|
1106
|
+
}
|
|
1107
|
+
const dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
1108
|
+
const framework = detectFramework(dependencies);
|
|
1109
|
+
if (!framework) {
|
|
1110
|
+
throw new CliUsageError("glyphpay init supports Next.js, Remix, Astro, SvelteKit, Express, and Hono. Install one of those frameworks first.");
|
|
1111
|
+
}
|
|
1112
|
+
const plan = await frameworkPlan(projectRoot, framework);
|
|
1113
|
+
const envPath = join(projectRoot, ".env.example");
|
|
1114
|
+
const generated = plan.files;
|
|
1115
|
+
const existing = [];
|
|
1116
|
+
for (const file of generated) {
|
|
1117
|
+
if (await fileExists(file.path))
|
|
1118
|
+
existing.push(relative(projectRoot, file.path).replace(/\\/g, "/"));
|
|
1119
|
+
}
|
|
1120
|
+
if (existing.length > 0 && !options.force) {
|
|
1121
|
+
throw new CliUsageError(`Refusing to overwrite existing files:\n ${existing.join("\n ")}\nRe-run with --force to overwrite them.`);
|
|
1122
|
+
}
|
|
1123
|
+
const envExisting = await readEnvFile(envPath);
|
|
1124
|
+
const envBase = envExisting.trim().length > 0 ? envExisting : plan.envComment;
|
|
1125
|
+
const envNext = ensureEnvKeys(envBase, [TOKEN_KEY, CALLBACK_SECRET_KEY]);
|
|
1126
|
+
const envChanged = envNext !== envExisting;
|
|
1127
|
+
const display = (path) => relative(projectRoot, path).replace(/\\/g, "/");
|
|
1128
|
+
if (!options.dryRun) {
|
|
1129
|
+
for (const file of generated) {
|
|
1130
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
1131
|
+
await writeFile(file.path, file.contents, "utf8");
|
|
1132
|
+
}
|
|
1133
|
+
if (envChanged)
|
|
1134
|
+
await writeFile(envPath, envNext, "utf8");
|
|
1135
|
+
}
|
|
1136
|
+
say();
|
|
1137
|
+
say(style(1, "Glyph Pay init"));
|
|
1138
|
+
say(`Framework: ${plan.framework}`);
|
|
1139
|
+
say();
|
|
1140
|
+
for (const file of generated) {
|
|
1141
|
+
const verb = existing.includes(display(file.path)) ? "Updated" : options.dryRun ? "Would create" : "Created";
|
|
1142
|
+
say(` ${verb} ${display(file.path)}`);
|
|
1143
|
+
}
|
|
1144
|
+
say(` ${envChanged ? (options.dryRun ? "Would update" : "Updated") : "Unchanged"} .env.example`);
|
|
1145
|
+
const pnpm = await fileExists(join(projectRoot, "pnpm-lock.yaml"));
|
|
1146
|
+
const yarn = await fileExists(join(projectRoot, "yarn.lock"));
|
|
1147
|
+
const bun = (await fileExists(join(projectRoot, "bun.lockb"))) || (await fileExists(join(projectRoot, "bun.lock")));
|
|
1148
|
+
say();
|
|
1149
|
+
say("Next steps:");
|
|
1150
|
+
say(` 1. ${installCommand(pnpm, yarn, bun)}`);
|
|
1151
|
+
say(" 2. npx glyphpay connect --url https://your-site.com");
|
|
1152
|
+
say(` 3. Wire priceOrder() and onEvent() in ${display(plan.libPath)}.`);
|
|
1153
|
+
for (const note of plan.notes)
|
|
1154
|
+
say(` ${note}`);
|
|
1155
|
+
if (options.dryRun) {
|
|
1156
|
+
say();
|
|
1157
|
+
say("Dry run: no files were written.");
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
377
1160
|
/* ------------------------------------------------------------------ */
|
|
378
1161
|
/* Entry point */
|
|
379
1162
|
/* ------------------------------------------------------------------ */
|
|
@@ -388,6 +1171,26 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
388
1171
|
say(VERSION);
|
|
389
1172
|
return 0;
|
|
390
1173
|
}
|
|
1174
|
+
if (parsed.command === "doctor") {
|
|
1175
|
+
const report = await runDoctor(parsed.options);
|
|
1176
|
+
return report.ok ? 0 : 1;
|
|
1177
|
+
}
|
|
1178
|
+
if (parsed.command === "init") {
|
|
1179
|
+
await runInit(parsed.options);
|
|
1180
|
+
return 0;
|
|
1181
|
+
}
|
|
1182
|
+
if (parsed.command === "deliveries") {
|
|
1183
|
+
const result = await runDeliveries(parsed.options);
|
|
1184
|
+
return "outcome" in result && result.outcome !== "delivered" ? 1 : 0;
|
|
1185
|
+
}
|
|
1186
|
+
if (parsed.command === "disconnect") {
|
|
1187
|
+
await runDisconnect(parsed.options);
|
|
1188
|
+
return 0;
|
|
1189
|
+
}
|
|
1190
|
+
if (parsed.command === "rotate") {
|
|
1191
|
+
await runRotate(parsed.options);
|
|
1192
|
+
return 0;
|
|
1193
|
+
}
|
|
391
1194
|
await runConnect(parsed.options);
|
|
392
1195
|
return 0;
|
|
393
1196
|
}
|
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.4.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"
|