← Files WixARCHIVED FILE
skills/wix-headless-kit/install/bootstrap.mjs
6.3 KB · Oct 8, 2026 · 12:02 UTC
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const PINS = JSON.parse(readFileSync(new URL("./pins.json", import.meta.url), "utf8"));
const WIX_CLI = `@wix/cli@${PINS["@wix/cli"]}`;
// ── tiny event protocol (one JSON object per line) ───────────────────────────
const emit = (event, extra = {}) =>
process.stdout.write(JSON.stringify({ event, ...extra }) + '\n');
const fail = (event, extra = {}) => {
emit(event, { ok: false, ...extra });
process.exit(1);
};
// ── platform-safe binary names (npm/npx are .cmd on Windows) ─────────────────
const isWin = process.platform === 'win32';
const bin = (name) => (isWin ? `${name}.cmd` : name);
const WIX = [bin('npx'), '-y', WIX_CLI]; // run the CLI via npx — no global install/mutation
// Force the CLI into non-interactive "agent" mode. Without an agent signal in
// the env, `wix login` renders an interactive Ink TUI (device code + keypress)
// that needs a raw TTY and crashes in an agent sandbox — and it never emits the
// JSON login events this script forwards. The CLI uses @vercel/detect-agent,
// whose first check is the AI_AGENT env var, so setting it guarantees agent mode
// (and the awaiting_user/success/logged_in events) for every child we spawn.
// Respect an existing value so a known runner (claude, cursor, …) keeps its name.
const AGENT_ENV = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'wix-headless-kit-skill' };
// Where the detached login parks its output between runs. It has to outlive this
// process, so it can't be a pipe and can't live in the project.
const STATE_DIR = join(tmpdir(), 'wix-headless-kit-login');
const LOG = join(STATE_DIR, 'login.log');
const PIDFILE = join(STATE_DIR, 'login.pid');
// run a command, capture stdout+stderr (combined), return {status, out}
function capture(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', shell: isWin, env: AGENT_ENV, ...opts });
return { status: r.status ?? 1, out: `${r.stdout || ''}${r.stderr || ''}`, error: r.error };
}
// ── 1. CLI reachable (via npx — no install) ──────────────────────────────────
function checkCli() {
const r = capture(WIX[0], [...WIX.slice(1), '--version']);
if (r.status !== 0) fail('cli_unreachable', { detail: r.out.trim().slice(0, 400) });
// npx interleaves "npm notice …" lines with the version, so don't just take the
// last line — pick the first semver-looking token, falling back to the last line.
const version = (r.out.match(/\d+\.\d+\.\d+[^\s]*/) || [])[0] || r.out.trim().split('\n').pop();
emit('cli_ok', { version });
}
// ── 2. Reuse an existing session, or start device login ──────────────────────
const hasExistingSession = () => capture(WIX[0], [...WIX.slice(1), 'whoami']).status === 0;
function loginEvents() {
if (!existsSync(LOG)) return [];
return readFileSync(LOG, 'utf8')
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null; // non-JSON CLI chatter
}
})
.filter((e) => e && e.event);
}
const alive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
// A login this machine started earlier may still be waiting on the browser. Reuse
// its code instead of minting a second one — a new login invalidates nothing, but
// it hands the user a different code than the one already on their screen.
function pendingLogin() {
if (!existsSync(PIDFILE)) return null;
let state;
try {
state = JSON.parse(readFileSync(PIDFILE, 'utf8'));
} catch {
return null;
}
if (!state.pid || !alive(state.pid)) return null;
const ev = loginEvents().find((e) => e.event === 'awaiting_user');
if (!ev) return null;
// Don't hand back a code that's about to expire mid-typing.
const ageSeconds = Math.round((Date.now() - state.startedAt) / 1000);
if (ageSeconds > (ev.expiresInSeconds ?? 600) - 60) return null;
return ev;
}
// Detach so the login keeps polling after this process exits. stdio goes to a
// file, not a pipe: a pipe dies with the parent, and the CLI would get EPIPE.
function startLogin() {
mkdirSync(STATE_DIR, { recursive: true });
writeFileSync(LOG, '');
const out = openSync(LOG, 'a');
const child = spawn(WIX[0], [...WIX.slice(1), 'login'], {
detached: true,
stdio: ['ignore', out, out],
env: AGENT_ENV,
shell: isWin,
});
child.on('error', (e) => fail('login_failed', { detail: String(e) }));
writeFileSync(PIDFILE, JSON.stringify({ pid: child.pid, startedAt: Date.now() }));
child.unref();
}
async function waitForCode(timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const events = loginEvents();
const ev = events.find((e) => e.event === 'awaiting_user');
if (ev) return ev;
const bad = events.find((e) => e.event === 'login_failed');
if (bad) fail('login_failed', bad);
await new Promise((r) => setTimeout(r, 500));
}
const detail = existsSync(LOG) ? readFileSync(LOG, 'utf8').trim().slice(-1500) : '';
fail('login_failed', {
detail: detail || `wix login produced no device code within ${timeoutMs / 1000}s.`,
});
}
// The next move is the user's, so hand the code back and exit rather than holding
// the caller open for a browser round-trip. `message` is the sentence to relay.
function surrenderTo(ev) {
emit('awaiting_user', {
...ev,
message:
`To connect your Wix account, open ${ev.verificationUri} and enter the code ` +
`${ev.userCode}. Tell me once you're done and I'll continue.`,
});
process.exit(0);
}
// ── main ────────────────────────────────────────────────────────────────────
checkCli();
if (hasExistingSession()) {
emit('logged_in');
process.exit(0);
}
const pending = pendingLogin();
if (pending) surrenderTo(pending);
startLogin();
surrenderTo(await waitForCode());
SHA-256: 74142387ded7c9d84e29413c617694791abf2c9da33ebf0278640f9ad46bacf1