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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-solvapay",
3
- "version": "0.7.2",
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.5"
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
- * The only non-stdlib dependency is `@apidevtools/swagger-parser`,
19
- * pulled on demand via `npx --package @apidevtools/swagger-parser`.
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
- const spec = await SwaggerParser.dereference(specPath)
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
- const spec = await SwaggerParser.dereference(absolute)
67
- return { spec }
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`. The skill ships a tiny
72
- * `scripts/package.json` declaring this single dep; the user runs
73
- * `npm install` inside `scripts/` once per checkout before invoking
74
- * `describe.mjs`, `scaffold.mjs`, or `test.mjs`. The error message
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.1.1",
142
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
143
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
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"
@@ -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
  }
@@ -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>\` runs from ${target} with no extra setup. Before \`node scripts/test.mjs\`, run \`( cd scripts && npm install )\` once inside ${target}.`,
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 => relative(process.cwd(), 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=sk_test_your_key_here
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=sk_test_… # populated by `npx solvapay init`
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=sk_test_…` with `SOLVAPAY_SECRET_KEY=sk_live_…` in `.env`.
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 result = runWrangler(['secret', 'put', name], { input: value })
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 readWranglerOAuthToken() {
137
- for (const path of wranglerConfigPaths()) {
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
- if (match?.[1]) return match[1]
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
- 'Register one (one-time setup), then re-run `npm run deploy`:',
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
- ` https://dash.cloudflare.com/${accountId}/workers/onboarding`,
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
- const localEnv = existsSync(dotEnvPath) ? parseDotEnv(readFileSync(dotEnvPath, 'utf8')) : {}
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
- if (!existsSync(dotEnvPath)) {
849
- console.error(
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
- const dryRun = passthrough.includes('--dry-run')
862
- const yesFlag =
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
- const preflight = await runDeployPreflight({ dryRun })
869
- const resolvedPublicUrl = ensureMcpPublicBaseUrl(localEnv, preflight, { dryRun })
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 confirmation = await confirmDeploymentUrl(localEnv, preflight, { dryRun, yes: yesFlag })
872
- if (confirmation === 'abort') process.exit(1)
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
- // Verify the merchant exists on the SolvaPay backend before uploading
875
- // the secret key. Otherwise a 404-from-the-backend deploy looks like a
876
- // success at the `wrangler deploy` level but every paid tool fails at
877
- // runtime.
878
- await runSolvaPayPreflight({
879
- secretKey: localEnv.SOLVAPAY_SECRET_KEY?.trim(),
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
- ensureSolvaPaySecretKey(localEnv, { dryRun })
885
- ensureUpstreamApiKeySecret(localEnv, { dryRun, force: forceFlag })
886
- ensureUpstreamOAuthSecrets(localEnv, { dryRun })
887
- ensureUpstreamApiHeadersSecret(localEnv, { dryRun, force: forceFlag })
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 wranglerPassthrough = passthrough.filter(
890
- arg => arg !== '--yes' && arg !== '-y' && arg !== '--force',
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
- const result = runWrangler(wranglerArgs, { captureOutput: true })
900
- printCapturedWranglerOutput(result)
930
+ if ((result.status ?? 1) === 0 && !workerExistedBeforeDeploy) {
931
+ uploadWorkerSecrets(localEnv, { dryRun, force: forceFlag })
932
+ }
901
933
 
902
- if (resolvedPublicUrl && !dryRun) {
903
- verifyDeployedWorkersDevUrl(resolvedPublicUrl, `${result.stdout ?? ''}\n${result.stderr ?? ''}`)
904
- }
934
+ if (resolvedPublicUrl && !dryRun) {
935
+ verifyDeployedWorkersDevUrl(resolvedPublicUrl, `${result.stdout ?? ''}\n${result.stderr ?? ''}`)
936
+ }
905
937
 
906
- if (!dryRun && (result.status ?? 1) === 0) {
907
- const finalUrl = localEnv.MCP_PUBLIC_BASE_URL?.trim()
908
- if (finalUrl) {
909
- const { name } = readWranglerConfig()
910
- printDeployedConnectionSnippets(finalUrl, name)
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
- process.exit(result.status ?? 1)
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
+ })