glyphpay 0.1.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 +60 -2
- package/dist/cli.d.ts +112 -0
- package/dist/cli.js +1019 -0
- package/package.json +35 -8
package/README.md
CHANGED
|
@@ -8,13 +8,71 @@ 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
|
-
|
|
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
|
+
|
|
21
|
+
## Connect a site from the CLI
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx glyphpay connect --url https://client-site.com
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The command opens Glyph Pay in your browser. Sign in as the client's billing email (or a Design Glyph admin), pick the client account, confirm the webhook URL and approve the pairing code. The terminal receives a test service credential and, when a webhook URL was registered, its signing secret. It writes both to `.env.local`:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
GLYPHPAY_TOKEN=dgsvc_test_...
|
|
31
|
+
GLYPHPAY_CALLBACK_SECRET=dgcb_...
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `--url <site>` registers `<site>/api/glyphpay/webhook`. Pass a full URL to use a different route.
|
|
35
|
+
- `--env <path>` targets another env file; `--print` prints the values instead of writing.
|
|
36
|
+
- `--no-open`, `--base-url` and `--timeout` help scripts and local testing.
|
|
37
|
+
- Codes expire after ten minutes. Approving again rotates the token: the previous token stops working immediately.
|
|
38
|
+
- Pairing is disabled while the platform is in live mode.
|
|
39
|
+
|
|
40
|
+
The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
|
|
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
|
+
|
|
69
|
+
## Manual setup: connect the client in Design Glyph admin
|
|
12
70
|
|
|
13
71
|
1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
|
|
14
72
|
2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
|
|
15
73
|
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
|
|
16
74
|
|
|
17
|
-
##
|
|
75
|
+
## Add to the client site
|
|
18
76
|
|
|
19
77
|
```sh
|
|
20
78
|
pnpm add glyphpay
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Glyph Pay command line.
|
|
4
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
15
|
+
*/
|
|
16
|
+
export declare class CliUsageError extends Error {
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare class CliApiError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly code: string;
|
|
22
|
+
constructor(status: number, code: string, message: string);
|
|
23
|
+
}
|
|
24
|
+
export interface CliOptions {
|
|
25
|
+
/** Site URL (a webhook path is appended when the URL has no path), or the exact callback URL. */
|
|
26
|
+
url?: string;
|
|
27
|
+
/** Env file to read or update. Defaults to `.env.local`. */
|
|
28
|
+
envFile?: string;
|
|
29
|
+
/** Print the credentials instead of writing them to a file. */
|
|
30
|
+
print?: boolean;
|
|
31
|
+
/** Skip opening a browser. */
|
|
32
|
+
noOpen?: boolean;
|
|
33
|
+
/** API origin. Defaults to GLYPHPAY_API_URL or production. */
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
/** How long to wait for browser approval. */
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
export interface ConnectResult {
|
|
53
|
+
businessName: string;
|
|
54
|
+
environment: "test" | "live";
|
|
55
|
+
callbackUrl: string | null;
|
|
56
|
+
writtenTo: string | null;
|
|
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";
|
|
65
|
+
interface ParsedCommand {
|
|
66
|
+
command: Command;
|
|
67
|
+
options: CliOptions;
|
|
68
|
+
}
|
|
69
|
+
export declare function parseArgs(argv: string[]): ParsedCommand;
|
|
70
|
+
export declare function helpText(): string;
|
|
71
|
+
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
72
|
+
export declare function toCallbackUrl(raw: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Replace values in an env file without touching unrelated lines. The first
|
|
75
|
+
* occurrence of a managed key is rewritten; duplicate managed keys are dropped
|
|
76
|
+
* so the file keeps a single source of truth.
|
|
77
|
+
*/
|
|
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[];
|
|
87
|
+
export declare function openBrowser(url: string): boolean;
|
|
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>;
|
|
111
|
+
export declare function main(argv?: string[]): Promise<number>;
|
|
112
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Glyph Pay command line.
|
|
4
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
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";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
const VERSION = "0.3.0";
|
|
23
|
+
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
24
|
+
const DEFAULT_ENV_FILE = ".env.local";
|
|
25
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
26
|
+
const TOKEN_KEY = "GLYPHPAY_TOKEN";
|
|
27
|
+
const CALLBACK_SECRET_KEY = "GLYPHPAY_CALLBACK_SECRET";
|
|
28
|
+
const WEBHOOK_PATH = "/api/glyphpay/webhook";
|
|
29
|
+
export class CliUsageError extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CliUsageError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class CliApiError extends Error {
|
|
36
|
+
status;
|
|
37
|
+
code;
|
|
38
|
+
constructor(status, code, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "CliApiError";
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
46
|
+
function style(code, text) {
|
|
47
|
+
return useColor ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
48
|
+
}
|
|
49
|
+
function say(text = "") {
|
|
50
|
+
process.stdout.write(`${text}\n`);
|
|
51
|
+
}
|
|
52
|
+
function sleep(ms) {
|
|
53
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
54
|
+
}
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* Argument parsing */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
function takeValue(argv, index, flag) {
|
|
59
|
+
const value = argv[index + 1];
|
|
60
|
+
if (!value || value.startsWith("-"))
|
|
61
|
+
throw new CliUsageError(`${flag} needs a value.`);
|
|
62
|
+
return [value, index + 1];
|
|
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
|
+
}
|
|
77
|
+
export function parseArgs(argv) {
|
|
78
|
+
const options = {};
|
|
79
|
+
let command = "connect";
|
|
80
|
+
let sawCommand = false;
|
|
81
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
82
|
+
const argument = argv[index];
|
|
83
|
+
if (argument === "--help" || argument === "-h" || argument === "help")
|
|
84
|
+
return { command: "help", options };
|
|
85
|
+
if (argument === "--version" || argument === "-v")
|
|
86
|
+
return { command: "version", options };
|
|
87
|
+
const namedCommand = commandFor(argument);
|
|
88
|
+
if (namedCommand) {
|
|
89
|
+
if (sawCommand)
|
|
90
|
+
throw new CliUsageError("Only one command can run at a time.");
|
|
91
|
+
sawCommand = true;
|
|
92
|
+
command = namedCommand;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (argument === "--print") {
|
|
96
|
+
options.print = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (argument === "--no-open") {
|
|
100
|
+
options.noOpen = true;
|
|
101
|
+
continue;
|
|
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
|
+
}
|
|
127
|
+
const [flag, inlineValue] = argument.includes("=") ? argument.split(/=(.*)/s, 2) : [argument, undefined];
|
|
128
|
+
if (flag === "--url" || flag === "--callback-url") {
|
|
129
|
+
if (inlineValue !== undefined) {
|
|
130
|
+
if (!inlineValue)
|
|
131
|
+
throw new CliUsageError(`${flag} needs a value.`);
|
|
132
|
+
options.url = inlineValue;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
136
|
+
options.url = value;
|
|
137
|
+
index = next;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (flag === "--env") {
|
|
142
|
+
if (inlineValue !== undefined) {
|
|
143
|
+
if (!inlineValue)
|
|
144
|
+
throw new CliUsageError("--env needs a path.");
|
|
145
|
+
options.envFile = inlineValue;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
149
|
+
options.envFile = value;
|
|
150
|
+
index = next;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (flag === "--base-url") {
|
|
155
|
+
if (inlineValue !== undefined) {
|
|
156
|
+
if (!inlineValue)
|
|
157
|
+
throw new CliUsageError("--base-url needs a URL.");
|
|
158
|
+
options.baseUrl = inlineValue;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
162
|
+
options.baseUrl = value;
|
|
163
|
+
index = next;
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
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
|
+
}
|
|
180
|
+
if (flag === "--timeout") {
|
|
181
|
+
const raw = inlineValue ?? takeValue(argv, index, flag)[0];
|
|
182
|
+
if (inlineValue === undefined)
|
|
183
|
+
index += 1;
|
|
184
|
+
const parsed = Number(raw);
|
|
185
|
+
if (!Number.isFinite(parsed) || parsed < 10 || parsed > 1800) {
|
|
186
|
+
throw new CliUsageError("--timeout must be between 10 and 1800 seconds.");
|
|
187
|
+
}
|
|
188
|
+
options.timeoutSeconds = Math.floor(parsed);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (argument.startsWith("-"))
|
|
192
|
+
throw new CliUsageError(`Unknown option: ${argument}`);
|
|
193
|
+
throw new CliUsageError(`Unknown command: ${argument}`);
|
|
194
|
+
}
|
|
195
|
+
return { command, options };
|
|
196
|
+
}
|
|
197
|
+
export function helpText() {
|
|
198
|
+
return `glyphpay - take payments on your site through Glyph Pay
|
|
199
|
+
|
|
200
|
+
Usage
|
|
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
|
|
206
|
+
glyphpay --version
|
|
207
|
+
|
|
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.
|
|
211
|
+
--env <path> Env file to update (default: ${DEFAULT_ENV_FILE})
|
|
212
|
+
--print Print the credentials instead of writing a file
|
|
213
|
+
--no-open Do not open a browser automatically
|
|
214
|
+
--base-url <url> Glyph Pay API origin (default: ${DEFAULT_BASE_URL})
|
|
215
|
+
--timeout <seconds> Wait for approval (default: ${DEFAULT_TIMEOUT_SECONDS})
|
|
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
|
|
229
|
+
|
|
230
|
+
The browser step signs in to Glyph Pay, lets you pick the client account, and
|
|
231
|
+
approves this machine. The service credential goes only to this terminal.`;
|
|
232
|
+
}
|
|
233
|
+
/* ------------------------------------------------------------------ */
|
|
234
|
+
/* URL and env-file helpers */
|
|
235
|
+
/* ------------------------------------------------------------------ */
|
|
236
|
+
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
237
|
+
export function toCallbackUrl(raw) {
|
|
238
|
+
let value = raw.trim();
|
|
239
|
+
if (!value)
|
|
240
|
+
throw new CliUsageError("Enter the site's URL.");
|
|
241
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
|
|
242
|
+
value = `https://${value}`;
|
|
243
|
+
let url;
|
|
244
|
+
try {
|
|
245
|
+
url = new URL(value);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new CliUsageError(`"${raw}" is not a valid URL.`);
|
|
249
|
+
}
|
|
250
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
251
|
+
throw new CliUsageError("The site URL must start with https:// (or http:// for local development).");
|
|
252
|
+
}
|
|
253
|
+
if (url.username || url.password)
|
|
254
|
+
throw new CliUsageError("The site URL must not contain a username or password.");
|
|
255
|
+
if (url.pathname === "/" || url.pathname === "")
|
|
256
|
+
url.pathname = WEBHOOK_PATH;
|
|
257
|
+
url.hash = "";
|
|
258
|
+
return url.toString();
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Replace values in an env file without touching unrelated lines. The first
|
|
262
|
+
* occurrence of a managed key is rewritten; duplicate managed keys are dropped
|
|
263
|
+
* so the file keeps a single source of truth.
|
|
264
|
+
*/
|
|
265
|
+
export function upsertEnv(contents, values) {
|
|
266
|
+
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
267
|
+
const lines = contents.length
|
|
268
|
+
? contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n")
|
|
269
|
+
: [];
|
|
270
|
+
const pending = new Map(Object.entries(values));
|
|
271
|
+
const handled = new Set();
|
|
272
|
+
const output = [];
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
275
|
+
const key = match?.[1];
|
|
276
|
+
if (key && pending.has(key)) {
|
|
277
|
+
output.push(`${key}=${pending.get(key)}`);
|
|
278
|
+
pending.delete(key);
|
|
279
|
+
handled.add(key);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (key && handled.has(key))
|
|
283
|
+
continue;
|
|
284
|
+
output.push(line);
|
|
285
|
+
}
|
|
286
|
+
for (const [key, value] of pending)
|
|
287
|
+
output.push(`${key}=${value}`);
|
|
288
|
+
return `${output.join(eol)}${eol}`;
|
|
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
|
+
}
|
|
334
|
+
function isNotFound(error) {
|
|
335
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
336
|
+
}
|
|
337
|
+
async function readEnvFile(path) {
|
|
338
|
+
try {
|
|
339
|
+
return await readFile(path, "utf8");
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (isNotFound(error))
|
|
343
|
+
return "";
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function writeEnvFile(path, contents) {
|
|
348
|
+
await mkdir(dirname(path), { recursive: true });
|
|
349
|
+
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
350
|
+
try {
|
|
351
|
+
await chmod(path, 0o600);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Windows does not use POSIX modes; the file still lives in the project.
|
|
355
|
+
}
|
|
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
|
+
}
|
|
374
|
+
/* ------------------------------------------------------------------ */
|
|
375
|
+
/* Browser and API */
|
|
376
|
+
/* ------------------------------------------------------------------ */
|
|
377
|
+
export function openBrowser(url) {
|
|
378
|
+
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
379
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
380
|
+
try {
|
|
381
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
382
|
+
child.on("error", () => undefined);
|
|
383
|
+
child.unref();
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function api(baseUrl, path, body, token) {
|
|
391
|
+
let response;
|
|
392
|
+
try {
|
|
393
|
+
response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/cli/v1${path}`, {
|
|
394
|
+
method: body === undefined ? "GET" : "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
accept: "application/json",
|
|
397
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
398
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
399
|
+
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
400
|
+
},
|
|
401
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
402
|
+
signal: AbortSignal.timeout(20_000),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
throw new CliApiError(0, "network", "Could not reach Glyph Pay. Check your connection and try again.");
|
|
407
|
+
}
|
|
408
|
+
const payload = (await response.json().catch(() => null));
|
|
409
|
+
if (!response.ok || !payload?.ok) {
|
|
410
|
+
throw new CliApiError(response.status, payload && "error" in payload ? payload.error?.code ?? "request_failed" : "request_failed", (payload && "error" in payload ? payload.error?.message : null) ??
|
|
411
|
+
`Glyph Pay request failed (HTTP ${response.status}).`);
|
|
412
|
+
}
|
|
413
|
+
return payload.data;
|
|
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
|
+
}
|
|
421
|
+
async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
422
|
+
const deadline = Date.now() + timeoutMs;
|
|
423
|
+
let interval = Math.max(1, firstInterval);
|
|
424
|
+
let lastNetworkError = null;
|
|
425
|
+
while (Date.now() < deadline) {
|
|
426
|
+
await sleep(interval * 1000);
|
|
427
|
+
try {
|
|
428
|
+
const result = await api(baseUrl, "/device/token", { deviceCode });
|
|
429
|
+
if (result.status === "approved")
|
|
430
|
+
return result;
|
|
431
|
+
interval = Math.max(1, result.interval || interval);
|
|
432
|
+
lastNetworkError = null;
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (error instanceof CliApiError && error.status === 429) {
|
|
436
|
+
interval = Math.min(interval + 2, 10);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (error instanceof CliApiError && (error.status === 404 || error.status === 410))
|
|
440
|
+
throw error;
|
|
441
|
+
lastNetworkError = error instanceof Error ? error.message : String(error);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
throw new CliApiError(0, "timeout", `Timed out waiting for approval${lastNetworkError ? ` (last error: ${lastNetworkError})` : ""}. ` +
|
|
445
|
+
"Run the command again for a fresh code.");
|
|
446
|
+
}
|
|
447
|
+
/* ------------------------------------------------------------------ */
|
|
448
|
+
/* Connect and rotate */
|
|
449
|
+
/* ------------------------------------------------------------------ */
|
|
450
|
+
export async function runConnect(options = {}, mode = "connect") {
|
|
451
|
+
const baseUrl = baseUrlFor(options);
|
|
452
|
+
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
453
|
+
const callbackUrl = options.url ? toCallbackUrl(options.url) : null;
|
|
454
|
+
const created = await api(baseUrl, "/device", { callbackUrl });
|
|
455
|
+
say();
|
|
456
|
+
say(style(1, mode === "rotate" ? "Glyph Pay - rotate credential" : "Glyph Pay - connect a site"));
|
|
457
|
+
say();
|
|
458
|
+
say(`1. Open ${style(4, created.verificationUrl)}`);
|
|
459
|
+
say(`2. Sign in and approve pairing code ${style(1, created.userCode)}.`);
|
|
460
|
+
say(` The code expires at ${new Date(created.expiresAt).toLocaleTimeString()}.`);
|
|
461
|
+
if (options.noOpen) {
|
|
462
|
+
say(" (Browser opening skipped: --no-open.)");
|
|
463
|
+
}
|
|
464
|
+
else if (openBrowser(created.verificationUrl)) {
|
|
465
|
+
say(" A browser window should open.");
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
say(" Could not open a browser. Open the link above manually.");
|
|
469
|
+
}
|
|
470
|
+
say();
|
|
471
|
+
say(mode === "rotate" ? "Waiting for approval (the previous token stays valid until then)..." : "Waiting for approval...");
|
|
472
|
+
const approved = await waitForApproval(baseUrl, created.deviceCode, created.interval, timeoutSeconds * 1000);
|
|
473
|
+
const values = { [TOKEN_KEY]: approved.token };
|
|
474
|
+
if (approved.callbackSecret)
|
|
475
|
+
values[CALLBACK_SECRET_KEY] = approved.callbackSecret;
|
|
476
|
+
say();
|
|
477
|
+
say(style(1, `${mode === "rotate" ? "Rotated" : "Connected"} ${approved.businessName} (${approved.environment} mode).`));
|
|
478
|
+
if (approved.callbackUrl)
|
|
479
|
+
say(`Webhook registered: ${approved.callbackUrl}`);
|
|
480
|
+
else
|
|
481
|
+
say("No webhook was registered. Add one later in Glyph Pay admin if your site needs payment events.");
|
|
482
|
+
let writtenTo = null;
|
|
483
|
+
if (options.print) {
|
|
484
|
+
say();
|
|
485
|
+
say("Add these to your server environment (never NEXT_PUBLIC_):");
|
|
486
|
+
for (const [key, value] of Object.entries(values))
|
|
487
|
+
say(`${key}=${value}`);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
const file = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
491
|
+
try {
|
|
492
|
+
const next = upsertEnv(await readEnvFile(file), values);
|
|
493
|
+
await writeEnvFile(file, next);
|
|
494
|
+
writtenTo = file;
|
|
495
|
+
const display = relative(process.cwd(), file) || file;
|
|
496
|
+
say(`Wrote ${Object.keys(values).join(" and ")} to ${display}.`);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
say();
|
|
500
|
+
say(style(33, `Could not write ${file}: ${error instanceof Error ? error.message : String(error)}`));
|
|
501
|
+
say("Copy these values into your server environment now; they are shown only this once:");
|
|
502
|
+
for (const [key, value] of Object.entries(values))
|
|
503
|
+
say(`${key}=${value}`);
|
|
504
|
+
throw new CliApiError(1, "env_write_failed", "The credentials were printed above but not saved to a file.");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
say();
|
|
508
|
+
say(mode === "rotate"
|
|
509
|
+
? "The previous token is already invalid. Deploy the new values before the next request."
|
|
510
|
+
: "The token is shown only once. Run this command again to rotate it.");
|
|
511
|
+
return {
|
|
512
|
+
businessName: approved.businessName,
|
|
513
|
+
environment: approved.environment,
|
|
514
|
+
callbackUrl: approved.callbackUrl,
|
|
515
|
+
writtenTo,
|
|
516
|
+
};
|
|
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
|
+
}
|
|
971
|
+
/* ------------------------------------------------------------------ */
|
|
972
|
+
/* Entry point */
|
|
973
|
+
/* ------------------------------------------------------------------ */
|
|
974
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
975
|
+
try {
|
|
976
|
+
const parsed = parseArgs(argv);
|
|
977
|
+
if (parsed.command === "help") {
|
|
978
|
+
say(helpText());
|
|
979
|
+
return 0;
|
|
980
|
+
}
|
|
981
|
+
if (parsed.command === "version") {
|
|
982
|
+
say(VERSION);
|
|
983
|
+
return 0;
|
|
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
|
+
}
|
|
1001
|
+
await runConnect(parsed.options);
|
|
1002
|
+
return 0;
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
if (error instanceof CliUsageError) {
|
|
1006
|
+
say(style(31, error.message));
|
|
1007
|
+
say("Run `glyphpay --help` for usage.");
|
|
1008
|
+
return 2;
|
|
1009
|
+
}
|
|
1010
|
+
say();
|
|
1011
|
+
say(style(31, error instanceof Error ? error.message : String(error)));
|
|
1012
|
+
return 1;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1016
|
+
void main().then((code) => {
|
|
1017
|
+
process.exitCode = code;
|
|
1018
|
+
});
|
|
1019
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glyphpay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drop-in GlyphPay checkout for websites
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Drop-in GlyphPay checkout for websites, with a CLI for setup, diagnostics, and credential lifecycle.",
|
|
5
5
|
"homepage": "https://glyphpay.net",
|
|
6
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"glyphpay",
|
|
8
|
+
"payments",
|
|
9
|
+
"checkout",
|
|
10
|
+
"stripe",
|
|
11
|
+
"react",
|
|
12
|
+
"nextjs"
|
|
13
|
+
],
|
|
7
14
|
"license": "UNLICENSED",
|
|
8
15
|
"type": "module",
|
|
9
|
-
"
|
|
16
|
+
"bin": {
|
|
17
|
+
"glyphpay": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
10
25
|
"exports": {
|
|
11
|
-
"./server": {
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
"./server": {
|
|
27
|
+
"types": "./dist/server.d.ts",
|
|
28
|
+
"default": "./dist/server.js"
|
|
29
|
+
},
|
|
30
|
+
"./browser": {
|
|
31
|
+
"types": "./dist/browser.d.ts",
|
|
32
|
+
"default": "./dist/browser.js"
|
|
33
|
+
},
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./dist/react.d.ts",
|
|
36
|
+
"default": "./dist/react.js"
|
|
37
|
+
}
|
|
14
38
|
},
|
|
15
39
|
"scripts": {
|
|
16
40
|
"build": "tsc",
|
|
@@ -24,9 +48,12 @@
|
|
|
24
48
|
"react": ">=18"
|
|
25
49
|
},
|
|
26
50
|
"peerDependenciesMeta": {
|
|
27
|
-
"react": {
|
|
51
|
+
"react": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
28
54
|
},
|
|
29
55
|
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.20.5",
|
|
30
57
|
"@types/react": "^19",
|
|
31
58
|
"typescript": "^5"
|
|
32
59
|
}
|