Setting the file. One moment. Bootstrap · Wix Headless Entry · wix/skills · Skills Docs — line 134
This file
- Number
- 8.1
- Position
- 1 of 1
- Type
- JavaScript
- Size
- 6 KB
- Lines
- 156
bootstrap.mjs
JavaScript·156 lines·6 KB
{})
=>
9 process.stdout.write(JSON.stringify({ event, ...extra }) + '\n');
10const fail = (event, extra = {}) => {
11 emit(event, { ok: false, ...extra });
12 process.exit(1);
13};
14
15// ── platform-safe binary names (npm/npx are .cmd on Windows) ─────────────────
16const isWin = process.platform === 'win32';
17const bin = (name) => (isWin ? `${name}.cmd` : name);
18const WIX = [bin('npx'), '-y', '@wix/cli@latest']; // run the CLI via npx — no global install/mutation
19
20// Force the CLI into non-interactive "agent" mode. Without an agent signal in
21// the env, `wix login` renders an interactive Ink TUI (device code + keypress)
22// that needs a raw TTY and crashes in an agent sandbox — and it never emits the
23// JSON login events this script forwards. The CLI uses @vercel/detect-agent,
24// whose first check is the AI_AGENT env var, so setting it guarantees agent mode
25// (and the awaiting_user/success/logged_in events) for every child we spawn.
26// Respect an existing value so a known runner (claude, cursor, …) keeps its name.
27const AGENT_ENV = { ...process.env, AI_AGENT: process.env.AI_AGENT || 'wix-headless-skill' };
28
29// Where the detached login parks its output between runs. It has to outlive this
30// process, so it can't be a pipe and can't live in the project.
31const STATE_DIR = join(tmpdir(), 'wix-headless-login');
32const LOG = join(STATE_DIR, 'login.log');
33const PIDFILE = join(STATE_DIR, 'login.pid');
34
35// run a command, capture stdout+stderr (combined), return {status, out}
36function capture(cmd, args, opts = {}) {
37 const r = spawnSync(cmd, args, { encoding: 'utf8', shell: isWin, env: AGENT_ENV, ...opts });
38 return { status: r.status ?? 1, out: `${r.stdout || ''}${r.stderr || ''}`, error: r.error };
39}
40
41// ── 1. CLI reachable (via npx — no install) ──────────────────────────────────
42function checkCli() {
43 const r = capture(WIX[0], [...WIX.slice(1), '--version']);
44 if (r.status !== 0) fail('cli_unreachable', { detail: r.out.trim().slice(0, 400) });
45 // npx interleaves "npm notice …" lines with the version, so don't just take the
46 // last line — pick the first semver-looking token, falling back to the last line.
47 const version = (r.out.match(/\d+\.\d+\.\d+[^\s]*/) || [])[0] || r.out.trim().split('\n').pop();
48 emit('cli_ok', { version });
49}
50
51// ── 2. Reuse an existing session, or start device login ──────────────────────
52const hasExistingSession = () => capture(WIX[0], [...WIX.slice(1), 'whoami']).status === 0;
53
54function loginEvents() {
55 if (!existsSync(LOG)) return [];
56 return readFileSync(LOG, 'utf8')
57 .split('\n')
58 .map((l) => l.trim())
59 .filter(Boolean)
60 .map((l) => {
61 try {
62 return JSON.parse(l);
63 } catch {
64 return null; // non-JSON CLI chatter
65 }
66 })
67 .filter((e) => e && e.event);
68}
69
70const alive = (pid) => {
71 try {
72 process.kill(pid, 0);
73 return true;
74 } catch {
75 return false;
76 }
77};
78
79// A login this machine started earlier may still be waiting on the browser. Reuse
80// its code instead of minting a second one — a new login invalidates nothing, but
81// it hands the user a different code than the one already on their screen.
82function pendingLogin() {
83 if (!existsSync(PIDFILE)) return null;
84 let state;
85 try {
86 state = JSON.parse(readFileSync(PIDFILE, 'utf8'));
87 } catch {
88 return null;
89 }
90 if (!state.pid || !alive(state.pid)) return null;
91 const ev = loginEvents().find((e) => e.event === 'awaiting_user');
92 if (!ev) return null;
93 // Don't hand back a code that's about to expire mid-typing.
94 const ageSeconds = Math.round((Date.now() - state.startedAt) / 1000);
95 if (ageSeconds > (ev.expiresInSeconds ?? 600) - 60) return null;
96 return ev;
97}
98
99// Detach so the login keeps polling after this process exits. stdio goes to a
100// file, not a pipe: a pipe dies with the parent, and the CLI would get EPIPE.
101function startLogin() {
102 mkdirSync(STATE_DIR, { recursive: true });
103 writeFileSync(LOG, '');
104 const out = openSync(LOG, 'a');
105 const child = spawn(WIX[0], [...WIX.slice(1), 'login'], {
106 detached: true,
107 stdio: ['ignore', out, out],
108 env: AGENT_ENV,
109 shell: isWin,
110 });
111 child.on('error', (e) => fail('login_failed', { detail: String(e) }));
112 writeFileSync(PIDFILE, JSON.stringify({ pid: child.pid, startedAt: Date.now() }));
113 child.unref();
114}
115
116async function waitForCode(timeoutMs = 60000) {
117 const deadline = Date.now() + timeoutMs;
118 while (Date.now() < deadline) {
119 const events = loginEvents();
120 const ev = events.find((e) => e.event === 'awaiting_user');
121 if (ev) return ev;
122 const bad = events.find((e) => e.event === 'login_failed');
123 if (bad) fail('login_failed', bad);
124 await new Promise((r) => setTimeout(r, 500));
125 }
126 const detail = existsSync(LOG) ? readFileSync(LOG, 'utf8').trim().slice(-1500) : '';
127 fail('login_failed', {
128 detail: detail || `wix login produced no device code within ${timeoutMs / 1000}s.`,
129 });
130}
131
132// The next move is the user's, so hand the code back and exit rather than holding
133// the caller open for a browser round-trip. `message` is the sentence to relay.
134function surrenderTo(ev) {
135 emit('awaiting_user', {
136 ...ev,
137 message:
138 `To connect your Wix account, open ${ev.verificationUri} and enter the code ` +
139 `${ev.userCode}. Tell me once you're done and I'll continue.`,
140 });
141 process.exit(0);
142}
143
144// ── main ────────────────────────────────────────────────────────────────────
145checkCli();
146
147if (hasExistingSession()) {
148 emit('logged_in');
149 process.exit(0);
150}
151
152const pending = pendingLogin();
153if (pending) surrenderTo(pending);
154
155startLogin();
156surrenderTo(await waitForCode());