dsh-update-status 0.2.0 → 0.3.0

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/lib/index.js CHANGED
@@ -28,6 +28,12 @@ const UPDATE_ENDPOINTS = {
28
28
  getStatus: "dsh-update-status.get-status",
29
29
  checkUpdate: "dsh-update-status.check-update"
30
30
  };
31
+ /** Restart RPC remains on the same authenticated Connection `/api` channel. */
32
+ const RESTART_ENDPOINTS = {
33
+ status: "dsh-update-status.restart-status",
34
+ check: "dsh-update-status.restart-check",
35
+ request: "dsh-update-status.restart"
36
+ };
31
37
  //#endregion
32
38
  //#region src/host/installation.ts
33
39
  /**
@@ -176,6 +182,16 @@ function requestOf(value) {
176
182
  ...record.cacheTtlMinutes === void 0 ? {} : { cacheTtlMinutes: record.cacheTtlMinutes }
177
183
  };
178
184
  }
185
+ function restartRequestOf(value) {
186
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
187
+ const record = value;
188
+ if (record.force !== void 0 && typeof record.force !== "boolean") return void 0;
189
+ if (typeof record.expectedInstanceId !== "string" || record.expectedInstanceId === "") return void 0;
190
+ return {
191
+ force: record.force === true,
192
+ expectedInstanceId: record.expectedInstanceId
193
+ };
194
+ }
179
195
  function jsonResponse(rpcId, result) {
180
196
  return Response.json({
181
197
  type: "server-response",
@@ -203,7 +219,7 @@ async function dispatchUpdateStatusFetch(endpoint, handler, request) {
203
219
  }
204
220
  }
205
221
  /** Endpoint dispatcher, exported to allow exact JSON-shape tests without a Host. */
206
- function createUpdateStatusRpcHandler(service) {
222
+ function createUpdateStatusRpcHandler(service, restart, restartContext) {
207
223
  return async (endpoint, payload) => {
208
224
  try {
209
225
  if (endpoint === UPDATE_ENDPOINTS.getStatus) {
@@ -222,6 +238,29 @@ function createUpdateStatusRpcHandler(service) {
222
238
  value: await service.check(request.force === true, request.cacheTtlMinutes)
223
239
  };
224
240
  }
241
+ if (endpoint === RESTART_ENDPOINTS.status) {
242
+ if (restart === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
243
+ return {
244
+ ok: true,
245
+ value: restart.status()
246
+ };
247
+ }
248
+ if (endpoint === RESTART_ENDPOINTS.check) {
249
+ if (restart === void 0 || restartContext === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
250
+ return {
251
+ ok: true,
252
+ value: restart.check(restartContext)
253
+ };
254
+ }
255
+ if (endpoint === RESTART_ENDPOINTS.request) {
256
+ const request = restartRequestOf(payload);
257
+ if (request === void 0) return failure("dsh-update-status/bad-request", "`force` must be boolean");
258
+ if (restart === void 0 || restartContext === void 0) return failure("dsh-update-status/restart-unavailable", "restart support is not configured");
259
+ return {
260
+ ok: true,
261
+ value: restart.request(restartContext, request.force, request.expectedInstanceId)
262
+ };
263
+ }
225
264
  return failure("dsh-update-status/unknown-endpoint", `unknown endpoint: ${endpoint}`);
226
265
  } catch (error) {
227
266
  return failure("dsh-update-status/internal", (error instanceof Error ? error.message : String(error)).slice(0, 220));
@@ -230,19 +269,18 @@ function createUpdateStatusRpcHandler(service) {
230
269
  }
231
270
  /**
232
271
  * Static packages do not receive dynamic Cordis's `harness.handle` closure.
233
- * DSH 0.1.5 serves browser unary RPC only on the shared `/api` channel; a
234
- * private prefix such as `/dsh-update-status` is answered by the SPA fallback
235
- * with HTTP 405. Exact Fetch routes on `/api/<endpoint>` are dispatched by
236
- * Connection before the Typert interceptor and do not need a new HTTP prefix.
272
+ * Exact Fetch routes on `/api/<endpoint>` are dispatched by Connection after
273
+ * its Host/Origin and cookie admission. Every destructive restart request is
274
+ * therefore authenticated before it reaches this plugin.
237
275
  */
238
- function installUpdateStatusRpc(ctx, service) {
239
- const handler = createUpdateStatusRpcHandler(service);
276
+ function installUpdateStatusRpc(ctx, service, restart) {
277
+ const handler = createUpdateStatusRpcHandler(service, restart, ctx);
240
278
  ctx.inject(["connection"], (owned) => {
241
279
  const connection = owned.connection;
242
280
  const register = typeof connection.fetch?.register === "function" ? connection.fetch.register.bind(connection.fetch) : void 0;
243
281
  if (register === void 0) return;
244
282
  try {
245
- for (const endpoint of Object.values(UPDATE_ENDPOINTS)) register({
283
+ for (const endpoint of [...Object.values(UPDATE_ENDPOINTS), ...Object.values(RESTART_ENDPOINTS)]) register({
246
284
  path: `${UPDATE_STATUS_CHANNEL}/${endpoint}`,
247
285
  methods: ["POST"],
248
286
  requestBody: "buffered",
@@ -253,6 +291,261 @@ function installUpdateStatusRpc(ctx, service) {
253
291
  }
254
292
  });
255
293
  }
294
+ const INSTANCE_KEY = Symbol.for("dsh-update-status/process-instance-id");
295
+ /**
296
+ * This must outlive plugin HMR/recomposition inside one Node process. A module
297
+ * local random value would make a Host reload look like a successful restart.
298
+ */
299
+ function processInstanceId() {
300
+ const carrier = globalThis;
301
+ const existing = carrier[INSTANCE_KEY];
302
+ if (existing !== void 0) return existing;
303
+ const created = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
304
+ carrier[INSTANCE_KEY] = created;
305
+ return created;
306
+ }
307
+ /**
308
+ * Strict supervisor recognition: the marker is written only by this package's
309
+ * user-service installer. Platform-specific signals defend against a user
310
+ * copying that environment variable into an arbitrary terminal by accident.
311
+ */
312
+ function restartStatusOf(options = {}) {
313
+ const env = options.env ?? process.env;
314
+ const platform = options.platform ?? process.platform;
315
+ const versions = options.versions ?? process.versions;
316
+ const instanceId = options.instanceId ?? processInstanceId();
317
+ if (versions.electron !== void 0 && versions.electron !== "") return {
318
+ instanceId,
319
+ available: false,
320
+ supervisor: null,
321
+ unavailableReason: "desktop"
322
+ };
323
+ if (env.DSH_WEB_SUPERVISOR !== "dsh-update-status") return {
324
+ instanceId,
325
+ available: false,
326
+ supervisor: null,
327
+ unavailableReason: "not-supervised"
328
+ };
329
+ if (platform === "darwin") {
330
+ if (env.XPC_SERVICE_NAME !== "com.idoall.dsh-update-status.web") return {
331
+ instanceId,
332
+ available: false,
333
+ supervisor: "launchd",
334
+ unavailableReason: "supervisor-mismatch"
335
+ };
336
+ return {
337
+ instanceId,
338
+ available: true,
339
+ supervisor: "launchd",
340
+ unavailableReason: null
341
+ };
342
+ }
343
+ if (platform === "linux") {
344
+ if (typeof env.INVOCATION_ID !== "string" || env.INVOCATION_ID === "") return {
345
+ instanceId,
346
+ available: false,
347
+ supervisor: "systemd",
348
+ unavailableReason: "supervisor-mismatch"
349
+ };
350
+ return {
351
+ instanceId,
352
+ available: true,
353
+ supervisor: "systemd",
354
+ unavailableReason: null
355
+ };
356
+ }
357
+ if (platform === "win32") return {
358
+ instanceId,
359
+ available: true,
360
+ supervisor: "task-scheduler",
361
+ unavailableReason: null
362
+ };
363
+ return {
364
+ instanceId,
365
+ available: false,
366
+ supervisor: "unknown",
367
+ unavailableReason: "supervisor-mismatch"
368
+ };
369
+ }
370
+ /** Same facts dsh-service lists before allowing its force-restart control. */
371
+ function collectActiveWork(ctx) {
372
+ const unavailable = () => ({
373
+ available: false,
374
+ hasActive: false,
375
+ items: []
376
+ });
377
+ const getter = typeof ctx.get === "function" ? ctx.get.bind(ctx) : void 0;
378
+ if (getter === void 0) return unavailable();
379
+ let agentsFace;
380
+ let jobsFace;
381
+ try {
382
+ agentsFace = getter("agents");
383
+ jobsFace = getter("jobs");
384
+ } catch {
385
+ return unavailable();
386
+ }
387
+ if (typeof agentsFace?.list !== "function") return unavailable();
388
+ let agents;
389
+ try {
390
+ agents = agentsFace.list();
391
+ } catch {
392
+ return unavailable();
393
+ }
394
+ const items = [];
395
+ for (const agent of agents) {
396
+ if (agent.status !== "running") continue;
397
+ const id = String(agent.id);
398
+ items.push({
399
+ type: "agent",
400
+ id,
401
+ label: id,
402
+ status: "running"
403
+ });
404
+ }
405
+ if (jobsFace !== void 0) {
406
+ if (typeof jobsFace.list !== "function") return unavailable();
407
+ const byId = /* @__PURE__ */ new Map();
408
+ for (const caller of [void 0, ...agents.map((agent) => agent.id)]) try {
409
+ for (const job of jobsFace.list(caller)) {
410
+ if (job.status !== "running" && job.status !== "stopping") continue;
411
+ byId.set(String(job.id), job);
412
+ }
413
+ } catch {
414
+ return unavailable();
415
+ }
416
+ for (const job of byId.values()) items.push({
417
+ type: "job",
418
+ id: String(job.id),
419
+ label: typeof job.label === "string" && job.label !== "" ? job.label : String(job.id),
420
+ status: typeof job.status === "string" ? job.status : "running",
421
+ ...job.owner === void 0 ? {} : { ownerSession: String(job.owner) }
422
+ });
423
+ }
424
+ const terminals = /* @__PURE__ */ new Map();
425
+ for (const owner of agents) {
426
+ const ownerCtx = owner.ctx;
427
+ if (ownerCtx === void 0 || typeof ownerCtx.get !== "function") return unavailable();
428
+ let face;
429
+ try {
430
+ face = ownerCtx.get("terminals");
431
+ } catch {
432
+ return unavailable();
433
+ }
434
+ if (face === void 0) continue;
435
+ if (typeof face.list !== "function") return unavailable();
436
+ try {
437
+ for (const terminal of face.list(owner)) {
438
+ if (terminal.status?.kind !== "running") continue;
439
+ terminals.set(String(terminal.sessionId), terminal);
440
+ }
441
+ } catch {
442
+ return unavailable();
443
+ }
444
+ }
445
+ for (const terminal of terminals.values()) {
446
+ const id = String(terminal.sessionId);
447
+ items.push({
448
+ type: "terminal",
449
+ id,
450
+ label: typeof terminal.name === "string" && terminal.name !== "" ? terminal.name : `${typeof terminal.type === "string" ? terminal.type : "terminal"} terminal`,
451
+ status: "running"
452
+ });
453
+ }
454
+ return {
455
+ available: true,
456
+ hasActive: items.length > 0,
457
+ items
458
+ };
459
+ }
460
+ var RestartController = class {
461
+ instanceId;
462
+ options;
463
+ scheduled = false;
464
+ constructor(options = {}) {
465
+ this.options = options;
466
+ this.instanceId = options.instanceId ?? processInstanceId();
467
+ }
468
+ status() {
469
+ return restartStatusOf({
470
+ ...this.options,
471
+ instanceId: this.instanceId
472
+ });
473
+ }
474
+ check(ctx) {
475
+ const status = this.status();
476
+ if (!status.available) return {
477
+ kind: "unavailable",
478
+ status
479
+ };
480
+ const activity = collectActiveWork(ctx);
481
+ if (!activity.available) return {
482
+ kind: "unavailable",
483
+ status: {
484
+ ...status,
485
+ available: false,
486
+ unavailableReason: "activity-unavailable"
487
+ }
488
+ };
489
+ if (activity.hasActive) return {
490
+ kind: "active-work",
491
+ status,
492
+ activity
493
+ };
494
+ return {
495
+ kind: "ready",
496
+ status
497
+ };
498
+ }
499
+ request(ctx, force, expectedInstanceId) {
500
+ if (expectedInstanceId !== this.instanceId) return {
501
+ kind: "unavailable",
502
+ status: {
503
+ ...this.status(),
504
+ available: false,
505
+ unavailableReason: "stale-instance"
506
+ }
507
+ };
508
+ const checked = this.check(ctx);
509
+ if (checked.kind === "unavailable") return checked;
510
+ if (checked.kind === "active-work" && !force) return checked;
511
+ if (this.scheduled) return {
512
+ kind: "in-progress",
513
+ instanceId: this.instanceId
514
+ };
515
+ let timer;
516
+ try {
517
+ timer = ctx.get?.("timer");
518
+ } catch {
519
+ timer = void 0;
520
+ }
521
+ const schedule = typeof timer?.timeout === "function" ? (callback) => {
522
+ timer.timeout(callback, 500);
523
+ } : this.options.schedule === void 0 ? void 0 : (callback) => {
524
+ this.options.schedule(callback, 500);
525
+ };
526
+ if (schedule === void 0) return {
527
+ kind: "unavailable",
528
+ status: {
529
+ ...this.status(),
530
+ available: false,
531
+ unavailableReason: "timer-unavailable"
532
+ }
533
+ };
534
+ this.scheduled = true;
535
+ const exit = this.options.exit ?? ((code) => process.exit(code));
536
+ schedule(() => {
537
+ exit(42);
538
+ });
539
+ return {
540
+ kind: "scheduled",
541
+ instanceId: this.instanceId
542
+ };
543
+ }
544
+ };
545
+ /** Creates the process-scoped controller once per DSH Host generation. */
546
+ function createRestartController() {
547
+ return new RestartController();
548
+ }
256
549
  //#endregion
257
550
  //#region src/host/schemastery.ts
258
551
  /**
@@ -885,7 +1178,7 @@ function apply(ctx, config = {}) {
885
1178
  runtimeWarnings: schema.warning === null ? [] : [schema.warning]
886
1179
  });
887
1180
  installSettings(ctx);
888
- installUpdateStatusRpc(ctx, service);
1181
+ installUpdateStatusRpc(ctx, service, createRestartController());
889
1182
  if (config.autoCheckOnMount !== false) service.getStatus();
890
1183
  }
891
1184
  //#endregion