orcasynth 1.1.2 → 1.2.1
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/dist/cli/commands.js +8 -6
- package/dist/cli/index.js +31 -1
- package/dist/cli/install/agentClis.js +16 -0
- package/dist/cli/install/index.js +358 -0
- package/dist/cli/install/preflight.js +25 -0
- package/dist/cli/install/proxy.js +56 -0
- package/dist/cli/install/runner.js +28 -0
- package/dist/cli/install/serviceUser.js +27 -0
- package/dist/cli/install/systemdUnits.js +47 -0
- package/dist/cli/menu.js +19 -53
- package/dist/cli/setupWizard.js +49 -0
- package/dist/daemon/bootstrap.js +7 -3
- package/dist/overseer/overseerAgent.js +4 -5
- package/dist/overseer/pilotAgent.js +4 -6
- package/dist/spawn/spawn.js +5 -5
- package/package.json +1 -1
- package/web-dist/.next/BUILD_ID +1 -1
- package/web-dist/.next/build-manifest.json +3 -3
- package/web-dist/.next/server/app/_global-error.html +1 -1
- package/web-dist/.next/server/app/_global-error.rsc +1 -1
- package/web-dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.html +1 -1
- package/web-dist/.next/server/app/_not-found.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/web-dist/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.html +1 -1
- package/web-dist/.next/server/app/account.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/account/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/account.segments/account.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.html +1 -1
- package/web-dist/.next/server/app/dash.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/dash/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/dash.segments/dash.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.html +1 -1
- package/web-dist/.next/server/app/escalations.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/escalations/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/escalations.segments/escalations.segment.rsc +1 -1
- package/web-dist/.next/server/app/index.html +1 -1
- package/web-dist/.next/server/app/index.rsc +1 -1
- package/web-dist/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.html +1 -1
- package/web-dist/.next/server/app/kanban.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/kanban/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/kanban.segments/kanban.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.html +1 -1
- package/web-dist/.next/server/app/onboarding.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/onboarding/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/onboarding.segments/onboarding.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.html +1 -1
- package/web-dist/.next/server/app/projects.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/projects/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/projects.segments/projects.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.html +1 -1
- package/web-dist/.next/server/app/sessions.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/sessions/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/sessions.segments/sessions.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.html +1 -1
- package/web-dist/.next/server/app/settings.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.html +1 -1
- package/web-dist/.next/server/app/tasks.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.html +1 -1
- package/web-dist/.next/server/app/timeline.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/timeline/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/timeline.segments/timeline.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.html +1 -1
- package/web-dist/.next/server/app/users.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/_full.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/_head.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/_index.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/_tree.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/users/__PAGE__.segment.rsc +1 -1
- package/web-dist/.next/server/app/users.segments/users.segment.rsc +1 -1
- package/web-dist/.next/server/middleware-build-manifest.js +3 -3
- package/web-dist/.next/server/pages/404.html +1 -1
- package/web-dist/.next/server/pages/500.html +1 -1
- /package/web-dist/.next/static/{PsfkGpyVFB2njgcpLmAo6 → bvh5kJTXH2EYcTgp6Gxcw}/_buildManifest.js +0 -0
- /package/web-dist/.next/static/{PsfkGpyVFB2njgcpLmAo6 → bvh5kJTXH2EYcTgp6Gxcw}/_clientMiddlewareManifest.js +0 -0
- /package/web-dist/.next/static/{PsfkGpyVFB2njgcpLmAo6 → bvh5kJTXH2EYcTgp6Gxcw}/_ssgManifest.js +0 -0
package/dist/cli/commands.js
CHANGED
|
@@ -10,16 +10,18 @@ export function defaultLifecycleDeps(version) {
|
|
|
10
10
|
update: realUpdate,
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
-
/** Render a one-glance status block. A service is shown stopped, running-but-unhealthy, or healthy.
|
|
14
|
-
|
|
13
|
+
/** Render a one-glance status block. A service is shown stopped, running-but-unhealthy, or healthy.
|
|
14
|
+
* When `version` is given, a header line is prepended. */
|
|
15
|
+
export function formatStatus(s, version) {
|
|
15
16
|
const line = (name, svc, url) => {
|
|
16
17
|
if (!svc.running)
|
|
17
|
-
return ` ${name.padEnd(7)} ○
|
|
18
|
+
return ` ${name.padEnd(7)} ○ stopped`;
|
|
18
19
|
const dot = svc.healthy ? '●' : '◐';
|
|
19
20
|
const health = svc.healthy ? 'healthy' : 'starting…';
|
|
20
|
-
return ` ${name.padEnd(7)} ${dot}
|
|
21
|
+
return ` ${name.padEnd(7)} ${dot} running :${svc.port} ${health}${svc.healthy && url ? ` ${url}` : ''}`.trimEnd();
|
|
21
22
|
};
|
|
22
|
-
|
|
23
|
+
const body = [line('daemon', s.daemon, ''), line('web', s.web, `http://localhost:${s.web.port || 4500}`)];
|
|
24
|
+
return (version ? [` orcasynth v${version}`, '', ...body] : body).join('\n');
|
|
23
25
|
}
|
|
24
26
|
/** Dispatch the install-lifecycle commands. Returns true when handled, false for anything else (the
|
|
25
27
|
* caller then falls through to the daemon-backed API CLI). Lifecycle commands manage the daemon
|
|
@@ -38,7 +40,7 @@ export async function runLifecycle(cmd, env, deps) {
|
|
|
38
40
|
return true;
|
|
39
41
|
}
|
|
40
42
|
case 'status': {
|
|
41
|
-
deps.log(formatStatus(await deps.status(env)));
|
|
43
|
+
deps.log(formatStatus(await deps.status(env), deps.version));
|
|
42
44
|
return true;
|
|
43
45
|
}
|
|
44
46
|
case 'update': {
|
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,15 @@ import { OrcaClient } from './client.js';
|
|
|
7
7
|
import { defaultLifecycleDeps, runLifecycle } from './commands.js';
|
|
8
8
|
import { menu } from './menu.js';
|
|
9
9
|
const BASE = process.env.ORCA_URL ?? 'http://localhost:4400';
|
|
10
|
+
const USAGE = 'usage: orca [menu] | install | <up|down|status|update> | <ls|ready|sessions|close|plan submit|overseer poll|overseer decide>';
|
|
11
|
+
/** Commands that talk to the daemon API — only these justify auto-starting it. Everything else
|
|
12
|
+
* (help, unknown verbs) must NOT spawn a daemon: a stray detached daemon squats the port and starves
|
|
13
|
+
* the systemd-managed one into a restart loop. */
|
|
14
|
+
const API_COMMANDS = new Set(['ls', 'ready', 'sessions', 'close', 'plan', 'overseer']);
|
|
15
|
+
/** True only for verbs that need the daemon API up — the gate for ensureDaemon's auto-spawn. */
|
|
16
|
+
export function needsDaemon(cmd) {
|
|
17
|
+
return cmd !== undefined && API_COMMANDS.has(cmd);
|
|
18
|
+
}
|
|
10
19
|
/** This package's version, read from its package.json (two dirs up from dist/cli/index.js). */
|
|
11
20
|
function pkgVersion() {
|
|
12
21
|
try {
|
|
@@ -136,7 +145,7 @@ export async function run(argv, c, env) {
|
|
|
136
145
|
break;
|
|
137
146
|
}
|
|
138
147
|
default:
|
|
139
|
-
console.error(
|
|
148
|
+
console.error(USAGE);
|
|
140
149
|
process.exit(1);
|
|
141
150
|
}
|
|
142
151
|
}
|
|
@@ -149,10 +158,31 @@ async function main() {
|
|
|
149
158
|
await menu(process.env, version);
|
|
150
159
|
return;
|
|
151
160
|
}
|
|
161
|
+
// Help / bare non-TTY invocation: print usage and stop. Must NOT fall through to ensureDaemon.
|
|
162
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
|
|
163
|
+
console.log(USAGE);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
167
|
+
console.log(version);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// `orca install` is the root provisioning wizard — it sets up systemd, the proxy and the admin
|
|
171
|
+
// itself, so it must run BEFORE ensureDaemon (no auto-spawn) and before the lifecycle commands.
|
|
172
|
+
if (argv[0] === 'install') {
|
|
173
|
+
const { install } = await import('./install/index.js');
|
|
174
|
+
await install(argv.slice(1));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
152
177
|
// Install-lifecycle commands manage the daemon/web themselves — handle them BEFORE ensureDaemon so
|
|
153
178
|
// they don't trigger the API-CLI's auto-spawn.
|
|
154
179
|
if (await runLifecycle(argv[0], process.env, defaultLifecycleDeps(version)))
|
|
155
180
|
return;
|
|
181
|
+
// Only API commands may auto-start the daemon; an unknown verb errors out without spawning anything.
|
|
182
|
+
if (!needsDaemon(argv[0])) {
|
|
183
|
+
console.error(USAGE);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
156
186
|
await ensureDaemon();
|
|
157
187
|
const c = new OrcaClient(BASE, process.env.ORCA_TOKEN);
|
|
158
188
|
await run(argv, c, process.env);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const AGENT_CLIS = [
|
|
2
|
+
{ id: 'claude', bin: 'claude', pkg: '@anthropic-ai/claude-code' },
|
|
3
|
+
{ id: 'opencode', bin: 'opencode', pkg: 'opencode-ai' },
|
|
4
|
+
{ id: 'codex', bin: 'codex', pkg: '@openai/codex' },
|
|
5
|
+
];
|
|
6
|
+
/** Resolve each CLI on the SERVICE USER's PATH (not root's) — that's who runs the agents, and where
|
|
7
|
+
* their `… login` auth must live. */
|
|
8
|
+
export async function detectAgentClis(r, asUser) {
|
|
9
|
+
return Promise.all(AGENT_CLIS.map(async (c) => {
|
|
10
|
+
const path = await r.which(c.bin, asUser);
|
|
11
|
+
return { ...c, installed: path !== null, path };
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
export function installCommand(cli) {
|
|
15
|
+
return { cmd: 'npm', args: ['install', '-g', cli.pkg] };
|
|
16
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, resolve, join } from 'node:path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import { realRunner } from './runner.js';
|
|
5
|
+
import { preflight, preflightBlockers } from './preflight.js';
|
|
6
|
+
import { ensureServiceUser, userHome } from './serviceUser.js';
|
|
7
|
+
import { detectAgentClis, installCommand } from './agentClis.js';
|
|
8
|
+
import { daemonUnit, webUnit } from './systemdUnits.js';
|
|
9
|
+
import { detectProxy, nginxVhost, apacheVhost, certbotCommand } from './proxy.js';
|
|
10
|
+
import { applySetup, buildSetupPlan, isFirstRun } from '../setup.js';
|
|
11
|
+
import { runSetupWizard } from '../setupWizard.js';
|
|
12
|
+
const DAEMON_PORT = Number(process.env.ORCA_PORT ?? 4400);
|
|
13
|
+
const WEB_PORT = Number(process.env.ORCA_WEB_PORT ?? 4500);
|
|
14
|
+
// ── package + npm path resolution ────────────────────────────────────────────
|
|
15
|
+
/** Absolute paths into the globally-installed package — this file lives at
|
|
16
|
+
* <pkgRoot>/dist/cli/install/index.js, so the daemon entry and web bundle resolve relative to it. */
|
|
17
|
+
function packagePaths() {
|
|
18
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
19
|
+
return { daemonEntry: join(pkgRoot, 'dist', 'daemon', 'index.js'), webServer: join(pkgRoot, 'web-dist', 'server.js') };
|
|
20
|
+
}
|
|
21
|
+
/** npm's global bin dir (where the `orca` symlink + globally-installed agent CLIs land). */
|
|
22
|
+
async function npmGlobalBin(r) {
|
|
23
|
+
const res = await r.exec('npm', ['prefix', '-g']);
|
|
24
|
+
return join(res.stdout.trim() || '/usr/local', 'bin');
|
|
25
|
+
}
|
|
26
|
+
// ── small helpers ────────────────────────────────────────────────────────────
|
|
27
|
+
const base = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
28
|
+
function bail(v) {
|
|
29
|
+
if (p.isCancel(v)) {
|
|
30
|
+
p.cancel('Installation cancelled.');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function step(label, fn) {
|
|
35
|
+
// Off a TTY (unattended / CI / piped logs) a spinner just spams frames — emit one line per step.
|
|
36
|
+
if (!process.stdout.isTTY) {
|
|
37
|
+
try {
|
|
38
|
+
const out = await fn();
|
|
39
|
+
p.log.success(label);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
p.log.error(`${label} — failed`);
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const s = p.spinner();
|
|
48
|
+
s.start(label);
|
|
49
|
+
try {
|
|
50
|
+
const out = await fn();
|
|
51
|
+
s.stop(`${label} ✓`);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
s.stop(`${label} ✗`);
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Run a command and throw with its stderr when it fails — used for the system mutations where a
|
|
60
|
+
* non-zero exit must abort the wizard rather than silently continue. */
|
|
61
|
+
async function must(r, cmd, args, opts) {
|
|
62
|
+
const res = await r.exec(cmd, args, opts);
|
|
63
|
+
if (res.code !== 0)
|
|
64
|
+
throw new Error(`${cmd} ${args.join(' ')} failed: ${(res.stderr || res.stdout).trim() || res.code}`);
|
|
65
|
+
}
|
|
66
|
+
/** Poll the daemon's /setup endpoint until it answers (services just came up) or we give up. */
|
|
67
|
+
async function waitForDaemon(tries = 40) {
|
|
68
|
+
for (let i = 0; i < tries; i++) {
|
|
69
|
+
try {
|
|
70
|
+
if ((await fetch(`${base}/setup`)).ok)
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch { /* not up yet */ }
|
|
74
|
+
await new Promise((res) => setTimeout(res, 500));
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** End-to-end check: the admin can authenticate against the running daemon. */
|
|
79
|
+
async function loginSmokeTest(username, password) {
|
|
80
|
+
const res = await fetch(`${base}/auth/login`, {
|
|
81
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ username, password }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new Error(`login returned ${res.status}`);
|
|
86
|
+
const body = await res.json();
|
|
87
|
+
if (!body.token)
|
|
88
|
+
throw new Error('login returned no token');
|
|
89
|
+
}
|
|
90
|
+
// ── prompt-free executors (shared by interactive + unattended) ───────────────
|
|
91
|
+
async function aptInstall(r, ...pkgs) {
|
|
92
|
+
await must(r, 'apt-get', ['update']);
|
|
93
|
+
await must(r, 'apt-get', ['install', '-y', ...pkgs]);
|
|
94
|
+
}
|
|
95
|
+
/** Write + enable the two systemd units and verify they came active. */
|
|
96
|
+
async function provisionSystemd(r, user, home) {
|
|
97
|
+
const { daemonEntry, webServer } = packagePaths();
|
|
98
|
+
const params = {
|
|
99
|
+
user, home, nodePath: process.execPath, daemonEntry, webServer,
|
|
100
|
+
npmGlobalBin: await npmGlobalBin(r), daemonPort: DAEMON_PORT, webPort: WEB_PORT,
|
|
101
|
+
};
|
|
102
|
+
// Ensure the data tree exists and is owned by the service user before first boot.
|
|
103
|
+
await must(r, 'mkdir', ['-p', join(home, '.config', 'orca', 'logs')]);
|
|
104
|
+
await must(r, 'chown', ['-R', `${user}:`, join(home, '.config', 'orca')]);
|
|
105
|
+
await r.writeFile('/etc/systemd/system/orca-daemon.service', daemonUnit(params));
|
|
106
|
+
await r.writeFile('/etc/systemd/system/orca-web.service', webUnit(params));
|
|
107
|
+
await must(r, 'systemctl', ['daemon-reload']);
|
|
108
|
+
await must(r, 'systemctl', ['enable', '--now', 'orca-daemon.service']);
|
|
109
|
+
await must(r, 'systemctl', ['enable', '--now', 'orca-web.service']);
|
|
110
|
+
for (const svc of ['orca-daemon', 'orca-web']) {
|
|
111
|
+
const res = await r.exec('systemctl', ['is-active', svc]);
|
|
112
|
+
if (res.stdout.trim() !== 'active')
|
|
113
|
+
throw new Error(`${svc} did not start (journalctl -u ${svc})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Detect the installed reverse proxy, installing the preferred one when none is present. */
|
|
117
|
+
async function resolveProxy(r, preference) {
|
|
118
|
+
const existing = await detectProxy(r);
|
|
119
|
+
if (existing)
|
|
120
|
+
return existing;
|
|
121
|
+
await aptInstall(r, preference === 'nginx' ? 'nginx' : 'apache2');
|
|
122
|
+
return preference;
|
|
123
|
+
}
|
|
124
|
+
/** Render the vhost for the domain and make the proxy serve it. */
|
|
125
|
+
async function configureVhost(r, kind, domain) {
|
|
126
|
+
if (kind === 'nginx') {
|
|
127
|
+
await r.writeFile('/etc/nginx/sites-available/orca.conf', nginxVhost(domain, WEB_PORT));
|
|
128
|
+
await must(r, 'ln', ['-sf', '/etc/nginx/sites-available/orca.conf', '/etc/nginx/sites-enabled/orca.conf']);
|
|
129
|
+
await must(r, 'nginx', ['-t']);
|
|
130
|
+
await must(r, 'systemctl', ['reload', 'nginx']);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
await r.writeFile('/etc/apache2/sites-available/orca.conf', apacheVhost(domain, WEB_PORT));
|
|
134
|
+
await must(r, 'a2enmod', ['proxy', 'proxy_http']);
|
|
135
|
+
await must(r, 'a2ensite', ['orca']);
|
|
136
|
+
await must(r, 'systemctl', ['reload', 'apache2']);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Install certbot if needed and obtain + install a Let's Encrypt certificate. */
|
|
140
|
+
async function obtainTls(r, kind, domain, email) {
|
|
141
|
+
if (!(await r.which('certbot'))) {
|
|
142
|
+
await aptInstall(r, 'certbot', kind === 'nginx' ? 'python3-certbot-nginx' : 'python3-certbot-apache');
|
|
143
|
+
}
|
|
144
|
+
const { cmd, args } = certbotCommand(kind, domain, email ?? undefined);
|
|
145
|
+
await must(r, cmd, args);
|
|
146
|
+
}
|
|
147
|
+
/** Create the first admin from the plan (only when the daemon has no users yet) and prove login. */
|
|
148
|
+
async function provisionAdmin(answers) {
|
|
149
|
+
if (!(await isFirstRun(fetch, base))) {
|
|
150
|
+
p.log.info('Admin already exists — skipping account creation.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await applySetup(fetch, base, buildSetupPlan(answers));
|
|
154
|
+
await loginSmokeTest(answers.username, answers.password);
|
|
155
|
+
}
|
|
156
|
+
/** Provision a box from a fully-resolved plan. Used directly by the unattended path; the interactive
|
|
157
|
+
* path drives the same executors with spinners and inline prompts. */
|
|
158
|
+
async function execute(r, plan) {
|
|
159
|
+
if (plan.installTmux)
|
|
160
|
+
await step('Installing tmux', () => aptInstall(r, 'tmux'));
|
|
161
|
+
const { home } = await step(`Service user "${plan.user.username}"`, () => ensureServiceUser(r, plan.user));
|
|
162
|
+
for (const id of plan.agents) {
|
|
163
|
+
const { cmd, args } = installCommand({ id, bin: id, pkg: agentPkg(id) });
|
|
164
|
+
await step(`Installing ${id}`, () => must(r, cmd, args));
|
|
165
|
+
}
|
|
166
|
+
await step('Configuring systemd services', () => provisionSystemd(r, plan.user.username, home));
|
|
167
|
+
const ready = await step('Waiting for the daemon', () => waitForDaemon());
|
|
168
|
+
if (!ready)
|
|
169
|
+
throw new Error('daemon did not become reachable — check: journalctl -u orca-daemon');
|
|
170
|
+
if (plan.domain) {
|
|
171
|
+
const kind = await step('Configuring reverse proxy', async () => {
|
|
172
|
+
const k = await resolveProxy(r, plan.proxyPreference);
|
|
173
|
+
await configureVhost(r, k, plan.domain);
|
|
174
|
+
return k;
|
|
175
|
+
});
|
|
176
|
+
if (plan.tls)
|
|
177
|
+
await step('Requesting HTTPS certificate', () => obtainTls(r, kind, plan.domain, plan.email));
|
|
178
|
+
}
|
|
179
|
+
if (plan.admin)
|
|
180
|
+
await step('Creating admin + verifying login', () => provisionAdmin(plan.admin));
|
|
181
|
+
}
|
|
182
|
+
/** npm package for an agent CLI id (so the executor needn't carry the full AgentCli around). */
|
|
183
|
+
function agentPkg(id) {
|
|
184
|
+
const map = { claude: '@anthropic-ai/claude-code', opencode: 'opencode-ai', codex: '@openai/codex' };
|
|
185
|
+
const pkg = map[id];
|
|
186
|
+
if (!pkg)
|
|
187
|
+
throw new Error(`unknown agent CLI: ${id}`);
|
|
188
|
+
return pkg;
|
|
189
|
+
}
|
|
190
|
+
// ── unattended front-end ─────────────────────────────────────────────────────
|
|
191
|
+
function flag(args, name) {
|
|
192
|
+
const i = args.indexOf(name);
|
|
193
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
194
|
+
}
|
|
195
|
+
/** Build a plan from CLI flags for `--unattended`. Resolves create-vs-existing from whether the user
|
|
196
|
+
* already exists, so the same command is idempotent across re-runs. */
|
|
197
|
+
async function planFromArgs(r, args) {
|
|
198
|
+
const username = flag(args, '--user') ?? 'orca';
|
|
199
|
+
const exists = (await userHome(r, username)) !== null;
|
|
200
|
+
const agentsRaw = flag(args, '--agents');
|
|
201
|
+
const agents = !agentsRaw || agentsRaw === 'none' ? []
|
|
202
|
+
: agentsRaw === 'all' ? ['claude', 'opencode', 'codex']
|
|
203
|
+
: agentsRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
204
|
+
const domain = flag(args, '--domain') ?? null;
|
|
205
|
+
const adminUser = flag(args, '--admin-user');
|
|
206
|
+
const adminPass = flag(args, '--admin-pass');
|
|
207
|
+
const admin = adminUser && adminPass
|
|
208
|
+
? { username: adminUser, password: adminPass, apiUrl: flag(args, '--llm-url') ?? 'https://api.openai.com/v1', apiKey: flag(args, '--llm-key') ?? '', model: flag(args, '--llm-model') ?? 'gpt-4o-mini' }
|
|
209
|
+
: null;
|
|
210
|
+
return {
|
|
211
|
+
installTmux: !args.includes('--no-tmux'),
|
|
212
|
+
user: { mode: exists ? 'existing' : 'create', username },
|
|
213
|
+
agents,
|
|
214
|
+
domain,
|
|
215
|
+
proxyPreference: flag(args, '--proxy') === 'apache' ? 'apache' : 'nginx',
|
|
216
|
+
tls: domain !== null && !args.includes('--no-tls'),
|
|
217
|
+
email: flag(args, '--email') ?? null,
|
|
218
|
+
admin,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// ── interactive front-end ────────────────────────────────────────────────────
|
|
222
|
+
async function chooseServiceUser() {
|
|
223
|
+
const mode = await p.select({
|
|
224
|
+
message: 'Which user should the ORCA services and agents run as?',
|
|
225
|
+
options: [
|
|
226
|
+
{ value: 'create', label: 'Create a dedicated "orca" system user', hint: 'recommended' },
|
|
227
|
+
{ value: 'existing', label: 'Use an existing user' },
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
bail(mode);
|
|
231
|
+
const name = await p.text({
|
|
232
|
+
message: mode === 'existing' ? 'Existing username' : 'New username',
|
|
233
|
+
initialValue: mode === 'existing' ? '' : 'orca',
|
|
234
|
+
validate: (v) => (mode === 'existing' && !(v ?? '').trim() ? 'Required' : undefined),
|
|
235
|
+
});
|
|
236
|
+
bail(name);
|
|
237
|
+
return { mode: mode, username: name.trim() || 'orca' };
|
|
238
|
+
}
|
|
239
|
+
async function chooseAgents(r, user) {
|
|
240
|
+
const detected = await detectAgentClis(r, user);
|
|
241
|
+
const installed = detected.filter((c) => c.installed).map((c) => c.id);
|
|
242
|
+
const missing = detected.filter((c) => !c.installed);
|
|
243
|
+
if (installed.length)
|
|
244
|
+
p.log.success(`Found agent CLIs: ${installed.join(', ')}`);
|
|
245
|
+
if (!missing.length)
|
|
246
|
+
return [];
|
|
247
|
+
const pick = await p.multiselect({
|
|
248
|
+
message: 'Install missing agent CLIs? (space to toggle, enter to confirm)',
|
|
249
|
+
required: false,
|
|
250
|
+
options: missing.map((c) => ({ value: c.id, label: c.id, hint: c.pkg })),
|
|
251
|
+
});
|
|
252
|
+
if (p.isCancel(pick))
|
|
253
|
+
return [];
|
|
254
|
+
return pick;
|
|
255
|
+
}
|
|
256
|
+
async function chooseProxy(r) {
|
|
257
|
+
const domain = await p.text({
|
|
258
|
+
message: 'Domain for the web UI (blank to skip the reverse proxy and serve on localhost only)',
|
|
259
|
+
placeholder: 'orca.example.com',
|
|
260
|
+
});
|
|
261
|
+
bail(domain);
|
|
262
|
+
if (!domain.trim())
|
|
263
|
+
return { domain: null, proxyPreference: 'nginx', tls: false, email: null };
|
|
264
|
+
let proxyPreference = 'nginx';
|
|
265
|
+
if (!(await detectProxy(r))) {
|
|
266
|
+
const which = await p.select({
|
|
267
|
+
message: 'No reverse proxy found. Install one?',
|
|
268
|
+
options: [{ value: 'nginx', label: 'nginx', hint: 'recommended' }, { value: 'apache', label: 'apache2' }],
|
|
269
|
+
});
|
|
270
|
+
bail(which);
|
|
271
|
+
proxyPreference = which;
|
|
272
|
+
}
|
|
273
|
+
const wantTls = await p.confirm({ message: `Obtain a free HTTPS certificate for ${domain.trim()} via Let's Encrypt?` });
|
|
274
|
+
if (p.isCancel(wantTls) || !wantTls)
|
|
275
|
+
return { domain: domain.trim(), proxyPreference, tls: false, email: null };
|
|
276
|
+
const email = await p.text({ message: 'Email for renewal notices (blank to register without email)', placeholder: 'you@example.com' });
|
|
277
|
+
bail(email);
|
|
278
|
+
return { domain: domain.trim(), proxyPreference, tls: true, email: email.trim() || null };
|
|
279
|
+
}
|
|
280
|
+
// ── entry point ──────────────────────────────────────────────────────────────
|
|
281
|
+
/** Human recap of what the wizard is about to do — shown for confirmation before anything is touched. */
|
|
282
|
+
function planSummary(plan) {
|
|
283
|
+
const pad = (s) => s.padEnd(9);
|
|
284
|
+
const web = plan.domain
|
|
285
|
+
? `${plan.proxyPreference} → ${plan.domain}${plan.tls ? ' + HTTPS (Let’s Encrypt)' : ' (HTTP only)'}`
|
|
286
|
+
: `localhost only — http://127.0.0.1:${WEB_PORT} (no reverse proxy)`;
|
|
287
|
+
return [
|
|
288
|
+
`${pad('User')}${plan.user.mode === 'create' ? `create system user "${plan.user.username}"` : `existing user "${plan.user.username}"`}`,
|
|
289
|
+
`${pad('Agents')}${plan.agents.length ? plan.agents.join(', ') : 'none (install later)'}`,
|
|
290
|
+
`${pad('tmux')}${plan.installTmux ? 'install' : 'present / skipped'}`,
|
|
291
|
+
`${pad('Web')}${web}`,
|
|
292
|
+
`${pad('Admin')}${plan.admin ? plan.admin.username : 'create interactively once the daemon is up'}`,
|
|
293
|
+
].join('\n');
|
|
294
|
+
}
|
|
295
|
+
/** `orca install` — provision a fresh Debian/Ubuntu box. Run as root. Pass `--unattended` (with flags)
|
|
296
|
+
* for a non-interactive install; otherwise an interactive wizard collects every answer. */
|
|
297
|
+
export async function install(args = []) {
|
|
298
|
+
const r = realRunner();
|
|
299
|
+
const unattended = args.includes('--unattended');
|
|
300
|
+
p.intro(`🐋 orca install${unattended ? ' (unattended)' : ''}`);
|
|
301
|
+
const pf = await preflight(r);
|
|
302
|
+
const blockers = preflightBlockers(pf);
|
|
303
|
+
if (blockers.length) {
|
|
304
|
+
blockers.forEach((b) => p.log.error(b));
|
|
305
|
+
p.outro('Cannot continue.');
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
if (pf.tmux)
|
|
309
|
+
p.log.success('tmux present');
|
|
310
|
+
let plan;
|
|
311
|
+
if (unattended) {
|
|
312
|
+
plan = await planFromArgs(r, args);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
let installTmux = false;
|
|
316
|
+
if (!pf.tmux) {
|
|
317
|
+
const wantTmux = await p.confirm({ message: 'tmux is required to run agents and is not installed. Install it now?' });
|
|
318
|
+
installTmux = !p.isCancel(wantTmux) && wantTmux === true;
|
|
319
|
+
if (!installTmux)
|
|
320
|
+
p.log.warn('Continuing without tmux — agents will not run until it is installed.');
|
|
321
|
+
}
|
|
322
|
+
const user = await chooseServiceUser();
|
|
323
|
+
const agents = await chooseAgents(r, user.username);
|
|
324
|
+
const proxy = await chooseProxy(r);
|
|
325
|
+
// Admin is created via the shared wizard AFTER the daemon is up, so collect it there instead.
|
|
326
|
+
plan = { installTmux, user, agents, ...proxy, admin: null };
|
|
327
|
+
}
|
|
328
|
+
// Recap everything before touching the system — last chance to back out.
|
|
329
|
+
p.note(planSummary(plan), 'Install plan');
|
|
330
|
+
if (!unattended) {
|
|
331
|
+
const go = await p.confirm({ message: 'Proceed with installation?' });
|
|
332
|
+
if (p.isCancel(go) || !go) {
|
|
333
|
+
p.cancel('Nothing was changed.');
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await execute(r, plan);
|
|
338
|
+
// Interactive: now that the daemon is live, run the shared first-run wizard for the admin + LLM.
|
|
339
|
+
let adminUser = plan.admin?.username ?? null;
|
|
340
|
+
if (!unattended) {
|
|
341
|
+
p.log.step('Create the first admin account');
|
|
342
|
+
const creds = await runSetupWizard(base);
|
|
343
|
+
if (creds) {
|
|
344
|
+
adminUser = creds.username;
|
|
345
|
+
await step('Verifying login', () => loginSmokeTest(creds.username, creds.password));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const url = plan.domain ? (plan.tls ? `https://${plan.domain}` : `http://${plan.domain}`) : `http://127.0.0.1:${WEB_PORT}`;
|
|
349
|
+
const summary = [
|
|
350
|
+
`Open ${url}`,
|
|
351
|
+
adminUser ? `Sign in ${adminUser}` : 'Sign in create an admin in the web UI',
|
|
352
|
+
`Status systemctl status orca-daemon orca-web`,
|
|
353
|
+
`Logs journalctl -u orca-daemon -f`,
|
|
354
|
+
`Restart systemctl restart orca-daemon orca-web`,
|
|
355
|
+
].join('\n');
|
|
356
|
+
p.note(summary, 'ORCA is ready 🐋');
|
|
357
|
+
p.outro(`Done — ORCA is live at ${url}`);
|
|
358
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MIN_NODE_MAJOR = 22;
|
|
2
|
+
export async function preflight(r) {
|
|
3
|
+
const id = await r.exec('id', ['-u']);
|
|
4
|
+
const node = await r.exec('node', ['-v']);
|
|
5
|
+
const version = node.stdout.trim();
|
|
6
|
+
const major = Number(version.replace(/^v/, '').split('.')[0]) || 0;
|
|
7
|
+
return {
|
|
8
|
+
isRoot: id.stdout.trim() === '0',
|
|
9
|
+
pkgManager: (await r.which('apt-get')) ? 'apt' : null,
|
|
10
|
+
node: { ok: major >= MIN_NODE_MAJOR, version },
|
|
11
|
+
tmux: (await r.which('tmux')) !== null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Hard blockers (must be empty to proceed). tmux is NOT a blocker — the wizard offers to apt-install
|
|
15
|
+
* it — so it isn't listed here. */
|
|
16
|
+
export function preflightBlockers(p) {
|
|
17
|
+
const out = [];
|
|
18
|
+
if (!p.isRoot)
|
|
19
|
+
out.push('Must run as root — try: sudo orca install');
|
|
20
|
+
if (!p.pkgManager)
|
|
21
|
+
out.push('Unsupported OS: orca install needs apt (Debian/Ubuntu) in this version');
|
|
22
|
+
if (!p.node.ok)
|
|
23
|
+
out.push(`Node ${MIN_NODE_MAJOR}+ required (found ${p.node.version || 'none'})`);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Which reverse proxy is installed, preferring nginx. null when neither — the wizard then offers to
|
|
2
|
+
* apt-install one. */
|
|
3
|
+
export async function detectProxy(r) {
|
|
4
|
+
if (await r.which('nginx'))
|
|
5
|
+
return 'nginx';
|
|
6
|
+
if (await r.which('apache2'))
|
|
7
|
+
return 'apache';
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
/** nginx vhost. `proxy_buffering off` + a long read timeout keep the SSE event stream (`/events`,
|
|
11
|
+
* proxied via the web's `/api`) flowing instead of being buffered/idle-closed. certbot --nginx
|
|
12
|
+
* rewrites this to add the :443 server and the HTTP→HTTPS redirect. */
|
|
13
|
+
export function nginxVhost(domain, webPort) {
|
|
14
|
+
return `server {
|
|
15
|
+
listen 80;
|
|
16
|
+
listen [::]:80;
|
|
17
|
+
server_name ${domain};
|
|
18
|
+
|
|
19
|
+
location / {
|
|
20
|
+
proxy_pass http://127.0.0.1:${webPort};
|
|
21
|
+
proxy_http_version 1.1;
|
|
22
|
+
proxy_set_header Host $host;
|
|
23
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
24
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
25
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
26
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
27
|
+
proxy_set_header Connection "";
|
|
28
|
+
proxy_buffering off;
|
|
29
|
+
proxy_read_timeout 3600s;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
/** apache vhost. Needs mod_proxy + mod_proxy_http (the wizard enables them). certbot --apache adds TLS. */
|
|
35
|
+
export function apacheVhost(domain, webPort) {
|
|
36
|
+
return `<VirtualHost *:80>
|
|
37
|
+
ServerName ${domain}
|
|
38
|
+
ProxyPreserveHost On
|
|
39
|
+
ProxyPass / http://127.0.0.1:${webPort}/
|
|
40
|
+
ProxyPassReverse / http://127.0.0.1:${webPort}/
|
|
41
|
+
# SSE: do not buffer the event stream
|
|
42
|
+
SetEnv proxy-sendchunked 1
|
|
43
|
+
ProxyTimeout 3600
|
|
44
|
+
</VirtualHost>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
/** certbot invocation for the chosen plugin: obtain + install the cert and add an HTTP→HTTPS redirect,
|
|
48
|
+
* non-interactively. With an email it registers normally; without, registers email-less. */
|
|
49
|
+
export function certbotCommand(kind, domain, email) {
|
|
50
|
+
const plugin = kind === 'nginx' ? '--nginx' : '--apache';
|
|
51
|
+
const reg = email ? ['-m', email] : ['--register-unsafely-without-email'];
|
|
52
|
+
return {
|
|
53
|
+
cmd: 'certbot',
|
|
54
|
+
args: [plugin, '-d', domain, '--redirect', ...reg, '--agree-tos', '--non-interactive'],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { writeFile, access } from 'node:fs/promises';
|
|
3
|
+
export function realRunner() {
|
|
4
|
+
const run = (cmd, args, input) => new Promise((resolve) => {
|
|
5
|
+
const child = execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
6
|
+
const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
|
|
7
|
+
resolve({ code, stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
|
|
8
|
+
});
|
|
9
|
+
if (input !== undefined) {
|
|
10
|
+
child.stdin?.end(input);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
exec: (cmd, args, opts) => opts?.user
|
|
15
|
+
// -H so $HOME is the target user's (agent CLIs read ~/.config etc); login shell for full PATH.
|
|
16
|
+
? run('sudo', ['-u', opts.user, '-H', 'bash', '-lc', [cmd, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')], opts.input)
|
|
17
|
+
: run(cmd, args, opts?.input),
|
|
18
|
+
which: async (cmd, asUser) => {
|
|
19
|
+
const r = asUser
|
|
20
|
+
? await run('sudo', ['-u', asUser, '-H', 'bash', '-lc', `command -v '${cmd}'`])
|
|
21
|
+
: await run('bash', ['-lc', `command -v '${cmd}'`]);
|
|
22
|
+
const out = r.stdout.trim();
|
|
23
|
+
return r.code === 0 && out ? out : null;
|
|
24
|
+
},
|
|
25
|
+
writeFile: (path, content) => writeFile(path, content, 'utf8'),
|
|
26
|
+
exists: (path) => access(path).then(() => true, () => false),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** HOME directory of a user from getent, or null when the user doesn't exist. */
|
|
2
|
+
export async function userHome(r, username) {
|
|
3
|
+
const res = await r.exec('getent', ['passwd', username]);
|
|
4
|
+
if (res.code !== 0)
|
|
5
|
+
return null;
|
|
6
|
+
const home = res.stdout.trim().split(':')[5];
|
|
7
|
+
return home || null;
|
|
8
|
+
}
|
|
9
|
+
/** Create the service user (idempotent) or validate the chosen existing one, returning its resolved
|
|
10
|
+
* username + HOME. A created user is a `--system` account with its own HOME and a real shell (so
|
|
11
|
+
* `sudo -u … -H bash -lc` gives the agent CLIs a normal environment). */
|
|
12
|
+
export async function ensureServiceUser(r, choice) {
|
|
13
|
+
const existingHome = await userHome(r, choice.username);
|
|
14
|
+
if (choice.mode === 'existing') {
|
|
15
|
+
if (!existingHome)
|
|
16
|
+
throw new Error(`user '${choice.username}' does not exist`);
|
|
17
|
+
return { username: choice.username, home: existingHome };
|
|
18
|
+
}
|
|
19
|
+
if (!existingHome) {
|
|
20
|
+
const home = `/var/lib/${choice.username}`;
|
|
21
|
+
const res = await r.exec('useradd', ['--system', '--create-home', '--home-dir', home, '--shell', '/bin/bash', choice.username]);
|
|
22
|
+
if (res.code !== 0)
|
|
23
|
+
throw new Error(`useradd failed: ${res.stderr.trim() || res.code}`);
|
|
24
|
+
return { username: choice.username, home };
|
|
25
|
+
}
|
|
26
|
+
return { username: choice.username, home: existingHome };
|
|
27
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Pure renderers for the two systemd unit files `orca install` writes. Kept string-only and
|
|
2
|
+
* side-effect-free so they're unit-tested without touching /etc; the wizard writes + enables them. */
|
|
3
|
+
const BASE_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
4
|
+
export function daemonUnit(p) {
|
|
5
|
+
return `[Unit]
|
|
6
|
+
Description=ORCA daemon (REST API)
|
|
7
|
+
After=network.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
User=${p.user}
|
|
12
|
+
Environment=ORCA_CLI=orca
|
|
13
|
+
Environment=ORCA_DB=${p.home}/.config/orca/orca.db
|
|
14
|
+
Environment=ORCA_LOG_DIR=${p.home}/.config/orca/logs
|
|
15
|
+
Environment=ORCA_PORT=${p.daemonPort}
|
|
16
|
+
Environment=ORCA_HOST=127.0.0.1
|
|
17
|
+
Environment=PATH=${p.npmGlobalBin}:${BASE_PATH}
|
|
18
|
+
ExecStart=${p.nodePath} ${p.daemonEntry}
|
|
19
|
+
Restart=on-failure
|
|
20
|
+
RestartSec=3
|
|
21
|
+
|
|
22
|
+
[Install]
|
|
23
|
+
WantedBy=multi-user.target
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
export function webUnit(p) {
|
|
27
|
+
return `[Unit]
|
|
28
|
+
Description=ORCA web UI
|
|
29
|
+
After=network.target orca-daemon.service
|
|
30
|
+
Wants=orca-daemon.service
|
|
31
|
+
|
|
32
|
+
[Service]
|
|
33
|
+
Type=simple
|
|
34
|
+
User=${p.user}
|
|
35
|
+
Environment=PORT=${p.webPort}
|
|
36
|
+
Environment=HOSTNAME=127.0.0.1
|
|
37
|
+
Environment=ORCA_DAEMON_URL=http://127.0.0.1:${p.daemonPort}
|
|
38
|
+
Environment=ORCA_LOG_DIR=${p.home}/.config/orca/logs
|
|
39
|
+
Environment=PATH=${p.npmGlobalBin}:${BASE_PATH}
|
|
40
|
+
ExecStart=${p.nodePath} ${p.webServer}
|
|
41
|
+
Restart=on-failure
|
|
42
|
+
RestartSec=3
|
|
43
|
+
|
|
44
|
+
[Install]
|
|
45
|
+
WantedBy=multi-user.target
|
|
46
|
+
`;
|
|
47
|
+
}
|