@devframes/plugin-code-server 0.9.9 → 0.9.10

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,801 @@
1
+ import { DEFAULT_CODE_SERVER_PORT, PLUGIN_ID, STATE_KEY, TERMINAL_SESSION_ICON, TERMINAL_SESSION_TITLE, getCookieSessionName } from "./constants.mjs";
2
+ import { i as diagnostics, n as getCodeServerSupervisor, r as setCodeServerSupervisor, t as serverFunctions } from "../rpc-Df9IRgPd.mjs";
3
+ import { execSync, spawn } from "node:child_process";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { request } from "node:http";
6
+ import { hostname } from "node:os";
7
+ import process from "node:process";
8
+ import { getPort } from "devframe/utils/get-port";
9
+ //#region src/node/backends.ts
10
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
11
+ /** Coder's open-source `code-server`, with password auth + session cookie handoff. */
12
+ const codeServerProfile = {
13
+ kind: "code-server",
14
+ backend: "code-server",
15
+ defaultBin: "code-server",
16
+ buildArgs: (c) => [
17
+ "--auth",
18
+ "password",
19
+ "--bind-addr",
20
+ `${c.host}:${c.port}`,
21
+ "--disable-telemetry",
22
+ "--disable-update-check",
23
+ ...c.extraArgs,
24
+ c.folder
25
+ ],
26
+ buildEnv: (c, base) => {
27
+ const env = { ...base };
28
+ delete env.PASSWORD;
29
+ env.HASHED_PASSWORD = sha256(c.secret);
30
+ return env;
31
+ },
32
+ healthPath: "/healthz",
33
+ matchPort: (line) => {
34
+ const m = line.match(/HTTP server listening on https?:\/\/(?:[^:]+|\[[^\]]+\]):(\d+)/);
35
+ return m ? Number.parseInt(m[1], 10) : void 0;
36
+ },
37
+ connect: (c) => ({
38
+ path: "/",
39
+ cookie: {
40
+ name: c.cookieName,
41
+ value: sha256(c.secret)
42
+ }
43
+ }),
44
+ connectReused: () => ({ path: "/" })
45
+ };
46
+ /** Microsoft's `code serve-web`, with a connection token passed via `?tkn=`. */
47
+ const serveWebProfile = {
48
+ kind: "serve-web",
49
+ backend: "ms-code-serve-web",
50
+ defaultBin: "code",
51
+ buildArgs: (c) => [
52
+ "serve-web",
53
+ "--accept-server-license-terms",
54
+ "--host",
55
+ c.host,
56
+ "--port",
57
+ String(c.port),
58
+ "--connection-token",
59
+ c.secret,
60
+ ...c.extraArgs
61
+ ],
62
+ buildEnv: (_c, base) => ({ ...base }),
63
+ /** serve-web has no health endpoint; the root responds (200/redirect) once up. */
64
+ healthPath: "/",
65
+ matchPort: (line) => {
66
+ const m = line.match(/available at https?:\/\/[^:]+:(\d+)/i);
67
+ return m ? Number.parseInt(m[1], 10) : void 0;
68
+ },
69
+ connect: (c) => ({ path: `/?tkn=${encodeURIComponent(c.secret)}&folder=${encodeURIComponent(c.folder)}` }),
70
+ connectReused: () => ({ path: "/" })
71
+ };
72
+ const TUNNEL_URL_RE = /(https:\/\/vscode\.dev\/tunnel\/\S+)/;
73
+ const DEVICE_LOGIN_RE = /log in(?:to)? (https?:\/\/\S+) and use code ([A-Z0-9-]+)/i;
74
+ /** Microsoft's `code tunnel`, embedding the hosted `vscode.dev` editor. */
75
+ const tunnelProfile = {
76
+ kind: "tunnel",
77
+ backend: "ms-code-serve-web",
78
+ defaultBin: "code",
79
+ buildArgs: (c) => [
80
+ "tunnel",
81
+ "--accept-server-license-terms",
82
+ "--name",
83
+ c.tunnelName,
84
+ ...c.extraArgs
85
+ ],
86
+ buildEnv: (_c, base) => ({ ...base }),
87
+ healthPath: null,
88
+ matchReadyUrl: (line) => {
89
+ const m = line.match(TUNNEL_URL_RE);
90
+ return m ? m[1].replace(/[)\].,]+$/, "") : void 0;
91
+ },
92
+ matchLogin: (line) => {
93
+ const m = line.match(DEVICE_LOGIN_RE);
94
+ return m ? {
95
+ url: m[1],
96
+ code: m[2]
97
+ } : void 0;
98
+ },
99
+ connect: (c) => ({ url: c.readyUrl ?? `https://vscode.dev/tunnel/${c.tunnelName}${c.folder}` }),
100
+ connectReused: () => ({})
101
+ };
102
+ const LOCAL_PROFILES = {
103
+ "code-server": codeServerProfile,
104
+ "ms-code-serve-web": serveWebProfile
105
+ };
106
+ /** The ordered auto-detection candidates for `mode: 'local'` with no explicit backend. */
107
+ const AUTO_DETECT_ORDER = ["code-server", "ms-code-serve-web"];
108
+ /** Resolve the launch profile for a mode + backend. */
109
+ function resolveProfile(mode, backend) {
110
+ return mode === "tunnel" ? tunnelProfile : LOCAL_PROFILES[backend];
111
+ }
112
+ //#endregion
113
+ //#region src/node/detect.ts
114
+ /**
115
+ * Probe the host for a usable code-server binary by running
116
+ * `<bin> --version`. Resolves to `installed: false` when the binary is
117
+ * missing (ENOENT), errors, or exits non-zero (never throws), so the
118
+ * launcher can fall back to install instructions.
119
+ *
120
+ * `code-server --version` prints e.g. `4.96.4 abc123 with Code 1.96.4`; the
121
+ * first semver-looking token is taken as the version. Matching a semver
122
+ * pattern (rather than the leading whitespace token) keeps cold-start i18n
123
+ * noise (e.g. an `i18next: …` initialization line printed before the version)
124
+ * from leaking into the reported version.
125
+ */
126
+ function detectCodeServer(bin = "code-server", timeoutMs = 5e3) {
127
+ return new Promise((resolve) => {
128
+ let settled = false;
129
+ let stdout = "";
130
+ const finish = (result) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ resolve(result);
134
+ };
135
+ let child;
136
+ try {
137
+ child = spawn(bin, ["--version"], {
138
+ windowsHide: true,
139
+ stdio: [
140
+ "ignore",
141
+ "pipe",
142
+ "ignore"
143
+ ],
144
+ shell: process.platform === "win32" && bin.endsWith(".cmd")
145
+ });
146
+ } catch {
147
+ finish({
148
+ installed: false,
149
+ bin
150
+ });
151
+ return;
152
+ }
153
+ const timer = setTimeout(() => {
154
+ child.kill();
155
+ finish({
156
+ installed: false,
157
+ bin
158
+ });
159
+ }, timeoutMs);
160
+ timer.unref?.();
161
+ child.stdout?.on("data", (chunk) => {
162
+ stdout += chunk.toString("utf8");
163
+ });
164
+ child.on("error", () => {
165
+ clearTimeout(timer);
166
+ finish({
167
+ installed: false,
168
+ bin
169
+ });
170
+ });
171
+ child.on("exit", (code) => {
172
+ clearTimeout(timer);
173
+ if (code === 0) {
174
+ const version = stdout.match(/\d+\.\d+\.\d\S*/)?.[0] ?? (stdout.trim().split(/\s+/)[0] || void 0);
175
+ finish({
176
+ installed: true,
177
+ version,
178
+ bin
179
+ });
180
+ } else finish({
181
+ installed: false,
182
+ bin
183
+ });
184
+ });
185
+ });
186
+ }
187
+ //#endregion
188
+ //#region src/node/supervisor.ts
189
+ /** Recent output lines retained for surfacing startup failures. */
190
+ const LOG_BUFFER_LINES = 200;
191
+ /** Interval between readiness probes. */
192
+ const PROBE_INTERVAL = 250;
193
+ /**
194
+ * Owns the lifecycle of a single editor child process. Resolves a launch
195
+ * {@link CodeServerProfile} (Coder `code-server`, Microsoft `code serve-web`,
196
+ * or `code tunnel`), detects the binary, launches it with freshly generated
197
+ * auth material, waits for readiness, and mirrors a secret-free status into
198
+ * shared state. The connect descriptor (session cookie / connection token /
199
+ * tunnel URL) is handed back only through `start()` / `status()` so the
200
+ * already-authorized client can open the editor without a login page.
201
+ *
202
+ * Depends only on the core devframe context (shared state), not on the hub.
203
+ */
204
+ var CodeServerSupervisor = class {
205
+ ctx;
206
+ mode;
207
+ explicitBackend;
208
+ explicitBin;
209
+ workspace;
210
+ host;
211
+ forcedPort;
212
+ extraArgs;
213
+ extraEnv;
214
+ cookieSuffix;
215
+ cookieName;
216
+ startTimeout;
217
+ reuseExistingServer;
218
+ tunnelName;
219
+ /** Resolved after the first detection. */
220
+ backend;
221
+ bin;
222
+ profile;
223
+ state;
224
+ detection;
225
+ server = { status: "stopped" };
226
+ proc;
227
+ /** Context of the live launch, used to compute the client connect descriptor. */
228
+ launchCtx;
229
+ /** Whether the running server was adopted (reused) rather than launched. */
230
+ adopted = false;
231
+ /** Captured `vscode.dev` URL for a running tunnel. */
232
+ readyUrl;
233
+ logBuffer = [];
234
+ exitHandler;
235
+ /** Stable id of the hub terminal session, reused across start/stop. */
236
+ sessionId;
237
+ /** The live hub terminal session when launched through `ctx.terminals`. */
238
+ session;
239
+ constructor(ctx, options = {}) {
240
+ this.ctx = ctx;
241
+ this.mode = options.mode ?? "local";
242
+ this.explicitBackend = options.backend;
243
+ this.explicitBin = options.bin;
244
+ this.workspace = options.cwd ?? ctx.cwd;
245
+ this.host = options.host ?? "127.0.0.1";
246
+ this.forcedPort = options.serverPort;
247
+ this.extraArgs = options.args ?? [];
248
+ this.extraEnv = options.env ?? {};
249
+ this.cookieSuffix = options.cookieSuffix;
250
+ this.cookieName = getCookieSessionName(options.cookieSuffix);
251
+ this.startTimeout = options.startTimeout ?? 3e4;
252
+ this.reuseExistingServer = options.reuseExistingServer ?? false;
253
+ this.tunnelName = options.tunnel?.name || hostname().split(".").join("") || "devframe";
254
+ this.sessionId = options.cookieSuffix ? `${PLUGIN_ID}:${options.cookieSuffix}` : PLUGIN_ID;
255
+ this.backend = this.explicitBackend ?? "code-server";
256
+ this.profile = resolveProfile(this.mode, this.backend);
257
+ this.bin = this.explicitBin ?? this.profile.defaultBin;
258
+ this.detection = {
259
+ checked: false,
260
+ installed: false,
261
+ bin: this.bin,
262
+ backend: this.backend,
263
+ mode: this.mode
264
+ };
265
+ }
266
+ /** Resolve shared state, register process-exit cleanup, run first detection. */
267
+ async init() {
268
+ if (this.state) return;
269
+ this.state = await this.ctx.rpc.sharedState.get(STATE_KEY, { initialValue: {
270
+ detection: this.detection,
271
+ server: this.server
272
+ } });
273
+ await this.detect();
274
+ }
275
+ /**
276
+ * Probe for a usable editor binary and publish the result. Resolves the
277
+ * backend + binary when the caller left them implicit: tunnel mode always
278
+ * uses `code`; an explicit backend or `bin` is honored as-is; otherwise the
279
+ * plugin tries each {@link AUTO_DETECT_ORDER} candidate and keeps the first
280
+ * that is installed.
281
+ */
282
+ async detect() {
283
+ let installed = false;
284
+ let version;
285
+ if (this.mode === "tunnel" || this.explicitBackend || this.explicitBin) {
286
+ const result = await detectCodeServer(this.bin);
287
+ installed = result.installed;
288
+ version = result.version;
289
+ } else {
290
+ let resolved;
291
+ for (const backend of AUTO_DETECT_ORDER) {
292
+ const bin = resolveProfile("local", backend).defaultBin;
293
+ const result = await detectCodeServer(bin);
294
+ if (result.installed) {
295
+ resolved = {
296
+ backend,
297
+ bin,
298
+ version: result.version
299
+ };
300
+ break;
301
+ }
302
+ }
303
+ if (resolved) {
304
+ this.setResolved(resolved.backend, resolved.bin);
305
+ installed = true;
306
+ version = resolved.version;
307
+ }
308
+ }
309
+ this.detection = {
310
+ checked: true,
311
+ installed,
312
+ version,
313
+ bin: this.bin,
314
+ backend: this.backend,
315
+ mode: this.mode
316
+ };
317
+ this.publish();
318
+ return this.detection;
319
+ }
320
+ /** Current status (+ connect info when running) for the launcher UI. */
321
+ status() {
322
+ return {
323
+ detection: { ...this.detection },
324
+ server: { ...this.server },
325
+ connect: this.connectInfo()
326
+ };
327
+ }
328
+ /**
329
+ * Launch the editor (if not already up) and resolve once it is reachable.
330
+ * Idempotent while starting/running; returns the live status instead of
331
+ * spawning a second process. In tunnel mode it resolves as soon as either
332
+ * the `vscode.dev` URL or a device-login prompt is seen, so the action never
333
+ * blocks on interactive authentication.
334
+ */
335
+ async start(req = {}) {
336
+ if (this.server.status === "running" || this.server.status === "starting") return this.status();
337
+ if (!this.detection.checked) await this.detect();
338
+ if (!this.detection.installed) throw diagnostics.DP_CODE_SERVER_0001({ bin: this.bin });
339
+ const folder = req.folder ?? this.workspace;
340
+ const isLocal = this.profile.kind !== "tunnel";
341
+ if (isLocal && this.reuseExistingServer) {
342
+ const adopted = await this.tryAdopt(folder);
343
+ if (adopted) return adopted;
344
+ }
345
+ const secret = randomBytes(32).toString("hex");
346
+ const initialPort = await this.resolveInitialPort(isLocal);
347
+ const ctx = this.baseCtx(folder, initialPort, secret);
348
+ this.launchCtx = ctx;
349
+ this.adopted = false;
350
+ this.readyUrl = void 0;
351
+ const args = this.profile.buildArgs(ctx);
352
+ const env = this.buildLaunchEnv(ctx);
353
+ this.logBuffer = [];
354
+ const signals = {
355
+ isLocal,
356
+ actualPort: initialPort
357
+ };
358
+ const portPromise = isLocal && initialPort === 0 ? new Promise((resolve, reject) => {
359
+ signals.resolvePort = resolve;
360
+ signals.rejectPort = reject;
361
+ }) : Promise.resolve(initialPort);
362
+ const readyUrlPromise = new Promise((resolve) => {
363
+ signals.resolveReadyUrl = resolve;
364
+ });
365
+ const loginPromise = new Promise((resolve) => {
366
+ signals.resolveLogin = resolve;
367
+ });
368
+ const child = await this.launchProcess(args, env, folder);
369
+ this.registerCleanup();
370
+ this.proc = child;
371
+ this.server = {
372
+ status: "starting",
373
+ port: isLocal ? signals.actualPort || void 0 : void 0,
374
+ pid: child.pid,
375
+ startedAt: Date.now()
376
+ };
377
+ this.publish();
378
+ child.stdout?.on("data", (chunk) => this.consumeOutput(chunk, child, signals));
379
+ child.stderr?.on("data", (chunk) => this.consumeOutput(chunk, child, signals));
380
+ child.on("error", (error) => this.handleChildError(child, error, signals));
381
+ child.on("exit", (code) => this.handleChildExit(child, code, signals));
382
+ try {
383
+ if (!isLocal) return await this.awaitTunnel(child, readyUrlPromise, loginPromise);
384
+ return await this.finalizeLocalStart(child, portPromise);
385
+ } catch (error) {
386
+ const reason = error instanceof Error ? error.message : String(error);
387
+ return this.handleStartFailure(child, reason, isLocal, signals.actualPort);
388
+ }
389
+ }
390
+ /** Adopt an already-running local server on the target port, if one answers. */
391
+ async tryAdopt(folder) {
392
+ const target = this.forcedPort && this.forcedPort !== 0 ? this.forcedPort : DEFAULT_CODE_SERVER_PORT;
393
+ if (!this.profile.healthPath || !await probeHealth(target, this.profile.healthPath)) return void 0;
394
+ this.adopted = true;
395
+ this.launchCtx = this.baseCtx(folder, target, "");
396
+ this.server = {
397
+ status: "running",
398
+ port: target,
399
+ startedAt: Date.now()
400
+ };
401
+ this.publish();
402
+ return this.status();
403
+ }
404
+ /** Resolve the port to launch on: 0 for tunnels/dynamic, a forced port, or a free one. */
405
+ async resolveInitialPort(isLocal) {
406
+ if (!isLocal) return 0;
407
+ if (this.forcedPort !== void 0 && this.forcedPort !== 0) return this.forcedPort;
408
+ if (this.forcedPort === 0) return 0;
409
+ return getPort({
410
+ host: "127.0.0.1",
411
+ port: DEFAULT_CODE_SERVER_PORT
412
+ });
413
+ }
414
+ /** The child's environment: inherited env, caller overrides, then profile shaping. */
415
+ buildLaunchEnv(ctx) {
416
+ const baseEnv = {};
417
+ for (const [k, v] of Object.entries(process.env)) if (v !== void 0) baseEnv[k] = v;
418
+ Object.assign(baseEnv, this.extraEnv);
419
+ return this.profile.buildEnv(ctx, baseEnv);
420
+ }
421
+ /** Buffer output and latch the port / login / ready-URL signals from each line. */
422
+ consumeOutput(chunk, child, s) {
423
+ const text = chunk.toString("utf8");
424
+ this.appendLog(text);
425
+ for (const line of text.split("\n")) {
426
+ this.latchPort(line, s);
427
+ this.latchLogin(line, child, s);
428
+ this.latchReadyUrl(line, child, s);
429
+ }
430
+ }
431
+ latchPort(line, s) {
432
+ if (!s.isLocal || s.actualPort !== 0 || !this.profile.matchPort) return;
433
+ const port = this.profile.matchPort(line);
434
+ if (!port) return;
435
+ s.actualPort = port;
436
+ this.server.port = port;
437
+ this.publish();
438
+ s.resolvePort?.(port);
439
+ s.resolvePort = void 0;
440
+ }
441
+ latchLogin(line, child, s) {
442
+ const login = this.profile.matchLogin?.(line);
443
+ if (!login || this.proc !== child) return;
444
+ this.server = {
445
+ ...this.server,
446
+ login
447
+ };
448
+ this.publish();
449
+ s.resolveLogin?.(login);
450
+ }
451
+ latchReadyUrl(line, child, s) {
452
+ const url = this.profile.matchReadyUrl?.(line);
453
+ if (!url || this.proc !== child) return;
454
+ this.readyUrl = url;
455
+ s.resolveReadyUrl?.(url);
456
+ }
457
+ handleChildError(child, error, s) {
458
+ if (this.proc !== child) return;
459
+ this.server = {
460
+ status: "error",
461
+ error: error.message
462
+ };
463
+ this.reset();
464
+ s.rejectPort?.(error);
465
+ this.publish();
466
+ }
467
+ handleChildExit(child, code, s) {
468
+ if (this.proc !== child) return;
469
+ const exitCode = code ?? 0;
470
+ const crashed = this.server.status !== "stopped" && exitCode !== 0;
471
+ this.reset();
472
+ this.server = crashed ? {
473
+ status: "error",
474
+ error: this.lastLog() || `${this.bin} exited with code ${exitCode}`
475
+ } : { status: "stopped" };
476
+ if (crashed) diagnostics.DP_CODE_SERVER_0005({ code: exitCode }, { method: "warn" });
477
+ this.reflectHub(crashed ? "error" : "stopped");
478
+ this.session = void 0;
479
+ s.rejectPort?.(/* @__PURE__ */ new Error(`${this.bin} exited before binding (code ${exitCode})`));
480
+ this.publish();
481
+ }
482
+ /** Wait for the dynamic port then readiness, and flip to `running`. */
483
+ async finalizeLocalStart(child, portPromise) {
484
+ const port = await Promise.race([portPromise, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("timeout waiting for dynamic port allocation")), this.startTimeout))]);
485
+ if (!await this.waitForReady(port, this.profile.healthPath)) throw new Error(this.lastLog() || "startup timed out");
486
+ if (this.proc !== child) return this.status();
487
+ this.server = {
488
+ status: "running",
489
+ port,
490
+ pid: child.pid,
491
+ startedAt: this.server.startedAt
492
+ };
493
+ this.publish();
494
+ return this.status();
495
+ }
496
+ handleStartFailure(child, reason, isLocal, actualPort) {
497
+ if (this.proc === child) {
498
+ this.terminate(child);
499
+ this.reflectHub("error");
500
+ this.session = void 0;
501
+ this.server = {
502
+ status: "error",
503
+ error: reason
504
+ };
505
+ this.reset();
506
+ this.publish();
507
+ }
508
+ if (!isLocal) throw diagnostics.DP_CODE_SERVER_0006({ timeout: this.startTimeout });
509
+ throw diagnostics.DP_CODE_SERVER_0002({
510
+ port: actualPort,
511
+ timeout: this.startTimeout
512
+ });
513
+ }
514
+ /** Stop the editor process and reset to `stopped`. */
515
+ stop() {
516
+ const child = this.proc;
517
+ const wasAdopted = this.adopted;
518
+ this.reset();
519
+ this.server = { status: "stopped" };
520
+ if (child && !wasAdopted) this.terminate(child);
521
+ this.reflectHub("stopped");
522
+ this.session = void 0;
523
+ this.publish();
524
+ return this.status();
525
+ }
526
+ /** Kill the process on host shutdown / test teardown. */
527
+ dispose() {
528
+ if (this.proc && !this.adopted) this.terminate(this.proc);
529
+ this.reset();
530
+ this.session = void 0;
531
+ if (this.exitHandler) {
532
+ process.off("exit", this.exitHandler);
533
+ this.exitHandler = void 0;
534
+ }
535
+ }
536
+ /** Resolved backend for tests / callers. */
537
+ get resolvedBackend() {
538
+ return this.backend;
539
+ }
540
+ setResolved(backend, bin) {
541
+ this.backend = backend;
542
+ this.bin = bin;
543
+ this.profile = resolveProfile(this.mode, backend);
544
+ }
545
+ baseCtx(folder, port, secret) {
546
+ return {
547
+ host: this.host,
548
+ port,
549
+ folder,
550
+ secret,
551
+ cookieName: this.cookieName,
552
+ extraArgs: this.extraArgs,
553
+ tunnelName: this.tunnelName
554
+ };
555
+ }
556
+ /** Clear per-launch process state (keeps `server`/`detection`). */
557
+ reset() {
558
+ this.proc = void 0;
559
+ this.launchCtx = void 0;
560
+ this.adopted = false;
561
+ this.readyUrl = void 0;
562
+ }
563
+ /**
564
+ * Resolve start() for a tunnel: succeed as soon as the `vscode.dev` URL
565
+ * appears (→ running) or a device-login prompt is seen (→ starting, so the
566
+ * user can authenticate while the log stream continues to a running URL).
567
+ */
568
+ async awaitTunnel(child, readyUrlPromise, loginPromise) {
569
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("timed out waiting for the tunnel")), this.startTimeout));
570
+ const outcome = await Promise.race([
571
+ readyUrlPromise.then(() => "ready"),
572
+ loginPromise.then(() => "login"),
573
+ timeout
574
+ ]);
575
+ if (this.proc !== child) return this.status();
576
+ if (outcome === "ready") {
577
+ this.server = {
578
+ status: "running",
579
+ pid: child.pid,
580
+ startedAt: this.server.startedAt
581
+ };
582
+ this.publish();
583
+ return this.status();
584
+ }
585
+ readyUrlPromise.then(() => {
586
+ if (this.proc === child && this.server.status !== "running") {
587
+ this.server = {
588
+ status: "running",
589
+ pid: child.pid,
590
+ startedAt: this.server.startedAt,
591
+ login: void 0
592
+ };
593
+ this.publish();
594
+ }
595
+ });
596
+ return this.status();
597
+ }
598
+ connectInfo() {
599
+ if (this.server.status !== "running" || !this.launchCtx) return void 0;
600
+ if (this.adopted) return this.profile.connectReused({ port: this.launchCtx.port });
601
+ return this.profile.connect({
602
+ ...this.launchCtx,
603
+ port: this.server.port ?? this.launchCtx.port,
604
+ readyUrl: this.readyUrl
605
+ });
606
+ }
607
+ terminate(child) {
608
+ if (this.session) {
609
+ this.session.terminate().catch(() => {});
610
+ return;
611
+ }
612
+ try {
613
+ if (process.platform === "win32" && this.bin.endsWith(".cmd") && child.pid) execSync(`taskkill /pid ${child.pid} /t /f`, { stdio: "ignore" });
614
+ else child.kill("SIGTERM");
615
+ } catch {}
616
+ }
617
+ /**
618
+ * Resolve the hub's terminals subsystem when this devframe is mounted in a
619
+ * hub. `ctx.terminals` only exists on a `DevframeHubContext`, so it is
620
+ * duck-typed: standalone runtimes (CLI / Vite / build) have no such property
621
+ * and fall back to a direct child process.
622
+ */
623
+ resolveHubTerminals() {
624
+ const terminals = this.ctx.terminals;
625
+ return terminals && typeof terminals.startChildProcess === "function" ? terminals : void 0;
626
+ }
627
+ /** Update the mirrored hub terminal session's status, when one exists. */
628
+ reflectHub(status) {
629
+ const hub = this.resolveHubTerminals();
630
+ if (hub?.sessions.has(this.sessionId)) hub.update({
631
+ id: this.sessionId,
632
+ status
633
+ });
634
+ }
635
+ /**
636
+ * Launch the editor binary. In a hub, spawn it through `ctx.terminals` so it
637
+ * shows up as a read-only terminal session (proper icon + name) whose output
638
+ * the hub streams to its terminals panel; standalone, spawn it directly.
639
+ * Either way, return the underlying {@link ChildProcess} so the shared
640
+ * readiness / port / log wiring in `start()` is identical.
641
+ */
642
+ async launchProcess(args, env, folder) {
643
+ const hub = this.resolveHubTerminals();
644
+ return hub ? this.launchViaHub(hub, args, env, folder) : this.launchDirect(args, env, folder);
645
+ }
646
+ /** Spawn through the hub's terminals subsystem so it shows as a terminal session. */
647
+ async launchViaHub(hub, args, env, folder) {
648
+ const stale = hub.sessions.get(this.sessionId);
649
+ if (stale) hub.remove?.(stale);
650
+ try {
651
+ const session = await hub.startChildProcess({
652
+ command: this.bin,
653
+ args,
654
+ cwd: folder,
655
+ /**
656
+ * The hub merges `process.env`, so neutralize PASSWORD explicitly
657
+ * (code-server authenticates via HASHED_PASSWORD).
658
+ */
659
+ env: {
660
+ ...env,
661
+ PASSWORD: ""
662
+ }
663
+ }, {
664
+ id: this.sessionId,
665
+ title: TERMINAL_SESSION_TITLE,
666
+ description: folder,
667
+ icon: TERMINAL_SESSION_ICON,
668
+ /**
669
+ * Restart re-runs the supervisor's start flow (fresh port + secret),
670
+ * not this raw process, so hide the panel's generic restart.
671
+ */
672
+ restartable: false
673
+ });
674
+ const child = session.getChildProcess();
675
+ if (!child) throw new Error("editor process handle was unavailable");
676
+ this.session = session;
677
+ return child;
678
+ } catch (error) {
679
+ const reason = error instanceof Error ? error.message : String(error);
680
+ const orphan = hub.sessions.get(this.sessionId);
681
+ if (orphan) hub.remove?.(orphan);
682
+ this.session = void 0;
683
+ this.server = {
684
+ status: "error",
685
+ error: reason
686
+ };
687
+ this.publish();
688
+ throw diagnostics.DP_CODE_SERVER_0003({
689
+ bin: this.bin,
690
+ reason
691
+ });
692
+ }
693
+ }
694
+ /** Spawn the editor binary directly (standalone runtimes with no hub). */
695
+ launchDirect(args, env, folder) {
696
+ try {
697
+ return spawn(this.bin, args, {
698
+ cwd: folder,
699
+ env,
700
+ windowsHide: true,
701
+ stdio: [
702
+ "ignore",
703
+ "pipe",
704
+ "pipe"
705
+ ],
706
+ shell: process.platform === "win32" && this.bin.endsWith(".cmd")
707
+ });
708
+ } catch (error) {
709
+ const reason = error instanceof Error ? error.message : String(error);
710
+ this.server = {
711
+ status: "error",
712
+ error: reason
713
+ };
714
+ this.publish();
715
+ throw diagnostics.DP_CODE_SERVER_0003({
716
+ bin: this.bin,
717
+ reason
718
+ });
719
+ }
720
+ }
721
+ appendLog(text) {
722
+ for (const line of text.split("\n")) {
723
+ if (line.length === 0) continue;
724
+ this.logBuffer.push(line);
725
+ }
726
+ if (this.logBuffer.length > LOG_BUFFER_LINES) this.logBuffer.splice(0, this.logBuffer.length - LOG_BUFFER_LINES);
727
+ }
728
+ lastLog() {
729
+ return this.logBuffer.length ? this.logBuffer[this.logBuffer.length - 1] : void 0;
730
+ }
731
+ publish() {
732
+ this.state?.mutate((draft) => {
733
+ draft.detection = { ...this.detection };
734
+ draft.server = { ...this.server };
735
+ });
736
+ }
737
+ /**
738
+ * Poll the server's readiness path until it responds or the timeout elapses.
739
+ * Returns false if the process exits first.
740
+ */
741
+ async waitForReady(port, path) {
742
+ const deadline = Date.now() + this.startTimeout;
743
+ while (Date.now() < deadline) {
744
+ if (!this.proc) return false;
745
+ if (await probeHealth(port, path)) return true;
746
+ await delay(PROBE_INTERVAL);
747
+ }
748
+ return false;
749
+ }
750
+ registerCleanup() {
751
+ if (this.exitHandler) return;
752
+ this.exitHandler = () => this.dispose();
753
+ process.once("exit", this.exitHandler);
754
+ }
755
+ };
756
+ function delay(ms) {
757
+ return new Promise((resolve) => {
758
+ setTimeout(resolve, ms).unref?.();
759
+ });
760
+ }
761
+ function probeHealth(port, path) {
762
+ return new Promise((resolve) => {
763
+ const req = request({
764
+ host: "127.0.0.1",
765
+ port,
766
+ path,
767
+ method: "GET",
768
+ timeout: 1500
769
+ }, (res) => {
770
+ res.resume();
771
+ resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 500);
772
+ });
773
+ req.on("error", () => resolve(false));
774
+ req.on("timeout", () => {
775
+ req.destroy();
776
+ resolve(false);
777
+ });
778
+ req.end();
779
+ });
780
+ }
781
+ //#endregion
782
+ //#region src/node/setup.ts
783
+ /**
784
+ * Wire the code-server subsystem onto a devframe node context: create the
785
+ * {@link CodeServerSupervisor}, run the initial binary detection, publish
786
+ * status into shared state, and register the control RPC functions. Returns
787
+ * the supervisor so callers can launch/stop or dispose it on shutdown.
788
+ *
789
+ * Works in any devframe runtime (CLI, Vite, embedded, build), since it only relies
790
+ * on the core `ctx.rpc` shared-state surface, not on the hub.
791
+ */
792
+ async function setupCodeServer(ctx, options = {}) {
793
+ const supervisor = new CodeServerSupervisor(ctx, options);
794
+ setCodeServerSupervisor(ctx, supervisor);
795
+ await supervisor.init();
796
+ for (const fn of serverFunctions) ctx.rpc.register(fn);
797
+ if (options.startOnBoot) supervisor.start().catch(() => {});
798
+ return supervisor;
799
+ }
800
+ //#endregion
801
+ export { CodeServerSupervisor, detectCodeServer, diagnostics, getCodeServerSupervisor, resolveProfile, setCodeServerSupervisor, setupCodeServer };