@astralyn/sash 0.1.1 → 0.1.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +17 -5
  3. package/THIRD_PARTY_NOTICES.md +9 -0
  4. package/dist/api.js +14 -45
  5. package/dist/app-state.js +99 -0
  6. package/dist/autostart/command.js +24 -0
  7. package/dist/autostart/context.js +43 -0
  8. package/dist/autostart/files.js +60 -0
  9. package/dist/autostart/installation.js +48 -0
  10. package/dist/autostart/start.js +47 -0
  11. package/dist/autostart/windows-registry.js +88 -0
  12. package/dist/autostart/windows.js +59 -0
  13. package/dist/autostart-contract.js +31 -0
  14. package/dist/autostart-entry.js +10 -0
  15. package/dist/autostart.js +69 -0
  16. package/dist/cli.js +11 -11
  17. package/dist/commands/auto.js +15 -0
  18. package/dist/commands/lifecycle.js +5 -15
  19. package/dist/commands/logs.js +11 -4
  20. package/dist/commands/shared.js +2 -4
  21. package/dist/commands/status.js +5 -1
  22. package/dist/commands/update.js +7 -3
  23. package/dist/commands/web.js +14 -50
  24. package/dist/contracts.js +168 -348
  25. package/dist/core-config-validation.js +22 -38
  26. package/dist/core-update.js +126 -762
  27. package/dist/core.js +20 -46
  28. package/dist/daemon/app.js +129 -117
  29. package/dist/daemon/context.js +13 -7
  30. package/dist/daemon/entry.js +21 -38
  31. package/dist/daemon/errors.js +3 -0
  32. package/dist/daemon/handlers/autostart.js +18 -0
  33. package/dist/daemon/handlers/core.js +32 -9
  34. package/dist/daemon/handlers/daemon.js +17 -6
  35. package/dist/daemon/handlers/profiles.js +12 -2
  36. package/dist/daemon/handlers/settings.js +9 -36
  37. package/dist/daemon/router.js +26 -21
  38. package/dist/daemon/scheduler.js +3 -1
  39. package/dist/daemon/server.js +5 -2
  40. package/dist/daemon-client.js +13 -3
  41. package/dist/daemon-http.js +6 -0
  42. package/dist/daemon-lifecycle.js +19 -196
  43. package/dist/github.js +7 -2
  44. package/dist/http.js +7 -5
  45. package/dist/mihomo-config.js +2 -1
  46. package/dist/paths.js +5 -7
  47. package/dist/profile-model.js +87 -0
  48. package/dist/profile-service.js +250 -587
  49. package/dist/profiles.js +43 -171
  50. package/dist/runtime-lifecycle.js +112 -194
  51. package/dist/runtime-owner.js +37 -76
  52. package/dist/sash-client.js +40 -19
  53. package/dist/settings-service.js +29 -224
  54. package/dist/settings.js +61 -289
  55. package/dist/status.js +24 -16
  56. package/dist/supervisor.js +1 -14
  57. package/dist/sysproxy/common.js +5 -45
  58. package/dist/sysproxy/factory.js +24 -65
  59. package/dist/sysproxy/snapshot.js +24 -257
  60. package/dist/sysproxy.js +1 -4
  61. package/dist/system-proxy-manager.js +37 -35
  62. package/dist/ui/assets/{ConnectionsView-CfaOG2JV.js → ConnectionsView-BgXQM6Z4.js} +1 -1
  63. package/dist/ui/assets/{LogsView-BDiRATXN.js → LogsView-2f542P8z.js} +1 -1
  64. package/dist/ui/assets/{PaginationFooter-DTytr1iu.js → PaginationFooter-cj1tcZej.js} +1 -1
  65. package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
  66. package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
  67. package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
  68. package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
  69. package/dist/ui/assets/{RulesView-CjEyCN1A.js → RulesView-CtKvlckZ.js} +1 -1
  70. package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
  71. package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
  72. package/dist/ui/assets/index-CBInDvdJ.js +19 -0
  73. package/dist/ui/assets/index-mOLy7fkB.css +1 -0
  74. package/dist/ui/assets/{theme-BwDBMsKO.js → theme-D40MF8cQ.js} +1 -1
  75. package/dist/ui/index.html +2 -2
  76. package/docs/architecture-proposal.md +109 -0
  77. package/docs/autostart.md +101 -0
  78. package/docs/backend.md +92 -224
  79. package/docs/frontend.md +60 -106
  80. package/docs/usage.md +92 -164
  81. package/package.json +3 -3
  82. package/dist/commands/upgrade.js +0 -43
  83. package/dist/core-install-transaction.js +0 -114
  84. package/dist/core-update-coordination.js +0 -105
  85. package/dist/core-update-service.js +0 -245
  86. package/dist/managed-state-transaction.js +0 -375
  87. package/dist/offline-mutation.js +0 -58
  88. package/dist/profile-migration.js +0 -101
  89. package/dist/runtime-recovery.js +0 -31
  90. package/dist/sysproxy/darwin.js +0 -287
  91. package/dist/sysproxy/gnome.js +0 -181
  92. package/dist/sysproxy/legacy.js +0 -58
  93. package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
  94. package/dist/ui/assets/CodeEditorModal-D1zn-jRS.js +0 -14
  95. package/dist/ui/assets/ProfileEditorDialog-CyyP6OF1.js +0 -1
  96. package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
  97. package/dist/ui/assets/ProfilesView-hFa7-O72.js +0 -2
  98. package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +0 -1
  99. package/dist/ui/assets/SettingsView-D5QvJId6.css +0 -1
  100. package/dist/ui/assets/SettingsView-DsRntRfn.js +0 -2
  101. package/dist/ui/assets/index-CxQTf_P9.css +0 -1
  102. package/dist/ui/assets/index-_mb4OfYp.js +0 -19
@@ -21,9 +21,10 @@ export async function redeemWebBootstrap(ctx, req) {
21
21
  }
22
22
  export async function daemonStatus(ctx, req) {
23
23
  const settings = ctx.settings.committed();
24
+ const ownership = ctx.supervisor.ownedCoreSnapshot();
24
25
  const runtimeCore = await ctx.supervisor.status();
25
26
  const installedVersion = currentCoreVersion(ctx.layout);
26
- const core = runtimeCore.version || !installedVersion
27
+ let core = runtimeCore.version || !installedVersion
27
28
  ? runtimeCore
28
29
  : { ...runtimeCore, version: installedVersion };
29
30
  let actualProxy;
@@ -36,22 +37,33 @@ export async function daemonStatus(ctx, req) {
36
37
  proxyApplied = inspection.applied;
37
38
  proxyAppliedKnown = inspection.appliedKnown;
38
39
  proxyStateKnown = inspection.stateKnown;
39
- if (proxyStateKnown)
40
- actualProxy = inspection.state;
40
+ actualProxy = inspection.state;
41
41
  proxyQueryError = inspection.queryError;
42
42
  }
43
43
  catch (err) {
44
44
  proxyQueryError = err instanceof Error ? err.message : String(err);
45
45
  }
46
46
  const active = ctx.profiles.active();
47
+ if (core.running && (!ownership || !ctx.supervisor.ownsCore(ownership)))
48
+ core = { running: false };
49
+ const applied = ctx.lifecycle.configuration();
47
50
  const status = {
48
51
  daemon: {
49
52
  pid: process.pid,
53
+ bootId: ctx.token,
50
54
  startedAt: ctx.startedAt,
51
55
  port: settings.daemonPort,
52
56
  },
53
57
  revisions: {
54
58
  profiles: ctx.profileRevision(),
59
+ runtime: ctx.lifecycle.revision,
60
+ },
61
+ configuration: {
62
+ pending: ctx.pendingApply(),
63
+ appliedProfile: applied?.profile ?? null,
64
+ appliedSettings: applied
65
+ ? { mixedPort: applied.settings.mixedPort, allowLan: applied.settings.allowLan }
66
+ : null,
55
67
  },
56
68
  core,
57
69
  systemProxy: {
@@ -68,9 +80,8 @@ export async function daemonStatus(ctx, req) {
68
80
  return { status: 200, json: status };
69
81
  }
70
82
  export function shutdownDaemon(ctx) {
71
- return ctx.shutdown().then((snapshot) => ({
72
- status: 200,
73
- json: { coreWasRunning: snapshot.coreWasRunning },
83
+ return ctx.shutdown().then(() => ({
84
+ status: 204,
74
85
  after: () => {
75
86
  void ctx
76
87
  .closeListener()
@@ -1,8 +1,15 @@
1
+ import { requiredParam } from "../../daemon-http.js";
1
2
  import { ProfileInputError } from "../../profile-service.js";
2
- import { requiredParam } from "../router.js";
3
3
  export function listProfiles(ctx) {
4
4
  return { status: 200, json: ctx.profiles.list() };
5
5
  }
6
+ export async function reorderProfiles(ctx, req) {
7
+ const { ids } = await req.readJson();
8
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string")) {
9
+ throw new ProfileInputError("Missing profile ids array");
10
+ }
11
+ return { status: 200, json: await ctx.profiles.reorder(ids) };
12
+ }
6
13
  export async function addProfile(ctx, req) {
7
14
  const body = await req.readJson();
8
15
  const url = typeof body.url === "string" ? body.url.trim() : "";
@@ -42,7 +49,10 @@ export function readProfileContent(ctx, req) {
42
49
  export async function writeProfileContent(ctx, req) {
43
50
  const body = await req.readJson(8 * 1024 * 1024);
44
51
  const content = typeof body.content === "string" ? body.content : "";
45
- const result = await ctx.profiles.writeContent(requiredParam(req, "id"), content);
52
+ const revision = body.revision;
53
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1)
54
+ throw new ProfileInputError("A positive profile revision is required");
55
+ const result = await ctx.profiles.writeContent(requiredParam(req, "id"), content, revision);
46
56
  return { status: 200, json: result };
47
57
  }
48
58
  export async function renameProfile(ctx, req) {
@@ -1,13 +1,6 @@
1
- import fs from "node:fs";
2
- import { parseSettingsPatch, } from "../../contracts.js";
1
+ import { parseSettingsPatch } from "../../contracts.js";
3
2
  import { HttpError } from "../../daemon-http.js";
4
- import { parseSettingsText, publicSettings } from "../../settings.js";
5
- function writeResultBody(result) {
6
- return {
7
- restartRequired: result.restartRequired,
8
- settings: publicSettings(result.settings),
9
- };
10
- }
3
+ import { publicSettings } from "../../settings.js";
11
4
  export function readSettings(ctx) {
12
5
  return { status: 200, json: publicSettings(ctx.settings.committed()) };
13
6
  }
@@ -15,34 +8,14 @@ export async function patchSettings(ctx, req) {
15
8
  const body = await req.readJson();
16
9
  let patch;
17
10
  try {
18
- patch = parseSettingsPatch(body, "request");
11
+ patch = parseSettingsPatch(body);
19
12
  }
20
- catch (err) {
21
- throw new HttpError(400, err.message);
13
+ catch (error) {
14
+ throw new HttpError(400, error instanceof Error ? error.message : String(error));
22
15
  }
23
16
  const result = await ctx.settingsService.apply(patch);
24
- return { status: 200, json: writeResultBody(result) };
25
- }
26
- export function readSettingsFile(ctx) {
27
- let content;
28
- try {
29
- content = fs.readFileSync(ctx.layout.settingsFile, "utf8");
30
- }
31
- catch {
32
- throw new HttpError(404, "Settings file does not exist yet");
33
- }
34
- return { status: 200, json: { content } };
35
- }
36
- export async function writeSettingsFile(ctx, req) {
37
- const body = await req.readJson(256 * 1024);
38
- const content = typeof body.content === "string" ? body.content : "";
39
- let parsed;
40
- try {
41
- parsed = parseSettingsText(content, ctx.layout.settingsFile);
42
- }
43
- catch (err) {
44
- throw new HttpError(400, err.message);
45
- }
46
- const result = await ctx.settingsService.applyFileSettings(parsed);
47
- return { status: 200, json: writeResultBody(result) };
17
+ return {
18
+ status: 200,
19
+ json: { restartRequired: result.restartRequired, settings: publicSettings(result.settings) },
20
+ };
48
21
  }
@@ -3,11 +3,12 @@ import { parseJsonObjectBody, sendError, sendJson } from "../daemon-http.js";
3
3
  import { forwardHttpToCore } from "../daemon-proxy.js";
4
4
  import { serveStaticUi } from "../daemon-static.js";
5
5
  import { errorToHttp } from "./errors.js";
6
- import { reloadCoreConfig, restartCore, startCore, stopCore } from "./handlers/core.js";
6
+ import { readAutostart, writeAutostart } from "./handlers/autostart.js";
7
+ import { restartCore, setCoreMode, startCore, stopCore, updateCore } from "./handlers/core.js";
7
8
  import { createWebBootstrap, daemonStatus, health, redeemWebBootstrap, shutdownDaemon, } from "./handlers/daemon.js";
8
- import { activateProfile, addProfile, importProfile, listProfiles, readProfileContent, removeProfile, renameProfile, updateAllProfiles, updateProfile, writeProfileContent, } from "./handlers/profiles.js";
9
+ import { activateProfile, addProfile, importProfile, listProfiles, readProfileContent, removeProfile, renameProfile, reorderProfiles, updateAllProfiles, updateProfile, writeProfileContent, } from "./handlers/profiles.js";
9
10
  import { proxyStatus } from "./handlers/proxy.js";
10
- import { patchSettings, readSettings, readSettingsFile, writeSettingsFile, } from "./handlers/settings.js";
11
+ import { patchSettings, readSettings } from "./handlers/settings.js";
11
12
  export function parseDaemonRequestTarget(rawTarget, hostHeader) {
12
13
  if (!rawTarget.startsWith("/") || rawTarget.startsWith("//")) {
13
14
  throw new Error("Unsupported HTTP request-target form");
@@ -62,6 +63,7 @@ export function buildRoutes() {
62
63
  handler: redeemWebBootstrap,
63
64
  },
64
65
  { methods: ["POST"], pattern: path("/sash/core/start"), auth: "control", handler: startCore },
66
+ { methods: ["PUT"], pattern: path("/sash/core/mode"), auth: "control", handler: setCoreMode },
65
67
  { methods: ["POST"], pattern: path("/sash/core/stop"), auth: "control", handler: stopCore },
66
68
  {
67
69
  methods: ["POST"],
@@ -71,11 +73,18 @@ export function buildRoutes() {
71
73
  },
72
74
  {
73
75
  methods: ["POST"],
74
- pattern: path("/sash/core/reload"),
76
+ pattern: path("/sash/core/update"),
75
77
  auth: "control",
76
- handler: reloadCoreConfig,
78
+ handler: updateCore,
77
79
  },
78
80
  { methods: ["GET"], pattern: path("/sash/proxy"), auth: "public", handler: proxyStatus },
81
+ { methods: ["GET"], pattern: path("/sash/autostart"), auth: "control", handler: readAutostart },
82
+ {
83
+ methods: ["PUT"],
84
+ pattern: path("/sash/autostart"),
85
+ auth: "control",
86
+ handler: writeAutostart,
87
+ },
79
88
  { methods: ["GET"], pattern: path("/sash/settings"), auth: "public", handler: readSettings },
80
89
  {
81
90
  methods: ["PATCH"],
@@ -83,20 +92,14 @@ export function buildRoutes() {
83
92
  auth: "control",
84
93
  handler: patchSettings,
85
94
  },
86
- {
87
- methods: ["GET"],
88
- pattern: path("/sash/settings/file"),
89
- auth: "control",
90
- handler: readSettingsFile,
91
- },
95
+ { methods: ["GET"], pattern: path("/sash/profiles"), auth: "public", handler: listProfiles },
96
+ { methods: ["POST"], pattern: path("/sash/profiles"), auth: "control", handler: addProfile },
92
97
  {
93
98
  methods: ["PUT"],
94
- pattern: path("/sash/settings/file"),
99
+ pattern: path("/sash/profiles/order"),
95
100
  auth: "control",
96
- handler: writeSettingsFile,
101
+ handler: reorderProfiles,
97
102
  },
98
- { methods: ["GET"], pattern: path("/sash/profiles"), auth: "public", handler: listProfiles },
99
- { methods: ["POST"], pattern: path("/sash/profiles"), auth: "control", handler: addProfile },
100
103
  {
101
104
  methods: ["POST"],
102
105
  pattern: path("/sash/profiles/import"),
@@ -166,6 +169,14 @@ export function buildRoutes() {
166
169
  ];
167
170
  }
168
171
  function forwardToCore(ctx, req, res, target) {
172
+ const method = req.method?.toUpperCase() ?? "GET";
173
+ const pathname = coreApiTarget(target).split("?")[0] ?? "/";
174
+ const allowedMutation = (method === "PUT" && /^\/proxies\/[^/]+$/.test(pathname)) ||
175
+ (method === "DELETE" && /^\/connections(?:\/[^/]+)?$/.test(pathname));
176
+ if (isControlMutation(method) && !allowedMutation) {
177
+ sendError(res, 403, "conflict", "Use Sash controls to change managed Core configuration");
178
+ return;
179
+ }
169
180
  const runtime = ctx.settings.runtime();
170
181
  forwardHttpToCore(req, res, coreApiTarget(target), runtime.controller, runtime.secret);
171
182
  }
@@ -187,12 +198,6 @@ function methodOrder(method) {
187
198
  const index = METHOD_ORDER.indexOf(method);
188
199
  return index < 0 ? METHOD_ORDER.length : index;
189
200
  }
190
- export function requiredParam(req, name) {
191
- const value = req.params[name];
192
- if (value === undefined)
193
- throw new Error(`Missing route parameter: ${name}`);
194
- return value;
195
- }
196
201
  function writeResponse(res, response) {
197
202
  if (response.after) {
198
203
  const after = response.after;
@@ -1,4 +1,4 @@
1
- export function startProfileUpdateScheduler(profiles, scheduler = {}) {
1
+ export function startProfileUpdateScheduler(profiles, scheduler = {}, isActive) {
2
2
  const intervalMs = scheduler.intervalMs ?? 15 * 60 * 1000;
3
3
  const kickoffMs = scheduler.kickoffMs ?? 10_000;
4
4
  const scheduleInterval = scheduler.setInterval ?? setInterval;
@@ -6,6 +6,8 @@ export function startProfileUpdateScheduler(profiles, scheduler = {}) {
6
6
  const scheduleTimeout = scheduler.setTimeout ?? setTimeout;
7
7
  const clearScheduledTimeout = scheduler.clearTimeout ?? clearTimeout;
8
8
  const autoUpdateProfiles = async () => {
9
+ if (!isActive())
10
+ return;
9
11
  try {
10
12
  await profiles.updateDue();
11
13
  }
@@ -79,7 +79,10 @@ export function createDaemonServer(deps) {
79
79
  rejectUpgrade(socket, 500, "WebSocket proxy failed");
80
80
  }
81
81
  });
82
- const scheduler = startProfileUpdateScheduler(context.profiles, deps.scheduler ?? {});
82
+ let scheduler;
83
+ server.once("listening", () => {
84
+ scheduler = startProfileUpdateScheduler(context.profiles, deps.scheduler ?? {}, () => !context.gate.isClosing);
85
+ });
83
86
  let listenerClosePromise;
84
87
  const closeListener = () => {
85
88
  if (listenerClosePromise)
@@ -94,7 +97,7 @@ export function createDaemonServer(deps) {
94
97
  socket.destroy();
95
98
  await closed;
96
99
  }
97
- scheduler.stop();
100
+ scheduler?.stop();
98
101
  })();
99
102
  listenerClosePromise = attempt;
100
103
  void attempt.catch(() => {
@@ -8,6 +8,7 @@ const daemonFetch = async (url, init) => {
8
8
  ...(init.body !== undefined ? { body: init.body } : {}),
9
9
  direct: true,
10
10
  deadlineMs: init.timeoutMs,
11
+ headersTimeoutMs: init.timeoutMs,
11
12
  ...(init.attempts !== undefined ? { attempts: init.attempts } : {}),
12
13
  });
13
14
  return {
@@ -46,15 +47,24 @@ export class SashDaemonClient {
46
47
  status(fresh = false) {
47
48
  return this.client.status(fresh);
48
49
  }
50
+ autostartStatus() {
51
+ return this.client.autostartStatus();
52
+ }
53
+ setAutostart(enabled) {
54
+ return this.client.setAutostart(enabled);
55
+ }
49
56
  startCore() {
50
57
  return this.client.startCore();
51
58
  }
59
+ restartCore() {
60
+ return this.client.restartCore();
61
+ }
62
+ updateCore(version) {
63
+ return this.client.updateCore(version);
64
+ }
52
65
  patchSettings(patch) {
53
66
  return this.client.patchSettings(patch);
54
67
  }
55
- maintenanceShutdown() {
56
- return this.client.shutdown();
57
- }
58
68
  async shutdown() {
59
69
  await this.client.shutdown();
60
70
  }
@@ -9,6 +9,12 @@ export class HttpError extends Error {
9
9
  this.name = "HttpError";
10
10
  }
11
11
  }
12
+ export function requiredParam(req, name) {
13
+ const value = req.params[name];
14
+ if (value === undefined)
15
+ throw new Error(`Missing route parameter: ${name}`);
16
+ return value;
17
+ }
12
18
  export function parseJsonBody(req, maxBytes = 1024 * 1024) {
13
19
  return new Promise((resolve, reject) => {
14
20
  const chunks = [];
@@ -3,13 +3,12 @@ import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { loadSettings } from "./app-state.js";
6
7
  import { SashDaemonClient } from "./daemon-client.js";
7
8
  import { isCanonicalIsoTimestamp, isPlainObject } from "./json-shape.js";
8
- import { log } from "./log.js";
9
9
  import { boundedLogTailSince, logTailCursor } from "./log-follow.js";
10
10
  import { sashLayout } from "./paths.js";
11
- import { buildSanitizedEnv, commandLineContains, isProcessAlive, killProcessGracefully, withPrivateAppendLogFds, } from "./process.js";
12
- import { loadSettings } from "./settings.js";
11
+ import { buildSanitizedEnv, isProcessAlive, killProcessGracefully, withPrivateAppendLogFds, } from "./process.js";
13
12
  import { readStateLockRecord, withStateLock } from "./state-lock.js";
14
13
  export function readDaemonPidRecord(layout = sashLayout()) {
15
14
  try {
@@ -50,41 +49,11 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
50
49
  lease = readStateLockRecord(layout.daemonLeaseFile);
51
50
  }
52
51
  catch {
53
- return {
54
- kind: "unhealthy",
55
- running: true,
56
- healthy: false,
57
- ...(record ? { stalePidFile: true } : {}),
58
- };
52
+ return { kind: "unhealthy", running: true, healthy: false };
59
53
  }
60
- if (!lease || !isProcessAlive(lease.pid)) {
61
- if (record && isProcessAlive(record.pid)) {
62
- const s = settings ?? loadSettings(layout);
63
- const legacyClient = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
64
- try {
65
- const health = await legacyClient.health();
66
- if (health.token === record.token && health.pid === record.pid) {
67
- return {
68
- kind: "healthy",
69
- running: true,
70
- healthy: true,
71
- legacyOwnership: true,
72
- pid: record.pid,
73
- port: record.port,
74
- };
75
- }
76
- }
77
- catch {
78
- }
79
- return {
80
- kind: "unhealthy",
81
- running: true,
82
- healthy: false,
83
- legacyOwnership: true,
84
- pid: record.pid,
85
- ...(lease ? { staleLeaseFile: true } : {}),
86
- };
87
- }
54
+ const liveLease = lease && isProcessAlive(lease.pid);
55
+ const liveRecord = record && isProcessAlive(record.pid);
56
+ if (!liveLease && !liveRecord)
88
57
  return {
89
58
  kind: "stopped",
90
59
  running: false,
@@ -93,46 +62,19 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
93
62
  ...(record ? { stalePidFile: true } : {}),
94
63
  ...(lease ? { staleLeaseFile: true } : {}),
95
64
  };
65
+ if (!lease || !record || !liveLease || !liveRecord || lease.pid !== record.pid) {
66
+ return { kind: "unhealthy", running: true, healthy: false, pid: lease?.pid ?? record?.pid };
96
67
  }
97
- if (!record || record.pid !== lease.pid || !isProcessAlive(record.pid)) {
98
- return {
99
- kind: "unhealthy",
100
- running: true,
101
- healthy: false,
102
- pid: lease.pid,
103
- ...(record ? { stalePidFile: true } : {}),
104
- };
105
- }
106
- const s = settings ?? loadSettings(layout);
107
- const client = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
108
68
  try {
69
+ const client = new SashDaemonClient(record.port, (settings ?? loadSettings(layout)).daemonSecret);
109
70
  const health = await client.health();
110
71
  if (health.token === record.token && health.pid === record.pid) {
111
- return {
112
- kind: "healthy",
113
- running: true,
114
- pid: record.pid,
115
- healthy: true,
116
- port: record.port,
117
- };
72
+ return { kind: "healthy", running: true, healthy: true, pid: record.pid, port: record.port };
118
73
  }
119
- return {
120
- kind: "unhealthy",
121
- running: true,
122
- healthy: false,
123
- stalePidFile: true,
124
- pid: record.pid,
125
- };
126
74
  }
127
75
  catch {
128
- return {
129
- kind: "unhealthy",
130
- running: true,
131
- pid: record.pid,
132
- healthy: false,
133
- port: record.port,
134
- };
135
76
  }
77
+ return { kind: "unhealthy", running: true, healthy: false, pid: record.pid, port: record.port };
136
78
  }
137
79
  function resolveDaemonEntryPath() {
138
80
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -242,137 +184,18 @@ export async function ensureDaemon(opts = {}) {
242
184
  return;
243
185
  await spawnDaemon({ layout, settings });
244
186
  }
245
- async function waitForDaemonExit(pid, timeoutMs) {
246
- const deadline = Date.now() + timeoutMs;
247
- while (Date.now() < deadline && isProcessAlive(pid))
248
- await sleep(100);
249
- if (isProcessAlive(pid)) {
250
- throw new Error(`sashd PID ${pid} did not exit after maintenance shutdown`);
251
- }
252
- }
253
- export async function prepareDaemonMaintenance(layout, settings, purpose, deps = {}) {
254
- const daemonState = await (deps.evaluateDaemon ?? evaluateDaemon)(layout, settings);
255
- if (daemonState.kind === "unhealthy") {
256
- throw new Error(`sashd is running but unresponsive; refusing a competing ${purpose}`);
257
- }
258
- if (daemonState.kind === "stopped") {
259
- return { daemonWasRunning: false, legacyDaemon: false, coreWasRunning: false };
260
- }
261
- const client = (deps.clientFactory ?? ((port, secret) => new SashDaemonClient(port, secret)))(daemonState.port, settings.daemonSecret);
262
- if (daemonState.legacyOwnership) {
263
- if (!client.status || !client.shutdown) {
264
- throw new Error("Legacy sashd maintenance requires status and shutdown support");
265
- }
266
- log.warn("legacy sashd detected; using the pre-maintenance compatibility path");
267
- const coreWasRunning = (await client.status()).core.running;
268
- await client.shutdown();
269
- await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
270
- return { daemonWasRunning: true, legacyDaemon: true, coreWasRunning };
271
- }
272
- const snapshot = await client.maintenanceShutdown();
273
- await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
274
- return { daemonWasRunning: true, legacyDaemon: false, coreWasRunning: snapshot.coreWasRunning };
275
- }
276
187
  export async function stopDaemonFromCli(opts = {}) {
277
188
  const layout = opts.layout ?? sashLayout();
278
189
  const settings = opts.settings ?? loadSettings(layout);
279
- const record = readDaemonPidRecord(layout);
280
- if (!record) {
281
- try {
282
- const lease = readStateLockRecord(layout.daemonLeaseFile);
283
- if (!lease || !isProcessAlive(lease.pid))
284
- return true;
285
- log.warn(`sashd is still starting or unresponsive (PID=${lease.pid}); try stopping again.`);
286
- return false;
287
- }
288
- catch {
289
- log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
290
- return false;
291
- }
292
- }
293
- const pid = record.pid;
294
- if (!isProcessAlive(pid))
190
+ const state = await evaluateDaemon(layout, settings);
191
+ if (state.kind === "stopped")
295
192
  return true;
296
- let lease;
297
- try {
298
- lease = readStateLockRecord(layout.daemonLeaseFile);
299
- }
300
- catch {
301
- log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
302
- return false;
303
- }
304
- if (lease && (!isProcessAlive(lease.pid) || lease.pid !== pid)) {
305
- log.warn(`Refusing to stop PID ${pid}: daemon PID metadata does not match singleton ownership.`);
193
+ if (state.kind !== "healthy")
306
194
  return false;
307
- }
308
- const client = new SashDaemonClient(record.port || settings.daemonPort, settings.daemonSecret);
309
- const legacyOwnership = !lease;
310
- const healthMatchesRecord = async () => {
311
- try {
312
- const health = await client.health();
313
- return health.token === record.token && health.pid === pid;
314
- }
315
- catch {
316
- return false;
317
- }
318
- };
319
- if (!(await healthMatchesRecord())) {
320
- log.warn(`Refusing to stop unverified sashd PID ${pid}.`);
321
- return false;
322
- }
323
- try {
324
- await client.shutdown();
325
- }
326
- catch {
327
- }
195
+ const client = new SashDaemonClient(state.port, settings.daemonSecret);
196
+ await client.shutdown();
328
197
  const deadline = Date.now() + (opts.timeoutMs ?? 20_000);
329
- while (Date.now() < deadline && isProcessAlive(pid)) {
330
- await sleep(200);
331
- }
332
- if (!isProcessAlive(pid))
333
- return true;
334
- if (legacyOwnership) {
335
- log.warn(`Legacy sashd PID ${pid} did not stop gracefully; refusing force termination without a singleton lease.`);
336
- return false;
337
- }
338
- return withStateLock(layout.mutationLockFile, { purpose: "verify forced sashd stop", timeoutMs: 10_000 }, async () => {
339
- if (!isProcessAlive(pid))
340
- return true;
341
- try {
342
- const status = await client.status(true);
343
- const actual = status.systemProxy.actual;
344
- if (status.daemon.pid !== pid ||
345
- status.core.running ||
346
- status.systemProxy.applied ||
347
- !actual ||
348
- actual.enabled ||
349
- (actual.supported && Boolean(actual.details)) ||
350
- fs.existsSync(layout.systemProxyStateFile) ||
351
- fs.existsSync(`${layout.systemProxyStateFile}.lock`)) {
352
- log.warn(`Refusing to force-terminate sashd PID ${pid}: managed runtime cleanup is incomplete or unknown.`);
353
- return false;
354
- }
355
- }
356
- catch {
357
- log.warn(`Refusing to force-terminate unresponsive sashd PID ${pid}: runtime cleanup cannot be verified.`);
358
- return false;
359
- }
360
- const verifyDaemonIdentity = async () => {
361
- if (!(await healthMatchesRecord()))
362
- return "mismatch";
363
- return commandLineContains(pid, "daemon-entry") ? "match" : "unknown";
364
- };
365
- if ((await verifyDaemonIdentity()) !== "match") {
366
- log.warn(`Refusing to terminate PID ${pid}: process identity no longer matches sashd. Verify manually.`);
367
- return false;
368
- }
369
- const killed = await killProcessGracefully(pid, {
370
- timeoutMs: 4000,
371
- verify: verifyDaemonIdentity,
372
- });
373
- if (killed)
374
- return true;
375
- log.error(`sashd process is still running after termination attempt (PID=${pid}).`);
376
- return false;
377
- });
198
+ while (isProcessAlive(state.pid) && Date.now() < deadline)
199
+ await sleep(100);
200
+ return !isProcessAlive(state.pid);
378
201
  }
package/dist/github.js CHANGED
@@ -21,10 +21,11 @@ function ghTokenHeaders() {
21
21
  const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
22
22
  return token ? { authorization: `Bearer ${token}` } : {};
23
23
  }
24
- export async function resolveLatestTag(repo) {
24
+ export async function resolveLatestTag(repo, signal) {
25
25
  const latestUrl = `https://github.com/${repo}/releases/latest`;
26
26
  try {
27
27
  const res = await fetchWithRetry(latestUrl, {
28
+ signal,
28
29
  manualRedirect: true,
29
30
  attempts: 2,
30
31
  deadlineMs: 15_000,
@@ -50,6 +51,7 @@ export async function resolveLatestTag(repo) {
50
51
  }
51
52
  const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`;
52
53
  const res = await fetchWithRetry(apiUrl, {
54
+ signal,
53
55
  headers: {
54
56
  accept: "application/vnd.github+json",
55
57
  ...ghTokenHeaders(),
@@ -74,9 +76,10 @@ export async function resolveLatestTag(repo) {
74
76
  }
75
77
  return data.tag_name;
76
78
  }
77
- export async function listReleaseAssets(repo, tag) {
79
+ export async function listReleaseAssets(repo, tag, signal) {
78
80
  const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`;
79
81
  const res = await fetchWithRetry(apiUrl, {
82
+ signal,
80
83
  headers: {
81
84
  accept: "application/vnd.github+json",
82
85
  ...ghTokenHeaders(),
@@ -157,6 +160,7 @@ export async function downloadReleaseAsset(opts) {
157
160
  const deadlineAt = Date.now() + deadlineMs;
158
161
  let lastError;
159
162
  for (const url of urls) {
163
+ opts.signal?.throwIfAborted();
160
164
  const remainingMs = deadlineAt - Date.now();
161
165
  if (remainingMs <= 0) {
162
166
  lastError = new Error(`Core asset download deadline exceeded after ${deadlineMs}ms`);
@@ -164,6 +168,7 @@ export async function downloadReleaseAsset(opts) {
164
168
  }
165
169
  try {
166
170
  await downloadToFile(url, opts.dest, {
171
+ signal: opts.signal,
167
172
  allowedHosts: GITHUB_DOWNLOAD_HOSTS,
168
173
  maxBytes: RELEASE_ASSET_SIZE_LIMIT,
169
174
  requireHttps: true,