amalgm 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +37 -1
  2. package/bin/amalgm.js +8 -2
  3. package/lib/auth-store.js +223 -0
  4. package/lib/cli.js +1000 -0
  5. package/lib/paths.js +30 -0
  6. package/lib/supervisor.js +467 -0
  7. package/lib/tunnel-chat.js +328 -0
  8. package/lib/tunnel-events.js +499 -0
  9. package/package.json +29 -3
  10. package/runtime/README.md +4 -0
  11. package/runtime/lib/chatInput.js +306 -0
  12. package/runtime/lib/harnesses.js +988 -0
  13. package/runtime/lib/local/amalgmStore.js +128 -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 +165 -0
  18. package/runtime/scripts/amalgm-mcp/agents/store.js +153 -0
  19. package/runtime/scripts/amalgm-mcp/agents/talk.js +1156 -0
  20. package/runtime/scripts/amalgm-mcp/agents/tools.js +210 -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 +141 -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 +138 -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 +98 -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 +393 -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 +80 -0
  50. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +151 -0
  51. package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
  52. package/runtime/scripts/amalgm-mcp/server/http.js +335 -0
  53. package/runtime/scripts/amalgm-mcp/server/mcp.js +116 -0
  54. package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
  55. package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
  56. package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
  57. package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
  58. package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
  59. package/runtime/scripts/amalgm-mcp/tasks/store.js +139 -0
  60. package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
  61. package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/rest.js +389 -0
  63. package/runtime/scripts/chat-core/adapters/claude.js +163 -0
  64. package/runtime/scripts/chat-core/adapters/codex.js +313 -0
  65. package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
  66. package/runtime/scripts/chat-core/auth.js +177 -0
  67. package/runtime/scripts/chat-core/contract.js +326 -0
  68. package/runtime/scripts/chat-core/credentials/store.js +212 -0
  69. package/runtime/scripts/chat-core/egress.js +87 -0
  70. package/runtime/scripts/chat-core/engine.js +195 -0
  71. package/runtime/scripts/chat-core/event-schema.js +231 -0
  72. package/runtime/scripts/chat-core/events.js +190 -0
  73. package/runtime/scripts/chat-core/index.js +11 -0
  74. package/runtime/scripts/chat-core/input.js +50 -0
  75. package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
  76. package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
  77. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
  78. package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
  79. package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
  80. package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
  81. package/runtime/scripts/chat-core/parts.js +253 -0
  82. package/runtime/scripts/chat-core/recorder.js +65 -0
  83. package/runtime/scripts/chat-core/runtime.js +86 -0
  84. package/runtime/scripts/chat-core/server.js +163 -0
  85. package/runtime/scripts/chat-core/sse.js +196 -0
  86. package/runtime/scripts/chat-core/stores.js +100 -0
  87. package/runtime/scripts/chat-core/tool-display.js +149 -0
  88. package/runtime/scripts/chat-core/tool-shape.js +143 -0
  89. package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
  90. package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
  91. package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
  92. package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
  93. package/runtime/scripts/chat-core/usage.js +343 -0
  94. package/runtime/scripts/chat-server/config.js +110 -0
  95. package/runtime/scripts/chat-server/db.js +529 -0
  96. package/runtime/scripts/chat-server/index.js +33 -0
  97. package/runtime/scripts/chat-server/model-catalog.js +327 -0
  98. package/runtime/scripts/chat-server.js +75 -0
  99. package/runtime/scripts/credential-adapter.js +129 -0
  100. package/runtime/scripts/fs-watcher.js +888 -0
  101. package/runtime/scripts/local-gateway.js +852 -0
  102. package/runtime/scripts/platform-context.txt +246 -0
  103. package/runtime/scripts/port-monitor.js +175 -0
  104. package/runtime/scripts/proxy-token-store.js +162 -0
  105. package/runtime/scripts/runtime-auth.js +163 -0
  106. package/runtime/scripts/test-claude-code-models.js +87 -0
  107. package/runtime/tsconfig.json +15 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Agents MCP tools: agents_list, agents_get, talk_to_agent.
3
+ */
4
+
5
+ const { textResult, errorResult } = require('../lib/tool-result');
6
+ const {
7
+ getAllAgentsWithBuiltins,
8
+ normalizeConversationId,
9
+ readAgentConvoLog,
10
+ resolveAgent,
11
+ } = require('./store');
12
+ const { getSelectedModel, DEFAULT_SELECTED_MODELS } = require('../lib/prefs');
13
+ const {
14
+ handleTalkToAgent,
15
+ summarizeActiveConversation,
16
+ MAX_INLINE_MESSAGE_CHARS,
17
+ DEFAULT_FOREGROUND_TIMEOUT_MS,
18
+ } = require('./talk');
19
+
20
+ const talkMessageSchema = { type: 'string' };
21
+ const talkSizeNote = MAX_INLINE_MESSAGE_CHARS > 0
22
+ ? ` Messages over ${MAX_INLINE_MESSAGE_CHARS} characters are stored as local context files and sent to the target agent as file references to avoid opaque transport timeouts.`
23
+ : '';
24
+
25
+ module.exports = [
26
+ {
27
+ name: 'agents_list',
28
+ description:
29
+ 'List all available agents (built-in defaults + custom user-created agents). Use this to discover which agents you can talk to via talk_to_agent.',
30
+ inputSchema: { type: 'object', properties: {} },
31
+ async handler() {
32
+ const all = getAllAgentsWithBuiltins();
33
+ if (all.length === 0) return textResult('No agents available.');
34
+ const summary = all
35
+ .map((a) => {
36
+ const tag = a.builtin ? '[built-in]' : '[custom]';
37
+ const model = a.builtin
38
+ ? getSelectedModel(a.baseHarnessId) || a.baseModelId
39
+ : a.baseModelId ||
40
+ getSelectedModel(a.baseHarnessId) ||
41
+ DEFAULT_SELECTED_MODELS[a.baseHarnessId];
42
+ return `- ${a.name} (${a.id}) ${tag}\n ${a.description || 'No description'}\n harness: ${a.baseHarnessId} | model: ${model}`;
43
+ })
44
+ .join('\n\n');
45
+ return textResult(`Available agents:\n\n${summary}`);
46
+ },
47
+ },
48
+ {
49
+ name: 'agents_get',
50
+ description:
51
+ 'Get detailed information about a specific agent by ID, including its description, base harness, model, and capabilities.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: { agent_id: { type: 'string' } },
55
+ required: ['agent_id'],
56
+ },
57
+ async handler({ agent_id }) {
58
+ const agent = resolveAgent(agent_id);
59
+ if (!agent) return errorResult(`Agent not found: ${agent_id}`);
60
+ const safe = {
61
+ id: agent.id,
62
+ name: agent.name,
63
+ description: agent.description,
64
+ baseHarnessId: agent.baseHarnessId,
65
+ baseModelId: agent.builtin
66
+ ? getSelectedModel(agent.baseHarnessId) || agent.baseModelId
67
+ : agent.baseModelId ||
68
+ getSelectedModel(agent.baseHarnessId) ||
69
+ DEFAULT_SELECTED_MODELS[agent.baseHarnessId],
70
+ systemPrompt: agent.systemPrompt ? '(configured)' : '(none)',
71
+ mcp: {
72
+ inheritAll: agent.mcp?.inheritAll ?? true,
73
+ customServerCount: agent.mcp?.customServers?.length ?? 0,
74
+ },
75
+ builtin: !!agent.builtin,
76
+ };
77
+ return textResult(JSON.stringify(safe, null, 2));
78
+ },
79
+ },
80
+ {
81
+ name: 'agents_get_conversation',
82
+ description:
83
+ 'Read the local transcript for an agent conversation by conversation_id. Use this to recover the response if talk_to_agent timed out after the target agent completed.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ conversation_id: { type: 'string', description: 'Stable talk_to_agent conversation_id. Preferred over session_id.' },
88
+ session_id: { type: 'string', description: 'Deprecated alias for conversation_id.' },
89
+ run_id: { type: 'string', description: 'Optional run_id to filter the transcript.' },
90
+ limit: { type: 'number', description: 'Maximum transcript entries to return (default: 20).' },
91
+ include_events: { type: 'boolean', description: 'Include raw status/tool lifecycle events. Defaults to false for a clean user/assistant transcript.' },
92
+ },
93
+ },
94
+ async handler({ conversation_id, session_id, run_id, limit, include_events }) {
95
+ const conversationId = normalizeConversationId(conversation_id || session_id);
96
+ if (!conversationId) return errorResult('conversation_id is required and must be a UUID');
97
+ const requestedLimit = Math.max(1, Number(limit) || 20);
98
+ const rawEvents = include_events === true || String(include_events || '').toLowerCase() === 'true';
99
+ const readLimit = rawEvents ? requestedLimit : Math.max(requestedLimit * 4, 50);
100
+ let entries = readAgentConvoLog(conversationId, readLimit);
101
+ if (run_id) entries = entries.filter((entry) => entry.runId === run_id);
102
+ const active = summarizeActiveConversation(conversationId);
103
+ if (!rawEvents) {
104
+ entries = entries
105
+ .filter((entry) => entry.role === 'user' || entry.role === 'assistant')
106
+ .map((entry) => ({
107
+ role: entry.role,
108
+ agent: entry.agentName || entry.agentId || null,
109
+ conversation_id: conversationId,
110
+ run_id: entry.runId || null,
111
+ description: entry.description || null,
112
+ message: entry.message || '',
113
+ timestamp: entry.timestamp || null,
114
+ ...(entry.stopReason ? { stop_reason: entry.stopReason } : {}),
115
+ ...(Array.isArray(entry.warnings) && entry.warnings.length ? { warnings: entry.warnings } : {}),
116
+ }))
117
+ .slice(-requestedLimit);
118
+ if (active && (!run_id || active.runId === run_id) && active.responsePreview) {
119
+ entries = [
120
+ ...entries,
121
+ {
122
+ role: 'assistant',
123
+ agent: active.agentName || active.agentId || null,
124
+ conversation_id: conversationId,
125
+ run_id: active.runId || null,
126
+ description: active.description || null,
127
+ message: active.responsePreview,
128
+ timestamp: active.lastEventAt || active.updatedAt || null,
129
+ streaming: true,
130
+ chars: active.responsePreviewChars || active.responsePreview.length,
131
+ },
132
+ ].slice(-requestedLimit);
133
+ }
134
+ }
135
+ return textResult(JSON.stringify({
136
+ conversation_id: conversationId,
137
+ session_id: conversationId,
138
+ run_id: run_id || null,
139
+ view: rawEvents ? 'raw' : 'messages',
140
+ active: active && (!run_id || active.runId === run_id) ? active : null,
141
+ entries,
142
+ }, null, 2));
143
+ },
144
+ },
145
+ {
146
+ name: 'talk_to_agent',
147
+ description:
148
+ `Delegate a task to another Amalgm agent. IMPORTANT: for code review, implementation, research, file inspection, tests, or anything that may take more than a few seconds, set run_in_background=true so the tool returns a stable conversation_id immediately instead of risking MCP transport timeout. Foreground mode is only for quick questions: it waits up to ${DEFAULT_FOREGROUND_TIMEOUT_MS}ms, then returns conversation_id/run_id with status timed_out while the peer keeps running. Background mode returns conversation_id/run_id immediately. When a peer is given a return channel, it should call this same tool with response_to_caller=true to deliver its own compact response back to the caller conversation; the bridge posts only a short fallback if the peer does not deliver one. Use conversation_id to continue a multi-turn peer conversation.${talkSizeNote} Poll agents_get_conversation for clean transcript text or raw events; while the peer is still running, the clean transcript includes a streaming assistant preview when visible text has arrived.`,
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ description: {
153
+ type: 'string',
154
+ description: 'Short 3-5 word task description.',
155
+ },
156
+ prompt: {
157
+ type: 'string',
158
+ description: 'The task for the agent to perform.',
159
+ },
160
+ agent: {
161
+ type: 'string',
162
+ description: 'Optional Amalgm agent name or id, e.g. Codex, OpenCode, Claude Code, QA Testing. Defaults to Codex.',
163
+ },
164
+ conversation_id: {
165
+ type: 'string',
166
+ description: 'Stable peer conversation id to continue. Preferred over session_id.',
167
+ },
168
+ session_id: {
169
+ type: 'string',
170
+ description: 'Deprecated alias for conversation_id.',
171
+ },
172
+ run_in_background: {
173
+ type: 'boolean',
174
+ description: 'Use true for long-running peer tasks such as code review, implementation, research, file inspection, or tests. Returns conversation_id/run_id immediately while the peer keeps working.',
175
+ },
176
+ response_to_caller: {
177
+ type: 'boolean',
178
+ description: 'Peer-only delivery mode. Set true only when responding to the caller conversation named by conversation_id. This appends prompt as the peer-authored completion message and does not launch a new agent turn.',
179
+ },
180
+ timeout_ms: {
181
+ type: 'number',
182
+ description: `Foreground soft wait budget before returning status timed_out with conversation_id/run_id. Defaults to ${DEFAULT_FOREGROUND_TIMEOUT_MS}. Keep below the caller transport timeout; prefer run_in_background for long tasks.`,
183
+ },
184
+ include_metrics: {
185
+ type: 'boolean',
186
+ description: 'Include full stream metrics/toolCalls in the response. Defaults to false to avoid context bloat; metric_summary is always included.',
187
+ },
188
+ isolation: {
189
+ type: 'string',
190
+ enum: ['worktree'],
191
+ description: 'Optional isolation mode. worktree is accepted as a requested execution isolation hint.',
192
+ },
193
+ cwd: {
194
+ type: 'string',
195
+ description: 'Working directory for this turn. It is advisory per turn and no longer freezes the session contract.',
196
+ },
197
+ agent_id: {
198
+ type: 'string',
199
+ description: 'Deprecated alias for agent.',
200
+ },
201
+ message: {
202
+ ...talkMessageSchema,
203
+ description: 'Deprecated alias for prompt.',
204
+ },
205
+ },
206
+ required: ['description', 'prompt'],
207
+ },
208
+ handler: handleTalkToAgent,
209
+ },
210
+ ];
@@ -0,0 +1,132 @@
1
+ const {
2
+ COMPUTER_AUTH_FILE,
3
+ COMPUTER_RECORD_FILE,
4
+ GATEWAY_BASE_URL,
5
+ GATEWAY_INTERNAL_SECRET,
6
+ ARTIFACT_ROUTE_SYNC_INTERVAL_MS,
7
+ } = require('../config');
8
+ const { readJson } = require('../lib/storage');
9
+ const { getConnectedRoutes } = require('./store');
10
+
11
+ let syncQueue = Promise.resolve();
12
+ let routeSyncTimer = null;
13
+ let lastFailure = '';
14
+ const warned = new Set();
15
+
16
+ function warnOnce(key, message) {
17
+ if (warned.has(key)) return;
18
+ warned.add(key);
19
+ console.warn(message);
20
+ }
21
+
22
+ function loadComputerRecord() {
23
+ const record = readJson(COMPUTER_RECORD_FILE, null);
24
+ if (!record || typeof record !== 'object') return null;
25
+ const auth = readJson(COMPUTER_AUTH_FILE, null);
26
+ if (!auth || typeof auth !== 'object') return record;
27
+ if (auth.computer_id && record.computer_id && auth.computer_id !== record.computer_id) return record;
28
+ return {
29
+ ...record,
30
+ tunnel_token: record.tunnel_token || auth.tunnel?.token || '',
31
+ };
32
+ }
33
+
34
+ async function postArtifactRoutes(computerId, routes, authHeaders) {
35
+ const response = await fetch(
36
+ `${GATEWAY_BASE_URL}/internal/computers/${encodeURIComponent(computerId)}/artifact-routes`,
37
+ {
38
+ method: 'POST',
39
+ headers: {
40
+ 'content-type': 'application/json',
41
+ ...authHeaders,
42
+ },
43
+ body: JSON.stringify({ routes }),
44
+ },
45
+ );
46
+
47
+ if (response.ok) {
48
+ lastFailure = '';
49
+ return response.json().catch(() => ({ ok: true, routesCount: routes.length }));
50
+ }
51
+
52
+ const text = await response.text().catch(() => '');
53
+ throw new Error(`Gateway route sync failed (${response.status}): ${text.slice(0, 300)}`);
54
+ }
55
+
56
+ function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
57
+ const { throwOnError = false } = options;
58
+
59
+ syncQueue = syncQueue
60
+ .catch(() => undefined)
61
+ .then(async () => {
62
+ const record = loadComputerRecord();
63
+ const computerId =
64
+ typeof record?.computer_id === 'string' && record.computer_id.trim()
65
+ ? record.computer_id.trim()
66
+ : null;
67
+ if (!computerId) {
68
+ warnOnce(
69
+ 'missing-computer',
70
+ `[AmalgmMCP] Skipping artifact route sync: missing computer_id in ${COMPUTER_RECORD_FILE}.`,
71
+ );
72
+ return { ok: false, skipped: 'missing-computer' };
73
+ }
74
+
75
+ const tunnelToken =
76
+ typeof record?.tunnel_token === 'string' && record.tunnel_token.trim()
77
+ ? record.tunnel_token.trim()
78
+ : '';
79
+ const authHeaders = tunnelToken
80
+ ? { 'x-tunnel-token': tunnelToken }
81
+ : (GATEWAY_INTERNAL_SECRET ? { 'x-admin-secret': GATEWAY_INTERNAL_SECRET } : null);
82
+ if (!authHeaders) {
83
+ warnOnce(
84
+ 'missing-auth',
85
+ '[AmalgmMCP] Skipping artifact route sync: neither tunnel_token nor AMALGM_GATEWAY_INTERNAL_SECRET is configured.',
86
+ );
87
+ return { ok: false, skipped: 'missing-auth' };
88
+ }
89
+
90
+ const routes = getConnectedRoutes();
91
+
92
+ try {
93
+ const payload = await postArtifactRoutes(computerId, routes, authHeaders);
94
+ if (reason !== 'interval') {
95
+ console.log(
96
+ `[AmalgmMCP] Synced artifact routes (${reason}): computer=${computerId} count=${routes.length}`,
97
+ );
98
+ }
99
+ return { ok: true, computerId, routes, payload };
100
+ } catch (err) {
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ if (message !== lastFailure || reason !== 'interval') {
103
+ console.warn(`[AmalgmMCP] Artifact route sync failed (${reason}): ${message}`);
104
+ }
105
+ lastFailure = message;
106
+ if (throwOnError) throw err;
107
+ return { ok: false, error: message };
108
+ }
109
+ });
110
+
111
+ return syncQueue;
112
+ }
113
+
114
+ function startArtifactRouteSyncLoop() {
115
+ if (routeSyncTimer) return routeSyncTimer;
116
+
117
+ void syncArtifactRoutesToGateway('boot');
118
+ routeSyncTimer = setInterval(() => {
119
+ void syncArtifactRoutesToGateway('interval');
120
+ }, ARTIFACT_ROUTE_SYNC_INTERVAL_MS);
121
+
122
+ if (typeof routeSyncTimer.unref === 'function') {
123
+ routeSyncTimer.unref();
124
+ }
125
+
126
+ return routeSyncTimer;
127
+ }
128
+
129
+ module.exports = {
130
+ startArtifactRouteSyncLoop,
131
+ syncArtifactRoutesToGateway,
132
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * /artifacts/* REST routes for the Next.js API and local tunnel runtime.
3
+ */
4
+
5
+ const { getConnectedRoutes, loadArtifacts } = require('./store');
6
+ const {
7
+ deleteArtifact,
8
+ connectDns,
9
+ disconnectDns,
10
+ redeployArtifact,
11
+ registerArtifact,
12
+ startArtifact,
13
+ stopArtifact,
14
+ } = require('./supervisor');
15
+
16
+ async function handleList(sendJson) {
17
+ sendJson(200, loadArtifacts());
18
+ }
19
+
20
+ async function handleRoutes(sendJson) {
21
+ sendJson(200, { routes: getConnectedRoutes() });
22
+ }
23
+
24
+ async function handleRegister(body, sendJson) {
25
+ try {
26
+ const artifact = await registerArtifact(body || {});
27
+ sendJson(200, { artifact });
28
+ } catch (err) {
29
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
30
+ }
31
+ }
32
+
33
+ async function handleRedeploy(body, sendJson) {
34
+ try {
35
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
36
+ const artifact = await redeployArtifact(body.artifact_id, body);
37
+ sendJson(200, { artifact });
38
+ } catch (err) {
39
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
40
+ }
41
+ }
42
+
43
+ async function handleStart(body, sendJson) {
44
+ try {
45
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
46
+ const artifact = await startArtifact(body.artifact_id);
47
+ sendJson(200, { artifact });
48
+ } catch (err) {
49
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
50
+ }
51
+ }
52
+
53
+ async function handleStop(body, sendJson) {
54
+ try {
55
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
56
+ const artifact = await stopArtifact(body.artifact_id);
57
+ sendJson(200, { artifact });
58
+ } catch (err) {
59
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
60
+ }
61
+ }
62
+
63
+ async function handleDelete(body, sendJson) {
64
+ try {
65
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
66
+ const artifact = await deleteArtifact(body.artifact_id);
67
+ sendJson(200, { artifact });
68
+ } catch (err) {
69
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
70
+ }
71
+ }
72
+
73
+ async function handleConnectDns(body, sendJson) {
74
+ try {
75
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
76
+ const artifact = await connectDns(body.artifact_id);
77
+ sendJson(200, { artifact });
78
+ } catch (err) {
79
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
80
+ }
81
+ }
82
+
83
+ async function handleDisconnectDns(body, sendJson) {
84
+ try {
85
+ if (!body?.artifact_id) return sendJson(400, { error: 'artifact_id is required' });
86
+ const artifact = await disconnectDns(body.artifact_id);
87
+ sendJson(200, { artifact });
88
+ } catch (err) {
89
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ handleConnectDns,
95
+ handleDelete,
96
+ handleDisconnectDns,
97
+ handleList,
98
+ handleRedeploy,
99
+ handleRegister,
100
+ handleRoutes,
101
+ handleStart,
102
+ handleStop,
103
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Artifact storage — ~/.amalgm/artifacts.json
3
+ *
4
+ * This is intentionally local-first. The laptop volume is the source of truth;
5
+ * the public gateway only gets ephemeral route advertisements while the
6
+ * computer is connected.
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+ const {
11
+ ARTIFACTS_DIR,
12
+ ARTIFACTS_FILE,
13
+ ARTIFACTS_DOMAIN,
14
+ ARTIFACT_PORT_MIN,
15
+ ARTIFACT_PORT_MAX,
16
+ } = require('../config');
17
+ const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
18
+
19
+ function ensureArtifactsDirs() {
20
+ ensureDir(ARTIFACTS_DIR);
21
+ if (!readJson(ARTIFACTS_FILE, null)) {
22
+ writeJsonAtomic(ARTIFACTS_FILE, { version: 1, artifacts: [] });
23
+ }
24
+ }
25
+
26
+ function publicUrlForRef(artifactRef) {
27
+ return `https://${artifactRef}.${ARTIFACTS_DOMAIN}/`;
28
+ }
29
+
30
+ function genArtifactRef() {
31
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
32
+ const bytes = crypto.randomBytes(12);
33
+ let out = '';
34
+ for (let i = 0; i < 12; i += 1) {
35
+ out += chars[bytes[i] % chars.length];
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function normalizeArtifact(artifact) {
41
+ const artifactRef = artifact.artifactRef || artifact.artifact_ref || genArtifactRef();
42
+ const dnsConnected =
43
+ artifact.dnsConnected !== undefined
44
+ ? artifact.dnsConnected !== false
45
+ : artifact.connected !== false;
46
+
47
+ return {
48
+ ...artifact,
49
+ kind: 'artifact',
50
+ artifactRef,
51
+ artifact_ref: artifactRef,
52
+ publicUrl: artifact.publicUrl || publicUrlForRef(artifactRef),
53
+ dnsConnected,
54
+ autostart: artifact.autostart !== false,
55
+ keepAlive: artifact.keepAlive !== false,
56
+ desiredState: artifact.desiredState || (artifact.status === 'stopped' ? 'stopped' : 'running'),
57
+ status: artifact.status || 'stopped',
58
+ pid: artifact.pid || null,
59
+ error: artifact.error || null,
60
+ };
61
+ }
62
+
63
+ function loadArtifacts() {
64
+ const data = readJson(ARTIFACTS_FILE, { version: 1, artifacts: [] });
65
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts.map(normalizeArtifact) : [];
66
+ return { version: 1, artifacts };
67
+ }
68
+
69
+ function saveArtifacts(data) {
70
+ ensureDir(ARTIFACTS_DIR);
71
+ writeJsonAtomic(ARTIFACTS_FILE, {
72
+ version: 1,
73
+ artifacts: Array.isArray(data.artifacts) ? data.artifacts.map(normalizeArtifact) : [],
74
+ });
75
+ }
76
+
77
+ function updateArtifactMeta(artifactId, updates) {
78
+ const data = loadArtifacts();
79
+ const artifact = data.artifacts.find((item) => item.id === artifactId);
80
+ if (!artifact) return null;
81
+ Object.assign(artifact, updates, { updatedAt: new Date().toISOString() });
82
+ saveArtifacts(data);
83
+ return normalizeArtifact(artifact);
84
+ }
85
+
86
+ function getArtifact(artifactId) {
87
+ return loadArtifacts().artifacts.find((artifact) => artifact.id === artifactId) || null;
88
+ }
89
+
90
+ function allocatePort(preferredPort) {
91
+ if (preferredPort !== undefined && preferredPort !== null) {
92
+ const parsed = Number(preferredPort);
93
+ if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed;
94
+ return null;
95
+ }
96
+
97
+ const used = new Set(loadArtifacts().artifacts.map((artifact) => Number(artifact.port)));
98
+ for (let port = ARTIFACT_PORT_MIN; port <= ARTIFACT_PORT_MAX; port += 1) {
99
+ if (!used.has(port)) return port;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function allocateUniqueArtifactRef() {
105
+ const used = new Set(loadArtifacts().artifacts.map((artifact) => artifact.artifactRef));
106
+ for (let attempt = 0; attempt < 12; attempt += 1) {
107
+ const candidate = genArtifactRef();
108
+ if (!used.has(candidate)) return candidate;
109
+ }
110
+ throw new Error('Could not allocate artifact DNS ref');
111
+ }
112
+
113
+ function getConnectedRoutes() {
114
+ return loadArtifacts()
115
+ .artifacts
116
+ .filter((artifact) =>
117
+ artifact.dnsConnected
118
+ && artifact.desiredState === 'running'
119
+ && artifact.status !== 'stopped'
120
+ && artifact.status !== 'error'
121
+ && artifact.port
122
+ && artifact.artifactRef,
123
+ )
124
+ .map((artifact) => ({
125
+ artifact_ref: artifact.artifactRef,
126
+ port: artifact.port,
127
+ name: artifact.name,
128
+ }));
129
+ }
130
+
131
+ module.exports = {
132
+ allocatePort,
133
+ allocateUniqueArtifactRef,
134
+ ensureArtifactsDirs,
135
+ getArtifact,
136
+ getConnectedRoutes,
137
+ loadArtifacts,
138
+ publicUrlForRef,
139
+ saveArtifacts,
140
+ updateArtifactMeta,
141
+ };