@spencer-kit/coder-studio 0.4.0 → 0.4.1
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/CHANGELOG.md +7 -0
- package/dist/esm/bin.mjs +12749 -11945
- package/dist/esm/bin.mjs.map +4 -4
- package/dist/esm/server-runner.mjs +12706 -11893
- package/dist/esm/server-runner.mjs.map +4 -4
- package/dist/esm/update-worker.mjs +156 -0
- package/dist/esm/update-worker.mjs.map +7 -0
- package/dist/web/assets/index-BkUU2b7M.css +1 -0
- package/dist/web/assets/index-g7vn9KmI.js +111 -0
- package/dist/web/assets/index-g7vn9KmI.js.map +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/auth-control.test.ts +3 -3
- package/src/auth-control.ts +6 -6
- package/src/bin.test.ts +24 -12
- package/src/cli.ts +7 -7
- package/src/config-store.test.ts +57 -14
- package/src/config-store.ts +25 -15
- package/src/package-manifest.ts +5 -0
- package/src/parse-args.ts +5 -4
- package/src/server-runner.test.ts +36 -5
- package/src/server-runner.ts +8 -7
- package/src/update-runtime.test.ts +13 -0
- package/src/update-runtime.ts +44 -0
- package/src/update-worker.test.ts +99 -0
- package/src/update-worker.ts +213 -0
- package/dist/web/assets/index-CNoLfuZU.js +0 -112
- package/dist/web/assets/index-CNoLfuZU.js.map +0 -1
- package/dist/web/assets/index-cMGhBE_V.css +0 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { getCliPackageName } from "./package-manifest.js";
|
|
5
|
+
|
|
6
|
+
export interface UpdateRuntimeInfo {
|
|
7
|
+
supported: boolean;
|
|
8
|
+
installKind: "global_npm" | "unsupported";
|
|
9
|
+
packageName: string;
|
|
10
|
+
cliCommand: string;
|
|
11
|
+
workerEntryPath?: string;
|
|
12
|
+
npmCommand: string;
|
|
13
|
+
restartArgs: string[];
|
|
14
|
+
installArgsPrefix: string[];
|
|
15
|
+
unsupportedReason: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveWorkerEntryPath(importMetaUrl: string): string | undefined {
|
|
19
|
+
const currentDir = dirname(fileURLToPath(importMetaUrl));
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(currentDir, "update-worker.mjs"),
|
|
22
|
+
join(currentDir, "../src/update-worker.ts"),
|
|
23
|
+
];
|
|
24
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getUpdateRuntimeInfo(importMetaUrl: string): UpdateRuntimeInfo {
|
|
28
|
+
const workerEntryPath = resolveWorkerEntryPath(importMetaUrl);
|
|
29
|
+
const packageName = getCliPackageName(importMetaUrl);
|
|
30
|
+
const unsupportedReason =
|
|
31
|
+
workerEntryPath === undefined ? "In-app update worker bundle is not available" : null;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
supported: workerEntryPath !== undefined,
|
|
35
|
+
installKind: workerEntryPath !== undefined ? "global_npm" : "unsupported",
|
|
36
|
+
packageName,
|
|
37
|
+
cliCommand: "coder-studio",
|
|
38
|
+
workerEntryPath,
|
|
39
|
+
npmCommand: "npm",
|
|
40
|
+
restartArgs: ["serve", "--restart"],
|
|
41
|
+
installArgsPrefix: ["install", "-g"],
|
|
42
|
+
unsupportedReason,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { runUpdateWorker } from "./update-worker.js";
|
|
6
|
+
|
|
7
|
+
describe("update-worker", () => {
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const dir of tempDirs.splice(0)) {
|
|
12
|
+
rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function createEnv() {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "update-worker-"));
|
|
18
|
+
tempDirs.push(dir);
|
|
19
|
+
return {
|
|
20
|
+
stateFilePath: join(dir, "update-state.json"),
|
|
21
|
+
logFilePath: join(dir, "update-worker.log"),
|
|
22
|
+
packageName: "@spencer-kit/coder-studio",
|
|
23
|
+
targetVersion: "0.5.0",
|
|
24
|
+
cliCommand: "coder-studio",
|
|
25
|
+
currentVersion: "0.4.0",
|
|
26
|
+
npmCommand: "npm",
|
|
27
|
+
restartArgs: ["serve", "--restart"],
|
|
28
|
+
installArgsPrefix: ["install", "-g"],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it("writes restarting state after install success and restart handoff", async () => {
|
|
33
|
+
const env = createEnv();
|
|
34
|
+
const runCommand = vi.fn(async () => {});
|
|
35
|
+
|
|
36
|
+
await runUpdateWorker(env, {
|
|
37
|
+
runCommand,
|
|
38
|
+
now: () => 1000,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { updateStatus: string };
|
|
42
|
+
expect(state.updateStatus).toBe("restarting");
|
|
43
|
+
expect(runCommand).toHaveBeenNthCalledWith(
|
|
44
|
+
1,
|
|
45
|
+
"npm",
|
|
46
|
+
["install", "-g", "@spencer-kit/coder-studio@0.5.0"],
|
|
47
|
+
expect.any(Object)
|
|
48
|
+
);
|
|
49
|
+
expect(runCommand).toHaveBeenNthCalledWith(
|
|
50
|
+
2,
|
|
51
|
+
"coder-studio",
|
|
52
|
+
["serve", "--restart"],
|
|
53
|
+
expect.any(Object)
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to manual_required on permission-related install errors", async () => {
|
|
58
|
+
const env = createEnv();
|
|
59
|
+
const runCommand = vi.fn(async () => {
|
|
60
|
+
throw new Error("npm install failed with EACCES");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await runUpdateWorker(env, {
|
|
64
|
+
runCommand,
|
|
65
|
+
now: () => 1000,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as {
|
|
69
|
+
updateStatus: string;
|
|
70
|
+
manualCommand: string;
|
|
71
|
+
requiresManualStep: boolean;
|
|
72
|
+
};
|
|
73
|
+
expect(state.updateStatus).toBe("manual_required");
|
|
74
|
+
expect(state.requiresManualStep).toBe(true);
|
|
75
|
+
expect(state.manualCommand).toContain("npm install -g @spencer-kit/coder-studio@0.5.0");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("marks restart failures with manual restart guidance", async () => {
|
|
79
|
+
const env = createEnv();
|
|
80
|
+
const runCommand = vi
|
|
81
|
+
.fn()
|
|
82
|
+
.mockResolvedValueOnce(undefined)
|
|
83
|
+
.mockRejectedValueOnce(new Error("pm2 restart failed"));
|
|
84
|
+
|
|
85
|
+
await runUpdateWorker(env, {
|
|
86
|
+
runCommand,
|
|
87
|
+
now: () => 1000,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as {
|
|
91
|
+
updateStatus: string;
|
|
92
|
+
manualCommand: string;
|
|
93
|
+
errorSummary: string;
|
|
94
|
+
};
|
|
95
|
+
expect(state.updateStatus).toBe("failed");
|
|
96
|
+
expect(state.manualCommand).toBe("coder-studio serve --restart");
|
|
97
|
+
expect(state.errorSummary).toContain("restart failed");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
interface UpdateStateSnapshot {
|
|
7
|
+
version: 1;
|
|
8
|
+
currentVersion: string;
|
|
9
|
+
latestVersion: string | null;
|
|
10
|
+
availability: "unknown" | "up_to_date" | "update_available" | "check_failed";
|
|
11
|
+
updateStatus:
|
|
12
|
+
| "idle"
|
|
13
|
+
| "checking"
|
|
14
|
+
| "installing"
|
|
15
|
+
| "restarting"
|
|
16
|
+
| "succeeded"
|
|
17
|
+
| "failed"
|
|
18
|
+
| "manual_required";
|
|
19
|
+
lastCheckedAt: number | null;
|
|
20
|
+
targetVersion: string | null;
|
|
21
|
+
startedAt: number | null;
|
|
22
|
+
finishedAt: number | null;
|
|
23
|
+
requiresManualStep: boolean;
|
|
24
|
+
manualCommand: string | null;
|
|
25
|
+
errorSummary: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WorkerEnv {
|
|
29
|
+
stateFilePath: string;
|
|
30
|
+
logFilePath: string;
|
|
31
|
+
packageName: string;
|
|
32
|
+
targetVersion: string;
|
|
33
|
+
cliCommand: string;
|
|
34
|
+
currentVersion: string;
|
|
35
|
+
npmCommand: string;
|
|
36
|
+
restartArgs: string[];
|
|
37
|
+
installArgsPrefix: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeState(filePath: string, value: UpdateStateSnapshot): Promise<void> {
|
|
41
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
42
|
+
await import("node:fs/promises").then(({ writeFile }) =>
|
|
43
|
+
writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf-8")
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseJsonArray(value: string | undefined, fallback: string[]): string[] {
|
|
48
|
+
if (!value) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(value) as unknown;
|
|
53
|
+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readEnv(env = process.env): WorkerEnv {
|
|
61
|
+
const stateFilePath = env.CODER_STUDIO_UPDATE_STATE_PATH;
|
|
62
|
+
const logFilePath = env.CODER_STUDIO_UPDATE_LOG_PATH;
|
|
63
|
+
const packageName = env.CODER_STUDIO_UPDATE_PACKAGE_NAME;
|
|
64
|
+
const targetVersion = env.CODER_STUDIO_UPDATE_TARGET_VERSION;
|
|
65
|
+
const cliCommand = env.CODER_STUDIO_UPDATE_CLI_COMMAND;
|
|
66
|
+
const currentVersion = env.CODER_STUDIO_UPDATE_CURRENT_VERSION;
|
|
67
|
+
if (
|
|
68
|
+
!stateFilePath ||
|
|
69
|
+
!logFilePath ||
|
|
70
|
+
!packageName ||
|
|
71
|
+
!targetVersion ||
|
|
72
|
+
!cliCommand ||
|
|
73
|
+
!currentVersion
|
|
74
|
+
) {
|
|
75
|
+
throw new Error("Missing detached update worker environment");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
stateFilePath,
|
|
79
|
+
logFilePath,
|
|
80
|
+
packageName,
|
|
81
|
+
targetVersion,
|
|
82
|
+
cliCommand,
|
|
83
|
+
currentVersion,
|
|
84
|
+
npmCommand: env.CODER_STUDIO_UPDATE_NPM_COMMAND || "npm",
|
|
85
|
+
restartArgs: parseJsonArray(env.CODER_STUDIO_UPDATE_RESTART_ARGS, ["serve", "--restart"]),
|
|
86
|
+
installArgsPrefix: parseJsonArray(env.CODER_STUDIO_UPDATE_INSTALL_ARGS_PREFIX, [
|
|
87
|
+
"install",
|
|
88
|
+
"-g",
|
|
89
|
+
]),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildManualCommand(input: WorkerEnv): string {
|
|
94
|
+
return [
|
|
95
|
+
`${input.npmCommand} ${[...input.installArgsPrefix, `${input.packageName}@${input.targetVersion}`].join(" ")}`,
|
|
96
|
+
`${input.cliCommand} ${input.restartArgs.join(" ")}`,
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runCommand(
|
|
101
|
+
command: string,
|
|
102
|
+
args: string[],
|
|
103
|
+
options?: { stdio?: "ignore" | "pipe"; logStream?: NodeJS.WritableStream }
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const child = spawn(command, args, {
|
|
107
|
+
stdio: options?.stdio === "ignore" ? "ignore" : "pipe",
|
|
108
|
+
env: process.env,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (options?.logStream && child.stdout) {
|
|
112
|
+
child.stdout.pipe(options.logStream, { end: false });
|
|
113
|
+
}
|
|
114
|
+
if (options?.logStream && child.stderr) {
|
|
115
|
+
child.stderr.pipe(options.logStream, { end: false });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
child.on("error", reject);
|
|
119
|
+
child.on("exit", (code) => {
|
|
120
|
+
if (code === 0) {
|
|
121
|
+
resolve();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
reject(new Error(`${command} exited with code ${code ?? 1}`));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function runUpdateWorker(
|
|
130
|
+
input = readEnv(),
|
|
131
|
+
deps?: {
|
|
132
|
+
runCommand?: typeof runCommand;
|
|
133
|
+
now?: () => number;
|
|
134
|
+
}
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const now = deps?.now ?? Date.now;
|
|
137
|
+
await mkdir(dirname(input.logFilePath), { recursive: true });
|
|
138
|
+
const logStream = createWriteStream(input.logFilePath, { flags: "a" });
|
|
139
|
+
const execute = deps?.runCommand ?? runCommand;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await execute(
|
|
143
|
+
input.npmCommand,
|
|
144
|
+
[...input.installArgsPrefix, `${input.packageName}@${input.targetVersion}`],
|
|
145
|
+
{ logStream }
|
|
146
|
+
);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
+
const permissionRelated =
|
|
150
|
+
/EACCES|EPERM|permission|not permitted/i.test(message) ||
|
|
151
|
+
/requires elevated privileges/i.test(message);
|
|
152
|
+
await writeState(input.stateFilePath, {
|
|
153
|
+
version: 1,
|
|
154
|
+
currentVersion: input.currentVersion,
|
|
155
|
+
latestVersion: input.targetVersion,
|
|
156
|
+
availability: "update_available",
|
|
157
|
+
updateStatus: permissionRelated ? "manual_required" : "failed",
|
|
158
|
+
lastCheckedAt: now(),
|
|
159
|
+
targetVersion: input.targetVersion,
|
|
160
|
+
startedAt: now(),
|
|
161
|
+
finishedAt: now(),
|
|
162
|
+
requiresManualStep: permissionRelated,
|
|
163
|
+
manualCommand: permissionRelated ? buildManualCommand(input) : null,
|
|
164
|
+
errorSummary: message,
|
|
165
|
+
});
|
|
166
|
+
logStream.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await writeState(input.stateFilePath, {
|
|
171
|
+
version: 1,
|
|
172
|
+
currentVersion: input.currentVersion,
|
|
173
|
+
latestVersion: input.targetVersion,
|
|
174
|
+
availability: "update_available",
|
|
175
|
+
updateStatus: "restarting",
|
|
176
|
+
lastCheckedAt: now(),
|
|
177
|
+
targetVersion: input.targetVersion,
|
|
178
|
+
startedAt: now(),
|
|
179
|
+
finishedAt: null,
|
|
180
|
+
requiresManualStep: false,
|
|
181
|
+
manualCommand: null,
|
|
182
|
+
errorSummary: null,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await execute(input.cliCommand, input.restartArgs, { logStream });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
await writeState(input.stateFilePath, {
|
|
190
|
+
version: 1,
|
|
191
|
+
currentVersion: input.currentVersion,
|
|
192
|
+
latestVersion: input.targetVersion,
|
|
193
|
+
availability: "update_available",
|
|
194
|
+
updateStatus: "failed",
|
|
195
|
+
lastCheckedAt: now(),
|
|
196
|
+
targetVersion: input.targetVersion,
|
|
197
|
+
startedAt: now(),
|
|
198
|
+
finishedAt: now(),
|
|
199
|
+
requiresManualStep: true,
|
|
200
|
+
manualCommand: `${input.cliCommand} ${input.restartArgs.join(" ")}`,
|
|
201
|
+
errorSummary: `new version installed but service restart failed: ${message}`,
|
|
202
|
+
});
|
|
203
|
+
} finally {
|
|
204
|
+
logStream.end();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) {
|
|
209
|
+
void runUpdateWorker().catch((error) => {
|
|
210
|
+
console.error("[update-worker]", error);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
});
|
|
213
|
+
}
|