@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.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -5
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/api.js +14 -45
- package/dist/app-state.js +99 -0
- package/dist/autostart/command.js +24 -0
- package/dist/autostart/context.js +43 -0
- package/dist/autostart/files.js +60 -0
- package/dist/autostart/installation.js +48 -0
- package/dist/autostart/start.js +47 -0
- package/dist/autostart/windows-registry.js +88 -0
- package/dist/autostart/windows.js +59 -0
- package/dist/autostart-contract.js +31 -0
- package/dist/autostart-entry.js +10 -0
- package/dist/autostart.js +69 -0
- package/dist/cli.js +11 -11
- package/dist/commands/auto.js +15 -0
- package/dist/commands/lifecycle.js +5 -15
- package/dist/commands/logs.js +11 -4
- package/dist/commands/shared.js +2 -4
- package/dist/commands/status.js +5 -1
- package/dist/commands/update.js +7 -3
- package/dist/commands/web.js +14 -50
- package/dist/contracts.js +168 -348
- package/dist/core-config-validation.js +22 -38
- package/dist/core-update.js +126 -762
- package/dist/core.js +20 -46
- package/dist/daemon/app.js +129 -117
- package/dist/daemon/context.js +13 -7
- package/dist/daemon/entry.js +21 -38
- package/dist/daemon/errors.js +3 -0
- package/dist/daemon/handlers/autostart.js +18 -0
- package/dist/daemon/handlers/core.js +32 -9
- package/dist/daemon/handlers/daemon.js +17 -6
- package/dist/daemon/handlers/profiles.js +12 -2
- package/dist/daemon/handlers/settings.js +9 -36
- package/dist/daemon/router.js +26 -21
- package/dist/daemon/scheduler.js +3 -1
- package/dist/daemon/server.js +5 -2
- package/dist/daemon-client.js +13 -3
- package/dist/daemon-http.js +6 -0
- package/dist/daemon-lifecycle.js +19 -196
- package/dist/github.js +7 -2
- package/dist/http.js +7 -5
- package/dist/mihomo-config.js +2 -1
- package/dist/paths.js +5 -7
- package/dist/profile-model.js +87 -0
- package/dist/profile-service.js +250 -587
- package/dist/profiles.js +43 -171
- package/dist/runtime-lifecycle.js +112 -194
- package/dist/runtime-owner.js +37 -76
- package/dist/sash-client.js +40 -19
- package/dist/settings-service.js +29 -224
- package/dist/settings.js +61 -289
- package/dist/status.js +24 -16
- package/dist/supervisor.js +1 -14
- package/dist/sysproxy/common.js +5 -45
- package/dist/sysproxy/factory.js +24 -65
- package/dist/sysproxy/snapshot.js +24 -257
- package/dist/sysproxy.js +1 -4
- package/dist/system-proxy-manager.js +37 -35
- package/dist/ui/assets/{ConnectionsView-CfaOG2JV.js → ConnectionsView-BgXQM6Z4.js} +1 -1
- package/dist/ui/assets/{LogsView-BDiRATXN.js → LogsView-2f542P8z.js} +1 -1
- package/dist/ui/assets/{PaginationFooter-DTytr1iu.js → PaginationFooter-cj1tcZej.js} +1 -1
- package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
- package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
- package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
- package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
- package/dist/ui/assets/{RulesView-CjEyCN1A.js → RulesView-CtKvlckZ.js} +1 -1
- package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
- package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
- package/dist/ui/assets/index-CBInDvdJ.js +19 -0
- package/dist/ui/assets/index-mOLy7fkB.css +1 -0
- package/dist/ui/assets/{theme-BwDBMsKO.js → theme-D40MF8cQ.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/docs/architecture-proposal.md +109 -0
- package/docs/autostart.md +101 -0
- package/docs/backend.md +92 -224
- package/docs/frontend.md +60 -106
- package/docs/usage.md +92 -164
- package/package.json +3 -3
- package/dist/commands/upgrade.js +0 -43
- package/dist/core-install-transaction.js +0 -114
- package/dist/core-update-coordination.js +0 -105
- package/dist/core-update-service.js +0 -245
- package/dist/managed-state-transaction.js +0 -375
- package/dist/offline-mutation.js +0 -58
- package/dist/profile-migration.js +0 -101
- package/dist/runtime-recovery.js +0 -31
- package/dist/sysproxy/darwin.js +0 -287
- package/dist/sysproxy/gnome.js +0 -181
- package/dist/sysproxy/legacy.js +0 -58
- package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
- package/dist/ui/assets/CodeEditorModal-D1zn-jRS.js +0 -14
- package/dist/ui/assets/ProfileEditorDialog-CyyP6OF1.js +0 -1
- package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
- package/dist/ui/assets/ProfilesView-hFa7-O72.js +0 -2
- package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +0 -1
- package/dist/ui/assets/SettingsView-D5QvJId6.css +0 -1
- package/dist/ui/assets/SettingsView-DsRntRfn.js +0 -2
- package/dist/ui/assets/index-CxQTf_P9.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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((
|
|
72
|
-
status:
|
|
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
|
|
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
|
|
2
|
-
import { parseSettingsPatch, } from "../../contracts.js";
|
|
1
|
+
import { parseSettingsPatch } from "../../contracts.js";
|
|
3
2
|
import { HttpError } from "../../daemon-http.js";
|
|
4
|
-
import {
|
|
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
|
|
11
|
+
patch = parseSettingsPatch(body);
|
|
19
12
|
}
|
|
20
|
-
catch (
|
|
21
|
-
throw new HttpError(400,
|
|
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 {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|
package/dist/daemon/router.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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/
|
|
76
|
+
pattern: path("/sash/core/update"),
|
|
75
77
|
auth: "control",
|
|
76
|
-
handler:
|
|
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
|
-
|
|
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/
|
|
99
|
+
pattern: path("/sash/profiles/order"),
|
|
95
100
|
auth: "control",
|
|
96
|
-
handler:
|
|
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;
|
package/dist/daemon/scheduler.js
CHANGED
|
@@ -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
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -79,7 +79,10 @@ export function createDaemonServer(deps) {
|
|
|
79
79
|
rejectUpgrade(socket, 500, "WebSocket proxy failed");
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
|
-
|
|
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
|
|
100
|
+
scheduler?.stop();
|
|
98
101
|
})();
|
|
99
102
|
listenerClosePromise = attempt;
|
|
100
103
|
void attempt.catch(() => {
|
package/dist/daemon-client.js
CHANGED
|
@@ -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
|
}
|
package/dist/daemon-http.js
CHANGED
|
@@ -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 = [];
|
package/dist/daemon-lifecycle.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
280
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
await sleep(
|
|
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,
|