perly-pay 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +208 -2
- package/dist/cli/bin.js +11 -0
- package/dist/cli/detect.js +168 -0
- package/dist/cli/env.js +146 -0
- package/dist/cli/init.js +252 -0
- package/dist/cli/main.js +167 -0
- package/dist/cli/templates.js +514 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +53 -0
- package/dist/http.d.ts +29 -0
- package/dist/http.js +121 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.js +277 -0
- package/dist/sha256.d.ts +8 -0
- package/dist/sha256.js +85 -0
- package/dist/types.d.ts +638 -0
- package/dist/types.js +9 -0
- package/dist/webhooks.d.ts +30 -0
- package/dist/webhooks.js +93 -0
- package/package.json +57 -4
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx perly-pay init`: from an empty project to a working test-mode
|
|
3
|
+
* checkout. Detects the framework, takes a TEST key, writes the code and
|
|
4
|
+
* the keys file (kept out of git), and says what is left to do.
|
|
5
|
+
*
|
|
6
|
+
* Everything outside the disk goes through `InitIO`, so the tests drive
|
|
7
|
+
* it without a terminal, a browser or a network.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { PerlyPay } from "../index.js";
|
|
12
|
+
import { PerlyPayError } from "../errors.js";
|
|
13
|
+
import { DetectError, FRAMEWORK_NAMES, detectProject, installCommand } from "./detect.js";
|
|
14
|
+
import { API_VAR, DEFAULT_API, KEY_VAR, WEBHOOK_SECRET_VAR, checkTestKey, ensureIgnored, maskKey, readEnvValue, upsertEnv, } from "./env.js";
|
|
15
|
+
import { DEFAULT_OFFER, planFiles } from "./templates.js";
|
|
16
|
+
/** The Claude Code plugin and the MCP server, as their packages are named. */
|
|
17
|
+
export const PLUGIN_NAME = "perly-pay";
|
|
18
|
+
export const MCP_PACKAGE = "perly-pay-mcp";
|
|
19
|
+
class InitError extends Error {
|
|
20
|
+
}
|
|
21
|
+
const KEY_HELP = "init sets up test mode only. Make a test key (sk_test_…) in Dashboard → Developers; going live is a step in the dashboard, later.";
|
|
22
|
+
function explainKey(reason, where) {
|
|
23
|
+
if (reason === "live")
|
|
24
|
+
return `That is a live key (sk_live_…) ${where}. ${KEY_HELP}`;
|
|
25
|
+
return `That is not a test key ${where}: it looks like sk_test_ and 32 letters or digits.`;
|
|
26
|
+
}
|
|
27
|
+
function readText(path) {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function resolveKey(options, io, envFileText, developersUrl) {
|
|
36
|
+
const candidates = [
|
|
37
|
+
[options.key, "--key", "in --key"],
|
|
38
|
+
[io.env[KEY_VAR], "environment", `in $${KEY_VAR}`],
|
|
39
|
+
[readEnvValue(envFileText, KEY_VAR), "keys file", "in the keys file"],
|
|
40
|
+
];
|
|
41
|
+
for (const [raw, source, where] of candidates) {
|
|
42
|
+
if (!raw?.trim())
|
|
43
|
+
continue;
|
|
44
|
+
const check = checkTestKey(raw);
|
|
45
|
+
if (!check.ok)
|
|
46
|
+
throw new InitError(explainKey(check.reason, where));
|
|
47
|
+
return { key: check.key, source };
|
|
48
|
+
}
|
|
49
|
+
if (!io.interactive || options.yes)
|
|
50
|
+
return { key: null, source: null };
|
|
51
|
+
io.print(`Opening ${developersUrl} — make a test key (sk_test_…) and paste it here.`);
|
|
52
|
+
io.openUrl(developersUrl);
|
|
53
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
54
|
+
const answer = (await io.ask("Test secret key (Enter to skip): ")).trim();
|
|
55
|
+
if (!answer)
|
|
56
|
+
return { key: null, source: null };
|
|
57
|
+
const check = checkTestKey(answer);
|
|
58
|
+
if (check.ok)
|
|
59
|
+
return { key: check.key, source: "pasted" };
|
|
60
|
+
io.print(explainKey(check.reason, "there"));
|
|
61
|
+
}
|
|
62
|
+
return { key: null, source: null };
|
|
63
|
+
}
|
|
64
|
+
async function registerWebhook(url, key, api, io, haveSecret) {
|
|
65
|
+
const pay = new PerlyPay(key, { baseUrl: api, ...(io.fetch ? { fetch: io.fetch } : {}) });
|
|
66
|
+
for await (const endpoint of pay.webhookEndpoints.listAll({ limit: 100 })) {
|
|
67
|
+
if (endpoint.url !== url)
|
|
68
|
+
continue;
|
|
69
|
+
if (haveSecret)
|
|
70
|
+
return { endpoint: endpoint.id, secret: null, how: "kept" };
|
|
71
|
+
// The secret is shown only when made or rolled; a fresh one is the only way to know it.
|
|
72
|
+
const rolled = await pay.webhookEndpoints.rollSecret(endpoint.id);
|
|
73
|
+
return { endpoint: endpoint.id, secret: rolled.secret ?? null, how: "rolled" };
|
|
74
|
+
}
|
|
75
|
+
const made = await pay.webhookEndpoints.create({ url, enabled_events: ["invoice.fulfilled"], description: "npx perly-pay init" });
|
|
76
|
+
return { endpoint: made.id, secret: made.secret ?? null, how: "created" };
|
|
77
|
+
}
|
|
78
|
+
function language(project) {
|
|
79
|
+
return project.framework === "laravel" ? "php" : project.style === "ts" ? "typescript" : "javascript";
|
|
80
|
+
}
|
|
81
|
+
export async function init(options, io) {
|
|
82
|
+
const result = { ok: false, files: [], env: {}, key: "placeholder", notes: [], next: [] };
|
|
83
|
+
try {
|
|
84
|
+
await run(options, io, result);
|
|
85
|
+
result.ok = true;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof InitError || error instanceof DetectError)
|
|
89
|
+
result.error = error.message;
|
|
90
|
+
else if (error instanceof PerlyPayError)
|
|
91
|
+
result.error = `Perly Pay said: ${error.message}`;
|
|
92
|
+
else
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
if (options.json)
|
|
96
|
+
io.print(JSON.stringify(result, null, 2));
|
|
97
|
+
else
|
|
98
|
+
io.print(summary(result, options));
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
async function run(options, io, result) {
|
|
102
|
+
const api = (options.api ?? DEFAULT_API).replace(/\/+$/, "");
|
|
103
|
+
if (!/^https?:\/\/[^/]+$/.test(api))
|
|
104
|
+
throw new InitError(`--api takes an origin, like ${DEFAULT_API} or http://localhost:3200.`);
|
|
105
|
+
const developersUrl = `${api}/dashboard/developers`;
|
|
106
|
+
const offer = options.offer ?? DEFAULT_OFFER;
|
|
107
|
+
const project = detectProject(options.dir, options.framework);
|
|
108
|
+
const plan = planFiles(project, offer);
|
|
109
|
+
result.framework = project.framework;
|
|
110
|
+
result.packageManager = project.packageManager;
|
|
111
|
+
result.language = language(project);
|
|
112
|
+
result.envFile = project.envFile;
|
|
113
|
+
result.routes = { button: plan.buttonPath, checkout: plan.checkoutPath, webhook: plan.webhookPath };
|
|
114
|
+
result.notes.push(...project.notes);
|
|
115
|
+
const envPath = join(project.dir, project.envFile);
|
|
116
|
+
const envText = readText(envPath) ?? "";
|
|
117
|
+
const { key, source } = await resolveKey(options, io, envText, developersUrl);
|
|
118
|
+
result.key = key ? "set" : "placeholder";
|
|
119
|
+
// The network first: if Perly Pay refuses the endpoint, nothing has been written yet.
|
|
120
|
+
let webhookSecret = null;
|
|
121
|
+
result.webhook = null;
|
|
122
|
+
if (options.webhookUrl) {
|
|
123
|
+
if (!key)
|
|
124
|
+
throw new InitError(`--webhook-url needs a test key: pass --key or set ${KEY_VAR}.`);
|
|
125
|
+
const haveSecret = !!readEnvValue(envText, WEBHOOK_SECRET_VAR);
|
|
126
|
+
if (options.dryRun) {
|
|
127
|
+
result.notes.push(`Would register ${options.webhookUrl} for invoice.fulfilled.`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const made = await registerWebhook(options.webhookUrl, key, api, io, haveSecret);
|
|
131
|
+
webhookSecret = made.secret;
|
|
132
|
+
result.webhook = { url: options.webhookUrl, endpoint: made.endpoint, secret: made.how };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const file of plan.files) {
|
|
136
|
+
const target = join(project.dir, file.path);
|
|
137
|
+
const current = readText(target);
|
|
138
|
+
let status;
|
|
139
|
+
if (current === file.content)
|
|
140
|
+
status = "unchanged";
|
|
141
|
+
else if (current !== null && !options.force)
|
|
142
|
+
status = "kept";
|
|
143
|
+
else
|
|
144
|
+
status = options.dryRun ? "would write" : current === null ? "written" : "replaced";
|
|
145
|
+
if (status === "written" || status === "replaced") {
|
|
146
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
147
|
+
writeFileSync(target, file.content);
|
|
148
|
+
}
|
|
149
|
+
result.files.push({ path: file.path, status });
|
|
150
|
+
}
|
|
151
|
+
const entries = [
|
|
152
|
+
{ name: KEY_VAR, value: key ?? "", comment: `Test key from Dashboard → Developers (sk_test_…). Never commit it.`, replace: source === "--key" || source === "pasted" },
|
|
153
|
+
{ name: WEBHOOK_SECRET_VAR, value: webhookSecret ?? "", comment: "Your webhook endpoint's signing secret (whsec_…).", replace: webhookSecret !== null },
|
|
154
|
+
];
|
|
155
|
+
if (api !== DEFAULT_API)
|
|
156
|
+
entries.push({ name: API_VAR, value: api, comment: "Perly Pay's address, when it is not https://pay.perly.io.", replace: true });
|
|
157
|
+
// Never write a key from the shell's environment into a file: it is already where the app reads it.
|
|
158
|
+
if (source === "environment")
|
|
159
|
+
entries.shift();
|
|
160
|
+
const env = upsertEnv(envText, entries, options.force);
|
|
161
|
+
result.env = env.outcomes;
|
|
162
|
+
if (!options.dryRun && env.content !== envText)
|
|
163
|
+
writeFileSync(envPath, env.content);
|
|
164
|
+
const gitignorePath = join(project.dir, ".gitignore");
|
|
165
|
+
const ignored = ensureIgnored(readText(gitignorePath), project.envFile);
|
|
166
|
+
result.gitignore = ignored === null ? "already ignored" : "added";
|
|
167
|
+
if (ignored !== null && !options.dryRun)
|
|
168
|
+
writeFileSync(gitignorePath, ignored);
|
|
169
|
+
const next = result.next;
|
|
170
|
+
if (!project.hasSdk) {
|
|
171
|
+
const command = installCommand(project);
|
|
172
|
+
if (options.install && !options.dryRun) {
|
|
173
|
+
const status = io.run(command, project.dir);
|
|
174
|
+
result.install = { command, ran: true, status };
|
|
175
|
+
if (status !== 0)
|
|
176
|
+
next.push(`Install the SDK (that failed): ${command}`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
result.install = { command, ran: false };
|
|
180
|
+
next.push(`Install the SDK: ${command}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (plan.mount)
|
|
184
|
+
next.push(`Wire in the routes:\n${indent(plan.mount)}`);
|
|
185
|
+
if (!project.loadsEnv)
|
|
186
|
+
next.push(`Load ${project.envFile} when the app starts: node --env-file=${project.envFile} … (Node 20.6+), or dotenv.`);
|
|
187
|
+
const finalSecret = webhookSecret ?? readEnvValue(env.content, WEBHOOK_SECRET_VAR);
|
|
188
|
+
const finalKey = source === "environment" ? key : readEnvValue(env.content, KEY_VAR);
|
|
189
|
+
if (!finalKey)
|
|
190
|
+
next.push(`Add your test key: make one at ${developersUrl}, then set ${KEY_VAR}=sk_test_… in ${project.envFile}.`);
|
|
191
|
+
if (!finalSecret) {
|
|
192
|
+
const port = project.framework === "laravel" ? 8000 : 3000;
|
|
193
|
+
next.push([
|
|
194
|
+
`Add a webhook: Dashboard → Developers → Webhooks → https://<your-domain>${plan.webhookPath}, event invoice.fulfilled;`,
|
|
195
|
+
`paste its whsec_… into ${project.envFile} as ${WEBHOOK_SECRET_VAR}. Or let init do both:`,
|
|
196
|
+
` npx perly-pay init --webhook-url https://<public-url>${plan.webhookPath}`,
|
|
197
|
+
`On your laptop, a tunnel gives a public URL: cloudflared tunnel --url http://localhost:${port}`,
|
|
198
|
+
].join("\n"));
|
|
199
|
+
}
|
|
200
|
+
const core = plan.files[0]?.path ?? "";
|
|
201
|
+
next.push(`Try it: start the app, open ${plan.buttonPath}, press Pay with crypto, then Simulate payment on the checkout. ` +
|
|
202
|
+
`Your webhook gets invoice.fulfilled and grantAccess() runs — the TODO in ${project.framework === "laravel" ? plan.files[1]?.path : core}.`);
|
|
203
|
+
next.push("Go live in the dashboard: connect your wallet's public key, make a live key (sk_live_…) and a live webhook, and set them where the app runs.");
|
|
204
|
+
if (options.plugin)
|
|
205
|
+
next.push(`Claude Code plugin "${PLUGIN_NAME}" — in Claude Code:\n /plugin install ${PLUGIN_NAME}`);
|
|
206
|
+
if (options.mcp) {
|
|
207
|
+
const envs = { [KEY_VAR]: "sk_test_…" };
|
|
208
|
+
if (api !== DEFAULT_API)
|
|
209
|
+
envs[API_VAR] = api;
|
|
210
|
+
const flags = Object.entries(envs)
|
|
211
|
+
.map(([k, v]) => `-e ${k}=${v}`)
|
|
212
|
+
.join(" ");
|
|
213
|
+
next.push(`MCP server (${MCP_PACKAGE}):\n claude mcp add perly-pay ${flags} -- npx -y ${MCP_PACKAGE}\n or in your client's MCP config:\n ` +
|
|
214
|
+
JSON.stringify({ mcpServers: { "perly-pay": { command: "npx", args: ["-y", MCP_PACKAGE], env: envs } } }));
|
|
215
|
+
}
|
|
216
|
+
if (key && source)
|
|
217
|
+
result.notes.push(`Test key ${maskKey(key)} (from ${source}).`);
|
|
218
|
+
}
|
|
219
|
+
function indent(text, by = " ") {
|
|
220
|
+
return text
|
|
221
|
+
.split("\n")
|
|
222
|
+
.map((l) => by + l)
|
|
223
|
+
.join("\n");
|
|
224
|
+
}
|
|
225
|
+
const MARK = { written: "+", replaced: "~", kept: "=", unchanged: "=", "would write": "?" };
|
|
226
|
+
function summary(r, options) {
|
|
227
|
+
if (!r.ok)
|
|
228
|
+
return `perly-pay init: ${r.error}`;
|
|
229
|
+
const out = [];
|
|
230
|
+
const lang = r.language === "typescript" ? "TypeScript" : r.language === "php" ? "PHP" : "JavaScript";
|
|
231
|
+
out.push(`Perly Pay for ${FRAMEWORK_NAMES[r.framework]} — ${lang}, ${r.packageManager}${options.dryRun ? " (dry run: nothing written)" : ""}`);
|
|
232
|
+
out.push("");
|
|
233
|
+
for (const f of r.files) {
|
|
234
|
+
const why = f.status === "kept" ? " (exists; --force replaces it)" : f.status === "unchanged" ? " (already up to date)" : "";
|
|
235
|
+
out.push(` ${MARK[f.status]} ${f.path}${why}`);
|
|
236
|
+
}
|
|
237
|
+
for (const [name, outcome] of Object.entries(r.env)) {
|
|
238
|
+
const what = outcome === "kept" ? "kept the value there" : outcome === "unchanged" ? "already set" : outcome;
|
|
239
|
+
out.push(` ${outcome === "added" ? "+" : outcome === "replaced" ? "~" : "="} ${r.envFile}: ${name} ${what}`);
|
|
240
|
+
}
|
|
241
|
+
out.push(` ${r.gitignore === "added" ? "+" : "="} .gitignore: ${r.envFile} ${r.gitignore === "added" ? "added" : "already ignored"}`);
|
|
242
|
+
if (r.webhook)
|
|
243
|
+
out.push(` + webhook ${r.webhook.url} (${r.webhook.endpoint}, secret ${r.webhook.secret})`);
|
|
244
|
+
if (r.install?.ran)
|
|
245
|
+
out.push(` ${r.install.status === 0 ? "+" : "!"} ${r.install.command}${r.install.status === 0 ? "" : ` (exit ${r.install.status})`}`);
|
|
246
|
+
for (const note of r.notes)
|
|
247
|
+
out.push(` · ${note}`);
|
|
248
|
+
out.push("");
|
|
249
|
+
out.push("Next");
|
|
250
|
+
r.next.forEach((step, i) => out.push(`${String(i + 1).padStart(3)}. ${step.split("\n").join("\n ")}`));
|
|
251
|
+
return out.join("\n");
|
|
252
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `perly-pay` command: `npx perly-pay init`. A separate entry from the
|
|
3
|
+
* library (`import "perly-pay"` never loads this), Node built-ins only.
|
|
4
|
+
* `bin.ts` runs it; the tests call `main` with their own IO.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { SDK_VERSION } from "../http.js";
|
|
11
|
+
import { FRAMEWORKS } from "./detect.js";
|
|
12
|
+
import { DEFAULT_API } from "./env.js";
|
|
13
|
+
import { init } from "./init.js";
|
|
14
|
+
import { DEFAULT_OFFER } from "./templates.js";
|
|
15
|
+
export const HELP = `perly-pay ${SDK_VERSION} — crypto payments straight to your wallet
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
npx perly-pay init [options]
|
|
19
|
+
|
|
20
|
+
Sets up Perly Pay in this project, in test mode: a checkout route, a webhook
|
|
21
|
+
route that checks signatures, a Pay button, and the keys in .env.local / .env
|
|
22
|
+
(kept out of git). Never replaces a file without --force.
|
|
23
|
+
|
|
24
|
+
Options
|
|
25
|
+
-y, --yes no questions (for AI assistants and scripts)
|
|
26
|
+
--framework <name> ${FRAMEWORKS.join(" | ")} (default: detected)
|
|
27
|
+
--key <sk_test_…> your test key (default: $PERLY_PAY_SECRET_KEY, or asks)
|
|
28
|
+
--webhook-url <url> register this URL for invoice.fulfilled and save its secret
|
|
29
|
+
--product <name> what you sell (default "${DEFAULT_OFFER.product}")
|
|
30
|
+
--amount <cents> its price in cents (default ${DEFAULT_OFFER.amount} = $20.00)
|
|
31
|
+
--currency <USD|EUR> default ${DEFAULT_OFFER.currency}
|
|
32
|
+
--install also install the SDK (otherwise prints the command)
|
|
33
|
+
--force replace files and values that are already there
|
|
34
|
+
--plugin print how to add the Claude Code plugin
|
|
35
|
+
--mcp print the MCP server config (${"perly-pay-mcp"})
|
|
36
|
+
--api <origin> Perly Pay's address (default ${DEFAULT_API})
|
|
37
|
+
--dir <path> the project (default: here)
|
|
38
|
+
--dry-run say what would happen, write nothing
|
|
39
|
+
--json print the result as JSON
|
|
40
|
+
-h, --help this
|
|
41
|
+
-v, --version the version
|
|
42
|
+
|
|
43
|
+
Docs: https://pay.perly.io/docs/quick-start-npx`;
|
|
44
|
+
export async function main({ argv, cwd, io }) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = parseArgs({
|
|
48
|
+
args: argv,
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
strict: true,
|
|
51
|
+
options: {
|
|
52
|
+
yes: { type: "boolean", short: "y" },
|
|
53
|
+
framework: { type: "string" },
|
|
54
|
+
key: { type: "string" },
|
|
55
|
+
"webhook-url": { type: "string" },
|
|
56
|
+
product: { type: "string" },
|
|
57
|
+
amount: { type: "string" },
|
|
58
|
+
currency: { type: "string" },
|
|
59
|
+
install: { type: "boolean" },
|
|
60
|
+
force: { type: "boolean" },
|
|
61
|
+
plugin: { type: "boolean" },
|
|
62
|
+
mcp: { type: "boolean" },
|
|
63
|
+
api: { type: "string" },
|
|
64
|
+
dir: { type: "string" },
|
|
65
|
+
"dry-run": { type: "boolean" },
|
|
66
|
+
json: { type: "boolean" },
|
|
67
|
+
help: { type: "boolean", short: "h" },
|
|
68
|
+
version: { type: "boolean", short: "v" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
io.print(`perly-pay: ${error.message}\n\n${HELP}`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
const { values: v, positionals } = parsed;
|
|
77
|
+
if (v.version) {
|
|
78
|
+
io.print(SDK_VERSION);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const command = positionals[0];
|
|
82
|
+
if (v.help || !command || command === "help") {
|
|
83
|
+
io.print(HELP);
|
|
84
|
+
return command || v.help ? 0 : 2;
|
|
85
|
+
}
|
|
86
|
+
if (command !== "init") {
|
|
87
|
+
io.print(`perly-pay: no command "${command}". Try: npx perly-pay init`);
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
const framework = v.framework === "nextjs" ? "next" : v.framework;
|
|
91
|
+
if (framework !== undefined && !FRAMEWORKS.includes(framework)) {
|
|
92
|
+
io.print(`perly-pay: --framework is one of ${FRAMEWORKS.join(", ")}.`);
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
const offer = { ...DEFAULT_OFFER };
|
|
96
|
+
if (v.product !== undefined) {
|
|
97
|
+
const product = v.product.trim();
|
|
98
|
+
if (!product || product.length > 300) {
|
|
99
|
+
io.print("perly-pay: --product is 1 to 300 characters.");
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
offer.product = product;
|
|
103
|
+
}
|
|
104
|
+
if (v.amount !== undefined) {
|
|
105
|
+
if (!/^[1-9]\d{0,9}$/.test(v.amount)) {
|
|
106
|
+
io.print("perly-pay: --amount is a whole number of cents: 2000 is $20.00.");
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
offer.amount = Number(v.amount);
|
|
110
|
+
}
|
|
111
|
+
if (v.currency !== undefined) {
|
|
112
|
+
const currency = v.currency.toUpperCase();
|
|
113
|
+
if (currency !== "USD" && currency !== "EUR") {
|
|
114
|
+
io.print("perly-pay: --currency is USD or EUR.");
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
offer.currency = currency;
|
|
118
|
+
}
|
|
119
|
+
const result = await init({
|
|
120
|
+
dir: resolve(cwd, v.dir ?? "."),
|
|
121
|
+
framework: framework,
|
|
122
|
+
key: v.key,
|
|
123
|
+
api: v.api,
|
|
124
|
+
yes: v.yes,
|
|
125
|
+
force: v.force,
|
|
126
|
+
install: v.install,
|
|
127
|
+
plugin: v.plugin,
|
|
128
|
+
mcp: v.mcp,
|
|
129
|
+
webhookUrl: v["webhook-url"],
|
|
130
|
+
offer,
|
|
131
|
+
dryRun: v["dry-run"],
|
|
132
|
+
json: v.json,
|
|
133
|
+
}, io);
|
|
134
|
+
return result.ok ? 0 : 1;
|
|
135
|
+
}
|
|
136
|
+
/** Opens a URL in the default browser; quietly does nothing where there is none. */
|
|
137
|
+
function openUrl(url) {
|
|
138
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
|
|
139
|
+
try {
|
|
140
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
141
|
+
child.on("error", () => { });
|
|
142
|
+
child.unref();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// No browser: the URL is printed above.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function nodeIO() {
|
|
149
|
+
return {
|
|
150
|
+
env: process.env,
|
|
151
|
+
interactive: !!process.stdin.isTTY && !!process.stdout.isTTY,
|
|
152
|
+
print: (text) => process.stdout.write(`${text}\n`),
|
|
153
|
+
async ask(question) {
|
|
154
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
155
|
+
try {
|
|
156
|
+
return await rl.question(question);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
rl.close();
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
openUrl,
|
|
163
|
+
run(command, cwd) {
|
|
164
|
+
return spawnSync(command, { cwd, shell: true, stdio: "inherit" }).status ?? 1;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|