create-solvapay 0.7.2 → 0.7.3
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/package.json +2 -2
- package/scripts/mcp/lib/openapi.mjs +90 -17
- package/scripts/mcp/lib/openapi.test.mjs +63 -0
- package/scripts/mcp/package-lock.json +15 -4
- package/scripts/mcp/package.json +2 -1
- package/scripts/mcp/scaffold.mjs +20 -5
- package/templates/mcp/_base/.env.example +1 -1
- package/templates/mcp/_base/README.md +2 -2
- package/templates/mcp/_base/package.json +0 -1
- package/templates/mcp/_base/scripts/deploy.mjs +102 -62
- package/templates/mcp/_base/scripts/deploy.test.mjs +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-solvapay",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Scaffold SolvaPay-monetized projects — MCP servers on Cloudflare Workers and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"node": ">=18.17"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@solvapay/init": "^0.4.
|
|
28
|
+
"@solvapay/init": "^0.4.6"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsup": "^8.5.1",
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
* - buildAdvisories(operations, schemes) -> Advisory[]
|
|
16
16
|
* - buildSpecShapeAdvisories({ servers, operations, schemes }) -> Advisory[]
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Non-stdlib dependencies live in `scripts/mcp/package.json`
|
|
19
|
+
* (`@apidevtools/swagger-parser`, `js-yaml`).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { access } from 'node:fs/promises'
|
|
23
|
-
import { resolve } from 'node:path'
|
|
22
|
+
import { access, readFile } from 'node:fs/promises'
|
|
23
|
+
import { dirname, resolve } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import yaml from 'js-yaml'
|
|
24
26
|
|
|
25
27
|
const SUPPORTED_AUTH_KINDS = new Set([
|
|
26
28
|
'http-bearer',
|
|
@@ -31,6 +33,16 @@ const SUPPORTED_AUTH_KINDS = new Set([
|
|
|
31
33
|
|
|
32
34
|
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
33
35
|
|
|
36
|
+
const OPENAPI_31_PATCH = /^3\.1\.\d+$/
|
|
37
|
+
|
|
38
|
+
export function swaggerParserInstallHint() {
|
|
39
|
+
const scriptsDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
40
|
+
return (
|
|
41
|
+
'`@apidevtools/swagger-parser` is not installed. Run `npm install` inside ' +
|
|
42
|
+
`${scriptsDir} (one-time setup), then re-run.`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* Load + parse an OpenAPI document and run `$ref` resolution through
|
|
36
48
|
* swagger-parser's `dereference`. Accepts a local JSON/YAML path or an
|
|
@@ -41,10 +53,20 @@ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
|
41
53
|
*/
|
|
42
54
|
export async function loadSpec(specPath) {
|
|
43
55
|
const SwaggerParser = await loadSwaggerParser()
|
|
56
|
+
const document = normaliseOpenApiPatch(await readSpecDocument(specPath))
|
|
57
|
+
try {
|
|
58
|
+
const spec = await SwaggerParser.dereference(document)
|
|
59
|
+
return { spec }
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw wrapSpecLoadError(err, specPath)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readSpecDocument(specPath) {
|
|
44
66
|
if (/^https?:\/\//i.test(specPath)) {
|
|
67
|
+
let response
|
|
45
68
|
try {
|
|
46
|
-
|
|
47
|
-
return { spec }
|
|
69
|
+
response = await fetch(specPath)
|
|
48
70
|
} catch (err) {
|
|
49
71
|
const message = err instanceof Error ? err.message : String(err)
|
|
50
72
|
throw new Error(
|
|
@@ -52,6 +74,13 @@ export async function loadSpec(specPath) {
|
|
|
52
74
|
'Point at the raw JSON/YAML document (e.g. .../swagger.json or .../openapi.yaml), not a docs/Swagger UI page.',
|
|
53
75
|
)
|
|
54
76
|
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Could not load OpenAPI spec from ${specPath}: HTTP ${response.status}. ` +
|
|
80
|
+
'Point at the raw JSON/YAML document (e.g. .../swagger.json or .../openapi.yaml), not a docs/Swagger UI page.',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return parseSpecText(await response.text(), specPath)
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
const absolute = resolve(specPath)
|
|
@@ -63,16 +92,63 @@ export async function loadSpec(specPath) {
|
|
|
63
92
|
}
|
|
64
93
|
throw err
|
|
65
94
|
}
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
return parseSpecText(await readFile(absolute, 'utf8'), absolute)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseSpecText(text, source) {
|
|
99
|
+
const trimmed = text.trim()
|
|
100
|
+
try {
|
|
101
|
+
if (trimmed.startsWith('{')) return JSON.parse(text)
|
|
102
|
+
const document = yaml.load(text)
|
|
103
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
104
|
+
throw new Error('document is not an object')
|
|
105
|
+
}
|
|
106
|
+
return document
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Could not load OpenAPI spec from ${source}: ${message}. ` +
|
|
111
|
+
'Point at the raw JSON/YAML document (e.g. .../swagger.json or .../openapi.yaml), not a docs/Swagger UI page.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normaliseOpenApiPatch(spec) {
|
|
117
|
+
if (
|
|
118
|
+
typeof spec?.openapi === 'string' &&
|
|
119
|
+
OPENAPI_31_PATCH.test(spec.openapi) &&
|
|
120
|
+
spec.openapi !== '3.1.0' &&
|
|
121
|
+
spec.openapi !== '3.1.1'
|
|
122
|
+
) {
|
|
123
|
+
console.log('OpenAPI 3.1.x patch version normalised to 3.1.1 for parsing.')
|
|
124
|
+
spec.openapi = '3.1.1'
|
|
125
|
+
}
|
|
126
|
+
return spec
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function wrapSpecLoadError(err, specPath) {
|
|
130
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
131
|
+
if (message.includes('Unsupported OpenAPI version')) {
|
|
132
|
+
return new Error(
|
|
133
|
+
`Could not parse OpenAPI spec from ${specPath}: ${message} ` +
|
|
134
|
+
'OpenAPI 3.1.x patch versions are rewritten to 3.1.1 before parsing. ' +
|
|
135
|
+
'For any other unsupported version, set the openapi field to 3.1.1 in a local copy of the spec.',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (/^https?:\/\//i.test(specPath)) {
|
|
139
|
+
return new Error(
|
|
140
|
+
`Could not load OpenAPI spec from ${specPath}: ${message}. ` +
|
|
141
|
+
'Point at the raw JSON/YAML document (e.g. .../swagger.json or .../openapi.yaml), not a docs/Swagger UI page.',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
return err instanceof Error ? err : new Error(message)
|
|
68
145
|
}
|
|
69
146
|
|
|
70
147
|
/**
|
|
71
|
-
* Lazy-load `@apidevtools/swagger-parser`.
|
|
72
|
-
*
|
|
73
|
-
* `
|
|
74
|
-
*
|
|
75
|
-
* here exists for the case where someone skipped that step.
|
|
148
|
+
* Lazy-load `@apidevtools/swagger-parser`. `scripts/mcp/package.json`
|
|
149
|
+
* declares it; the user runs `npm install` inside `scripts/mcp` once
|
|
150
|
+
* before invoking `describe.mjs`, `scaffold.mjs`, or `test.mjs`. The
|
|
151
|
+
* error message here exists for the case where someone skipped that step.
|
|
76
152
|
*/
|
|
77
153
|
async function loadSwaggerParser() {
|
|
78
154
|
try {
|
|
@@ -80,10 +156,7 @@ async function loadSwaggerParser() {
|
|
|
80
156
|
return mod.default ?? mod
|
|
81
157
|
} catch (err) {
|
|
82
158
|
if (err && typeof err === 'object' && err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
83
|
-
throw new Error(
|
|
84
|
-
'`@apidevtools/swagger-parser` is not installed. Run `npm install` inside the ' +
|
|
85
|
-
'`scripts/` directory of this skill (one-time setup), then re-run.',
|
|
86
|
-
)
|
|
159
|
+
throw new Error(swaggerParserInstallHint())
|
|
87
160
|
}
|
|
88
161
|
throw err
|
|
89
162
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { loadSpec, swaggerParserInstallHint } from './openapi.mjs'
|
|
6
|
+
|
|
7
|
+
const tempDirs = []
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks()
|
|
11
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function writeSpec(document) {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), 'openapi-spec-'))
|
|
16
|
+
tempDirs.push(dir)
|
|
17
|
+
const specPath = join(dir, 'openapi.json')
|
|
18
|
+
writeFileSync(specPath, JSON.stringify(document))
|
|
19
|
+
return specPath
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const minimalSpec = version => ({
|
|
23
|
+
openapi: version,
|
|
24
|
+
info: { title: 'trial', version: '1.0.0' },
|
|
25
|
+
paths: {},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('loadSpec OpenAPI version', () => {
|
|
29
|
+
it('normalises an OpenAPI 3.1.x patch version to 3.1.1 before parsing', async () => {
|
|
30
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
31
|
+
const specPath = writeSpec(minimalSpec('3.1.2'))
|
|
32
|
+
|
|
33
|
+
const { spec } = await loadSpec(specPath)
|
|
34
|
+
|
|
35
|
+
expect(spec.openapi).toBe('3.1.1')
|
|
36
|
+
expect(log).toHaveBeenCalledWith('OpenAPI 3.1.x patch version normalised to 3.1.1 for parsing.')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('leaves OpenAPI 3.1.1 unchanged and does not print the normalisation line', async () => {
|
|
40
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
41
|
+
const specPath = writeSpec(minimalSpec('3.1.1'))
|
|
42
|
+
|
|
43
|
+
const { spec } = await loadSpec(specPath)
|
|
44
|
+
|
|
45
|
+
expect(spec.openapi).toBe('3.1.1')
|
|
46
|
+
expect(log).not.toHaveBeenCalledWith(
|
|
47
|
+
'OpenAPI 3.1.x patch version normalised to 3.1.1 for parsing.',
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('wraps an unsupported OpenAPI version in a readable error', async () => {
|
|
52
|
+
const specPath = writeSpec(minimalSpec('3.2.0'))
|
|
53
|
+
|
|
54
|
+
await expect(loadSpec(specPath)).rejects.toThrow(/Could not parse OpenAPI spec/)
|
|
55
|
+
await expect(loadSpec(specPath)).rejects.toThrow(/Unsupported OpenAPI version: 3.2.0/)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('swaggerParserInstallHint', () => {
|
|
60
|
+
it('names the scripts/mcp directory that owns the swagger-parser dependency', () => {
|
|
61
|
+
expect(swaggerParserInstallHint()).toMatch(/Run `npm install` inside .+\/scripts\/mcp\b/)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"name": "@solvapay-skills/create-solvapay-mcp",
|
|
9
9
|
"version": "0.0.0",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@apidevtools/swagger-parser": "^10.1.0"
|
|
11
|
+
"@apidevtools/swagger-parser": "^10.1.0",
|
|
12
|
+
"js-yaml": "^4.3.2"
|
|
12
13
|
}
|
|
13
14
|
},
|
|
14
15
|
"node_modules/@apidevtools/json-schema-ref-parser": {
|
|
@@ -138,9 +139,19 @@
|
|
|
138
139
|
"license": "BSD-3-Clause"
|
|
139
140
|
},
|
|
140
141
|
"node_modules/js-yaml": {
|
|
141
|
-
"version": "4.
|
|
142
|
-
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.
|
|
143
|
-
"integrity": "sha512-
|
|
142
|
+
"version": "4.3.2",
|
|
143
|
+
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
|
144
|
+
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
|
145
|
+
"funding": [
|
|
146
|
+
{
|
|
147
|
+
"type": "github",
|
|
148
|
+
"url": "https://github.com/sponsors/puzrin"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"type": "github",
|
|
152
|
+
"url": "https://github.com/sponsors/nodeca"
|
|
153
|
+
}
|
|
154
|
+
],
|
|
144
155
|
"license": "MIT",
|
|
145
156
|
"dependencies": {
|
|
146
157
|
"argparse": "^2.0.1"
|
package/scripts/mcp/package.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Internal runtime deps for create-solvapay MCP scripts. Run `npm install` once inside this directory before invoking describe.mjs / scaffold.mjs / test.mjs.",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@apidevtools/swagger-parser": "^10.1.0"
|
|
8
|
+
"@apidevtools/swagger-parser": "^10.1.0",
|
|
9
|
+
"js-yaml": "^4.3.2"
|
|
9
10
|
}
|
|
10
11
|
}
|
package/scripts/mcp/scaffold.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
30
31
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
31
32
|
import { resolve, dirname, relative, isAbsolute, join } from 'node:path'
|
|
32
33
|
import { fileURLToPath } from 'node:url'
|
|
@@ -146,6 +147,7 @@ async function main() {
|
|
|
146
147
|
await writeIndexFile(target, toolFiles, selections.upstreamAuth.kind, mode)
|
|
147
148
|
await ensureGitignoreCoversEnv(target)
|
|
148
149
|
const envWritten = await writeDotEnv(target, selections)
|
|
150
|
+
installGeneratedScriptDeps(target)
|
|
149
151
|
|
|
150
152
|
const planReminders = Array.isArray(selections.plans)
|
|
151
153
|
? collectPlanSelectionReminders(selections.plans)
|
|
@@ -157,12 +159,12 @@ async function main() {
|
|
|
157
159
|
...(mode === 'intent-driven'
|
|
158
160
|
? [
|
|
159
161
|
'Intent-driven mode: author src/tools/*.ts files per intent-driven.md, then update src/tools/index.ts to import and call each register{IntentName}(ctx, env). The .env and project skeleton are ready.',
|
|
160
|
-
`Run \`npx solvapay init\` inside ${target} to populate SOLVAPAY_SECRET_KEY (see the generated README).`,
|
|
162
|
+
`Run \`npx -y solvapay@latest init\` inside ${target} to populate SOLVAPAY_SECRET_KEY (see the generated README).`,
|
|
161
163
|
`\`node scripts/verify.mjs <url>\` runs from ${target} with no extra setup. \`node scripts/test.mjs\` will report intent tools as skipped (they aren't in the spec's operationIds) — exercise them manually per intent-driven.md.`,
|
|
162
164
|
]
|
|
163
165
|
: [
|
|
164
|
-
`Run \`npx solvapay init\` inside ${target} to populate SOLVAPAY_SECRET_KEY (see the generated README).`,
|
|
165
|
-
`\`node scripts/verify.mjs <url>\`
|
|
166
|
+
`Run \`npx -y solvapay@latest init\` inside ${target} to populate SOLVAPAY_SECRET_KEY (see the generated README).`,
|
|
167
|
+
`\`node scripts/verify.mjs <url>\` and \`node scripts/test.mjs\` run from ${target}. Scaffold already ran \`npm install\` inside ${target}/scripts.`,
|
|
166
168
|
]),
|
|
167
169
|
]
|
|
168
170
|
|
|
@@ -974,7 +976,7 @@ async function writeDotEnv(target, selections) {
|
|
|
974
976
|
: PLACEHOLDERS.PRODUCT_REF
|
|
975
977
|
const lines = [
|
|
976
978
|
'# Generated by create-solvapay scaffold.',
|
|
977
|
-
'# SOLVAPAY_SECRET_KEY is populated by `npx solvapay init` (see the generated README).',
|
|
979
|
+
'# SOLVAPAY_SECRET_KEY is populated by `npx -y solvapay@latest init` (see the generated README).',
|
|
978
980
|
`SOLVAPAY_PRODUCT_REF=${productRef}`,
|
|
979
981
|
`MCP_PUBLIC_BASE_URL=${selections.mcpPublicBaseUrl}`,
|
|
980
982
|
]
|
|
@@ -1032,7 +1034,20 @@ function collectWrittenPaths(target, toolFiles, envPath) {
|
|
|
1032
1034
|
for (const id of toolFiles) {
|
|
1033
1035
|
written.push(join(target, 'src', 'tools', `${id}.ts`))
|
|
1034
1036
|
}
|
|
1035
|
-
return written.map(p =>
|
|
1037
|
+
return written.map(p => resolve(p))
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function installGeneratedScriptDeps(target) {
|
|
1041
|
+
const scriptsDir = join(target, 'scripts')
|
|
1042
|
+
const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], {
|
|
1043
|
+
cwd: scriptsDir,
|
|
1044
|
+
encoding: 'utf8',
|
|
1045
|
+
})
|
|
1046
|
+
if ((result.status ?? 1) !== 0) {
|
|
1047
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n')
|
|
1048
|
+
console.error(`npm install inside ${scriptsDir} failed.\n${detail}`)
|
|
1049
|
+
process.exit(result.status ?? 1)
|
|
1050
|
+
}
|
|
1036
1051
|
}
|
|
1037
1052
|
|
|
1038
1053
|
function jsKey(name) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# The SolvaPay secret key for your merchant account. Never commit.
|
|
13
13
|
# Populated by `npx solvapay init`. Uploaded to Cloudflare as a Worker
|
|
14
14
|
# Secret automatically on the first `npm run deploy`.
|
|
15
|
-
SOLVAPAY_SECRET_KEY=
|
|
15
|
+
SOLVAPAY_SECRET_KEY=sk_sandbox_your_key_here
|
|
16
16
|
|
|
17
17
|
# Product ref the paywall gates on. Create one in the SolvaPay Console
|
|
18
18
|
# under Products and copy its `prd_…` ID. Populated by scaffold.
|
|
@@ -96,7 +96,7 @@ npm run verify -- http://localhost:8787 --credentials-file ./credentials.json
|
|
|
96
96
|
`.env` (gitignored) is what `wrangler dev` reads:
|
|
97
97
|
|
|
98
98
|
```
|
|
99
|
-
SOLVAPAY_SECRET_KEY=
|
|
99
|
+
SOLVAPAY_SECRET_KEY=sk_sandbox_… # populated by `npx -y solvapay@latest init`
|
|
100
100
|
SOLVAPAY_PRODUCT_REF=__SOLVAPAY_PRODUCT_REF__
|
|
101
101
|
MCP_PUBLIC_BASE_URL=__MCP_PUBLIC_BASE_URL__
|
|
102
102
|
UPSTREAM_API_KEY=… # bearer or single apiKey-header upstream auth
|
|
@@ -139,7 +139,7 @@ Then redeploy.
|
|
|
139
139
|
## Go-live (swap sandbox → live)
|
|
140
140
|
|
|
141
141
|
1. Generate a live key in the SolvaPay Console (API Keys → Live).
|
|
142
|
-
2. Replace `SOLVAPAY_SECRET_KEY=
|
|
142
|
+
2. Replace `SOLVAPAY_SECRET_KEY=sk_sandbox_…` with `SOLVAPAY_SECRET_KEY=sk_live_…` in `.env`.
|
|
143
143
|
3. The first-deploy auto-upload only runs when no `SOLVAPAY_SECRET_KEY` is present on the worker. Since one is already there, push the new value explicitly: `npx wrangler secret put SOLVAPAY_SECRET_KEY`.
|
|
144
144
|
4. `npm run deploy`.
|
|
145
145
|
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
"build": "cross-env INPUT=mcp-app.html vite build && mkdir -p src/assets && cp dist/mcp-app.html src/assets/mcp-app.html",
|
|
8
8
|
"dev": "npm run build && node scripts/dev.mjs",
|
|
9
9
|
"dev:widget": "cross-env INPUT=mcp-app.html vite build --watch",
|
|
10
|
-
"predeploy": "npm run build",
|
|
11
10
|
"deploy": "node scripts/deploy.mjs",
|
|
12
11
|
"serve:local": "wrangler dev",
|
|
13
12
|
"typecheck": "tsc --noEmit",
|
|
@@ -103,7 +103,10 @@ function listWorkerSecretNames() {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function putWorkerSecret(name, value) {
|
|
106
|
-
const
|
|
106
|
+
const workerName = readWranglerConfig().name
|
|
107
|
+
const args = ['secret', 'put', name]
|
|
108
|
+
if (workerName && !workerName.includes('__')) args.push('--name', workerName)
|
|
109
|
+
const result = runWrangler(args, { input: value })
|
|
107
110
|
if (result.status !== 0) process.exit(result.status ?? 1)
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -119,7 +122,7 @@ function getWranglerWhoami() {
|
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
function wranglerConfigPaths() {
|
|
125
|
+
export function wranglerConfigPaths() {
|
|
123
126
|
const home = homedir()
|
|
124
127
|
const paths = []
|
|
125
128
|
|
|
@@ -127,21 +130,29 @@ function wranglerConfigPaths() {
|
|
|
127
130
|
paths.push(join(process.env.WRANGLER_HOME, 'config', 'default.toml'))
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
134
|
+
paths.push(join(process.env.XDG_CONFIG_HOME, '.wrangler', 'config', 'default.toml'))
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
paths.push(join(home, 'Library', 'Preferences', '.wrangler', 'config', 'default.toml'))
|
|
131
138
|
paths.push(join(home, '.wrangler', 'config', 'default.toml'))
|
|
132
139
|
|
|
133
140
|
return paths
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
function
|
|
137
|
-
for (const path of
|
|
143
|
+
export function readOAuthTokenFromConfigPaths(paths) {
|
|
144
|
+
for (const path of paths) {
|
|
138
145
|
if (!existsSync(path)) continue
|
|
139
146
|
const match = readFileSync(path, 'utf8').match(/^oauth_token\s*=\s*"([^"]+)"/m)
|
|
140
|
-
|
|
147
|
+
return match?.[1] ?? null
|
|
141
148
|
}
|
|
142
149
|
return null
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
function readWranglerOAuthToken() {
|
|
153
|
+
return readOAuthTokenFromConfigPaths(wranglerConfigPaths())
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
function resolveCloudflareApiToken() {
|
|
146
157
|
const fromEnv = process.env.CLOUDFLARE_API_TOKEN?.trim()
|
|
147
158
|
if (fromEnv) return fromEnv
|
|
@@ -313,9 +324,10 @@ function failSubdomainNotRegistered(accountId) {
|
|
|
313
324
|
[
|
|
314
325
|
'',
|
|
315
326
|
'This Cloudflare account has not registered a workers.dev subdomain yet.',
|
|
316
|
-
|
|
327
|
+
`Account ${accountId}: opening Workers & Pages creates the workers.dev subdomain; no paid plan needed.`,
|
|
328
|
+
'Do that once, then re-run `npm run deploy`:',
|
|
317
329
|
'',
|
|
318
|
-
|
|
330
|
+
' https://dash.cloudflare.com/?to=/:account/workers-and-pages',
|
|
319
331
|
'',
|
|
320
332
|
].join('\n'),
|
|
321
333
|
)
|
|
@@ -843,72 +855,100 @@ function parseDotEnv(contents) {
|
|
|
843
855
|
return env
|
|
844
856
|
}
|
|
845
857
|
|
|
846
|
-
|
|
858
|
+
function uploadWorkerSecrets(localEnv, { dryRun, force }) {
|
|
859
|
+
ensureSolvaPaySecretKey(localEnv, { dryRun })
|
|
860
|
+
ensureUpstreamApiKeySecret(localEnv, { dryRun, force })
|
|
861
|
+
ensureUpstreamOAuthSecrets(localEnv, { dryRun })
|
|
862
|
+
ensureUpstreamApiHeadersSecret(localEnv, { dryRun, force })
|
|
863
|
+
}
|
|
847
864
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
'',
|
|
852
|
-
`⚠ ${dotEnvPath} not found — deploying with placeholder vars from wrangler.jsonc.`,
|
|
853
|
-
' Run `npx solvapay init` (which writes SOLVAPAY_SECRET_KEY) and verify the other',
|
|
854
|
-
' keys in .env match your merchant, then re-run `npm run deploy` — it uploads',
|
|
855
|
-
' SOLVAPAY_SECRET_KEY from .env as a Worker secret on the first deploy.',
|
|
856
|
-
'',
|
|
857
|
-
].join('\n'),
|
|
858
|
-
)
|
|
865
|
+
function runProjectBuild() {
|
|
866
|
+
const result = spawnSync('npm', ['run', 'build'], { cwd: projectRoot, stdio: 'inherit' })
|
|
867
|
+
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1)
|
|
859
868
|
}
|
|
860
869
|
|
|
861
|
-
|
|
862
|
-
const
|
|
863
|
-
passthrough.includes('--yes') ||
|
|
864
|
-
passthrough.includes('-y') ||
|
|
865
|
-
process.env.SOLVAPAY_DEPLOY_YES === '1'
|
|
866
|
-
const forceFlag = passthrough.includes('--force')
|
|
870
|
+
async function main() {
|
|
871
|
+
const localEnv = existsSync(dotEnvPath) ? parseDotEnv(readFileSync(dotEnvPath, 'utf8')) : {}
|
|
867
872
|
|
|
868
|
-
|
|
869
|
-
|
|
873
|
+
if (!existsSync(dotEnvPath)) {
|
|
874
|
+
console.error(
|
|
875
|
+
[
|
|
876
|
+
'',
|
|
877
|
+
`⚠ ${dotEnvPath} not found — deploying with placeholder vars from wrangler.jsonc.`,
|
|
878
|
+
' Run `npx -y solvapay@latest init` (which writes SOLVAPAY_SECRET_KEY) and verify the other',
|
|
879
|
+
' keys in .env match your merchant, then re-run `npm run deploy` — it uploads',
|
|
880
|
+
' SOLVAPAY_SECRET_KEY from .env as a Worker secret on the first deploy.',
|
|
881
|
+
'',
|
|
882
|
+
].join('\n'),
|
|
883
|
+
)
|
|
884
|
+
}
|
|
870
885
|
|
|
871
|
-
const
|
|
872
|
-
|
|
886
|
+
const dryRun = passthrough.includes('--dry-run')
|
|
887
|
+
const yesFlag =
|
|
888
|
+
passthrough.includes('--yes') ||
|
|
889
|
+
passthrough.includes('-y') ||
|
|
890
|
+
process.env.SOLVAPAY_DEPLOY_YES === '1'
|
|
891
|
+
const forceFlag = passthrough.includes('--force')
|
|
892
|
+
|
|
893
|
+
const preflight = await runDeployPreflight({ dryRun })
|
|
894
|
+
runProjectBuild()
|
|
895
|
+
const resolvedPublicUrl = ensureMcpPublicBaseUrl(localEnv, preflight, { dryRun })
|
|
896
|
+
|
|
897
|
+
const confirmation = await confirmDeploymentUrl(localEnv, preflight, { dryRun, yes: yesFlag })
|
|
898
|
+
if (confirmation === 'abort') process.exit(1)
|
|
899
|
+
|
|
900
|
+
// Verify the merchant exists on the SolvaPay backend before uploading
|
|
901
|
+
// the secret key. Otherwise a 404-from-the-backend deploy looks like a
|
|
902
|
+
// success at the `wrangler deploy` level but every paid tool fails at
|
|
903
|
+
// runtime.
|
|
904
|
+
await runSolvaPayPreflight({
|
|
905
|
+
secretKey: localEnv.SOLVAPAY_SECRET_KEY?.trim(),
|
|
906
|
+
apiBaseUrl: localEnv.SOLVAPAY_API_BASE_URL?.trim(),
|
|
907
|
+
dryRun,
|
|
908
|
+
})
|
|
873
909
|
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
apiBaseUrl: localEnv.SOLVAPAY_API_BASE_URL?.trim(),
|
|
881
|
-
dryRun,
|
|
882
|
-
})
|
|
910
|
+
// `wrangler secret put` on a worker that does not exist yet prompts to
|
|
911
|
+
// create one. Upload only after the first successful deploy in that case.
|
|
912
|
+
const workerExistedBeforeDeploy = listWorkerSecretNames() !== null
|
|
913
|
+
if (workerExistedBeforeDeploy) {
|
|
914
|
+
uploadWorkerSecrets(localEnv, { dryRun, force: forceFlag })
|
|
915
|
+
}
|
|
883
916
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
917
|
+
const wranglerPassthrough = passthrough.filter(
|
|
918
|
+
arg => arg !== '--yes' && arg !== '-y' && arg !== '--force',
|
|
919
|
+
)
|
|
920
|
+
const wranglerArgs = ['deploy']
|
|
921
|
+
for (const name of OVERRIDABLE_VARS) {
|
|
922
|
+
const value = localEnv[name]
|
|
923
|
+
if (value) wranglerArgs.push('--var', `${name}:${value}`)
|
|
924
|
+
}
|
|
925
|
+
wranglerArgs.push(...wranglerPassthrough)
|
|
888
926
|
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
)
|
|
892
|
-
const wranglerArgs = ['deploy']
|
|
893
|
-
for (const name of OVERRIDABLE_VARS) {
|
|
894
|
-
const value = localEnv[name]
|
|
895
|
-
if (value) wranglerArgs.push('--var', `${name}:${value}`)
|
|
896
|
-
}
|
|
897
|
-
wranglerArgs.push(...wranglerPassthrough)
|
|
927
|
+
const result = runWrangler(wranglerArgs, { captureOutput: true })
|
|
928
|
+
printCapturedWranglerOutput(result)
|
|
898
929
|
|
|
899
|
-
|
|
900
|
-
|
|
930
|
+
if ((result.status ?? 1) === 0 && !workerExistedBeforeDeploy) {
|
|
931
|
+
uploadWorkerSecrets(localEnv, { dryRun, force: forceFlag })
|
|
932
|
+
}
|
|
901
933
|
|
|
902
|
-
if (resolvedPublicUrl && !dryRun) {
|
|
903
|
-
|
|
904
|
-
}
|
|
934
|
+
if (resolvedPublicUrl && !dryRun) {
|
|
935
|
+
verifyDeployedWorkersDevUrl(resolvedPublicUrl, `${result.stdout ?? ''}\n${result.stderr ?? ''}`)
|
|
936
|
+
}
|
|
905
937
|
|
|
906
|
-
if (!dryRun && (result.status ?? 1) === 0) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
938
|
+
if (!dryRun && (result.status ?? 1) === 0) {
|
|
939
|
+
const finalUrl = localEnv.MCP_PUBLIC_BASE_URL?.trim()
|
|
940
|
+
if (finalUrl) {
|
|
941
|
+
const { name } = readWranglerConfig()
|
|
942
|
+
printDeployedConnectionSnippets(finalUrl, name)
|
|
943
|
+
}
|
|
911
944
|
}
|
|
945
|
+
|
|
946
|
+
process.exit(result.status ?? 1)
|
|
912
947
|
}
|
|
913
948
|
|
|
914
|
-
|
|
949
|
+
const isDirectRun =
|
|
950
|
+
Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
951
|
+
|
|
952
|
+
if (isDirectRun) {
|
|
953
|
+
await main()
|
|
954
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { readOAuthTokenFromConfigPaths, wranglerConfigPaths } from './deploy.mjs'
|
|
6
|
+
|
|
7
|
+
describe('wranglerConfigPaths', () => {
|
|
8
|
+
const originalWranglerHome = process.env.WRANGLER_HOME
|
|
9
|
+
const originalXdg = process.env.XDG_CONFIG_HOME
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (originalWranglerHome === undefined) delete process.env.WRANGLER_HOME
|
|
13
|
+
else process.env.WRANGLER_HOME = originalWranglerHome
|
|
14
|
+
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME
|
|
15
|
+
else process.env.XDG_CONFIG_HOME = originalXdg
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('checks XDG_CONFIG_HOME after WRANGLER_HOME and before the macOS preferences path', () => {
|
|
19
|
+
process.env.WRANGLER_HOME = '/tmp/wrangler-home'
|
|
20
|
+
process.env.XDG_CONFIG_HOME = '/tmp/xdg-config'
|
|
21
|
+
const paths = wranglerConfigPaths()
|
|
22
|
+
expect(paths[0]).toBe('/tmp/wrangler-home/config/default.toml')
|
|
23
|
+
expect(paths[1]).toBe('/tmp/xdg-config/.wrangler/config/default.toml')
|
|
24
|
+
expect(paths[2]).toMatch(/Library\/Preferences\/\.wrangler\/config\/default\.toml$/)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('readOAuthTokenFromConfigPaths', () => {
|
|
29
|
+
const dirs = []
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function writeConfig(token) {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'wrangler-cfg-'))
|
|
37
|
+
dirs.push(dir)
|
|
38
|
+
mkdirSync(join(dir, 'config'), { recursive: true })
|
|
39
|
+
const path = join(dir, 'config', 'default.toml')
|
|
40
|
+
writeFileSync(path, token == null ? 'no_token = true\n' : `oauth_token = "${token}"\n`)
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it('stops at the first config file that exists, even when it has no token', () => {
|
|
45
|
+
const first = writeConfig(null)
|
|
46
|
+
const second = writeConfig('from-later-file')
|
|
47
|
+
expect(readOAuthTokenFromConfigPaths([first, second])).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('reads the token from the first existing config file', () => {
|
|
51
|
+
const missing = join(tmpdir(), 'does-not-exist-wrangler-config.toml')
|
|
52
|
+
const found = writeConfig('from-xdg')
|
|
53
|
+
expect(readOAuthTokenFromConfigPaths([missing, found])).toBe('from-xdg')
|
|
54
|
+
})
|
|
55
|
+
})
|