| 'use strict'; | ||
| /** | ||
| * Conversion submission idempotency helpers for WPConvert CLI / MCP. | ||
| * Keys are generated once per intentional conversion invocation. | ||
| */ | ||
| const crypto = require('crypto'); | ||
| const KEY_PREFIX = 'wpconvert-cli-'; | ||
| const MCP_KEY_PREFIX = 'wpconvert-mcp-'; | ||
| const MAX_KEY_LENGTH = 128; | ||
| const ALLOWED_PREFIXES = [KEY_PREFIX, MCP_KEY_PREFIX]; | ||
| /** | ||
| * @param {string} [prefix] | ||
| * @returns {string} | ||
| */ | ||
| function generateIdempotencyKey(prefix = KEY_PREFIX) { | ||
| return `${prefix}${crypto.randomUUID()}`; | ||
| } | ||
| /** | ||
| * @param {string | undefined | null} key | ||
| * @param {{ allowedPrefixes?: string[] }} [opts] | ||
| * @returns {string} | ||
| */ | ||
| function assertIdempotencyKey(key, { allowedPrefixes = ALLOWED_PREFIXES } = {}) { | ||
| if (key == null || typeof key !== 'string') { | ||
| throw new Error('Internal error: missing idempotency key for conversion submission.'); | ||
| } | ||
| const normalized = key.trim(); | ||
| if (normalized.length === 0) { | ||
| throw new Error('Internal error: missing idempotency key for conversion submission.'); | ||
| } | ||
| if (normalized.length > MAX_KEY_LENGTH) { | ||
| throw new Error('Internal error: idempotency key exceeds maximum length.'); | ||
| } | ||
| for (let i = 0; i < normalized.length; i++) { | ||
| const code = normalized.charCodeAt(i); | ||
| if (code < 0x20 || code > 0x7e) { | ||
| throw new Error('Internal error: idempotency key contains invalid characters.'); | ||
| } | ||
| } | ||
| if (Array.isArray(allowedPrefixes) && allowedPrefixes.length > 0) { | ||
| const ok = allowedPrefixes.some((p) => normalized.startsWith(p)); | ||
| if (!ok) { | ||
| throw new Error('Internal error: invalid idempotency key format.'); | ||
| } | ||
| } | ||
| return normalized; | ||
| } | ||
| /** | ||
| * Ambiguous transport failures that may occur before a usable HTTP response. | ||
| * @param {unknown} err | ||
| * @returns {boolean} | ||
| */ | ||
| function isAmbiguousTransportError(err) { | ||
| if (!err) return false; | ||
| const code = err.cause?.code || err.code; | ||
| if (code && /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|ENOTFOUND|ENETUNREACH|EAI_AGAIN/i.test(String(code))) { | ||
| return true; | ||
| } | ||
| const msg = String(err.message || err); | ||
| return /fetch failed|socket hang up|network|timed out|timeout/i.test(msg); | ||
| } | ||
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | ||
| module.exports = { | ||
| KEY_PREFIX, | ||
| MCP_KEY_PREFIX, | ||
| MAX_KEY_LENGTH, | ||
| ALLOWED_PREFIXES, | ||
| generateIdempotencyKey, | ||
| assertIdempotencyKey, | ||
| isAmbiguousTransportError, | ||
| sleep, | ||
| }; |
+203
-11
@@ -22,2 +22,3 @@ #!/usr/bin/env node | ||
| const api = require('../src/api'); | ||
| const { generateIdempotencyKey, assertIdempotencyKey } = require('../src/idempotency'); | ||
| const { planZip, buildZipBuffer, formatBytes } = require('../src/zip'); | ||
@@ -29,2 +30,4 @@ const { detectSiteRoot, BUILD_DIRS } = require('../src/detect'); | ||
| const COMING_SOON_TYPES = ['elementor', 'gutenberg']; // not available via CLI/API yet | ||
| const PREVIEW_LOCKED_DOWNLOAD_COPY = | ||
| 'Download locked. Upgrade to PRO or add PAYG credits, then re-run this conversion to download theme.zip.'; | ||
@@ -65,2 +68,5 @@ const program = new Command(); | ||
| case 'upgrade_required': | ||
| if (d.preview_only || d.reason === 'dev_preview_limit' || d.reason === 'preview_only_job') { | ||
| return PREVIEW_LOCKED_DOWNLOAD_COPY + (d.buy_credits_url ? `\nUpgrade / credits: ${c.cyan(d.buy_credits_url)}` : ''); | ||
| } | ||
| return `${e.message}${d.plan_needed ? ` (needs: ${d.plan_needed})` : ''}`; | ||
@@ -71,2 +77,23 @@ case 'rate_limited': | ||
| return `You already have ${d.current ?? '?'}/${d.cap ?? '?'} conversions in progress. Wait for one to finish, then retry.`; | ||
| case 'idempotency_request_in_progress': | ||
| return ( | ||
| 'The conversion request was accepted but its job ID is not available yet. ' + | ||
| 'Retry this command shortly only if necessary.' + | ||
| (d.retry_after ? ` (retry after ~${d.retry_after}s)` : '') | ||
| ); | ||
| case 'idempotency_payload_mismatch': | ||
| return 'Internal consistency error: this conversion reused an idempotency key with different request data. Please report this to WPConvert support.'; | ||
| case 'idempotency_previous_failed': | ||
| return e.message || 'A previous conversion attempt with this idempotency key failed. Run a new `wpconvert convert` to try again.'; | ||
| case 'invalid_idempotency_key': | ||
| return 'Internal CLI error: invalid idempotency key. Please update the WPConvert CLI and try again.'; | ||
| case 'network_error': | ||
| if (d.hadTransportRetry) { | ||
| return ( | ||
| `${e.message}\n` + | ||
| 'The server may have accepted the request. Do not rerun this command repeatedly — ' + | ||
| 'check recent projects in WPConvert or run `wpconvert status <jobId>` if you have a job ID.' | ||
| ); | ||
| } | ||
| return e.message || 'Network request failed.'; | ||
| case 'conversion_not_ready': | ||
@@ -76,2 +103,6 @@ return `Conversion is not ready yet (status: ${d.status || 'pending'}). Try again shortly.`; | ||
| return 'Conversion not found. Check the job ID.'; | ||
| case 'theme_expired': | ||
| return 'This theme has expired and is no longer available for preview. Re-run the conversion to preview it again.'; | ||
| 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.'; | ||
| default: | ||
@@ -95,2 +126,18 @@ return e.message || 'Request failed.'; | ||
| /** Open a URL in the user's default browser (best-effort, cross-platform). */ | ||
| function openInBrowser(targetUrl) { | ||
| const { spawn } = require('child_process'); | ||
| const platform = process.platform; | ||
| const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'; | ||
| const args = platform === 'win32' ? ['/c', 'start', '', targetUrl] : [targetUrl]; | ||
| try { | ||
| const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); | ||
| child.on('error', () => { /* non-fatal — URL is already printed */ }); | ||
| child.unref(); | ||
| return true; | ||
| } catch (_) { | ||
| return false; | ||
| } | ||
| } | ||
| // ------------------------------- login -------------------------------------- | ||
@@ -157,2 +204,5 @@ | ||
| .option('--no-download', 'do not auto-download the result on success') | ||
| .option('--no-preview', 'do not auto-create a Playground preview on success') | ||
| .option('--open', 'open the Playground preview in your browser (paid users; preview-only jobs open by default)') | ||
| .option('--no-open', 'do not auto-open the browser (preview-only jobs only; use in CI/headless)') | ||
| .option('--out <dir>', 'directory to save the downloaded theme (default: cwd)') | ||
@@ -242,6 +292,8 @@ .action(withErrorHandling(async (target, opts) => { | ||
| // Route by size. Small -> multipart; large -> direct-to-storage. | ||
| const idempotencyKey = assertIdempotencyKey(generateIdempotencyKey()); | ||
| const conversionOpts = { projectName, exportType: type, elementor, idempotencyKey }; | ||
| let submit; | ||
| if (zipMB <= MULTIPART_CAP_MB) { | ||
| log(c.dim('Uploading (multipart) and starting conversion ...')); | ||
| submit = await api.convertMultipart(zipBuffer, { projectName, exportType: type, elementor }); | ||
| submit = await api.convertMultipart(zipBuffer, conversionOpts); | ||
| } else { | ||
@@ -260,5 +312,15 @@ log(c.dim('Large upload: requesting a direct upload URL ...')); | ||
| log(c.dim('Starting conversion ...')); | ||
| submit = await api.createJobFromStorage(up.jobId, { projectName, exportType: type, elementor }); | ||
| submit = await api.createJobFromStorage(up.jobId, conversionOpts); | ||
| } | ||
| if (submit._hadTransportRetry) { | ||
| log(c.yellow( | ||
| '! Ambiguous network error during submission; retried once with the same idempotency key. ' + | ||
| 'If unsure whether the conversion started, check your WPConvert dashboard before rerunning.' | ||
| )); | ||
| } | ||
| if (submit.idempotent_replay) { | ||
| log(c.dim('Recovered existing conversion request.')); | ||
| } | ||
| const jobId = submit.jobId || submit.project_id || submit.id; | ||
@@ -268,2 +330,9 @@ if (!jobId) die('Conversion started but no job ID was returned. Check the dashboard.'); | ||
| if (submit.preview_only || submit.conversion_mode === 'preview_only') { | ||
| const n = submit.free_dev_preview?.number; | ||
| const lim = submit.free_dev_preview?.limit ?? 3; | ||
| if (n != null) log(c.yellow(`Free developer preview ${n} of ${lim}.`)); | ||
| log(c.yellow(PREVIEW_LOCKED_DOWNLOAD_COPY)); | ||
| } | ||
| // Poll to completion with exponential backoff (cap 5s). | ||
@@ -278,9 +347,20 @@ const final = await pollUntilDone(jobId); | ||
| if (opts.download === false) { | ||
| log(c.dim(`Skipping download (--no-download). Run: wpconvert download ${jobId}`)); | ||
| return; | ||
| } | ||
| await downloadResult(jobId, opts.out); | ||
| await finishConversion(jobId, final, opts, { | ||
| previewOnly: !!(submit.preview_only || submit.conversion_mode === 'preview_only'), | ||
| }); | ||
| })); | ||
| /** True for errors that are often transient during status polling (deploy restarts, local nodemon, etc.). */ | ||
| function isTransientPollError(e) { | ||
| if (!e) return false; | ||
| if (e.name === 'ApiError') { | ||
| if (e.code === 'rate_limited' || e.code === 'network_error') return true; | ||
| if (e.code === 'invalid_api_key') return true; | ||
| if (e.status >= 500) return true; | ||
| return false; | ||
| } | ||
| const msg = String(e.message || e); | ||
| return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up|network/i.test(msg); | ||
| } | ||
| /** Poll status until done/failed (or timeout). Returns the final status payload. */ | ||
@@ -292,2 +372,4 @@ async function pollUntilDone(jobId) { | ||
| let lastLine = ''; | ||
| let transientRetries = 0; | ||
| const maxTransientRetries = 8; // deploy restarts, local nodemon, brief API unavailability | ||
| while (true) { | ||
@@ -297,8 +379,20 @@ let s; | ||
| s = await api.getStatus(jobId); | ||
| transientRetries = 0; | ||
| } catch (e) { | ||
| // Transient status errors (e.g. brief rate-limit) shouldn't abort polling. | ||
| if (e.name === 'ApiError' && (e.code === 'rate_limited' || e.status >= 500)) { | ||
| await sleep(Math.max(delay, (e.details && e.details.retry_after ? e.details.retry_after * 1000 : delay))); | ||
| if (isTransientPollError(e) && transientRetries < maxTransientRetries) { | ||
| transientRetries += 1; | ||
| const retryAfter = | ||
| e.name === 'ApiError' && e.details?.retry_after | ||
| ? e.details.retry_after * 1000 | ||
| : Math.min(delay * transientRetries, 8000); | ||
| await sleep(Math.max(delay, retryAfter)); | ||
| continue; | ||
| } | ||
| if (isTransientPollError(e)) { | ||
| process.stdout.write('\n'); | ||
| die( | ||
| 'Lost connection to the API while polling (the conversion may still be running).\n' + | ||
| `Check status: ${c.cyan(`wpconvert status ${jobId}`)}` | ||
| ); | ||
| } | ||
| throw e; | ||
@@ -340,2 +434,73 @@ } | ||
| /** Create a Playground session and print the preview URL (optionally open the browser). */ | ||
| async function showPlaygroundPreview(jobId, { open = false } = {}) { | ||
| log(c.dim('Creating Playground preview ...')); | ||
| const session = await api.createPlaygroundSession(jobId); | ||
| if (session.warning) log(c.yellow('! ') + session.warning); | ||
| log(c.green('✔ ') + 'Preview ready (WordPress Playground):'); | ||
| log(' ' + c.cyan(session.playground_url)); | ||
| if (session.expires_at) { | ||
| log(c.dim(` Link expires ${new Date(session.expires_at).toLocaleString()} and is single-use limited.`)); | ||
| } | ||
| log(c.dim(' Anyone with this URL can view the theme until it expires — treat it as sensitive (avoid CI logs).')); | ||
| if (open) { | ||
| const opened = openInBrowser(session.playground_url); | ||
| if (opened) log(c.dim(' Opening in your default browser ...')); | ||
| } | ||
| return session; | ||
| } | ||
| /** | ||
| * True when the job is a free developer preview-only API conversion. | ||
| * Uses the final status payload, with submit-time hint as fallback (worker | ||
| * completion used to overwrite metadata and drop preview_only before merge fix). | ||
| */ | ||
| function isPreviewOnlyJob(final, ctx = {}) { | ||
| if (final.preview_only === true || final.conversion_mode === 'preview_only') return true; | ||
| if (ctx.previewOnly === true) return true; | ||
| return false; | ||
| } | ||
| /** | ||
| * Preview-only API jobs auto-open the browser (the preview is the deliverable). | ||
| * Paid/downloadable jobs print the URL only unless --open is passed. | ||
| * Never auto-open in CI or when --no-open is set. | ||
| */ | ||
| function shouldOpenPlaygroundBrowser(final, opts, ctx = {}) { | ||
| if (process.env.CI) return false; | ||
| if (opts.noOpen) return false; | ||
| if (opts.open) return true; | ||
| return isPreviewOnlyJob(final, ctx); | ||
| } | ||
| /** | ||
| * Post-conversion UX: preview-first when download is locked; otherwise download then preview. | ||
| */ | ||
| async function finishConversion(jobId, final, opts, ctx = {}) { | ||
| const isPreviewOnly = isPreviewOnlyJob(final, ctx); | ||
| const canDownload = final.download_available === true && !isPreviewOnly; | ||
| if (isPreviewOnly) { | ||
| log(c.yellow(PREVIEW_LOCKED_DOWNLOAD_COPY)); | ||
| } else if (!canDownload) { | ||
| log(c.yellow('Download is locked on this plan. Use the Playground preview below.')); | ||
| } | ||
| if (canDownload && opts.download !== false) { | ||
| await downloadResult(jobId, opts.out); | ||
| } | ||
| if (opts.preview !== false) { | ||
| const openBrowser = shouldOpenPlaygroundBrowser(final, opts, ctx); | ||
| await showPlaygroundPreview(jobId, { open: openBrowser }); | ||
| if (!openBrowser) { | ||
| log(c.dim(`Open in browser: ${c.cyan(`wpconvert preview ${jobId} --open`)}`)); | ||
| } | ||
| } | ||
| if (isPreviewOnly) { | ||
| log(c.dim('Upgrade to Pro/Agency or buy PAYG credits, then re-run convert to download theme.zip.')); | ||
| } | ||
| } | ||
| // ------------------------------- status ------------------------------------- | ||
@@ -352,3 +517,12 @@ | ||
| if (s.project_name) log(`${c.bold('Project')} ${s.project_name}`); | ||
| if (s.status === 'done') log(`${c.bold('Download')} run: ${c.cyan(`wpconvert download ${s.project_id || jobId}`)}`); | ||
| if (s.preview_only || s.conversion_mode === 'preview_only') { | ||
| log(`${c.bold('Mode')} preview-only (download locked)`); | ||
| log(c.yellow(PREVIEW_LOCKED_DOWNLOAD_COPY)); | ||
| } | ||
| if (s.status === 'done' && !s.preview_only && s.conversion_mode !== 'preview_only' && s.download_available) { | ||
| log(`${c.bold('Download')} run: ${c.cyan(`wpconvert download ${s.project_id || jobId}`)}`); | ||
| } | ||
| if (s.status === 'done') { | ||
| log(`${c.bold('Preview')} run: ${c.cyan(`wpconvert preview ${s.project_id || jobId} --open`)}`); | ||
| } | ||
| if (s.status === 'failed' && s.error) log(`${c.bold('Error')} ${c.red(s.error)}`); | ||
@@ -368,2 +542,20 @@ })); | ||
| // ------------------------------- preview ------------------------------------ | ||
| program | ||
| .command('preview') | ||
| .description('Create a WordPress Playground preview of a completed conversion.') | ||
| .argument('<jobId>', 'job/project ID returned by `convert`') | ||
| .option('--open', 'open the preview URL in your default browser') | ||
| .action(withErrorHandling(async (jobId, opts) => { | ||
| // Confirm the job is finished before spending a preview session. | ||
| const s = await api.getStatus(jobId); | ||
| if (s.status === 'failed') die(`Conversion failed: ${s.error || 'unknown error'}`); | ||
| if (s.status !== 'done') { | ||
| die(`Conversion is not ready yet (status: ${s.status}). Wait until it is "done", then retry.`); | ||
| } | ||
| await showPlaygroundPreview(jobId, { open: !!opts.open }); | ||
| })); | ||
| // ------------------------------- quota -------------------------------------- | ||
@@ -370,0 +562,0 @@ |
+3
-2
| { | ||
| "name": "wpconvert", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "WPConvert.ai CLI — convert a website/codebase folder into a WordPress theme without leaving your terminal.", | ||
@@ -20,3 +20,4 @@ "license": "MIT", | ||
| "scripts": { | ||
| "start": "node bin/wpconvert.js" | ||
| "start": "node bin/wpconvert.js", | ||
| "test": "node --test tests/**/*.test.js" | ||
| }, | ||
@@ -23,0 +24,0 @@ "dependencies": { |
+28
-2
@@ -23,3 +23,3 @@ # wpconvert (CLI) | ||
| API keys require a **Pro/Agency** plan or available **PAYG credits**. Each successful conversion uses **1 credit** (Agency is unlimited up to its soft cap), exactly like the web app. Failed conversions are refunded. | ||
| API keys require a **Pro/Agency** plan or available **PAYG credits** for full downloadable conversions. Free verified accounts may also create preview-only keys (up to **3 lifetime** Playground previews — no theme ZIP download) when the server has developer previews enabled. | ||
@@ -40,3 +40,3 @@ ## Convert a folder | ||
| - Uploads small zips via multipart; large zips go directly to storage (up to your plan ceiling). | ||
| - Polls until done, then downloads the theme `.zip` into the current directory. | ||
| - Polls until done, then downloads the theme `.zip` into the current directory (paid conversions only; preview-only jobs open Playground instead). | ||
@@ -62,2 +62,13 @@ ### Where do I run it? | ||
| ## Preview in WordPress Playground | ||
| Preview a finished conversion in a live, in-browser WordPress (no local install): | ||
| ```bash | ||
| wpconvert preview <jobId> # print a preview URL | ||
| wpconvert preview <jobId> --open # also open it in your default browser | ||
| ``` | ||
| The URL boots WordPress Playground with your theme installed and activated — the same preview you get in the dashboard. Sessions expire after 30 minutes and are use-limited. | ||
| ## Other commands | ||
@@ -68,2 +79,3 @@ | ||
| wpconvert download <jobId> # download a completed conversion | ||
| wpconvert preview <jobId> # preview the theme in WordPress Playground | ||
| wpconvert quota # show remaining conversions / credits | ||
@@ -75,2 +87,16 @@ ``` | ||
| - **Secrets**: always run `--dry-run` first if you're unsure what will be uploaded. The secret denylist is on by default; `--include-env` is the only way to include those files. | ||
| - **Preview links are capability URLs**: anyone with a `wpconvert preview` URL can view the theme until the session expires. Avoid printing them in shared CI logs, and prefer omitting `--open` in headless environments. | ||
| ## Free developer previews (preview-only) | ||
| When enabled on the server, free verified accounts can create API keys that run **preview-only** conversions — WordPress Playground preview, **no theme ZIP download**. Try the CLI/MCP workflow before upgrading. | ||
| - **3 lifetime** preview-only conversions per account (separate from the dashboard free preview). | ||
| - **1 concurrent** job; stricter submit rate limits than paid keys. | ||
| - **Full exports require PRO, Agency, or PAYG credits** — a one-time Starter unlock does not grant programmatic download access. | ||
| - Preview-only jobs are **never retroactively downloadable**; after upgrading, **re-run** the conversion to get a ZIP. | ||
| On success, `wpconvert convert` **automatically creates a Playground preview URL**. Preview-only jobs **auto-open your browser** by default; paid users get the link only (pass `--open` to launch). Use `--no-open` in CI/headless, or `--no-preview` to skip Playground entirely. | ||
| Paid conversions use **1 credit** each (Agency is unlimited up to its soft cap), exactly like the web app. Failed conversions are refunded. | ||
| - **Retries**: the CLI never auto-retries a submit after it's been sent (so you're never double-charged). The large-upload PUT step is idempotent and safe to retry. | ||
@@ -77,0 +103,0 @@ - **URL conversion** is not available yet; point the CLI at a folder. |
+189
-33
@@ -20,3 +20,20 @@ 'use strict'; | ||
| const { getApiBase, getApiKey } = require('./config'); | ||
| const { assertIdempotencyKey, isAmbiguousTransportError, sleep } = require('./idempotency'); | ||
| const CLI_VERSION = require('../package.json').version; | ||
| const IDEMPOTENCY_HEADER = 'Idempotency-Key'; | ||
| const SUBMISSION_TRANSPORT_RETRY_MS = 500; | ||
| /** Optional per-request extras (e.g. MCP tool name). Set via setRequestExtras(). */ | ||
| let requestExtras = {}; | ||
| function setRequestExtras(extras = {}) { | ||
| requestExtras = extras && typeof extras === 'object' ? extras : {}; | ||
| } | ||
| function clientHeaderValue() { | ||
| if (process.env.WPCONVERT_CLIENT) return process.env.WPCONVERT_CLIENT; | ||
| return `cli/${CLI_VERSION}`; | ||
| } | ||
| class ApiError extends Error { | ||
@@ -44,5 +61,19 @@ constructor(message, { code, status, details } = {}) { | ||
| function authHeaders(extra = {}) { | ||
| return { 'X-API-Key': requireKey(), ...extra }; | ||
| const headers = { | ||
| 'X-API-Key': requireKey(), | ||
| 'X-WPConvert-Client': clientHeaderValue(), | ||
| ...extra, | ||
| }; | ||
| if (requestExtras.tool) headers['X-WPConvert-Tool'] = String(requestExtras.tool); | ||
| return headers; | ||
| } | ||
| function submissionHeaders(idempotencyKey, extra = {}) { | ||
| const headers = authHeaders(extra); | ||
| if (idempotencyKey) { | ||
| headers[IDEMPOTENCY_HEADER] = assertIdempotencyKey(idempotencyKey); | ||
| } | ||
| return headers; | ||
| } | ||
| /** Parse a response, throwing ApiError on non-2xx (handles clean + legacy shapes). */ | ||
@@ -58,18 +89,48 @@ async function parseResponse(res) { | ||
| // Clean envelope: { error: { code, message, ... } } | ||
| throw apiErrorFromResponse(res.status, body); | ||
| } | ||
| /** | ||
| * Parse conversion submission responses (200/202 success; structured 409 conflicts). | ||
| * @returns {Promise<object>} | ||
| */ | ||
| async function parseSubmissionResponse(res, { hadTransportRetry = false } = {}) { | ||
| let body = null; | ||
| const text = await res.text(); | ||
| if (text) { | ||
| try { body = JSON.parse(text); } catch (_) { body = { raw: text }; } | ||
| } | ||
| if (res.status === 200 || res.status === 202) { | ||
| if (!body || typeof body !== 'object') { | ||
| throw new ApiError('Conversion started but the server returned an empty response.', { | ||
| code: 'http_error', | ||
| status: res.status, | ||
| details: { hadTransportRetry }, | ||
| }); | ||
| } | ||
| if (hadTransportRetry) body._hadTransportRetry = true; | ||
| return body; | ||
| } | ||
| const err = apiErrorFromResponse(res.status, body); | ||
| if (hadTransportRetry) err.details = { ...err.details, hadTransportRetry: true }; | ||
| throw err; | ||
| } | ||
| function apiErrorFromResponse(status, body) { | ||
| if (body && body.error && typeof body.error === 'object') { | ||
| const { code, message, ...rest } = body.error; | ||
| throw new ApiError(message, { code, status: res.status, details: rest }); | ||
| return new ApiError(message, { code, status, details: rest }); | ||
| } | ||
| // Legacy shape: { error: 'string', message?, ... } | ||
| if (body && typeof body.error === 'string') { | ||
| throw new ApiError(body.message || body.error, { | ||
| return new ApiError(body.message || body.error, { | ||
| code: body.code || body.error, | ||
| status: res.status, | ||
| status, | ||
| details: body, | ||
| }); | ||
| } | ||
| throw new ApiError(`Request failed with status ${res.status}`, { | ||
| return new ApiError(`Request failed with status ${status}`, { | ||
| code: 'http_error', | ||
| status: res.status, | ||
| status, | ||
| details: body || {}, | ||
@@ -79,2 +140,58 @@ }); | ||
| function isNonRetryableSubmissionError(err) { | ||
| if (!(err instanceof ApiError)) return false; | ||
| if ([400, 401, 403, 409, 422, 429].includes(err.status)) return true; | ||
| if (err.code === 'invalid_idempotency_key') return true; | ||
| return false; | ||
| } | ||
| /** | ||
| * Perform one conversion submission with optional bounded transport retry. | ||
| * @param {(headers: Record<string, string>) => Promise<Response>} performRequest | ||
| * @param {{ idempotencyKey?: string }} opts | ||
| */ | ||
| async function submitConversionRequest(performRequest, { idempotencyKey } = {}) { | ||
| const key = idempotencyKey ? assertIdempotencyKey(idempotencyKey) : null; | ||
| let hadTransportRetry = false; | ||
| for (let attempt = 0; attempt < 2; attempt++) { | ||
| let res; | ||
| try { | ||
| res = await performRequest(submissionHeaders(key)); | ||
| } catch (e) { | ||
| if (attempt === 0 && isAmbiguousTransportError(e)) { | ||
| hadTransportRetry = true; | ||
| await sleep(SUBMISSION_TRANSPORT_RETRY_MS); | ||
| continue; | ||
| } | ||
| throw new ApiError(e.message || 'Network request failed', { | ||
| code: 'network_error', | ||
| status: 0, | ||
| details: { | ||
| cause: e.cause?.code || null, | ||
| hadTransportRetry, | ||
| }, | ||
| }); | ||
| } | ||
| try { | ||
| return await parseSubmissionResponse(res, { hadTransportRetry }); | ||
| } catch (e) { | ||
| if (e instanceof ApiError && isNonRetryableSubmissionError(e)) throw e; | ||
| if (attempt === 0 && isAmbiguousTransportError(e)) { | ||
| hadTransportRetry = true; | ||
| await sleep(SUBMISSION_TRANSPORT_RETRY_MS); | ||
| continue; | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
| throw new ApiError('Network request failed after retry.', { | ||
| code: 'network_error', | ||
| status: 0, | ||
| details: { hadTransportRetry: true }, | ||
| }); | ||
| } | ||
| function url(p) { | ||
@@ -95,2 +212,10 @@ return `${getApiBase()}${p}`; | ||
| function buildMultipartFormData(zipBuffer, { projectName, exportType, elementor } = {}) { | ||
| const fd = new FormData(); | ||
| const blob = new Blob([zipBuffer], { type: 'application/zip' }); | ||
| fd.append('file', blob, `${(projectName || 'project').replace(/[^a-z0-9-_]+/gi, '-')}.zip`); | ||
| applyConversionFields((k, v) => fd.append(k, v), { projectName, exportType, elementor }); | ||
| return fd; | ||
| } | ||
| /** GET /api/convert/quota */ | ||
@@ -105,17 +230,13 @@ async function getQuota() { | ||
| * (includes jobId/project_id/status). | ||
| * NOTE: not idempotent — never auto-retry once the request has been sent. | ||
| */ | ||
| async function convertMultipart(zipBuffer, { projectName, exportType, elementor } = {}) { | ||
| const fd = new FormData(); | ||
| const blob = new Blob([zipBuffer], { type: 'application/zip' }); | ||
| // Field name MUST be "file"; filename MUST end in .zip (server fileFilter). | ||
| fd.append('file', blob, `${(projectName || 'project').replace(/[^a-z0-9-_]+/gi, '-')}.zip`); | ||
| applyConversionFields((k, v) => fd.append(k, v), { projectName, exportType, elementor }); | ||
| const res = await fetch(url('/api/convert'), { | ||
| method: 'POST', | ||
| headers: authHeaders(), // do NOT set content-type; fetch sets the multipart boundary | ||
| body: fd, | ||
| }); | ||
| return parseResponse(res); | ||
| async function convertMultipart(zipBuffer, { projectName, exportType, elementor, idempotencyKey } = {}) { | ||
| const fields = { projectName, exportType, elementor }; | ||
| return submitConversionRequest( | ||
| (headers) => fetch(url('/api/convert'), { | ||
| method: 'POST', | ||
| headers, // do NOT set content-type; fetch sets the multipart boundary | ||
| body: buildMultipartFormData(zipBuffer, fields), | ||
| }), | ||
| { idempotencyKey } | ||
| ); | ||
| } | ||
@@ -151,14 +272,17 @@ | ||
| /** Large-zip step 3: POST /api/convert/from-storage. Not idempotent — don't auto-retry. */ | ||
| async function createJobFromStorage(jobId, { projectName, exportType, elementor } = {}) { | ||
| /** Large-zip step 3: POST /api/convert/from-storage */ | ||
| async function createJobFromStorage(jobId, { projectName, exportType, elementor, idempotencyKey } = {}) { | ||
| const body = { jobId }; | ||
| applyConversionFields((k, v) => { body[k] = v; }, { projectName, exportType, elementor }); | ||
| if (body.force_free_safe === 'true') body.force_free_safe = true; | ||
| const payload = JSON.stringify(body); | ||
| const res = await fetch(url('/api/convert/from-storage'), { | ||
| method: 'POST', | ||
| headers: authHeaders({ 'content-type': 'application/json' }), | ||
| body: JSON.stringify(body), | ||
| }); | ||
| return parseResponse(res); | ||
| return submitConversionRequest( | ||
| (headers) => fetch(url('/api/convert/from-storage'), { | ||
| method: 'POST', | ||
| headers: { ...headers, 'content-type': 'application/json' }, | ||
| body: payload, | ||
| }), | ||
| { idempotencyKey } | ||
| ); | ||
| } | ||
@@ -168,5 +292,14 @@ | ||
| async function getStatus(jobId) { | ||
| const res = await fetch(url(`/api/convert/${encodeURIComponent(jobId)}/status`), { | ||
| headers: authHeaders(), | ||
| }); | ||
| let res; | ||
| try { | ||
| res = await fetch(url(`/api/convert/${encodeURIComponent(jobId)}/status`), { | ||
| headers: authHeaders(), | ||
| }); | ||
| } catch (e) { | ||
| throw new ApiError(e.message || 'Network request failed', { | ||
| code: 'network_error', | ||
| status: 0, | ||
| details: { cause: e.cause?.code || null }, | ||
| }); | ||
| } | ||
| return parseResponse(res); | ||
@@ -183,2 +316,15 @@ } | ||
| /** | ||
| * POST /api/playground/sessions -> { playground_url, expires_at, session_id, ... } | ||
| * Creates an on-demand WordPress Playground preview session for a completed job. | ||
| */ | ||
| async function createPlaygroundSession(projectId) { | ||
| const res = await fetch(url('/api/playground/sessions'), { | ||
| method: 'POST', | ||
| headers: authHeaders({ 'content-type': 'application/json' }), | ||
| body: JSON.stringify({ projectId }), | ||
| }); | ||
| return parseResponse(res); | ||
| } | ||
| /** Fetch raw bytes from a (signed) download URL. */ | ||
@@ -199,2 +345,4 @@ async function fetchBinary(downloadUrl) { | ||
| ApiError, | ||
| IDEMPOTENCY_HEADER, | ||
| setRequestExtras, | ||
| getQuota, | ||
@@ -207,3 +355,11 @@ convertMultipart, | ||
| getDownload, | ||
| createPlaygroundSession, | ||
| fetchBinary, | ||
| // exported for tests | ||
| _internals: { | ||
| parseSubmissionResponse, | ||
| submitConversionRequest, | ||
| submissionHeaders, | ||
| buildMultipartFormData, | ||
| }, | ||
| }; |
+13
-4
@@ -119,2 +119,4 @@ 'use strict'; | ||
| * Build the zip in-memory from a manifest. | ||
| * Entry order and timestamps are normalized so identical source bytes produce | ||
| * identical zip bytes — required for idempotent recovery that fingerprints content. | ||
| * @param {{relPath,absPath}[]} files | ||
@@ -125,6 +127,13 @@ * @returns {Buffer} | ||
| const zip = new AdmZip(); | ||
| for (const f of files) { | ||
| // Preserve relative directory structure inside the zip. | ||
| const dir = path.posix.dirname(f.relPath); | ||
| zip.addLocalFile(f.absPath, dir === '.' ? '' : dir); | ||
| const sorted = [...files].sort((a, b) => a.relPath.localeCompare(b.relPath)); | ||
| // DOS epoch — stable across rebuilds (AdmZip rejects pre-1980 dates). | ||
| const stableTime = new Date(Date.UTC(1980, 0, 1, 0, 0, 0)); | ||
| for (const f of sorted) { | ||
| const rel = f.relPath.replace(/\\/g, '/'); | ||
| const data = fs.readFileSync(f.absPath); | ||
| zip.addFile(rel, data); | ||
| const entry = zip.getEntry(rel); | ||
| if (entry && entry.header) { | ||
| entry.header.time = stableTime; | ||
| } | ||
| } | ||
@@ -131,0 +140,0 @@ return zip.toBuffer(); |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
60636
42.07%9
12.5%1335
42.63%2
-33.33%106
32.5%14
27.27%10
25%