ework-aio 0.1.17 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -20
- package/bin/ework-aio +3 -439
- package/package.json +6 -3
- package/src/cli.ts +467 -0
- package/src/commands/config.ts +208 -0
- package/src/commands/env.ts +27 -0
- package/src/commands/install.ts +637 -0
- package/src/commands/lifecycle.ts +206 -0
- package/src/commands/logs.ts +71 -0
- package/src/commands/uninstall.ts +64 -0
- package/src/config.ts +88 -0
- package/src/env.ts +219 -0
- package/src/log.ts +97 -0
- package/src/opencode-config.ts +104 -0
- package/src/paths.ts +88 -0
- package/src/pidfile.ts +164 -0
- package/src/preflight.ts +48 -0
- package/src/systemd.ts +234 -0
- package/src/types.ts +95 -0
- package/bin/install.sh +0 -1200
package/src/systemd.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// systemd unit file generation + systemctl wrapper. Opt-in only — never
|
|
2
|
+
// invoked from default install path. Replaces bash here-doc + systemctl
|
|
3
|
+
// calls with TS string templates + spawnSync.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { InstallError } from "./log.ts";
|
|
9
|
+
|
|
10
|
+
export type ServiceName = "ework-web" | "ework-daemon";
|
|
11
|
+
|
|
12
|
+
export type SystemdScope = "user" | "system";
|
|
13
|
+
|
|
14
|
+
export interface UnitContext {
|
|
15
|
+
user: string;
|
|
16
|
+
group: string;
|
|
17
|
+
binPath: string; // resolved binary path (e.g. /home/u/.bun/bin/bun)
|
|
18
|
+
mainScript: string; // entry script (e.g. /usr/lib/node_modules/ework-web/bin/ework-web.js)
|
|
19
|
+
envFile: string;
|
|
20
|
+
workingDirectory: string;
|
|
21
|
+
// Absolute path the service should pipe stdout/stderr to. Must match the
|
|
22
|
+
// PID-file mode log path (paths.webLogFile / paths.daemonLogFile) so
|
|
23
|
+
// `ework-aio logs <svc>` reads the same file regardless of how the
|
|
24
|
+
// service was started.
|
|
25
|
+
logFile: string;
|
|
26
|
+
restart?: "always" | "on-failure" | "no";
|
|
27
|
+
description?: string;
|
|
28
|
+
// Drives WantedBy: system → multi-user.target (the sysv-init successor);
|
|
29
|
+
// user → default.target (graphical-session.user). Defaults to "user" for
|
|
30
|
+
// back-compat with callers that haven't been updated.
|
|
31
|
+
scope?: SystemdScope;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// User/Group values are interpolated verbatim into the unit file. Reject
|
|
35
|
+
// anything outside systemd's POSIX-ish portable set so a typo can't smuggle
|
|
36
|
+
// in a unit-file directive (e.g. `User=alice\nRestart=always`).
|
|
37
|
+
const SYSTEMD_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
38
|
+
|
|
39
|
+
// systemd allows %-specifiers (Unit, Spec, %h, %H, ...) but they're not
|
|
40
|
+
// meaningful for our paths and look like a typo or injection attempt.
|
|
41
|
+
// Paths must be absolute; we never want a relative path here.
|
|
42
|
+
function escapePath(p: string, field: string): string {
|
|
43
|
+
if (!path.isAbsolute(p)) {
|
|
44
|
+
throw new InstallError(
|
|
45
|
+
`systemd ${field} must be an absolute path (got '${p}')`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (p.includes("\n") || p.includes("\r")) {
|
|
49
|
+
throw new InstallError(
|
|
50
|
+
`systemd ${field} must not contain newlines (got ${JSON.stringify(p)})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
// Quoting rules: systemd treats "…" in unit files literally for path-valued
|
|
54
|
+
// keys. Empty values are forbidden by the unit grammar. Spaces, quotes,
|
|
55
|
+
// and dollar signs are kept inside the quoted form.
|
|
56
|
+
if (/^[A-Za-z0-9._\/\-]+$/.test(p)) return p;
|
|
57
|
+
if (p.includes('"') || p.includes("\\")) {
|
|
58
|
+
throw new InstallError(
|
|
59
|
+
`systemd ${field} contains unsupported characters (quotes/backslashes): ${JSON.stringify(p)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return `"${p}"`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateName(name: string, field: string): string {
|
|
66
|
+
if (!SYSTEMD_NAME_RE.test(name)) {
|
|
67
|
+
throw new InstallError(
|
|
68
|
+
`systemd ${field}='${name}' must match ${SYSTEMD_NAME_RE.source}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function generateUnitFile(svc: ServiceName, ctx: UnitContext): string {
|
|
75
|
+
const restart = ctx.restart ?? "always";
|
|
76
|
+
const description = ctx.description ?? (svc === "ework-web" ? "ework-web (issue tracker)" : "ework-daemon (AI bridge)");
|
|
77
|
+
const scope: SystemdScope = ctx.scope ?? "user";
|
|
78
|
+
const wantedBy = scope === "system" ? "multi-user.target" : "default.target";
|
|
79
|
+
|
|
80
|
+
const user = validateName(ctx.user, "User");
|
|
81
|
+
const group = validateName(ctx.group, "Group");
|
|
82
|
+
const workingDirectory = escapePath(ctx.workingDirectory, "WorkingDirectory");
|
|
83
|
+
const envFile = escapePath(ctx.envFile, "EnvironmentFile");
|
|
84
|
+
const logFile = escapePath(ctx.logFile, "StandardOutput/StandardError target");
|
|
85
|
+
const binPath = escapePath(ctx.binPath, "ExecStart binary");
|
|
86
|
+
const mainScript = escapePath(ctx.mainScript, "ExecStart main script");
|
|
87
|
+
|
|
88
|
+
return `# Generated by ework-aio. Edits will be overwritten on next install.
|
|
89
|
+
[Unit]
|
|
90
|
+
Description=${description}
|
|
91
|
+
After=network.target
|
|
92
|
+
|
|
93
|
+
[Service]
|
|
94
|
+
Type=simple
|
|
95
|
+
User=${user}
|
|
96
|
+
Group=${group}
|
|
97
|
+
WorkingDirectory=${workingDirectory}
|
|
98
|
+
EnvironmentFile=${envFile}
|
|
99
|
+
ExecStart=${binPath} ${mainScript}
|
|
100
|
+
Restart=${restart}
|
|
101
|
+
RestartSec=3
|
|
102
|
+
StandardOutput=append:${logFile}
|
|
103
|
+
StandardError=append:${logFile}
|
|
104
|
+
|
|
105
|
+
[Install]
|
|
106
|
+
WantedBy=${wantedBy}
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeUnitFile(unitFile: string, content: string): Promise<void> {
|
|
111
|
+
const dir = path.dirname(unitFile);
|
|
112
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
113
|
+
const tmp = path.join(dir, `.unit.tmp.${process.pid}.${Date.now()}`);
|
|
114
|
+
await fs.promises.writeFile(tmp, content, { mode: 0o644 });
|
|
115
|
+
try {
|
|
116
|
+
await fs.promises.rename(tmp, unitFile);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SystemctlOptions {
|
|
124
|
+
scope: "user" | "system";
|
|
125
|
+
// If scope=user, whether to also run `loginctl enable-linger` (only meaningful for user mode).
|
|
126
|
+
enableLinger?: boolean;
|
|
127
|
+
// For tests: override systemctl binary path.
|
|
128
|
+
systemctlBin?: string;
|
|
129
|
+
// For tests: capture stdout/stderr of invocations.
|
|
130
|
+
captureIO?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SystemctlResult {
|
|
134
|
+
exitCode: number;
|
|
135
|
+
stdout: string;
|
|
136
|
+
stderr: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function systemctlBase(opts: SystemctlOptions): string {
|
|
140
|
+
return opts.systemctlBin ?? "systemctl";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function systemctlArgs(opts: SystemctlOptions, args: string[]): string[] {
|
|
144
|
+
return opts.scope === "user" ? ["--user", ...args] : args;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function runSystemctl(args: string[], opts: SystemctlOptions): SystemctlResult {
|
|
148
|
+
const bin = systemctlBase(opts);
|
|
149
|
+
const fullArgs = systemctlArgs(opts, args);
|
|
150
|
+
const r = spawnSync(bin, fullArgs, {
|
|
151
|
+
encoding: "utf8",
|
|
152
|
+
// For --user scope, XDG_RUNTIME_DIR must be set; caller's env has it.
|
|
153
|
+
env: process.env,
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
exitCode: r.status ?? -1,
|
|
157
|
+
stdout: r.stdout ?? "",
|
|
158
|
+
stderr: r.stderr ?? "",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// install_unit: writes unit file, runs daemon-reload + enable. Atomic — if
|
|
163
|
+
// any systemctl call fails, returns the error without leaving half-state.
|
|
164
|
+
// Caller decides whether to roll back (delete the unit file).
|
|
165
|
+
export async function installUnit(svc: ServiceName, unitFile: string, content: string, opts: SystemctlOptions): Promise<void> {
|
|
166
|
+
await writeUnitFile(unitFile, content);
|
|
167
|
+
|
|
168
|
+
const reload = runSystemctl(["daemon-reload"], opts);
|
|
169
|
+
if (reload.exitCode !== 0) {
|
|
170
|
+
throw new InstallError(
|
|
171
|
+
`systemctl daemon-reload failed for ${svc}: ${reload.stderr.trim() || reload.stdout.trim()}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const enable = runSystemctl(["enable", `${svc}.service`], opts);
|
|
176
|
+
if (enable.exitCode !== 0) {
|
|
177
|
+
throw new InstallError(
|
|
178
|
+
`systemctl enable ${svc} failed: ${enable.stderr.trim() || enable.stdout.trim()}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function startUnit(svc: ServiceName, opts: SystemctlOptions): Promise<void> {
|
|
184
|
+
const r = runSystemctl(["start", `${svc}.service`], opts);
|
|
185
|
+
if (r.exitCode !== 0) {
|
|
186
|
+
throw new InstallError(
|
|
187
|
+
`systemctl start ${svc} failed: ${r.stderr.trim() || r.stdout.trim()}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function stopUnit(svc: ServiceName, opts: SystemctlOptions): Promise<void> {
|
|
193
|
+
// stop is best-effort — ignore "unit not loaded" errors
|
|
194
|
+
const r = runSystemctl(["stop", `${svc}.service`], opts);
|
|
195
|
+
if (r.exitCode !== 0 && !/not loaded/i.test(r.stderr)) {
|
|
196
|
+
throw new InstallError(
|
|
197
|
+
`systemctl stop ${svc} failed: ${r.stderr.trim() || r.stdout.trim()}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function disableUnit(svc: ServiceName, opts: SystemctlOptions): Promise<void> {
|
|
203
|
+
const r = runSystemctl(["disable", `${svc}.service`], opts);
|
|
204
|
+
if (r.exitCode !== 0 && !/not loaded/i.test(r.stderr)) {
|
|
205
|
+
throw new InstallError(
|
|
206
|
+
`systemctl disable ${svc} failed: ${r.stderr.trim() || r.stdout.trim()}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type ServiceState = "active" | "inactive" | "failed" | "unknown";
|
|
212
|
+
|
|
213
|
+
export function getUnitState(svc: ServiceName, opts: SystemctlOptions): ServiceState {
|
|
214
|
+
const r = runSystemctl(["is-active", `${svc}.service`], opts);
|
|
215
|
+
const out = r.stdout.trim().toLowerCase();
|
|
216
|
+
if (r.exitCode === 0) return "active";
|
|
217
|
+
if (out === "inactive" || out === "deactivating") return "inactive";
|
|
218
|
+
if (out === "failed") return "failed";
|
|
219
|
+
return "unknown";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function removeUnit(svc: ServiceName, unitFile: string, opts: SystemctlOptions): Promise<void> {
|
|
223
|
+
await disableUnit(svc, opts);
|
|
224
|
+
await stopUnit(svc, opts);
|
|
225
|
+
const r = runSystemctl(["daemon-reload"], opts);
|
|
226
|
+
if (r.exitCode !== 0) {
|
|
227
|
+
// reload failure is non-fatal during uninstall
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await fs.promises.unlink(unitFile);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Shared types for the CLI surface. Kept in a dedicated module so commands
|
|
2
|
+
// don't need to import from cli.ts (which would create a cycle: cli imports
|
|
3
|
+
// commands, commands import types from cli).
|
|
4
|
+
//
|
|
5
|
+
// Design note: GlobalOptions is the parsed-argv shape every command receives.
|
|
6
|
+
// CLI stays the only place that knows about flag parsing; commands consume
|
|
7
|
+
// the typed result.
|
|
8
|
+
|
|
9
|
+
export type Scope = "user" | "system";
|
|
10
|
+
|
|
11
|
+
// Which service(s) a command targets. `ps` and `status` use "both";
|
|
12
|
+
// `start web`/`stop daemon` use a single target.
|
|
13
|
+
export type ServiceTarget = "web" | "daemon" | "both";
|
|
14
|
+
|
|
15
|
+
export interface GlobalOptions {
|
|
16
|
+
// Install-time knobs (defaults live in src/types.ts DEFAULTS).
|
|
17
|
+
workPort: number; // --port
|
|
18
|
+
daemonPort: number; // --daemon-port
|
|
19
|
+
botName: string; // --bot-name
|
|
20
|
+
scope: Scope; // --user | --system
|
|
21
|
+
useSystemd: boolean; // true when invoked as `install systemd`
|
|
22
|
+
assumeYes: boolean; // --yes / -y
|
|
23
|
+
allowRoot: boolean; // --allow-root
|
|
24
|
+
asUser?: string; // --as-user <login> (re-exec target)
|
|
25
|
+
noRestart: boolean; // --no-restart (config set)
|
|
26
|
+
noStart: boolean; // --no-start (install)
|
|
27
|
+
// Optional path overrides.
|
|
28
|
+
dataDir?: string; // --data-dir
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULTS = {
|
|
32
|
+
workPort: 3002,
|
|
33
|
+
daemonPort: 3101,
|
|
34
|
+
botName: "ework-daemon",
|
|
35
|
+
scope: "user" as Scope,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
// SETTABLE_KEYS: the allow-list for `config set`. Secrets (WORK_TOKEN,
|
|
39
|
+
// *_WEBHOOK_SECRET, BOT_TOKEN), DB paths, and the web<->daemon contract
|
|
40
|
+
// keys (GITEA_URL/TOKEN, WORK_DAEMON_WEBHOOK_*) are deliberately excluded
|
|
41
|
+
// — changing them by hand breaks the install.
|
|
42
|
+
//
|
|
43
|
+
// Each row mirrors the bash SETTABLE_KEYS array: KEY | SERVICE | DESCRIPTION.
|
|
44
|
+
// `propagate` is set for keys whose change requires updating the other
|
|
45
|
+
// service's .env (cross-link).
|
|
46
|
+
export interface SettableKeySpec {
|
|
47
|
+
key: string;
|
|
48
|
+
service: "web" | "daemon";
|
|
49
|
+
description: string;
|
|
50
|
+
// If set, when this key changes the listed cross-link key in the other
|
|
51
|
+
// service's .env is also rewritten. Used for WORK_PORT / DAEMON_PORT.
|
|
52
|
+
propagate?: {
|
|
53
|
+
targetService: "web" | "daemon";
|
|
54
|
+
targetKey: string;
|
|
55
|
+
template: (value: string) => string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const SETTABLE_KEYS: readonly SettableKeySpec[] = [
|
|
60
|
+
{ key: "WORK_PORT", service: "web", description: "ework-web listen port (default 3002)",
|
|
61
|
+
propagate: { targetService: "daemon", targetKey: "GITEA_URL", template: (v) => `http://127.0.0.1:${v}` } },
|
|
62
|
+
{ key: "WORK_HOST", service: "web", description: "ework-web bind address (default 127.0.0.1; 0.0.0.0 for LAN)" },
|
|
63
|
+
{ key: "WORK_OPERATOR_LOGIN", service: "web", description: "login auto-promoted to admin" },
|
|
64
|
+
{ key: "WORK_TRANSLATE_URL", service: "web", description: "OpenAI-compat /v1/chat/completions endpoint for translate" },
|
|
65
|
+
{ key: "WORK_TRANSLATE_MODEL", service: "web", description: "translate model name" },
|
|
66
|
+
{ key: "WORK_TTS_SPEED", service: "web", description: "TTS playback rate (default 1.0)" },
|
|
67
|
+
{ key: "WORK_FILE_ROOTS", service: "web", description: "comma-separated file-viewer roots" },
|
|
68
|
+
{ key: "WORK_COMMENT_SORT", service: "web", description: "comment sort order: desc|asc" },
|
|
69
|
+
{ key: "DAEMON_PORT", service: "daemon", description: "ework-daemon listen port (default 3101)",
|
|
70
|
+
propagate: { targetService: "web", targetKey: "WORK_DAEMON_WEBHOOK_URL", template: (v) => `http://127.0.0.1:${v}` } },
|
|
71
|
+
{ key: "DAEMON_HOST", service: "daemon", description: "ework-daemon bind address (default 127.0.0.1)" },
|
|
72
|
+
{ key: "OPENCODE_BINARY", service: "daemon", description: "opencode binary path" },
|
|
73
|
+
{ key: "OPENCODE_BASE_WORKDIR", service: "daemon", description: "opencode working directory base" },
|
|
74
|
+
{ key: "COMPLETION_CHECK_API_KEY", service: "daemon", description: "completion-check API key" },
|
|
75
|
+
{ key: "COMPLETION_CHECK_BASE_URL", service: "daemon", description: "completion-check API base URL" },
|
|
76
|
+
{ key: "COMPLETION_CHECK_MODEL", service: "daemon", description: "completion-check model name" },
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
// For a given key, which service(s) need to restart on change. WORK_PORT
|
|
80
|
+
// and DAEMON_PORT fan out to "both" because their cross-link touches the
|
|
81
|
+
// other service's .env.
|
|
82
|
+
export function serviceForKey(key: string): ServiceTarget | null {
|
|
83
|
+
if (key === "WORK_PORT" || key === "DAEMON_PORT") return "both";
|
|
84
|
+
for (const entry of SETTABLE_KEYS) {
|
|
85
|
+
if (entry.key === key) return entry.service;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findSettableKey(key: string): SettableKeySpec | null {
|
|
91
|
+
for (const entry of SETTABLE_KEYS) {
|
|
92
|
+
if (entry.key === key) return entry;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|