@linzumi/cli 0.0.20-beta → 0.0.23-beta

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.
@@ -1,1061 +0,0 @@
1
- /*
2
- - Date: 2026-04-26
3
- Spec: plans/2026-04-26-local-runner-forwarding-and-editor-plan.md
4
- Relationship: Starts loopback-only code-server instances for Kandan-owned
5
- local editor sessions after the runner has verified cwd permissions.
6
- */
7
- import { spawn, type ChildProcess } from "node:child_process";
8
- import {
9
- cpSync,
10
- existsSync,
11
- mkdirSync,
12
- mkdtempSync,
13
- realpathSync,
14
- writeFileSync,
15
- } from "node:fs";
16
- import { tmpdir } from "node:os";
17
- import { basename, delimiter, dirname, join } from "node:path";
18
- import { chooseLoopbackPort } from "./codexAppServer";
19
- import { resolveAllowedCwd } from "./localCapabilities";
20
- import type { InstalledEditorRuntime } from "./localEditorRuntime";
21
-
22
- export type LocalEditorState =
23
- | { readonly status: "disabled" }
24
- | {
25
- readonly status: "running";
26
- readonly cwd: string;
27
- readonly port: number;
28
- readonly process: ChildProcess;
29
- readonly collaboration?: RunningLocalEditorCollaboration | undefined;
30
- readonly exited: Promise<void>;
31
- };
32
-
33
- export type StartLocalEditorControl = {
34
- readonly type: "start_local_editor";
35
- readonly instanceId?: string;
36
- readonly requestId?: string;
37
- readonly cwd: string;
38
- readonly browserBaseUrl?: string | undefined;
39
- readonly collaboration?: LocalEditorCollaborationControl | undefined;
40
- };
41
-
42
- export type StartLocalEditorOptions = {
43
- readonly codeServerBin: string | undefined;
44
- readonly editorRuntime?: InstalledEditorRuntime | undefined;
45
- readonly allowedCwds: readonly string[];
46
- readonly currentState: LocalEditorState;
47
- readonly browserBaseUrl?: string | undefined;
48
- readonly runnerId?: string | undefined;
49
- };
50
-
51
- export type StartLocalEditorResult =
52
- | {
53
- readonly ok: true;
54
- readonly state: LocalEditorState;
55
- readonly event: {
56
- readonly cwd: string;
57
- readonly port: number;
58
- readonly startedAt: string;
59
- readonly collaboration?: LocalEditorCollaborationEvent | undefined;
60
- };
61
- }
62
- | {
63
- readonly ok: false;
64
- readonly state: LocalEditorState;
65
- readonly reason:
66
- | "local_editor_disabled"
67
- | "missing_cwd"
68
- | "no_allowed_cwd"
69
- | "cwd_not_allowed"
70
- | "cwd_not_found"
71
- | "local_editor_filesystem_sandbox_unavailable"
72
- | "missing_browser_base_url"
73
- | "code_server_spawn_failed";
74
- };
75
-
76
- type PrepareCodeServerProfileResult =
77
- | {
78
- readonly ok: true;
79
- readonly userDataDir: string;
80
- readonly extensionsDir: string;
81
- readonly collaborationServerDir: string;
82
- }
83
- | { readonly ok: false; readonly reason: "code_server_spawn_failed" };
84
-
85
- export type PrepareCodeServerLaunchOptions = {
86
- readonly codeServerBin: string;
87
- readonly codeServerRuntimeRoot?: string | undefined;
88
- readonly port: number;
89
- readonly cwd: string;
90
- readonly userDataDir: string;
91
- readonly extensionsDir?: string | undefined;
92
- readonly platform?: NodeJS.Platform | undefined;
93
- readonly sandboxExecBin?: string | undefined;
94
- readonly bubblewrapBin?: string | undefined;
95
- readonly envPath?: string | undefined;
96
- };
97
-
98
- export type PrepareCodeServerLaunchResult =
99
- | {
100
- readonly ok: true;
101
- readonly command: string;
102
- readonly args: readonly string[];
103
- }
104
- | {
105
- readonly ok: false;
106
- readonly reason: "local_editor_filesystem_sandbox_unavailable";
107
- };
108
-
109
- export type LocalEditorCollaborationControl = {
110
- readonly provider: "oct";
111
- readonly editorSessionId: number;
112
- readonly runtimeSessionId: string;
113
- readonly roomId: string;
114
- readonly extensionAssetPath: string;
115
- readonly serverAssetPath: string;
116
- readonly bootstrapToken?: string | undefined;
117
- };
118
-
119
- export type RunningLocalEditorCollaboration = {
120
- readonly provider: "oct";
121
- readonly editorSessionId: number;
122
- readonly runtimeSessionId: string;
123
- readonly roomId: string;
124
- readonly serverPort: number;
125
- readonly serverUrl: string;
126
- readonly bootstrapServerUrl: string;
127
- readonly process: ChildProcess;
128
- readonly exited: Promise<void>;
129
- };
130
-
131
- export type LocalEditorCollaborationEvent = {
132
- readonly provider: "oct";
133
- readonly editorSessionId: number;
134
- readonly runtimeSessionId: string;
135
- readonly roomId: string;
136
- readonly serverPort: number;
137
- readonly serverUrl: string;
138
- };
139
-
140
- export function isStartLocalEditorControl(control: {
141
- readonly type: string;
142
- }): control is StartLocalEditorControl {
143
- return control.type === "start_local_editor";
144
- }
145
-
146
- export function localEditorCapabilities(
147
- editorRuntime: InstalledEditorRuntime | undefined,
148
- allowedCwds: readonly string[],
149
- state: LocalEditorState,
150
- ): {
151
- readonly localEditor: boolean;
152
- readonly localEditorStatus: string;
153
- readonly localEditorPort?: number;
154
- readonly localEditorCwd?: string;
155
- readonly localEditorCollaboration?: LocalEditorCollaborationEvent;
156
- } {
157
- const base = {
158
- localEditor: editorRuntime !== undefined && allowedCwds.length > 0,
159
- localEditorStatus: state.status,
160
- };
161
-
162
- return state.status === "running"
163
- ? {
164
- ...base,
165
- localEditorPort: state.port,
166
- localEditorCwd: state.cwd,
167
- ...(state.collaboration === undefined
168
- ? {}
169
- : {
170
- localEditorCollaboration:
171
- collaborationEvent(state.collaboration),
172
- }),
173
- }
174
- : base;
175
- }
176
-
177
- export async function startLocalEditor(
178
- control: StartLocalEditorControl,
179
- options: StartLocalEditorOptions,
180
- ): Promise<StartLocalEditorResult> {
181
- if (options.editorRuntime === undefined) {
182
- return {
183
- ok: false,
184
- state: options.currentState,
185
- reason: "local_editor_disabled",
186
- };
187
- }
188
-
189
- const cwdDecision = resolveAllowedCwd(control.cwd, options.allowedCwds);
190
-
191
- if (!cwdDecision.ok) {
192
- return {
193
- ok: false,
194
- state: options.currentState,
195
- reason: cwdDecision.reason,
196
- };
197
- }
198
-
199
- if (
200
- options.currentState.status === "running" &&
201
- options.currentState.cwd === cwdDecision.cwd &&
202
- sameCollaboration(options.currentState.collaboration, control.collaboration)
203
- ) {
204
- return {
205
- ok: true,
206
- state: options.currentState,
207
- event: {
208
- cwd: options.currentState.cwd,
209
- port: options.currentState.port,
210
- startedAt: new Date().toISOString(),
211
- collaboration: collaborationEvent(options.currentState.collaboration),
212
- },
213
- };
214
- }
215
-
216
- const browserBaseUrl = localEditorBrowserBaseUrl(control, options);
217
- if (control.collaboration !== undefined && browserBaseUrl === undefined) {
218
- return {
219
- ok: false,
220
- state: options.currentState,
221
- reason: "missing_browser_base_url",
222
- };
223
- }
224
-
225
- const port = await chooseLoopbackPort();
226
- const collaborationPort =
227
- control.collaboration === undefined ? undefined : await chooseLoopbackPort();
228
- const collaboration =
229
- control.collaboration === undefined || browserBaseUrl === undefined
230
- ? undefined
231
- : prepareLocalEditorCollaboration(
232
- control.collaboration,
233
- options.runnerId ?? "local-runner",
234
- collaborationPort,
235
- browserBaseUrl,
236
- );
237
- const profileResult = prepareCodeServerProfile(
238
- collaboration,
239
- options.editorRuntime,
240
- );
241
-
242
- if (!profileResult.ok) {
243
- return {
244
- ok: false,
245
- state: options.currentState,
246
- reason: profileResult.reason,
247
- };
248
- }
249
-
250
- const launchResult = prepareCodeServerLaunch({
251
- codeServerBin: options.editorRuntime.codeServerBin,
252
- codeServerRuntimeRoot: options.editorRuntime.root,
253
- port,
254
- cwd: cwdDecision.cwd,
255
- userDataDir: profileResult.userDataDir,
256
- extensionsDir: profileResult.extensionsDir,
257
- });
258
-
259
- if (!launchResult.ok) {
260
- return {
261
- ok: false,
262
- state: options.currentState,
263
- reason: launchResult.reason,
264
- };
265
- }
266
-
267
- const collaborationResult = await startCollaborationSidecar(
268
- collaboration,
269
- profileResult,
270
- options.editorRuntime,
271
- );
272
-
273
- if (!collaborationResult.ok) {
274
- return {
275
- ok: false,
276
- state: options.currentState,
277
- reason: "code_server_spawn_failed",
278
- };
279
- }
280
-
281
- const child = spawn(launchResult.command, [...launchResult.args], {
282
- cwd: cwdDecision.cwd,
283
- env: codeServerEnv(
284
- process.env,
285
- profileResult.userDataDir,
286
- collaborationResult.collaboration,
287
- ),
288
- stdio: ["ignore", "inherit", "inherit"],
289
- });
290
-
291
- const exited = waitForCodeServerExit(child);
292
- const spawnResult = await waitForCodeServerSpawn(child);
293
-
294
- if (spawnResult === "failed") {
295
- collaborationResult.collaboration?.process.kill("SIGINT");
296
- return {
297
- ok: false,
298
- state: options.currentState,
299
- reason: "code_server_spawn_failed",
300
- };
301
- }
302
-
303
- const [readyResult, collaborationReadyResult] = await Promise.all([
304
- waitForCodeServerReady(port, exited),
305
- collaborationResult.ready,
306
- ]);
307
-
308
- if (readyResult === "failed" || collaborationReadyResult === "failed") {
309
- child.kill("SIGINT");
310
- collaborationResult.collaboration?.process.kill("SIGINT");
311
- return {
312
- ok: false,
313
- state: options.currentState,
314
- reason: "code_server_spawn_failed",
315
- };
316
- }
317
-
318
- if (options.currentState.status === "running") {
319
- options.currentState.process.kill("SIGINT");
320
- options.currentState.collaboration?.process.kill("SIGINT");
321
- }
322
-
323
- const state = {
324
- status: "running" as const,
325
- cwd: cwdDecision.cwd,
326
- port,
327
- process: child,
328
- collaboration: collaborationResult.collaboration,
329
- exited,
330
- };
331
-
332
- return {
333
- ok: true,
334
- state,
335
- event: {
336
- cwd: cwdDecision.cwd,
337
- port,
338
- startedAt: new Date().toISOString(),
339
- collaboration: collaborationEvent(collaborationResult.collaboration),
340
- },
341
- };
342
- }
343
-
344
- function localEditorBrowserBaseUrl(
345
- control: StartLocalEditorControl,
346
- options: StartLocalEditorOptions,
347
- ): string | undefined {
348
- const value = control.browserBaseUrl ?? options.browserBaseUrl;
349
- return value === undefined || value.trim() === ""
350
- ? undefined
351
- : value.trim().replace(/\/+$/, "");
352
- }
353
-
354
- export function codeServerArgs(
355
- port: number,
356
- cwd: string,
357
- userDataDir: string,
358
- extensionsDir?: string | undefined,
359
- ): string[] {
360
- return [
361
- "--bind-addr",
362
- `127.0.0.1:${port}`,
363
- "--auth",
364
- "none",
365
- "--disable-telemetry",
366
- "--disable-update-check",
367
- "--disable-workspace-trust",
368
- "--disable-getting-started-override",
369
- "--app-name",
370
- "Kandan",
371
- "--user-data-dir",
372
- userDataDir,
373
- ...(extensionsDir === undefined ? [] : ["--extensions-dir", extensionsDir]),
374
- cwd,
375
- ];
376
- }
377
-
378
- export function prepareCodeServerProfile(
379
- collaboration?: PreparedLocalEditorCollaboration | undefined,
380
- editorRuntime?: InstalledEditorRuntime | undefined,
381
- ): PrepareCodeServerProfileResult {
382
- try {
383
- const userDataDir = mkdtempSync(join(tmpdir(), "kandan-local-editor-"));
384
- const extensionsDir = join(userDataDir, "extensions");
385
- const collaborationServerDir = join(userDataDir, "collaboration-server");
386
- const userSettingsDir = join(userDataDir, "User");
387
- mkdirSync(userSettingsDir, { recursive: true });
388
- mkdirSync(extensionsDir, { recursive: true });
389
- mkdirSync(collaborationServerDir, { recursive: true });
390
- if (editorRuntime !== undefined) {
391
- installDirectory(
392
- editorRuntime.assets.documentStateExtensionDir,
393
- join(extensionsDir, "kandan.document-state-telemetry"),
394
- );
395
- }
396
- writeFileSync(
397
- join(userSettingsDir, "settings.json"),
398
- JSON.stringify(
399
- codeServerSettings(collaboration),
400
- null,
401
- 2,
402
- ),
403
- );
404
-
405
- return { ok: true, userDataDir, extensionsDir, collaborationServerDir };
406
- } catch (_error) {
407
- return { ok: false, reason: "code_server_spawn_failed" };
408
- }
409
- }
410
-
411
- export function prepareCodeServerLaunch(
412
- options: PrepareCodeServerLaunchOptions,
413
- ): PrepareCodeServerLaunchResult {
414
- const platform = options.platform ?? process.platform;
415
-
416
- if (platform === "linux") {
417
- return prepareLinuxCodeServerLaunch(options);
418
- }
419
-
420
- if (platform !== "darwin") {
421
- return filesystemSandboxUnavailable();
422
- }
423
-
424
- const sandboxExecBin = options.sandboxExecBin ?? "/usr/bin/sandbox-exec";
425
-
426
- if (!existsSync(sandboxExecBin)) {
427
- return filesystemSandboxUnavailable();
428
- }
429
-
430
- const codeServerExecutable = resolveCodeServerExecutable(
431
- options.codeServerBin,
432
- options.envPath ?? process.env.PATH ?? "",
433
- );
434
-
435
- if (!codeServerExecutable.ok) {
436
- return filesystemSandboxUnavailable();
437
- }
438
-
439
- return {
440
- ok: true,
441
- command: sandboxExecBin,
442
- args: [
443
- "-p",
444
- codeServerSandboxProfile(options, codeServerExecutable.directory),
445
- "--",
446
- codeServerExecutable.command,
447
- ...codeServerArgs(
448
- options.port,
449
- options.cwd,
450
- options.userDataDir,
451
- options.extensionsDir,
452
- ),
453
- ],
454
- };
455
- }
456
-
457
- function prepareLinuxCodeServerLaunch(
458
- options: PrepareCodeServerLaunchOptions,
459
- ): PrepareCodeServerLaunchResult {
460
- const bubblewrapExecutable = resolveCodeServerExecutable(
461
- options.bubblewrapBin ?? "bwrap",
462
- options.envPath ?? process.env.PATH ?? "",
463
- );
464
-
465
- if (!bubblewrapExecutable.ok) {
466
- return filesystemSandboxUnavailable();
467
- }
468
-
469
- const codeServerExecutable = resolveCodeServerExecutable(
470
- options.codeServerBin,
471
- options.envPath ?? process.env.PATH ?? "",
472
- );
473
-
474
- if (!codeServerExecutable.ok) {
475
- return filesystemSandboxUnavailable();
476
- }
477
-
478
- const readOnlyRoots = uniquePaths([
479
- "/usr",
480
- "/bin",
481
- "/sbin",
482
- "/lib",
483
- "/lib64",
484
- "/etc",
485
- "/nix",
486
- "/opt",
487
- ...(options.codeServerRuntimeRoot === undefined
488
- ? []
489
- : sandboxPathAliases(options.codeServerRuntimeRoot)),
490
- codeServerExecutable.directory,
491
- ]);
492
-
493
- const args = [
494
- "--die-with-parent",
495
- "--proc",
496
- "/proc",
497
- "--dev-bind",
498
- "/dev",
499
- "/dev",
500
- "--tmpfs",
501
- "/tmp",
502
- "--setenv",
503
- "HOME",
504
- options.userDataDir,
505
- "--setenv",
506
- "XDG_DATA_HOME",
507
- join(options.userDataDir, "data"),
508
- "--setenv",
509
- "XDG_CONFIG_HOME",
510
- join(options.userDataDir, "config"),
511
- ...readOnlyRoots.flatMap((path) => ["--ro-bind-try", path, path]),
512
- "--bind",
513
- options.cwd,
514
- options.cwd,
515
- "--bind",
516
- options.userDataDir,
517
- options.userDataDir,
518
- ...(options.extensionsDir === undefined
519
- ? []
520
- : ["--bind", options.extensionsDir, options.extensionsDir]),
521
- "--chdir",
522
- options.cwd,
523
- "--",
524
- codeServerExecutable.command,
525
- ...codeServerArgs(
526
- options.port,
527
- options.cwd,
528
- options.userDataDir,
529
- options.extensionsDir,
530
- ),
531
- ];
532
-
533
- return {
534
- ok: true,
535
- command: bubblewrapExecutable.command,
536
- args,
537
- };
538
- }
539
-
540
- function filesystemSandboxUnavailable(): PrepareCodeServerLaunchResult {
541
- return {
542
- ok: false,
543
- reason: "local_editor_filesystem_sandbox_unavailable",
544
- };
545
- }
546
-
547
- function codeServerSandboxProfile(
548
- options: PrepareCodeServerLaunchOptions,
549
- codeServerBinDir: string,
550
- ): string {
551
- const readOnlyRoots = uniquePaths([
552
- "/System",
553
- "/Library",
554
- "/usr",
555
- "/bin",
556
- "/sbin",
557
- "/etc",
558
- "/private/etc",
559
- "/opt/homebrew",
560
- "/dev",
561
- ...(options.codeServerRuntimeRoot === undefined
562
- ? []
563
- : sandboxPathAliases(options.codeServerRuntimeRoot)),
564
- codeServerBinDir,
565
- ]);
566
- const readWriteRoots = uniquePaths([
567
- ...sandboxPathAliases(options.cwd),
568
- ...sandboxPathAliases(options.userDataDir),
569
- ...(options.extensionsDir === undefined
570
- ? []
571
- : sandboxPathAliases(options.extensionsDir)),
572
- ]);
573
-
574
- return [
575
- "(version 1)",
576
- "(deny default)",
577
- "(allow process*)",
578
- "(allow signal (target self))",
579
- "(allow signal (target same-sandbox))",
580
- "(allow sysctl*)",
581
- "(allow mach*)",
582
- "(allow ipc*)",
583
- "(allow network*)",
584
- "(allow file-read-metadata)",
585
- "(allow file-map-executable)",
586
- '(allow file-read* (literal "/") (literal "/private") (literal "/private/var"))',
587
- `(allow file-read* ${readOnlyRoots.map(sandboxSubpath).join(" ")})`,
588
- `(allow file-read* file-write* ${readWriteRoots.map(sandboxSubpath).join(" ")})`,
589
- ].join("\n");
590
- }
591
-
592
- type CodeServerExecutable =
593
- | { readonly ok: true; readonly command: string; readonly directory: string }
594
- | { readonly ok: false };
595
-
596
- function resolveCodeServerExecutable(
597
- command: string,
598
- envPath: string,
599
- ): CodeServerExecutable {
600
- if (hasPathSeparator(command)) {
601
- const directory = safeRealpathDir(command);
602
- return directory === undefined
603
- ? { ok: false }
604
- : { ok: true, command, directory };
605
- }
606
-
607
- for (const directory of envPath.split(delimiter)) {
608
- if (directory.trim() === "") {
609
- continue;
610
- }
611
-
612
- const candidate = join(directory, command);
613
-
614
- if (!existsSync(candidate)) {
615
- continue;
616
- }
617
-
618
- const realpath = realpathSync(candidate);
619
- return { ok: true, command: realpath, directory: dirname(realpath) };
620
- }
621
-
622
- return { ok: false };
623
- }
624
-
625
- function hasPathSeparator(path: string): boolean {
626
- return path.includes("/") || path.includes("\\");
627
- }
628
-
629
- function safeRealpathDir(path: string): string | undefined {
630
- try {
631
- const directory = dirname(realpathSync(path));
632
- return directory === "/" ? undefined : directory;
633
- } catch (_error) {
634
- const directory = dirname(path);
635
- return directory === "." || directory === "/" ? undefined : directory;
636
- }
637
- }
638
-
639
- function sandboxPathAliases(path: string): readonly string[] {
640
- try {
641
- return [path, realpathSync(path)];
642
- } catch (_error) {
643
- return [path];
644
- }
645
- }
646
-
647
- function uniquePaths(paths: readonly string[]): readonly string[] {
648
- return Array.from(new Set(paths.filter((path) => path.length > 0)));
649
- }
650
-
651
- function sandboxSubpath(path: string): string {
652
- return `(subpath "${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}")`;
653
- }
654
-
655
- type PreparedLocalEditorCollaboration = LocalEditorCollaborationControl & {
656
- readonly serverPort: number;
657
- readonly serverUrl: string;
658
- readonly bootstrapServerUrl: string;
659
- };
660
-
661
- type StartCollaborationSidecarResult =
662
- | {
663
- readonly ok: true;
664
- readonly collaboration?: RunningLocalEditorCollaboration | undefined;
665
- readonly ready: Promise<"ready" | "failed">;
666
- }
667
- | { readonly ok: false };
668
-
669
- export function prepareLocalEditorCollaboration(
670
- collaboration: LocalEditorCollaborationControl | undefined,
671
- runnerId: string,
672
- serverPort: number | undefined,
673
- browserBaseUrl: string,
674
- ): PreparedLocalEditorCollaboration | undefined {
675
- if (collaboration === undefined || serverPort === undefined) {
676
- return undefined;
677
- }
678
-
679
- const targetPath = `/local-codex-runners/${encodeURIComponent(runnerId)}/forwards/${serverPort}/preview-target`;
680
- const serverUrl = new URL(targetPath, `${browserBaseUrl}/`).toString();
681
- const bootstrapServerUrl =
682
- collaboration.bootstrapToken === undefined || collaboration.bootstrapToken === ""
683
- ? serverUrl
684
- : new URL(
685
- `${targetPath}/_kandan-collaboration/${encodeURIComponent(collaboration.bootstrapToken)}`,
686
- `${browserBaseUrl}/`,
687
- ).toString();
688
-
689
- return {
690
- ...collaboration,
691
- serverPort,
692
- serverUrl,
693
- bootstrapServerUrl,
694
- };
695
- }
696
-
697
- function codeServerSettings(
698
- collaboration?: PreparedLocalEditorCollaboration | undefined,
699
- ): Record<string, unknown> {
700
- return {
701
- "extensions.autoCheckUpdates": false,
702
- "extensions.autoUpdate": false,
703
- "extensions.ignoreRecommendations": true,
704
- "extensions.showRecommendationsOnlyOnDemand": true,
705
- "oct.alwaysAskToOverrideServerUrl": false,
706
- "oct.joinAcceptMode": "auto",
707
- ...(collaboration === undefined
708
- ? {}
709
- : {
710
- "oct.kandanEditorSessionId": collaboration.editorSessionId,
711
- "oct.serverUrl": collaboration.bootstrapServerUrl,
712
- }),
713
- "security.workspace.trust.enabled": false,
714
- "telemetry.telemetryLevel": "off",
715
- "workbench.secondarySideBar.defaultVisibility": "hidden",
716
- "workbench.startupEditor": "none",
717
- "workbench.welcomePage.walkthroughs.openOnInstall": false,
718
- };
719
- }
720
-
721
- async function startCollaborationSidecar(
722
- collaboration: PreparedLocalEditorCollaboration | undefined,
723
- profile: Extract<PrepareCodeServerProfileResult, { ok: true }>,
724
- editorRuntime: InstalledEditorRuntime,
725
- ): Promise<StartCollaborationSidecarResult> {
726
- if (collaboration === undefined) {
727
- return { ok: true, ready: Promise.resolve("ready") };
728
- }
729
-
730
- try {
731
- await Promise.all([
732
- installLocalTarball(
733
- editorRuntime.assets.collaborationExtensionTarball,
734
- profile.extensionsDir,
735
- ),
736
- installLocalTarball(
737
- editorRuntime.assets.collaborationServerTarball,
738
- profile.collaborationServerDir,
739
- ),
740
- ]);
741
-
742
- const child = spawn(
743
- nodeRuntimeExecutable(),
744
- [
745
- join(
746
- profile.collaborationServerDir,
747
- "open-collaboration-server",
748
- "bundle",
749
- "app.js",
750
- ),
751
- "--hostname",
752
- "127.0.0.1",
753
- "--port",
754
- String(collaboration.serverPort),
755
- ],
756
- {
757
- env: {
758
- ...process.env,
759
- OCT_ACTIVATE_SIMPLE_LOGIN: "true",
760
- OCT_SERVER_OWNER: "Kandan",
761
- },
762
- stdio: ["ignore", "inherit", "inherit"],
763
- },
764
- );
765
- const exited = waitForCodeServerExit(child);
766
- const spawnResult = await waitForCodeServerSpawn(child);
767
-
768
- if (spawnResult === "failed") {
769
- return { ok: false };
770
- }
771
-
772
- const ready = waitForCollaborationServerReady(
773
- collaboration.serverPort,
774
- exited,
775
- );
776
-
777
- return {
778
- ok: true,
779
- ready,
780
- collaboration: {
781
- provider: collaboration.provider,
782
- editorSessionId: collaboration.editorSessionId,
783
- runtimeSessionId: collaboration.runtimeSessionId,
784
- roomId: collaboration.roomId,
785
- serverPort: collaboration.serverPort,
786
- serverUrl: collaboration.serverUrl,
787
- bootstrapServerUrl: collaboration.bootstrapServerUrl,
788
- process: child,
789
- exited,
790
- },
791
- };
792
- } catch (_error) {
793
- return { ok: false };
794
- }
795
- }
796
-
797
- export function nodeRuntimeExecutable(
798
- env: NodeJS.ProcessEnv = process.env,
799
- execPath = process.execPath,
800
- ): string {
801
- const configured = env.LINZUMI_NODE_BIN?.trim();
802
-
803
- if (configured !== undefined && configured !== "") {
804
- return configured;
805
- }
806
-
807
- return basename(execPath).toLowerCase().includes("bun") ? "node" : execPath;
808
- }
809
-
810
- async function installLocalTarball(
811
- archivePath: string,
812
- destinationDir: string,
813
- ): Promise<void> {
814
- mkdirSync(destinationDir, { recursive: true });
815
- await runProcess("tar", ["-xzf", archivePath, "-C", destinationDir]);
816
- }
817
-
818
- function installDirectory(sourceDir: string, destinationDir: string): void {
819
- mkdirSync(dirname(destinationDir), { recursive: true });
820
- cpSync(sourceDir, destinationDir, { recursive: true });
821
- }
822
-
823
- function codeServerEnv(
824
- env: NodeJS.ProcessEnv,
825
- userDataDir: string,
826
- collaboration?: RunningLocalEditorCollaboration | undefined,
827
- ): NodeJS.ProcessEnv {
828
- const { PORT: _port, ...hostEnv } = env;
829
- const base = {
830
- ...hostEnv,
831
- HOME: userDataDir,
832
- XDG_CACHE_HOME: join(userDataDir, "xdg-cache"),
833
- XDG_CONFIG_HOME: join(userDataDir, "xdg-config"),
834
- XDG_DATA_HOME: join(userDataDir, "xdg-data"),
835
- };
836
-
837
- if (collaboration === undefined) {
838
- return base;
839
- }
840
-
841
- return {
842
- ...base,
843
- KANDAN_EDITOR_COLLABORATION_DEPLOYMENT_SHAPE: "local_runner_sidecar",
844
- KANDAN_EDITOR_COLLABORATION_ENTRY_MODE: "kandan_auto_host_or_join",
845
- KANDAN_EDITOR_COLLABORATION_ROOM_ID: collaboration.roomId,
846
- KANDAN_EDITOR_COLLABORATION_SERVER_URL: collaboration.bootstrapServerUrl,
847
- };
848
- }
849
-
850
- function collaborationEvent(
851
- collaboration?: RunningLocalEditorCollaboration | undefined,
852
- ): LocalEditorCollaborationEvent | undefined {
853
- if (collaboration === undefined) {
854
- return undefined;
855
- }
856
-
857
- return {
858
- provider: collaboration.provider,
859
- editorSessionId: collaboration.editorSessionId,
860
- runtimeSessionId: collaboration.runtimeSessionId,
861
- roomId: collaboration.roomId,
862
- serverPort: collaboration.serverPort,
863
- serverUrl: collaboration.serverUrl,
864
- };
865
- }
866
-
867
- function sameCollaboration(
868
- running: RunningLocalEditorCollaboration | undefined,
869
- requested: LocalEditorCollaborationControl | undefined,
870
- ): boolean {
871
- if (running === undefined || requested === undefined) {
872
- return running === undefined && requested === undefined;
873
- }
874
-
875
- return (
876
- running.editorSessionId === requested.editorSessionId &&
877
- running.runtimeSessionId === requested.runtimeSessionId &&
878
- running.roomId === requested.roomId
879
- );
880
- }
881
-
882
- function runProcess(command: string, args: readonly string[]): Promise<void> {
883
- return new Promise((resolve, reject) => {
884
- const child = spawn(command, [...args], {
885
- stdio: ["ignore", "ignore", "inherit"],
886
- });
887
- child.once("error", reject);
888
- child.once("exit", (code) => {
889
- if (code === 0) {
890
- resolve();
891
- } else {
892
- reject(new Error(`${command} exited with ${code ?? "unknown"}`));
893
- }
894
- });
895
- });
896
- }
897
-
898
- function waitForCodeServerExit(child: ChildProcess): Promise<void> {
899
- return new Promise((resolve) => {
900
- let settled = false;
901
- const cleanup = () => {
902
- child.off("exit", onDone);
903
- child.off("close", onDone);
904
- };
905
- const onDone = () => {
906
- if (settled) {
907
- return;
908
- }
909
- settled = true;
910
- cleanup();
911
- resolve();
912
- };
913
-
914
- child.once("exit", onDone);
915
- child.once("close", onDone);
916
-
917
- if (child.exitCode !== null || child.signalCode !== null) {
918
- onDone();
919
- }
920
- });
921
- }
922
-
923
- function waitForCodeServerReady(
924
- port: number,
925
- exited: Promise<void>,
926
- timeoutMs = 10_000,
927
- ): Promise<"ready" | "failed"> {
928
- const deadline = Date.now() + timeoutMs;
929
- const readyUrl = `http://127.0.0.1:${port}/`;
930
-
931
- return new Promise((resolve) => {
932
- let settled = false;
933
- let timer: ReturnType<typeof setTimeout> | undefined;
934
- const finish = (result: "ready" | "failed") => {
935
- if (settled) {
936
- return;
937
- }
938
- settled = true;
939
-
940
- if (timer !== undefined) {
941
- clearTimeout(timer);
942
- }
943
-
944
- resolve(result);
945
- };
946
- const scheduleCheck = () => {
947
- timer = setTimeout(checkReady, 50);
948
- };
949
- const checkReady = async () => {
950
- if (Date.now() > deadline) {
951
- finish("failed");
952
- return;
953
- }
954
-
955
- try {
956
- const response = await fetch(readyUrl);
957
-
958
- if (response.status < 500) {
959
- finish("ready");
960
- return;
961
- }
962
- } catch (_error) {
963
- scheduleCheck();
964
- return;
965
- }
966
-
967
- scheduleCheck();
968
- };
969
-
970
- void exited.then(() => finish("failed"));
971
- void checkReady();
972
- });
973
- }
974
-
975
- function waitForCollaborationServerReady(
976
- port: number,
977
- exited: Promise<void>,
978
- timeoutMs = 10_000,
979
- ): Promise<"ready" | "failed"> {
980
- const deadline = Date.now() + timeoutMs;
981
- const readyUrl = `http://127.0.0.1:${port}/api/login/initial`;
982
-
983
- return new Promise((resolve) => {
984
- let settled = false;
985
- let timer: ReturnType<typeof setTimeout> | undefined;
986
- const finish = (result: "ready" | "failed") => {
987
- if (settled) {
988
- return;
989
- }
990
- settled = true;
991
-
992
- if (timer !== undefined) {
993
- clearTimeout(timer);
994
- }
995
-
996
- resolve(result);
997
- };
998
- const scheduleCheck = () => {
999
- timer = setTimeout(checkReady, 50);
1000
- };
1001
- const checkReady = async () => {
1002
- if (Date.now() > deadline) {
1003
- finish("failed");
1004
- return;
1005
- }
1006
-
1007
- try {
1008
- const response = await fetch(readyUrl, { method: "POST" });
1009
-
1010
- if (response.status < 500) {
1011
- finish("ready");
1012
- return;
1013
- }
1014
- } catch (_error) {
1015
- scheduleCheck();
1016
- return;
1017
- }
1018
-
1019
- scheduleCheck();
1020
- };
1021
-
1022
- void exited.then(() => finish("failed"));
1023
- void checkReady();
1024
- });
1025
- }
1026
-
1027
- function waitForCodeServerSpawn(
1028
- child: ChildProcess,
1029
- ): Promise<"spawned" | "failed"> {
1030
- return new Promise((resolve) => {
1031
- let settled = false;
1032
- const cleanup = () => {
1033
- child.off("spawn", onSpawn);
1034
- child.off("error", onError);
1035
- };
1036
- const onSpawn = () => {
1037
- if (settled) {
1038
- return;
1039
- }
1040
- settled = true;
1041
- cleanup();
1042
- resolve("spawned");
1043
- };
1044
- const onError = () => {
1045
- if (settled) {
1046
- return;
1047
- }
1048
- settled = true;
1049
- cleanup();
1050
- resolve("failed");
1051
- };
1052
-
1053
- child.once("spawn", onSpawn);
1054
- child.once("error", onError);
1055
- setImmediate(() => {
1056
- if (!settled && child.pid !== undefined) {
1057
- onSpawn();
1058
- }
1059
- });
1060
- });
1061
- }