amalgm 0.1.38 → 0.1.40

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 CHANGED
@@ -20,9 +20,9 @@ If you start from the Amalgm web app or are setting up a remote/headless machine
20
20
  amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
21
21
  ```
22
22
 
23
- `amalgm doctor` checks the installed runtime, login record, required local ports, daemon state, and whether an HMAC proxy token is available.
23
+ `amalgm doctor` checks the installed runtime, login record, service supervisor, required local ports, daemon state, and whether an HMAC proxy token is available.
24
24
 
25
- `amalgm start` runs the local essentials:
25
+ `amalgm start` installs/starts the best available service supervisor for the machine, then the supervisor runs `amalgm run` and keeps the local essentials alive:
26
26
 
27
27
  - port monitor on `8081`
28
28
  - filesystem/auth watcher on `8082`
@@ -31,7 +31,7 @@ amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
31
31
  - events/previews/artifact tunnel to `wire.events.amalgm.ai`
32
32
  - chat tunnel to `amalgm-chat-gateway`
33
33
 
34
- The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`.
34
+ The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`. For direct foreground debugging, use `amalgm start --foreground`.
35
35
 
36
36
  Useful commands:
37
37
 
@@ -40,6 +40,7 @@ amalgm status
40
40
  amalgm update
41
41
  amalgm logs
42
42
  amalgm logs chat-server
43
+ amalgm service status
43
44
  amalgm stop
44
45
  amalgm logout
45
46
  ```
package/lib/cli.js CHANGED
@@ -18,8 +18,16 @@ const {
18
18
  RUNTIME_DIR,
19
19
  RUNTIME_STATE_FILE,
20
20
  RUNTIME_TOKEN_FILE,
21
+ SHUTDOWN_REASON_FILE,
21
22
  } = require('./paths');
22
23
  const { startSupervisor } = require('./supervisor');
24
+ const {
25
+ getServiceStatus,
26
+ installService,
27
+ startService,
28
+ stopService,
29
+ uninstallService,
30
+ } = require('./service');
23
31
  const {
24
32
  deleteComputerAuth,
25
33
  loadComputerRecord,
@@ -79,6 +87,7 @@ function usage() {
79
87
  ' amalgm stop',
80
88
  ' amalgm status',
81
89
  ' amalgm doctor',
90
+ ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|portable]',
82
91
  ' amalgm update [--tag latest|canary]',
83
92
  ' amalgm logs [service]',
84
93
  ' amalgm logout',
@@ -824,11 +833,16 @@ async function start(options) {
824
833
  const pid = readPid();
825
834
  if (isPidRunning(pid)) {
826
835
  const restartReason = runningDaemonRestartReason(pid, record, localOnly);
827
- if (!restartReason) {
836
+ const serviceStatus = getServiceStatus();
837
+ if (!restartReason && (options['no-service'] || serviceStatus.running)) {
828
838
  console.log(`Amalgm is already running (pid ${pid}).`);
829
839
  return;
830
840
  }
831
- console.log(`Amalgm is already running (pid ${pid}), but ${restartReason}. Restarting.`);
841
+ if (!restartReason) {
842
+ console.log(`Amalgm is already running (pid ${pid}) without a service supervisor. Moving it under service supervision.`);
843
+ } else {
844
+ console.log(`Amalgm is already running (pid ${pid}), but ${restartReason}. Restarting.`);
845
+ }
832
846
  await stop();
833
847
  }
834
848
 
@@ -843,6 +857,27 @@ async function start(options) {
843
857
  return;
844
858
  }
845
859
 
860
+ if (!options['no-service']) {
861
+ const statusBefore = getServiceStatus();
862
+ const statusAfter = startService({
863
+ localOnly,
864
+ mode: options.mode || process.env.AMALGM_SERVICE_MODE || 'auto',
865
+ });
866
+ const backend = statusAfter.backend || statusBefore.backend || 'service';
867
+ console.log(`Amalgm service ${statusBefore.installed ? 'started' : 'installed and started'} (${backend}).`);
868
+ const serviceDetail = statusAfter.detail || statusAfter.state?.detail;
869
+ if (serviceDetail) console.log(`Service: ${serviceDetail}`);
870
+
871
+ const ready = await waitForServices(8000);
872
+ const pending = ready.filter(([, ok]) => !ok).map(([name]) => name);
873
+ if (pending.length === 0) {
874
+ console.log('Services: all local essentials are up.');
875
+ } else {
876
+ console.log(`Services still warming up: ${pending.join(', ')}. Run \`amalgm status\` or \`amalgm logs\` if they stay down.`);
877
+ }
878
+ return;
879
+ }
880
+
846
881
  ensureDir(LOG_DIR);
847
882
  const logPath = path.join(LOG_DIR, 'daemon.log');
848
883
  const logFd = fs.openSync(logPath, 'a');
@@ -869,6 +904,23 @@ async function start(options) {
869
904
  }
870
905
 
871
906
  async function stop() {
907
+ const serviceStatus = getServiceStatus();
908
+ if (serviceStatus.installed || serviceStatus.running) {
909
+ const result = stopService();
910
+ if (result.service.backend) {
911
+ console.log(`Amalgm service stopped (${result.service.backend}).`);
912
+ }
913
+ if (!isPidRunning(readPid())) {
914
+ try {
915
+ fs.unlinkSync(PID_FILE);
916
+ } catch {
917
+ // noop
918
+ }
919
+ console.log('Amalgm stopped.');
920
+ return;
921
+ }
922
+ }
923
+
872
924
  const pid = readPid();
873
925
  if (!isPidRunning(pid)) {
874
926
  try {
@@ -944,10 +996,24 @@ async function status() {
944
996
  const pid = readPid();
945
997
  const record = loadComputerRecord({ migrate: true });
946
998
  const runtimeState = readJson(RUNTIME_STATE_FILE, null);
999
+ const serviceStatus = getServiceStatus();
1000
+ const lastShutdown = readJson(SHUTDOWN_REASON_FILE, null);
947
1001
 
948
1002
  console.log(`Daemon: ${isPidRunning(pid) ? `running (pid ${pid})` : 'stopped'}`);
1003
+ console.log(
1004
+ `Service: ${
1005
+ serviceStatus.installed
1006
+ ? `${serviceStatus.running ? 'running' : 'stopped'} (${serviceStatus.backend || 'unknown'})`
1007
+ : 'not installed'
1008
+ }`,
1009
+ );
949
1010
  console.log(`State: ${AMALGM_DIR}`);
950
1011
  if (runtimeState?.gateway_url) console.log(`Gateway: ${runtimeState.gateway_url}`);
1012
+ if (lastShutdown?.signal || lastShutdown?.reason) {
1013
+ console.log(
1014
+ `Last shutdown: ${lastShutdown.signal || lastShutdown.reason} at ${lastShutdown.at || lastShutdown.time || 'unknown time'}`,
1015
+ );
1016
+ }
951
1017
  console.log(
952
1018
  `Computer: ${
953
1019
  record?.computer_id
@@ -1016,7 +1082,33 @@ async function doctor() {
1016
1082
 
1017
1083
  const pid = readPid();
1018
1084
  const daemonRunning = isPidRunning(pid);
1085
+ const serviceStatus = getServiceStatus();
1086
+ const lastShutdown = readJson(SHUTDOWN_REASON_FILE, null);
1087
+ add(
1088
+ 'service',
1089
+ serviceStatus.installed && serviceStatus.running,
1090
+ serviceStatus.installed
1091
+ ? `${serviceStatus.running ? 'running' : 'stopped'} (${serviceStatus.backend || 'unknown'})`
1092
+ : 'not installed; `amalgm start` will install one',
1093
+ serviceStatus.installed && serviceStatus.running ? 'ok' : 'warn',
1094
+ );
1095
+ if (serviceStatus.backend === 'portable' && /container/i.test(serviceStatus.detail || '')) {
1096
+ add(
1097
+ 'service durability',
1098
+ true,
1099
+ 'portable watchdog is best-effort inside managed containers; platform entrypoint/service is stronger',
1100
+ 'warn',
1101
+ );
1102
+ }
1019
1103
  add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
1104
+ if (lastShutdown?.signal || lastShutdown?.reason) {
1105
+ add(
1106
+ 'last shutdown',
1107
+ true,
1108
+ `${lastShutdown.signal || lastShutdown.reason} at ${lastShutdown.at || 'unknown time'}`,
1109
+ 'warn',
1110
+ );
1111
+ }
1020
1112
 
1021
1113
  for (const [name, port] of servicePorts()) {
1022
1114
  const envName = {
@@ -1057,13 +1149,104 @@ function readLastLines(file, maxLines = 120) {
1057
1149
  return buffer.toString('utf8').trimEnd().split('\n').slice(-maxLines).join('\n');
1058
1150
  }
1059
1151
 
1152
+ function readNativeServiceLogs(maxLines = 120) {
1153
+ const serviceStatus = getServiceStatus();
1154
+ const label = serviceStatus.state?.label || 'ai.amalgm.runtime';
1155
+ const chunks = [];
1156
+
1157
+ if (serviceStatus.backend === 'systemd') {
1158
+ const statusResult = spawnSync('systemctl', ['--user', 'status', label, '--no-pager', '-l'], {
1159
+ encoding: 'utf8',
1160
+ timeout: 5000,
1161
+ });
1162
+ const statusOutput = [statusResult.stdout, statusResult.stderr].filter(Boolean).join('\n').trim();
1163
+ if (statusOutput) chunks.push(`===== systemctl --user status ${label} =====\n${statusOutput}`);
1164
+
1165
+ const journalResult = spawnSync('journalctl', ['--user', '-u', label, '-n', String(maxLines), '--no-pager'], {
1166
+ encoding: 'utf8',
1167
+ timeout: 5000,
1168
+ });
1169
+ const journalOutput = [journalResult.stdout, journalResult.stderr].filter(Boolean).join('\n').trim();
1170
+ if (journalOutput) chunks.push(`===== journalctl --user -u ${label} =====\n${journalOutput}`);
1171
+ } else if (serviceStatus.backend === 'launchd') {
1172
+ const domain = `gui/${process.getuid ? process.getuid() : ''}`;
1173
+ const result = spawnSync('launchctl', ['print', `${domain}/${label}`], {
1174
+ encoding: 'utf8',
1175
+ timeout: 5000,
1176
+ });
1177
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
1178
+ if (output) chunks.push(`===== launchctl print ${domain}/${label} =====\n${output}`);
1179
+ }
1180
+
1181
+ return chunks.join('\n\n');
1182
+ }
1183
+
1060
1184
  async function logs(options, positionals) {
1061
1185
  const service = String(options.service || positionals[0] || 'daemon').replace(/[^a-z0-9_-]/gi, '');
1186
+ const maxLines = Number(options.lines || 120);
1187
+ if (service === 'service') {
1188
+ const nativeLogs = readNativeServiceLogs(maxLines);
1189
+ if (nativeLogs) {
1190
+ console.log(nativeLogs);
1191
+ return;
1192
+ }
1193
+ }
1062
1194
  const logPath = path.join(LOG_DIR, `${service}.log`);
1063
1195
  if (!fs.existsSync(logPath)) {
1064
1196
  throw new Error(`No log file found for "${service}" at ${logPath}`);
1065
1197
  }
1066
- console.log(readLastLines(logPath, Number(options.lines || 120)));
1198
+ console.log(readLastLines(logPath, maxLines));
1199
+ }
1200
+
1201
+ function printServiceStatus(statusInfo = getServiceStatus()) {
1202
+ console.log(
1203
+ `Service: ${
1204
+ statusInfo.installed
1205
+ ? `${statusInfo.running ? 'running' : 'stopped'} (${statusInfo.backend || 'unknown'})`
1206
+ : 'not installed'
1207
+ }`,
1208
+ );
1209
+ if (statusInfo.detail) console.log(`Backend: ${statusInfo.detail}`);
1210
+ if (statusInfo.pid) console.log(`Service pid: ${statusInfo.pid}`);
1211
+ if (statusInfo.stopRequested) console.log('Stop request: pending');
1212
+ }
1213
+
1214
+ async function serviceCommand(options, positionals) {
1215
+ const action = positionals[0] || 'status';
1216
+ const localOnly = !!options['local-only'];
1217
+ const mode = options.mode || process.env.AMALGM_SERVICE_MODE || 'auto';
1218
+
1219
+ if (action === 'install') {
1220
+ const state = installService({ localOnly, mode });
1221
+ console.log(`Amalgm service installed (${state.backend}).`);
1222
+ if (state.detail) console.log(`Service: ${state.detail}`);
1223
+ return;
1224
+ }
1225
+
1226
+ if (action === 'start') {
1227
+ const statusInfo = startService({ localOnly, mode });
1228
+ printServiceStatus(statusInfo);
1229
+ return;
1230
+ }
1231
+
1232
+ if (action === 'stop') {
1233
+ stopService();
1234
+ printServiceStatus();
1235
+ return;
1236
+ }
1237
+
1238
+ if (action === 'status') {
1239
+ printServiceStatus();
1240
+ return;
1241
+ }
1242
+
1243
+ if (action === 'uninstall') {
1244
+ uninstallService();
1245
+ console.log('Amalgm service uninstalled.');
1246
+ return;
1247
+ }
1248
+
1249
+ throw new Error(`Unknown service action "${action}". Use install, start, stop, status, or uninstall.`);
1067
1250
  }
1068
1251
 
1069
1252
  async function logout() {
@@ -1113,12 +1296,13 @@ async function main(argv) {
1113
1296
  if (command === 'run') {
1114
1297
  return startSupervisor({
1115
1298
  foreground: true,
1116
- localOnly: process.env.AMALGM_LOCAL_ONLY === 'true',
1299
+ localOnly: !!options['local-only'] || process.env.AMALGM_LOCAL_ONLY === 'true',
1117
1300
  });
1118
1301
  }
1119
1302
  if (command === 'stop') return stop();
1120
1303
  if (command === 'status') return status();
1121
1304
  if (command === 'doctor') return doctor();
1305
+ if (command === 'service') return serviceCommand(options, positionals);
1122
1306
  if (command === 'update') return update(options);
1123
1307
  if (command === 'logs') return logs(options, positionals);
1124
1308
  if (command === 'logout') return logout();
package/lib/paths.js CHANGED
@@ -13,6 +13,11 @@ const RUNTIME_TOKEN_FILE = path.join(AMALGM_DIR, 'runtime-token.json');
13
13
  const PID_FILE = path.join(AMALGM_DIR, 'amalgm.pid');
14
14
  const RUNTIME_STATE_FILE = path.join(AMALGM_DIR, 'runtime-state.json');
15
15
  const LOG_DIR = path.join(AMALGM_DIR, 'logs');
16
+ const SERVICE_DIR = path.join(AMALGM_DIR, 'service');
17
+ const SERVICE_PID_FILE = path.join(AMALGM_DIR, 'amalgm-service.pid');
18
+ const SERVICE_STATE_FILE = path.join(AMALGM_DIR, 'service-state.json');
19
+ const SERVICE_STOP_FILE = path.join(AMALGM_DIR, 'amalgm-service.stop');
20
+ const SHUTDOWN_REASON_FILE = path.join(AMALGM_DIR, 'shutdown-reason.json');
16
21
  const DEFAULT_APP_URL = process.env.AMALGM_APP_URL || 'https://amalgm.ai';
17
22
 
18
23
  module.exports = {
@@ -27,4 +32,9 @@ module.exports = {
27
32
  RUNTIME_DIR,
28
33
  RUNTIME_STATE_FILE,
29
34
  RUNTIME_TOKEN_FILE,
35
+ SERVICE_DIR,
36
+ SERVICE_PID_FILE,
37
+ SERVICE_STATE_FILE,
38
+ SERVICE_STOP_FILE,
39
+ SHUTDOWN_REASON_FILE,
30
40
  };
package/lib/service.js ADDED
@@ -0,0 +1,823 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawn, spawnSync } = require('child_process');
7
+
8
+ const {
9
+ AMALGM_DIR,
10
+ LOG_DIR,
11
+ PACKAGE_ROOT,
12
+ PID_FILE,
13
+ SERVICE_DIR,
14
+ SERVICE_PID_FILE,
15
+ SERVICE_STATE_FILE,
16
+ SERVICE_STOP_FILE,
17
+ } = require('./paths');
18
+
19
+ const PACKAGE_VERSION = require('../package.json').version;
20
+ const SERVICE_LABEL = 'ai.amalgm.runtime';
21
+ const SERVICE_SCRIPT_FILE = path.join(SERVICE_DIR, 'amalgm-watchdog.sh');
22
+ const SERVICE_NODE_SCRIPT_FILE = path.join(SERVICE_DIR, 'amalgm-watchdog.cjs');
23
+ const SERVICE_LOG_FILE = path.join(LOG_DIR, 'service.log');
24
+ const AMALGM_BIN = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js');
25
+
26
+ const SERVICE_ENV_KEYS = [
27
+ 'AMALGM_APP_URL',
28
+ 'AMALGM_AUTH_BACKUP_ENABLED',
29
+ 'AMALGM_BIND_HOST',
30
+ 'AMALGM_CREATE_AUTH_WATCH_DIRS',
31
+ 'AMALGM_DEFAULT_CWD',
32
+ 'AMALGM_GATEWAY_PORT',
33
+ 'AMALGM_NATIVE_NODE_MODULES',
34
+ 'AMALGM_PROJECTS_DIR',
35
+ 'AMALGM_SKIP_NATIVE_INSTALL',
36
+ 'AMALGM_WORKSPACES_DIR',
37
+ 'CHAT_SERVER_PORT',
38
+ 'ELECTRON_RUN_AS_NODE',
39
+ 'FS_WATCHER_PORT',
40
+ 'LANG',
41
+ 'LC_ALL',
42
+ 'NODE_PATH',
43
+ 'PATH',
44
+ 'PORT_MONITOR_PORT',
45
+ 'SHELL',
46
+ 'TERM',
47
+ 'TMP',
48
+ 'TMPDIR',
49
+ 'TEMP',
50
+ 'XDG_CACHE_HOME',
51
+ 'XDG_CONFIG_HOME',
52
+ 'XDG_DATA_HOME',
53
+ ];
54
+
55
+ function ensureDir(dir, mode = 0o700) {
56
+ fs.mkdirSync(dir, { recursive: true, mode });
57
+ try {
58
+ fs.chmodSync(dir, mode);
59
+ } catch {
60
+ // Best effort on non-POSIX filesystems.
61
+ }
62
+ }
63
+
64
+ function readJson(file, fallback = null) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
67
+ } catch {
68
+ return fallback;
69
+ }
70
+ }
71
+
72
+ function writeJson(file, data, mode = 0o600) {
73
+ ensureDir(path.dirname(file), 0o700);
74
+ const temp = `${file}.${process.pid}.tmp`;
75
+ fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode });
76
+ fs.renameSync(temp, file);
77
+ try {
78
+ fs.chmodSync(file, mode);
79
+ } catch {
80
+ // Best effort.
81
+ }
82
+ }
83
+
84
+ function removeFile(file) {
85
+ try {
86
+ fs.unlinkSync(file);
87
+ } catch {
88
+ // noop
89
+ }
90
+ }
91
+
92
+ function shellQuote(value) {
93
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
94
+ }
95
+
96
+ function plistEscape(value) {
97
+ return String(value)
98
+ .replace(/&/g, '&')
99
+ .replace(/</g, '&lt;')
100
+ .replace(/>/g, '&gt;')
101
+ .replace(/"/g, '&quot;')
102
+ .replace(/'/g, '&apos;');
103
+ }
104
+
105
+ function systemdQuote(value) {
106
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
107
+ }
108
+
109
+ function isPidRunning(pid) {
110
+ if (!Number.isInteger(pid) || pid <= 0) return false;
111
+ try {
112
+ process.kill(pid, 0);
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function readPidFile(file) {
120
+ const raw = readJson(file, null);
121
+ if (raw && Number.isInteger(raw.pid)) return raw.pid;
122
+ try {
123
+ const parsed = Number(fs.readFileSync(file, 'utf8').trim());
124
+ return Number.isInteger(parsed) ? parsed : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function commandExists(command) {
131
+ const pathValue = process.env.PATH || '';
132
+ const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
133
+ for (const dir of pathValue.split(path.delimiter)) {
134
+ if (!dir) continue;
135
+ for (const ext of extensions) {
136
+ const candidate = path.join(dir, `${command}${ext}`);
137
+ try {
138
+ fs.accessSync(candidate, fs.constants.X_OK);
139
+ return true;
140
+ } catch {
141
+ // keep looking
142
+ }
143
+ }
144
+ }
145
+ return false;
146
+ }
147
+
148
+ function systemdUnavailableOutput(output) {
149
+ return /systemd"? is not running|Failed to connect to bus|No medium found|Host is down|Connection refused/i.test(String(output || ''));
150
+ }
151
+
152
+ function systemdAvailable() {
153
+ if (process.platform !== 'linux' || !commandExists('systemctl')) return false;
154
+ const showEnvironment = spawnSync('systemctl', ['--user', 'show-environment'], {
155
+ encoding: 'utf8',
156
+ timeout: 2500,
157
+ });
158
+ const showEnvironmentOutput = [showEnvironment.stderr, showEnvironment.stdout].filter(Boolean).join('\n');
159
+ if (showEnvironment.status !== 0 || systemdUnavailableOutput(showEnvironmentOutput)) return false;
160
+
161
+ const showTarget = spawnSync('systemctl', ['--user', 'show', 'default.target', '--property=LoadState', '--property=ActiveState'], {
162
+ encoding: 'utf8',
163
+ timeout: 2500,
164
+ });
165
+ const showTargetOutput = [showTarget.stderr, showTarget.stdout].filter(Boolean).join('\n');
166
+ return showTarget.status === 0 && !systemdUnavailableOutput(showTargetOutput);
167
+ }
168
+
169
+ function launchdAvailable() {
170
+ return process.platform === 'darwin' && commandExists('launchctl');
171
+ }
172
+
173
+ function runningInContainer() {
174
+ if (fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv')) return true;
175
+ try {
176
+ const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
177
+ return /docker|kubepods|containerd|libpod|lxc|daytona|e2b/i.test(cgroup);
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ function normalizeMode(mode) {
184
+ const value = String(mode || 'auto').trim().toLowerCase();
185
+ if (!value || value === 'auto') return 'auto';
186
+ if (value === 'watchdog') return 'portable';
187
+ if (['portable', 'systemd', 'launchd'].includes(value)) return value;
188
+ throw new Error(`Unsupported service mode "${mode}". Use auto, systemd, launchd, or portable.`);
189
+ }
190
+
191
+ function detectServiceBackend(mode = 'auto') {
192
+ const requested = normalizeMode(mode);
193
+ const inContainer = runningInContainer();
194
+ if (requested === 'launchd') {
195
+ if (!launchdAvailable()) throw new Error('launchd is not available on this machine.');
196
+ return { backend: 'launchd', detail: 'macOS LaunchAgent' };
197
+ }
198
+ if (requested === 'systemd') {
199
+ if (!systemdAvailable()) throw new Error('systemd --user is not available in this session.');
200
+ return { backend: 'systemd', detail: 'systemd user service' };
201
+ }
202
+ if (requested === 'portable') {
203
+ return {
204
+ backend: 'portable',
205
+ detail: inContainer ? 'portable watchdog in container' : 'portable watchdog',
206
+ };
207
+ }
208
+ if (launchdAvailable()) return { backend: 'launchd', detail: 'macOS LaunchAgent' };
209
+ if (inContainer) {
210
+ return {
211
+ backend: 'portable',
212
+ detail: 'portable watchdog in container',
213
+ };
214
+ }
215
+ if (systemdAvailable()) return { backend: 'systemd', detail: 'systemd user service' };
216
+ return {
217
+ backend: 'portable',
218
+ detail: inContainer ? 'portable watchdog in container' : 'portable watchdog',
219
+ };
220
+ }
221
+
222
+ function buildServiceEnv(localOnly) {
223
+ const env = {
224
+ AMALGM_DIR,
225
+ AMALGM_LOCAL_ONLY: localOnly ? 'true' : 'false',
226
+ AMALGM_SERVICE_MANAGED: 'true',
227
+ AMALGM_SERVICE_LABEL: SERVICE_LABEL,
228
+ ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE || '1',
229
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
230
+ };
231
+
232
+ for (const key of SERVICE_ENV_KEYS) {
233
+ if (process.env[key] && !env[key]) env[key] = process.env[key];
234
+ }
235
+ return env;
236
+ }
237
+
238
+ function writePortableScript(localOnly) {
239
+ ensureDir(SERVICE_DIR, 0o700);
240
+ ensureDir(LOG_DIR, 0o700);
241
+ const env = buildServiceEnv(localOnly);
242
+ const lines = [
243
+ '#!/bin/sh',
244
+ 'set -u',
245
+ `AMALGM_DIR=${shellQuote(AMALGM_DIR)}`,
246
+ `LOG_DIR=${shellQuote(LOG_DIR)}`,
247
+ `PID_FILE=${shellQuote(SERVICE_PID_FILE)}`,
248
+ `WORKER_PID_FILE=${shellQuote(PID_FILE)}`,
249
+ `STOP_FILE=${shellQuote(SERVICE_STOP_FILE)}`,
250
+ `LOCK_DIR=${shellQuote(path.join(SERVICE_DIR, 'lock'))}`,
251
+ `NODE_BIN=${shellQuote(process.execPath)}`,
252
+ `AMALGM_BIN=${shellQuote(AMALGM_BIN)}`,
253
+ `DAEMON_LOG=${shellQuote(path.join(LOG_DIR, 'daemon.log'))}`,
254
+ `SERVICE_LOG=${shellQuote(SERVICE_LOG_FILE)}`,
255
+ `RESTART_DELAY=${shellQuote(String(process.env.AMALGM_SERVICE_RESTART_DELAY || '5'))}`,
256
+ ];
257
+
258
+ for (const [key, value] of Object.entries(env)) {
259
+ lines.push(`export ${key}=${shellQuote(value)}`);
260
+ }
261
+
262
+ lines.push(
263
+ 'mkdir -p "$AMALGM_DIR" "$LOG_DIR"',
264
+ 'if ! mkdir "$LOCK_DIR" 2>/dev/null; then',
265
+ ' existing="$(cat "$PID_FILE" 2>/dev/null | sed -n \'s/.*"pid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p\')"',
266
+ ' [ -n "$existing" ] || existing="$(cat "$PID_FILE" 2>/dev/null || true)"',
267
+ ' if [ -n "$existing" ] && kill -0 "$existing" 2>/dev/null; then',
268
+ ' exit 0',
269
+ ' fi',
270
+ ' rm -rf "$LOCK_DIR"',
271
+ ' mkdir "$LOCK_DIR" || exit 1',
272
+ 'fi',
273
+ 'child_pid=""',
274
+ 'cleanup() {',
275
+ ' touch "$STOP_FILE" 2>/dev/null || true',
276
+ ' if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then',
277
+ ' kill "$child_pid" 2>/dev/null || true',
278
+ ' wait "$child_pid" 2>/dev/null || true',
279
+ ' fi',
280
+ ' rm -f "$PID_FILE"',
281
+ ' rm -rf "$LOCK_DIR"',
282
+ ' exit 0',
283
+ '}',
284
+ 'trap cleanup TERM INT HUP',
285
+ 'printf \'{"pid":%s,"started_at":"%s","backend":"portable"}\\n\' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PID_FILE"',
286
+ 'rm -f "$STOP_FILE"',
287
+ 'while [ ! -f "$STOP_FILE" ]; do',
288
+ ' printf \'[%s] [service] starting amalgm run\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$SERVICE_LOG"',
289
+ ' "$NODE_BIN" "$AMALGM_BIN" run >> "$DAEMON_LOG" 2>&1 &',
290
+ ' child_pid=$!',
291
+ ' wait "$child_pid"',
292
+ ' code=$?',
293
+ ' child_pid=""',
294
+ ' printf \'[%s] [service] amalgm run exited code=%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$code" >> "$SERVICE_LOG"',
295
+ ' [ -f "$STOP_FILE" ] && break',
296
+ ' sleep "$RESTART_DELAY"',
297
+ 'done',
298
+ 'rm -f "$PID_FILE"',
299
+ 'rm -rf "$LOCK_DIR"',
300
+ );
301
+
302
+ fs.writeFileSync(SERVICE_SCRIPT_FILE, `${lines.join('\n')}\n`, { mode: 0o700 });
303
+ try {
304
+ fs.chmodSync(SERVICE_SCRIPT_FILE, 0o700);
305
+ } catch {
306
+ // Best effort.
307
+ }
308
+ return SERVICE_SCRIPT_FILE;
309
+ }
310
+
311
+ function writePortableNodeScript(localOnly) {
312
+ ensureDir(SERVICE_DIR, 0o700);
313
+ ensureDir(LOG_DIR, 0o700);
314
+ const config = {
315
+ amalgmDir: AMALGM_DIR,
316
+ logDir: LOG_DIR,
317
+ pidFile: SERVICE_PID_FILE,
318
+ workerPidFile: PID_FILE,
319
+ stopFile: SERVICE_STOP_FILE,
320
+ lockDir: path.join(SERVICE_DIR, 'lock'),
321
+ nodeBin: process.execPath,
322
+ amalgmBin: AMALGM_BIN,
323
+ daemonLog: path.join(LOG_DIR, 'daemon.log'),
324
+ serviceLog: SERVICE_LOG_FILE,
325
+ restartDelayMs: Math.max(1000, Number(process.env.AMALGM_SERVICE_RESTART_DELAY || 5) * 1000),
326
+ env: buildServiceEnv(localOnly),
327
+ };
328
+ const source = `'use strict';
329
+
330
+ const fs = require('fs');
331
+ const path = require('path');
332
+ const { spawn } = require('child_process');
333
+
334
+ const config = ${JSON.stringify(config, null, 2)};
335
+ let child = null;
336
+ let stopping = false;
337
+ let exiting = false;
338
+
339
+ function ensureDir(dir) {
340
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
341
+ }
342
+
343
+ function append(file, line) {
344
+ try {
345
+ fs.appendFileSync(file, \`[\${new Date().toISOString()}] [service] \${line}\\n\`);
346
+ } catch {
347
+ // noop
348
+ }
349
+ }
350
+
351
+ function readPid(file) {
352
+ try {
353
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
354
+ if (Number.isInteger(parsed.pid)) return parsed.pid;
355
+ } catch {
356
+ // fall back to raw pid
357
+ }
358
+ try {
359
+ const raw = Number(fs.readFileSync(file, 'utf8').trim());
360
+ return Number.isInteger(raw) ? raw : null;
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ function isPidRunning(pid) {
367
+ if (!Number.isInteger(pid) || pid <= 0) return false;
368
+ try {
369
+ process.kill(pid, 0);
370
+ return true;
371
+ } catch {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ function acquireLock() {
377
+ try {
378
+ fs.mkdirSync(config.lockDir);
379
+ return true;
380
+ } catch {
381
+ const existing = readPid(config.pidFile);
382
+ if (isPidRunning(existing)) return false;
383
+ try { fs.rmSync(config.lockDir, { recursive: true, force: true }); } catch {}
384
+ fs.mkdirSync(config.lockDir);
385
+ return true;
386
+ }
387
+ }
388
+
389
+ function writePid() {
390
+ fs.writeFileSync(config.pidFile, JSON.stringify({
391
+ pid: process.pid,
392
+ backend: 'portable',
393
+ started_at: new Date().toISOString(),
394
+ }, null, 2) + '\\n', { mode: 0o600 });
395
+ }
396
+
397
+ function finish(exitCode = 0) {
398
+ try { fs.rmSync(config.pidFile, { force: true }); } catch {}
399
+ try { fs.rmSync(config.lockDir, { recursive: true, force: true }); } catch {}
400
+ process.exit(exitCode);
401
+ }
402
+
403
+ function cleanup(exitCode = 0) {
404
+ if (exiting) return;
405
+ exiting = true;
406
+ stopping = true;
407
+ try { fs.writeFileSync(config.stopFile, new Date().toISOString() + '\\n', { mode: 0o600 }); } catch {}
408
+ if (child && !child.killed) {
409
+ try { child.kill('SIGTERM'); } catch {}
410
+ const timer = setTimeout(() => {
411
+ if (child && !child.killed) {
412
+ try { child.kill('SIGKILL'); } catch {}
413
+ }
414
+ finish(exitCode);
415
+ }, 5000);
416
+ child.once('exit', () => {
417
+ clearTimeout(timer);
418
+ finish(exitCode);
419
+ });
420
+ return;
421
+ }
422
+ finish(exitCode);
423
+ }
424
+
425
+ function launch() {
426
+ if (stopping || fs.existsSync(config.stopFile)) return cleanup(0);
427
+ append(config.serviceLog, 'starting amalgm run');
428
+ const fd = fs.openSync(config.daemonLog, 'a');
429
+ child = spawn(config.nodeBin, [config.amalgmBin, 'run'], {
430
+ cwd: process.env.HOME || process.cwd(),
431
+ env: { ...process.env, ...config.env },
432
+ stdio: ['ignore', fd, fd],
433
+ windowsHide: true,
434
+ });
435
+ try { fs.closeSync(fd); } catch {}
436
+ child.on('exit', (code, signal) => {
437
+ append(config.serviceLog, \`amalgm run exited code=\${code ?? ''} signal=\${signal ?? ''}\`);
438
+ child = null;
439
+ if (stopping || fs.existsSync(config.stopFile)) return cleanup(0);
440
+ setTimeout(launch, config.restartDelayMs);
441
+ });
442
+ }
443
+
444
+ ensureDir(config.amalgmDir);
445
+ ensureDir(config.logDir);
446
+ if (!acquireLock()) process.exit(0);
447
+ writePid();
448
+ try { fs.rmSync(config.stopFile, { force: true }); } catch {}
449
+ process.on('SIGTERM', () => cleanup(0));
450
+ process.on('SIGINT', () => cleanup(0));
451
+ if (process.platform !== 'win32') process.on('SIGHUP', () => cleanup(0));
452
+ launch();
453
+ `;
454
+ fs.writeFileSync(SERVICE_NODE_SCRIPT_FILE, source, { mode: 0o700 });
455
+ try {
456
+ fs.chmodSync(SERVICE_NODE_SCRIPT_FILE, 0o700);
457
+ } catch {
458
+ // Best effort.
459
+ }
460
+ return SERVICE_NODE_SCRIPT_FILE;
461
+ }
462
+
463
+ function launchdPlistPath() {
464
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
465
+ }
466
+
467
+ function writeLaunchdPlist(localOnly) {
468
+ const plistPath = launchdPlistPath();
469
+ ensureDir(path.dirname(plistPath), 0o700);
470
+ ensureDir(LOG_DIR, 0o700);
471
+ const env = buildServiceEnv(localOnly);
472
+ const envXml = Object.entries(env)
473
+ .map(([key, value]) => ` <key>${plistEscape(key)}</key>\n <string>${plistEscape(value)}</string>`)
474
+ .join('\n');
475
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
476
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
477
+ <plist version="1.0">
478
+ <dict>
479
+ <key>Label</key>
480
+ <string>${plistEscape(SERVICE_LABEL)}</string>
481
+ <key>ProgramArguments</key>
482
+ <array>
483
+ <string>${plistEscape(process.execPath)}</string>
484
+ <string>${plistEscape(AMALGM_BIN)}</string>
485
+ <string>run</string>
486
+ </array>
487
+ <key>EnvironmentVariables</key>
488
+ <dict>
489
+ ${envXml}
490
+ </dict>
491
+ <key>WorkingDirectory</key>
492
+ <string>${plistEscape(os.homedir())}</string>
493
+ <key>KeepAlive</key>
494
+ <true/>
495
+ <key>RunAtLoad</key>
496
+ <true/>
497
+ <key>StandardOutPath</key>
498
+ <string>${plistEscape(path.join(LOG_DIR, 'daemon.log'))}</string>
499
+ <key>StandardErrorPath</key>
500
+ <string>${plistEscape(path.join(LOG_DIR, 'daemon.log'))}</string>
501
+ </dict>
502
+ </plist>
503
+ `;
504
+ fs.writeFileSync(plistPath, plist, { mode: 0o600 });
505
+ return plistPath;
506
+ }
507
+
508
+ function systemdUnitPath() {
509
+ return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`);
510
+ }
511
+
512
+ function writeSystemdUnit(localOnly) {
513
+ const unitPath = systemdUnitPath();
514
+ ensureDir(path.dirname(unitPath), 0o700);
515
+ ensureDir(LOG_DIR, 0o700);
516
+ const env = {
517
+ ...buildServiceEnv(localOnly),
518
+ AMALGM_NODE: process.execPath,
519
+ AMALGM_BIN,
520
+ AMALGM_DAEMON_LOG: path.join(LOG_DIR, 'daemon.log'),
521
+ };
522
+ const envLines = Object.entries(env)
523
+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
524
+ .join('\n');
525
+ const unit = `[Unit]
526
+ Description=Amalgm local runtime
527
+ After=network-online.target
528
+ Wants=network-online.target
529
+
530
+ [Service]
531
+ Type=simple
532
+ WorkingDirectory=${systemdQuote(os.homedir())}
533
+ ${envLines}
534
+ ExecStart=/bin/sh -lc ${systemdQuote('exec "$AMALGM_NODE" "$AMALGM_BIN" run >> "$AMALGM_DAEMON_LOG" 2>&1')}
535
+ Restart=always
536
+ RestartSec=5
537
+ KillSignal=SIGTERM
538
+ TimeoutStopSec=20
539
+
540
+ [Install]
541
+ WantedBy=default.target
542
+ `;
543
+ fs.writeFileSync(unitPath, unit, { mode: 0o600 });
544
+ return unitPath;
545
+ }
546
+
547
+ function serviceStateFor(backend, localOnly, files = {}) {
548
+ return {
549
+ backend,
550
+ label: SERVICE_LABEL,
551
+ local_only: !!localOnly,
552
+ package_version: PACKAGE_VERSION,
553
+ package_root: PACKAGE_ROOT,
554
+ node: process.execPath,
555
+ bin: AMALGM_BIN,
556
+ files,
557
+ updated_at: new Date().toISOString(),
558
+ };
559
+ }
560
+
561
+ function installService(options = {}) {
562
+ ensureDir(AMALGM_DIR, 0o700);
563
+ ensureDir(LOG_DIR, 0o700);
564
+ const localOnly = !!options.localOnly;
565
+ const { backend, detail } = detectServiceBackend(options.mode || 'auto');
566
+ let files = {};
567
+
568
+ if (backend === 'launchd') {
569
+ files.plist = writeLaunchdPlist(localOnly);
570
+ } else if (backend === 'systemd') {
571
+ files.unit = writeSystemdUnit(localOnly);
572
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore', timeout: 5000 });
573
+ } else {
574
+ files.script = writePortableScript(localOnly);
575
+ files.nodeScript = writePortableNodeScript(localOnly);
576
+ }
577
+
578
+ const state = {
579
+ ...serviceStateFor(backend, localOnly, files),
580
+ detail,
581
+ installed_at: new Date().toISOString(),
582
+ };
583
+ writeJson(SERVICE_STATE_FILE, state);
584
+ return state;
585
+ }
586
+
587
+ function stateMatchesRequest(state, options = {}) {
588
+ if (!state) return false;
589
+ if (state.package_root !== PACKAGE_ROOT) return false;
590
+ if (state.package_version !== PACKAGE_VERSION) return false;
591
+ if (!!state.local_only !== !!options.localOnly) return false;
592
+ const requested = normalizeMode(options.mode || 'auto');
593
+ if (requested === 'auto') {
594
+ const desired = detectServiceBackend('auto');
595
+ if (desired.backend !== state.backend) return false;
596
+ } else if (requested !== state.backend) {
597
+ return false;
598
+ }
599
+ return true;
600
+ }
601
+
602
+ function ensureServiceInstalled(options = {}) {
603
+ const state = readJson(SERVICE_STATE_FILE, null);
604
+ if (stateMatchesRequest(state, options)) return state;
605
+ return installService(options);
606
+ }
607
+
608
+ function runCommand(command, args, options = {}) {
609
+ const result = spawnSync(command, args, {
610
+ encoding: 'utf8',
611
+ timeout: options.timeout || 10000,
612
+ stdio: options.stdio || 'pipe',
613
+ });
614
+ if (result.status !== 0) {
615
+ const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
616
+ throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
617
+ }
618
+ return result;
619
+ }
620
+
621
+ function launchdDomain() {
622
+ return `gui/${process.getuid ? process.getuid() : ''}`;
623
+ }
624
+
625
+ function startLaunchd(state) {
626
+ const plist = state?.files?.plist || launchdPlistPath();
627
+ const domain = launchdDomain();
628
+ spawnSync('launchctl', ['bootout', domain, plist], { stdio: 'ignore', timeout: 5000 });
629
+ runCommand('launchctl', ['bootstrap', domain, plist], { timeout: 10000 });
630
+ spawnSync('launchctl', ['enable', `${domain}/${SERVICE_LABEL}`], { stdio: 'ignore', timeout: 5000 });
631
+ spawnSync('launchctl', ['kickstart', '-k', `${domain}/${SERVICE_LABEL}`], { stdio: 'ignore', timeout: 5000 });
632
+ }
633
+
634
+ function stopLaunchd(state) {
635
+ const plist = state?.files?.plist || launchdPlistPath();
636
+ spawnSync('launchctl', ['bootout', launchdDomain(), plist], { stdio: 'ignore', timeout: 10000 });
637
+ }
638
+
639
+ function startSystemd() {
640
+ runCommand('systemctl', ['--user', 'daemon-reload'], { timeout: 10000 });
641
+ spawnSync('systemctl', ['--user', 'enable', SERVICE_LABEL], { stdio: 'ignore', timeout: 10000 });
642
+ runCommand('systemctl', ['--user', 'start', SERVICE_LABEL], { timeout: 10000 });
643
+ }
644
+
645
+ function stopSystemd() {
646
+ spawnSync('systemctl', ['--user', 'stop', SERVICE_LABEL], { stdio: 'ignore', timeout: 10000 });
647
+ }
648
+
649
+ function startPortable() {
650
+ const state = readJson(SERVICE_STATE_FILE, null);
651
+ writePortableScript(state?.local_only);
652
+ writePortableNodeScript(state?.local_only);
653
+ const pid = readPidFile(SERVICE_PID_FILE);
654
+ if (isPidRunning(pid)) return;
655
+ removeFile(SERVICE_STOP_FILE);
656
+ ensureDir(LOG_DIR, 0o700);
657
+ const logFd = fs.openSync(SERVICE_LOG_FILE, 'a');
658
+ const command = process.platform === 'win32' ? process.execPath : '/bin/sh';
659
+ const args = process.platform === 'win32'
660
+ ? [state?.files?.nodeScript || SERVICE_NODE_SCRIPT_FILE]
661
+ : [state?.files?.script || SERVICE_SCRIPT_FILE];
662
+ const child = spawn(command, args, {
663
+ detached: true,
664
+ env: {
665
+ ...process.env,
666
+ ...buildServiceEnv(state?.local_only),
667
+ },
668
+ cwd: os.homedir(),
669
+ stdio: ['ignore', logFd, logFd],
670
+ windowsHide: true,
671
+ });
672
+ child.unref();
673
+ fs.closeSync(logFd);
674
+ writeJson(SERVICE_PID_FILE, {
675
+ pid: child.pid,
676
+ backend: 'portable',
677
+ started_at: new Date().toISOString(),
678
+ });
679
+ }
680
+
681
+ function stopDaemonProcess() {
682
+ const pid = readPidFile(PID_FILE);
683
+ if (!isPidRunning(pid)) {
684
+ removeFile(PID_FILE);
685
+ return false;
686
+ }
687
+ try {
688
+ process.kill(pid, 'SIGTERM');
689
+ } catch {
690
+ return false;
691
+ }
692
+ const deadline = Date.now() + 5000;
693
+ while (Date.now() < deadline && isPidRunning(pid)) {
694
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
695
+ }
696
+ if (isPidRunning(pid)) {
697
+ try {
698
+ process.kill(pid, 'SIGKILL');
699
+ } catch {
700
+ // noop
701
+ }
702
+ }
703
+ removeFile(PID_FILE);
704
+ return true;
705
+ }
706
+
707
+ function stopPortable() {
708
+ ensureDir(AMALGM_DIR, 0o700);
709
+ fs.writeFileSync(SERVICE_STOP_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
710
+ const pid = readPidFile(SERVICE_PID_FILE);
711
+ if (isPidRunning(pid)) {
712
+ try {
713
+ process.kill(pid, 'SIGTERM');
714
+ } catch {
715
+ // noop
716
+ }
717
+ }
718
+ const deadline = Date.now() + 5000;
719
+ while (Date.now() < deadline && isPidRunning(pid)) {
720
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
721
+ }
722
+ removeFile(SERVICE_PID_FILE);
723
+ }
724
+
725
+ function startService(options = {}) {
726
+ const state = ensureServiceInstalled(options);
727
+ removeFile(SERVICE_STOP_FILE);
728
+ if (state.backend === 'launchd') startLaunchd(state);
729
+ else if (state.backend === 'systemd') startSystemd(state);
730
+ else startPortable(state);
731
+ return getServiceStatus();
732
+ }
733
+
734
+ function stopService(options = {}) {
735
+ const state = readJson(SERVICE_STATE_FILE, null);
736
+ ensureDir(AMALGM_DIR, 0o700);
737
+ fs.writeFileSync(SERVICE_STOP_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
738
+
739
+ if (state?.backend === 'launchd') stopLaunchd(state);
740
+ else if (state?.backend === 'systemd') stopSystemd(state);
741
+ else stopPortable();
742
+
743
+ const stoppedDaemon = stopDaemonProcess();
744
+ if (!options.preserveStopFile) removeFile(SERVICE_STOP_FILE);
745
+ return { service: getServiceStatus(), stoppedDaemon };
746
+ }
747
+
748
+ function uninstallService() {
749
+ const state = readJson(SERVICE_STATE_FILE, null);
750
+ stopService({ preserveStopFile: true });
751
+ if (state?.backend === 'systemd') {
752
+ spawnSync('systemctl', ['--user', 'disable', SERVICE_LABEL], { stdio: 'ignore', timeout: 10000 });
753
+ removeFile(state?.files?.unit || systemdUnitPath());
754
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore', timeout: 5000 });
755
+ } else if (state?.backend === 'launchd') {
756
+ removeFile(state?.files?.plist || launchdPlistPath());
757
+ } else {
758
+ removeFile(SERVICE_SCRIPT_FILE);
759
+ removeFile(SERVICE_NODE_SCRIPT_FILE);
760
+ }
761
+ removeFile(SERVICE_STATE_FILE);
762
+ removeFile(SERVICE_STOP_FILE);
763
+ return true;
764
+ }
765
+
766
+ function systemdRunning() {
767
+ if (!systemdAvailable()) return false;
768
+ const result = spawnSync('systemctl', ['--user', 'is-active', '--quiet', SERVICE_LABEL], {
769
+ encoding: 'utf8',
770
+ timeout: 3000,
771
+ });
772
+ const output = [result.stderr, result.stdout].filter(Boolean).join('\n');
773
+ return result.status === 0 && !systemdUnavailableOutput(output);
774
+ }
775
+
776
+ function launchdRunning() {
777
+ const result = spawnSync('launchctl', ['print', `${launchdDomain()}/${SERVICE_LABEL}`], {
778
+ stdio: 'ignore',
779
+ timeout: 3000,
780
+ });
781
+ return result.status === 0;
782
+ }
783
+
784
+ function getServiceStatus() {
785
+ const state = readJson(SERVICE_STATE_FILE, null);
786
+ const backend = state?.backend || null;
787
+ let installed = false;
788
+ let running = false;
789
+ let pid = null;
790
+
791
+ if (backend === 'systemd') {
792
+ installed = fs.existsSync(state?.files?.unit || systemdUnitPath());
793
+ running = installed && systemdRunning();
794
+ } else if (backend === 'launchd') {
795
+ installed = fs.existsSync(state?.files?.plist || launchdPlistPath());
796
+ running = installed && launchdRunning();
797
+ } else if (backend === 'portable') {
798
+ installed = fs.existsSync(state?.files?.script || SERVICE_SCRIPT_FILE)
799
+ || fs.existsSync(state?.files?.nodeScript || SERVICE_NODE_SCRIPT_FILE);
800
+ pid = readPidFile(SERVICE_PID_FILE);
801
+ running = isPidRunning(pid);
802
+ }
803
+
804
+ return {
805
+ backend,
806
+ detail: state?.detail || null,
807
+ installed,
808
+ running,
809
+ pid,
810
+ state,
811
+ stopRequested: fs.existsSync(SERVICE_STOP_FILE),
812
+ };
813
+ }
814
+
815
+ module.exports = {
816
+ detectServiceBackend,
817
+ ensureServiceInstalled,
818
+ getServiceStatus,
819
+ installService,
820
+ startService,
821
+ stopService,
822
+ uninstallService,
823
+ };
package/lib/supervisor.js CHANGED
@@ -4,7 +4,7 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { spawn } = require('child_process');
7
+ const { spawn, spawnSync } = require('child_process');
8
8
  const net = require('net');
9
9
 
10
10
  const {
@@ -14,6 +14,7 @@ const {
14
14
  RUNTIME_DIR,
15
15
  RUNTIME_STATE_FILE,
16
16
  RUNTIME_TOKEN_FILE,
17
+ SHUTDOWN_REASON_FILE,
17
18
  } = require('./paths');
18
19
  const {
19
20
  loadComputerRecord,
@@ -56,6 +57,42 @@ function writeJsonSecret(file, data) {
56
57
  }
57
58
  }
58
59
 
60
+ function readProcessCommand(pid) {
61
+ if (!Number.isInteger(pid) || pid <= 0) return '';
62
+ try {
63
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`);
64
+ const cmd = raw.toString('utf8').replace(/\0/g, ' ').trim();
65
+ if (cmd) return cmd;
66
+ } catch {
67
+ // /proc is Linux-only.
68
+ }
69
+ try {
70
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
71
+ encoding: 'utf8',
72
+ timeout: 1000,
73
+ });
74
+ return String(result.stdout || '').trim();
75
+ } catch {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ function writeShutdownReason(signal) {
81
+ const at = new Date().toISOString();
82
+ const reason = {
83
+ at,
84
+ signal,
85
+ reason: signal ? `received ${signal}` : 'cleanup requested',
86
+ pid: process.pid,
87
+ ppid: process.ppid,
88
+ parent_command: readProcessCommand(process.ppid) || null,
89
+ uptime_seconds: Math.round(process.uptime()),
90
+ package_version: PACKAGE_VERSION,
91
+ };
92
+ writeJsonSecret(SHUTDOWN_REASON_FILE, reason);
93
+ console.log(`[supervisor] shutdown requested signal=${signal || 'unknown'} pid=${process.pid} ppid=${process.ppid} parent=${reason.parent_command || 'unknown'} uptime=${reason.uptime_seconds}s`);
94
+ }
95
+
59
96
  function runtimeEntry(relativePath) {
60
97
  return path.join(RUNTIME_DIR, relativePath);
61
98
  }
@@ -447,9 +484,10 @@ async function startSupervisor(options = {}) {
447
484
 
448
485
  await new Promise((resolve) => {
449
486
  let cleaned = false;
450
- const cleanup = () => {
487
+ const cleanup = (signal) => {
451
488
  if (cleaned) return;
452
489
  cleaned = true;
490
+ writeShutdownReason(signal);
453
491
  for (const tunnel of tunnels) tunnel.stop();
454
492
  for (const proc of managed) proc.stop();
455
493
  try {
@@ -467,9 +505,9 @@ async function startSupervisor(options = {}) {
467
505
  resolve();
468
506
  };
469
507
 
470
- process.once('SIGINT', cleanup);
471
- process.once('SIGTERM', cleanup);
472
- process.once('SIGHUP', cleanup);
508
+ process.once('SIGINT', () => cleanup('SIGINT'));
509
+ process.once('SIGTERM', () => cleanup('SIGTERM'));
510
+ process.once('SIGHUP', () => cleanup('SIGHUP'));
473
511
  });
474
512
  }
475
513
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/service.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"