@wpconvert/mcp
Advanced tools
| /** | ||
| * Pure MCP projections for developer-capabilities contract. | ||
| * No entitlement math — delegates to wpconvert/src/capabilities for labels/preflight. | ||
| */ | ||
| import { createRequire } from 'module'; | ||
| const require = createRequire(import.meta.url); | ||
| const { | ||
| getConversionCapability, | ||
| formatPreflightSummary, | ||
| formatDenialMessage, | ||
| formatQuotaHuman, | ||
| reasonLabel, | ||
| actionLabel, | ||
| jobIsDownloadable, | ||
| } = require('wpconvert/src/capabilities'); | ||
| const TOOL_QUOTA = 'wpconvert_quota'; | ||
| const TOOL_CONVERT = 'wpconvert_convert_folder'; | ||
| const TOOL_STATUS = 'wpconvert_check_status'; | ||
| const TOOL_DOWNLOAD = 'wpconvert_download_result'; | ||
| const TOOL_PREVIEW = 'wpconvert_create_preview'; | ||
| const TOOL_EXPLAIN = 'wpconvert_explain_failure'; | ||
| const STATUS_POLL_SECONDS = 15; | ||
| function downloadAvailable(capabilities) { | ||
| const dl = capabilities && capabilities.outputs && capabilities.outputs.download; | ||
| if (!dl || typeof dl !== 'object') return null; | ||
| return dl.available === true; | ||
| } | ||
| function buildSummary(quota) { | ||
| const conv = getConversionCapability(quota); | ||
| if (!conv) return undefined; | ||
| const caps = quota.capabilities || {}; | ||
| const dl = downloadAvailable(caps); | ||
| return { | ||
| can_start: conv.can_start, | ||
| mode: conv.mode, | ||
| consumes: conv.consumes, | ||
| reason: conv.reason, | ||
| download_available: dl === true, | ||
| recommended_action: caps.recommended_action || null, | ||
| }; | ||
| } | ||
| /** | ||
| * Recommend the next MCP tool from a quota snapshot. | ||
| */ | ||
| export function recommendedNextForQuota(quota) { | ||
| const conv = getConversionCapability(quota); | ||
| if (!conv) { | ||
| return { | ||
| tool: TOOL_CONVERT, | ||
| reason: 'Capabilities unavailable from server; server will enforce entitlement on submit.', | ||
| }; | ||
| } | ||
| if (conv.can_start === false) { | ||
| const action = quota.capabilities && quota.capabilities.recommended_action; | ||
| const actionText = action ? actionLabel(action) : null; | ||
| return { | ||
| tool: TOOL_QUOTA, | ||
| reason: actionText | ||
| ? `Conversion blocked. ${actionText}. Review quota before retrying.` | ||
| : 'Conversion blocked. Review quota and recommended action before retrying.', | ||
| }; | ||
| } | ||
| const mode = conv.mode === 'preview_only' ? 'preview' : 'full'; | ||
| return { | ||
| tool: TOOL_CONVERT, | ||
| reason: `Conversion allowed (${mode} mode). Call wpconvert_convert_folder when ready.`, | ||
| }; | ||
| } | ||
| /** | ||
| * Project quota API response into MCP structured payload. | ||
| */ | ||
| export function projectQuotaResponse(quota) { | ||
| const conv = getConversionCapability(quota); | ||
| const capabilitiesAvailable = !!conv; | ||
| return { | ||
| ok: true, | ||
| quota, | ||
| capabilities_available: capabilitiesAvailable, | ||
| summary: buildSummary(quota), | ||
| recommended_next: recommendedNextForQuota(quota), | ||
| }; | ||
| } | ||
| /** | ||
| * Recommend the next MCP tool from a job status snapshot. | ||
| */ | ||
| export function recommendedNextForStatus(status) { | ||
| const s = status || {}; | ||
| const jobStatus = String(s.status || '').toLowerCase(); | ||
| if (jobStatus === 'failed') { | ||
| return { | ||
| tool: TOOL_EXPLAIN, | ||
| reason: 'Job failed. Use wpconvert_explain_failure for details and recovery guidance.', | ||
| }; | ||
| } | ||
| if (jobStatus === 'queued' || jobStatus === 'processing' || jobStatus === 'pending') { | ||
| return { | ||
| tool: TOOL_STATUS, | ||
| reason: 'Job still running. Poll wpconvert_check_status until done.', | ||
| retry_after_seconds: STATUS_POLL_SECONDS, | ||
| }; | ||
| } | ||
| if (jobStatus === 'done') { | ||
| const previewOnly = s.preview_only === true || s.conversion_mode === 'preview_only'; | ||
| const downloadable = jobIsDownloadable(s); | ||
| if (previewOnly || !downloadable) { | ||
| return { | ||
| tool: TOOL_PREVIEW, | ||
| reason: previewOnly | ||
| ? 'Preview-only conversion. Use wpconvert_create_preview; download requires upgrade.' | ||
| : 'Download unavailable. Use wpconvert_create_preview or review quota.', | ||
| }; | ||
| } | ||
| return { | ||
| tool: TOOL_DOWNLOAD, | ||
| reason: 'Conversion complete. Download theme.zip with wpconvert_download_result.', | ||
| }; | ||
| } | ||
| return { | ||
| tool: TOOL_STATUS, | ||
| reason: 'Poll wpconvert_check_status for the latest job state.', | ||
| retry_after_seconds: STATUS_POLL_SECONDS, | ||
| }; | ||
| } | ||
| /** | ||
| * Project job status into MCP structured payload (no fabricated fields). | ||
| */ | ||
| export function projectStatusResponse(status) { | ||
| const s = status || {}; | ||
| const out = { | ||
| job_id: s.jobId || s.project_id || s.id || null, | ||
| project_id: s.project_id || s.jobId || s.id || null, | ||
| status: s.status || null, | ||
| recommended_next: recommendedNextForStatus(s), | ||
| }; | ||
| if (s.progress != null) out.progress = s.progress; | ||
| if (s.error != null) out.error = s.error; | ||
| if (s.preview_only != null) out.preview_only = s.preview_only; | ||
| if (s.conversion_mode != null) out.conversion_mode = s.conversion_mode; | ||
| if (s.download_available != null) out.download_available = s.download_available; | ||
| if (s.preview_available != null) out.preview_available = s.preview_available; | ||
| return out; | ||
| } | ||
| /** | ||
| * Structured denial when preflight returns can_start === false. | ||
| */ | ||
| export function buildConversionDenial(quota) { | ||
| const caps = (quota && quota.capabilities) || {}; | ||
| const conv = getConversionCapability(quota) || {}; | ||
| const denialLines = formatDenialMessage(quota); | ||
| const message = denialLines.join(' '); | ||
| return { | ||
| ok: false, | ||
| error: { | ||
| code: 'conversion_not_available', | ||
| message, | ||
| reason: conv.reason || 'conversion_not_available', | ||
| recommended_action: caps.recommended_action || null, | ||
| retry_safe: false, | ||
| mode: conv.mode || null, | ||
| consumes: conv.consumes || null, | ||
| }, | ||
| quota, | ||
| summary: buildSummary(quota), | ||
| recommended_next: recommendedNextForQuota(quota), | ||
| }; | ||
| } | ||
| /** | ||
| * Human-readable quota text for MCP tool prose. | ||
| */ | ||
| export function formatQuotaText(quota) { | ||
| return formatQuotaHuman(quota).join('\n'); | ||
| } | ||
| /** | ||
| * One-line preflight summary when conversion is allowed. | ||
| */ | ||
| export function formatAllowSummary(quota) { | ||
| return formatPreflightSummary(quota); | ||
| } | ||
| export { | ||
| TOOL_QUOTA, | ||
| TOOL_CONVERT, | ||
| TOOL_STATUS, | ||
| TOOL_DOWNLOAD, | ||
| TOOL_PREVIEW, | ||
| TOOL_EXPLAIN, | ||
| STATUS_POLL_SECONDS, | ||
| reasonLabel, | ||
| }; |
+2
-2
| { | ||
| "name": "@wpconvert/mcp", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "description": "Model Context Protocol server for WPConvert.ai — lets an AI agent convert the current workspace folder into a WordPress theme.", | ||
@@ -27,3 +27,3 @@ "license": "MIT", | ||
| "@modelcontextprotocol/sdk": "^1.0.0", | ||
| "wpconvert": "0.2.0" | ||
| "wpconvert": "0.3.0" | ||
| }, | ||
@@ -30,0 +30,0 @@ "keywords": [ |
+45
-5
@@ -18,3 +18,3 @@ # @wpconvert/mcp | ||
| | `wpconvert_explain_failure` | Return the failure reason for a failed job. | | ||
| | `wpconvert_quota` | Show remaining conversions / credits. | | ||
| | `wpconvert_quota` | Show quota, capabilities, and `recommended_next`. Call before converting. | | ||
@@ -41,7 +41,47 @@ ## Configure (Cursor / Claude Desktop) | ||
| 1. `wpconvert_convert_folder { "path": "./my-site", "type": "theme" }` → `jobId` | ||
| 2. `wpconvert_check_status { "jobId": "..." }` (repeat until `done`; conversions take a few minutes) | ||
| 3. `wpconvert_download_result { "jobId": "..." }` → saved theme `.zip` | ||
| 4. Optional: `wpconvert_create_preview { "jobId": "..." }` → a WordPress Playground URL to view the theme live | ||
| **Quota-first (recommended):** | ||
| 1. `wpconvert_quota` → review `capabilities` (conversion mode, `can_start`, download availability) and `recommended_next` | ||
| 2. `wpconvert_convert_folder { "path": "./my-site", "type": "theme" }` → `jobId` + `idempotency_key` + `recommended_next` | ||
| 3. `wpconvert_check_status { "jobId": "..." }` (repeat until `done`; follow `recommended_next` — usually poll every ~15s) | ||
| 4. When `recommended_next.tool` is `wpconvert_download_result` → download the theme `.zip` | ||
| 5. When preview-only or download locked → `wpconvert_create_preview` for a WordPress Playground URL (no ZIP) | ||
| If conversion is blocked (`can_start: false`), `wpconvert_convert_folder` returns a structured denial **before** zipping or submitting. Call `wpconvert_quota` to review `recommended_action`. | ||
| ## Capabilities and structured responses | ||
| Tools return human-readable prose plus a trailing JSON block (same `ok()` / `fail()` pattern as before). Key fields: | ||
| | Field | Where | Meaning | | ||
| | --- | --- | --- | | ||
| | `quota` | `wpconvert_quota` | Full backend quota body (unknown future fields preserved) | | ||
| | `summary` | quota, denials | Projected `can_start`, `mode`, `consumes`, `download_available` | | ||
| | `capabilities_available` | quota | `false` on legacy backends without `capabilities.conversion` | | ||
| | `recommended_next` | all tools | `{ tool, reason, retry_after_seconds? }` — which MCP tool to call next | | ||
| | `next_action` | convert success | **String** (unchanged) — human guidance for agents | | ||
| | `idempotency_key` | convert | Omitted on preflight denial (no key generated) | | ||
| **Status `recommended_next` rules:** | ||
| - `queued` / `processing` → `wpconvert_check_status` (poll) | ||
| - `done` + downloadable → `wpconvert_download_result` | ||
| - `done` + preview-only / download locked → `wpconvert_create_preview` (never download) | ||
| - `failed` → `wpconvert_explain_failure` | ||
| ## Idempotency recovery | ||
| - Leave `idempotency_key` blank for a **new intentional** conversion. | ||
| - If a prior `wpconvert_convert_folder` call timed out or returned an ambiguous network error, retry with the **exact** `idempotency_key` from that call — do not omit it and do not invent a new one. | ||
| - Do **not** reuse a key for changed files, options, or a deliberate new conversion. | ||
| - Preflight runs **after** path/plan validation but **before** key generation (when omitted), ZIP build, and submit — so blocked accounts never upload. | ||
| ## Preview-only / download locked | ||
| Free developer previews are preview-only (Playground, no ZIP download). When `download_available` is `false` or `preview_only` is `true`, status and download tools recommend `wpconvert_create_preview` or `wpconvert_quota` — not download retry. | ||
| ## Dependency pin | ||
| This package pins `wpconvert@0.3.0` exactly for shared capability helpers (`resolveConvertPreflight`, quota formatters). MCP package version remains **0.2.0** until a separate release review. | ||
| Billing, quotas, and refunds are identical to the dashboard and CLI for paid conversions. Free developer previews are preview-only (Playground, no ZIP download) and limited to 3 lifetime attempts per account. Secrets are excluded from the zip by default (set `includeEnv: true` only if you truly need them). |
+148
-29
@@ -51,2 +51,13 @@ #!/usr/bin/env node | ||
| } = require('wpconvert/src/idempotency'); | ||
| const { resolveConvertPreflight } = require('wpconvert/src/capabilities'); | ||
| import { | ||
| projectQuotaResponse, | ||
| projectStatusResponse, | ||
| buildConversionDenial, | ||
| formatQuotaText, | ||
| formatAllowSummary, | ||
| recommendedNextForStatus, | ||
| TOOL_STATUS, | ||
| STATUS_POLL_SECONDS, | ||
| } from './capabilities.mjs'; | ||
@@ -80,3 +91,3 @@ const MULTIPART_CAP_MB = 50; | ||
| description: | ||
| 'Zip a local folder (excluding node_modules, build output, and secrets by default) and start a WordPress theme conversion. Returns a jobId and an idempotency_key. Leave idempotency_key blank for a new intentional conversion. If a prior call may have timed out or returned an ambiguous network error, retry with the exact idempotency_key returned by that prior call — do not omit it and do not invent a new one. Do not reuse a key for changed files, options, or a deliberate new conversion. Once you have a jobId, poll wpconvert_check_status until "done", then call wpconvert_download_result or wpconvert_create_preview.', | ||
| 'Zip a local folder (excluding node_modules, build output, and secrets by default) and start a WordPress theme conversion. Call wpconvert_quota first to review capabilities (mode, credits, download availability). Returns a jobId and an idempotency_key. Leave idempotency_key blank for a new intentional conversion. If a prior call may have timed out or returned an ambiguous network error, retry with the exact idempotency_key returned by that prior call — do not omit it and do not invent a new one. Do not reuse a key for changed files, options, or a deliberate new conversion. Once you have a jobId, poll wpconvert_check_status until "done", then follow recommended_next for download or preview.', | ||
| inputSchema: { | ||
@@ -102,3 +113,4 @@ type: 'object', | ||
| name: 'wpconvert_check_status', | ||
| description: 'Check the status of a conversion job. Returns status (queued/processing/done/failed), progress, and whether a live preview is available. When done, you can call wpconvert_download_result and/or wpconvert_create_preview.', | ||
| description: | ||
| 'Check the status of a conversion job. Returns status (queued/processing/done/failed), progress, download/preview availability, and recommended_next (which tool to call next). When done and downloadable, recommended_next points to wpconvert_download_result; for preview-only jobs it points to wpconvert_create_preview instead.', | ||
| inputSchema: { | ||
@@ -112,3 +124,4 @@ type: 'object', | ||
| name: 'wpconvert_download_result', | ||
| description: 'Download a completed conversion to disk. Returns the saved file path. Optionally, use wpconvert_create_preview to view the theme in a live WordPress before/after downloading.', | ||
| description: | ||
| 'Download a completed conversion to disk. Only use when wpconvert_check_status shows download_available and recommended_next is wpconvert_download_result. Returns the saved file path. For preview-only jobs, use wpconvert_create_preview or upgrade and re-convert instead.', | ||
| inputSchema: { | ||
@@ -145,3 +158,4 @@ type: 'object', | ||
| name: 'wpconvert_quota', | ||
| description: 'Show the account\'s remaining conversions and PAYG credits.', | ||
| description: | ||
| 'Show account quota, capabilities (conversion mode, can_start, download availability), and recommended_next. Call this before wpconvert_convert_folder to understand what the next conversion will consume.', | ||
| inputSchema: { type: 'object', properties: {} }, | ||
@@ -152,2 +166,13 @@ }, | ||
| /** | ||
| * Validate a caller-supplied idempotency key without generating one. | ||
| */ | ||
| function validateSuppliedIdempotencyKey(args) { | ||
| const raw = args && args.idempotency_key; | ||
| if (raw == null || String(raw).trim() === '') return null; | ||
| return assertIdempotencyKey(String(raw), { | ||
| allowedPrefixes: [MCP_KEY_PREFIX, KEY_PREFIX], | ||
| }); | ||
| } | ||
| /** | ||
| * Resolve the idempotency key for one convert_folder invocation. | ||
@@ -172,3 +197,3 @@ * Caller-provided keys are used unchanged; otherwise generate once. | ||
| function successPayload({ jobId, projectId, status, idempotencyKey, replay, nextAction, extras = {} }) { | ||
| function successPayload({ jobId, projectId, status, idempotencyKey, replay, nextAction, recommendedNext, extras = {} }) { | ||
| return { | ||
@@ -181,2 +206,3 @@ job_id: jobId, | ||
| next_action: nextAction, | ||
| recommended_next: recommendedNext, | ||
| ...extras, | ||
@@ -297,5 +323,5 @@ }; | ||
| let idempotencyKey; | ||
| let validatedKey; | ||
| try { | ||
| ({ key: idempotencyKey } = resolveIdempotencyKey(args)); | ||
| validatedKey = validateSuppliedIdempotencyKey(args); | ||
| } catch (e) { | ||
@@ -336,2 +362,29 @@ const supplied = args.idempotency_key != null ? String(args.idempotency_key).slice(0, MAX_KEY_LENGTH) : undefined; | ||
| const preflight = await resolveConvertPreflight(() => api.getQuota()); | ||
| if (preflight.outcome === 'auth_error') { | ||
| return fail(redactSecrets(renderApiError(preflight.error)), errorPayload({ | ||
| code: (preflight.error && preflight.error.code) || 'auth_error', | ||
| message: redactSecrets((preflight.error && preflight.error.message) || 'Authentication failed.'), | ||
| retrySafe: false, | ||
| reuseKey: false, | ||
| idempotencyKey: validatedKey || undefined, | ||
| })); | ||
| } | ||
| if (preflight.outcome === 'deny') { | ||
| const denial = buildConversionDenial(preflight.quota); | ||
| return fail( | ||
| denial.error.message + '\nCall wpconvert_quota to review capabilities and recommended action.', | ||
| denial | ||
| ); | ||
| } | ||
| const preflightNote = preflight.outcome === 'allow' | ||
| ? (formatAllowSummary(preflight.quota) ? `${formatAllowSummary(preflight.quota)}\n` : '') | ||
| : ''; | ||
| const preflightWarning = preflight.warning ? `${preflight.warning}\n` : ''; | ||
| const idempotencyKey = validatedKey ?? generateIdempotencyKey(MCP_KEY_PREFIX); | ||
| const zipBuffer = buildZipBuffer(files); | ||
@@ -384,4 +437,12 @@ const zipMB = zipBuffer.length / (1024 * 1024); | ||
| const recommendedNext = { | ||
| tool: TOOL_STATUS, | ||
| reason: 'Conversion submitted. Poll wpconvert_check_status until done.', | ||
| retry_after_seconds: STATUS_POLL_SECONDS, | ||
| }; | ||
| const text = | ||
| detectNote + | ||
| preflightWarning + | ||
| preflightNote + | ||
| (replay | ||
@@ -393,3 +454,3 @@ ? `Recovered existing conversion request. jobId=${jobId}\n` | ||
| ? 'Continue with wpconvert_check_status for this jobId — do not submit another conversion.' | ||
| : 'Poll wpconvert_check_status with this jobId until status is "done", then wpconvert_download_result or wpconvert_create_preview.') + | ||
| : 'Poll wpconvert_check_status with this jobId until status is "done", then follow recommended_next for download or preview.') + | ||
| previewNote; | ||
@@ -406,2 +467,3 @@ | ||
| nextAction, | ||
| recommendedNext, | ||
| extras: previewOnly | ||
@@ -443,3 +505,3 @@ ? { | ||
| case 'theme_too_large_for_preview': | ||
| return 'This theme is too large for in-browser preview (over 30MB). Download it and test on a WordPress install instead.'; | ||
| return e.message || 'This theme is too large for in-browser preview. Download it and test on a WordPress install instead.'; | ||
| case 'idempotency_request_in_progress': | ||
@@ -468,19 +530,59 @@ return 'The conversion request was accepted but its job ID is not available yet. Retry wpconvert_convert_folder with the same idempotency_key.'; | ||
| const s = await api.getStatus(args.jobId); | ||
| const projected = projectStatusResponse(s); | ||
| const previewOnly = s.preview_only || s.conversion_mode === 'preview_only'; | ||
| return ok(`status=${s.status}${s.progress != null ? ` progress=${s.progress}%` : ''}` + | ||
| const text = | ||
| `status=${s.status}${s.progress != null ? ` progress=${s.progress}%` : ''}` + | ||
| `${s.status === 'done' && s.preview_available ? '\npreview: available (call wpconvert_create_preview)' : ''}` + | ||
| `${previewOnly && s.status === 'done' ? `\n${PREVIEW_LOCKED_DOWNLOAD_COPY}` : ''}` + | ||
| `${s.status === 'failed' && s.error ? `\nerror: ${s.error}` : ''}`); | ||
| `${s.status === 'failed' && s.error ? `\nerror: ${s.error}` : ''}` + | ||
| `\nNext: ${projected.recommended_next.tool} — ${projected.recommended_next.reason}`; | ||
| return ok(text, projected); | ||
| } | ||
| case 'wpconvert_download_result': { | ||
| const info = await api.getDownload(args.jobId); | ||
| if (!info.download_url) return fail('No download URL available yet. Poll status until done.'); | ||
| const dir = args.outDir ? path.resolve(process.cwd(), args.outDir) : process.cwd(); | ||
| if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | ||
| const fileName = info.name || `${args.jobId}-theme.zip`; | ||
| const outPath = path.join(dir, fileName); | ||
| const bytes = await api.fetchBinary(info.download_url); | ||
| fs.writeFileSync(outPath, bytes); | ||
| return ok(`Saved ${fileName} (${formatBytes(bytes.length)}) to ${outPath}`); | ||
| try { | ||
| const info = await api.getDownload(args.jobId); | ||
| if (!info.download_url) { | ||
| return fail( | ||
| 'No download URL available yet. Poll status until done.', | ||
| { | ||
| ok: false, | ||
| error: { code: 'conversion_not_ready', message: 'No download URL available yet.' }, | ||
| recommended_next: recommendedNextForStatus({ status: 'processing' }), | ||
| } | ||
| ); | ||
| } | ||
| const dir = args.outDir ? path.resolve(process.cwd(), args.outDir) : process.cwd(); | ||
| if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | ||
| const fileName = info.name || `${args.jobId}-theme.zip`; | ||
| const outPath = path.join(dir, fileName); | ||
| const bytes = await api.fetchBinary(info.download_url); | ||
| fs.writeFileSync(outPath, bytes); | ||
| return ok(`Saved ${fileName} (${formatBytes(bytes.length)}) to ${outPath}`, { | ||
| ok: true, | ||
| job_id: args.jobId, | ||
| path: outPath, | ||
| size_bytes: bytes.length, | ||
| }); | ||
| } catch (e) { | ||
| if (e && e.name === 'ApiError' && (e.code === 'upgrade_required' || e.details?.preview_only)) { | ||
| return fail( | ||
| PREVIEW_LOCKED_DOWNLOAD_COPY + '\nCall wpconvert_quota to review upgrade options.', | ||
| { | ||
| ok: false, | ||
| error: { | ||
| code: e.code || 'upgrade_required', | ||
| message: PREVIEW_LOCKED_DOWNLOAD_COPY, | ||
| reason: e.details?.reason || 'download_requires_upgrade', | ||
| retry_safe: false, | ||
| }, | ||
| recommended_next: { | ||
| tool: 'wpconvert_quota', | ||
| reason: 'Download locked for preview-only job. Review quota and upgrade before re-converting.', | ||
| }, | ||
| } | ||
| ); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
@@ -490,7 +592,19 @@ | ||
| const s = await api.getStatus(args.jobId); | ||
| if (s.status === 'failed') return fail(`Conversion failed: ${s.error || 'unknown error'}`); | ||
| if (s.status !== 'done') return fail(`Conversion is not ready yet (status=${s.status}). Poll status until "done", then retry.`); | ||
| if (s.status === 'failed') { | ||
| return fail(`Conversion failed: ${s.error || 'unknown error'}`, { | ||
| ok: false, | ||
| error: { code: 'conversion_failed', message: s.error || 'unknown error' }, | ||
| recommended_next: recommendedNextForStatus(s), | ||
| }); | ||
| } | ||
| if (s.status !== 'done') { | ||
| return fail(`Conversion is not ready yet (status=${s.status}). Poll status until "done", then retry.`, { | ||
| ok: false, | ||
| error: { code: 'conversion_not_ready', message: `status=${s.status}` }, | ||
| recommended_next: recommendedNextForStatus(s), | ||
| }); | ||
| } | ||
| const session = await api.createPlaygroundSession(args.jobId); | ||
| const expires = session.expires_at ? new Date(session.expires_at).toISOString() : null; | ||
| return ok( | ||
| const text = | ||
| `Preview ready. Open this URL in a browser to view the theme in WordPress Playground:\n` + | ||
@@ -500,4 +614,10 @@ `${session.playground_url}\n` + | ||
| `Anyone with this URL can view the theme until it expires — treat it as sensitive.` + | ||
| `${session.warning ? `\nNote: ${session.warning}` : ''}` | ||
| ); | ||
| `${session.warning ? `\nNote: ${session.warning}` : ''}`; | ||
| return ok(text, { | ||
| ok: true, | ||
| job_id: args.jobId, | ||
| playground_url: session.playground_url, | ||
| expires_at: expires, | ||
| warning: session.warning || null, | ||
| }); | ||
| } | ||
@@ -526,6 +646,5 @@ | ||
| const q = await api.getQuota(); | ||
| return ok( | ||
| `plan=${q.effectivePlan || 'unknown'} used=${q.current ?? '?'}/${q.max ?? '?'} ` + | ||
| `remaining=${q.remaining ?? '?'}${q.payg_credits != null ? ` payg=${q.payg_credits}` : ''}` | ||
| ); | ||
| const projected = projectQuotaResponse(q); | ||
| const text = formatQuotaText(q); | ||
| return ok(text, projected); | ||
| } | ||
@@ -532,0 +651,0 @@ |
39175
49.44%4
33.33%822
57.77%86
86.96%5
25%+ Added
- Removed
Updated