home-hosted 0.6.5 → 0.6.6

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/AGENTS.md CHANGED
@@ -247,6 +247,9 @@ either is a last resort, and never an accidental one.
247
247
  every entry's child) instead of killing it — a port held by a sibling is a config mistake.
248
248
  - **Never expose beyond loopback without auth and a non-default password.** `checkExposure()` is the
249
249
  single rule, enforced at startup, on every settings write, and in the UI.
250
+ - **The CLI drives a running panel through `/_hh`, never `/api`.** `down` and `start`/`stop` read
251
+ run.json's token and speak over loopback through one guard (`assertLocalCall`), so they need no
252
+ session, password or API token — and a new local command must not open an unauthenticated `/api` route.
250
253
  - **UIs are external clients.** Nothing in `src/**` may know a UI's markup or files;
251
254
  `$HHOSTED_HOME/.ui` overrides the packaged UI at runtime.
252
255
  - **A restore writes only where a config says** — archive allowlist, `origin` matching first,
package/README.md CHANGED
@@ -79,8 +79,8 @@ some arguments, and the environment you give it:
79
79
  { "id": "gateway", "command": "node", "args": ["server.js"], "port": 4000, "autostart": true }
80
80
  ```
81
81
 
82
- <sub>Manage them from the panel, `home-hosted status --json`, or `GET /api/state`; `GET /healthz` is
83
- the same status line for your own monitor, no session needed.</sub>
82
+ <sub>Manage them from the panel, `home-hosted start|stop <id>` from a shell, `home-hosted status --json`,
83
+ or `GET /api/state`; `GET /healthz` is the same status line for your own monitor, no session needed.</sub>
84
84
 
85
85
  ---
86
86
 
@@ -293,6 +293,8 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
293
293
  | `home-hosted down` | stop it cleanly — supervised processes included, persistent entries left running |
294
294
  | `home-hosted restart` | `down`, then `up` |
295
295
  | `home-hosted status` | pid, URL, health, uptime, state and log paths (`--json` for scripts) |
296
+ | `home-hosted start <id>` | start one server, leaving the panel and its other servers alone |
297
+ | `home-hosted stop <id>` | stop one server — the panel stays up |
296
298
  | `home-hosted set-password` | set the panel password without opening a browser |
297
299
  | `home-hosted set-token` | set the API token scripts and agents use (`--generate`, `--clear`) |
298
300
  | `home-hosted migrate` | bring `servers.config.json` up to this release's schema (`--dry-run`, `--yes`) |
@@ -311,6 +313,7 @@ up, restart -c/--config -p/--port --host --open --no-autostart --foregroun
311
313
 
312
314
  down (no flags)
313
315
  status --json
316
+ start, stop <id> (the server's id in servers.config.json; both need the panel up)
314
317
  init --dir --name --pm --no-install -y/--yes
315
318
  set-password --clear
316
319
  set-token --generate --clear
package/dist/cli.js CHANGED
@@ -133,6 +133,7 @@ function aliasesOf(def) {
133
133
  function findArg(argsDef, name) {
134
134
  for (const [key, def] of Object.entries(argsDef)) {
135
135
  if (def === void 0) continue;
136
+ if (def.type === "positional") continue;
136
137
  if ((/* @__PURE__ */ new Set([
137
138
  key,
138
139
  kebab(key),
@@ -152,10 +153,15 @@ function findArg(argsDef, name) {
152
153
  */
153
154
  function rejectUnknownFlags(argv, argsDef) {
154
155
  if (argsDef === void 0 || Object.keys(argsDef).length === 0) return null;
156
+ let positionals = Object.values(argsDef).filter((def) => def?.type === "positional").length;
155
157
  for (let index = 0; index < argv.length; index++) {
156
158
  const token = argv[index];
157
159
  if (token === "--") return null;
158
- if (!token.startsWith("-") || token.length === 1) return `Unexpected argument '${token}'`;
160
+ if (!token.startsWith("-") || token.length === 1) {
161
+ if (positionals === 0) return `Unexpected argument '${token}'`;
162
+ positionals -= 1;
163
+ continue;
164
+ }
159
165
  const body = token.startsWith("--") ? token.slice(2) : token.slice(1);
160
166
  const equals = body.indexOf("=");
161
167
  const name = equals === -1 ? body : body.slice(0, equals);
@@ -300,6 +306,50 @@ var init_runtime = __esmMin((() => {
300
306
  NANNY_COMMAND = "__nanny";
301
307
  }));
302
308
  //#endregion
309
+ //#region src/helpers/version.ts
310
+ var version_exports = /* @__PURE__ */ __exportAll({
311
+ appVersion: () => appVersion,
312
+ versionMismatchNote: () => versionMismatchNote
313
+ });
314
+ /**
315
+ * The running release, read from the package manifest.
316
+ *
317
+ * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
318
+ * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
319
+ * its own read because it may only import node builtins statically.
320
+ */
321
+ function appVersion() {
322
+ if (cached !== null) return cached;
323
+ let dir = path.dirname(fileURLToPath(import.meta.url));
324
+ for (let depth = 0; depth < 4; depth++) {
325
+ try {
326
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
327
+ if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
328
+ cached = manifest.version;
329
+ return cached;
330
+ }
331
+ } catch {}
332
+ const parent = path.dirname(dir);
333
+ if (parent === dir) break;
334
+ dir = parent;
335
+ }
336
+ cached = "0.0.0";
337
+ return cached;
338
+ }
339
+ /**
340
+ * The one line to add when this CLI and the panel it is talking to are not the same
341
+ * release — which is what a linked checkout, a global install and a project pin
342
+ * produce. `null` when they agree.
343
+ */
344
+ function versionMismatchNote(panelVersion) {
345
+ const cli = appVersion();
346
+ return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
347
+ }
348
+ var cached;
349
+ var init_version = __esmMin((() => {
350
+ cached = null;
351
+ }));
352
+ //#endregion
303
353
  //#region src/helpers/cookies.ts
304
354
  function parseCookies(header) {
305
355
  const cookies = {};
@@ -893,20 +943,22 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
893
943
  tls: tlsStatusSchema
894
944
  });
895
945
  appStateSchema = type({
896
- control: controlViewSchema,
897
- defaults: defaultsSchema,
898
- logs: logsSchema,
899
- notifications: notificationViewSchema,
900
- host: hostViewSchema,
901
- backups: backupsViewSchema,
902
- configPath: "string",
903
- configError: "string | null",
946
+ "control": controlViewSchema,
947
+ "defaults": defaultsSchema,
948
+ "logs": logsSchema,
949
+ "notifications": notificationViewSchema,
950
+ "host": hostViewSchema,
951
+ "backups": backupsViewSchema,
952
+ "configPath": "string",
953
+ "configError": "string | null",
904
954
  /** The directory the panel was started from; relative entry paths use it. */
905
- projectDir: "string",
955
+ "projectDir": "string",
906
956
  /** `HHOSTED_HOME`: every file home-hosted owns lives under here. */
907
- dataRoot: "string",
908
- logsDir: "string",
909
- servers: serverViewSchema.array()
957
+ "dataRoot": "string",
958
+ "logsDir": "string",
959
+ /** The release this panel is running. Optional: an older panel does not send one. */
960
+ "version?": "string",
961
+ "servers": serverViewSchema.array()
910
962
  });
911
963
  type({
912
964
  type: "\"hello\" | \"state\" | \"log\" | \"server\"",
@@ -1934,11 +1986,13 @@ function buildAppState(deps) {
1934
1986
  projectDir,
1935
1987
  dataRoot,
1936
1988
  logsDir: deps.logsDir,
1989
+ version: appVersion(),
1937
1990
  servers: deps.views
1938
1991
  };
1939
1992
  }
1940
1993
  var init_state$1 = __esmMin((() => {
1941
1994
  init_paths();
1995
+ init_version();
1942
1996
  init_exposure();
1943
1997
  }));
1944
1998
  //#endregion
@@ -2108,6 +2162,14 @@ var init_backups$1 = __esmMin((() => {
2108
2162
  nameParam = type({ name: "string >= 1" });
2109
2163
  }));
2110
2164
  //#endregion
2165
+ //#region src/helpers/action-result.ts
2166
+ /** Unknown ids are 404; a server that exists but cannot act is a 409. */
2167
+ function statusForAction(result) {
2168
+ if (result.ok) return 200;
2169
+ return result.error?.startsWith("unknown server") ? 404 : 409;
2170
+ }
2171
+ var init_action_result = __esmMin((() => {}));
2172
+ //#endregion
2111
2173
  //#region src/helpers/deferred.ts
2112
2174
  /**
2113
2175
  * Runs a task after the current response has been written.
@@ -2126,35 +2188,64 @@ var init_deferred = __esmMin((() => {}));
2126
2188
  //#endregion
2127
2189
  //#region src/api/control.ts
2128
2190
  /**
2129
- * The local control channel used by `home-hosted down`.
2191
+ * The local control channel used by `home-hosted down` and `home-hosted start|stop`.
2130
2192
  *
2131
2193
  * It is deliberately outside `/api` (so no session is needed) and guarded by a
2132
2194
  * token that only the owner of `run.json` can read, plus a loopback check: a
2133
2195
  * different local user, or anyone on the network, gets nothing.
2134
2196
  */
2135
2197
  function createControlRoute(deps) {
2136
- return appFactory.createApp().post("/shutdown", describeRoute({
2137
- tags: ["panel"],
2138
- summary: "Stop the panel and everything it supervises (local token required)",
2139
- responses: {
2140
- 200: { description: "Stopping" },
2141
- 403: { description: "Bad token, or not a local caller" }
2142
- }
2143
- }), (c) => {
2198
+ /** The one place the local channel's credentials are read. */
2199
+ function assertLocalCall(c, action) {
2144
2200
  const token = c.req.header("x-home-hosted-token");
2145
2201
  if (token === void 0 || token !== deps.runtimeToken) throw new DetailedError("invalid token", {
2146
2202
  statusCode: 403,
2147
2203
  code: "INVALID_TOKEN"
2148
2204
  });
2149
- if (!isLoopbackRequest(c)) throw new DetailedError("only this machine may stop the control panel", {
2205
+ if (!isLoopbackRequest(c)) throw new DetailedError(`only this machine may ${action}`, {
2150
2206
  statusCode: 403,
2151
2207
  code: "NOT_LOOPBACK"
2152
2208
  });
2209
+ }
2210
+ return appFactory.createApp().post("/shutdown", describeRoute({
2211
+ tags: ["panel"],
2212
+ summary: "Stop the panel and everything it supervises (local token required)",
2213
+ responses: {
2214
+ 200: { description: "Stopping" },
2215
+ 403: { description: "Bad token, or not a local caller" }
2216
+ }
2217
+ }), (c) => {
2218
+ assertLocalCall(c, "stop the control panel");
2153
2219
  afterResponse(deps.onShutdown, (error) => logger.error("shutdown failed", error));
2154
2220
  return c.json({ ok: true });
2221
+ }).post("/servers/:id/start", describeRoute({
2222
+ tags: ["panel"],
2223
+ summary: "Start one server, without a session (local token required)",
2224
+ responses: {
2225
+ 200: { description: "Started" },
2226
+ 403: { description: "Bad token, or not a local caller" },
2227
+ 404: { description: "No such server" }
2228
+ }
2229
+ }), async (c) => {
2230
+ assertLocalCall(c, "start or stop a server");
2231
+ const result = await deps.supervisor.start(c.req.param("id"));
2232
+ return c.json(result, statusForAction(result));
2233
+ }).post("/servers/:id/stop", describeRoute({
2234
+ tags: ["panel"],
2235
+ summary: "Stop one server, without a session (local token required)",
2236
+ responses: {
2237
+ 200: { description: "Stopped" },
2238
+ 403: { description: "Bad token, or not a local caller" },
2239
+ 404: { description: "No such server" }
2240
+ }
2241
+ }), async (c) => {
2242
+ assertLocalCall(c, "start or stop a server");
2243
+ const result = await deps.supervisor.stop(c.req.param("id"));
2244
+ return c.json(result, statusForAction(result));
2155
2245
  });
2156
2246
  }
2157
2247
  var init_control = __esmMin((() => {
2248
+ init_action_result();
2158
2249
  init_deferred();
2159
2250
  init_factory();
2160
2251
  init_logger();
@@ -2676,38 +2767,6 @@ var init_schema = __esmMin((() => {
2676
2767
  ];
2677
2768
  }));
2678
2769
  //#endregion
2679
- //#region src/helpers/version.ts
2680
- var version_exports = /* @__PURE__ */ __exportAll({ appVersion: () => appVersion });
2681
- /**
2682
- * The running release, read from the package manifest.
2683
- *
2684
- * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
2685
- * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
2686
- * its own read because it may only import node builtins statically.
2687
- */
2688
- function appVersion() {
2689
- if (cached !== null) return cached;
2690
- let dir = path.dirname(fileURLToPath(import.meta.url));
2691
- for (let depth = 0; depth < 4; depth++) {
2692
- try {
2693
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
2694
- if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
2695
- cached = manifest.version;
2696
- return cached;
2697
- }
2698
- } catch {}
2699
- const parent = path.dirname(dir);
2700
- if (parent === dir) break;
2701
- dir = parent;
2702
- }
2703
- cached = "0.0.0";
2704
- return cached;
2705
- }
2706
- var cached;
2707
- var init_version = __esmMin((() => {
2708
- cached = null;
2709
- }));
2710
- //#endregion
2711
2770
  //#region src/config/parse.ts
2712
2771
  function isRecord$1(value) {
2713
2772
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -3233,11 +3292,6 @@ var init_store = __esmMin((() => {
3233
3292
  }));
3234
3293
  //#endregion
3235
3294
  //#region src/api/servers/$.routes.ts
3236
- /** Unknown ids are 404; a server that exists but cannot start is a 409. */
3237
- function statusFor(result) {
3238
- if (result.ok) return 200;
3239
- return result.error?.startsWith("unknown server") ? 404 : 409;
3240
- }
3241
3295
  function unknownServer(id) {
3242
3296
  return new DetailedError(`unknown server "${id}"`, {
3243
3297
  statusCode: 404,
@@ -3387,7 +3441,7 @@ function createServersRoute(deps) {
3387
3441
  }
3388
3442
  }), validate("param", idParam), async (c) => {
3389
3443
  const result = await deps.supervisor.start(c.req.valid("param").id);
3390
- return c.json(result, statusFor(result));
3444
+ return c.json(result, statusForAction(result));
3391
3445
  }).post("/:id/stop", describeRoute({
3392
3446
  tags: ["servers"],
3393
3447
  summary: "Stop a server",
@@ -3407,7 +3461,7 @@ function createServersRoute(deps) {
3407
3461
  }
3408
3462
  }), validate("param", idParam), async (c) => {
3409
3463
  const result = await deps.supervisor.restart(c.req.valid("param").id);
3410
- return c.json(result, statusFor(result));
3464
+ return c.json(result, statusForAction(result));
3411
3465
  }).post("/:id/clear-logs", describeRoute({
3412
3466
  tags: ["servers"],
3413
3467
  summary: "Forget the buffered log lines",
@@ -3433,7 +3487,7 @@ function createServersRoute(deps) {
3433
3487
  const { id } = c.req.valid("param");
3434
3488
  const result = await deps.supervisor.freePort(id);
3435
3489
  if (!result.ok) throw new DetailedError(result.error ?? `could not free the port for "${id}"`, {
3436
- statusCode: statusFor(result),
3490
+ statusCode: statusForAction(result),
3437
3491
  code: "FREE_PORT_FAILED"
3438
3492
  });
3439
3493
  return c.json(result);
@@ -3486,6 +3540,7 @@ function createServersRoute(deps) {
3486
3540
  var idParam, MAX_PENDING_WRITES, serverResponse, serversResponse, okResponse;
3487
3541
  var init_$_routes = __esmMin((() => {
3488
3542
  init_store();
3543
+ init_action_result();
3489
3544
  init_factory();
3490
3545
  init_openapi$1();
3491
3546
  init_validator();
@@ -3968,6 +4023,7 @@ var daemon_exports = /* @__PURE__ */ __exportAll({
3968
4023
  newToken: () => newToken,
3969
4024
  probeRuntime: () => probeRuntime,
3970
4025
  readRuntime: () => readRuntime,
4026
+ requestControl: () => requestControl,
3971
4027
  requestShutdown: () => requestShutdown,
3972
4028
  runtimeSchema: () => runtimeSchema,
3973
4029
  writeRuntime: () => writeRuntime
@@ -4000,10 +4056,10 @@ function isProcessAlive$1(pid) {
4000
4056
  }
4001
4057
  /** A degraded panel is still answering: a 503 must not read as "not running". */
4002
4058
  async function probeRuntime(runtime, timeoutMs = 2500) {
4003
- const status = await localRequest(runtime, "/healthz", "GET", void 0, timeoutMs);
4059
+ const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
4004
4060
  return {
4005
- reachable: status !== null,
4006
- degraded: status === 503
4061
+ reachable: answer !== null,
4062
+ degraded: answer?.status === 503
4007
4063
  };
4008
4064
  }
4009
4065
  /**
@@ -4011,15 +4067,36 @@ async function probeRuntime(runtime, timeoutMs = 2500) {
4011
4067
  * shut down cleanly on every platform (a bare signal is not graceful on Windows).
4012
4068
  */
4013
4069
  async function requestShutdown(runtime, timeoutMs = 4e3) {
4014
- const status = await localRequest(runtime, "/_hh/shutdown", "POST", runtime.token, timeoutMs);
4015
- return status !== null && status >= 200 && status < 300;
4070
+ const answer = await localCall(runtime, "/_hh/shutdown", "POST", runtime.token, timeoutMs);
4071
+ return answer !== null && answer.status >= 200 && answer.status < 300;
4072
+ }
4073
+ /**
4074
+ * Drives a supervised server from this machine (`home-hosted start`/`stop`). The
4075
+ * token comes from run.json, so it needs no session, no password and no API token —
4076
+ * only the owner of that 0600 file, talking over loopback.
4077
+ *
4078
+ * The wait is generous on purpose: a start may have to run a bootstrap, which has a
4079
+ * 120s default timeout of its own. Past this it is a wedged panel, not a slow one.
4080
+ */
4081
+ async function requestControl(runtime, path, timeoutMs = 15e4) {
4082
+ const answer = await localCall(runtime, path, "POST", runtime.token, timeoutMs);
4083
+ if (answer === null) return null;
4084
+ let body = null;
4085
+ try {
4086
+ body = JSON.parse(answer.body);
4087
+ } catch {}
4088
+ return {
4089
+ status: answer.status,
4090
+ body
4091
+ };
4016
4092
  }
4017
4093
  /**
4018
4094
  * Talks to the panel over loopback. Node's `fetch` cannot be told to accept the
4019
4095
  * self-signed certificate an uploaded TLS pair usually is, which would break
4020
- * `status` and the graceful `down` — so this speaks http/https directly.
4096
+ * `status`, the graceful `down` and the local start/stop — so this speaks
4097
+ * http/https directly.
4021
4098
  */
4022
- function localRequest(runtime, path, method, token, timeoutMs) {
4099
+ function localCall(runtime, path, method, token, timeoutMs) {
4023
4100
  return new Promise((resolve) => {
4024
4101
  const url = new URL(`${runtime.probeUrl}${path}`);
4025
4102
  const secure = url.protocol === "https:";
@@ -4032,8 +4109,15 @@ function localRequest(runtime, path, method, token, timeoutMs) {
4032
4109
  headers: token === void 0 ? {} : { "x-home-hosted-token": token },
4033
4110
  timeout: timeoutMs
4034
4111
  }, (response) => {
4035
- response.resume();
4036
- response.once("end", () => resolve(response.statusCode ?? null));
4112
+ let body = "";
4113
+ response.setEncoding("utf8");
4114
+ response.on("data", (chunk) => {
4115
+ body += chunk;
4116
+ });
4117
+ response.once("end", () => resolve({
4118
+ status: response.statusCode ?? 0,
4119
+ body
4120
+ }));
4037
4121
  });
4038
4122
  request.once("error", () => resolve(null));
4039
4123
  request.once("timeout", () => {
@@ -9530,7 +9614,9 @@ async function runUp(flags, entry) {
9530
9614
  const { daemonLogPath, dataRoot, projectDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9531
9615
  const existing = readRuntime();
9532
9616
  if (existing !== null && isProcessAlive(existing.pid)) {
9533
- process.stdout.write(`${green("already running")} (pid ${existing.pid}) at ${existing.url}\n`);
9617
+ process.stdout.write(`${green("already running")} home-hosted ${existing.version} (pid ${existing.pid}) at ${existing.url}\n`);
9618
+ const note = versionMismatchNote(existing.version);
9619
+ if (note !== null) process.stdout.write(`${dim(note)}\n`);
9534
9620
  process.stdout.write(`${dim("stop it with `home-hosted down`")}\n`);
9535
9621
  return;
9536
9622
  }
@@ -9566,7 +9652,7 @@ async function runUp(flags, entry) {
9566
9652
  if (output.length > 0) process.stderr.write(`${dim(`${daemonLogPath}:`)}\n${output}\n`);
9567
9653
  process.exit(1);
9568
9654
  }
9569
- process.stdout.write(`${green("home-hosted is up")} (pid ${runtime.pid})\n`);
9655
+ process.stdout.write(`${green(`home-hosted ${runtime.version} is up`)} (pid ${runtime.pid})\n`);
9570
9656
  process.stdout.write(` ${bold(runtime.url)}\n`);
9571
9657
  process.stdout.write(` ${dim(`project ${runtime.projectDir}`)}\n`);
9572
9658
  process.stdout.write(` ${dim(`state ${runtime.dataRoot}`)}\n`);
@@ -9600,6 +9686,7 @@ var init_up = __esmMin((() => {
9600
9686
  init_args();
9601
9687
  init_io();
9602
9688
  init_runtime();
9689
+ init_version();
9603
9690
  DEFAULT_PORT$1 = 3999;
9604
9691
  LOG_ROTATE_BYTES = 5242880;
9605
9692
  upArgs = {
@@ -9652,14 +9739,20 @@ async function runDown() {
9652
9739
  }
9653
9740
  if (!isProcessAlive(runtime.pid)) {
9654
9741
  clearRuntime();
9655
- process.stdout.write("home-hosted is not running (removed a stale run.json)\n");
9742
+ process.stdout.write(`home-hosted is not running (removed a stale run.json from home-hosted ${runtime.version})\n`);
9656
9743
  return;
9657
9744
  }
9745
+ /** What was stopped, and whether this CLI is a different release than it was. */
9746
+ const reportStopped = (forced) => {
9747
+ process.stdout.write(`${green("stopped")}${forced ? " (forced)" : ""} ${dim(`— was home-hosted ${runtime.version}`)}\n`);
9748
+ const note = versionMismatchNote(runtime.version);
9749
+ if (note !== null) process.stdout.write(`${dim(note)}\n`);
9750
+ };
9658
9751
  process.stdout.write(`stopping pid ${runtime.pid}…\n`);
9659
9752
  if (!await requestShutdown(runtime)) signal(runtime.pid, "SIGTERM");
9660
9753
  if (await waitForExit(runtime.pid, 2e4)) {
9661
9754
  clearRuntime();
9662
- process.stdout.write(`${green("stopped")}\n`);
9755
+ reportStopped(false);
9663
9756
  await reportPersistent();
9664
9757
  return;
9665
9758
  }
@@ -9667,7 +9760,7 @@ async function runDown() {
9667
9760
  forceStop(runtime.pid);
9668
9761
  await waitForExit(runtime.pid, 5e3);
9669
9762
  clearRuntime();
9670
- process.stdout.write(`${green("stopped")} (forced)\n`);
9763
+ reportStopped(true);
9671
9764
  await reportPersistent();
9672
9765
  }
9673
9766
  /**
@@ -9725,6 +9818,7 @@ function forceStop(pid) {
9725
9818
  var downCommand;
9726
9819
  var init_down = __esmMin((() => {
9727
9820
  init_io();
9821
+ init_version();
9728
9822
  downCommand = defineCommand({
9729
9823
  meta: {
9730
9824
  name: "down",
@@ -9836,6 +9930,55 @@ var init_status = __esmMin((() => {
9836
9930
  });
9837
9931
  }));
9838
9932
  //#endregion
9933
+ //#region src/cli/server.ts
9934
+ var server_exports = /* @__PURE__ */ __exportAll({
9935
+ runServerAction: () => runServerAction,
9936
+ startCommand: () => startCommand,
9937
+ stopCommand: () => stopCommand
9938
+ });
9939
+ async function runServerAction(action, id) {
9940
+ const { clearRuntime, isProcessAlive, readRuntime, requestControl } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
9941
+ const runtime = readRuntime();
9942
+ if (runtime === null || !isProcessAlive(runtime.pid)) {
9943
+ if (runtime !== null) clearRuntime();
9944
+ fail("home-hosted is not running — start it with `home-hosted up`");
9945
+ }
9946
+ const answer = await requestControl(runtime, `/_hh/servers/${encodeURIComponent(id)}/${action}`);
9947
+ if (answer === null) fail(`the control panel is not answering on ${runtime.probeUrl}`);
9948
+ const body = answer.body ?? {};
9949
+ if (body.ok !== true) {
9950
+ const reason = body.error ?? body.message;
9951
+ if (reason !== void 0) fail(reason);
9952
+ if (answer.status === 404) fail(`the panel on ${runtime.url} is home-hosted ${runtime.version}, which does not know this command — restart it on this release`);
9953
+ fail(`the panel answered ${answer.status}`);
9954
+ }
9955
+ process.stdout.write(`${green(action === "start" ? "started" : "stopped")} ${id}\n`);
9956
+ }
9957
+ function serverCommand(action) {
9958
+ return defineCommand({
9959
+ meta: {
9960
+ name: action,
9961
+ description: action === "start" ? "start one server (the panel keeps running)" : "stop one server (the panel keeps running)"
9962
+ },
9963
+ args: { id: {
9964
+ type: "positional",
9965
+ required: false,
9966
+ description: "the server id from servers.config.json"
9967
+ } },
9968
+ run: async ({ args }) => {
9969
+ const id = typeof args.id === "string" ? args.id.trim() : "";
9970
+ if (id.length === 0) fail(`${action} needs a server id — see \`home-hosted status\` or servers.config.json`);
9971
+ await runServerAction(action, id);
9972
+ }
9973
+ });
9974
+ }
9975
+ var startCommand, stopCommand;
9976
+ var init_server = __esmMin((() => {
9977
+ init_io();
9978
+ startCommand = serverCommand("start");
9979
+ stopCommand = serverCommand("stop");
9980
+ }));
9981
+ //#endregion
9839
9982
  //#region src/cli/set-password.ts
9840
9983
  var set_password_exports = /* @__PURE__ */ __exportAll({
9841
9984
  runSetPassword: () => runSetPassword,
@@ -10894,6 +11037,8 @@ var SYNOPSIS = {
10894
11037
  "down": "home-hosted down",
10895
11038
  "restart": "home-hosted restart [options]",
10896
11039
  "status": "home-hosted status [--json]",
11040
+ "start": "home-hosted start <id>",
11041
+ "stop": "home-hosted stop <id>",
10897
11042
  "set-password": "home-hosted set-password",
10898
11043
  "set-token": "home-hosted set-token",
10899
11044
  "migrate": "home-hosted migrate",
@@ -10907,6 +11052,8 @@ var SUMMARIES = {
10907
11052
  "down": "stop it, and everything it supervises",
10908
11053
  "restart": "down, then up",
10909
11054
  "status": "is it running, where, and how to reach it",
11055
+ "start": "start one server, leaving the panel up",
11056
+ "stop": "stop one server, leaving the panel up",
10910
11057
  "set-password": "set the panel password without the API",
10911
11058
  "set-token": "set the API token that scripts and agents use",
10912
11059
  "migrate": "bring the config up to this release's schema",
@@ -10933,6 +11080,10 @@ var STATUS_SECTION = {
10933
11080
  heading: "Options for status",
10934
11081
  lines: [["--json", "print machine-readable JSON"]]
10935
11082
  };
11083
+ var SERVER_SECTION = {
11084
+ heading: "Arguments for start/stop",
11085
+ lines: [["<id>", "the server id from servers.config.json"]]
11086
+ };
10936
11087
  var SET_PASSWORD_SECTION = {
10937
11088
  heading: "Options for set-password",
10938
11089
  lines: [["--clear", "remove the password, which disables authentication"]]
@@ -11044,11 +11195,15 @@ var USAGE = [
11044
11195
  "",
11045
11196
  renderSection(STATUS_SECTION),
11046
11197
  "",
11198
+ renderSection(SERVER_SECTION),
11199
+ "",
11047
11200
  SHARED_TRAILER
11048
11201
  ].join("\n");
11049
11202
  var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
11050
11203
  var SECTIONS = {
11051
11204
  "status": STATUS_SECTION,
11205
+ "start": SERVER_SECTION,
11206
+ "stop": SERVER_SECTION,
11052
11207
  "set-password": SET_PASSWORD_SECTION,
11053
11208
  "set-token": SET_TOKEN_SECTION,
11054
11209
  "migrate": MIGRATE_SECTION,
@@ -11095,6 +11250,8 @@ var COMMANDS = {
11095
11250
  "down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
11096
11251
  "restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
11097
11252
  "status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
11253
+ "start": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.startCommand),
11254
+ "stop": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.stopCommand),
11098
11255
  "set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),
11099
11256
  "set-token": () => Promise.resolve().then(() => (init_set_token(), set_token_exports)).then((module) => module.setTokenCommand),
11100
11257
  "migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),