amalgm 0.0.0 → 0.0.32

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.
Files changed (116) hide show
  1. package/README.md +41 -1
  2. package/bin/amalgm.js +8 -2
  3. package/lib/auth-store.js +223 -0
  4. package/lib/cli.js +1132 -0
  5. package/lib/paths.js +30 -0
  6. package/lib/supervisor.js +476 -0
  7. package/lib/tunnel-chat.js +328 -0
  8. package/lib/tunnel-events.js +499 -0
  9. package/package.json +30 -3
  10. package/runtime/README.md +4 -0
  11. package/runtime/lib/chatInput.js +315 -0
  12. package/runtime/lib/harnesses.js +988 -0
  13. package/runtime/lib/local/amalgmStore.js +136 -0
  14. package/runtime/lib/local/credentialResolver.js +425 -0
  15. package/runtime/lib/mcpApps/registry.js +619 -0
  16. package/runtime/package.json +5 -0
  17. package/runtime/scripts/amalgm-mcp/agents/rest.js +144 -0
  18. package/runtime/scripts/amalgm-mcp/agents/store.js +684 -0
  19. package/runtime/scripts/amalgm-mcp/agents/talk.js +1162 -0
  20. package/runtime/scripts/amalgm-mcp/agents/tools.js +221 -0
  21. package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
  22. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
  23. package/runtime/scripts/amalgm-mcp/artifacts/store.js +157 -0
  24. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
  25. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
  26. package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
  27. package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
  28. package/runtime/scripts/amalgm-mcp/config.js +140 -0
  29. package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
  30. package/runtime/scripts/amalgm-mcp/deps.js +40 -0
  31. package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
  32. package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
  33. package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
  34. package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
  35. package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
  36. package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
  37. package/runtime/scripts/amalgm-mcp/events/store.js +113 -0
  38. package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
  39. package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
  40. package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
  41. package/runtime/scripts/amalgm-mcp/index.js +100 -0
  42. package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
  43. package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
  44. package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
  45. package/runtime/scripts/amalgm-mcp/lib/prefs.js +441 -0
  46. package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
  47. package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
  48. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
  49. package/runtime/scripts/amalgm-mcp/local/rest.js +87 -0
  50. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +234 -0
  51. package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
  52. package/runtime/scripts/amalgm-mcp/server/core-tools.js +20 -0
  53. package/runtime/scripts/amalgm-mcp/server/http.js +377 -0
  54. package/runtime/scripts/amalgm-mcp/server/mcp.js +127 -0
  55. package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
  56. package/runtime/scripts/amalgm-mcp/state/db.js +194 -0
  57. package/runtime/scripts/amalgm-mcp/state/events.js +113 -0
  58. package/runtime/scripts/amalgm-mcp/state/rest.js +64 -0
  59. package/runtime/scripts/amalgm-mcp/state/snapshot.js +76 -0
  60. package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
  61. package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
  62. package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
  63. package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
  64. package/runtime/scripts/amalgm-mcp/tasks/store.js +154 -0
  65. package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
  66. package/runtime/scripts/amalgm-mcp/toolbox/rest.js +75 -0
  67. package/runtime/scripts/amalgm-mcp/toolbox/runner.js +257 -0
  68. package/runtime/scripts/amalgm-mcp/toolbox/store.js +933 -0
  69. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +269 -0
  70. package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +497 -0
  72. package/runtime/scripts/chat-core/adapters/claude.js +165 -0
  73. package/runtime/scripts/chat-core/adapters/codex.js +313 -0
  74. package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
  75. package/runtime/scripts/chat-core/auth.js +177 -0
  76. package/runtime/scripts/chat-core/contract.js +328 -0
  77. package/runtime/scripts/chat-core/credentials/store.js +212 -0
  78. package/runtime/scripts/chat-core/egress.js +87 -0
  79. package/runtime/scripts/chat-core/engine.js +253 -0
  80. package/runtime/scripts/chat-core/event-schema.js +231 -0
  81. package/runtime/scripts/chat-core/events.js +190 -0
  82. package/runtime/scripts/chat-core/index.js +11 -0
  83. package/runtime/scripts/chat-core/input.js +50 -0
  84. package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
  85. package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
  86. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
  87. package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
  88. package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
  89. package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
  90. package/runtime/scripts/chat-core/parts.js +253 -0
  91. package/runtime/scripts/chat-core/recorder.js +65 -0
  92. package/runtime/scripts/chat-core/runtime.js +86 -0
  93. package/runtime/scripts/chat-core/server.js +163 -0
  94. package/runtime/scripts/chat-core/sse.js +196 -0
  95. package/runtime/scripts/chat-core/stores.js +100 -0
  96. package/runtime/scripts/chat-core/tool-display.js +149 -0
  97. package/runtime/scripts/chat-core/tool-shape.js +143 -0
  98. package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
  99. package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
  100. package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
  101. package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
  102. package/runtime/scripts/chat-core/usage.js +343 -0
  103. package/runtime/scripts/chat-server/config.js +110 -0
  104. package/runtime/scripts/chat-server/db.js +529 -0
  105. package/runtime/scripts/chat-server/index.js +33 -0
  106. package/runtime/scripts/chat-server/model-catalog.js +327 -0
  107. package/runtime/scripts/chat-server.js +75 -0
  108. package/runtime/scripts/credential-adapter.js +131 -0
  109. package/runtime/scripts/fs-watcher.js +888 -0
  110. package/runtime/scripts/local-gateway.js +854 -0
  111. package/runtime/scripts/platform-context.txt +246 -0
  112. package/runtime/scripts/port-monitor.js +175 -0
  113. package/runtime/scripts/proxy-token-store.js +162 -0
  114. package/runtime/scripts/runtime-auth.js +163 -0
  115. package/runtime/scripts/test-claude-code-models.js +87 -0
  116. package/runtime/tsconfig.json +15 -0
package/lib/cli.js ADDED
@@ -0,0 +1,1132 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const net = require('net');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const readline = require('readline');
10
+ const { spawn, spawnSync } = require('child_process');
11
+
12
+ const {
13
+ AMALGM_DIR,
14
+ DEFAULT_APP_URL,
15
+ DEVICE_FILE,
16
+ LOG_DIR,
17
+ PID_FILE,
18
+ RUNTIME_DIR,
19
+ RUNTIME_STATE_FILE,
20
+ RUNTIME_TOKEN_FILE,
21
+ } = require('./paths');
22
+ const { startSupervisor } = require('./supervisor');
23
+ const {
24
+ deleteComputerAuth,
25
+ loadComputerRecord,
26
+ proxyTokenFromRecord,
27
+ proxyTokenNeedsRefresh,
28
+ saveComputerRecord,
29
+ } = require('./auth-store');
30
+ const {
31
+ agentCommandShimStatus,
32
+ binaryStatus,
33
+ ensureAgentCommandShims,
34
+ ensureNativeBinaries,
35
+ } = require('../runtime/scripts/chat-core/tooling/native-binaries');
36
+
37
+ const PACKAGE_VERSION = require('../package.json').version;
38
+ const SETUP_CODE_LENGTH = 16;
39
+
40
+ function parseArgs(argv) {
41
+ const args = argv.slice(2);
42
+ const command = args.shift() || 'help';
43
+ const options = {};
44
+ const positionals = [];
45
+
46
+ for (let i = 0; i < args.length; i += 1) {
47
+ const arg = args[i];
48
+ if (!arg.startsWith('--')) {
49
+ positionals.push(arg);
50
+ continue;
51
+ }
52
+
53
+ const eq = arg.indexOf('=');
54
+ if (eq !== -1) {
55
+ options[arg.slice(2, eq)] = arg.slice(eq + 1);
56
+ continue;
57
+ }
58
+
59
+ const key = arg.slice(2);
60
+ const next = args[i + 1];
61
+ if (next && !next.startsWith('--')) {
62
+ options[key] = next;
63
+ i += 1;
64
+ } else {
65
+ options[key] = true;
66
+ }
67
+ }
68
+
69
+ return { command, options, positionals };
70
+ }
71
+
72
+ function usage() {
73
+ return [
74
+ 'amalgm - local computer runtime',
75
+ '',
76
+ 'Usage:',
77
+ ' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
78
+ ' amalgm start [--foreground] [--local-only]',
79
+ ' amalgm stop',
80
+ ' amalgm status',
81
+ ' amalgm doctor',
82
+ ' amalgm update [--tag latest|canary]',
83
+ ' amalgm logs [service]',
84
+ ' amalgm logout',
85
+ '',
86
+ 'Environment:',
87
+ ' AMALGM_APP_URL Web app base URL for login/register',
88
+ ' AMALGM_AUTO_UPDATE Set to 1 to update before start when a newer dist-tag exists',
89
+ ' AMALGM_DIR Runtime state dir (default ~/.amalgm)',
90
+ ' AMALGM_PROXY_TOKEN Optional short-lived proxy token override',
91
+ ].join('\n');
92
+ }
93
+
94
+ function ensureDir(dir, mode = 0o700) {
95
+ fs.mkdirSync(dir, { recursive: true, mode });
96
+ try {
97
+ fs.chmodSync(dir, mode);
98
+ } catch {
99
+ // chmod is best-effort on non-POSIX filesystems.
100
+ }
101
+ }
102
+
103
+ function ensureBaseDirs() {
104
+ ensureDir(AMALGM_DIR, 0o700);
105
+ ensureDir(LOG_DIR, 0o700);
106
+ }
107
+
108
+ function readJson(file, fallback = null) {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
111
+ } catch {
112
+ return fallback;
113
+ }
114
+ }
115
+
116
+ function writeJsonSecret(file, data) {
117
+ ensureDir(path.dirname(file), 0o700);
118
+ const temp = `${file}.${process.pid}.tmp`;
119
+ fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
120
+ fs.renameSync(temp, file);
121
+ try {
122
+ fs.chmodSync(file, 0o600);
123
+ } catch {
124
+ // chmod is best-effort on non-POSIX filesystems.
125
+ }
126
+ }
127
+
128
+ function runtimeEntry(relativePath) {
129
+ return path.join(RUNTIME_DIR, relativePath);
130
+ }
131
+
132
+ function runtimeMissingFiles() {
133
+ const required = [
134
+ runtimeEntry('scripts/amalgm-mcp/index.js'),
135
+ runtimeEntry('scripts/chat-server.js'),
136
+ runtimeEntry('scripts/fs-watcher.js'),
137
+ runtimeEntry('scripts/local-gateway.js'),
138
+ runtimeEntry('scripts/port-monitor.js'),
139
+ ];
140
+ return required.filter((file) => !fs.existsSync(file));
141
+ }
142
+
143
+ function servicePorts() {
144
+ const state = readJson(RUNTIME_STATE_FILE, null);
145
+ const statePorts = state?.ports || {};
146
+ if (Number.isInteger(state?.gateway_port)) {
147
+ return [
148
+ ['local-gateway', Number(state.gateway_port)],
149
+ ['port-monitor', Number(statePorts.port_monitor || 0)],
150
+ ['fs-watcher', Number(statePorts.fs_watcher || 0)],
151
+ ['amalgm-mcp', Number(statePorts.amalgm_mcp || 0)],
152
+ ['chat-server', Number(statePorts.chat_server || 0)],
153
+ ].filter(([, port]) => Number.isInteger(port) && port > 0);
154
+ }
155
+
156
+ return [
157
+ ['local-gateway', Number(process.env.AMALGM_GATEWAY_PORT || 28781)],
158
+ ['port-monitor', Number(process.env.PORT_MONITOR_PORT || 8081)],
159
+ ['fs-watcher', Number(process.env.FS_WATCHER_PORT || 8082)],
160
+ ['amalgm-mcp', Number(process.env.AMALGM_MCP_PORT || 8083)],
161
+ ['chat-server', Number(process.env.CHAT_SERVER_PORT || 8084)],
162
+ ];
163
+ }
164
+
165
+ function hasRequiredComputerFields(record) {
166
+ return !!(
167
+ record
168
+ && record.computer_id
169
+ && record.webhook_url
170
+ && record.tunnel_url
171
+ && record.tunnel_token
172
+ );
173
+ }
174
+
175
+ const SAFE_DAEMON_ENV_KEYS = [
176
+ 'AMALGM_BIND_HOST',
177
+ 'AMALGM_DEFAULT_CWD',
178
+ 'AMALGM_GATEWAY_PORT',
179
+ 'AMALGM_MCP_PORT',
180
+ 'AMALGM_NATIVE_NODE_MODULES',
181
+ 'AMALGM_SKIP_NATIVE_INSTALL',
182
+ 'CHAT_SERVER_PORT',
183
+ 'ELECTRON_RUN_AS_NODE',
184
+ 'FS_WATCHER_PORT',
185
+ 'FS_WATCHER_ROOT',
186
+ 'HOME',
187
+ 'LANG',
188
+ 'LC_ALL',
189
+ 'LOGNAME',
190
+ 'NODE_PATH',
191
+ 'PATH',
192
+ 'PORT_MONITOR_PORT',
193
+ 'SHELL',
194
+ 'SSH_AUTH_SOCK',
195
+ 'TERM',
196
+ 'TMP',
197
+ 'TMPDIR',
198
+ 'TEMP',
199
+ 'USER',
200
+ 'XDG_CACHE_HOME',
201
+ 'XDG_CONFIG_HOME',
202
+ 'XDG_DATA_HOME',
203
+ ];
204
+
205
+ function daemonEnv(localOnly) {
206
+ const env = {};
207
+ for (const key of SAFE_DAEMON_ENV_KEYS) {
208
+ if (process.env[key]) env[key] = process.env[key];
209
+ }
210
+ env.AMALGM_DIR = AMALGM_DIR;
211
+ if (localOnly) env.AMALGM_LOCAL_ONLY = 'true';
212
+ return env;
213
+ }
214
+
215
+ function normalizeBaseUrl(value) {
216
+ const url = new URL(String(value || DEFAULT_APP_URL));
217
+ url.pathname = url.pathname.replace(/\/+$/, '');
218
+ url.search = '';
219
+ url.hash = '';
220
+ return url.toString().replace(/\/$/, '');
221
+ }
222
+
223
+ function machineName() {
224
+ return process.env.AMALGM_COMPUTER_NAME || os.hostname() || 'Amalgm computer';
225
+ }
226
+
227
+ function platformName() {
228
+ return `${process.platform}-${process.arch}`;
229
+ }
230
+
231
+ function normalizeSetupCode(value) {
232
+ return String(value || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, SETUP_CODE_LENGTH);
233
+ }
234
+
235
+ function formatSetupCode(value) {
236
+ const normalized = normalizeSetupCode(value);
237
+ return normalized.match(/.{1,4}/g)?.join('-') || '';
238
+ }
239
+
240
+ async function ensureDevice() {
241
+ const existing = readJson(DEVICE_FILE, null);
242
+ if (existing && typeof existing.device_id === 'string' && existing.device_id.length >= 8) {
243
+ return existing;
244
+ }
245
+
246
+ const device = {
247
+ device_id: `npm-${crypto.randomUUID()}`,
248
+ created_at: new Date().toISOString(),
249
+ };
250
+ writeJsonSecret(DEVICE_FILE, device);
251
+ return device;
252
+ }
253
+
254
+ async function requestJson(url, options = {}) {
255
+ let res;
256
+ try {
257
+ res = await fetch(url, {
258
+ ...options,
259
+ headers: {
260
+ accept: 'application/json',
261
+ ...(options.body ? { 'content-type': 'application/json' } : {}),
262
+ ...(options.headers || {}),
263
+ },
264
+ });
265
+ } catch (error) {
266
+ const method = String(options.method || 'GET').toUpperCase();
267
+ const cause = error?.cause || {};
268
+ const causeDetail = [
269
+ cause.code,
270
+ cause.errno,
271
+ cause.syscall,
272
+ cause.hostname,
273
+ cause.address,
274
+ cause.port,
275
+ ].filter(Boolean).join(' ');
276
+ const lines = [
277
+ `Could not reach the Amalgm web app (${method} ${url}).`,
278
+ `Cause: ${causeDetail || error.message || 'network request failed'}`,
279
+ 'Check network, DNS, TLS certificates, or pass --app-url to target a different deployment.',
280
+ ];
281
+ if (String(url).includes('/api/auth/computer-setup/cli/start')) {
282
+ lines.push('For headless machines, create a setup code in the web app and run `amalgm login --setup-code <code>`.');
283
+ }
284
+ const wrapped = new Error(lines.join('\n'));
285
+ wrapped.cause = error;
286
+ throw wrapped;
287
+ }
288
+
289
+ const text = await res.text();
290
+ let payload = {};
291
+ if (text) {
292
+ try {
293
+ payload = JSON.parse(text);
294
+ } catch {
295
+ payload = { text };
296
+ }
297
+ }
298
+
299
+ if (!res.ok) {
300
+ const detail = payload && payload.error ? payload.error : text || res.statusText;
301
+ const error = new Error(`${res.status} ${detail}`);
302
+ error.status = res.status;
303
+ error.payload = payload;
304
+ throw error;
305
+ }
306
+
307
+ return payload;
308
+ }
309
+
310
+ function sleep(ms) {
311
+ return new Promise((resolve) => setTimeout(resolve, ms));
312
+ }
313
+
314
+ function assertRuntimePresent() {
315
+ const missing = runtimeMissingFiles();
316
+ if (missing.length > 0) {
317
+ throw new Error(
318
+ [
319
+ 'The Amalgm runtime is missing from this package.',
320
+ 'If you are developing locally, run `npm run npm:sync` from amalgm-engine.',
321
+ 'If this came from npm, reinstall the package.',
322
+ `Missing: ${missing[0]}`,
323
+ ].join('\n'),
324
+ );
325
+ }
326
+ }
327
+
328
+ function isPortFree(port, host = '127.0.0.1') {
329
+ return new Promise((resolve) => {
330
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
331
+ resolve(false);
332
+ return;
333
+ }
334
+
335
+ const server = net.createServer();
336
+ server.unref();
337
+ server.on('error', () => resolve(false));
338
+ server.listen({ port, host }, () => {
339
+ server.close(() => resolve(true));
340
+ });
341
+ });
342
+ }
343
+
344
+ async function unavailableServicePorts() {
345
+ const results = [];
346
+ const explicitEnvByService = new Map([
347
+ ['local-gateway', 'AMALGM_GATEWAY_PORT'],
348
+ ['port-monitor', 'PORT_MONITOR_PORT'],
349
+ ['fs-watcher', 'FS_WATCHER_PORT'],
350
+ ['amalgm-mcp', 'AMALGM_MCP_PORT'],
351
+ ['chat-server', 'CHAT_SERVER_PORT'],
352
+ ]);
353
+ for (const [name, port] of servicePorts()) {
354
+ const envName = explicitEnvByService.get(name);
355
+ if (!envName || !process.env[envName]) continue;
356
+ if (!(await isPortFree(port))) results.push([name, port]);
357
+ }
358
+ return results;
359
+ }
360
+
361
+ function commandExists(command) {
362
+ if (!command || process.platform === 'win32') return true;
363
+ const paths = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
364
+ return paths.some((dir) => {
365
+ try {
366
+ fs.accessSync(path.join(dir, command), fs.constants.X_OK);
367
+ return true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ });
372
+ }
373
+
374
+ function parseSemver(value) {
375
+ const [core, prerelease = ''] = String(value || '').trim().replace(/^v/, '').split('-', 2);
376
+ const parts = core.split('.').map((part) => Number.parseInt(part, 10));
377
+ return {
378
+ major: Number.isFinite(parts[0]) ? parts[0] : 0,
379
+ minor: Number.isFinite(parts[1]) ? parts[1] : 0,
380
+ patch: Number.isFinite(parts[2]) ? parts[2] : 0,
381
+ prerelease: prerelease ? prerelease.split('.') : [],
382
+ };
383
+ }
384
+
385
+ function comparePrerelease(left, right) {
386
+ if (left.length === 0 && right.length === 0) return 0;
387
+ if (left.length === 0) return 1;
388
+ if (right.length === 0) return -1;
389
+
390
+ const length = Math.max(left.length, right.length);
391
+ for (let i = 0; i < length; i += 1) {
392
+ const a = left[i];
393
+ const b = right[i];
394
+ if (a === undefined) return -1;
395
+ if (b === undefined) return 1;
396
+ const aNum = /^\d+$/.test(a) ? Number.parseInt(a, 10) : null;
397
+ const bNum = /^\d+$/.test(b) ? Number.parseInt(b, 10) : null;
398
+ if (aNum !== null && bNum !== null && aNum !== bNum) return aNum > bNum ? 1 : -1;
399
+ if (aNum !== null && bNum === null) return -1;
400
+ if (aNum === null && bNum !== null) return 1;
401
+ if (a !== b) return a > b ? 1 : -1;
402
+ }
403
+ return 0;
404
+ }
405
+
406
+ function compareVersions(left, right) {
407
+ const a = parseSemver(left);
408
+ const b = parseSemver(right);
409
+ for (const key of ['major', 'minor', 'patch']) {
410
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
411
+ }
412
+ return comparePrerelease(a.prerelease, b.prerelease);
413
+ }
414
+
415
+ function npmCommand() {
416
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
417
+ }
418
+
419
+ function npmInstallTag(options = {}) {
420
+ const explicit = options.tag || options.channel;
421
+ if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
422
+ return PACKAGE_VERSION.includes('-canary.') ? 'canary' : 'latest';
423
+ }
424
+
425
+ function npmPackageVersionForTag(tag) {
426
+ const result = spawnSync(npmCommand(), ['view', `amalgm@${tag}`, 'version', '--json'], {
427
+ encoding: 'utf8',
428
+ timeout: 15000,
429
+ env: {
430
+ ...process.env,
431
+ npm_config_audit: 'false',
432
+ npm_config_fund: 'false',
433
+ },
434
+ });
435
+ if (result.status !== 0) {
436
+ const detail = String(result.stderr || result.stdout || '').trim();
437
+ throw new Error(`Could not check npm dist-tag "${tag}". ${detail}`);
438
+ }
439
+
440
+ const raw = String(result.stdout || '').trim();
441
+ try {
442
+ return JSON.parse(raw);
443
+ } catch {
444
+ return raw.replace(/^"|"$/g, '');
445
+ }
446
+ }
447
+
448
+ function installNpmPackageTag(tag) {
449
+ return spawnSync(npmCommand(), ['install', '-g', `amalgm@${tag}`], {
450
+ stdio: 'inherit',
451
+ env: {
452
+ ...process.env,
453
+ npm_config_audit: 'false',
454
+ npm_config_fund: 'false',
455
+ },
456
+ });
457
+ }
458
+
459
+ function openBrowser(url) {
460
+ const command =
461
+ process.platform === 'darwin'
462
+ ? 'open'
463
+ : process.platform === 'win32'
464
+ ? 'cmd'
465
+ : 'xdg-open';
466
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
467
+
468
+ try {
469
+ if (!commandExists(command)) return false;
470
+ const child = spawn(command, args, {
471
+ detached: true,
472
+ stdio: 'ignore',
473
+ windowsHide: true,
474
+ });
475
+ child.once('error', () => {
476
+ // Browser opening is opportunistic. The login URL is always printed.
477
+ });
478
+ child.unref();
479
+ return true;
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ async function registerComputer(appUrl, registrationToken, device, name) {
486
+ const registered = await requestJson(`${appUrl}/api/computers/register`, {
487
+ method: 'POST',
488
+ headers: {
489
+ authorization: `Bearer ${registrationToken}`,
490
+ },
491
+ body: JSON.stringify({
492
+ device_id: device.device_id,
493
+ name,
494
+ platform: platformName(),
495
+ app_version: PACKAGE_VERSION,
496
+ }),
497
+ });
498
+
499
+ const record = {
500
+ ...registered,
501
+ app_url: appUrl,
502
+ name,
503
+ platform: platformName(),
504
+ app_version: PACKAGE_VERSION,
505
+ registered_at: new Date().toISOString(),
506
+ };
507
+ saveComputerRecord(record);
508
+
509
+ console.log(`Registered computer: ${record.computer_id}`);
510
+ console.log(`Events URL: ${record.webhook_url}`);
511
+ if (!proxyTokenFromRecord(record)) {
512
+ console.log('HMAC proxy: not minted yet; Amalgm-managed egress will refresh after registration is complete.');
513
+ }
514
+ console.log('Run `amalgm start` to connect this machine.');
515
+ return record;
516
+ }
517
+
518
+ async function refreshStoredProxyToken(record) {
519
+ const refreshToken = record?.computer_auth_token || record?.tunnel_token;
520
+ if (!refreshToken || !record?.computer_id || !record?.app_url) return record;
521
+ if (!proxyTokenNeedsRefresh(record)) return record;
522
+
523
+ const refreshed = await requestJson(`${record.app_url}/api/computers/proxy-token`, {
524
+ method: 'POST',
525
+ headers: {
526
+ authorization: `Bearer ${refreshToken}`,
527
+ },
528
+ body: JSON.stringify({
529
+ computer_id: record.computer_id,
530
+ device_id: record.device_id || '',
531
+ }),
532
+ });
533
+
534
+ const next = {
535
+ ...record,
536
+ proxy_token: refreshed.proxy_token || record.proxy_token,
537
+ proxy_token_expires_in: refreshed.proxy_token_expires_in || record.proxy_token_expires_in,
538
+ proxy_token_expires_at: refreshed.proxy_token_expires_at || record.proxy_token_expires_at,
539
+ proxy_token_refreshed_at: new Date().toISOString(),
540
+ };
541
+ return saveComputerRecord(next);
542
+ }
543
+
544
+ async function loginWithSetupCode(appUrl, setupCode, device, name) {
545
+ const normalizedCode = normalizeSetupCode(setupCode);
546
+ if (normalizedCode.length !== SETUP_CODE_LENGTH) {
547
+ throw new Error('Invalid setup code. It should look like ABCD-EFGH-JKLM-NPQR.');
548
+ }
549
+
550
+ console.log(`Connecting this computer to Amalgm as ${name}`);
551
+ const redeemed = await requestJson(`${appUrl}/api/auth/computer-setup/redeem`, {
552
+ method: 'POST',
553
+ body: JSON.stringify({
554
+ setup_code: normalizedCode,
555
+ device_id: device.device_id,
556
+ name,
557
+ platform: platformName(),
558
+ app_version: PACKAGE_VERSION,
559
+ }),
560
+ });
561
+
562
+ if (!redeemed.computer_registration_token) {
563
+ throw new Error('Setup code was accepted, but no registration token was returned.');
564
+ }
565
+
566
+ return registerComputer(appUrl, redeemed.computer_registration_token, device, name);
567
+ }
568
+
569
+ function appUrlFlag(appUrl) {
570
+ return appUrl === normalizeBaseUrl(DEFAULT_APP_URL) ? '' : ` --app-url ${JSON.stringify(appUrl)}`;
571
+ }
572
+
573
+ async function promptYesNo(question, defaultYes = true) {
574
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
575
+
576
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
577
+ const rl = readline.createInterface({
578
+ input: process.stdin,
579
+ output: process.stdout,
580
+ });
581
+
582
+ try {
583
+ const answer = await new Promise((resolve) => {
584
+ rl.question(`${question}${suffix}`, resolve);
585
+ });
586
+ const normalized = String(answer || '').trim().toLowerCase();
587
+ if (!normalized) return defaultYes;
588
+ return normalized === 'y' || normalized === 'yes';
589
+ } finally {
590
+ rl.close();
591
+ }
592
+ }
593
+
594
+ async function promptForFreshLogin(appUrl, device, name, options, reason) {
595
+ if (!(await promptYesNo(`${reason} Create a new setup code?`))) {
596
+ throw new Error('Login was not completed. Run `amalgm login` again to create a fresh setup code.');
597
+ }
598
+ return startCliLogin(appUrl, device, name, options);
599
+ }
600
+
601
+ async function startCliLogin(appUrl, device, name, options) {
602
+ console.log(`Starting Amalgm login for ${name}`);
603
+ const started = await requestJson(`${appUrl}/api/auth/computer-setup/cli/start`, {
604
+ method: 'POST',
605
+ body: JSON.stringify({
606
+ device_id: device.device_id,
607
+ name,
608
+ platform: platformName(),
609
+ app_version: PACKAGE_VERSION,
610
+ }),
611
+ });
612
+
613
+ if (!started.device_code || !started.setup_code || !started.verification_url) {
614
+ throw new Error('Login started, but the server did not return a setup URL.');
615
+ }
616
+
617
+ const setupCode = formatSetupCode(started.setup_code);
618
+ const verificationUrl = String(started.verification_url);
619
+ const manualCommand = `amalgm login --setup-code ${setupCode}${appUrlFlag(appUrl)}`;
620
+
621
+ console.log('');
622
+ if (!options['no-open'] && openBrowser(verificationUrl)) {
623
+ console.log('Opened your browser to finish login.');
624
+ } else {
625
+ console.log('Open this URL in any browser to finish login:');
626
+ }
627
+ console.log(verificationUrl);
628
+ console.log('');
629
+ console.log(`Setup code: ${setupCode}`);
630
+ console.log('');
631
+ console.log('Headless/SSH/container flow: approve the code in your browser, then paste this command back here if polling is unavailable:');
632
+ console.log(` ${manualCommand}`);
633
+
634
+ if (options['no-poll']) {
635
+ console.log('');
636
+ console.log('Polling disabled. Run the command above after approving the setup code.');
637
+ return null;
638
+ }
639
+
640
+ const intervalMs = Math.max(1, Number(started.interval || 2)) * 1000;
641
+ const deadline = Date.now() + Math.max(60, Number(started.expires_in || 180)) * 1000;
642
+ let lastWaitingLog = 0;
643
+
644
+ console.log('');
645
+ console.log('Waiting for browser approval...');
646
+
647
+ while (Date.now() < deadline) {
648
+ let polled;
649
+ try {
650
+ polled = await requestJson(`${appUrl}/api/auth/computer-setup/cli/poll`, {
651
+ method: 'POST',
652
+ body: JSON.stringify({
653
+ device_code: started.device_code,
654
+ device_id: device.device_id,
655
+ name,
656
+ platform: platformName(),
657
+ app_version: PACKAGE_VERSION,
658
+ }),
659
+ });
660
+ } catch (error) {
661
+ if (error.status === 410) {
662
+ return promptForFreshLogin(appUrl, device, name, options, 'Setup code expired.');
663
+ }
664
+ throw error;
665
+ }
666
+
667
+ if (polled.status === 'approved' && polled.computer_registration_token) {
668
+ if (polled.approved_by?.label) {
669
+ console.log(`Approved by: ${polled.approved_by.label}`);
670
+ }
671
+ console.log('Browser approved. Registering this computer...');
672
+ return registerComputer(appUrl, polled.computer_registration_token, device, name);
673
+ }
674
+
675
+ if (polled.status === 'registered') {
676
+ console.log('This setup code is already registered. Run `amalgm status` to inspect local state.');
677
+ return null;
678
+ }
679
+
680
+ const now = Date.now();
681
+ if (now - lastWaitingLog > 15000) {
682
+ lastWaitingLog = now;
683
+ console.log('Still waiting for approval...');
684
+ }
685
+ await sleep(intervalMs);
686
+ }
687
+
688
+ return promptForFreshLogin(appUrl, device, name, options, 'Login timed out.');
689
+ }
690
+
691
+ async function login(options) {
692
+ ensureBaseDirs();
693
+ const appUrl = normalizeBaseUrl(options['app-url'] || DEFAULT_APP_URL);
694
+ const device = await ensureDevice();
695
+ const name = String(options.name || machineName()).slice(0, 120);
696
+ const setupCode = options['setup-code'] || options.code;
697
+
698
+ if (setupCode) {
699
+ return loginWithSetupCode(appUrl, setupCode, device, name);
700
+ }
701
+
702
+ return startCliLogin(appUrl, device, name, options);
703
+ }
704
+
705
+ function isPidRunning(pid) {
706
+ if (!pid || !Number.isInteger(pid)) return false;
707
+ try {
708
+ process.kill(pid, 0);
709
+ return true;
710
+ } catch {
711
+ return false;
712
+ }
713
+ }
714
+
715
+ function readPid() {
716
+ const raw = readJson(PID_FILE, null);
717
+ if (raw && Number.isInteger(raw.pid)) return raw.pid;
718
+ try {
719
+ const parsed = Number(fs.readFileSync(PID_FILE, 'utf8').trim());
720
+ return Number.isInteger(parsed) ? parsed : null;
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+
726
+ function runningDaemonRestartReason(pid, record, localOnly) {
727
+ const state = readJson(RUNTIME_STATE_FILE, null);
728
+ const supervisorPid = Number(state?.supervisor_pid || state?.pid || 0);
729
+ if (!state || supervisorPid !== pid) return 'runtime ownership state is missing';
730
+ if (state.source && state.source !== 'npm') return `it is owned by ${state.source}`;
731
+ if (state.package_version && state.package_version !== PACKAGE_VERSION) {
732
+ return `it is running package ${state.package_version}, not ${PACKAGE_VERSION}`;
733
+ }
734
+ if (state.runtime_dir && state.runtime_dir !== RUNTIME_DIR) {
735
+ return 'it is running a different package install';
736
+ }
737
+ if (!!state.local_only !== !!localOnly) {
738
+ return localOnly ? 'it is running in registered mode' : 'it is running in local-only mode';
739
+ }
740
+ if (!localOnly && (state.computer_id || '') !== (record?.computer_id || '')) {
741
+ return `it is connected as ${state.computer_id || 'another computer'}`;
742
+ }
743
+ return '';
744
+ }
745
+
746
+ function ensureAgentRuntimeCommands(options = {}) {
747
+ const logger = options.logger || console;
748
+ const nativeResult = ensureNativeBinaries({ logger, quiet: !!options.quiet });
749
+ if (!nativeResult.ok) {
750
+ logger.warn?.(`Could not install all agent binaries: ${nativeResult.error?.message || 'unknown error'}`);
751
+ const missing = nativeResult.after.filter((item) => !item.ok).map((item) => item.name);
752
+ if (missing.length > 0) logger.warn?.(`Missing agents: ${missing.join(', ')}`);
753
+ } else if (nativeResult.installed && !options.quiet) {
754
+ logger.log?.('Agent binaries are ready.');
755
+ }
756
+
757
+ const shimResult = ensureAgentCommandShims({ logger, quiet: !!options.quiet });
758
+ if (!shimResult.ok) {
759
+ logger.warn?.(`Could not expose all agent commands: ${shimResult.error?.message || 'unknown error'}`);
760
+ }
761
+ }
762
+
763
+ async function update(options = {}) {
764
+ const tag = npmInstallTag(options);
765
+ const targetVersion = npmPackageVersionForTag(tag);
766
+ if (!targetVersion) throw new Error(`npm did not return a version for amalgm@${tag}`);
767
+
768
+ if (compareVersions(targetVersion, PACKAGE_VERSION) <= 0) {
769
+ console.log(`Amalgm is already current for ${tag}: ${PACKAGE_VERSION}`);
770
+ return false;
771
+ }
772
+
773
+ console.log(`Updating Amalgm from ${PACKAGE_VERSION} to ${targetVersion} (${tag}).`);
774
+ const result = installNpmPackageTag(tag);
775
+ if (result.status !== 0) {
776
+ throw new Error(`npm install -g amalgm@${tag} failed with status ${result.status ?? result.signal ?? 'unknown'}`);
777
+ }
778
+
779
+ console.log(`Amalgm updated to ${targetVersion}.`);
780
+ const pid = readPid();
781
+ if (isPidRunning(pid)) {
782
+ console.log('A previous Amalgm daemon is still running. Run `amalgm stop && amalgm start` to restart it with the new package.');
783
+ }
784
+ return true;
785
+ }
786
+
787
+ async function updateBeforeStartIfRequested(options = {}) {
788
+ const requested = options['auto-update'] || process.env.AMALGM_AUTO_UPDATE === '1';
789
+ if (!requested) return false;
790
+
791
+ try {
792
+ return await update(options);
793
+ } catch (error) {
794
+ console.warn(`Auto-update skipped: ${error.message}`);
795
+ return false;
796
+ }
797
+ }
798
+
799
+ async function start(options) {
800
+ ensureBaseDirs();
801
+
802
+ if (await updateBeforeStartIfRequested(options)) {
803
+ console.log('Run `amalgm start` again to launch the updated package.');
804
+ return;
805
+ }
806
+
807
+ assertRuntimePresent();
808
+
809
+ let record = loadComputerRecord({ migrate: true });
810
+ const localOnly = !!options['local-only'];
811
+ if (!localOnly && !hasRequiredComputerFields(record)) {
812
+ throw new Error('This machine is not logged in. Run `amalgm login` to authorize it in your browser, or use `amalgm start --local-only` for local-only development.');
813
+ }
814
+ if (!localOnly) {
815
+ try {
816
+ record = await refreshStoredProxyToken(record);
817
+ } catch (error) {
818
+ console.warn(`Could not refresh proxy token before start: ${error.message}`);
819
+ }
820
+ }
821
+
822
+ ensureAgentRuntimeCommands({ logger: console });
823
+
824
+ const pid = readPid();
825
+ if (isPidRunning(pid)) {
826
+ const restartReason = runningDaemonRestartReason(pid, record, localOnly);
827
+ if (!restartReason) {
828
+ console.log(`Amalgm is already running (pid ${pid}).`);
829
+ return;
830
+ }
831
+ console.log(`Amalgm is already running (pid ${pid}), but ${restartReason}. Restarting.`);
832
+ await stop();
833
+ }
834
+
835
+ const blockedPorts = await unavailableServicePorts();
836
+ if (blockedPorts.length > 0) {
837
+ const detail = blockedPorts.map(([name, port]) => `${name} (${port})`).join(', ');
838
+ throw new Error(`Cannot start Amalgm because explicitly configured port(s) are already in use: ${detail}`);
839
+ }
840
+
841
+ if (options.foreground) {
842
+ await startSupervisor({ foreground: true, localOnly });
843
+ return;
844
+ }
845
+
846
+ ensureDir(LOG_DIR);
847
+ const logPath = path.join(LOG_DIR, 'daemon.log');
848
+ const logFd = fs.openSync(logPath, 'a');
849
+ const child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'amalgm.js'), 'run'], {
850
+ detached: true,
851
+ env: daemonEnv(localOnly),
852
+ stdio: ['ignore', logFd, logFd],
853
+ windowsHide: true,
854
+ });
855
+
856
+ child.unref();
857
+ fs.closeSync(logFd);
858
+ writeJsonSecret(PID_FILE, { pid: child.pid, started_at: new Date().toISOString() });
859
+ console.log(`Amalgm started (pid ${child.pid}).`);
860
+ console.log(`Logs: ${logPath}`);
861
+
862
+ const ready = await waitForServices(8000);
863
+ const pending = ready.filter(([, ok]) => !ok).map(([name]) => name);
864
+ if (pending.length === 0) {
865
+ console.log('Services: all local essentials are up.');
866
+ } else {
867
+ console.log(`Services still warming up: ${pending.join(', ')}. Run \`amalgm status\` or \`amalgm logs\` if they stay down.`);
868
+ }
869
+ }
870
+
871
+ async function stop() {
872
+ const pid = readPid();
873
+ if (!isPidRunning(pid)) {
874
+ try {
875
+ fs.unlinkSync(PID_FILE);
876
+ } catch {
877
+ // noop
878
+ }
879
+ console.log('Amalgm is not running.');
880
+ return;
881
+ }
882
+
883
+ process.kill(pid, 'SIGTERM');
884
+ for (let i = 0; i < 25; i += 1) {
885
+ await sleep(200);
886
+ if (!isPidRunning(pid)) break;
887
+ }
888
+
889
+ if (isPidRunning(pid)) {
890
+ try {
891
+ process.kill(pid, 'SIGKILL');
892
+ } catch {
893
+ // noop
894
+ }
895
+ }
896
+
897
+ try {
898
+ fs.unlinkSync(PID_FILE);
899
+ } catch {
900
+ // noop
901
+ }
902
+ console.log('Amalgm stopped.');
903
+ }
904
+
905
+ async function checkHttp(port, pathName = '/healthz') {
906
+ return new Promise((resolve) => {
907
+ const req = http.request(
908
+ {
909
+ host: '127.0.0.1',
910
+ port,
911
+ path: pathName,
912
+ method: 'GET',
913
+ timeout: 1000,
914
+ },
915
+ (res) => {
916
+ res.resume();
917
+ res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 500));
918
+ },
919
+ );
920
+ req.on('timeout', () => {
921
+ req.destroy();
922
+ resolve(false);
923
+ });
924
+ req.on('error', () => resolve(false));
925
+ req.end();
926
+ });
927
+ }
928
+
929
+ async function waitForServices(timeoutMs) {
930
+ const deadline = Date.now() + timeoutMs;
931
+ let latest = [];
932
+ while (Date.now() < deadline) {
933
+ latest = [];
934
+ for (const [name, port] of servicePorts()) {
935
+ latest.push([name, await checkHttp(port)]);
936
+ }
937
+ if (latest.every(([, ok]) => ok)) return latest;
938
+ await sleep(300);
939
+ }
940
+ return latest;
941
+ }
942
+
943
+ async function status() {
944
+ const pid = readPid();
945
+ const record = loadComputerRecord({ migrate: true });
946
+ const runtimeState = readJson(RUNTIME_STATE_FILE, null);
947
+
948
+ console.log(`Daemon: ${isPidRunning(pid) ? `running (pid ${pid})` : 'stopped'}`);
949
+ console.log(`State: ${AMALGM_DIR}`);
950
+ if (runtimeState?.gateway_url) console.log(`Gateway: ${runtimeState.gateway_url}`);
951
+ console.log(
952
+ `Computer: ${
953
+ record?.computer_id
954
+ ? `${record.computer_id} (${record.name || record.device_id || 'unnamed'})`
955
+ : 'not logged in'
956
+ }`,
957
+ );
958
+ if (record?.webhook_url) console.log(`Events: ${record.webhook_url}`);
959
+ if (record?.tunnel_url) console.log(`Tunnel: ${record.tunnel_url}`);
960
+ if (record?.tunnel_token) {
961
+ console.log(`Chat tunnel: ${record.chat_tunnel_url || 'wss://amalgm-chat-gateway.fly.dev/wire'}`);
962
+ }
963
+ console.log(`HMAC proxy: ${proxyTokenFromRecord(record) ? 'configured' : 'not configured'}`);
964
+
965
+ for (const [name, port] of servicePorts()) {
966
+ const ok = await checkHttp(port);
967
+ console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}`);
968
+ }
969
+ }
970
+
971
+ async function doctor() {
972
+ ensureBaseDirs();
973
+
974
+ const checks = [];
975
+ const add = (name, ok, detail = '', level = ok ? 'ok' : 'err') => {
976
+ checks.push({ name, ok, detail, level });
977
+ };
978
+
979
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
980
+ add('node', nodeMajor >= 20, process.version);
981
+
982
+ const missing = runtimeMissingFiles();
983
+ add('runtime', missing.length === 0, missing.length === 0 ? RUNTIME_DIR : `missing ${missing[0]}`);
984
+
985
+ const record = loadComputerRecord({ migrate: true });
986
+ add(
987
+ 'login',
988
+ hasRequiredComputerFields(record),
989
+ record?.computer_id ? `computer ${record.computer_id}` : 'not logged in',
990
+ );
991
+ add(
992
+ 'hmac proxy',
993
+ !!proxyTokenFromRecord(record),
994
+ proxyTokenFromRecord(record)
995
+ ? 'configured'
996
+ : 'pending server mint/refresh endpoint; BYOK/provider-auth can still run locally',
997
+ proxyTokenFromRecord(record) ? 'ok' : 'warn',
998
+ );
999
+
1000
+ for (const agent of binaryStatus()) {
1001
+ add(
1002
+ `${agent.name} binary`,
1003
+ agent.ok,
1004
+ agent.ok ? agent.path : 'missing; `amalgm start` will try to install it',
1005
+ agent.ok ? 'ok' : 'warn',
1006
+ );
1007
+ }
1008
+ for (const shim of agentCommandShimStatus()) {
1009
+ add(
1010
+ `${shim.command} command`,
1011
+ shim.ok,
1012
+ shim.ok ? `${shim.path} (${shim.status})` : shim.status,
1013
+ shim.ok ? 'ok' : 'warn',
1014
+ );
1015
+ }
1016
+
1017
+ const pid = readPid();
1018
+ const daemonRunning = isPidRunning(pid);
1019
+ add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
1020
+
1021
+ for (const [name, port] of servicePorts()) {
1022
+ const envName = {
1023
+ 'local-gateway': 'AMALGM_GATEWAY_PORT',
1024
+ 'port-monitor': 'PORT_MONITOR_PORT',
1025
+ 'fs-watcher': 'FS_WATCHER_PORT',
1026
+ 'amalgm-mcp': 'AMALGM_MCP_PORT',
1027
+ 'chat-server': 'CHAT_SERVER_PORT',
1028
+ }[name];
1029
+ const ok = daemonRunning ? await checkHttp(port) : (!process.env[envName] || await isPortFree(port));
1030
+ add(
1031
+ `${name} port`,
1032
+ ok,
1033
+ daemonRunning
1034
+ ? `http://127.0.0.1:${port}`
1035
+ : (ok ? `available ${port}` : `configured port in use ${port}`),
1036
+ ok ? 'ok' : (daemonRunning ? 'err' : 'warn'),
1037
+ );
1038
+ }
1039
+
1040
+ console.log('Amalgm doctor');
1041
+ console.log(`State: ${AMALGM_DIR}`);
1042
+ for (const check of checks) {
1043
+ console.log(`${check.level.padEnd(4)} ${check.name.padEnd(18)} ${check.detail}`);
1044
+ }
1045
+
1046
+ const failedBlocking = checks.filter((check) => check.level === 'err');
1047
+ if (failedBlocking.length > 0) process.exitCode = 1;
1048
+ }
1049
+
1050
+ function readLastLines(file, maxLines = 120) {
1051
+ const stat = fs.statSync(file);
1052
+ const bytes = Math.min(stat.size, 128 * 1024);
1053
+ const fd = fs.openSync(file, 'r');
1054
+ const buffer = Buffer.alloc(bytes);
1055
+ fs.readSync(fd, buffer, 0, bytes, stat.size - bytes);
1056
+ fs.closeSync(fd);
1057
+ return buffer.toString('utf8').trimEnd().split('\n').slice(-maxLines).join('\n');
1058
+ }
1059
+
1060
+ async function logs(options, positionals) {
1061
+ const service = String(options.service || positionals[0] || 'daemon').replace(/[^a-z0-9_-]/gi, '');
1062
+ const logPath = path.join(LOG_DIR, `${service}.log`);
1063
+ if (!fs.existsSync(logPath)) {
1064
+ throw new Error(`No log file found for "${service}" at ${logPath}`);
1065
+ }
1066
+ console.log(readLastLines(logPath, Number(options.lines || 120)));
1067
+ }
1068
+
1069
+ async function logout() {
1070
+ const record = loadComputerRecord({ migrate: true });
1071
+ await stop();
1072
+ if (record?.computer_auth_token && record?.app_url && record?.computer_id) {
1073
+ try {
1074
+ await requestJson(`${record.app_url}/api/computers/revoke`, {
1075
+ method: 'POST',
1076
+ headers: {
1077
+ authorization: `Bearer ${record.computer_auth_token}`,
1078
+ },
1079
+ body: JSON.stringify({
1080
+ computer_id: record.computer_id,
1081
+ device_id: record.device_id || '',
1082
+ }),
1083
+ });
1084
+ console.log('Revoked computer tokens on Amalgm.');
1085
+ } catch (error) {
1086
+ console.warn(`Remote revoke failed: ${error.message}`);
1087
+ }
1088
+ }
1089
+ deleteComputerAuth();
1090
+ try {
1091
+ fs.unlinkSync(RUNTIME_TOKEN_FILE);
1092
+ } catch {
1093
+ // noop
1094
+ }
1095
+ console.log('Logged out locally. The device id remains so re-login updates the same computer.');
1096
+ }
1097
+
1098
+ async function main(argv) {
1099
+ const { command, options, positionals } = parseArgs(argv);
1100
+
1101
+ if (command === 'help' || command === '--help' || command === '-h') {
1102
+ console.log(usage());
1103
+ return;
1104
+ }
1105
+
1106
+ if (command === 'version' || command === '--version' || command === '-v') {
1107
+ console.log(PACKAGE_VERSION);
1108
+ return;
1109
+ }
1110
+
1111
+ if (command === 'login') return login(options);
1112
+ if (command === 'start') return start(options);
1113
+ if (command === 'run') {
1114
+ return startSupervisor({
1115
+ foreground: true,
1116
+ localOnly: process.env.AMALGM_LOCAL_ONLY === 'true',
1117
+ });
1118
+ }
1119
+ if (command === 'stop') return stop();
1120
+ if (command === 'status') return status();
1121
+ if (command === 'doctor') return doctor();
1122
+ if (command === 'update') return update(options);
1123
+ if (command === 'logs') return logs(options, positionals);
1124
+ if (command === 'logout') return logout();
1125
+
1126
+ console.error(`Unknown command: ${command}`);
1127
+ console.error('');
1128
+ console.error(usage());
1129
+ process.exitCode = 1;
1130
+ }
1131
+
1132
+ module.exports = { main };