@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Recipe seam (see ../../CONTEXT.md).
|
|
3
|
+
*
|
|
4
|
+
* The per-repo Foreman config that says how to start the app in a Workspace: start command,
|
|
5
|
+
* readiness URL, and the port variable it honours, plus the commands the Gates run. It lives under
|
|
6
|
+
* the `foreman` key of the analyzer-written `.opsee/config` (yaml and its json copy), beside the
|
|
7
|
+
* analyzer's own `commands`, so the orchestrator's `OpseeConfig` round-trips it untouched.
|
|
8
|
+
*
|
|
9
|
+
* The Foreman substitutes a Worker-specific port (`{port}` in any field, or the port variable as
|
|
10
|
+
* `$PORT` / `${PORT}`), starts the app with that variable set, and waits on the readiness URL
|
|
11
|
+
* before a Verifier is launched (spec story 37).
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parseJsonObject } from "../init/text.js";
|
|
16
|
+
import { OPSEE_CONFIG_JSON, OPSEE_CONFIG_YAML, readOpseeConfigFiles, readYamlBlock } from "../opsee-config.js";
|
|
17
|
+
import { PROCESS_GROUPS, stopProcessGroup } from "./process-group.js";
|
|
18
|
+
import { trackLiveProcess } from "./worker-process.js";
|
|
19
|
+
|
|
20
|
+
/** The deterministic checks a Gate runs after a Hand-off; each is a shell command, pass or fail by
|
|
21
|
+
* exit code. A missing command means the repo has no such Gate. */
|
|
22
|
+
export interface RunRecipeGates {
|
|
23
|
+
test?: string;
|
|
24
|
+
lint?: string;
|
|
25
|
+
typecheck?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a Task gets browser verification after its Gates (core/verifier.ts): `auto` reads the
|
|
29
|
+
* Task's Verification section for a UI journey, `browser` verifies every Task in the browser,
|
|
30
|
+
* `none` stops every Task at its Gates. */
|
|
31
|
+
export const VERIFY_MODES = ["auto", "browser", "none"] as const;
|
|
32
|
+
export type VerifyMode = (typeof VERIFY_MODES)[number];
|
|
33
|
+
|
|
34
|
+
export interface RunRecipe {
|
|
35
|
+
/** Shell command that serves the app; `{port}` and the port variable are substituted. */
|
|
36
|
+
start: string;
|
|
37
|
+
/** URL that answers once the app is up; `{port}` and the port variable are substituted. */
|
|
38
|
+
readinessUrl: string;
|
|
39
|
+
/** Name of the environment variable set to the port when `start` runs. */
|
|
40
|
+
portEnv: string;
|
|
41
|
+
gates: RunRecipeGates;
|
|
42
|
+
/** Absent means `auto`; `opsee init` never writes it, a human adds it to the block. */
|
|
43
|
+
verify?: VerifyMode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `foreman` block as it sits in `.opsee/config` (snake_case, mirroring the analyzer's keys). */
|
|
47
|
+
export interface RunRecipeConfig {
|
|
48
|
+
start?: string;
|
|
49
|
+
readiness_url?: string;
|
|
50
|
+
port_env?: string;
|
|
51
|
+
gates?: RunRecipeGates;
|
|
52
|
+
verify?: VerifyMode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The block's `verify` value, `auto` when absent; an unknown word is an error the caller names. */
|
|
56
|
+
export function verifyModeOf(raw: unknown): VerifyMode {
|
|
57
|
+
if (raw === undefined || raw === null || raw === "") return "auto";
|
|
58
|
+
if (typeof raw === "string" && (VERIFY_MODES as readonly string[]).includes(raw)) return raw as VerifyMode;
|
|
59
|
+
throw new Error(`foreman.verify must be one of ${VERIFY_MODES.join(", ")}, not ${JSON.stringify(raw)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const RECIPE_KEY = "foreman";
|
|
63
|
+
export const DEFAULT_PORT_ENV = "PORT";
|
|
64
|
+
export const DEFAULT_READINESS_URL = "http://localhost:{port}/";
|
|
65
|
+
|
|
66
|
+
/** Expands the port into `text`: the `{port}` placeholder, and `$VAR` / `${VAR}` for the recipe's
|
|
67
|
+
* port variable (a longer name such as `$PORTS` is a different variable and stays). */
|
|
68
|
+
export function substitutePort(text: string, port: number, portEnv: string): string {
|
|
69
|
+
const name = portEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
return text
|
|
71
|
+
.replace(/\{port\}/g, String(port))
|
|
72
|
+
.replace(new RegExp(`\\$\\{${name}\\}`, "g"), String(port))
|
|
73
|
+
.replace(new RegExp(`\\$${name}(?![A-Za-z0-9_])`, "g"), String(port));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
77
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Validates a raw `foreman` block into a RunRecipe; `source` names the file for the error. */
|
|
81
|
+
export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
|
|
82
|
+
if (!raw || typeof raw !== "object") throw new Error(`${source} has no foreman block; run opsee init to write the Run Recipe.`);
|
|
83
|
+
const block = raw as Record<string, unknown>;
|
|
84
|
+
const start = nonEmptyString(block.start);
|
|
85
|
+
const readinessUrl = nonEmptyString(block.readiness_url);
|
|
86
|
+
if (!start) throw new Error(`${source}: foreman.start is missing; it is the command that serves the app.`);
|
|
87
|
+
if (!readinessUrl) throw new Error(`${source}: foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
|
|
88
|
+
const gatesRaw = block.gates && typeof block.gates === "object" ? (block.gates as Record<string, unknown>) : {};
|
|
89
|
+
const gates: RunRecipeGates = {};
|
|
90
|
+
for (const name of ["test", "lint", "typecheck"] as const) {
|
|
91
|
+
const command = nonEmptyString(gatesRaw[name]);
|
|
92
|
+
if (command) gates[name] = command;
|
|
93
|
+
}
|
|
94
|
+
let verify: VerifyMode;
|
|
95
|
+
try {
|
|
96
|
+
verify = verifyModeOf(block.verify);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(`${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
}
|
|
100
|
+
return { start, readinessUrl, portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV, gates, ...(block.verify === undefined ? {} : { verify }) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolves the Run Recipe of the repo at `root`. The JSON copy is authoritative when present
|
|
104
|
+
* (it is the analyzer's programmatic form); otherwise the `foreman` block is read from the YAML. */
|
|
105
|
+
export function loadRunRecipe(root: string): RunRecipe {
|
|
106
|
+
const files = readOpseeConfigFiles(root);
|
|
107
|
+
if (files.json !== null) {
|
|
108
|
+
const parsed = parseJsonObject(files.json);
|
|
109
|
+
if ("reason" in parsed) throw new Error(`${OPSEE_CONFIG_JSON} is not valid JSON: ${parsed.reason}`);
|
|
110
|
+
return recipeFromConfig(parsed.root[RECIPE_KEY], OPSEE_CONFIG_JSON);
|
|
111
|
+
}
|
|
112
|
+
if (files.yaml === null) throw new Error(`${join(root, OPSEE_CONFIG_YAML)}: no ${OPSEE_CONFIG_YAML} here; run opsee init to write the Run Recipe.`);
|
|
113
|
+
return recipeFromConfig(readYamlBlock(files.yaml, RECIPE_KEY) ?? undefined, OPSEE_CONFIG_YAML);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface StartOptions {
|
|
117
|
+
port: number;
|
|
118
|
+
/** Working directory the start command runs in: the Workspace root. */
|
|
119
|
+
cwd: string;
|
|
120
|
+
/** Receives the app's combined stdout and stderr, line by line, when given. */
|
|
121
|
+
onOutput?: (line: string) => void;
|
|
122
|
+
/** The app's environment before the port variable is set; this process's when unset. The
|
|
123
|
+
* Verifier hands in the Gates' filtered one (core/gates.ts `gateEnv`): the start command is
|
|
124
|
+
* code from the repository and sees no credential the Foreman holds. */
|
|
125
|
+
env?: Readonly<Record<string, string>>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface AppHandle {
|
|
129
|
+
/** The start command after port substitution: what was actually run. */
|
|
130
|
+
command: string;
|
|
131
|
+
/** The readiness URL after port substitution. */
|
|
132
|
+
readinessUrl: string;
|
|
133
|
+
pid: number | undefined;
|
|
134
|
+
/** Resolves with the exit code (null when killed by a signal) once the process is gone. */
|
|
135
|
+
exited: Promise<number | null>;
|
|
136
|
+
/** Fires when the process exits, with the reason; hand it to waitForReady so an app that dies
|
|
137
|
+
* before it is up fails the wait at once instead of at the timeout. */
|
|
138
|
+
exitSignal: AbortSignal;
|
|
139
|
+
/** SIGTERM to the whole process group, SIGKILL to whatever is still in it after `graceMs`
|
|
140
|
+
* (default 5s); resolves once the group is gone, not just the shell. Idempotent. */
|
|
141
|
+
stop: (graceMs?: number) => Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Starts the app described by `recipe` on `port`. The command runs through the shell (recipes
|
|
145
|
+
* are shell commands) in its own process group, so `stop` reaches the servers a `make` or `bun run`
|
|
146
|
+
* wrapper spawned underneath it. The child goes on this process's live registry
|
|
147
|
+
* (worker-process.ts `trackLiveProcess`) beside the Workers, so a Foreman that is signalled or
|
|
148
|
+
* exits stops the app with them rather than orphaning it. */
|
|
149
|
+
export function startApp(recipe: RunRecipe, options: StartOptions): AppHandle {
|
|
150
|
+
const command = substitutePort(recipe.start, options.port, recipe.portEnv);
|
|
151
|
+
const readinessUrl = substitutePort(recipe.readinessUrl, options.port, recipe.portEnv);
|
|
152
|
+
const child = spawn(command, {
|
|
153
|
+
cwd: options.cwd,
|
|
154
|
+
shell: true,
|
|
155
|
+
detached: PROCESS_GROUPS,
|
|
156
|
+
env: { ...(options.env ?? process.env), [recipe.portEnv]: String(options.port) },
|
|
157
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
158
|
+
});
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
let done = false;
|
|
161
|
+
const exited = new Promise<number | null>((resolve) => {
|
|
162
|
+
const finish = (code: number | null, reason: string) => {
|
|
163
|
+
if (done) return;
|
|
164
|
+
done = true;
|
|
165
|
+
controller.abort(new Error(reason));
|
|
166
|
+
resolve(code);
|
|
167
|
+
};
|
|
168
|
+
child.once("exit", (code, signal) => finish(code, signal ? `app exited on ${signal}` : `app exited with code ${code}`));
|
|
169
|
+
child.once("error", (error) => finish(null, `app could not start: ${error.message}`));
|
|
170
|
+
});
|
|
171
|
+
if (options.onOutput) {
|
|
172
|
+
const forward = (chunk: Buffer) => chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => options.onOutput?.(line));
|
|
173
|
+
child.stdout?.on("data", forward);
|
|
174
|
+
child.stderr?.on("data", forward);
|
|
175
|
+
} else {
|
|
176
|
+
child.stdout?.resume();
|
|
177
|
+
child.stderr?.resume();
|
|
178
|
+
}
|
|
179
|
+
const stop = (graceMs = 5_000) => stopProcessGroup(child, exited, graceMs, () => done);
|
|
180
|
+
trackLiveProcess({ child, settled: exited, stop: () => stop() });
|
|
181
|
+
return { command, readinessUrl, pid: child.pid, exited, exitSignal: controller.signal, stop };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface WaitOptions {
|
|
185
|
+
timeoutMs: number;
|
|
186
|
+
/** First retry delay; doubles each attempt up to `maxDelayMs` (defaults 100ms and 1s). */
|
|
187
|
+
initialDelayMs?: number;
|
|
188
|
+
maxDelayMs?: number;
|
|
189
|
+
/** Aborting ends the wait early with the signal's reason (see AppHandle.exitSignal). */
|
|
190
|
+
signal?: AbortSignal;
|
|
191
|
+
/** Test seam; defaults to the global fetch. */
|
|
192
|
+
fetch?: typeof fetch;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface Readiness {
|
|
196
|
+
attempts: number;
|
|
197
|
+
elapsedMs: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Polls `url` with exponential backoff until it answers with anything below HTTP 500 (a 404 still
|
|
201
|
+
* means the server is up), or throws once `timeoutMs` has elapsed or `signal` fires. */
|
|
202
|
+
export async function waitForReady(url: string, options: WaitOptions): Promise<Readiness> {
|
|
203
|
+
const started = Date.now();
|
|
204
|
+
const doFetch = options.fetch ?? fetch;
|
|
205
|
+
let delay = options.initialDelayMs ?? 100;
|
|
206
|
+
const maxDelay = options.maxDelayMs ?? 1_000;
|
|
207
|
+
let attempts = 0;
|
|
208
|
+
let last = "not attempted";
|
|
209
|
+
for (;;) {
|
|
210
|
+
if (options.signal?.aborted) throw abortError(options.signal, url, attempts, last);
|
|
211
|
+
attempts++;
|
|
212
|
+
try {
|
|
213
|
+
const response = await doFetch(url, { signal: requestSignal(options) });
|
|
214
|
+
if (response.status < 500) return { attempts, elapsedMs: Date.now() - started };
|
|
215
|
+
last = `HTTP ${response.status}`;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (options.signal?.aborted) throw abortError(options.signal, url, attempts, last);
|
|
218
|
+
last = describeFetchError(error);
|
|
219
|
+
}
|
|
220
|
+
const remaining = options.timeoutMs - (Date.now() - started);
|
|
221
|
+
if (remaining <= 0) {
|
|
222
|
+
throw new Error(`${url} not ready after ${options.timeoutMs}ms (${attempts} attempts; last: ${last})`);
|
|
223
|
+
}
|
|
224
|
+
// Never sleep past the deadline: the last attempt lands at the timeout, not up to a second early.
|
|
225
|
+
await sleep(Math.min(delay, remaining), options.signal);
|
|
226
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One request's signal: a per-request timeout, and the caller's signal so an app that exits
|
|
231
|
+
* mid-request ends the request at once rather than after the request timeout. */
|
|
232
|
+
function requestSignal(options: WaitOptions): AbortSignal {
|
|
233
|
+
const perRequest = Math.max(1_000, Math.min(5_000, options.timeoutMs));
|
|
234
|
+
if (!options.signal) return AbortSignal.timeout(perRequest);
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${perRequest}ms`)), perRequest);
|
|
237
|
+
const onAbort = () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
controller.abort(options.signal?.reason);
|
|
240
|
+
};
|
|
241
|
+
if (options.signal.aborted) onAbort();
|
|
242
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
243
|
+
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
244
|
+
return controller.signal;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function abortError(signal: AbortSignal, url: string, attempts: number, last: string): Error {
|
|
248
|
+
const reason = signal.reason instanceof Error ? signal.reason.message : String(signal.reason ?? "aborted");
|
|
249
|
+
return new Error(`${url}: gave up waiting, ${reason} (${attempts} attempts; last: ${last})`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function describeFetchError(error: unknown): string {
|
|
253
|
+
if (error instanceof Error) {
|
|
254
|
+
const cause = (error as Error & { cause?: unknown }).cause;
|
|
255
|
+
const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code: unknown }).code) : null;
|
|
256
|
+
return code ?? error.message;
|
|
257
|
+
}
|
|
258
|
+
return String(error);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
262
|
+
if (signal?.aborted) return Promise.resolve();
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
signal?.removeEventListener("abort", onAbort);
|
|
266
|
+
resolve();
|
|
267
|
+
}, ms);
|
|
268
|
+
const onAbort = () => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
resolve();
|
|
271
|
+
};
|
|
272
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service manager's view of the daemon (story 16): a launchd LaunchAgent on macOS, a systemd
|
|
3
|
+
* user unit on Linux, each starting `opsee foreman up` at login or boot and restarting it after a
|
|
4
|
+
* crash. Pure writers: given where node, the CLI, the checkout and the state directory are, they
|
|
5
|
+
* produce the file and the commands the service manager wants, and never run anything. The unit
|
|
6
|
+
* carries no credential: the daemon reads Accounts from its store (ADR-0001), and a key-by-name
|
|
7
|
+
* Account needs its variable in the service environment, which is the developer's to add.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** The identity the service manager knows the daemon by. */
|
|
12
|
+
export const LAUNCHD_LABEL = "io.opsee.foreman";
|
|
13
|
+
export const SYSTEMD_UNIT = "opsee-foreman.service";
|
|
14
|
+
|
|
15
|
+
/** The exit code `foreman up` leaves with when another Foreman holds the pid file (foreman-up.ts):
|
|
16
|
+
* not a crash, so systemd must not restart it (RestartPreventExitStatus). launchd cannot tell exit
|
|
17
|
+
* codes apart, so there KeepAlive retries it every ThrottleInterval until the other one stops. */
|
|
18
|
+
export const EXIT_ANOTHER_FOREMAN = 3;
|
|
19
|
+
|
|
20
|
+
/** How long both managers give the daemon to leave after SIGTERM, in seconds. On SIGTERM the
|
|
21
|
+
* daemon's process hooks SIGTERM every Worker it has running and SIGKILL them 5 s later
|
|
22
|
+
* (worker-process.ts KILL_GRACE_MS), and systemd's default KillMode=control-group signals the
|
|
23
|
+
* Workers at the same time anyway; the daemon then removes its pid file and leaves. The interrupted
|
|
24
|
+
* turn is resumed by session id on the next start (ADR-0009), so the budget is short: it covers
|
|
25
|
+
* the grace period, not a Worker turn. */
|
|
26
|
+
export const STOP_TIMEOUT_S = 30;
|
|
27
|
+
|
|
28
|
+
/** How long launchd waits before restarting the daemon after an unsuccessful exit. */
|
|
29
|
+
export const RESTART_INTERVAL_S = 10;
|
|
30
|
+
|
|
31
|
+
/** What both writers need to know about this machine. */
|
|
32
|
+
export interface ServiceTarget {
|
|
33
|
+
/** The user's home, where the unit lives. */
|
|
34
|
+
home: string;
|
|
35
|
+
/** The node binary that runs the CLI, absolute (process.execPath). */
|
|
36
|
+
node: string;
|
|
37
|
+
/** The CLI entry the unit runs, absolute (bin/opsee.js). */
|
|
38
|
+
bin: string;
|
|
39
|
+
/** The checkout the daemon serves: the unit's working directory. */
|
|
40
|
+
repoRoot: string;
|
|
41
|
+
/** The Foreman's local state directory (local-dir.ts): where the daemon's output goes. */
|
|
42
|
+
localDir: string;
|
|
43
|
+
/** The PATH the daemon inherits; the vendors' CLIs must be on it. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Further variables the daemon inherits, after PATH and HOME: the OPSEE_* overrides set where
|
|
46
|
+
* install ran, so the daemon talks to the same backend and files. Never a credential. */
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
/** The user id, for the launchd domain in the printed commands; undefined where node has none. */
|
|
49
|
+
uid?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One service-manager command, and the exit it may end with that is not a failure. */
|
|
53
|
+
export interface ServiceCommand {
|
|
54
|
+
argv: string[];
|
|
55
|
+
/** An exit that means "nothing to do" rather than "failed": the codes, an output that says the
|
|
56
|
+
* same, and what it means, printed in place of a failure. */
|
|
57
|
+
tolerated?: { codes: number[]; output?: RegExp; means: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A written unit: where it goes, what it says, and how the service manager is told about it. */
|
|
61
|
+
export interface ServiceUnit {
|
|
62
|
+
manager: "launchd" | "systemd";
|
|
63
|
+
path: string;
|
|
64
|
+
content: string;
|
|
65
|
+
/** Where the daemon's output goes once the service manager runs it. */
|
|
66
|
+
log: string;
|
|
67
|
+
/** Commands, in order, to load and start the unit after the file is written. A loaded copy is
|
|
68
|
+
* replaced: launchd is told to bootout before bootstrap, systemd to restart after enable, so a
|
|
69
|
+
* re-install takes effect instead of failing (bootstrap exits 5 on a loaded label) or leaving
|
|
70
|
+
* the old process running (enable --now does not restart). */
|
|
71
|
+
start: ServiceCommand[];
|
|
72
|
+
/** Commands, in order, to stop and unload the unit before the file is removed. */
|
|
73
|
+
stop: ServiceCommand[];
|
|
74
|
+
/** Notes the developer must read: what the unit does not do for them. */
|
|
75
|
+
notes: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function launchdPlistPath(home: string): string {
|
|
79
|
+
return join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function systemdUnitPath(home: string): string {
|
|
83
|
+
return join(home, ".config", "systemd", "user", SYSTEMD_UNIT);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where the service manager sends the daemon's stdout and stderr (one file, so a `tail -f` shows
|
|
87
|
+
* both), beside the Process Table. */
|
|
88
|
+
export function serviceLogPath(localDir: string): string {
|
|
89
|
+
return join(localDir, "foreman.log");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The extra variables in a stable order, so the file is the same for the same machine. */
|
|
93
|
+
function extraEnv(target: ServiceTarget): [string, string][] {
|
|
94
|
+
return Object.entries(target.env ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function xml(text: string): string {
|
|
98
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The launchd domain the agent lives in: the user's GUI session. */
|
|
102
|
+
function launchdDomain(uid: number): string {
|
|
103
|
+
return `gui/${uid}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The LaunchAgent: RunAtLoad starts it at login, KeepAlive on an unsuccessful exit restarts it
|
|
107
|
+
* after a crash but leaves a clean stop (exit 0 on SIGTERM) down, ThrottleInterval spaces those
|
|
108
|
+
* restarts so a daemon that keeps failing (the checkout gone, another Foreman holding the pid file)
|
|
109
|
+
* is retried every 10 s instead of in a tight loop, and ExitTimeOut gives it the same shutdown
|
|
110
|
+
* budget systemd does. Everything is an absolute path: launchd runs it with no shell and a bare
|
|
111
|
+
* environment. */
|
|
112
|
+
export function launchdPlist(target: ServiceTarget): string {
|
|
113
|
+
const log = serviceLogPath(target.localDir);
|
|
114
|
+
const args = [target.node, target.bin, "foreman", "up"];
|
|
115
|
+
return [
|
|
116
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
117
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
118
|
+
'<plist version="1.0">',
|
|
119
|
+
`<!-- Written by "opsee foreman service install". KeepAlive restarts any unsuccessful exit, exit ${EXIT_ANOTHER_FOREMAN} (another Foreman, started from a terminal, holds the pid file) included: stop that one first, or launchd retries every ${RESTART_INTERVAL_S} s. -->`,
|
|
120
|
+
"<dict>",
|
|
121
|
+
" <key>Label</key>",
|
|
122
|
+
` <string>${LAUNCHD_LABEL}</string>`,
|
|
123
|
+
" <key>ProgramArguments</key>",
|
|
124
|
+
" <array>",
|
|
125
|
+
...args.map((a) => ` <string>${xml(a)}</string>`),
|
|
126
|
+
" </array>",
|
|
127
|
+
" <key>WorkingDirectory</key>",
|
|
128
|
+
` <string>${xml(target.repoRoot)}</string>`,
|
|
129
|
+
" <key>RunAtLoad</key>",
|
|
130
|
+
" <true/>",
|
|
131
|
+
" <key>KeepAlive</key>",
|
|
132
|
+
" <dict>",
|
|
133
|
+
" <key>SuccessfulExit</key>",
|
|
134
|
+
" <false/>",
|
|
135
|
+
" </dict>",
|
|
136
|
+
" <key>ThrottleInterval</key>",
|
|
137
|
+
` <integer>${RESTART_INTERVAL_S}</integer>`,
|
|
138
|
+
" <key>ExitTimeOut</key>",
|
|
139
|
+
` <integer>${STOP_TIMEOUT_S}</integer>`,
|
|
140
|
+
" <key>StandardOutPath</key>",
|
|
141
|
+
` <string>${xml(log)}</string>`,
|
|
142
|
+
" <key>StandardErrorPath</key>",
|
|
143
|
+
` <string>${xml(log)}</string>`,
|
|
144
|
+
" <key>EnvironmentVariables</key>",
|
|
145
|
+
" <dict>",
|
|
146
|
+
" <key>PATH</key>",
|
|
147
|
+
` <string>${xml(target.path)}</string>`,
|
|
148
|
+
" <key>HOME</key>",
|
|
149
|
+
` <string>${xml(target.home)}</string>`,
|
|
150
|
+
...extraEnv(target).flatMap(([key, value]) => [` <key>${xml(key)}</key>`, ` <string>${xml(value)}</string>`]),
|
|
151
|
+
" </dict>",
|
|
152
|
+
"</dict>",
|
|
153
|
+
"</plist>",
|
|
154
|
+
"",
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A value systemd reads literally: `%` is a specifier (`%h`, `%i`) in Environment=, ExecStart=
|
|
159
|
+
* and WorkingDirectory=, so it is doubled. */
|
|
160
|
+
function unitLiteral(text: string): string {
|
|
161
|
+
return text.replace(/%/g, "%%");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One word of a systemd command line or Environment= assignment: `%` doubled, then quoted the
|
|
165
|
+
* way systemd's word splitter reads it (double quotes, with a backslash and the quote escaped).
|
|
166
|
+
* Not for WorkingDirectory=, which takes one path and does not strip quotes. */
|
|
167
|
+
function unitQuote(text: string): string {
|
|
168
|
+
return `"${unitLiteral(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The user unit: Restart=on-failure restarts a crash and leaves a clean stop down, exit 3 (another
|
|
172
|
+
* Foreman holds the pid file) is not retried, and default.target starts it with the user's
|
|
173
|
+
* session. The session only exists at boot when lingering is on; that is a note, not the unit's.
|
|
174
|
+
* No network ordering: network-online.target is a system target and a no-op in the user manager;
|
|
175
|
+
* the daemon retries the backend on its own each tick. */
|
|
176
|
+
export function systemdUnit(target: ServiceTarget): string {
|
|
177
|
+
return [
|
|
178
|
+
"[Unit]",
|
|
179
|
+
"Description=Opsee Foreman daemon (opsee foreman up)",
|
|
180
|
+
"",
|
|
181
|
+
"[Service]",
|
|
182
|
+
"Type=simple",
|
|
183
|
+
`WorkingDirectory=${unitLiteral(target.repoRoot)}`,
|
|
184
|
+
`ExecStart=${unitQuote(target.node)} ${unitQuote(target.bin)} foreman up`,
|
|
185
|
+
"Restart=on-failure",
|
|
186
|
+
"RestartSec=10",
|
|
187
|
+
// Another Foreman (started from a terminal) holds the pid file: not a crash, so no restart.
|
|
188
|
+
`RestartPreventExitStatus=${EXIT_ANOTHER_FOREMAN}`,
|
|
189
|
+
"KillSignal=SIGTERM",
|
|
190
|
+
// The daemon stops its Workers (SIGTERM, SIGKILL after 5 s) and leaves; the turn is resumed by
|
|
191
|
+
// session on the next start, so the budget covers the grace period, not a Worker turn.
|
|
192
|
+
`TimeoutStopSec=${STOP_TIMEOUT_S}`,
|
|
193
|
+
`Environment=${unitQuote(`PATH=${target.path}`)}`,
|
|
194
|
+
`Environment=${unitQuote(`HOME=${target.home}`)}`,
|
|
195
|
+
...extraEnv(target).map(([key, value]) => `Environment=${unitQuote(`${key}=${value}`)}`),
|
|
196
|
+
"",
|
|
197
|
+
"[Install]",
|
|
198
|
+
"WantedBy=default.target",
|
|
199
|
+
"",
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One command as a shell line, for printing: an argument with a space or a quote is quoted. */
|
|
204
|
+
export function shellLine(argv: string[]): string {
|
|
205
|
+
return argv.map((a) => (/[\s"'$`\\]/.test(a) ? `'${a.replace(/'/g, "'\\''")}'` : a)).join(" ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The unit for this platform, or undefined where no service manager is supported. On darwin the
|
|
209
|
+
* user id is required: the launchd domain is `gui/<uid>`, and a command line with a placeholder in
|
|
210
|
+
* it would be run as written, not through a shell. */
|
|
211
|
+
export function serviceUnitFor(platform: NodeJS.Platform, target: ServiceTarget): ServiceUnit | undefined {
|
|
212
|
+
const log = serviceLogPath(target.localDir);
|
|
213
|
+
if (platform === "darwin") {
|
|
214
|
+
if (target.uid === undefined) throw new Error("launchd needs the user id for its gui/<uid> domain, and this node reports none (process.getuid)");
|
|
215
|
+
const path = launchdPlistPath(target.home);
|
|
216
|
+
const domain = launchdDomain(target.uid);
|
|
217
|
+
const service = `${domain}/${LAUNCHD_LABEL}`;
|
|
218
|
+
return {
|
|
219
|
+
manager: "launchd",
|
|
220
|
+
path,
|
|
221
|
+
content: launchdPlist(target),
|
|
222
|
+
log,
|
|
223
|
+
start: [
|
|
224
|
+
// bootstrap fails (exit 5) on a label that is already loaded and launchd keeps the old plist;
|
|
225
|
+
// unload first, tolerating "not loaded".
|
|
226
|
+
{ argv: ["launchctl", "bootout", service], tolerated: { codes: [3, 5], output: /No such process/, means: "was not loaded" } },
|
|
227
|
+
{ argv: ["launchctl", "bootstrap", domain, path] },
|
|
228
|
+
],
|
|
229
|
+
stop: [{ argv: ["launchctl", "bootout", service], tolerated: { codes: [3], output: /No such process/, means: "was not loaded" } }],
|
|
230
|
+
notes: [
|
|
231
|
+
`launchd starts it at login and after a crash; a clean stop (launchctl bootout, or a SIGTERM) leaves it down until the next login or: launchctl kickstart -k ${service}`,
|
|
232
|
+
`KeepAlive also restarts exit ${EXIT_ANOTHER_FOREMAN} (a Foreman started from a terminal holds the pid file), every ${RESTART_INTERVAL_S} s until that one is stopped; stop it before loading the unit.`,
|
|
233
|
+
`Output: ${log}`,
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (platform === "linux") {
|
|
238
|
+
return {
|
|
239
|
+
manager: "systemd",
|
|
240
|
+
path: systemdUnitPath(target.home),
|
|
241
|
+
content: systemdUnit(target),
|
|
242
|
+
log: "journalctl --user -u opsee-foreman -f",
|
|
243
|
+
start: [
|
|
244
|
+
{ argv: ["systemctl", "--user", "daemon-reload"] },
|
|
245
|
+
{ argv: ["systemctl", "--user", "enable", SYSTEMD_UNIT] },
|
|
246
|
+
// restart, not enable --now: a running unit is replaced by the one just written.
|
|
247
|
+
{ argv: ["systemctl", "--user", "restart", SYSTEMD_UNIT] },
|
|
248
|
+
],
|
|
249
|
+
stop: [{ argv: ["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT] }],
|
|
250
|
+
notes: [
|
|
251
|
+
"A user unit only runs while you have a session; for it to start at boot and survive logout, enable lingering once: loginctl enable-linger $USER",
|
|
252
|
+
"Output: journalctl --user -u opsee-foreman -f",
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|