@spencer-kit/coder-studio 0.3.0 → 0.3.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/src/cli.ts ADDED
@@ -0,0 +1,347 @@
1
+ import { existsSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js";
5
+ import { openBrowser } from "./browser.js";
6
+ import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js";
7
+ import { readLogExcerpt } from "./log-excerpt.js";
8
+ import { assertSupportedNodeVersion } from "./node-version.js";
9
+ import { getCliVersion } from "./package-manifest.js";
10
+ import { parseArgs } from "./parse-args.js";
11
+ import { startManagedServer } from "./pm2-control.js";
12
+ import { confirmYesNo, isInteractiveSession } from "./prompts.js";
13
+ import { getServerStatus, type ServerStatus, stopRunningServer } from "./server-control.js";
14
+ import { startServer } from "./server-runner.js";
15
+ import { getBrowserUrl, getListenIp, getListenUrl } from "./server-url.js";
16
+
17
+ const MANAGED_SERVER_WAIT_MS = 5000;
18
+ const DEFAULT_LOG_TAIL_LINES = 40;
19
+
20
+ function formatConfig(config: CliConfig | null): string {
21
+ return JSON.stringify(config ?? {}, null, 2);
22
+ }
23
+
24
+ function formatStatus(status: ServerStatus): string {
25
+ const listenUrl = getListenUrl(status) ?? "n/a";
26
+ const browserUrl = getBrowserUrl(status) ?? "n/a";
27
+ const startedAt = status.startedAt === null ? "n/a" : new Date(status.startedAt).toISOString();
28
+
29
+ return [
30
+ `Status: ${status.status}`,
31
+ `Listen host: ${status.host ?? "n/a"}`,
32
+ `Listen IP: ${getListenIp(status) ?? "n/a"}`,
33
+ `Port: ${status.port ?? "n/a"}`,
34
+ `Listen URL: ${listenUrl}`,
35
+ `Local URL: ${browserUrl}`,
36
+ `PID: ${status.pid ?? "n/a"}`,
37
+ `Started: ${startedAt}`,
38
+ `Restarts: ${status.restartCount}`,
39
+ `Out log: ${status.outFile}`,
40
+ `Error log: ${status.errFile}`,
41
+ ].join("\n");
42
+ }
43
+
44
+ function showLogs(
45
+ status: ServerStatus,
46
+ {
47
+ tail = DEFAULT_LOG_TAIL_LINES,
48
+ errorsOnly = false,
49
+ }: { tail?: number; errorsOnly?: boolean } = {}
50
+ ): void {
51
+ const paths = errorsOnly ? [status.errFile] : [status.outFile, status.errFile];
52
+ const contents = paths
53
+ .filter((path, index, paths) => paths.indexOf(path) === index)
54
+ .flatMap((path) => {
55
+ const content = readLogExcerpt(path, { maxLines: tail, maxChars: null });
56
+ return content ? [content] : [];
57
+ });
58
+
59
+ console.log(contents.length === 0 ? "No logs available." : contents.join("\n"));
60
+ }
61
+
62
+ function showHelp(): void {
63
+ console.log(`
64
+ @spencer-kit/coder-studio - Coder Studio CLI
65
+
66
+ USAGE:
67
+ coder-studio [COMMAND]
68
+
69
+ COMMANDS:
70
+ serve Start the Coder Studio server in background (default)
71
+ server Alias for serve
72
+ open Start the server if needed and open Coder Studio in a browser
73
+ auth Manage auth login blocks in local server storage
74
+ config Persist CLI host/port/data-dir/password settings
75
+ stop Stop the managed Coder Studio server
76
+ status Show the managed server status
77
+ logs Show the managed server logs
78
+ help Show this help message
79
+ version Show version
80
+
81
+ OPTIONS:
82
+ --host <string> Save server host for future runs
83
+ --port, -p <number> Save server port for future runs
84
+ --data-dir, -d <path> Save data directory for future runs
85
+ --password <string> Save auth password for future runs
86
+ --restart Restart an already running managed server for serve/open
87
+ --help Show help
88
+ --version, -v Show version
89
+
90
+ EXAMPLES:
91
+ coder-studio
92
+ coder-studio serve
93
+ coder-studio server
94
+ coder-studio auth ban-list
95
+ coder-studio auth unblock --ip 198.51.100.24
96
+ coder-studio serve --foreground
97
+ coder-studio serve --restart
98
+ coder-studio open
99
+ coder-studio open --restart
100
+ coder-studio status
101
+ coder-studio logs
102
+ coder-studio stop
103
+ coder-studio config --host 0.0.0.0 --port 8080
104
+ `);
105
+ }
106
+
107
+ function showConfigHelp(): void {
108
+ console.log(`
109
+ @spencer-kit/coder-studio - config
110
+
111
+ USAGE:
112
+ coder-studio config [OPTIONS]
113
+ coder-studio config help
114
+
115
+ BEHAVIOR:
116
+ Without options, prints the current saved config.
117
+ Bare serve reads this saved config for future runs.
118
+
119
+ OPTIONS:
120
+ --host <string> Save server host for future runs
121
+ --port, -p <number> Save server port for future runs
122
+ --data-dir, -d <path> Save data directory for future runs
123
+ --password <string> Save auth password for future runs
124
+ --help Show config help
125
+
126
+ EXAMPLES:
127
+ coder-studio config
128
+ coder-studio config --host 0.0.0.0
129
+ coder-studio config --port 8080
130
+ coder-studio config --data-dir /tmp/cs-data
131
+ coder-studio config --password sekrit
132
+ coder-studio config --host 0.0.0.0 --port 8080
133
+ `);
134
+ }
135
+
136
+ function showVersion(): void {
137
+ console.log(`@spencer-kit/coder-studio v${getCliVersion(import.meta.url)}`);
138
+ }
139
+
140
+ function formatAuthBlocks(blocks: Awaited<ReturnType<typeof listAuthBlocks>>): string {
141
+ if (blocks.length === 0) {
142
+ return "No blocked IPs.";
143
+ }
144
+
145
+ return JSON.stringify(blocks, null, 2);
146
+ }
147
+
148
+ function resolveManagedScriptPath(): string {
149
+ const currentFile = fileURLToPath(import.meta.url);
150
+ const currentDir = dirname(currentFile);
151
+ const candidates = [
152
+ join(currentDir, "server-runner.js"),
153
+ join(currentDir, "server-runner.mjs"),
154
+ join(currentDir, "../src/server-runner.ts"),
155
+ ];
156
+
157
+ const scriptPath = candidates.find((candidate) => existsSync(candidate));
158
+ if (!scriptPath) {
159
+ throw new Error("Unable to locate the managed server entry script");
160
+ }
161
+
162
+ return scriptPath;
163
+ }
164
+
165
+ function isRunningStatus(status: ServerStatus): boolean {
166
+ return status.status === "running" || status.status === "starting";
167
+ }
168
+
169
+ interface ManagedStartupDecision {
170
+ existingStatus: ServerStatus | null;
171
+ restartRequested: boolean;
172
+ }
173
+
174
+ async function shouldRestartRunningServer(status: ServerStatus): Promise<boolean> {
175
+ const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "the existing server";
176
+
177
+ if (!isInteractiveSession()) {
178
+ return false;
179
+ }
180
+
181
+ return confirmYesNo(`Coder Studio is already running at ${currentUrl}. Restart it? [y/N] `);
182
+ }
183
+
184
+ async function prepareManagedStartup(forceRestart = false): Promise<ManagedStartupDecision> {
185
+ const status = await getServerStatus();
186
+ if (!isRunningStatus(status)) {
187
+ return {
188
+ existingStatus: null,
189
+ restartRequested: false,
190
+ };
191
+ }
192
+
193
+ const restart = forceRestart ? true : await shouldRestartRunningServer(status);
194
+ if (!restart) {
195
+ const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "n/a";
196
+ if (!isInteractiveSession()) {
197
+ console.log(
198
+ `Coder Studio is already running at ${currentUrl}. Service already exists and was not restarted.`
199
+ );
200
+ } else {
201
+ console.log(`Leaving the existing Coder Studio server running at ${currentUrl}.`);
202
+ }
203
+ return {
204
+ existingStatus: status,
205
+ restartRequested: false,
206
+ };
207
+ }
208
+
209
+ console.log("Restarting the managed Coder Studio server...");
210
+ return {
211
+ existingStatus: null,
212
+ restartRequested: true,
213
+ };
214
+ }
215
+
216
+ async function startManagedServerFlow(): Promise<void> {
217
+ await startManagedServer({
218
+ script: resolveManagedScriptPath(),
219
+ cwd: process.cwd(),
220
+ waitMs: MANAGED_SERVER_WAIT_MS,
221
+ });
222
+ }
223
+
224
+ async function openManagedServerInBrowser(existingStatus?: ServerStatus | null): Promise<void> {
225
+ const status = existingStatus ?? (await getServerStatus());
226
+ const browserUrl = getBrowserUrl(status);
227
+
228
+ if (browserUrl === null) {
229
+ throw new Error("Unable to determine the running Coder Studio URL.");
230
+ }
231
+
232
+ console.log(`Opening Coder Studio in your browser: ${browserUrl}`);
233
+ await openBrowser(browserUrl);
234
+ }
235
+
236
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
237
+ assertSupportedNodeVersion();
238
+ const args = parseArgs(argv);
239
+
240
+ if (args.command === "config") {
241
+ if (args.configHelp) {
242
+ showConfigHelp();
243
+ return;
244
+ }
245
+
246
+ if (
247
+ args.host === undefined &&
248
+ args.port === undefined &&
249
+ args.dataDir === undefined &&
250
+ args.password === undefined
251
+ ) {
252
+ console.log(formatConfig(readCliConfig()));
253
+ return;
254
+ }
255
+
256
+ const savedConfig = readCliConfig();
257
+ const nextConfig: CliConfig = {
258
+ ...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}),
259
+ ...(savedConfig?.port !== undefined && savedConfig.port > 0
260
+ ? { port: savedConfig.port }
261
+ : {}),
262
+ ...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}),
263
+ ...(savedConfig?.password !== undefined ? { password: savedConfig.password } : {}),
264
+ ...(args.host !== undefined ? { host: args.host } : {}),
265
+ ...(args.port !== undefined ? { port: args.port } : {}),
266
+ ...(args.dataDir !== undefined ? { dataDir: args.dataDir } : {}),
267
+ ...(args.password !== undefined ? { password: args.password } : {}),
268
+ };
269
+ writeCliConfig(nextConfig);
270
+ console.log(formatConfig(nextConfig));
271
+ return;
272
+ }
273
+
274
+ if (args.command === "stop") {
275
+ const stopped = await stopRunningServer();
276
+ console.log(stopped ? "Stopped Coder Studio server." : "No running Coder Studio server found.");
277
+ return;
278
+ }
279
+
280
+ if (args.command === "status") {
281
+ console.log(formatStatus(await getServerStatus()));
282
+ return;
283
+ }
284
+
285
+ if (args.command === "logs") {
286
+ showLogs(await getServerStatus(), { tail: args.tail, errorsOnly: args.errorsOnly });
287
+ return;
288
+ }
289
+
290
+ if (args.command === "help") {
291
+ showHelp();
292
+ return;
293
+ }
294
+
295
+ if (args.command === "version") {
296
+ showVersion();
297
+ return;
298
+ }
299
+
300
+ if (args.command === "auth") {
301
+ if (args.authCommand === "ban-list") {
302
+ console.log(formatAuthBlocks(await listAuthBlocks()));
303
+ return;
304
+ }
305
+
306
+ if (args.authCommand === "unblock") {
307
+ const cleared = await clearAuthBlockByIp(args.ip!);
308
+ console.log(cleared ? `Unblocked IP: ${args.ip}` : `No block found for IP: ${args.ip}`);
309
+ return;
310
+ }
311
+ }
312
+
313
+ if (args.command === "open") {
314
+ const startup = await prepareManagedStartup(args.restart);
315
+ if (startup.existingStatus === null) {
316
+ await startManagedServerFlow();
317
+ }
318
+
319
+ await openManagedServerInBrowser(startup.existingStatus);
320
+ return;
321
+ }
322
+
323
+ if (args.foreground) {
324
+ const startup = await prepareManagedStartup(args.restart);
325
+ if (startup.existingStatus !== null) {
326
+ return;
327
+ }
328
+
329
+ if (startup.restartRequested) {
330
+ await stopRunningServer();
331
+ }
332
+
333
+ console.log("Starting Coder Studio Server in foreground...");
334
+ await startServer();
335
+ return;
336
+ }
337
+
338
+ const startup = await prepareManagedStartup(args.restart);
339
+ if (startup.existingStatus !== null) {
340
+ return;
341
+ }
342
+
343
+ await startManagedServerFlow();
344
+
345
+ console.log("Coder Studio server started in background.");
346
+ console.log("Run `coder-studio status` to inspect the server.");
347
+ }
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { fileURLToPath } from "url";
2
3
  import { describe, expect, it } from "vitest";
4
+ import { getCliVersion, resolveCliPackageManifestUrl } from "./package-manifest.js";
3
5
 
4
6
  interface PackageManifest {
5
7
  dependencies?: Record<string, string>;
@@ -12,6 +14,18 @@ function readPackageManifest(relativePath: string): PackageManifest {
12
14
  }
13
15
 
14
16
  describe("cli package manifest", () => {
17
+ it("resolves the CLI package manifest instead of the workspace root manifest", () => {
18
+ expect(fileURLToPath(resolveCliPackageManifestUrl(import.meta.url))).toBe(
19
+ fileURLToPath(new URL("../package.json", import.meta.url))
20
+ );
21
+ });
22
+
23
+ it("reads the published CLI version from the CLI package manifest", () => {
24
+ const cliPackage = readPackageManifest("../package.json") as { version?: string };
25
+
26
+ expect(getCliVersion(import.meta.url)).toBe(cliPackage.version);
27
+ });
28
+
15
29
  it("declares every external server runtime dependency", () => {
16
30
  const cliPackage = readPackageManifest("../package.json");
17
31
  const serverPackage = readPackageManifest("../../server/package.json");
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+
3
+ interface CliPackageManifest {
4
+ version?: string;
5
+ }
6
+
7
+ export function resolveCliPackageManifestUrl(importMetaUrl: string): URL {
8
+ const manifestUrl = [
9
+ new URL("../package.json", importMetaUrl),
10
+ new URL("../../package.json", importMetaUrl),
11
+ ].find((candidate) => existsSync(candidate));
12
+
13
+ if (!manifestUrl) {
14
+ throw new Error("Unable to locate CLI package.json");
15
+ }
16
+
17
+ return manifestUrl;
18
+ }
19
+
20
+ export function getCliPackageManifest(importMetaUrl: string): CliPackageManifest {
21
+ return JSON.parse(
22
+ readFileSync(resolveCliPackageManifestUrl(importMetaUrl), "utf-8")
23
+ ) as CliPackageManifest;
24
+ }
25
+
26
+ export function getCliVersion(importMetaUrl: string): string {
27
+ return getCliPackageManifest(importMetaUrl).version ?? "0.0.0";
28
+ }
@@ -33,15 +33,22 @@ import {
33
33
  describe("pm2-control", () => {
34
34
  const originalHome = process.env.HOME;
35
35
  const originalUserProfile = process.env.USERPROFILE;
36
+ const originalRuntimeDir = process.env.CODER_STUDIO_RUNTIME_DIR;
37
+ const originalRuntimeJsonPath = process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
36
38
  let testHomeDir: string;
37
39
 
38
40
  beforeEach(() => {
39
41
  testHomeDir = mkdtempSync(join(tmpdir(), "cs-pm2-control-home-"));
40
42
  process.env.HOME = testHomeDir;
41
43
  process.env.USERPROFILE = testHomeDir;
44
+ process.env.CODER_STUDIO_RUNTIME_DIR = join(testHomeDir, ".coder-studio");
45
+ delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
42
46
 
43
47
  connect.mockImplementation((callback: (error: Error | null) => void) => callback(null));
44
- disconnect.mockImplementation(() => undefined);
48
+ disconnect.mockImplementation((callback?: (error: Error | null) => void) => {
49
+ callback?.(null);
50
+ return undefined;
51
+ });
45
52
  start.mockImplementation(
46
53
  (_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => {
47
54
  writeRuntimeConfig({
@@ -80,6 +87,18 @@ describe("pm2-control", () => {
80
87
  process.env.USERPROFILE = originalUserProfile;
81
88
  }
82
89
 
90
+ if (originalRuntimeDir === undefined) {
91
+ delete process.env.CODER_STUDIO_RUNTIME_DIR;
92
+ } else {
93
+ process.env.CODER_STUDIO_RUNTIME_DIR = originalRuntimeDir;
94
+ }
95
+
96
+ if (originalRuntimeJsonPath === undefined) {
97
+ delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
98
+ } else {
99
+ process.env.CODER_STUDIO_RUNTIME_JSON_PATH = originalRuntimeJsonPath;
100
+ }
101
+
83
102
  if (existsSync(testHomeDir)) {
84
103
  rmSync(testHomeDir, { recursive: true, force: true });
85
104
  }
@@ -157,6 +176,67 @@ describe("pm2-control", () => {
157
176
  ).resolves.toBe("waiting");
158
177
 
159
178
  expect(start).not.toHaveBeenCalled();
179
+ await pendingStart;
180
+ });
181
+
182
+ it("reuses one pm2 session while polling deletion during startup", async () => {
183
+ describeProcess
184
+ .mockImplementationOnce(
185
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
186
+ callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
187
+ )
188
+ .mockImplementationOnce(
189
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
190
+ callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
191
+ )
192
+ .mockImplementationOnce(
193
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
194
+ callback(null, [])
195
+ );
196
+
197
+ await startManagedServer({
198
+ script: "/cli/dist/esm/server-runner.js",
199
+ cwd: "/repo",
200
+ waitMs: 10,
201
+ });
202
+
203
+ expect(connect).toHaveBeenCalledTimes(1);
204
+ expect(disconnect).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
208
+ describeProcess
209
+ .mockImplementationOnce(
210
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
211
+ callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
212
+ )
213
+ .mockImplementationOnce(
214
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
215
+ callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
216
+ )
217
+ .mockImplementationOnce(
218
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
219
+ callback(null, [])
220
+ );
221
+ deleteProcess.mockImplementationOnce((_name: string, callback: (error: Error | null) => void) =>
222
+ callback(new Error("process or namespace not found"))
223
+ );
224
+
225
+ const pendingStart = startManagedServer({
226
+ script: "/cli/dist/esm/server-runner.js",
227
+ cwd: "/repo",
228
+ waitMs: 10,
229
+ });
230
+
231
+ await expect(
232
+ Promise.race([
233
+ pendingStart.then(() => "started"),
234
+ new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)),
235
+ ])
236
+ ).resolves.toBe("waiting");
237
+
238
+ expect(start).not.toHaveBeenCalled();
239
+ await pendingStart;
160
240
  });
161
241
 
162
242
  it("ignores delete-time missing errors when requested", async () => {
@@ -169,6 +249,8 @@ describe("pm2-control", () => {
169
249
  );
170
250
 
171
251
  await expect(deleteManagedServer({ ignoreMissing: true })).resolves.toBe(false);
252
+ expect(connect).toHaveBeenCalledTimes(1);
253
+ expect(disconnect).toHaveBeenCalledTimes(1);
172
254
  });
173
255
 
174
256
  it("fails background startup when runtime readiness times out", async () => {
@@ -183,6 +265,10 @@ describe("pm2-control", () => {
183
265
  callback(null, [])
184
266
  )
185
267
  .mockImplementationOnce(
268
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
269
+ callback(null, [])
270
+ )
271
+ .mockImplementation(
186
272
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
187
273
  callback(null, [{ pid: 424242, pm2_env: { status: "online", restart_time: 0 } }])
188
274
  );
@@ -243,6 +329,8 @@ describe("pm2-control", () => {
243
329
  pm2Pid: null,
244
330
  restartCount: 0,
245
331
  });
332
+ expect(connect).toHaveBeenCalledTimes(1);
333
+ expect(disconnect).toHaveBeenCalledTimes(1);
246
334
  });
247
335
 
248
336
  it("maps an online PM2 app to running status", async () => {