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 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
- ## 2. Add to the client site
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, use `createPayment` and `verifyCallback` directly.
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
- * `glyphpay connect` - browser pairing for a client site.
3
+ * Glyph Pay command line.
4
4
  *
5
- * The CLI never asks for a password or an API token. It creates a short-lived
6
- * pairing request, opens the Glyph Pay site, and waits while the signed-in
7
- * owner approves it in the browser. The service credential and webhook signing
8
- * secret are returned only to this process, then written to the site's server
9
- * environment file.
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 ConnectOptions {
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: "connect" | "help" | "version";
43
- options: ConnectOptions;
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?: ConnectOptions): Promise<ConnectResult>;
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
- * `glyphpay connect` - browser pairing for a client site.
3
+ * Glyph Pay command line.
4
4
  *
5
- * The CLI never asks for a password or an API token. It creates a short-lived
6
- * pairing request, opens the Glyph Pay site, and waits while the signed-in
7
- * owner approves it in the browser. The service credential and webhook signing
8
- * secret are returned only to this process, then written to the site's server
9
- * environment file.
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 { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
15
- import { dirname, relative, resolve } from "node:path";
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.2.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
- if (argument === "connect" || argument === "login") {
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 = "connect";
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] Connect a site (alias: glyphpay login)
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
- Options
149
- --url <site-url> Site to connect. Adds /api/glyphpay/webhook when the
150
- URL has no path. Or pass the exact callback URL.
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
- -h, --help Show this help
157
- -v, --version Show the version
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.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
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, `Connected ${approved.businessName} (${approved.environment} mode).`));
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("The token is shown only once. Run this command again to rotate it.");
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.2.0",
4
- "description": "Drop-in GlyphPay checkout for websites, plus `glyphpay connect` for browser-based setup.",
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": "./dist/cli.js"
17
+ "glyphpay": "dist/cli.js"
18
18
  },
19
19
  "files": [
20
20
  "dist"