Sign In

wpconvert

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

wpconvert - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+80
src/idempotency.js
'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": {

@@ -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,
},
};

@@ -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();