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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,209 @@
|
|
|
1
|
-
#
|
|
1
|
+
# perly-pay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Crypto payments straight to your wallet — bitcoin and dollar stablecoins,
|
|
4
|
+
0% of volume. The server SDK for the [Perly Pay API](https://pay.perly.io/docs/api).
|
|
5
|
+
|
|
6
|
+
- TypeScript, **zero dependencies**, plain `fetch` (Node 18+, Deno, Bun, workers, edge)
|
|
7
|
+
- every POST carries an **Idempotency-Key**, reused across retries
|
|
8
|
+
- retries on 429 / 5xx / network errors with backoff, honouring `Retry-After`
|
|
9
|
+
- `webhooks.verify` checks `Perly-Signature` in constant time, synchronously
|
|
10
|
+
|
|
11
|
+
## Five minutes
|
|
12
|
+
|
|
13
|
+
**1. Install and make a client** with your secret key (Dashboard → Developers).
|
|
14
|
+
A `sk_test_…` key works in test mode: test networks, nothing real moves.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install perly-pay
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { PerlyPay } from "perly-pay";
|
|
22
|
+
|
|
23
|
+
const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY!);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**2. Sell something.**
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const product = await pay.products.create({ name: "Pro plan" });
|
|
30
|
+
const price = await pay.prices.create({
|
|
31
|
+
product: product.id,
|
|
32
|
+
model: "one_time",
|
|
33
|
+
unit_amount: 4900, // $49.00 — always integers, in cents
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**3. Take a payment.** Make a checkout session and send the buyer to its `url`
|
|
38
|
+
(or open it over your page with [pay.js](#on-your-page)).
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const session = await pay.checkoutSessions.create({
|
|
42
|
+
line_items: [{ price: price.id, quantity: 1 }],
|
|
43
|
+
return_url: "https://shop.example/thanks",
|
|
44
|
+
});
|
|
45
|
+
redirect(session.url);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The buyer picks bitcoin or USDC/USDT, pays from any wallet, and the money lands
|
|
49
|
+
in **your** wallet. Perly Pay never holds it.
|
|
50
|
+
|
|
51
|
+
**4. Hear about it.** Add an endpoint once and keep its secret:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const endpoint = await pay.webhookEndpoints.create({
|
|
55
|
+
url: "https://shop.example/webhooks/perly",
|
|
56
|
+
enabled_events: ["invoice.paid", "invoice.fulfilled", "refund.confirmed"],
|
|
57
|
+
});
|
|
58
|
+
// endpoint.secret — "whsec_…", shown only now
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then verify every delivery on the **raw** body:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Next.js route handler
|
|
65
|
+
export async function POST(request: Request) {
|
|
66
|
+
const raw = await request.text();
|
|
67
|
+
const event = pay.webhooks.verify(raw, request.headers.get("perly-signature"), process.env.PERLY_PAY_WEBHOOK_SECRET!);
|
|
68
|
+
if (event.type === "invoice.fulfilled") await grantAccess(event.object_id);
|
|
69
|
+
return new Response(null, { status: 204 });
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A signature older than five minutes is refused (replays), and any change to the body breaks it.
|
|
74
|
+
With Express, take the raw bytes: `express.raw({ type: "application/json" })`, then `pay.webhooks.verify(req.body, req.header("Perly-Signature"), secret)`.
|
|
75
|
+
|
|
76
|
+
**5. Try it without a wallet** (test keys only):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const invoice = await pay.invoices.create({ line_items: [{ price: price.id }], customer_email: "ada@example.com" });
|
|
80
|
+
await pay.invoices.simulate(invoice.id); // paid, fulfilled, receipt, webhooks ({ outcome: "short" | "over" } too)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## More
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// Does this customer have access right now?
|
|
87
|
+
const { has_access } = await pay.entitlements.check({ customer_email: "ada@example.com", product: product.id });
|
|
88
|
+
|
|
89
|
+
// Subscriptions renew by invoice — no auto-debit.
|
|
90
|
+
const sub = await pay.subscriptions.create({ customer: "cus_…", price: "price_…" });
|
|
91
|
+
await pay.subscriptions.update(sub.id, { cancel_at_period_end: true });
|
|
92
|
+
|
|
93
|
+
// Refunds are non-custodial: the buyer gives an address, you send from your wallet.
|
|
94
|
+
const quote = await pay.refunds.quote({ invoice: invoice.id });
|
|
95
|
+
const refund = await pay.refunds.create({ invoice: invoice.id });
|
|
96
|
+
// …when refund.status is "ready_to_send", send refund.amount_to_send to refund.refund_address, then:
|
|
97
|
+
await pay.refunds.markSent(refund.id, { txid: "0x…" });
|
|
98
|
+
|
|
99
|
+
// Every page of a list
|
|
100
|
+
for await (const inv of pay.invoices.listAll({ status: "paid", limit: 100 })) console.log(inv.id);
|
|
101
|
+
|
|
102
|
+
// A receipt as PDF
|
|
103
|
+
const pdf = await pay.receipts.pdf("rcpt_…");
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Credits
|
|
107
|
+
|
|
108
|
+
Sell credits for an API or an AI service: the buyer tops up on their own page and pays — never an auto-debit — and your server spends them a call at a time. The customer is a `cus_…` id, an email, or your own id for them (`external_id`).
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const { balance, low, top_up_url } = await pay.credits.balance("user_42");
|
|
112
|
+
|
|
113
|
+
// Never below zero; a retry with the same key never spends twice.
|
|
114
|
+
try {
|
|
115
|
+
await pay.credits.consume("user_42", 3, { action: "chat", idempotencyKey: requestId });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e instanceof InvalidRequestError && e.code === "insufficient_credits") return sendTopUp(await pay.credits.topUpUrl("user_42"));
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Webhooks `credits.low` (the product's threshold, set in the dashboard) and `credits.depleted` arrive once per crossing.
|
|
123
|
+
|
|
124
|
+
### License keys from your app
|
|
125
|
+
|
|
126
|
+
Your app never holds a secret key. `LicenseClient` calls the public license endpoints with the buyer's key alone:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { LicenseClient } from "perly-pay";
|
|
130
|
+
|
|
131
|
+
const licenses = new LicenseClient();
|
|
132
|
+
const result = await licenses.activate({ key: userKey, product: "prod_…", instance_name: os.hostname() });
|
|
133
|
+
if (!result.valid) showWhy(result.reason); // "limit_reached", "revoked", "expired", …
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
Every failure throws a `PerlyPayError` (`InvalidRequestError`, `AuthenticationError`,
|
|
139
|
+
`PermissionError`, `RateLimitError`, `IdempotencyError`, `APIError`, `ConnectionError`) carrying the
|
|
140
|
+
API's `type`, `code`, `message`, `param`, the HTTP `status` and the `requestId` to quote to support.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
try {
|
|
144
|
+
await pay.invoices.create({ line_items: [] });
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e instanceof InvalidRequestError) console.log(e.code, e.param); // "parameter_invalid", "line_items"
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Amounts
|
|
151
|
+
|
|
152
|
+
Fiat amounts are integers in minor units (`4900` = $49.00). On-chain amounts are integer **strings** in the
|
|
153
|
+
asset's smallest unit (`"10000000"` USDC = 10.00 USDC; satoshis for bitcoin) — use `BigInt`, never floats.
|
|
154
|
+
|
|
155
|
+
## Options
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
new PerlyPay(key, {
|
|
159
|
+
baseUrl: "https://pay.perly.io", // or http://localhost:3200 against a local stack
|
|
160
|
+
maxRetries: 2,
|
|
161
|
+
timeoutMs: 30_000,
|
|
162
|
+
fetch: customFetch,
|
|
163
|
+
});
|
|
164
|
+
await pay.invoices.create(params, { idempotencyKey: `order-${orderId}` });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Testing your webhook handler
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { webhooks } from "perly-pay";
|
|
171
|
+
const { body, headers } = webhooks.testRequest({ id: "evt_1", type: "invoice.paid", /* … */ }, secret);
|
|
172
|
+
await handler(new Request("http://localhost/webhooks", { method: "POST", body, headers }));
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## On your page
|
|
176
|
+
|
|
177
|
+
pay.js opens the hosted checkout over your page. Load it pinned, with its
|
|
178
|
+
`integrity`, so a changed file never runs on your page:
|
|
179
|
+
|
|
180
|
+
<!-- pay.js snippet: generated by packages/embed/build.mjs -->
|
|
181
|
+
```html
|
|
182
|
+
<script src="https://pay.perly.io/pay/v1.999bed6b66c7.js"
|
|
183
|
+
integrity="sha384-mZvta2bHoZN4yW6xg4DvV5Jb9jS7TPg+Pq1Upm5VFEOFdgubm8MmsAYgVHNNW45N"
|
|
184
|
+
crossorigin="anonymous" defer></script>
|
|
185
|
+
```
|
|
186
|
+
<!-- /pay.js snippet -->
|
|
187
|
+
|
|
188
|
+
```html
|
|
189
|
+
<button data-perly-pay data-session="cs_…">Pay</button>
|
|
190
|
+
<script>
|
|
191
|
+
addEventListener("perlypay:paid", (e) => location.assign("/thanks?invoice=" + e.detail.invoice));
|
|
192
|
+
</script>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Add your site's origin under Dashboard → Settings → Checkout so the checkout may be framed there.
|
|
196
|
+
|
|
197
|
+
## Links
|
|
198
|
+
|
|
199
|
+
- Docs: <https://pay.perly.io/docs> · API reference: <https://pay.perly.io/docs/api> · SDKs: <https://pay.perly.io/docs/sdks>
|
|
200
|
+
- OpenAPI: <https://pay.perly.io/api/v1/openapi.json>
|
|
201
|
+
- PHP: [`perly/perly-pay`](https://pay.perly.io/docs/sdks) on Composer, the same calls and the same webhook check
|
|
202
|
+
|
|
203
|
+
## Build
|
|
204
|
+
|
|
205
|
+
`npm run build` (in `packages/sdk`) writes ESM + `.d.ts` to `dist/`. Node 18.17+.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT. The SDK is open source; the Perly Pay service it talks to is not.
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** `npx perly-pay …` lands here. */
|
|
3
|
+
import { main, nodeIO } from "./main.js";
|
|
4
|
+
main({ argv: process.argv.slice(2), cwd: process.cwd(), io: nodeIO() }).then(
|
|
5
|
+
// exitCode, not exit(): on macOS a piped stdout is written asynchronously, and exit() would cut it short.
|
|
6
|
+
(code) => {
|
|
7
|
+
process.exitCode = code;
|
|
8
|
+
}, (error) => {
|
|
9
|
+
process.stderr.write(`perly-pay: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kind of project `npx perly-pay init` is standing in: the framework,
|
|
3
|
+
* the package manager, TypeScript or not, ES modules or CommonJS, and
|
|
4
|
+
* where the code and the keys go. Reads files only — never runs anything.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
export const FRAMEWORKS = ["next", "express", "hono", "laravel", "node"];
|
|
9
|
+
export const FRAMEWORK_NAMES = {
|
|
10
|
+
next: "Next.js (App Router)",
|
|
11
|
+
express: "Express",
|
|
12
|
+
hono: "Hono",
|
|
13
|
+
laravel: "Laravel",
|
|
14
|
+
node: "Node.js",
|
|
15
|
+
};
|
|
16
|
+
export class DetectError extends Error {
|
|
17
|
+
}
|
|
18
|
+
function readJson(path) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isDir(path) {
|
|
28
|
+
try {
|
|
29
|
+
return statSync(path).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function depsOf(pkg, ...fields) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const field of fields) {
|
|
38
|
+
const deps = pkg?.[field];
|
|
39
|
+
if (deps && typeof deps === "object")
|
|
40
|
+
Object.assign(out, deps);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** The framework the files say, or null when nothing here looks like a project. */
|
|
45
|
+
export function detectFramework(dir) {
|
|
46
|
+
const composer = readJson(join(dir, "composer.json"));
|
|
47
|
+
if (existsSync(join(dir, "artisan")) || "laravel/framework" in depsOf(composer, "require"))
|
|
48
|
+
return "laravel";
|
|
49
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
50
|
+
if (!pkg)
|
|
51
|
+
return null;
|
|
52
|
+
const deps = depsOf(pkg, "dependencies", "devDependencies");
|
|
53
|
+
if ("next" in deps)
|
|
54
|
+
return "next";
|
|
55
|
+
if ("hono" in deps)
|
|
56
|
+
return "hono";
|
|
57
|
+
if ("express" in deps)
|
|
58
|
+
return "express";
|
|
59
|
+
return "node";
|
|
60
|
+
}
|
|
61
|
+
export function detectPackageManager(dir, framework) {
|
|
62
|
+
if (framework === "laravel")
|
|
63
|
+
return "composer";
|
|
64
|
+
const declared = readJson(join(dir, "package.json"))?.packageManager;
|
|
65
|
+
if (typeof declared === "string") {
|
|
66
|
+
const name = declared.split("@")[0];
|
|
67
|
+
if (name === "pnpm" || name === "yarn" || name === "bun" || name === "npm")
|
|
68
|
+
return name;
|
|
69
|
+
}
|
|
70
|
+
if (existsSync(join(dir, "pnpm-lock.yaml")))
|
|
71
|
+
return "pnpm";
|
|
72
|
+
if (existsSync(join(dir, "yarn.lock")))
|
|
73
|
+
return "yarn";
|
|
74
|
+
if (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))
|
|
75
|
+
return "bun";
|
|
76
|
+
return "npm";
|
|
77
|
+
}
|
|
78
|
+
/** The tsconfig's `module`, lower-case, when it says one (comments and trailing commas tolerated). */
|
|
79
|
+
function tsModule(dir) {
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(join(dir, "tsconfig.json"), "utf8");
|
|
82
|
+
const match = /"module"\s*:\s*"([^"]+)"/i.exec(text);
|
|
83
|
+
return match?.[1]?.toLowerCase() ?? null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function detectProject(dir, forced) {
|
|
90
|
+
const found = detectFramework(dir);
|
|
91
|
+
const framework = forced ?? found;
|
|
92
|
+
if (!framework) {
|
|
93
|
+
throw new DetectError("No package.json or composer.json here. Run this in your project's folder, or pass --framework.");
|
|
94
|
+
}
|
|
95
|
+
const notes = [];
|
|
96
|
+
if (forced && found && forced !== found)
|
|
97
|
+
notes.push(`This looks like ${FRAMEWORK_NAMES[found]}; using ${FRAMEWORK_NAMES[forced]} as asked.`);
|
|
98
|
+
const packageManager = detectPackageManager(dir, framework);
|
|
99
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
100
|
+
const deps = depsOf(pkg, "dependencies", "devDependencies");
|
|
101
|
+
if (framework === "laravel") {
|
|
102
|
+
const composer = readJson(join(dir, "composer.json"));
|
|
103
|
+
return {
|
|
104
|
+
dir,
|
|
105
|
+
framework,
|
|
106
|
+
packageManager,
|
|
107
|
+
style: "esm",
|
|
108
|
+
base: "",
|
|
109
|
+
appDir: "",
|
|
110
|
+
envFile: ".env",
|
|
111
|
+
hasSdk: "perly/perly-pay" in depsOf(composer, "require"),
|
|
112
|
+
loadsEnv: true,
|
|
113
|
+
notes,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const typescript = existsSync(join(dir, "tsconfig.json")) || "typescript" in deps;
|
|
117
|
+
const style = typescript ? "ts" : framework === "next" || pkg?.type === "module" ? "esm" : "cjs";
|
|
118
|
+
const mod = typescript ? tsModule(dir) : null;
|
|
119
|
+
if (typescript && framework !== "next" && mod === "commonjs") {
|
|
120
|
+
notes.push('tsconfig compiles to CommonJS, and perly-pay is an ES module: set "module": "nodenext" (or run with tsx).');
|
|
121
|
+
}
|
|
122
|
+
let base = "";
|
|
123
|
+
let appDir = "";
|
|
124
|
+
if (framework === "next") {
|
|
125
|
+
if (isDir(join(dir, "src", "app")))
|
|
126
|
+
appDir = "src/app";
|
|
127
|
+
else if (isDir(join(dir, "app")))
|
|
128
|
+
appDir = "app";
|
|
129
|
+
else if (isDir(join(dir, "src")))
|
|
130
|
+
appDir = "src/app";
|
|
131
|
+
else
|
|
132
|
+
appDir = "app";
|
|
133
|
+
base = appDir.startsWith("src/") ? "src/" : "";
|
|
134
|
+
if (!isDir(join(dir, appDir)) && (isDir(join(dir, "pages")) || isDir(join(dir, "src", "pages")))) {
|
|
135
|
+
notes.push(`No App Router yet: the routes go in ${appDir}/, which Next.js serves beside pages/.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (isDir(join(dir, "src"))) {
|
|
139
|
+
base = "src/";
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
dir,
|
|
143
|
+
framework,
|
|
144
|
+
packageManager,
|
|
145
|
+
style,
|
|
146
|
+
base,
|
|
147
|
+
appDir,
|
|
148
|
+
envFile: framework === "next" ? ".env.local" : ".env",
|
|
149
|
+
hasSdk: "perly-pay" in deps,
|
|
150
|
+
// Next.js loads .env.local itself; elsewhere something must.
|
|
151
|
+
loadsEnv: framework === "next" || "dotenv" in deps || "@dotenvx/dotenvx" in deps,
|
|
152
|
+
notes,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function installCommand(project) {
|
|
156
|
+
switch (project.packageManager) {
|
|
157
|
+
case "composer":
|
|
158
|
+
return "composer require perly/perly-pay";
|
|
159
|
+
case "pnpm":
|
|
160
|
+
return "pnpm add perly-pay";
|
|
161
|
+
case "yarn":
|
|
162
|
+
return "yarn add perly-pay";
|
|
163
|
+
case "bun":
|
|
164
|
+
return "bun add perly-pay";
|
|
165
|
+
default:
|
|
166
|
+
return "npm install perly-pay";
|
|
167
|
+
}
|
|
168
|
+
}
|
package/dist/cli/env.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys file (`.env.local` / `.env`) and the `.gitignore` that keeps it
|
|
3
|
+
* out of git. Pure string work, so it is tested without a disk.
|
|
4
|
+
*/
|
|
5
|
+
export const KEY_VAR = "PERLY_PAY_SECRET_KEY";
|
|
6
|
+
export const WEBHOOK_SECRET_VAR = "PERLY_PAY_WEBHOOK_SECRET";
|
|
7
|
+
export const API_VAR = "PERLY_PAY_API";
|
|
8
|
+
export const DEFAULT_API = "https://pay.perly.io";
|
|
9
|
+
const line = (name) => new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=(.*)$`, "m");
|
|
10
|
+
/** A variable's value in a dotenv file ("" when set empty), or null when absent. Quotes and a trailing comment are dropped. */
|
|
11
|
+
export function readEnvValue(content, name) {
|
|
12
|
+
const match = line(name).exec(content);
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
let value = (match[1] ?? "").trim();
|
|
16
|
+
const quoted = /^(['"])(.*)\1$/.exec(value);
|
|
17
|
+
if (quoted)
|
|
18
|
+
return quoted[2] ?? "";
|
|
19
|
+
if (value.startsWith("#"))
|
|
20
|
+
return "";
|
|
21
|
+
value = value.replace(/\s+#.*$/, "");
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Adds or fills each variable. A variable that already has a value is kept
|
|
26
|
+
* unless `force` (or its own `replace`) says otherwise; an empty one is filled.
|
|
27
|
+
*/
|
|
28
|
+
export function upsertEnv(content, entries, force = false) {
|
|
29
|
+
let out = content;
|
|
30
|
+
const outcomes = {};
|
|
31
|
+
const added = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const current = readEnvValue(out, entry.name);
|
|
34
|
+
if (current === null) {
|
|
35
|
+
if (entry.comment)
|
|
36
|
+
added.push(`# ${entry.comment}`);
|
|
37
|
+
added.push(`${entry.name}=${entry.value}`);
|
|
38
|
+
outcomes[entry.name] = "added";
|
|
39
|
+
}
|
|
40
|
+
else if (current === entry.value) {
|
|
41
|
+
outcomes[entry.name] = "unchanged";
|
|
42
|
+
}
|
|
43
|
+
else if (current === "" || force || entry.replace) {
|
|
44
|
+
out = out.replace(line(entry.name), () => `${entry.name}=${entry.value}`);
|
|
45
|
+
outcomes[entry.name] = "replaced";
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
outcomes[entry.name] = "kept";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (added.length) {
|
|
52
|
+
const sep = out === "" ? "" : out.endsWith("\n") ? "\n" : "\n\n";
|
|
53
|
+
out = `${out}${sep}# Perly Pay (npx perly-pay init). Keep this file out of git.\n${added.join("\n")}\n`;
|
|
54
|
+
}
|
|
55
|
+
return { content: out, outcomes };
|
|
56
|
+
}
|
|
57
|
+
function globToRegex(glob) {
|
|
58
|
+
let re = "";
|
|
59
|
+
for (let i = 0; i < glob.length; i++) {
|
|
60
|
+
const c = glob[i];
|
|
61
|
+
if (c === "*") {
|
|
62
|
+
if (glob[i + 1] === "*") {
|
|
63
|
+
re += ".*";
|
|
64
|
+
i++;
|
|
65
|
+
if (glob[i + 1] === "/")
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
re += "[^/]*";
|
|
70
|
+
}
|
|
71
|
+
else if (c === "?")
|
|
72
|
+
re += "[^/]";
|
|
73
|
+
else if (c === "[") {
|
|
74
|
+
const end = glob.indexOf("]", i + 1);
|
|
75
|
+
if (end > i) {
|
|
76
|
+
re += glob.slice(i, end + 1).replace(/^\[!/, "[^");
|
|
77
|
+
i = end;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
re += "\\[";
|
|
81
|
+
}
|
|
82
|
+
else
|
|
83
|
+
re += c.replace(/[.+^${}()|\\]/g, "\\$&");
|
|
84
|
+
}
|
|
85
|
+
return re;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether a root `.gitignore` ignores the file at `path` (relative, "/"-separated).
|
|
89
|
+
* The common subset of git's rules: globs, `**`, a leading `/` anchors, a
|
|
90
|
+
* trailing `/` matches folders, `!` re-includes, the last match wins.
|
|
91
|
+
*/
|
|
92
|
+
export function gitignoreIgnores(gitignore, path) {
|
|
93
|
+
const parts = path.split("/");
|
|
94
|
+
let ignored = false;
|
|
95
|
+
for (const raw of gitignore.split(/\r?\n/)) {
|
|
96
|
+
let pattern = raw.replace(/(?<!\\)\s+$/, "");
|
|
97
|
+
if (!pattern || pattern.startsWith("#"))
|
|
98
|
+
continue;
|
|
99
|
+
const negate = pattern.startsWith("!");
|
|
100
|
+
if (negate)
|
|
101
|
+
pattern = pattern.slice(1);
|
|
102
|
+
const dirOnly = pattern.endsWith("/");
|
|
103
|
+
if (dirOnly)
|
|
104
|
+
pattern = pattern.slice(0, -1);
|
|
105
|
+
const anchored = pattern.includes("/");
|
|
106
|
+
pattern = pattern.replace(/^\//, "");
|
|
107
|
+
const re = new RegExp(`^${globToRegex(pattern)}$`);
|
|
108
|
+
let hit = false;
|
|
109
|
+
if (anchored) {
|
|
110
|
+
// The pattern names a path from the root: the file itself, or a folder above it.
|
|
111
|
+
for (let n = dirOnly ? parts.length - 1 : parts.length; n >= 1 && !hit; n--)
|
|
112
|
+
hit = re.test(parts.slice(0, n).join("/"));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// A bare name matches at any depth: the file's name, or any folder above it.
|
|
116
|
+
const names = dirOnly ? parts.slice(0, -1) : parts;
|
|
117
|
+
hit = names.some((name) => re.test(name));
|
|
118
|
+
}
|
|
119
|
+
if (hit)
|
|
120
|
+
ignored = !negate;
|
|
121
|
+
}
|
|
122
|
+
return ignored;
|
|
123
|
+
}
|
|
124
|
+
/** `.gitignore` with the keys file added when it is not ignored yet; null when it already is. */
|
|
125
|
+
export function ensureIgnored(gitignore, path) {
|
|
126
|
+
const current = gitignore ?? "";
|
|
127
|
+
if (gitignoreIgnores(current, path))
|
|
128
|
+
return null;
|
|
129
|
+
const sep = current === "" ? "" : current.endsWith("\n") ? "\n" : "\n\n";
|
|
130
|
+
return `${current}${sep}# Perly Pay keys (npx perly-pay init)\n${path}\n`;
|
|
131
|
+
}
|
|
132
|
+
/** `sk_test_kest…`: enough to recognise a key, never the key. */
|
|
133
|
+
export function maskKey(key) {
|
|
134
|
+
return key.length > 12 ? `${key.slice(0, 12)}…` : `${key.slice(0, 8)}…`;
|
|
135
|
+
}
|
|
136
|
+
/** init takes test keys only: a live key moves real money and belongs in the dashboard's go-live step. */
|
|
137
|
+
export function checkTestKey(raw) {
|
|
138
|
+
const key = (raw ?? "").trim();
|
|
139
|
+
if (!key)
|
|
140
|
+
return { ok: false, reason: "empty" };
|
|
141
|
+
if (key.startsWith("sk_live_"))
|
|
142
|
+
return { ok: false, reason: "live" };
|
|
143
|
+
if (!/^sk_test_[0-9A-Za-z]{32}$/.test(key))
|
|
144
|
+
return { ok: false, reason: "malformed" };
|
|
145
|
+
return { ok: true, key };
|
|
146
|
+
}
|