@task-handoff/server 0.0.3-alpha.3

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 ADDED
@@ -0,0 +1,3 @@
1
+ # @task-handoff/server
2
+
3
+ Complete TaskHandoff server package. Installing it installs the control plane, node agent, and controlled instance runtimes at the same version. Run `task-handoff-install-server` as root to create and start the systemd services.
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { spawnSync } = require("node:child_process");
6
+
7
+ function resolvePackageRoot(packageName) {
8
+ const manifest = require.resolve(`${packageName}/package.json`, { paths: [__dirname] });
9
+ return path.dirname(manifest);
10
+ }
11
+
12
+ function packageBin(packageName, binName) {
13
+ const packageRoot = resolvePackageRoot(packageName);
14
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
15
+ const relativeBin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName];
16
+ if (!relativeBin) {
17
+ throw new Error(`${packageName} does not provide the ${binName} executable.`);
18
+ }
19
+ return path.join(packageRoot, relativeBin);
20
+ }
21
+
22
+ let controlPlane;
23
+ let nodeAgent;
24
+ let controlledInstance;
25
+ try {
26
+ controlPlane = packageBin("@task-handoff/control-plane", "task-handoff-control-plane");
27
+ nodeAgent = packageBin("@task-handoff/node-agent", "task-handoff-node-agent");
28
+ controlledInstance = packageBin("@task-handoff/controlled-instance", "task-handoff-controlled-instance");
29
+ } catch (error) {
30
+ console.error(`TaskHandoff server package is incomplete: ${error.message}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const result = spawnSync(
35
+ path.join(__dirname, "task-handoff-install-server-services"),
36
+ [
37
+ "--control-plane-bin",
38
+ controlPlane,
39
+ "--node-agent-bin",
40
+ nodeAgent,
41
+ "--controlled-instance-bin",
42
+ controlledInstance,
43
+ ...process.argv.slice(2),
44
+ ],
45
+ { stdio: "inherit" },
46
+ );
47
+
48
+ if (result.error) {
49
+ console.error(`Failed to start the TaskHandoff service installer: ${result.error.message}`);
50
+ process.exit(1);
51
+ }
52
+ process.exit(result.status ?? 1);
@@ -0,0 +1,250 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ INSTALLER_BIN_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
5
+ REPO_DIR="$(CDPATH= cd -- "$INSTALLER_BIN_DIR/.." && pwd)"
6
+ TASK_HANDOFF_BIN=""
7
+ TASK_HANDOFF_COMMAND=""
8
+ CONTROL_PLANE_BIN=""
9
+ NODE_AGENT_BIN=""
10
+ CONTROLLED_INSTANCE_BIN=""
11
+ CONTROL_PLANE_COMMAND=""
12
+ NODE_AGENT_COMMAND=""
13
+ CONTROLLED_INSTANCE_COMMAND=""
14
+ SERVICE_USER="root"
15
+ ENV_DIR="/etc/task-handoff"
16
+ CONTROL_PLANE_DATA_DIR="/var/lib/task-handoff/control-plane"
17
+ NODE_AGENT_DATA_DIR="/var/lib/task-handoff/node-agent"
18
+ CONTROL_PLANE_HOST="0.0.0.0"
19
+ CONTROL_PLANE_PORT="8081"
20
+ NODE_AGENT_HOST="127.0.0.1"
21
+ NODE_AGENT_PORT="8091"
22
+ NODE_AGENT_IPC_PATH="/run/task-handoff/node-agent.sock"
23
+ AUTH_MODE="password"
24
+ STATIC_DIR=""
25
+
26
+ usage() {
27
+ cat <<'USAGE'
28
+ Usage: scripts/install-server-services.sh [options]
29
+
30
+ Installs server-side TaskHandoff services on a systemd host:
31
+ task-handoff-node-agent.service
32
+ task-handoff-control-plane.service
33
+
34
+ Options:
35
+ --repo-dir <path> Repository or unpacked release directory
36
+ --task-handoff-bin <path> Legacy combined task-handoff executable
37
+ --control-plane-bin <path> Control-plane executable
38
+ --node-agent-bin <path> Node-agent executable
39
+ --controlled-instance-bin <path> Controlled-instance executable for local runtimes
40
+ --service-user <user> systemd service user, default root
41
+ --control-plane-data-dir <path> Control-plane data directory
42
+ --node-agent-data-dir <path> Node-agent data directory
43
+ --control-plane-host <host> Control-plane bind host, default 0.0.0.0
44
+ --control-plane-port <port> Control-plane port, default 8081
45
+ --node-agent-host <host> Local node-agent bind host, default 127.0.0.1
46
+ --node-agent-port <port> Local node-agent port, default 8091
47
+ --node-agent-ipc-path <path> Local control socket, default /run/task-handoff/node-agent.sock
48
+ --auth-mode <mode> Control-plane auth mode: password or disabled
49
+ --static-dir <path> Built control-plane UI directory
50
+ USAGE
51
+ }
52
+
53
+ need_root() {
54
+ if [ "$(id -u)" != "0" ]; then
55
+ echo "This installer must run as root. Re-run with sudo." >&2
56
+ exit 1
57
+ fi
58
+ }
59
+
60
+ while [ "$#" -gt 0 ]; do
61
+ case "$1" in
62
+ --repo-dir) REPO_DIR="${2:-}"; shift 2 ;;
63
+ --task-handoff-bin) TASK_HANDOFF_BIN="${2:-}"; shift 2 ;;
64
+ --control-plane-bin) CONTROL_PLANE_BIN="${2:-}"; shift 2 ;;
65
+ --node-agent-bin) NODE_AGENT_BIN="${2:-}"; shift 2 ;;
66
+ --controlled-instance-bin) CONTROLLED_INSTANCE_BIN="${2:-}"; shift 2 ;;
67
+ --service-user) SERVICE_USER="${2:-}"; shift 2 ;;
68
+ --control-plane-data-dir) CONTROL_PLANE_DATA_DIR="${2:-}"; shift 2 ;;
69
+ --node-agent-data-dir) NODE_AGENT_DATA_DIR="${2:-}"; shift 2 ;;
70
+ --control-plane-host) CONTROL_PLANE_HOST="${2:-}"; shift 2 ;;
71
+ --control-plane-port) CONTROL_PLANE_PORT="${2:-}"; shift 2 ;;
72
+ --node-agent-host) NODE_AGENT_HOST="${2:-}"; shift 2 ;;
73
+ --node-agent-port) NODE_AGENT_PORT="${2:-}"; shift 2 ;;
74
+ --node-agent-ipc-path) NODE_AGENT_IPC_PATH="${2:-}"; shift 2 ;;
75
+ --auth-mode) AUTH_MODE="${2:-}"; shift 2 ;;
76
+ --static-dir) STATIC_DIR="${2:-}"; shift 2 ;;
77
+ -h|--help) usage; exit 0 ;;
78
+ *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
79
+ esac
80
+ done
81
+
82
+ need_root
83
+
84
+ if [ "$AUTH_MODE" != "password" ] && [ "$AUTH_MODE" != "disabled" ]; then
85
+ echo "--auth-mode must be password or disabled." >&2
86
+ exit 1
87
+ fi
88
+
89
+ if ! command -v systemctl >/dev/null 2>&1; then
90
+ echo "systemd is required for this installer." >&2
91
+ exit 1
92
+ fi
93
+ if ! command -v node >/dev/null 2>&1; then
94
+ echo "node is required for this installer." >&2
95
+ exit 1
96
+ fi
97
+
98
+ resolve_command() {
99
+ candidate="$1"
100
+ if [ -f "$candidate" ]; then
101
+ if [ -x "$candidate" ]; then
102
+ printf '%s\n' "$candidate"
103
+ else
104
+ if ! command -v node >/dev/null 2>&1; then
105
+ echo "node is required to run $candidate." >&2
106
+ exit 1
107
+ fi
108
+ printf '%s %s\n' "$(command -v node)" "$candidate"
109
+ fi
110
+ elif command -v "$candidate" >/dev/null 2>&1; then
111
+ command -v "$candidate"
112
+ else
113
+ echo "TaskHandoff executable was not found: $candidate" >&2
114
+ exit 1
115
+ fi
116
+ }
117
+
118
+ if [ -n "$TASK_HANDOFF_BIN" ]; then
119
+ TASK_HANDOFF_COMMAND="$(resolve_command "$TASK_HANDOFF_BIN")"
120
+ elif [ -z "$CONTROL_PLANE_BIN" ] && [ -z "$NODE_AGENT_BIN" ] && [ -z "$CONTROLLED_INSTANCE_BIN" ] && [ -f "$REPO_DIR/bin/task-handoff.js" ]; then
121
+ TASK_HANDOFF_COMMAND="$(resolve_command "$REPO_DIR/bin/task-handoff.js")"
122
+ fi
123
+
124
+ if [ -n "$TASK_HANDOFF_COMMAND" ]; then
125
+ CONTROL_PLANE_COMMAND="$TASK_HANDOFF_COMMAND control-plane"
126
+ NODE_AGENT_COMMAND="$TASK_HANDOFF_COMMAND node-agent"
127
+ CONTROLLED_INSTANCE_COMMAND="$TASK_HANDOFF_COMMAND web"
128
+ if [ -z "$STATIC_DIR" ]; then
129
+ STATIC_DIR="$REPO_DIR/packages/control-plane-ui/dist"
130
+ fi
131
+ else
132
+ if [ -z "$CONTROL_PLANE_BIN" ] && [ -f "$INSTALLER_BIN_DIR/task-handoff-control-plane" ]; then
133
+ CONTROL_PLANE_BIN="$INSTALLER_BIN_DIR/task-handoff-control-plane"
134
+ fi
135
+ if [ -z "$NODE_AGENT_BIN" ] && [ -f "$INSTALLER_BIN_DIR/task-handoff-node-agent" ]; then
136
+ NODE_AGENT_BIN="$INSTALLER_BIN_DIR/task-handoff-node-agent"
137
+ fi
138
+ if [ -z "$CONTROLLED_INSTANCE_BIN" ] && [ -f "$INSTALLER_BIN_DIR/task-handoff-controlled-instance" ]; then
139
+ CONTROLLED_INSTANCE_BIN="$INSTALLER_BIN_DIR/task-handoff-controlled-instance"
140
+ fi
141
+ CONTROL_PLANE_COMMAND="$(resolve_command "${CONTROL_PLANE_BIN:-task-handoff-control-plane}")"
142
+ NODE_AGENT_COMMAND="$(resolve_command "${NODE_AGENT_BIN:-task-handoff-node-agent}")"
143
+ CONTROLLED_INSTANCE_COMMAND="$(resolve_command "${CONTROLLED_INSTANCE_BIN:-task-handoff-controlled-instance}") web"
144
+ fi
145
+
146
+ CONTROL_PLANE_STATIC_OPTION=""
147
+ if [ -n "$STATIC_DIR" ]; then
148
+ CONTROL_PLANE_STATIC_OPTION="--static-dir $STATIC_DIR"
149
+ fi
150
+
151
+ NODE_AGENT_IPC_ENDPOINT="$(node -e 'process.stdout.write(`ipc://${encodeURIComponent(process.argv[1])}`)' "$NODE_AGENT_IPC_PATH")"
152
+
153
+ if [ "$SERVICE_USER" != "root" ] && ! id "$SERVICE_USER" >/dev/null 2>&1; then
154
+ useradd --system --create-home --home-dir /var/lib/task-handoff --shell /usr/sbin/nologin "$SERVICE_USER"
155
+ fi
156
+ if [ "$SERVICE_USER" != "root" ] && getent group docker >/dev/null 2>&1; then
157
+ usermod -aG docker "$SERVICE_USER" || true
158
+ fi
159
+
160
+ assert_service_command_accessible() {
161
+ command_value="$1"
162
+ command_label="$2"
163
+ executable="${command_value%% *}"
164
+ if [ "$SERVICE_USER" != "root" ] && command -v runuser >/dev/null 2>&1 && ! runuser -u "$SERVICE_USER" -- test -x "$executable"; then
165
+ echo "$command_label is not executable by service user $SERVICE_USER: $executable" >&2
166
+ echo "Install Node.js and TaskHandoff under a system-wide prefix such as /usr/local, not a root-only NVM directory." >&2
167
+ exit 1
168
+ fi
169
+ }
170
+
171
+ assert_service_command_accessible "$CONTROL_PLANE_COMMAND" "Control-plane command"
172
+ assert_service_command_accessible "$NODE_AGENT_COMMAND" "Node-agent command"
173
+ assert_service_command_accessible "$CONTROLLED_INSTANCE_COMMAND" "Controlled-instance command"
174
+
175
+ mkdir -p "$ENV_DIR" "$CONTROL_PLANE_DATA_DIR" "$NODE_AGENT_DATA_DIR"
176
+ chown -R "$SERVICE_USER":"$SERVICE_USER" "$CONTROL_PLANE_DATA_DIR" "$NODE_AGENT_DATA_DIR" 2>/dev/null || true
177
+
178
+ cat > "$ENV_DIR/node-agent.env" <<EOF
179
+ TASK_HANDOFF_NODE_AGENT_HOST=$NODE_AGENT_HOST
180
+ TASK_HANDOFF_NODE_AGENT_PORT=$NODE_AGENT_PORT
181
+ TASK_HANDOFF_NODE_AGENT_DATA_DIR=$NODE_AGENT_DATA_DIR
182
+ TASK_HANDOFF_NODE_AGENT_CONNECTION_MODE=local-ipc
183
+ TASK_HANDOFF_NODE_AGENT_IPC_PATH=$NODE_AGENT_IPC_PATH
184
+ TASK_HANDOFF_NODE_AGENT_CONTAINER_URL=http://host.docker.internal:$NODE_AGENT_PORT
185
+ TASK_HANDOFF_LOCAL_CONTROLLED_COMMAND=$CONTROLLED_INSTANCE_COMMAND
186
+ EOF
187
+ chmod 0640 "$ENV_DIR/node-agent.env"
188
+
189
+ cat > "$ENV_DIR/control-plane.env" <<EOF
190
+ TASK_HANDOFF_CONTROL_PLANE_HOST=$CONTROL_PLANE_HOST
191
+ TASK_HANDOFF_CONTROL_PLANE_PORT=$CONTROL_PLANE_PORT
192
+ TASK_HANDOFF_CONTROL_PLANE_DATA_DIR=$CONTROL_PLANE_DATA_DIR
193
+ TASK_HANDOFF_CONTROL_PLANE_STATIC_DIR=$STATIC_DIR
194
+ TASK_HANDOFF_CONTROL_PLANE_AUTH_MODE=$AUTH_MODE
195
+ TASK_HANDOFF_NODE_AGENT_ENDPOINT=http://$NODE_AGENT_HOST:$NODE_AGENT_PORT
196
+ TASK_HANDOFF_NODE_AGENT_CONTROL_ENDPOINT=$NODE_AGENT_IPC_ENDPOINT
197
+ TASK_HANDOFF_NODE_AGENT_CONTAINER_URL=http://host.docker.internal:$NODE_AGENT_PORT
198
+ EOF
199
+ chmod 0640 "$ENV_DIR/control-plane.env"
200
+
201
+ cat > /etc/systemd/system/task-handoff-node-agent.service <<EOF
202
+ [Unit]
203
+ Description=TaskHandoff Local Node Agent
204
+ After=network-online.target docker.service
205
+ Wants=network-online.target
206
+
207
+ [Service]
208
+ Type=simple
209
+ User=$SERVICE_USER
210
+ WorkingDirectory=$NODE_AGENT_DATA_DIR
211
+ RuntimeDirectory=task-handoff
212
+ RuntimeDirectoryMode=0700
213
+ EnvironmentFile=-$ENV_DIR/node-agent.env
214
+ ExecStart=$NODE_AGENT_COMMAND --host $NODE_AGENT_HOST --port $NODE_AGENT_PORT --data-dir $NODE_AGENT_DATA_DIR --connection-mode local-ipc --ipc-path $NODE_AGENT_IPC_PATH
215
+ Restart=always
216
+ RestartSec=3
217
+ KillSignal=SIGTERM
218
+
219
+ [Install]
220
+ WantedBy=multi-user.target
221
+ EOF
222
+
223
+ cat > /etc/systemd/system/task-handoff-control-plane.service <<EOF
224
+ [Unit]
225
+ Description=TaskHandoff Control Plane
226
+ After=network-online.target task-handoff-node-agent.service
227
+ Wants=network-online.target task-handoff-node-agent.service
228
+
229
+ [Service]
230
+ Type=simple
231
+ User=$SERVICE_USER
232
+ WorkingDirectory=$CONTROL_PLANE_DATA_DIR
233
+ EnvironmentFile=-$ENV_DIR/control-plane.env
234
+ ExecStartPre=/bin/sh -c 'for i in \$(seq 1 30); do [ -S "$NODE_AGENT_IPC_PATH" ] && exit 0; sleep 1; done; exit 1'
235
+ ExecStart=$CONTROL_PLANE_COMMAND --host $CONTROL_PLANE_HOST --port $CONTROL_PLANE_PORT --data-dir $CONTROL_PLANE_DATA_DIR $CONTROL_PLANE_STATIC_OPTION --auth-mode $AUTH_MODE
236
+ Restart=always
237
+ RestartSec=3
238
+ KillSignal=SIGTERM
239
+
240
+ [Install]
241
+ WantedBy=multi-user.target
242
+ EOF
243
+
244
+ systemctl daemon-reload
245
+ systemctl enable --now task-handoff-node-agent.service
246
+ systemctl enable --now task-handoff-control-plane.service
247
+
248
+ echo "TaskHandoff server services are installed."
249
+ echo "Control plane: task-handoff-control-plane.service on $CONTROL_PLANE_HOST:$CONTROL_PLANE_PORT"
250
+ echo "Local node-agent: task-handoff-node-agent.service on $NODE_AGENT_HOST:$NODE_AGENT_PORT"
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const http = require("node:http");
5
+ const path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+
8
+ const packageRoot = path.resolve(__dirname, "..");
9
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
10
+
11
+ function usage() {
12
+ console.log(`Usage: task-handoff-server <command> [options]
13
+
14
+ Commands:
15
+ status Show installed version and service state
16
+ check [options] Check npm for an available version
17
+ update [options] Update from npm and restart services safely
18
+
19
+ Update options:
20
+ --channel <channel> npm channel: stable, beta, or alpha (default: stable)
21
+ --to <version> Install an exact version instead of a channel
22
+ --registry <url> Use a specific npm registry
23
+ --force Reinstall even when the resolved version is unchanged
24
+ `);
25
+ }
26
+
27
+ function run(command, args, options = {}) {
28
+ const result = spawnSync(command, args, { encoding: "utf8", ...options });
29
+ if (result.status !== 0) {
30
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
31
+ throw new Error(`${command} ${args.join(" ")} failed${output ? `:\n${output}` : ""}`);
32
+ }
33
+ return String(result.stdout || "").trim();
34
+ }
35
+
36
+ function findGlobalPrefix() {
37
+ let current = packageRoot;
38
+ while (current !== path.dirname(current)) {
39
+ if (path.basename(current) === "node_modules" && path.basename(path.dirname(current)) === "lib") {
40
+ return path.dirname(path.dirname(current));
41
+ }
42
+ current = path.dirname(current);
43
+ }
44
+ throw new Error(`Cannot determine the npm global prefix from ${packageRoot}.`);
45
+ }
46
+
47
+ function parseEnvFile(file) {
48
+ const values = {};
49
+ if (!fs.existsSync(file)) return values;
50
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
51
+ if (!line || line.startsWith("#")) continue;
52
+ const separator = line.indexOf("=");
53
+ if (separator <= 0) continue;
54
+ values[line.slice(0, separator)] = line.slice(separator + 1);
55
+ }
56
+ return values;
57
+ }
58
+
59
+ function serviceUser() {
60
+ const unit = "/etc/systemd/system/task-handoff-node-agent.service";
61
+ if (!fs.existsSync(unit)) return "root";
62
+ const match = fs.readFileSync(unit, "utf8").match(/^User=(.+)$/m);
63
+ return match?.[1] || "root";
64
+ }
65
+
66
+ function currentInstallOptions() {
67
+ const controlPlane = parseEnvFile("/etc/task-handoff/control-plane.env");
68
+ const nodeAgent = parseEnvFile("/etc/task-handoff/node-agent.env");
69
+ return [
70
+ "--service-user", serviceUser(),
71
+ "--control-plane-data-dir", controlPlane.TASK_HANDOFF_CONTROL_PLANE_DATA_DIR || "/var/lib/task-handoff/control-plane",
72
+ "--node-agent-data-dir", nodeAgent.TASK_HANDOFF_NODE_AGENT_DATA_DIR || "/var/lib/task-handoff/node-agent",
73
+ "--control-plane-host", controlPlane.TASK_HANDOFF_CONTROL_PLANE_HOST || "0.0.0.0",
74
+ "--control-plane-port", controlPlane.TASK_HANDOFF_CONTROL_PLANE_PORT || "8081",
75
+ "--node-agent-host", nodeAgent.TASK_HANDOFF_NODE_AGENT_HOST || "127.0.0.1",
76
+ "--node-agent-port", nodeAgent.TASK_HANDOFF_NODE_AGENT_PORT || "8091",
77
+ "--node-agent-ipc-path", nodeAgent.TASK_HANDOFF_NODE_AGENT_IPC_PATH || "/run/task-handoff/node-agent.sock",
78
+ "--auth-mode", controlPlane.TASK_HANDOFF_CONTROL_PLANE_AUTH_MODE || "password",
79
+ ];
80
+ }
81
+
82
+ function systemctlState(service) {
83
+ const result = spawnSync("systemctl", ["is-active", service], { encoding: "utf8" });
84
+ return String(result.stdout || "unknown").trim() || "unknown";
85
+ }
86
+
87
+ function npmVersion(target, registry) {
88
+ const args = ["view", `@task-handoff/server@${target}`, "version", "--json"];
89
+ if (registry) args.push("--registry", registry);
90
+ const value = JSON.parse(run("npm", args));
91
+ if (typeof value !== "string") throw new Error(`npm target ${target} did not resolve to one version.`);
92
+ return value;
93
+ }
94
+
95
+ function npmTagForChannel(channel) {
96
+ return channel === "stable" ? "latest" : channel;
97
+ }
98
+
99
+ function waitForSocket(socketPath, timeoutMs = 30000) {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ try {
103
+ if (fs.statSync(socketPath).isSocket()) return;
104
+ } catch {}
105
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
106
+ }
107
+ throw new Error(`Node agent socket was not ready after ${timeoutMs}ms: ${socketPath}`);
108
+ }
109
+
110
+ function waitForHttp(port, timeoutMs = 30000) {
111
+ return new Promise((resolve, reject) => {
112
+ const deadline = Date.now() + timeoutMs;
113
+ const attempt = () => {
114
+ const request = http.get({ host: "127.0.0.1", port, path: "/api/health", timeout: 1000 }, (response) => {
115
+ response.resume();
116
+ if (response.statusCode === 200) return resolve();
117
+ retry();
118
+ });
119
+ request.on("timeout", () => request.destroy());
120
+ request.on("error", retry);
121
+ };
122
+ const retry = () => {
123
+ if (Date.now() >= deadline) return reject(new Error(`Control plane was not healthy on port ${port} after ${timeoutMs}ms.`));
124
+ setTimeout(attempt, 250);
125
+ };
126
+ attempt();
127
+ });
128
+ }
129
+
130
+ function acquireLock() {
131
+ const lock = "/run/task-handoff-server-update.lock";
132
+ try {
133
+ fs.mkdirSync(lock);
134
+ } catch (error) {
135
+ if (error.code === "EEXIST") throw new Error("Another TaskHandoff server update is already running.");
136
+ throw error;
137
+ }
138
+ return () => fs.rmSync(lock, { recursive: true, force: true });
139
+ }
140
+
141
+ function parseOptions(args, allowExactVersion) {
142
+ let channel = "stable";
143
+ let channelExplicit = false;
144
+ let exactVersion = "";
145
+ let registry = "";
146
+ let force = false;
147
+ while (args.length) {
148
+ const option = args.shift();
149
+ if (option === "--channel") {
150
+ channel = args.shift() || "";
151
+ channelExplicit = true;
152
+ }
153
+ else if (option === "--to" && allowExactVersion) exactVersion = args.shift() || "";
154
+ else if (option === "--registry") registry = args.shift() || "";
155
+ else if (option === "--force" && allowExactVersion) force = true;
156
+ else throw new Error(`Unknown option: ${option}`);
157
+ }
158
+ if (!new Set(["stable", "beta", "alpha"]).has(channel)) {
159
+ throw new Error("--channel must be stable, beta, or alpha.");
160
+ }
161
+ if (channelExplicit && exactVersion) throw new Error("Use either --channel or --to, not both.");
162
+ if (allowExactVersion && exactVersion && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(exactVersion)) {
163
+ throw new Error("--to must be an exact semantic version.");
164
+ }
165
+ return { target: exactVersion || npmTagForChannel(channel), channel, registry, force };
166
+ }
167
+
168
+ async function main() {
169
+ const command = process.argv[2];
170
+ if (!command || command === "help" || command === "--help" || command === "-h") {
171
+ usage();
172
+ return;
173
+ }
174
+ if (command === "status") {
175
+ console.log(`Package: @task-handoff/server ${manifest.version}`);
176
+ console.log(`Node agent: ${systemctlState("task-handoff-node-agent.service")}`);
177
+ console.log(`Control plane: ${systemctlState("task-handoff-control-plane.service")}`);
178
+ return;
179
+ }
180
+ if (command === "check") {
181
+ const { target, channel, registry } = parseOptions(process.argv.slice(3), false);
182
+ const available = npmVersion(target, registry);
183
+ console.log(`Installed: ${manifest.version}`);
184
+ console.log(`Available (${channel} channel, npm ${target}): ${available}`);
185
+ console.log(available === manifest.version ? "Up to date." : `Update available: task-handoff-server update --to ${available}`);
186
+ return;
187
+ }
188
+ if (command !== "update") throw new Error(`Unknown command: ${command}`);
189
+ if (typeof process.getuid === "function" && process.getuid() !== 0) {
190
+ throw new Error("Run updates as root so npm and systemd can be updated.");
191
+ }
192
+ const { target, registry, force } = parseOptions(process.argv.slice(3), true);
193
+ const targetVersion = target === manifest.version ? manifest.version : npmVersion(target, registry);
194
+ if (targetVersion === manifest.version && !force) {
195
+ console.log(`@task-handoff/server ${manifest.version} is already installed.`);
196
+ return;
197
+ }
198
+ const releaseLock = acquireLock();
199
+ try {
200
+ const prefix = findGlobalPrefix();
201
+ const installOptions = currentInstallOptions();
202
+ const nodeSocket = installOptions[installOptions.indexOf("--node-agent-ipc-path") + 1];
203
+ const controlPlanePort = Number(installOptions[installOptions.indexOf("--control-plane-port") + 1]);
204
+ console.log(`Updating @task-handoff/server ${manifest.version} -> ${targetVersion}`);
205
+ const npmArgs = ["install", "--global", "--prefix", prefix, `@task-handoff/server@${targetVersion}`];
206
+ if (registry) npmArgs.push("--registry", registry);
207
+ run("npm", npmArgs, { stdio: "inherit" });
208
+ run(path.join(prefix, "bin", "task-handoff-install-server"), installOptions, { stdio: "inherit" });
209
+ run("systemctl", ["restart", "task-handoff-node-agent.service"]);
210
+ waitForSocket(nodeSocket);
211
+ run("systemctl", ["restart", "task-handoff-control-plane.service"]);
212
+ await waitForHttp(controlPlanePort);
213
+ console.log(`Updated TaskHandoff server to ${targetVersion}.`);
214
+ } catch (error) {
215
+ console.error(`Update failed. To reinstall the previous version, run: npm install -g @task-handoff/server@${manifest.version}`);
216
+ throw error;
217
+ } finally {
218
+ releaseLock();
219
+ }
220
+ }
221
+
222
+ main().catch((error) => {
223
+ console.error(`Error: ${error.message}`);
224
+ process.exit(1);
225
+ });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@task-handoff/server",
3
+ "version": "0.0.3-alpha.3",
4
+ "description": "Complete TaskHandoff server package.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "task-handoff-install-server": "bin/task-handoff-install-server",
9
+ "task-handoff-server-install": "bin/task-handoff-install-server",
10
+ "task-handoff-server": "bin/task-handoff-server"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.22.2"
18
+ },
19
+ "dependencies": {
20
+ "@task-handoff/control-plane": "0.0.3-alpha.3",
21
+ "@task-handoff/node-agent": "0.0.3-alpha.3",
22
+ "@task-handoff/controlled-instance": "0.0.3-alpha.3"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }