@xuda.io/ai_module 1.1.5656 → 1.1.5658

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ // The verify loop. This is the product.
2
+ //
3
+ // docs/plans/xudex.md section 6. Codex and Claude Code are terminals: they will happily
4
+ // tell you they are done and leave your build broken, because they have no environment to
5
+ // check against. Xudex has one, so after every run and BEFORE the customer is told anything
6
+ // succeeded, the change is put through the project's own gates:
7
+ //
8
+ // install (only if the manifest moved) -> typecheck -> test -> build -> the running app
9
+ //
10
+ // A failure is fed back to the engine for a bounded number of repair attempts. If it cannot
11
+ // get to green it SAYS SO and shows the failure. It never claims success on a broken tree,
12
+ // and it never hands back a working copy the customer has to untangle by hand.
13
+ //
14
+ // This is also where the honest version of the efficiency claim comes from (plan section
15
+ // 11): fewer retries is efficiency, and it arrives as a byproduct of checking rather than
16
+ // as a head-on token fight we would lose.
17
+ //
18
+ // ── Two rules that sound small and are not ─────────────────────────────────────────────
19
+ //
20
+ // NEVER CLAIM A STEP THAT DID NOT RUN. A project with no test script has not passed its
21
+ // tests; it has no tests. Reporting "tests passed" there is the same lie as reporting it on
22
+ // a broken tree, just harder to notice, and it is the lie that would make the whole
23
+ // verification worthless the first time somebody checked.
24
+ //
25
+ // NEVER LET THE AGENT DELETE THE TEST. Told only "the tests fail", a coding agent will
26
+ // cheerfully make them stop failing by removing them. The repair prompt says so explicitly,
27
+ // because it is the single most common way an automated fix loop produces a green build
28
+ // that means nothing.
29
+
30
+ import path from 'node:path';
31
+
32
+ if (!global._conf) {
33
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
34
+ }
35
+
36
+ const verify_conf = () => global._conf.xudex?.verify || {};
37
+
38
+ const DEFAULTS = {
39
+ max_repair_attempts: 2,
40
+ step_timeout_ms: 10 * 60 * 1000,
41
+ install_timeout_ms: 15 * 60 * 1000,
42
+ output_tail_lines: 60,
43
+ output_max_chars: 8000,
44
+ };
45
+
46
+ const conf_num = (key) => Number(verify_conf()[key]) || DEFAULTS[key];
47
+
48
+ // The npm placeholder. `npm init` writes a test script that exits 1 with "no test
49
+ // specified", so treating the mere presence of `scripts.test` as "this project has tests"
50
+ // would fail every fresh project forever and teach customers the loop is broken.
51
+ const NPM_TEST_PLACEHOLDER = /no test specified/i;
52
+
53
+ // Which package manager the project actually uses, decided by its lockfile rather than by
54
+ // preference. Running npm in a pnpm project half-works and then fails strangely later.
55
+ export const detect_package_manager = function (files) {
56
+ if (files.includes('pnpm-lock.yaml')) return 'pnpm';
57
+ if (files.includes('yarn.lock')) return 'yarn';
58
+ return 'npm';
59
+ };
60
+
61
+ const install_argv = (pm) => (pm === 'pnpm' ? ['pnpm', 'install'] : pm === 'yarn' ? ['yarn', 'install'] : ['npm', 'install']);
62
+ const run_argv = (pm, script) => (pm === 'npm' ? ['npm', 'run', script] : [pm, 'run', script]);
63
+
64
+ // Work out what this project can actually be checked with. Every step carries WHY it was
65
+ // included or skipped, because "skipped" has to be reportable: a step that silently
66
+ // vanishes is indistinguishable from one that passed.
67
+ export const plan_steps = function ({ manifest, files = [], node_modules_present = false, manifest_changed = false } = {}) {
68
+ const pkg = manifest || {};
69
+ const scripts = pkg.scripts || {};
70
+ const pm = detect_package_manager(files);
71
+ const has_deps = Object.keys(pkg.dependencies || {}).length > 0 || Object.keys(pkg.devDependencies || {}).length > 0;
72
+ const steps = [];
73
+
74
+ // Install, only when it would change something. Reinstalling on every run would add
75
+ // minutes to a loop whose whole promise is that the second run is fast.
76
+ if (has_deps && (!node_modules_present || manifest_changed)) {
77
+ steps.push({
78
+ name: 'install',
79
+ argv: install_argv(pm),
80
+ timeout_ms: conf_num('install_timeout_ms'),
81
+ why: !node_modules_present ? 'dependencies are not installed yet' : 'the manifest changed',
82
+ });
83
+ } else if (has_deps) {
84
+ steps.push({ name: 'install', skipped: true, why: 'dependencies already installed and the manifest did not change' });
85
+ }
86
+
87
+ if (scripts.typecheck) {
88
+ steps.push({ name: 'typecheck', argv: run_argv(pm, 'typecheck'), timeout_ms: conf_num('step_timeout_ms'), why: 'the project has a typecheck script' });
89
+ } else if (files.includes('tsconfig.json')) {
90
+ steps.push({ name: 'typecheck', argv: ['npx', '--no-install', 'tsc', '--noEmit'], timeout_ms: conf_num('step_timeout_ms'), why: 'the project has a tsconfig' });
91
+ } else {
92
+ steps.push({ name: 'typecheck', skipped: true, why: 'this project is not typechecked' });
93
+ }
94
+
95
+ if (scripts.test && !NPM_TEST_PLACEHOLDER.test(scripts.test)) {
96
+ steps.push({ name: 'test', argv: run_argv(pm, 'test'), timeout_ms: conf_num('step_timeout_ms'), why: 'the project has a test script' });
97
+ } else {
98
+ steps.push({ name: 'test', skipped: true, why: scripts.test ? 'the test script is the npm placeholder' : 'this project has no tests' });
99
+ }
100
+
101
+ if (scripts.build) {
102
+ steps.push({ name: 'build', argv: run_argv(pm, 'build'), timeout_ms: conf_num('step_timeout_ms'), why: 'the project has a build script' });
103
+ } else {
104
+ steps.push({ name: 'build', skipped: true, why: 'this project has no build step' });
105
+ }
106
+
107
+ return { package_manager: pm, steps };
108
+ };
109
+
110
+ // Keep the END of the output. Compilers and test runners print the summary last, and a
111
+ // head-truncated log is the half without the error in it.
112
+ export const tail_output = function (text, lines = conf_num('output_tail_lines'), max_chars = conf_num('output_max_chars')) {
113
+ const all = String(text || '').split('\n');
114
+ let out = all.slice(-lines).join('\n');
115
+ if (out.length > max_chars) out = out.slice(-max_chars);
116
+ return out.trim();
117
+ };
118
+
119
+ // What the engine is told when a step fails. Deliberately narrow: one failing step, its
120
+ // command, its exit code and the tail of what it printed. Handing over every step's output
121
+ // buries the actual error in noise and costs tokens for the privilege.
122
+ export const repair_prompt = function (result) {
123
+ const failed = result?.failed;
124
+ if (!failed) return null;
125
+ return [
126
+ 'Your change did not pass verification. Fix it.',
127
+ '',
128
+ `Step: ${failed.name}`,
129
+ `Command: ${(failed.argv || []).join(' ')}`,
130
+ `Exit code: ${failed.exit_code}`,
131
+ '',
132
+ 'Output:',
133
+ failed.output,
134
+ '',
135
+ // Without this line an agent will reliably "fix" a failing test by deleting it, or by
136
+ // loosening the assertion until it passes. That produces a green build that means
137
+ // nothing, which is worse than a red one because it is believed.
138
+ 'Fix the underlying cause. Do NOT delete, skip or weaken tests to make this pass, and do not disable typechecking. If you believe the test itself is wrong, say so and explain why instead of changing it.',
139
+ ].join('\n');
140
+ };
141
+
142
+ // Run the gates. `workspace` is the runtime interface's handle (xudex_runtime.mjs), so this
143
+ // is identical on a VM, an ephemeral runner or a container.
144
+ //
145
+ // `runtime_check` is the seam for the preview leg (build order step 11): once a preview
146
+ // exists it gets passed in here and its runtime errors become just another failing step.
147
+ // Absent, that stage is reported as not run rather than quietly assumed fine.
148
+ export const run_verify = async function ({ workspace, manifest, files = [], node_modules_present = false, manifest_changed = false, runtime_check = null, on_step = null } = {}) {
149
+ const planned = plan_steps({ manifest, files, node_modules_present, manifest_changed });
150
+ const results = [];
151
+ let failed = null;
152
+
153
+ for (const step of planned.steps) {
154
+ if (step.skipped) {
155
+ results.push({ name: step.name, ran: false, ok: null, why: step.why });
156
+ if (on_step) on_step(results[results.length - 1]);
157
+ continue;
158
+ }
159
+
160
+ const started = Date.now();
161
+ let ret;
162
+ try {
163
+ ret = await workspace.exec(step.argv, { timeout_ms: step.timeout_ms });
164
+ } catch (err) {
165
+ ret = { exit_code: -1, stdout: '', stderr: `could not run ${step.argv.join(' ')}: ${err.message}`, timed_out: false };
166
+ }
167
+ const output = tail_output(`${ret.stdout || ''}\n${ret.stderr || ''}`);
168
+ const entry = {
169
+ name: step.name,
170
+ ran: true,
171
+ ok: ret.exit_code === 0,
172
+ exit_code: ret.exit_code,
173
+ argv: step.argv,
174
+ ms: Date.now() - started,
175
+ timed_out: ret.timed_out === true,
176
+ output,
177
+ why: step.why,
178
+ };
179
+ results.push(entry);
180
+ if (on_step) on_step(entry);
181
+
182
+ // Stop at the first failure. Later gates run on the state the failing one left behind,
183
+ // so their output is noise at best and misleading at worst: a typecheck error makes the
184
+ // test suite fail too, and reporting both invites fixing the wrong one.
185
+ if (!entry.ok) {
186
+ failed = entry;
187
+ break;
188
+ }
189
+ }
190
+
191
+ // The running application, when there is one to check.
192
+ if (!failed && typeof runtime_check === 'function') {
193
+ const started = Date.now();
194
+ let rc;
195
+ try {
196
+ rc = await runtime_check(workspace);
197
+ } catch (err) {
198
+ rc = { ok: false, output: `the preview check failed: ${err.message}` };
199
+ }
200
+ const entry = { name: 'runtime', ran: true, ok: rc.ok !== false, ms: Date.now() - started, output: tail_output(rc.output || ''), argv: ['preview'], exit_code: rc.ok === false ? 1 : 0, why: 'the project has a running preview' };
201
+ results.push(entry);
202
+ if (on_step) on_step(entry);
203
+ if (!entry.ok) failed = entry;
204
+ } else if (!failed) {
205
+ results.push({ name: 'runtime', ran: false, ok: null, why: 'no preview is running for this project' });
206
+ }
207
+
208
+ const ran = results.filter((r) => r.ran);
209
+ return {
210
+ ok: !failed,
211
+ failed,
212
+ steps: results,
213
+ package_manager: planned.package_manager,
214
+ // The honest headline. "Green" is only claimed for gates that actually ran, and a
215
+ // project with nothing to check gets told that rather than congratulated.
216
+ summary: summarize(results),
217
+ checked: ran.length,
218
+ };
219
+ };
220
+
221
+ export const summarize = function (results) {
222
+ const ran = results.filter((r) => r.ran);
223
+ const failed = ran.find((r) => r.ok === false);
224
+ if (failed) return `${failed.name} failed`;
225
+ if (!ran.length) return 'nothing to verify: this project has no install, typecheck, test or build step';
226
+ const names = ran.map((r) => r.name).join(', ');
227
+ const skipped = results.filter((r) => !r.ran && r.name !== 'runtime').map((r) => r.name);
228
+ return skipped.length ? `${names} passed (${skipped.join(' and ')} not configured)` : `${names} passed`;
229
+ };
230
+
231
+ // The loop itself: verify, and if it failed, hand the failure back to the engine and verify
232
+ // again, up to a bounded number of attempts.
233
+ //
234
+ // `repair` is injected so this file never learns how to launch an engine, which is what
235
+ // keeps the two seams (engines, runtime) independent of each other.
236
+ export const verify_with_repair = async function ({ workspace, repair, max_attempts = conf_num('max_repair_attempts'), on_step = null, on_attempt = null, ...ctx } = {}) {
237
+ const attempts = [];
238
+ let result = await run_verify({ workspace, on_step, ...ctx });
239
+ attempts.push({ attempt: 0, ok: result.ok, summary: result.summary });
240
+
241
+ let n = 0;
242
+ while (!result.ok && typeof repair === 'function' && n < max_attempts) {
243
+ n++;
244
+ if (on_attempt) on_attempt({ attempt: n, of: max_attempts, fixing: result.failed?.name });
245
+ let repaired = false;
246
+ try {
247
+ repaired = await repair(repair_prompt(result), { attempt: n, failed: result.failed });
248
+ } catch (err) {
249
+ console.warn(`[xudex] repair attempt ${n} threw: ${err.message}`);
250
+ break;
251
+ }
252
+ // A repair that declined to change anything will fail identically next time, so
253
+ // spending another engine turn on it is pure cost.
254
+ if (repaired === false) break;
255
+ // After a repair, dependencies may have moved, so the manifest is treated as changed.
256
+ result = await run_verify({ workspace, on_step, ...ctx, manifest_changed: true });
257
+ attempts.push({ attempt: n, ok: result.ok, summary: result.summary });
258
+ }
259
+
260
+ return {
261
+ ...result,
262
+ attempts,
263
+ repair_attempts: n,
264
+ // What the customer is told. On failure this is the whole point of the feature: it did
265
+ // not work, here is which gate, here is what it printed.
266
+ verdict: result.ok
267
+ ? n > 0
268
+ ? `${result.summary} after ${n} automatic ${n === 1 ? 'fix' : 'fixes'}`
269
+ : result.summary
270
+ : `${result.summary}${n ? ` after ${n} automatic ${n === 1 ? 'fix attempt' : 'fix attempts'}` : ''}`,
271
+ };
272
+ };
package/xudex_vm.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // The VM substrate: a xudex workspace on the account's own machine.
2
+ //
3
+ // docs/plans/xudex.md 3.2 and 3.8. This is the second implementation of the runtime
4
+ // interface, and building it is what proves that seam was real rather than a comment: the
5
+ // engine, the verify loop, the tracker and preview are not touched by anything in this file.
6
+ //
7
+ // ── It does NOT use SSH, and that is not a preference ──────────────────────────────────
8
+ // A xudex machine is `app_type: 'vps'` (the flavor rides on `is_xudex`, see deploy_xudex),
9
+ // and customer VPS on this platform are KEYLESS by design: since the 2026-07-15 consent
10
+ // split no standing platform SSH key lives in those guests, and they correctly refuse one.
11
+ // Reaching them over SSH would mean pushing the control-plane key back in, which regresses
12
+ // the keyless model on purpose-built grounds.
13
+ //
14
+ // So this runs over the Proxmox qemu-guest-agent channel, exactly as the Files feature
15
+ // already does (`_farm_fs_via_agent` in app_module). That happens to line up perfectly with
16
+ // the product rule in plan 3.7: the customer gets no shell, and neither do we.
17
+ //
18
+ // ── What the channel cannot do, stated up front ────────────────────────────────────────
19
+ // `agent/exec` dispatches a command and returns a pid; output is only readable once the
20
+ // process EXITS. There is no stream. A build takes minutes and an engine run takes longer,
21
+ // so two things follow and both are handled below:
22
+ //
23
+ // 1. Polling needs a far bigger budget than the Files layer's ~25 seconds.
24
+ // 2. Live progress has to come from somewhere else. Long commands are redirected to a log
25
+ // file in the guest and that file is tailed incrementally between status polls, which
26
+ // is how `onStdout` keeps working for callers that stream.
27
+
28
+ import path from 'node:path';
29
+
30
+ if (!global._conf) {
31
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
32
+ }
33
+
34
+ // Single-quote for the guest's shell. Every path and value crossing this boundary is
35
+ // attacker-influenced: the agent writes the file names and the customer writes the prompt.
36
+ export const shq = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
37
+
38
+ const vm_conf = () => global._conf.xudex?.vm || {};
39
+
40
+ export const create_vm_substrate = function ({ pve_request, resolve_machine, delay = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now() }) {
41
+ if (typeof pve_request !== 'function') throw new Error('create_vm_substrate needs pve_request');
42
+ if (typeof resolve_machine !== 'function') throw new Error('create_vm_substrate needs resolve_machine');
43
+
44
+ const poll_interval = () => Number(vm_conf().poll_interval_ms) || 700;
45
+
46
+ // Dispatch a script in the guest and wait for it, with a deadline rather than a poll count.
47
+ // `on_tail` is called with new bytes from the log file as they appear, which is the only way
48
+ // to see progress on a channel that otherwise speaks once, at the end.
49
+ const guest_exec = async function ({ node_doc, vmid, script, timeout_ms, on_tail = null, log_file = null }) {
50
+ const body = [
51
+ ['command', '/bin/bash'],
52
+ ['command', '-c'],
53
+ ['command', script],
54
+ ];
55
+ const disp = await pve_request(node_doc, 'POST', `/nodes/${node_doc.node}/qemu/${vmid}/agent/exec`, body);
56
+ const pid = disp && (disp.pid ?? disp.PID);
57
+ if (pid == null) throw new Error('guest exec returned no pid (is qemu-guest-agent running?)');
58
+
59
+ const deadline = now() + (Number(timeout_ms) || 30 * 60 * 1000);
60
+ let offset = 0;
61
+
62
+ while (now() < deadline) {
63
+ const st = await pve_request(node_doc, 'GET', `/nodes/${node_doc.node}/qemu/${vmid}/agent/exec-status?pid=${pid}`);
64
+ if (st && st.exited) {
65
+ return {
66
+ stdout: st['out-data'] != null ? String(st['out-data']) : '',
67
+ stderr: st['err-data'] != null ? String(st['err-data']) : '',
68
+ exit_code: st.exitcode ?? 0,
69
+ truncated: !!(st['out-truncated'] || st['err-truncated']),
70
+ timed_out: false,
71
+ };
72
+ }
73
+
74
+ // Still running. If the caller wants progress and we redirected to a log, read what is
75
+ // new since last time. `tail -c +N` is one byte-offset read rather than re-sending the
76
+ // whole file every second, which matters when a build prints megabytes.
77
+ if (on_tail && log_file) {
78
+ try {
79
+ const chunk = await pve_request(node_doc, 'POST', `/nodes/${node_doc.node}/qemu/${vmid}/agent/exec`, [
80
+ ['command', '/bin/bash'],
81
+ ['command', '-c'],
82
+ ['command', `tail -c +${offset + 1} ${shq(log_file)} 2>/dev/null | head -c 200000`],
83
+ ]);
84
+ const cpid = chunk && (chunk.pid ?? chunk.PID);
85
+ if (cpid != null) {
86
+ const cst = await pve_request(node_doc, 'GET', `/nodes/${node_doc.node}/qemu/${vmid}/agent/exec-status?pid=${cpid}`);
87
+ const text = cst && cst.exited && cst['out-data'] != null ? String(cst['out-data']) : '';
88
+ if (text) {
89
+ offset += Buffer.byteLength(text);
90
+ on_tail(text);
91
+ }
92
+ }
93
+ } catch (e) {
94
+ // Progress is a courtesy. Losing it must never fail the command it is watching.
95
+ }
96
+ }
97
+
98
+ await delay(poll_interval());
99
+ }
100
+
101
+ // Out of time. Kill the process group in the guest rather than leaving a build running on
102
+ // a machine the customer is paying for, then say so.
103
+ try {
104
+ await pve_request(node_doc, 'POST', `/nodes/${node_doc.node}/qemu/${vmid}/agent/exec`, [
105
+ ['command', '/bin/bash'],
106
+ ['command', '-c'],
107
+ ['command', `kill -TERM -${pid} 2>/dev/null; kill -KILL -${pid} 2>/dev/null; true`],
108
+ ]);
109
+ } catch (e) {}
110
+ return { stdout: '', stderr: `the command did not finish within ${Math.round((Number(timeout_ms) || 0) / 1000)}s and was stopped`, exit_code: 124, truncated: false, timed_out: true };
111
+ };
112
+
113
+ return {
114
+ kind: 'vm',
115
+ guest_exec,
116
+
117
+ // Available when this box can reach Proxmox at all. Whether a PARTICULAR account has a
118
+ // machine is answered by acquire, because that is a per-request question.
119
+ available() {
120
+ return global._conf.xudex?.enabled === true && !!global._conf.proxmox && global._conf.proxmox.fs_agent !== false;
121
+ },
122
+
123
+ async acquire({ uid, project_id, app_id }) {
124
+ const machine = await resolve_machine({ uid, app_id: app_id || project_id });
125
+ if (!machine || machine.error) throw new Error(machine?.error || 'this account has no xudex machine');
126
+ const { node_doc, vmid } = machine;
127
+ const root = global._conf.xudex?.projects_root || '/srv/xudex';
128
+ const dir = path.posix.join(root, String(project_id));
129
+
130
+ await guest_exec({ node_doc, vmid, script: `mkdir -p ${shq(dir)}`, timeout_ms: 60000 });
131
+
132
+ const safe = (rel) => {
133
+ const resolved = path.posix.resolve(dir, String(rel || ''));
134
+ if (resolved !== dir && !resolved.startsWith(dir + '/')) throw new Error(`path escapes the workspace: ${rel}`);
135
+ return resolved;
136
+ };
137
+
138
+ return {
139
+ id: `vm:${vmid}:${project_id}`,
140
+ kind: 'vm',
141
+ project_id,
142
+ dir,
143
+ vmid,
144
+
145
+ async exec(argv, opts = {}) {
146
+ if (!Array.isArray(argv) || !argv.length) throw new Error('exec needs an argv array');
147
+ const cwd = opts.cwd ? safe(opts.cwd) : dir;
148
+ const env = Object.entries(opts.env || {})
149
+ .map(([k, v]) => `export ${k}=${shq(v)};`)
150
+ .join(' ');
151
+ const command = argv.map(shq).join(' ');
152
+ const started = now();
153
+
154
+ // A caller that wants progress gets a log file and an incremental tail; one that does
155
+ // not gets the plain form, which is cheaper by one exec per poll.
156
+ const log_file = opts.onStdout ? `/tmp/xudex-exec-${vmid}-${started}.log` : null;
157
+ const script = log_file
158
+ ? `cd ${shq(cwd)}; ${env} { ${command}; } > ${shq(log_file)} 2>&1; rc=$?; cat ${shq(log_file)}; rm -f ${shq(log_file)}; exit $rc`
159
+ : `cd ${shq(cwd)}; ${env} ${command}`;
160
+
161
+ const ret = await guest_exec({ node_doc, vmid, script, timeout_ms: opts.timeout_ms, on_tail: opts.onStdout || null, log_file });
162
+ return { exit_code: ret.exit_code, stdout: ret.stdout || '', stderr: ret.stderr || '', timed_out: ret.timed_out === true, ms: now() - started, truncated: ret.truncated };
163
+ },
164
+
165
+ // Base64 across the channel in both directions. Proxmox hands back out-data already
166
+ // decoded as TEXT, so anything binary (or merely non-UTF8) would be mangled in transit;
167
+ // encoding inside the guest is the same fix the Files layer arrived at.
168
+ async read_file(rel) {
169
+ const p = safe(rel);
170
+ const ret = await guest_exec({ node_doc, vmid, script: `base64 -w0 ${shq(p)}`, timeout_ms: 120000 });
171
+ if (ret.exit_code !== 0) throw new Error(`could not read ${rel}: ${(ret.stderr || '').trim()}`);
172
+ return Buffer.from((ret.stdout || '').trim(), 'base64').toString('utf8');
173
+ },
174
+
175
+ async write_file(rel, data) {
176
+ const p = safe(rel);
177
+ const b64 = Buffer.from(String(data), 'utf8').toString('base64');
178
+ // Chunked because the whole script is one argv element and the guest has a hard argv
179
+ // limit (~128 KB); a single big write silently fails at the API boundary otherwise.
180
+ const CHUNK = 60 * 1024;
181
+ await guest_exec({ node_doc, vmid, script: `mkdir -p ${shq(path.posix.dirname(p))} && : > ${shq(p)}.b64`, timeout_ms: 60000 });
182
+ for (let i = 0; i < b64.length; i += CHUNK) {
183
+ const part = b64.slice(i, i + CHUNK);
184
+ const ret = await guest_exec({ node_doc, vmid, script: `printf %s ${shq(part)} >> ${shq(p)}.b64`, timeout_ms: 120000 });
185
+ if (ret.exit_code !== 0) throw new Error(`could not write ${rel}: ${(ret.stderr || '').trim()}`);
186
+ }
187
+ const fin = await guest_exec({ node_doc, vmid, script: `base64 -d ${shq(p)}.b64 > ${shq(p)} && rm -f ${shq(p)}.b64`, timeout_ms: 120000 });
188
+ if (fin.exit_code !== 0) throw new Error(`could not write ${rel}: ${(fin.stderr || '').trim()}`);
189
+ return true;
190
+ },
191
+
192
+ async list_dir(rel = '.') {
193
+ const ret = await guest_exec({ node_doc, vmid, script: `ls -1A ${shq(safe(rel))} 2>/dev/null || true`, timeout_ms: 60000 });
194
+ return (ret.stdout || '')
195
+ .split('\n')
196
+ .map((s) => s.trim())
197
+ .filter(Boolean);
198
+ },
199
+
200
+ // The preview URL. A xudex machine publishes nothing but previews (plan 3.7), so this is
201
+ // the one port that is ever reachable, and it goes through the platform's existing web
202
+ // route for the app rather than a second proxy invented here.
203
+ async expose_port(port) {
204
+ const base = global._conf.xudex?.preview_domain;
205
+ if (!base) return null;
206
+ return { url: `https://${String(project_id).replace(/[^a-zA-Z0-9]/g, '')}.${base}`, port };
207
+ },
208
+
209
+ // The machine persists between runs, which is the whole point of the paid tiers, so
210
+ // release is a no-op unless the caller explicitly wants the working copy gone.
211
+ async release(opts = {}) {
212
+ if (opts.destroy === true) await guest_exec({ node_doc, vmid, script: `rm -rf ${shq(dir)}`, timeout_ms: 120000 });
213
+ return true;
214
+ },
215
+ };
216
+ },
217
+ };
218
+ };