@spencer-kit/coder-studio 0.1.3
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 +24 -0
- package/bin/coder-studio.mjs +10 -0
- package/lib/cli.mjs +1190 -0
- package/lib/completion.mjs +562 -0
- package/lib/config.mjs +59 -0
- package/lib/http.mjs +89 -0
- package/lib/platform.mjs +50 -0
- package/lib/process-utils.mjs +76 -0
- package/lib/runtime-controller.mjs +350 -0
- package/lib/state.mjs +75 -0
- package/lib/user-config.mjs +521 -0
- package/package.json +26 -0
package/lib/http.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { buildEndpoint } from './config.mjs';
|
|
4
|
+
function adminEndpoint(endpoint) {
|
|
5
|
+
const url = new URL(endpoint);
|
|
6
|
+
const host = url.hostname;
|
|
7
|
+
if (host === '0.0.0.0' || host === '::' || host === '[::]') {
|
|
8
|
+
url.hostname = '127.0.0.1';
|
|
9
|
+
}
|
|
10
|
+
return url.toString().replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
async function requestJson(url, init = {}) {
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
...init,
|
|
15
|
+
headers: {
|
|
16
|
+
'content-type': 'application/json',
|
|
17
|
+
...(init.headers ?? {}),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
let body = null;
|
|
21
|
+
try {
|
|
22
|
+
body = await response.json();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
body = null;
|
|
26
|
+
}
|
|
27
|
+
if (!response.ok || body?.ok === false) {
|
|
28
|
+
const error = body?.error || `${response.status}`;
|
|
29
|
+
throw new Error(error);
|
|
30
|
+
}
|
|
31
|
+
return body?.data ?? body ?? null;
|
|
32
|
+
}
|
|
33
|
+
export async function fetchHealth(endpoint) {
|
|
34
|
+
const response = await fetch(`${endpoint.replace(/\/$/, '')}/health`);
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`health_http_${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return response.json();
|
|
39
|
+
}
|
|
40
|
+
export async function waitForHealth(endpoint, { timeoutMs = 15000, intervalMs = 250 } = {}) {
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
let lastError = null;
|
|
43
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
44
|
+
try {
|
|
45
|
+
return await fetchHealth(endpoint);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
lastError = error;
|
|
49
|
+
await delay(intervalMs);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastError ?? new Error('health_timeout');
|
|
53
|
+
}
|
|
54
|
+
export async function requestShutdown(endpoint) {
|
|
55
|
+
const response = await fetch(`${endpoint.replace(/\/$/, '')}/api/system/shutdown`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'content-type': 'application/json'
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`shutdown_http_${response.status}`);
|
|
63
|
+
}
|
|
64
|
+
return response.json().catch(() => ({ ok: true }));
|
|
65
|
+
}
|
|
66
|
+
export async function fetchAdminConfig(endpoint) {
|
|
67
|
+
return requestJson(`${adminEndpoint(endpoint)}/api/system/config`, { method: 'GET' });
|
|
68
|
+
}
|
|
69
|
+
export async function patchAdminConfig(endpoint, updates) {
|
|
70
|
+
return requestJson(`${adminEndpoint(endpoint)}/api/system/config`, {
|
|
71
|
+
method: 'PATCH',
|
|
72
|
+
body: JSON.stringify({ updates }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function fetchAdminAuthStatus(endpoint) {
|
|
76
|
+
return requestJson(`${adminEndpoint(endpoint)}/api/system/auth/status`, { method: 'GET' });
|
|
77
|
+
}
|
|
78
|
+
export async function fetchAdminIpBlocks(endpoint) {
|
|
79
|
+
return requestJson(`${adminEndpoint(endpoint)}/api/system/auth/ip-blocks`, { method: 'GET' });
|
|
80
|
+
}
|
|
81
|
+
export async function unblockAdminIp(endpoint, payload) {
|
|
82
|
+
return requestJson(`${adminEndpoint(endpoint)}/api/system/auth/ip-blocks/unblock`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
body: JSON.stringify(payload),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export function buildAdminEndpoint(host, port) {
|
|
88
|
+
return adminEndpoint(buildEndpoint(host, port));
|
|
89
|
+
}
|
package/lib/platform.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const PLATFORM_PACKAGES = {
|
|
7
|
+
'linux:x64': '@spencer-kit/coder-studio-linux-x64',
|
|
8
|
+
'darwin:arm64': '@spencer-kit/coder-studio-darwin-arm64',
|
|
9
|
+
'darwin:x64': '@spencer-kit/coder-studio-darwin-x64',
|
|
10
|
+
'win32:x64': '@spencer-kit/coder-studio-win32-x64'
|
|
11
|
+
};
|
|
12
|
+
export function resolvePlatformPackage(options = {}) {
|
|
13
|
+
const { env = process.env, platform = process.platform, arch = process.arch } = options;
|
|
14
|
+
const binaryName = platform === 'win32' ? 'coder-studio.exe' : 'coder-studio';
|
|
15
|
+
const binaryPath = env.CODER_STUDIO_BINARY_PATH ? path.resolve(env.CODER_STUDIO_BINARY_PATH) : '';
|
|
16
|
+
const distDir = env.CODER_STUDIO_DIST_DIR ? path.resolve(env.CODER_STUDIO_DIST_DIR) : '';
|
|
17
|
+
if (binaryPath) {
|
|
18
|
+
return {
|
|
19
|
+
packageName: 'override',
|
|
20
|
+
packageDir: path.dirname(binaryPath),
|
|
21
|
+
binaryPath,
|
|
22
|
+
distDir,
|
|
23
|
+
binaryName
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const packageName = PLATFORM_PACKAGES[`${platform}:${arch}`];
|
|
27
|
+
if (!packageName) {
|
|
28
|
+
throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
29
|
+
}
|
|
30
|
+
const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
|
31
|
+
const packageDir = path.dirname(packageJsonPath);
|
|
32
|
+
return {
|
|
33
|
+
packageName,
|
|
34
|
+
packageDir,
|
|
35
|
+
binaryPath: path.join(packageDir, 'bin', binaryName),
|
|
36
|
+
distDir: path.join(packageDir, 'dist'),
|
|
37
|
+
binaryName
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function assertRuntimeBundle(bundle) {
|
|
41
|
+
if (!bundle.binaryPath || !fs.existsSync(bundle.binaryPath)) {
|
|
42
|
+
throw new Error(`Runtime binary not found: ${bundle.binaryPath || 'unknown'}`);
|
|
43
|
+
}
|
|
44
|
+
if (process.platform !== 'win32') {
|
|
45
|
+
fs.chmodSync(bundle.binaryPath, 0o755);
|
|
46
|
+
}
|
|
47
|
+
if (!bundle.distDir || !fs.existsSync(bundle.distDir)) {
|
|
48
|
+
throw new Error(`Frontend dist not found: ${bundle.distDir || 'unknown'}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
export function isPidRunning(pid) {
|
|
10
|
+
if (!pid || !Number.isInteger(pid))
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function terminateProcess(pid, { force = false } = {}) {
|
|
21
|
+
if (!pid)
|
|
22
|
+
return;
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
const args = ['/PID', String(pid), '/T'];
|
|
25
|
+
if (force)
|
|
26
|
+
args.push('/F');
|
|
27
|
+
await execFileAsync('taskkill', args);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
31
|
+
}
|
|
32
|
+
export async function waitForProcessExit(pid, timeoutMs = 8000) {
|
|
33
|
+
const startedAt = Date.now();
|
|
34
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
35
|
+
if (!isPidRunning(pid)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
await sleep(200);
|
|
39
|
+
}
|
|
40
|
+
return !isPidRunning(pid);
|
|
41
|
+
}
|
|
42
|
+
export function spawnBackground(command, args, options = {}) {
|
|
43
|
+
return spawn(command, args, {
|
|
44
|
+
...options,
|
|
45
|
+
detached: true,
|
|
46
|
+
stdio: options.stdio ?? 'ignore',
|
|
47
|
+
windowsHide: true
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function spawnForeground(command, args, options = {}) {
|
|
51
|
+
return spawn(command, args, {
|
|
52
|
+
...options,
|
|
53
|
+
stdio: options.stdio ?? 'inherit',
|
|
54
|
+
windowsHide: true
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function openExternal(targetUrl, env = process.env) {
|
|
58
|
+
if (env.CODER_STUDIO_OPEN_COMMAND) {
|
|
59
|
+
const [command, ...extraArgs] = env.CODER_STUDIO_OPEN_COMMAND.split(' ');
|
|
60
|
+
await execFileAsync(command, [...extraArgs, targetUrl]);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (process.platform === 'darwin') {
|
|
64
|
+
await execFileAsync('open', [targetUrl]);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (process.platform === 'win32') {
|
|
68
|
+
await execFileAsync('cmd', ['/c', 'start', '', targetUrl]);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await execFileAsync('xdg-open', [targetUrl]);
|
|
72
|
+
}
|
|
73
|
+
export async function ensureFile(pathname) {
|
|
74
|
+
const handle = await fs.open(pathname, 'a');
|
|
75
|
+
return handle;
|
|
76
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { buildEndpoint, DEFAULT_HOST, DEFAULT_LOG_TAIL_LINES, DEFAULT_PORT, resolveDataDir, resolveLogPath, resolveStateDir } from './config.mjs';
|
|
5
|
+
import { fetchHealth, requestShutdown, waitForHealth } from './http.mjs';
|
|
6
|
+
import { assertRuntimeBundle, resolvePlatformPackage } from './platform.mjs';
|
|
7
|
+
import { ensureFile, isPidRunning, openExternal, spawnBackground, spawnForeground, terminateProcess, waitForProcessExit } from './process-utils.mjs';
|
|
8
|
+
import { buildRuntimeState, clearRuntimeState, ensureStateDirs, readPackageVersion, readRuntimeState, readLogTail, writeRuntimeState } from './state.mjs';
|
|
9
|
+
const DEFAULT_START_TIMEOUT_MS = 15000;
|
|
10
|
+
function resolveStartTimeout(input, env) {
|
|
11
|
+
const candidate = input ?? env?.CODER_STUDIO_START_TIMEOUT_MS;
|
|
12
|
+
const timeout = Number(candidate);
|
|
13
|
+
return Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_START_TIMEOUT_MS;
|
|
14
|
+
}
|
|
15
|
+
function resolveOptions(input = {}) {
|
|
16
|
+
const stateDir = input.stateDir || resolveStateDir(input.env);
|
|
17
|
+
const env = input.env || process.env;
|
|
18
|
+
const host = input.host || DEFAULT_HOST;
|
|
19
|
+
const port = Number(input.port ?? DEFAULT_PORT);
|
|
20
|
+
const endpoint = input.endpoint || buildEndpoint(host, port);
|
|
21
|
+
const dataDir = input.dataDir || resolveDataDir(stateDir, env);
|
|
22
|
+
const logPath = input.logPath || resolveLogPath(stateDir);
|
|
23
|
+
const tailLines = Number(input.tailLines ?? DEFAULT_LOG_TAIL_LINES);
|
|
24
|
+
const timeoutMs = resolveStartTimeout(input.timeoutMs, env);
|
|
25
|
+
return {
|
|
26
|
+
...input,
|
|
27
|
+
stateDir,
|
|
28
|
+
host,
|
|
29
|
+
port,
|
|
30
|
+
endpoint,
|
|
31
|
+
dataDir,
|
|
32
|
+
logPath,
|
|
33
|
+
timeoutMs,
|
|
34
|
+
tailLines: Number.isFinite(tailLines) && tailLines > 0 ? tailLines : DEFAULT_LOG_TAIL_LINES,
|
|
35
|
+
openCommand: input.openCommand || null,
|
|
36
|
+
env
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function probeManagedStatus(options) {
|
|
40
|
+
const runtime = await readRuntimeState(options.stateDir);
|
|
41
|
+
if (!runtime)
|
|
42
|
+
return null;
|
|
43
|
+
const endpoint = runtime.endpoint || options.endpoint;
|
|
44
|
+
const pid = Number(runtime.pid || 0);
|
|
45
|
+
const running = pid > 0 && isPidRunning(pid);
|
|
46
|
+
if (!running) {
|
|
47
|
+
await clearRuntimeState(options.stateDir);
|
|
48
|
+
return {
|
|
49
|
+
status: 'stopped',
|
|
50
|
+
managed: true,
|
|
51
|
+
stale: true,
|
|
52
|
+
endpoint,
|
|
53
|
+
pid,
|
|
54
|
+
runtime
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const health = await fetchHealth(endpoint);
|
|
59
|
+
return {
|
|
60
|
+
status: 'running',
|
|
61
|
+
managed: true,
|
|
62
|
+
stale: false,
|
|
63
|
+
endpoint,
|
|
64
|
+
pid,
|
|
65
|
+
runtime,
|
|
66
|
+
health
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
status: 'degraded',
|
|
72
|
+
managed: true,
|
|
73
|
+
stale: false,
|
|
74
|
+
endpoint,
|
|
75
|
+
pid,
|
|
76
|
+
runtime,
|
|
77
|
+
error: error instanceof Error ? error.message : String(error)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function getStatus(input = {}) {
|
|
82
|
+
const options = resolveOptions(input);
|
|
83
|
+
const managed = await probeManagedStatus(options);
|
|
84
|
+
if (managed) {
|
|
85
|
+
return {
|
|
86
|
+
...managed,
|
|
87
|
+
stateDir: options.stateDir,
|
|
88
|
+
logPath: managed.runtime?.logPath || options.logPath,
|
|
89
|
+
dataDir: options.dataDir
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const health = await fetchHealth(options.endpoint);
|
|
94
|
+
return {
|
|
95
|
+
status: 'running',
|
|
96
|
+
managed: false,
|
|
97
|
+
stale: false,
|
|
98
|
+
endpoint: options.endpoint,
|
|
99
|
+
pid: null,
|
|
100
|
+
runtime: null,
|
|
101
|
+
health,
|
|
102
|
+
stateDir: options.stateDir,
|
|
103
|
+
logPath: options.logPath,
|
|
104
|
+
dataDir: options.dataDir
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'stopped',
|
|
110
|
+
managed: false,
|
|
111
|
+
stale: false,
|
|
112
|
+
endpoint: options.endpoint,
|
|
113
|
+
pid: null,
|
|
114
|
+
runtime: null,
|
|
115
|
+
error: error instanceof Error ? error.message : String(error),
|
|
116
|
+
stateDir: options.stateDir,
|
|
117
|
+
logPath: options.logPath,
|
|
118
|
+
dataDir: options.dataDir
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function waitForReady(endpoint, pid, timeoutMs) {
|
|
123
|
+
const startedAt = Date.now();
|
|
124
|
+
let lastError = null;
|
|
125
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
126
|
+
if (pid && !isPidRunning(pid)) {
|
|
127
|
+
throw new Error('runtime_exited_early');
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await waitForHealth(endpoint, { timeoutMs: 500, intervalMs: 200 });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
lastError = error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw lastError ?? new Error('health_timeout');
|
|
137
|
+
}
|
|
138
|
+
function buildChildEnv(options, bundle) {
|
|
139
|
+
return {
|
|
140
|
+
...process.env,
|
|
141
|
+
...options.env,
|
|
142
|
+
CODER_STUDIO_HOST: options.host,
|
|
143
|
+
CODER_STUDIO_PORT: String(options.port),
|
|
144
|
+
CODER_STUDIO_DATA_DIR: options.dataDir,
|
|
145
|
+
CODER_STUDIO_DIST_DIR: bundle.distDir
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function writeStateForPid(options, bundle, pid) {
|
|
149
|
+
const version = await readPackageVersion();
|
|
150
|
+
const state = buildRuntimeState({
|
|
151
|
+
version,
|
|
152
|
+
pid,
|
|
153
|
+
endpoint: options.endpoint,
|
|
154
|
+
binaryPath: bundle.binaryPath,
|
|
155
|
+
logPath: options.logPath
|
|
156
|
+
});
|
|
157
|
+
await writeRuntimeState(options.stateDir, state);
|
|
158
|
+
return state;
|
|
159
|
+
}
|
|
160
|
+
async function cleanupIfManagedPid(options, pid) {
|
|
161
|
+
const runtime = await readRuntimeState(options.stateDir);
|
|
162
|
+
if (runtime && Number(runtime.pid) === Number(pid)) {
|
|
163
|
+
await clearRuntimeState(options.stateDir);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function startRuntime(input = {}) {
|
|
167
|
+
const options = resolveOptions(input);
|
|
168
|
+
await ensureStateDirs(options.stateDir, options.dataDir);
|
|
169
|
+
const current = await getStatus(options);
|
|
170
|
+
if (current.status === 'running' || current.status === 'degraded') {
|
|
171
|
+
return {
|
|
172
|
+
changed: false,
|
|
173
|
+
...current
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const bundle = resolvePlatformPackage({ env: options.env });
|
|
177
|
+
assertRuntimeBundle(bundle);
|
|
178
|
+
const env = buildChildEnv(options, bundle);
|
|
179
|
+
if (options.foreground) {
|
|
180
|
+
const child = spawnForeground(bundle.binaryPath, [], {
|
|
181
|
+
cwd: options.stateDir,
|
|
182
|
+
env
|
|
183
|
+
});
|
|
184
|
+
if (!child.pid) {
|
|
185
|
+
throw new Error('runtime_pid_missing');
|
|
186
|
+
}
|
|
187
|
+
const exitPromise = once(child, 'exit').then(([code, signal]) => ({ code, signal }));
|
|
188
|
+
const errorPromise = once(child, 'error').then(([error]) => { throw error; });
|
|
189
|
+
await Promise.race([
|
|
190
|
+
waitForReady(options.endpoint, child.pid, options.timeoutMs),
|
|
191
|
+
exitPromise.then(() => {
|
|
192
|
+
throw new Error('runtime_exited_early');
|
|
193
|
+
}),
|
|
194
|
+
errorPromise
|
|
195
|
+
]);
|
|
196
|
+
await writeStateForPid(options, bundle, child.pid);
|
|
197
|
+
if (typeof options.onReady === 'function') {
|
|
198
|
+
await options.onReady({ endpoint: options.endpoint, pid: child.pid, logPath: options.logPath });
|
|
199
|
+
}
|
|
200
|
+
const forward = (signal) => {
|
|
201
|
+
try {
|
|
202
|
+
child.kill(signal);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Ignore when already stopped.
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
process.on('SIGINT', forward);
|
|
209
|
+
process.on('SIGTERM', forward);
|
|
210
|
+
try {
|
|
211
|
+
const { code, signal } = await Promise.race([exitPromise, errorPromise]);
|
|
212
|
+
await cleanupIfManagedPid(options, child.pid);
|
|
213
|
+
return {
|
|
214
|
+
changed: true,
|
|
215
|
+
status: code === 0 ? 'stopped' : 'failed',
|
|
216
|
+
endpoint: options.endpoint,
|
|
217
|
+
pid: child.pid,
|
|
218
|
+
exitCode: code,
|
|
219
|
+
signal
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
process.off('SIGINT', forward);
|
|
224
|
+
process.off('SIGTERM', forward);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const logHandle = await ensureFile(options.logPath);
|
|
228
|
+
const child = spawnBackground(bundle.binaryPath, [], {
|
|
229
|
+
cwd: options.stateDir,
|
|
230
|
+
env,
|
|
231
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd]
|
|
232
|
+
});
|
|
233
|
+
if (!child.pid) {
|
|
234
|
+
await logHandle.close();
|
|
235
|
+
throw new Error('runtime_pid_missing');
|
|
236
|
+
}
|
|
237
|
+
child.unref();
|
|
238
|
+
await logHandle.close();
|
|
239
|
+
const exitPromise = once(child, 'exit').then(([code, signal]) => ({ code, signal }));
|
|
240
|
+
const errorPromise = once(child, 'error').then(([error]) => {
|
|
241
|
+
throw error;
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
await Promise.race([
|
|
245
|
+
waitForReady(options.endpoint, child.pid, options.timeoutMs),
|
|
246
|
+
exitPromise.then(() => {
|
|
247
|
+
throw new Error('runtime_exited_early');
|
|
248
|
+
}),
|
|
249
|
+
errorPromise
|
|
250
|
+
]);
|
|
251
|
+
await writeStateForPid(options, bundle, child.pid);
|
|
252
|
+
return {
|
|
253
|
+
changed: true,
|
|
254
|
+
status: 'running',
|
|
255
|
+
endpoint: options.endpoint,
|
|
256
|
+
pid: child.pid,
|
|
257
|
+
logPath: options.logPath,
|
|
258
|
+
stateDir: options.stateDir,
|
|
259
|
+
dataDir: options.dataDir
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
await Promise.allSettled([
|
|
264
|
+
terminateProcess(child.pid, { force: false }),
|
|
265
|
+
clearRuntimeState(options.stateDir)
|
|
266
|
+
]);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export async function stopRuntime(input = {}) {
|
|
271
|
+
const options = resolveOptions(input);
|
|
272
|
+
const status = await getStatus(options);
|
|
273
|
+
if (status.status === 'stopped') {
|
|
274
|
+
return {
|
|
275
|
+
changed: false,
|
|
276
|
+
...status
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
let shutdownError = null;
|
|
280
|
+
try {
|
|
281
|
+
await requestShutdown(status.endpoint);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
shutdownError = error instanceof Error ? error.message : String(error);
|
|
285
|
+
}
|
|
286
|
+
if (status.pid) {
|
|
287
|
+
const gracefulExit = await waitForProcessExit(status.pid, 8000);
|
|
288
|
+
if (!gracefulExit) {
|
|
289
|
+
try {
|
|
290
|
+
await terminateProcess(status.pid, { force: false });
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Ignore fallback kill failure here and try force next.
|
|
294
|
+
}
|
|
295
|
+
const terminated = await waitForProcessExit(status.pid, 4000);
|
|
296
|
+
if (!terminated) {
|
|
297
|
+
await terminateProcess(status.pid, { force: true });
|
|
298
|
+
await waitForProcessExit(status.pid, 2000);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
await clearRuntimeState(options.stateDir);
|
|
303
|
+
return {
|
|
304
|
+
changed: true,
|
|
305
|
+
status: 'stopped',
|
|
306
|
+
endpoint: status.endpoint,
|
|
307
|
+
pid: status.pid,
|
|
308
|
+
shutdownError
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
export async function restartRuntime(input = {}) {
|
|
312
|
+
await stopRuntime(input);
|
|
313
|
+
return startRuntime(input);
|
|
314
|
+
}
|
|
315
|
+
export async function openRuntime(input = {}) {
|
|
316
|
+
const options = resolveOptions(input);
|
|
317
|
+
const status = await getStatus(options);
|
|
318
|
+
const running = status.status === 'running' || status.status === 'degraded';
|
|
319
|
+
const active = running ? status : await startRuntime(options);
|
|
320
|
+
const openEnv = options.openCommand ? { ...options.env, CODER_STUDIO_OPEN_COMMAND: options.openCommand } : options.env;
|
|
321
|
+
await openExternal(active.endpoint, openEnv);
|
|
322
|
+
return active;
|
|
323
|
+
}
|
|
324
|
+
export async function readRuntimeLogs(input = {}) {
|
|
325
|
+
const options = resolveOptions(input);
|
|
326
|
+
return readLogTail(options.logPath, input.lines ?? options.tailLines ?? DEFAULT_LOG_TAIL_LINES);
|
|
327
|
+
}
|
|
328
|
+
export async function doctorRuntime(input = {}) {
|
|
329
|
+
const options = resolveOptions(input);
|
|
330
|
+
const bundle = (() => {
|
|
331
|
+
try {
|
|
332
|
+
return resolvePlatformPackage({ env: options.env });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
336
|
+
}
|
|
337
|
+
})();
|
|
338
|
+
const status = await getStatus(options);
|
|
339
|
+
const logExists = await fs.stat(options.logPath).then(() => true).catch(() => false);
|
|
340
|
+
const runtime = await readRuntimeState(options.stateDir);
|
|
341
|
+
return {
|
|
342
|
+
status,
|
|
343
|
+
bundle,
|
|
344
|
+
stateDir: options.stateDir,
|
|
345
|
+
dataDir: options.dataDir,
|
|
346
|
+
logPath: options.logPath,
|
|
347
|
+
logExists,
|
|
348
|
+
runtime
|
|
349
|
+
};
|
|
350
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolveLogPath, resolvePidPath, resolveRuntimePath } from './config.mjs';
|
|
6
|
+
const PACKAGE_JSON_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
7
|
+
export async function readPackageVersion() {
|
|
8
|
+
const raw = await fs.readFile(PACKAGE_JSON_PATH, 'utf8');
|
|
9
|
+
return JSON.parse(raw).version;
|
|
10
|
+
}
|
|
11
|
+
export async function ensureStateDirs(stateDir, dataDir) {
|
|
12
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
13
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
export async function readRuntimeState(stateDir) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fs.readFile(resolveRuntimePath(stateDir), 'utf8');
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function writeRuntimeState(stateDir, state) {
|
|
28
|
+
const runtimePath = resolveRuntimePath(stateDir);
|
|
29
|
+
const pidPath = resolvePidPath(stateDir);
|
|
30
|
+
await fs.writeFile(runtimePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
31
|
+
await fs.writeFile(pidPath, `${state.pid}\n`, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
export async function clearRuntimeState(stateDir) {
|
|
34
|
+
await Promise.allSettled([
|
|
35
|
+
fs.rm(resolveRuntimePath(stateDir), { force: true }),
|
|
36
|
+
fs.rm(resolvePidPath(stateDir), { force: true })
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
export function buildRuntimeState({ version, pid, endpoint, binaryPath, logPath }) {
|
|
40
|
+
return {
|
|
41
|
+
version,
|
|
42
|
+
pid,
|
|
43
|
+
endpoint,
|
|
44
|
+
binaryPath,
|
|
45
|
+
logPath,
|
|
46
|
+
startedAt: new Date().toISOString()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function readLogTail(logPath, lineCount = 80) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(logPath, 'utf8');
|
|
52
|
+
return raw.trimEnd().split(/\r?\n/).slice(-lineCount).join('\n');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function statIfExists(filePath) {
|
|
62
|
+
try {
|
|
63
|
+
return await fs.stat(filePath);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function resolvePackageRoot() {
|
|
73
|
+
return path.dirname(PACKAGE_JSON_PATH);
|
|
74
|
+
}
|
|
75
|
+
export { resolveLogPath, resolvePidPath, resolveRuntimePath };
|