glyphpay 0.2.0 → 0.3.0

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