@monster-orange/den 0.4.21 → 0.4.22
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 +68 -0
- package/docs/distributions.md +95 -12
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/doctor-command.ts +72 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +217 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +4 -0
- package/src/ui/server/index.ts +4 -0
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/i18n/es.js +14 -0
- package/src/ui/server/public/i18n/ja.js +14 -0
- package/src/ui/server/public/shell.js +14 -0
- package/src/ui/server/session-routes.ts +4 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D18/D20: doctor's "where it runs" rows (macOS only).
|
|
3
|
+
*
|
|
4
|
+
* server the web server's launchd session (/api/server-info)
|
|
5
|
+
* session host the session host's (its hello), where every agent runs
|
|
6
|
+
* full disk access whether the host (and so every agent) has it, and
|
|
7
|
+
* the executable to grant it to: Den's node copy, never the
|
|
8
|
+
* shared node
|
|
9
|
+
* service the installed launchd job: always (a LaunchDaemon, kept
|
|
10
|
+
* across logout) or login (a LaunchAgent in the desktop
|
|
11
|
+
* session), and "plist present, job not loaded" with the
|
|
12
|
+
* bootstrap that loads it
|
|
13
|
+
*
|
|
14
|
+
* A process in the desktop session (Aqua) stops at logout: a warning with
|
|
15
|
+
* the fix. None of these fails doctor.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
import {
|
|
21
|
+
ALWAYS_UNPROVEN,
|
|
22
|
+
endsAtLogout,
|
|
23
|
+
sessionWarning,
|
|
24
|
+
} from "../../shared/launchd-session.js";
|
|
25
|
+
|
|
26
|
+
export type ServiceMode = "always" | "login";
|
|
27
|
+
|
|
28
|
+
export interface ServiceJob {
|
|
29
|
+
/** unknown: launchctl could not be asked. */
|
|
30
|
+
state: "loaded" | "not-loaded" | "unknown";
|
|
31
|
+
mode: ServiceMode;
|
|
32
|
+
plist: string;
|
|
33
|
+
domain: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WhereDeps {
|
|
37
|
+
cli: string;
|
|
38
|
+
platform: NodeJS.Platform;
|
|
39
|
+
/** The web server's launchd session; null when it does not answer. */
|
|
40
|
+
server: () => Promise<{ launchd?: string | null } | null>;
|
|
41
|
+
/** The running session host's; null when none runs. */
|
|
42
|
+
host: () => Promise<{
|
|
43
|
+
launchd: string | null;
|
|
44
|
+
access?: { exe: string; fullDiskAccess: boolean | null } | null;
|
|
45
|
+
} | null>;
|
|
46
|
+
service: () => ServiceJob | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WhereRow {
|
|
50
|
+
what: "server" | "session host" | "full disk access" | "service";
|
|
51
|
+
session: string;
|
|
52
|
+
status: "ok" | "warn";
|
|
53
|
+
detail?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sessionRow(
|
|
57
|
+
what: "server" | "session host",
|
|
58
|
+
launchd: string | null | undefined,
|
|
59
|
+
cli: string,
|
|
60
|
+
): WhereRow {
|
|
61
|
+
const session = launchd || "unknown";
|
|
62
|
+
return {
|
|
63
|
+
what,
|
|
64
|
+
session,
|
|
65
|
+
status: endsAtLogout(launchd) ? "warn" : "ok",
|
|
66
|
+
...(sessionWarning(launchd, cli, what)
|
|
67
|
+
? { detail: sessionWarning(launchd, cli, what)! }
|
|
68
|
+
: {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
|
|
73
|
+
if (d.platform !== "darwin") return [];
|
|
74
|
+
const rows: WhereRow[] = [];
|
|
75
|
+
const server = await d.server().catch(() => null);
|
|
76
|
+
rows.push(
|
|
77
|
+
server
|
|
78
|
+
? sessionRow("server", server.launchd, d.cli)
|
|
79
|
+
: {
|
|
80
|
+
// Not started yet is not a problem (a fresh install).
|
|
81
|
+
what: "server",
|
|
82
|
+
session: "not running",
|
|
83
|
+
status: "ok",
|
|
84
|
+
detail: `start it: ${d.cli} web up, or ${d.cli} service install`,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
const host = await d.host().catch(() => null);
|
|
88
|
+
rows.push(
|
|
89
|
+
host
|
|
90
|
+
? sessionRow("session host", host.launchd, d.cli)
|
|
91
|
+
: {
|
|
92
|
+
what: "session host",
|
|
93
|
+
session: "not running",
|
|
94
|
+
status: "ok",
|
|
95
|
+
detail: "it starts when the first terminal or agent does",
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
const fda = host?.access;
|
|
99
|
+
if (fda && fda.fullDiskAccess !== null) {
|
|
100
|
+
const own = /-sessions-host$/.test(fda.exe);
|
|
101
|
+
rows.push(
|
|
102
|
+
fda.fullDiskAccess
|
|
103
|
+
? {
|
|
104
|
+
what: "full disk access",
|
|
105
|
+
session: "granted",
|
|
106
|
+
status: "ok",
|
|
107
|
+
detail: `to ${fda.exe}`,
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
what: "full disk access",
|
|
111
|
+
session: "not granted",
|
|
112
|
+
status: "warn",
|
|
113
|
+
detail: own
|
|
114
|
+
? `agents can't open protected folders (other volumes, Desktop, Documents): System Settings → Privacy & Security → Full Disk Access → + → ${fda.exe}, then ${d.cli} sessions host --stop (it restarts with the grant)`
|
|
115
|
+
: `agents can't open protected folders. The host runs the shared node (${fda.exe}): ${d.cli} service install gives it its own copy to grant, so the grant never goes to every node program`,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const svc = d.service();
|
|
120
|
+
if (!svc)
|
|
121
|
+
rows.push({
|
|
122
|
+
what: "service",
|
|
123
|
+
session: "not installed",
|
|
124
|
+
status: "ok",
|
|
125
|
+
detail: `${d.cli} service install (login, the default; --mode always is opt-in and ${ALWAYS_UNPROVEN})`,
|
|
126
|
+
});
|
|
127
|
+
else if (svc.state === "not-loaded")
|
|
128
|
+
rows.push({
|
|
129
|
+
what: "service",
|
|
130
|
+
session: `${svc.mode} (${svc.domain})`,
|
|
131
|
+
status: "warn",
|
|
132
|
+
detail: `plist present, job not loaded: ${svc.domain === "system" ? "sudo " : ""}launchctl bootstrap ${svc.domain} ${svc.plist}`,
|
|
133
|
+
});
|
|
134
|
+
else
|
|
135
|
+
rows.push({
|
|
136
|
+
what: "service",
|
|
137
|
+
session: `${svc.mode} (${svc.domain})`,
|
|
138
|
+
status: "ok",
|
|
139
|
+
...(svc.state === "unknown"
|
|
140
|
+
? { detail: "launchctl could not be asked whether it is loaded" }
|
|
141
|
+
: svc.mode === "always"
|
|
142
|
+
? { detail: `opt-in: ${ALWAYS_UNPROVEN}` }
|
|
143
|
+
: {}),
|
|
144
|
+
});
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface JobProbe {
|
|
149
|
+
exists: (p: string) => boolean;
|
|
150
|
+
/** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
|
|
151
|
+
print: (target: string) => number | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const realProbe: JobProbe = {
|
|
155
|
+
exists: (p) => fs.existsSync(p),
|
|
156
|
+
print: (target) =>
|
|
157
|
+
spawnSync("launchctl", ["print", target], {
|
|
158
|
+
stdio: "ignore",
|
|
159
|
+
timeout: 3000,
|
|
160
|
+
}).status,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The installed service job for `label`: the LaunchDaemon (always) when
|
|
165
|
+
* its plist exists, else the LaunchAgent (login); null when neither does.
|
|
166
|
+
*/
|
|
167
|
+
export function serviceJobState(
|
|
168
|
+
label: string,
|
|
169
|
+
home: string,
|
|
170
|
+
uid: number,
|
|
171
|
+
probe: JobProbe = realProbe,
|
|
172
|
+
): ServiceJob | null {
|
|
173
|
+
const daemon = path.join("/Library/LaunchDaemons", `${label}.plist`);
|
|
174
|
+
const agent = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
|
|
175
|
+
const found: [ServiceMode, string, string] | null = probe.exists(daemon)
|
|
176
|
+
? ["always", daemon, "system"]
|
|
177
|
+
: probe.exists(agent)
|
|
178
|
+
? ["login", agent, `gui/${uid}`]
|
|
179
|
+
: null;
|
|
180
|
+
if (!found) return null;
|
|
181
|
+
const [mode, plist, domain] = found;
|
|
182
|
+
const code = probe.print(`${domain}/${label}`);
|
|
183
|
+
const state =
|
|
184
|
+
code === 0
|
|
185
|
+
? "loaded"
|
|
186
|
+
: code === null || code === 127
|
|
187
|
+
? "unknown"
|
|
188
|
+
: "not-loaded";
|
|
189
|
+
return { state, mode, plist, domain };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The rows as doctor prints them, under a heading. */
|
|
193
|
+
export function formatWhere(rows: WhereRow[], title: string): string[] {
|
|
194
|
+
if (!rows.length) return [];
|
|
195
|
+
const lines = ["", `${title} doctor: where it runs`, ""];
|
|
196
|
+
const w = Math.max(...rows.map((r) => r.session.length));
|
|
197
|
+
for (const r of rows) {
|
|
198
|
+
const mark = r.status === "ok" ? "✓" : "⚠";
|
|
199
|
+
lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
|
|
200
|
+
if (r.detail) lines.push(` ${r.status === "ok" ? "" : "⚠ "}${r.detail}`);
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -5317,7 +5317,7 @@ class DenCLI {
|
|
|
5317
5317
|
" mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
|
|
5318
5318
|
);
|
|
5319
5319
|
console.log(
|
|
5320
|
-
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
5320
|
+
" service install Run the web UI as a launchd service (status, print, uninstall, help)",
|
|
5321
5321
|
);
|
|
5322
5322
|
console.log(
|
|
5323
5323
|
" theme add <repo>@<ref> Install a web UI theme (list, validate, remove)",
|
|
@@ -14,6 +14,19 @@
|
|
|
14
14
|
* (install.sh print), the label, the paths and whose server answers on the
|
|
15
15
|
* port, and writes and loads nothing; exit 1 when that server is foreign.
|
|
16
16
|
* `status` also names a foreign server on the port.
|
|
17
|
+
*
|
|
18
|
+
* D18: `install --mode login|always`. login: LaunchAgents in the desktop
|
|
19
|
+
* session, stopped at logout. always: LaunchDaemons that run as you, kept
|
|
20
|
+
* across logout (one sudo). Without --mode, the mode already installed
|
|
21
|
+
* (else login): re-running install never changes it. Both modes also
|
|
22
|
+
* install the session host as its own job, on Den's node copy
|
|
23
|
+
* (`<stateDir>/bin/<cli>-sessions-host`, the path Full Disk Access is
|
|
24
|
+
* granted to), and `<cli> sessions` then waits for that job instead of
|
|
25
|
+
* starting a host of its own.
|
|
26
|
+
*
|
|
27
|
+
* D26: "running" means the server answering on the port is the job's own
|
|
28
|
+
* process (its pid is launchd's). A plain `web up` server of this install
|
|
29
|
+
* holding the port is stopped first; `status` says when one holds it.
|
|
17
30
|
*/
|
|
18
31
|
import {
|
|
19
32
|
ownerGithubLine,
|
|
@@ -21,8 +34,15 @@ import {
|
|
|
21
34
|
type OwnerGithubResult,
|
|
22
35
|
} from "../../shared/owner-github.js";
|
|
23
36
|
import { spawnSync } from "child_process";
|
|
37
|
+
import * as fs from "fs";
|
|
24
38
|
import * as os from "os";
|
|
25
39
|
import * as path from "path";
|
|
40
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
41
|
+
import {
|
|
42
|
+
serviceJobState,
|
|
43
|
+
type ServiceJob,
|
|
44
|
+
type ServiceMode,
|
|
45
|
+
} from "./doctor-where.js";
|
|
26
46
|
import {
|
|
27
47
|
readDenConfig,
|
|
28
48
|
readWebServiceConfig,
|
|
@@ -31,6 +51,7 @@ import {
|
|
|
31
51
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
32
52
|
import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
|
|
33
53
|
import { product } from "../../shared/product.js";
|
|
54
|
+
import { ALWAYS_UNPROVEN } from "../../shared/launchd-session.js";
|
|
34
55
|
import {
|
|
35
56
|
classifyServerInfo,
|
|
36
57
|
ForeignServerError,
|
|
@@ -53,7 +74,7 @@ const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
|
|
|
53
74
|
|
|
54
75
|
/** The flags each subcommand takes; any other is refused before acting. */
|
|
55
76
|
const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
|
|
56
|
-
install: ["--plan"],
|
|
77
|
+
install: ["--plan", "--mode"],
|
|
57
78
|
status: [],
|
|
58
79
|
print: [],
|
|
59
80
|
uninstall: [],
|
|
@@ -105,6 +126,14 @@ export async function waitForServerInfo(
|
|
|
105
126
|
|
|
106
127
|
export interface ServiceDeps {
|
|
107
128
|
run?: ServiceRunner;
|
|
129
|
+
/** The state dir (default getStateDir()): Den's node copy, the host job file. */
|
|
130
|
+
stateDir?: string;
|
|
131
|
+
/** D18: the job installed for a label, if any (default: plists + launchctl). */
|
|
132
|
+
job?: (label: string) => ServiceJob | null;
|
|
133
|
+
/** D26: the pid launchd runs a job as, or null (default: launchctl print). */
|
|
134
|
+
jobPid?: (job: ServiceJob, label: string) => number | null;
|
|
135
|
+
/** D26: stop a plain server of this install (default: SIGTERM, wait). */
|
|
136
|
+
stopPlain?: (pid: number, port: number) => Promise<boolean>;
|
|
108
137
|
/** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
|
|
109
138
|
recordOwner?: () => OwnerGithubResult;
|
|
110
139
|
config?: DenConfig;
|
|
@@ -142,20 +171,108 @@ export function identityLine(port: number, id: ServerIdentity): string {
|
|
|
142
171
|
}
|
|
143
172
|
}
|
|
144
173
|
|
|
174
|
+
/** D18: the session host's job label: the web label's `.web` → `.sessions`. */
|
|
175
|
+
export function hostLabel(label: string): string {
|
|
176
|
+
return `${label.replace(/\.web$/, "")}.sessions`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** D18: Den's node copy for the session host, the path Full Disk Access names. */
|
|
180
|
+
export function hostExePath(stateDir: string, cli: string): string {
|
|
181
|
+
return path.join(stateDir, "bin", `${cli}-sessions-host`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* D18: the file that says the session host runs as a launchd job
|
|
186
|
+
* (`<cli> sessions` waits for it instead of starting one).
|
|
187
|
+
*/
|
|
188
|
+
export function hostJobFile(stateDir: string): string {
|
|
189
|
+
return path.join(stateDir, "sessions-host-job.json");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `--mode <m>` / `--mode=<m>` from install's args; undefined when absent. */
|
|
193
|
+
function modeArg(args: string[]): ServiceMode | undefined {
|
|
194
|
+
let v: string | undefined;
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
if (args[i] === "--mode") v = args[i + 1];
|
|
197
|
+
else if (args[i].startsWith("--mode=")) v = args[i].slice(7);
|
|
198
|
+
}
|
|
199
|
+
if (v === undefined) return undefined;
|
|
200
|
+
if (v === "login" || v === "always") return v;
|
|
201
|
+
throw new Error(`service install --mode takes login or always, not "${v}"`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Install's args without `--mode <m>`, for the unknown-argument check. */
|
|
205
|
+
function withoutMode(args: string[]): string[] {
|
|
206
|
+
const out: string[] = [];
|
|
207
|
+
for (let i = 0; i < args.length; i++) {
|
|
208
|
+
if (args[i] === "--mode") i++;
|
|
209
|
+
else if (!args[i].startsWith("--mode=")) out.push(args[i]);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function defaultJobPid(job: ServiceJob, label: string): number | null {
|
|
215
|
+
const r = spawnSync("launchctl", ["print", `${job.domain}/${label}`], {
|
|
216
|
+
encoding: "utf-8",
|
|
217
|
+
timeout: 3000,
|
|
218
|
+
});
|
|
219
|
+
if (r.status !== 0) return null;
|
|
220
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
221
|
+
return m ? Number(m[1]) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function defaultStopPlain(pid: number, port: number): Promise<boolean> {
|
|
225
|
+
const { killWebProcess } = await import("./web-command.js");
|
|
226
|
+
killWebProcess(pid, "SIGTERM");
|
|
227
|
+
const deadline = Date.now() + 10_000;
|
|
228
|
+
while (Date.now() < deadline) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 0);
|
|
231
|
+
} catch {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
235
|
+
}
|
|
236
|
+
void port;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `service help`: the subcommands and the two install modes. */
|
|
241
|
+
export function serviceHelp(cli: string): string[] {
|
|
242
|
+
return [
|
|
243
|
+
`${cli} service: run the web UI and the session host as launchd jobs (macOS)`,
|
|
244
|
+
``,
|
|
245
|
+
` install [--mode login|always] install (or refresh) and start them`,
|
|
246
|
+
` install --plan what install would do; writes and loads nothing`,
|
|
247
|
+
` status launchd's view: state, pid, last exit code`,
|
|
248
|
+
` print the web plist it would install; installs nothing`,
|
|
249
|
+
` uninstall stop them and remove their plists`,
|
|
250
|
+
``,
|
|
251
|
+
` --mode login (default) LaunchAgents in the desktop session: stop at logout, with every agent`,
|
|
252
|
+
` --mode always LaunchDaemons running as you, one sudo: meant to keep agents running across logout. Opt-in, ${ALWAYS_UNPROVEN}`,
|
|
253
|
+
` Without --mode, install keeps the mode already installed.`,
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
145
257
|
/** Run `service <sub>`; returns the exit code. */
|
|
146
258
|
export async function cmdService(
|
|
147
259
|
args: string[],
|
|
148
260
|
deps: ServiceDeps = {},
|
|
149
261
|
): Promise<number> {
|
|
150
|
-
|
|
151
|
-
|
|
262
|
+
if (args[0] === undefined || ["help", "--help", "-h"].includes(args[0])) {
|
|
263
|
+
for (const l of serviceHelp(product().cli))
|
|
264
|
+
(deps.log ?? ((x: string) => console.log(x)))(l);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
const sub = args[0] as (typeof SUBCOMMANDS)[number];
|
|
268
|
+
if (!(SUBCOMMANDS as readonly string[]).includes(sub))
|
|
152
269
|
throw new Error(
|
|
153
|
-
|
|
270
|
+
`Unknown service command "${sub}". Use: ${SUBCOMMANDS.join(" | ")} (${product().cli} service help)`,
|
|
154
271
|
);
|
|
155
272
|
// A typo'd --plan must not install for real.
|
|
156
|
-
const extra =
|
|
157
|
-
.slice(1)
|
|
158
|
-
|
|
273
|
+
const extra = (
|
|
274
|
+
sub === "install" ? withoutMode(args.slice(1)) : args.slice(1)
|
|
275
|
+
).filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
|
|
159
276
|
if (extra.length)
|
|
160
277
|
throw new Error(
|
|
161
278
|
`service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
|
|
@@ -164,6 +281,7 @@ export async function cmdService(
|
|
|
164
281
|
: ""),
|
|
165
282
|
);
|
|
166
283
|
const plan = sub === "install" && args.includes("--plan");
|
|
284
|
+
const askedMode = sub === "install" ? modeArg(args) : undefined;
|
|
167
285
|
// print (and install --plan) only write the plist to stdout; the rest
|
|
168
286
|
// talk to launchd.
|
|
169
287
|
if (
|
|
@@ -185,10 +303,24 @@ export async function cmdService(
|
|
|
185
303
|
"service",
|
|
186
304
|
"install.sh",
|
|
187
305
|
);
|
|
188
|
-
const
|
|
306
|
+
const home = deps.home ?? os.homedir();
|
|
307
|
+
const jobOf =
|
|
308
|
+
deps.job ??
|
|
309
|
+
((l: string) => serviceJobState(l, home, process.getuid?.() ?? 0));
|
|
310
|
+
const installed = jobOf(label);
|
|
311
|
+
// D18: without --mode, the mode it has (re-running install keeps it).
|
|
312
|
+
const mode: ServiceMode = askedMode ?? installed?.mode ?? "login";
|
|
313
|
+
const stateDir = deps.stateDir ?? getStateDir();
|
|
314
|
+
const env: Record<string, string> = {
|
|
189
315
|
DEN_ROOT: distRoot,
|
|
190
316
|
DEN_SERVICE_LABEL: label,
|
|
191
317
|
DEN_SERVICE_LOGS: logs,
|
|
318
|
+
...(sub === "install" || sub === "print"
|
|
319
|
+
? {
|
|
320
|
+
DEN_SERVICE_MODE: mode,
|
|
321
|
+
DEN_SERVICE_HOST_EXE: hostExePath(stateDir, product().cli),
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
192
324
|
};
|
|
193
325
|
const run = deps.run ?? defaultRunner;
|
|
194
326
|
const log = deps.log ?? ((l: string) => console.log(l));
|
|
@@ -197,12 +329,20 @@ export async function cmdService(
|
|
|
197
329
|
const identify =
|
|
198
330
|
deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
|
|
199
331
|
|
|
332
|
+
const jobPid = deps.jobPid ?? defaultJobPid;
|
|
200
333
|
if (plan) {
|
|
201
|
-
const
|
|
334
|
+
const dir =
|
|
335
|
+
mode === "always"
|
|
336
|
+
? "/Library/LaunchDaemons"
|
|
337
|
+
: path.join(home, "Library", "LaunchAgents");
|
|
202
338
|
log(`service install --plan (read-only: nothing is written or loaded)`);
|
|
339
|
+
log(
|
|
340
|
+
` mode: ${mode}${mode === "always" ? ` (LaunchDaemons running as you: meant to keep running across logout, ${ALWAYS_UNPROVEN}; opt-in; one sudo)` : " (LaunchAgents: stop at logout)"}`,
|
|
341
|
+
);
|
|
203
342
|
log(` label: ${label}`);
|
|
343
|
+
log(` plist: ${path.join(dir, `${label}.plist`)}`);
|
|
204
344
|
log(
|
|
205
|
-
`
|
|
345
|
+
` host: ${hostLabel(label)} on ${hostExePath(stateDir, product().cli)} (grant this path Full Disk Access for protected folders)`,
|
|
206
346
|
);
|
|
207
347
|
log(` logs: ${path.join(logs, "web.log")}`);
|
|
208
348
|
log(` root: ${distRoot}`);
|
|
@@ -210,6 +350,15 @@ export async function cmdService(
|
|
|
210
350
|
log(` url: http://localhost:${port}`);
|
|
211
351
|
const id = await identify(port, own);
|
|
212
352
|
log(` server: ${identityLine(port, id)}`);
|
|
353
|
+
// D26: what install does with a plain server of this install.
|
|
354
|
+
if (
|
|
355
|
+
id.kind === "ours" &&
|
|
356
|
+
id.pid &&
|
|
357
|
+
id.pid !== (installed ? jobPid(installed, label) : null)
|
|
358
|
+
)
|
|
359
|
+
log(
|
|
360
|
+
` install stops this plain server (pid ${id.pid}) first; the service runs it then`,
|
|
361
|
+
);
|
|
213
362
|
log(` plist it would write:`);
|
|
214
363
|
// install.sh print writes the plist to stdout only.
|
|
215
364
|
const code = run("bash", [script, "print"], env);
|
|
@@ -221,6 +370,26 @@ export async function cmdService(
|
|
|
221
370
|
return 0;
|
|
222
371
|
}
|
|
223
372
|
|
|
373
|
+
// D26: a plain `web up` server of this install on the port would answer
|
|
374
|
+
// for the job (which then crash-loops on the taken port): stop it first.
|
|
375
|
+
if (sub === "install") {
|
|
376
|
+
const id = await identify(port, own);
|
|
377
|
+
const pid = id.kind === "ours" ? id.pid : undefined;
|
|
378
|
+
const theJob = installed ? jobPid(installed, label) : null;
|
|
379
|
+
if (pid && pid !== theJob) {
|
|
380
|
+
const stopped = await (deps.stopPlain ?? defaultStopPlain)(pid, port);
|
|
381
|
+
if (!stopped) {
|
|
382
|
+
log(
|
|
383
|
+
`✗ a plain server of this install (pid ${pid}) holds port ${port} and did not stop: ${product().cli} web down, then service install again`,
|
|
384
|
+
);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
log(
|
|
388
|
+
`… stopped the plain server of this install (pid ${pid}) on port ${port}: the service runs it now`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
224
393
|
// D17: whoever installs it can connect their GitHub account from any
|
|
225
394
|
// browser (web.owner_github, from gh), before the server reads it.
|
|
226
395
|
if (sub === "install") {
|
|
@@ -229,18 +398,43 @@ export async function cmdService(
|
|
|
229
398
|
(deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
|
|
230
399
|
const line = ownerGithubLine(rec(), product().cli);
|
|
231
400
|
if (line) log(line);
|
|
401
|
+
if (mode === "always")
|
|
402
|
+
log(`note: --mode always is opt-in and ${ALWAYS_UNPROVEN}`);
|
|
232
403
|
}
|
|
233
404
|
const code = run("bash", [script, sub!], {
|
|
234
405
|
...env,
|
|
235
406
|
// The script then leaves saying "running" to us (after the poll below).
|
|
236
407
|
...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
|
|
237
408
|
});
|
|
409
|
+
if (sub === "uninstall" && code === 0)
|
|
410
|
+
fs.rmSync(hostJobFile(stateDir), { force: true });
|
|
238
411
|
if (sub === "status") {
|
|
239
412
|
const id = await identify(port, own);
|
|
240
413
|
if (id.kind === "foreign") log(`✗ ${identityLine(port, id)}`);
|
|
414
|
+
// D26: the server on the port is the job's, or say whose it is.
|
|
415
|
+
if (id.kind === "ours" && id.pid && installed) {
|
|
416
|
+
const theJob = jobPid(installed, label);
|
|
417
|
+
if (theJob !== id.pid) {
|
|
418
|
+
log(
|
|
419
|
+
`✗ port ${port} is served by pid ${id.pid}, not the service (${theJob ? `pid ${theJob}` : "not running"}): a plain server of this install holds it. ${product().cli} web down, then ${product().cli} service install`,
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
241
424
|
return code;
|
|
242
425
|
}
|
|
243
426
|
if (sub !== "install" || code !== 0) return code;
|
|
427
|
+
// D18: `<cli> sessions` waits for the host job instead of starting one.
|
|
428
|
+
try {
|
|
429
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
430
|
+
fs.writeFileSync(
|
|
431
|
+
hostJobFile(stateDir),
|
|
432
|
+
JSON.stringify({ label: hostLabel(label), mode }) + "\n",
|
|
433
|
+
{ mode: 0o600 },
|
|
434
|
+
);
|
|
435
|
+
} catch {
|
|
436
|
+
/* sessions then start a host of their own, as before */
|
|
437
|
+
}
|
|
244
438
|
|
|
245
439
|
const url = `http://localhost:${port}/api/server-info`;
|
|
246
440
|
const waitReady =
|
|
@@ -258,8 +452,20 @@ export async function cmdService(
|
|
|
258
452
|
return 1;
|
|
259
453
|
}
|
|
260
454
|
if (ready) {
|
|
455
|
+
// D26: the answer must come from the job itself, not from another
|
|
456
|
+
// process of this install on the port (the job then crash-loops).
|
|
457
|
+
const job = jobOf(label);
|
|
458
|
+
const id = await identify(port, own);
|
|
459
|
+
const served = id.kind === "ours" ? id.pid : undefined;
|
|
460
|
+
const theJob = job ? jobPid(job, label) : null;
|
|
461
|
+
if (job && served && theJob !== served) {
|
|
462
|
+
log(
|
|
463
|
+
`✗ ${label} installed, but port ${port} is served by pid ${served}, not the job (${theJob ? `pid ${theJob}` : "not running"}): ${product().cli} web down, then ${product().cli} service install`,
|
|
464
|
+
);
|
|
465
|
+
return 1;
|
|
466
|
+
}
|
|
261
467
|
log(
|
|
262
|
-
`✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
|
|
468
|
+
`✓ ${label} running (${mode}) at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
|
|
263
469
|
);
|
|
264
470
|
return 0;
|
|
265
471
|
}
|
|
@@ -133,6 +133,33 @@ export interface Resolved {
|
|
|
133
133
|
stdout: NodeJS.WriteStream;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* D18: the session host's launchd job, when `service install` set one up
|
|
138
|
+
* and its plist is still there: then the job starts the host (in the
|
|
139
|
+
* session its mode chose), never a process of ours, which would run in
|
|
140
|
+
* this caller's launchd session (D20: a desktop one ends at logout).
|
|
141
|
+
*/
|
|
142
|
+
export function hostJob(
|
|
143
|
+
stateDir: string,
|
|
144
|
+
home: string = process.env.HOME ?? "",
|
|
145
|
+
exists: (p: string) => boolean = fs.existsSync,
|
|
146
|
+
): { label: string; mode: string } | null {
|
|
147
|
+
try {
|
|
148
|
+
const v = JSON.parse(
|
|
149
|
+
fs.readFileSync(path.join(stateDir, "sessions-host-job.json"), "utf-8"),
|
|
150
|
+
) as { label?: unknown; mode?: unknown };
|
|
151
|
+
if (typeof v.label !== "string" || !/^[\w.-]+$/.test(v.label)) return null;
|
|
152
|
+
const plists = [
|
|
153
|
+
path.join("/Library/LaunchDaemons", `${v.label}.plist`),
|
|
154
|
+
path.join(home, "Library", "LaunchAgents", `${v.label}.plist`),
|
|
155
|
+
];
|
|
156
|
+
if (!plists.some((p) => exists(p))) return null;
|
|
157
|
+
return { label: v.label, mode: String(v.mode ?? "") };
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
136
163
|
/** Start `<cli> sessions host` in the background, detached, logging to the state dir. */
|
|
137
164
|
function spawnHost(stateDir: string): void {
|
|
138
165
|
const cmd = cliEntrypoint();
|
|
@@ -164,17 +191,17 @@ export function resolveDeps(d: SessionsDeps): Resolved {
|
|
|
164
191
|
cli: d.cli ?? product().cli,
|
|
165
192
|
connect:
|
|
166
193
|
d.connect ??
|
|
167
|
-
(() =>
|
|
168
|
-
|
|
194
|
+
(() => {
|
|
195
|
+
const job = hostJob(stateDir());
|
|
196
|
+
return ensureHost({
|
|
169
197
|
socket: sessionPaths(stateDir()).socket,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
product().cli,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
})),
|
|
198
|
+
// D18: launchd starts (and restarts) the host job; wait for it.
|
|
199
|
+
spawnHost: job ? () => undefined : () => spawnHost(stateDir()),
|
|
200
|
+
logHint: job
|
|
201
|
+
? `its launchd job ${job.label} (${job.mode}): ${product().cli} service status`
|
|
202
|
+
: path.join(stateDir(), "logs", product().cli, "sessions-host.log"),
|
|
203
|
+
});
|
|
204
|
+
}),
|
|
178
205
|
connectExisting:
|
|
179
206
|
d.connectExisting ??
|
|
180
207
|
(d.connect
|
|
@@ -314,6 +341,8 @@ export function contractInfo(s: SessionInfo): SessionInfo {
|
|
|
314
341
|
startedAt: s.startedAt,
|
|
315
342
|
...(s.exited ? { exited: true } : {}),
|
|
316
343
|
...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
344
|
+
// D20: where its host ran (Aqua: it ends at logout).
|
|
345
|
+
...(s.launchd ? { launchd: s.launchd } : {}),
|
|
317
346
|
};
|
|
318
347
|
}
|
|
319
348
|
|
|
@@ -677,10 +706,15 @@ function attachTerminal(
|
|
|
677
706
|
|
|
678
707
|
async function host(args: string[], d: Resolved): Promise<number> {
|
|
679
708
|
const where = "sessions host";
|
|
680
|
-
const p = parseArgs(where, args, { "--stop": false });
|
|
709
|
+
const p = parseArgs(where, args, { "--stop": false, "--stand-by": false });
|
|
681
710
|
noExtra(where, p);
|
|
682
711
|
if (!p.flags["--stop"])
|
|
683
|
-
return runSessionHost({
|
|
712
|
+
return runSessionHost({
|
|
713
|
+
stateDir: d.stateDir(),
|
|
714
|
+
pty: () => loadPty(),
|
|
715
|
+
// D18: the launchd job's: wait out a host started before it.
|
|
716
|
+
standBy: Boolean(p.flags["--stand-by"]),
|
|
717
|
+
});
|
|
684
718
|
const c = await d.connectExisting();
|
|
685
719
|
if (!c) {
|
|
686
720
|
d.out("no session host is running");
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ownerGithubLine,
|
|
3
3
|
recordOwnerGithub,
|
|
4
4
|
} from "../../shared/owner-github.js";
|
|
5
|
+
import { launchdSession, webUpWarning } from "../../shared/launchd-session.js";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
7
8
|
import * as fs from "fs";
|
|
@@ -1388,6 +1389,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1388
1389
|
}
|
|
1389
1390
|
|
|
1390
1391
|
if (!running) {
|
|
1392
|
+
// D20: started from an ssh (Background) shell, it lives there.
|
|
1393
|
+
const where = webUpWarning(launchdSession(), product().cli);
|
|
1394
|
+
if (where) console.log(chalk.yellow(`\n ⚠ ${where}`));
|
|
1391
1395
|
console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
|
|
1392
1396
|
try {
|
|
1393
1397
|
started = startDenWebServer(options);
|
package/src/ui/server/index.ts
CHANGED
|
@@ -918,6 +918,7 @@ import {
|
|
|
918
918
|
|
|
919
919
|
import { product } from "../../shared/product.js";
|
|
920
920
|
import { readBrand } from "../../shared/brand.js";
|
|
921
|
+
import { launchdSession } from "../../shared/launchd-session.js";
|
|
921
922
|
/** config `web.port` (user > team), falling back to the built-in default. */
|
|
922
923
|
function readWebPortSafe(): number {
|
|
923
924
|
try {
|
|
@@ -4422,6 +4423,9 @@ async function handleRequest(
|
|
|
4422
4423
|
// another distribution's server (see cli/server-identity.ts).
|
|
4423
4424
|
root: ROOT,
|
|
4424
4425
|
product: product().name,
|
|
4426
|
+
// D20: the launchd session it runs in (Aqua ends at logout); doctor
|
|
4427
|
+
// shows it. null off macOS.
|
|
4428
|
+
launchd: launchdSession(),
|
|
4425
4429
|
startedAt: new Date(SERVER_STARTED_AT).toISOString(),
|
|
4426
4430
|
uptimeMs: Date.now() - SERVER_STARTED_AT,
|
|
4427
4431
|
stale,
|