dsh-code-server-app 0.1.2
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.
- package/README.md +150 -0
- package/cordis.patch.yml +31 -0
- package/lib/client.js +945 -0
- package/lib/index.js +666 -0
- package/package.json +47 -0
- package/scripts/setup-code-server.mjs +157 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-code-server — host 半部:code-server 进程的启动/停止/状态管理 + /code-server JSON API。
|
|
3
|
+
*
|
|
4
|
+
* 零外部依赖:只用 Node 内置模块(child_process / http / fs / path / os)。
|
|
5
|
+
* 静态 profile 插件,与 dsh-webproxy-router-plugin 同形态:
|
|
6
|
+
* - exports.name = 插件名(与 cordis.patch.yml 行 id 一致)
|
|
7
|
+
* - exports.inject = ['webServer'](硬依赖:等待 webserver 服务就绪)
|
|
8
|
+
* - apply(ctx, config) 注册 3 条 exact JSON 路由:status / start / stop
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* - 进程生命周期归本插件:启动写 pid.json,停止用树级终止(taskkill /T 或
|
|
12
|
+
* 进程组 SIGTERM→SIGKILL),退出监听更新状态。
|
|
13
|
+
* - host 重启后 adopt:pid.json 中的进程仍存活且 /healthz 响应 → 接管为
|
|
14
|
+
* running(不重复启动);否则清理 pid.json 视为 stopped。绝不误杀别的进程。
|
|
15
|
+
* - 就绪探测轮询 /healthz;失败时 status 携带启动日志尾部与错误信息。
|
|
16
|
+
* - auth=none 仅允许回环 host;非回环强制 password(未配置 token 则拒绝启动)。
|
|
17
|
+
* - 挂载于 ctx.effect:插件销毁(host 关闭/卸载)时回收自己启动的进程。
|
|
18
|
+
*
|
|
19
|
+
* API(同源 fetch,与 webproxy-plugin 的 webServer JSON API 同机制):
|
|
20
|
+
* GET /code-server/status → { ok, running, status, port, pid, cwd, url,
|
|
21
|
+
* version, error, logTail[, adopted] }
|
|
22
|
+
* POST /code-server/start { cwd? } → 幂等启动(换 cwd 则先停后启) → status
|
|
23
|
+
* POST /code-server/stop → 停止 → status
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn, execFile, execFileSync } from 'node:child_process';
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import * as os from 'node:os';
|
|
30
|
+
import * as http from 'node:http';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
// schemastery 由 DSH 部署自带(官方核心依赖),仿 auto-open-web 的解析策略:
|
|
35
|
+
// 常规 import 优先,不可用时回退到全局 npm 布局的 DSH 部署副本。
|
|
36
|
+
let z = null;
|
|
37
|
+
try {
|
|
38
|
+
z = (await import('@deepseek-ai/schemastery')).default;
|
|
39
|
+
} catch {
|
|
40
|
+
try {
|
|
41
|
+
const globalRoot = process.env.APPDATA ? path.join(process.env.APPDATA, 'npm', 'node_modules') : '';
|
|
42
|
+
const dshEntry = path.join(globalRoot, '@deepseek-ai', 'dsh', 'package.json');
|
|
43
|
+
if (fs.existsSync(dshEntry)) z = createRequire(dshEntry)('@deepseek-ai/schemastery');
|
|
44
|
+
} catch {
|
|
45
|
+
/* 两次解析均失败 → 下方抛错 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (z === null || z === undefined) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'[code-server] schemastery not found (neither local nor DSH deployment); ' +
|
|
51
|
+
'this plugin cannot build its settings schema. Check the DSH deployment.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const name = 'code-server';
|
|
56
|
+
export const inject = ['webServer', 'settings'];
|
|
57
|
+
|
|
58
|
+
/** 设置命名空间:卡片经官方 settings 域读写,持久化到官方 settings 文档。 */
|
|
59
|
+
export const SETTINGS_NS = 'code-server';
|
|
60
|
+
|
|
61
|
+
/** 设置卡片 schema(参照 auto-open-web 的 Config 形态)。 */
|
|
62
|
+
export const Config = z.object({
|
|
63
|
+
/** reserveComposer=true(默认)窗口不盖输入框(初始/缩放/最大化止于输入栏上方);
|
|
64
|
+
* false 时允许盖住输入框(最大化到视口底)。 */
|
|
65
|
+
reserveComposer: z.boolean().default(true),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const DEFAULT_CONFIG = {
|
|
69
|
+
bin: 'code-server',
|
|
70
|
+
host: '127.0.0.1',
|
|
71
|
+
port: 8090,
|
|
72
|
+
auth: 'none',
|
|
73
|
+
passwordToken: '',
|
|
74
|
+
userDataDir: '',
|
|
75
|
+
extensionsDir: '',
|
|
76
|
+
readyTimeoutMs: 60000,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
80
|
+
const LOG_TAIL_MAX = 6000;
|
|
81
|
+
|
|
82
|
+
function dshHome() {
|
|
83
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dataRoot(config) {
|
|
87
|
+
return path.join(dshHome(), 'code-server');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pidFile(config) {
|
|
91
|
+
return path.join(dataRoot(config), 'pid.json');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isAlive(pid) {
|
|
95
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return err && err.code === 'EPERM';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function win32() {
|
|
105
|
+
return process.platform === 'win32';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 插件自带 code-server 入口探测,兼容三种依赖布局:
|
|
109
|
+
* 1. profile 专用目录:profileRoot/.code-server-app/node_modules/code-server/...
|
|
110
|
+
* (方案 D 默认安装位,独立项目、避开 profile 依赖树冲突);
|
|
111
|
+
* 2. npm/flat:插件目录/node_modules/code-server/...(工作区/独立 npm install);
|
|
112
|
+
* 3. pnpm hoisted:profile node_modules/code-server/...(历史布局)。
|
|
113
|
+
* 不存在 → 回退配置/PATH。 */
|
|
114
|
+
function bundledRuntimeEntry() {
|
|
115
|
+
try {
|
|
116
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // .../dsh-code-server-app/lib 或 .../dsh-code-server/lib
|
|
117
|
+
const candidates = [
|
|
118
|
+
// profile 专用目录:here=.../dsh-code-server/lib → ../..=node_modules → ../../..=profile根
|
|
119
|
+
path.join(here, '..', '..', '..', '.code-server-app', 'node_modules', 'code-server', 'out', 'node', 'entry.js'),
|
|
120
|
+
path.join(here, '..', 'node_modules', 'code-server', 'out', 'node', 'entry.js'),
|
|
121
|
+
path.join(here, '..', '..', 'code-server', 'out', 'node', 'entry.js'),
|
|
122
|
+
];
|
|
123
|
+
for (const entry of candidates) {
|
|
124
|
+
if (fs.existsSync(entry)) return entry;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 环境检测:code-server 入口 + 原生模块 + VS Code 内部依赖 是否就绪。
|
|
133
|
+
* 返回 { ok, entry, native, vscodeInner, node, platform, arch, pathToSetup }。 */
|
|
134
|
+
function envCheck() {
|
|
135
|
+
const entry = bundledRuntimeEntry();
|
|
136
|
+
const csRoot = entry ? path.dirname(path.dirname(path.dirname(entry))) : null; // .../code-server
|
|
137
|
+
const nativeOk = csRoot !== null && (
|
|
138
|
+
fs.existsSync(path.join(csRoot, 'node_modules', 'argon2', 'build', 'Release', 'argon2.node')) ||
|
|
139
|
+
fs.existsSync(path.join(csRoot, 'node_modules', 'argon2', 'prebuilds'))
|
|
140
|
+
);
|
|
141
|
+
const innerOk = csRoot !== null &&
|
|
142
|
+
fs.existsSync(path.join(csRoot, 'lib', 'vscode', 'node_modules'));
|
|
143
|
+
return {
|
|
144
|
+
ok: entry !== null && nativeOk && innerOk,
|
|
145
|
+
entry: entry !== null ? entry.replace(/\\/g, '/') : null,
|
|
146
|
+
native: nativeOk,
|
|
147
|
+
vscodeInner: innerOk,
|
|
148
|
+
node: process.version,
|
|
149
|
+
platform: process.platform,
|
|
150
|
+
arch: process.arch,
|
|
151
|
+
pathToSetup: path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'setup-code-server.mjs').replace(/\\/g, '/'),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 解析 code-server 启动方式:返回 { launched, command, script } | { command, args }。
|
|
156
|
+
* - `bin` 为可执行文件(裸名/绝对路径 .exe/.cmd):直接 spawn;
|
|
157
|
+
* - `bin` 为 JS 入口(依赖安装的 out/node/entry.js):自动用 node 运行;
|
|
158
|
+
* - 未配置 `bin` 时:先探测插件依赖安装的 code-server,再回退 PATH 裸名 code-server。 */
|
|
159
|
+
function resolveLaunch(config) {
|
|
160
|
+
const probe = bundledRuntimeEntry();
|
|
161
|
+
let preferred = config.bin || DEFAULT_CONFIG.bin;
|
|
162
|
+
// 依赖安装的 code-server 优先,除非用户显式配置了非默认 bin
|
|
163
|
+
if (preferred === DEFAULT_CONFIG.bin && probe !== null) preferred = probe;
|
|
164
|
+
console.log(`[code-server] resolveLaunch: configuredBin=${config.bin ?? '(none)'} probe=${probe ?? '(none)'} -> ${preferred}`);
|
|
165
|
+
if (/\.(js|mjs|cjs)$/i.test(preferred)) {
|
|
166
|
+
// JS 入口:node <entry> ...(code-server 的 out/node/entry.js 是官方发布形态)
|
|
167
|
+
if (!fs.existsSync(preferred)) {
|
|
168
|
+
throw new Error(`code-server 入口不存在: ${preferred}`);
|
|
169
|
+
}
|
|
170
|
+
return { kind: 'node', script: preferred };
|
|
171
|
+
}
|
|
172
|
+
if (path.isAbsolute(preferred)) return { kind: 'bin', command: preferred };
|
|
173
|
+
try {
|
|
174
|
+
if (win32()) {
|
|
175
|
+
const out = execFileSync('where.exe', [preferred], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
176
|
+
const lines = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
177
|
+
return { kind: 'bin', command: lines.find((l) => /\.cmd$/i.test(l)) ?? lines.find((l) => /\.exe$/i.test(l)) ?? lines[0] };
|
|
178
|
+
}
|
|
179
|
+
const out = execFileSync('which', [preferred], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
180
|
+
return { kind: 'bin', command: out.split('\n')[0].trim() };
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`code-server 未找到("${preferred}" 不在 PATH)。请安装最新版并确保可执行: ` +
|
|
184
|
+
`npm install -g code-server@latest(Windows 原生需配套最新版 node-gyp 与 VS Spectre 缓解库),` +
|
|
185
|
+
`或在 cordis.patch.yml 的 code-server config 中把 bin 指向已安装的 code-server 可执行文件 / out/node/entry.js。`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function healthCheck(host, port, timeoutMs = 1500) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const req = http.get({ host, port, path: '/healthz', timeout: timeoutMs, method: 'GET' }, (res) => {
|
|
193
|
+
res.resume();
|
|
194
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 500, statusCode: res.statusCode });
|
|
195
|
+
});
|
|
196
|
+
req.on('error', () => resolve({ ok: false }));
|
|
197
|
+
req.on('timeout', () => {
|
|
198
|
+
req.destroy();
|
|
199
|
+
resolve({ ok: false });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readPidFile(config) {
|
|
205
|
+
try {
|
|
206
|
+
const raw = JSON.parse(fs.readFileSync(pidFile(config), 'utf8'));
|
|
207
|
+
return raw && typeof raw.pid === 'number' ? raw : null;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function writePidFile(config, record) {
|
|
214
|
+
fs.mkdirSync(path.dirname(pidFile(config)), { recursive: true });
|
|
215
|
+
fs.writeFileSync(pidFile(config), JSON.stringify(record, null, 2), 'utf8');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function removePidFile(config) {
|
|
219
|
+
try {
|
|
220
|
+
fs.rmSync(pidFile(config), { force: true });
|
|
221
|
+
} catch {
|
|
222
|
+
// ignore
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function apply(ctx, config) {
|
|
227
|
+
const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
|
|
228
|
+
|
|
229
|
+
// ---- 设置:行配置为种子;settings 命名空间持久化(设置卡片写入) ----
|
|
230
|
+
const settingsSvc = ctx.get('settings');
|
|
231
|
+
let reserveComposer = true;
|
|
232
|
+
if (settingsSvc !== undefined && typeof settingsSvc.register === 'function') {
|
|
233
|
+
try {
|
|
234
|
+
const scope = settingsSvc.register(SETTINGS_NS, Config);
|
|
235
|
+
const rawDoc = settingsSvc.get(SETTINGS_NS);
|
|
236
|
+
if (rawDoc !== undefined && rawDoc !== null) {
|
|
237
|
+
const resolved = scope.get();
|
|
238
|
+
reserveComposer = resolved && typeof resolved.reserveComposer === 'boolean' ? resolved.reserveComposer : true;
|
|
239
|
+
}
|
|
240
|
+
scope.watch((next) => {
|
|
241
|
+
if (next != null && typeof next.reserveComposer === 'boolean') {
|
|
242
|
+
reserveComposer = next.reserveComposer;
|
|
243
|
+
console.log(`[code-server] reserveComposer updated: ${reserveComposer}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error(`[code-server] settings unavailable; using default reserveComposer=true: ${error.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const state = {
|
|
252
|
+
status: 'stopped', // stopped | starting | running | stopping | error
|
|
253
|
+
pid: null,
|
|
254
|
+
port: cfg.port,
|
|
255
|
+
cwd: null,
|
|
256
|
+
version: null,
|
|
257
|
+
error: null,
|
|
258
|
+
logTail: '',
|
|
259
|
+
startedAt: null,
|
|
260
|
+
adopted: false,
|
|
261
|
+
env: envCheck(), // 环境检测(code-server 入口/native/内部依赖)
|
|
262
|
+
setup: { running: false, done: false, ok: false, logTail: '', startedAt: null, finishedAt: null }, // 环境安装任务
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
let child = null;
|
|
266
|
+
let pollTimer = null;
|
|
267
|
+
let disposeKilled = false;
|
|
268
|
+
|
|
269
|
+
function snapshot() {
|
|
270
|
+
const running = state.status === 'running' && state.pid !== null;
|
|
271
|
+
return {
|
|
272
|
+
ok: state.status !== 'error' || running,
|
|
273
|
+
running,
|
|
274
|
+
status: state.status,
|
|
275
|
+
port: state.port,
|
|
276
|
+
host: cfg.host,
|
|
277
|
+
pid: state.pid,
|
|
278
|
+
cwd: state.cwd,
|
|
279
|
+
url: running ? `http://${cfg.host}:${state.port}/` : null,
|
|
280
|
+
version: state.version,
|
|
281
|
+
error: state.error,
|
|
282
|
+
logTail: state.logTail.slice(-LOG_TAIL_MAX),
|
|
283
|
+
adopted: state.adopted,
|
|
284
|
+
reserveComposer,
|
|
285
|
+
env: state.env,
|
|
286
|
+
setup: {
|
|
287
|
+
running: state.setup.running,
|
|
288
|
+
done: state.setup.done,
|
|
289
|
+
ok: state.setup.ok,
|
|
290
|
+
logTail: state.setup.logTail.slice(-LOG_TAIL_MAX),
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function appendLog(chunk) {
|
|
296
|
+
try {
|
|
297
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
298
|
+
state.logTail = (state.logTail + text).slice(-LOG_TAIL_MAX * 2);
|
|
299
|
+
} catch {
|
|
300
|
+
// ignore
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function stopPolling() {
|
|
305
|
+
if (pollTimer !== null) {
|
|
306
|
+
clearInterval(pollTimer);
|
|
307
|
+
pollTimer = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function killTree(pid) {
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
if (!isAlive(pid)) return resolve(false);
|
|
314
|
+
if (win32()) {
|
|
315
|
+
execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], (err) => resolve(!err));
|
|
316
|
+
} else {
|
|
317
|
+
try {
|
|
318
|
+
process.kill(-pid, 'SIGTERM');
|
|
319
|
+
} catch {
|
|
320
|
+
try {
|
|
321
|
+
process.kill(pid, 'SIGTERM');
|
|
322
|
+
} catch {
|
|
323
|
+
return resolve(false);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const timer = setTimeout(() => {
|
|
327
|
+
try {
|
|
328
|
+
process.kill(-pid, 'SIGKILL');
|
|
329
|
+
} catch {
|
|
330
|
+
try {
|
|
331
|
+
process.kill(pid, 'SIGKILL');
|
|
332
|
+
} catch {
|
|
333
|
+
// gone
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
resolve(true);
|
|
337
|
+
}, 3000);
|
|
338
|
+
timer.unref?.();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function stop(reason) {
|
|
344
|
+
stopPolling();
|
|
345
|
+
const wasRunning = state.status === 'running' || state.status === 'starting';
|
|
346
|
+
const pid = state.pid;
|
|
347
|
+
if (child !== null) {
|
|
348
|
+
child.stdout?.removeAllListeners?.('data');
|
|
349
|
+
child.stderr?.removeAllListeners?.('data');
|
|
350
|
+
try {
|
|
351
|
+
child.removeAllListeners?.('exit');
|
|
352
|
+
child.removeAllListeners?.('error');
|
|
353
|
+
} catch {
|
|
354
|
+
// ignore
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
child = null;
|
|
358
|
+
if (pid) {
|
|
359
|
+
await killTree(pid);
|
|
360
|
+
}
|
|
361
|
+
removePidFile(cfg);
|
|
362
|
+
state.status = 'stopped';
|
|
363
|
+
state.pid = null;
|
|
364
|
+
state.cwd = null;
|
|
365
|
+
state.startedAt = null;
|
|
366
|
+
state.adopted = false;
|
|
367
|
+
if (reason) state.error = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function beginPollingReady() {
|
|
371
|
+
stopPolling();
|
|
372
|
+
const deadline = Date.now() + (Number(cfg.readyTimeoutMs) || DEFAULT_CONFIG.readyTimeoutMs);
|
|
373
|
+
pollTimer = setInterval(async () => {
|
|
374
|
+
const probe = await healthCheck(cfg.host, state.port);
|
|
375
|
+
if (probe.ok) {
|
|
376
|
+
stopPolling();
|
|
377
|
+
state.status = 'running';
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (Date.now() > deadline) {
|
|
381
|
+
stopPolling();
|
|
382
|
+
state.status = 'error';
|
|
383
|
+
state.error = `启动超时(${cfg.readyTimeoutMs}ms 内 /healthz 未就绪);code-server 启动日志尾部:\n${state.logTail.slice(-2000)}`;
|
|
384
|
+
}
|
|
385
|
+
}, 1500);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function start(cwdArg) {
|
|
389
|
+
const cwd = typeof cwdArg === 'string' && cwdArg.trim() !== '' ? cwdArg : undefined;
|
|
390
|
+
if (state.status === 'running' && state.pid !== null) {
|
|
391
|
+
if (cwd === undefined || cwd === state.cwd) return snapshot();
|
|
392
|
+
await stop('restart');
|
|
393
|
+
}
|
|
394
|
+
// 上一次启动仍在进行:等待它结算后再按本次 cwd 启动,避免 cwd 切换被吞
|
|
395
|
+
if (state.status === 'starting') {
|
|
396
|
+
const deadline = Date.now() + 10000;
|
|
397
|
+
while (state.status === 'starting' && Date.now() < deadline) {
|
|
398
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
399
|
+
}
|
|
400
|
+
if (state.status === 'starting') {
|
|
401
|
+
state.status = 'error';
|
|
402
|
+
state.error = '启动长时间未结算(10s),请查看启动日志;后可重试';
|
|
403
|
+
stopPolling();
|
|
404
|
+
return snapshot();
|
|
405
|
+
}
|
|
406
|
+
if (state.status === 'running') {
|
|
407
|
+
if (cwd === undefined || cwd === state.cwd) return snapshot();
|
|
408
|
+
await stop('restart');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const launch = resolveLaunch(cfg); // throws with install guidance when missing
|
|
413
|
+
|
|
414
|
+
// 认证:非回环 host 必须 password;显式 password 必须带 token
|
|
415
|
+
const auth = cfg.auth || 'none';
|
|
416
|
+
if (auth === 'none' && !LOOPBACK_HOSTS.has(cfg.host)) {
|
|
417
|
+
state.status = 'error';
|
|
418
|
+
state.error = `auth=none 仅允许回环绑定(当前 host="${cfg.host}");请改用 auth=password 并配置 passwordToken`;
|
|
419
|
+
return snapshot();
|
|
420
|
+
}
|
|
421
|
+
if (auth === 'password' && !cfg.passwordToken) {
|
|
422
|
+
state.status = 'error';
|
|
423
|
+
state.error = 'auth=password 需要配置 passwordToken(cordis.patch.yml 的 config.passwordToken)';
|
|
424
|
+
return snapshot();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 端口占用则尝试 adopt(pid.json 有效 + /healthz 响应),否则报错
|
|
428
|
+
const probe = await healthCheck(cfg.host, cfg.port, 800);
|
|
429
|
+
if (probe.ok) {
|
|
430
|
+
const record = readPidFile(cfg);
|
|
431
|
+
if (record && isAlive(record.pid)) {
|
|
432
|
+
state.status = 'running';
|
|
433
|
+
state.pid = record.pid;
|
|
434
|
+
state.cwd = cwd ?? record.cwd ?? null;
|
|
435
|
+
state.startedAt = record.startedAt ?? null;
|
|
436
|
+
state.adopted = true;
|
|
437
|
+
return snapshot();
|
|
438
|
+
}
|
|
439
|
+
state.status = 'error';
|
|
440
|
+
state.error = `端口 ${cfg.port} 已被占用且没有有效的 pid.json 记录(拒绝误杀);请释放端口或修改 port 配置`;
|
|
441
|
+
return snapshot();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 重建数据目录
|
|
445
|
+
const root = dataRoot(cfg);
|
|
446
|
+
const userDataDir = cfg.userDataDir || path.join(root, 'user-data');
|
|
447
|
+
const extensionsDir = cfg.extensionsDir || path.join(root, 'extensions');
|
|
448
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
449
|
+
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
450
|
+
|
|
451
|
+
const args = [
|
|
452
|
+
'--bind-addr', `${cfg.host}:${cfg.port}`,
|
|
453
|
+
'--auth', auth === 'password' ? 'password' : 'none',
|
|
454
|
+
'--user-data-dir', userDataDir,
|
|
455
|
+
'--extensions-dir', extensionsDir,
|
|
456
|
+
'--disable-telemetry',
|
|
457
|
+
'--disable-update-check',
|
|
458
|
+
];
|
|
459
|
+
if (cwd !== undefined) args.push(cwd);
|
|
460
|
+
|
|
461
|
+
state.error = null;
|
|
462
|
+
state.logTail = '';
|
|
463
|
+
state.status = 'starting';
|
|
464
|
+
state.cwd = cwd ?? null;
|
|
465
|
+
state.startedAt = Date.now();
|
|
466
|
+
state.adopted = false;
|
|
467
|
+
|
|
468
|
+
const env = { ...process.env };
|
|
469
|
+
if (auth === 'password') env.PASSWORD = cfg.passwordToken;
|
|
470
|
+
|
|
471
|
+
let proc;
|
|
472
|
+
try {
|
|
473
|
+
const isCmd = launch.kind === 'bin' && win32() && /\.cmd$/i.test(launch.command);
|
|
474
|
+
const command = launch.kind === 'node' ? process.execPath : launch.command;
|
|
475
|
+
const spawnArgs = launch.kind === 'node' ? [launch.script, ...args] : args;
|
|
476
|
+
// shell 仅对 .cmd shim(Windows npm 全局包)必要:它必须经 cmd.exe 解析。
|
|
477
|
+
// 含空格路径由 spawn 数组传参,不再经 shell 拼接,避免 'C:\Program' 拆分。
|
|
478
|
+
proc = spawn(isCmd ? `"${command}"` : command, spawnArgs, {
|
|
479
|
+
cwd: cwd ?? process.cwd(),
|
|
480
|
+
env,
|
|
481
|
+
shell: isCmd,
|
|
482
|
+
windowsHide: true,
|
|
483
|
+
detached: !win32() && !isCmd,
|
|
484
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
485
|
+
});
|
|
486
|
+
} catch (err) {
|
|
487
|
+
state.status = 'error';
|
|
488
|
+
state.error = `spawn 失败: ${err && err.message ? err.message : String(err)}`;
|
|
489
|
+
return snapshot();
|
|
490
|
+
}
|
|
491
|
+
child = proc;
|
|
492
|
+
state.pid = proc.pid ?? null;
|
|
493
|
+
writePidFile(cfg, {
|
|
494
|
+
pid: proc.pid,
|
|
495
|
+
startedAt: state.startedAt,
|
|
496
|
+
cwd: cwd ?? null,
|
|
497
|
+
host: cfg.host,
|
|
498
|
+
port: cfg.port,
|
|
499
|
+
launchKind: launch.kind,
|
|
500
|
+
launchCommand: launch.kind === 'node' ? launch.script : launch.command,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
proc.stdout?.on?.('data', appendLog);
|
|
504
|
+
proc.stderr?.on?.('data', appendLog);
|
|
505
|
+
|
|
506
|
+
proc.on('error', (err) => {
|
|
507
|
+
if (child !== proc) return;
|
|
508
|
+
child = null;
|
|
509
|
+
removePidFile(cfg);
|
|
510
|
+
state.status = 'error';
|
|
511
|
+
state.error = `code-server 启动失败: ${err && err.message ? err.message : String(err)}\n${state.logTail.slice(-1000)}`;
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
proc.on('exit', (code, signal) => {
|
|
515
|
+
if (child !== proc) return; // 已被 stop/dispose 接管
|
|
516
|
+
child = null;
|
|
517
|
+
removePidFile(cfg);
|
|
518
|
+
if (disposeKilled) return;
|
|
519
|
+
state.status = 'error';
|
|
520
|
+
state.error = `code-server 意外退出${code !== null ? `(exit ${code})` : signal ? `(signal ${signal})` : ''}:\n${state.logTail.slice(-1500)}`;
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
beginPollingReady();
|
|
524
|
+
return snapshot();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** 后台启动环境安装(setup 脚本):npm 自装 code-server + native + vscode 内部依赖。
|
|
528
|
+
* 异步运行,进度经 status.setup 轮询返回;完成后刷新 envCheck。 */
|
|
529
|
+
function startSetup() {
|
|
530
|
+
if (state.setup.running) return;
|
|
531
|
+
const script = envCheck().pathToSetup;
|
|
532
|
+
state.setup = { running: true, done: false, ok: false, logTail: '', startedAt: Date.now(), finishedAt: null };
|
|
533
|
+
let proc;
|
|
534
|
+
try {
|
|
535
|
+
proc = spawn(process.execPath, [script], {
|
|
536
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
537
|
+
windowsHide: true,
|
|
538
|
+
});
|
|
539
|
+
} catch (err) {
|
|
540
|
+
state.setup = { running: false, done: true, ok: false, logTail: `spawn 失败: ${err.message}`, startedAt: Date.now(), finishedAt: Date.now() };
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const onChunk = (chunk) => {
|
|
544
|
+
try {
|
|
545
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
546
|
+
state.setup.logTail = (state.setup.logTail + text).slice(-LOG_TAIL_MAX * 2);
|
|
547
|
+
} catch { /* ignore */ }
|
|
548
|
+
};
|
|
549
|
+
proc.stdout?.on?.('data', onChunk);
|
|
550
|
+
proc.stderr?.on?.('data', onChunk);
|
|
551
|
+
proc.on('error', (err) => {
|
|
552
|
+
state.setup = { ...state.setup, running: false, done: true, ok: false, logTail: state.setup.logTail + `\nspawn 错误: ${err.message}`, finishedAt: Date.now() };
|
|
553
|
+
state.env = envCheck();
|
|
554
|
+
});
|
|
555
|
+
proc.on('exit', (code) => {
|
|
556
|
+
state.setup = { ...state.setup, running: false, done: true, ok: code === 0, finishedAt: Date.now() };
|
|
557
|
+
state.env = envCheck();
|
|
558
|
+
console.log(`[code-server] setup finished code=${code} env.ok=${state.env.ok}`);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function handleApi(req, res) {
|
|
563
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
564
|
+
const payload = { ok: false, error: 'unknown route' };
|
|
565
|
+
try {
|
|
566
|
+
if (req.url === '/code-server/status' && method === 'GET') {
|
|
567
|
+
Object.assign(payload, snapshot());
|
|
568
|
+
} else if (req.url === '/code-server/start' && method === 'POST') {
|
|
569
|
+
let body = {};
|
|
570
|
+
for await (const chunk of req) {
|
|
571
|
+
const text = chunk.toString('utf8');
|
|
572
|
+
if (text) {
|
|
573
|
+
try {
|
|
574
|
+
body = { ...body, ...JSON.parse(text) };
|
|
575
|
+
} catch {
|
|
576
|
+
// ignore malformed fragments; keep best-effort
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const cwd = body && typeof body.cwd === 'string' ? body.cwd : undefined;
|
|
581
|
+
Object.assign(payload, await start(cwd));
|
|
582
|
+
} else if (req.url === '/code-server/stop' && method === 'POST') {
|
|
583
|
+
await stop('user');
|
|
584
|
+
Object.assign(payload, snapshot());
|
|
585
|
+
} else if (req.url === '/code-server/setup' && method === 'POST') {
|
|
586
|
+
// 环境安装:后台执行 setup 脚本(npm 自装 code-server + native + vscode 内部依赖)
|
|
587
|
+
if (state.setup.running) {
|
|
588
|
+
// 已在安装中:同样视为"已发起"(客户端会轮询 setup 状态直至结束)
|
|
589
|
+
payload.ok = true;
|
|
590
|
+
payload.message = '环境安装已在进行中,请等待完成';
|
|
591
|
+
payload.error = null;
|
|
592
|
+
} else {
|
|
593
|
+
startSetup();
|
|
594
|
+
Object.assign(payload, snapshot());
|
|
595
|
+
payload.ok = true; // 安装任务已启动;服务态 error(如未安装)不掩盖安装态
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
payload.error = 'unknown route';
|
|
599
|
+
payload.status = 404;
|
|
600
|
+
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
|
|
601
|
+
res.end(JSON.stringify(payload));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
605
|
+
res.end(JSON.stringify(payload));
|
|
606
|
+
} catch (err) {
|
|
607
|
+
payload.ok = false;
|
|
608
|
+
payload.error = err && err.message ? err.message : String(err);
|
|
609
|
+
payload.runner = 'code-server';
|
|
610
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
611
|
+
res.end(JSON.stringify(payload));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const webServer = ctx.get('webServer');
|
|
616
|
+
if (webServer === undefined) {
|
|
617
|
+
console.error('[code-server] webServer service unavailable; plugin registered but idle');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const disposers = [
|
|
622
|
+
webServer.register({ kind: 'exact', path: '/code-server/status', handler: handleApi }),
|
|
623
|
+
webServer.register({ kind: 'exact', path: '/code-server/start', handler: handleApi }),
|
|
624
|
+
webServer.register({ kind: 'exact', path: '/code-server/stop', handler: handleApi }),
|
|
625
|
+
webServer.register({ kind: 'exact', path: '/code-server/setup', handler: handleApi }),
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
ctx.effect(() => {
|
|
629
|
+
return () => {
|
|
630
|
+
disposeKilled = true;
|
|
631
|
+
stopPolling();
|
|
632
|
+
for (const d of disposers) {
|
|
633
|
+
try {
|
|
634
|
+
d();
|
|
635
|
+
} catch {
|
|
636
|
+
// ignore
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (child !== null || (state.pid !== null && isAlive(state.pid))) {
|
|
640
|
+
const pid = state.pid;
|
|
641
|
+
child = null;
|
|
642
|
+
if (pid) {
|
|
643
|
+
killTree(pid).then(() => removePidFile(cfg));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
}, 'code-server: lifecycle');
|
|
648
|
+
|
|
649
|
+
// DSH host 重启后 adopt:pid.json 有效且进程存活且 /healthz 响应 → 接管
|
|
650
|
+
const record = readPidFile(cfg);
|
|
651
|
+
if (record && isAlive(record.pid)) {
|
|
652
|
+
const probe = await healthCheck(cfg.host, cfg.port, 800);
|
|
653
|
+
if (probe.ok) {
|
|
654
|
+
state.status = 'running';
|
|
655
|
+
state.pid = record.pid;
|
|
656
|
+
state.cwd = record.cwd ?? null;
|
|
657
|
+
state.startedAt = record.startedAt ?? null;
|
|
658
|
+
state.adopted = true;
|
|
659
|
+
console.log(`[code-server] adopted running instance pid=${record.pid} port=${cfg.port}`);
|
|
660
|
+
} else {
|
|
661
|
+
removePidFile(cfg);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
console.log(`[code-server] static plugin loaded (host=${cfg.host} port=${cfg.port} auth=${cfg.auth})`);
|
|
666
|
+
}
|