amalgm 0.1.51 → 0.1.53

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 (71) hide show
  1. package/lib/tunnel-events.js +48 -23
  2. package/package.json +2 -2
  3. package/runtime/lib/harnesses.js +12 -4
  4. package/runtime/scripts/amalgm-mcp/agents/store.js +5 -5
  5. package/runtime/scripts/amalgm-mcp/{artifacts → apps}/advertise.js +39 -24
  6. package/runtime/scripts/amalgm-mcp/apps/rest.js +144 -0
  7. package/runtime/scripts/amalgm-mcp/apps/store.js +171 -0
  8. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +439 -0
  9. package/runtime/scripts/amalgm-mcp/apps/tools.js +176 -0
  10. package/runtime/scripts/amalgm-mcp/automations/cell-references.js +237 -0
  11. package/runtime/scripts/amalgm-mcp/automations/context.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/automations/rest.js +148 -0
  13. package/runtime/scripts/amalgm-mcp/automations/runner.js +613 -0
  14. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +90 -0
  15. package/runtime/scripts/amalgm-mcp/automations/store.js +1125 -0
  16. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +177 -0
  17. package/runtime/scripts/amalgm-mcp/automations/tools.js +418 -0
  18. package/runtime/scripts/amalgm-mcp/automations/validator.js +225 -0
  19. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +547 -0
  20. package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +222 -0
  21. package/runtime/scripts/amalgm-mcp/browser/page.js +13 -631
  22. package/runtime/scripts/amalgm-mcp/browser/tools.js +9 -7
  23. package/runtime/scripts/amalgm-mcp/config.js +33 -48
  24. package/runtime/scripts/amalgm-mcp/deps.js +1 -31
  25. package/runtime/scripts/amalgm-mcp/events/ingress.js +50 -42
  26. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +169 -0
  27. package/runtime/scripts/amalgm-mcp/events/matcher.js +45 -14
  28. package/runtime/scripts/amalgm-mcp/events/store.js +106 -57
  29. package/runtime/scripts/amalgm-mcp/index.js +12 -14
  30. package/runtime/scripts/amalgm-mcp/lib/prefs.js +229 -65
  31. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -27
  32. package/runtime/scripts/amalgm-mcp/server/core-tools.js +2 -3
  33. package/runtime/scripts/amalgm-mcp/server/http.js +106 -56
  34. package/runtime/scripts/amalgm-mcp/slack/inbound.js +1 -1
  35. package/runtime/scripts/amalgm-mcp/state/db.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/state/snapshot.js +16 -3
  37. package/runtime/scripts/amalgm-mcp/tasks/executor.js +1 -1
  38. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +348 -0
  39. package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +23 -0
  40. package/runtime/scripts/amalgm-mcp/tests/workflows-store-runner.test.js +67 -0
  41. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +16 -3
  42. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +222 -0
  43. package/runtime/scripts/amalgm-mcp/workflows/runner.js +593 -0
  44. package/runtime/scripts/amalgm-mcp/workflows/store.js +237 -0
  45. package/runtime/scripts/chat-core/adapters/claude.js +2 -1
  46. package/runtime/scripts/chat-core/auth.js +82 -12
  47. package/runtime/scripts/chat-core/contract.js +5 -1
  48. package/runtime/scripts/chat-core/engine.js +103 -62
  49. package/runtime/scripts/chat-core/event-schema.js +8 -0
  50. package/runtime/scripts/chat-core/events.js +5 -0
  51. package/runtime/scripts/chat-core/normalizers/codex.js +13 -1
  52. package/runtime/scripts/chat-core/parts.js +21 -6
  53. package/runtime/scripts/chat-core/sse.js +3 -0
  54. package/runtime/scripts/chat-core/tests/auth.test.js +84 -6
  55. package/runtime/scripts/chat-core/tests/engine.test.js +312 -0
  56. package/runtime/scripts/chat-core/tests/native-config.test.js +23 -0
  57. package/runtime/scripts/chat-core/tool-shape.js +4 -4
  58. package/runtime/scripts/chat-core/tooling/active-memory.js +5 -4
  59. package/runtime/scripts/chat-core/tooling/native-binaries.js +34 -9
  60. package/runtime/scripts/chat-core/tooling/native-config.js +34 -3
  61. package/runtime/scripts/local-gateway.js +34 -27
  62. package/runtime/scripts/platform-context.txt +76 -94
  63. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +0 -103
  64. package/runtime/scripts/amalgm-mcp/artifacts/store.js +0 -157
  65. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +0 -439
  66. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +0 -176
  67. package/runtime/scripts/amalgm-mcp/events/executor.js +0 -258
  68. package/runtime/scripts/amalgm-mcp/events/rest.js +0 -214
  69. package/runtime/scripts/amalgm-mcp/events/tools.js +0 -323
  70. package/runtime/scripts/amalgm-mcp/tasks/rest.js +0 -110
  71. package/runtime/scripts/amalgm-mcp/tasks/tools.js +0 -416
@@ -122,36 +122,55 @@ function sanitizePreviewWsHeaders(headers = {}) {
122
122
  return out;
123
123
  }
124
124
 
125
- function readArtifactRoutes() {
126
- const file = path.join(AMALGM_DIR, 'artifacts.json');
125
+ function readAppsFile() {
126
+ const appsFile = path.join(AMALGM_DIR, 'apps.json');
127
+ const legacyArtifactsFile = path.join(AMALGM_DIR, 'artifacts.json');
127
128
  let parsed = null;
128
129
  try {
129
- parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
130
+ parsed = JSON.parse(fs.readFileSync(appsFile, 'utf8'));
130
131
  } catch {
131
- return [];
132
+ try {
133
+ parsed = JSON.parse(fs.readFileSync(legacyArtifactsFile, 'utf8'));
134
+ } catch {
135
+ return [];
136
+ }
132
137
  }
133
138
 
134
- const artifacts = Array.isArray(parsed?.artifacts) ? parsed.artifacts : [];
135
- return artifacts
136
- .filter((artifact) => {
137
- const connected = artifact.dnsConnected !== false && artifact.connected !== false;
138
- const desiredRunning = (artifact.desiredState || 'running') === 'running';
139
- const active = artifact.status !== 'stopped' && artifact.status !== 'error';
140
- return connected && desiredRunning && active && artifact.port && (artifact.artifactRef || artifact.artifact_ref);
139
+ return Array.isArray(parsed?.apps)
140
+ ? parsed.apps
141
+ : (Array.isArray(parsed?.artifacts) ? parsed.artifacts : []);
142
+ }
143
+
144
+ function readAppRoutes() {
145
+ const apps = readAppsFile();
146
+ return apps
147
+ .filter((app) => {
148
+ const connected = app.dnsConnected !== false && app.connected !== false;
149
+ const desiredRunning = (app.desiredState || 'running') === 'running';
150
+ const active = app.status !== 'stopped' && app.status !== 'error';
151
+ return connected && desiredRunning && active && app.port && (app.appRef || app.app_ref || app.artifactRef || app.artifact_ref);
141
152
  })
142
- .map((artifact) => ({
143
- artifact_ref: artifact.artifactRef || artifact.artifact_ref,
144
- port: Number(artifact.port),
145
- name: artifact.name,
153
+ .map((app) => ({
154
+ app_ref: app.appRef || app.app_ref || app.artifactRef || app.artifact_ref,
155
+ port: Number(app.port),
156
+ name: app.name,
146
157
  }))
147
158
  .filter((route) =>
148
- /^[a-z0-9]{8,24}$/.test(route.artifact_ref)
159
+ /^[a-z0-9]{8,24}$/.test(route.app_ref)
149
160
  && Number.isInteger(route.port)
150
161
  && route.port > 0
151
162
  && route.port <= 65535,
152
163
  );
153
164
  }
154
165
 
166
+ function legacyArtifactRoutes(routes) {
167
+ return routes.map((route) => ({
168
+ artifact_ref: route.app_ref,
169
+ port: route.port,
170
+ name: route.name,
171
+ }));
172
+ }
173
+
155
174
  function readRuntimePorts() {
156
175
  try {
157
176
  const parsed = JSON.parse(fs.readFileSync(RUNTIME_STATE_FILE, 'utf8'));
@@ -175,7 +194,7 @@ function allowedTargetPorts() {
175
194
  for (const port of [runtimePorts.gateway, runtimePorts.mcp, runtimePorts.chat]) {
176
195
  if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port);
177
196
  }
178
- for (const route of readArtifactRoutes()) ports.add(route.port);
197
+ for (const route of readAppRoutes()) ports.add(route.port);
179
198
  return ports;
180
199
  }
181
200
 
@@ -252,11 +271,13 @@ function createEventTunnel({ record, foreground = false }) {
252
271
 
253
272
  if (ws.readyState !== WebSocket.OPEN) return;
254
273
 
274
+ const appRoutes = readAppRoutes();
255
275
  send({
256
276
  type: 'hello',
257
277
  app_version: require('../package.json').version,
258
278
  runtime_gateway_port: runtimeGatewayPort(),
259
- artifact_routes: readArtifactRoutes(),
279
+ app_routes: appRoutes,
280
+ artifact_routes: legacyArtifactRoutes(appRoutes),
260
281
  });
261
282
 
262
283
  try {
@@ -287,11 +308,13 @@ function createEventTunnel({ record, foreground = false }) {
287
308
  return out;
288
309
  }
289
310
 
290
- function advertiseArtifacts() {
311
+ function advertiseApps() {
312
+ const appRoutes = readAppRoutes();
291
313
  send({
292
- type: 'artifact_routes',
314
+ type: 'app_routes',
293
315
  runtime_gateway_port: runtimeGatewayPort(),
294
- artifact_routes: readArtifactRoutes(),
316
+ app_routes: appRoutes,
317
+ artifact_routes: legacyArtifactRoutes(appRoutes),
295
318
  });
296
319
  }
297
320
 
@@ -436,14 +459,16 @@ function createEventTunnel({ record, foreground = false }) {
436
459
  lastGatewayFrameAt = Date.now();
437
460
  log(`connected ${record.computer_id}`);
438
461
  void postPresence(record, true);
462
+ const appRoutes = readAppRoutes();
439
463
  send({
440
464
  type: 'hello',
441
465
  app_version: require('../package.json').version,
442
466
  runtime_gateway_port: runtimeGatewayPort(),
443
- artifact_routes: readArtifactRoutes(),
467
+ app_routes: appRoutes,
468
+ artifact_routes: legacyArtifactRoutes(appRoutes),
444
469
  });
445
470
  if (!routeTimer) {
446
- routeTimer = setInterval(advertiseArtifacts, 30000);
471
+ routeTimer = setInterval(advertiseApps, 30000);
447
472
  if (typeof routeTimer.unref === 'function') routeTimer.unref();
448
473
  }
449
474
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -29,11 +29,11 @@
29
29
  "@modelcontextprotocol/sdk": "^1.27.1",
30
30
  "@openai/codex": "^0.128.0",
31
31
  "@opencode-ai/sdk": "^1.14.29",
32
+ "agent-browser": "^0.27.0",
32
33
  "ai": "^6.0.116",
33
34
  "better-sqlite3": "^12.10.0",
34
35
  "cron-parser": "^5.5.0",
35
36
  "opencode-ai": "^1.14.35",
36
- "playwright-core": "^1.59.1",
37
37
  "tsx": "^4.21.0",
38
38
  "ws": "^8.18.3",
39
39
  "zod": "^4.1.8"
@@ -56,8 +56,8 @@ function buildGatewayCliModel(gatewayId, harness) {
56
56
  // lib/modelNaming.ts
57
57
  var DEFAULT_MODEL_IDS = {
58
58
  claude_code: "anthropic/claude-opus-4.7",
59
- codex: "openai/gpt-5.4-thinking-xhigh",
60
- opencode: "opencode-anthropic-claude-sonnet-4-6",
59
+ codex: "openai/gpt-5.5",
60
+ opencode: "opencode-deepseek-deepseek-v4-pro",
61
61
  pi: "pi-anthropic-claude-sonnet-4.6",
62
62
  amp: "amp-default",
63
63
  cursor: "cursor/composer-2-fast"
@@ -492,6 +492,14 @@ var OPENCODE_MODELS = [
492
492
  providerGroup: "xai"
493
493
  },
494
494
  // ── DeepSeek ──
495
+ {
496
+ id: DEFAULT_MODEL_IDS.opencode,
497
+ name: "DeepSeek V4 Pro",
498
+ description: "DeepSeek flagship model",
499
+ cliModel: buildGatewayCliModel("deepseek/deepseek-v4-pro", "opencode"),
500
+ provider: "opencode",
501
+ providerGroup: "deepseek"
502
+ },
495
503
  {
496
504
  id: "opencode-deepseek-deepseek-v3.2",
497
505
  name: "DeepSeek V3.2",
@@ -765,7 +773,7 @@ var HARNESSES = {
765
773
  description: "Open-source coding agent",
766
774
  logoKey: "opencode",
767
775
  models: OPENCODE_MODELS,
768
- defaultModelId: "opencode-anthropic-claude-sonnet-4-6",
776
+ defaultModelId: DEFAULT_MODEL_IDS.opencode,
769
777
  supportedAuthMethods: ["amalgm", "byok"],
770
778
  dynamicModels: true
771
779
  },
@@ -916,7 +924,7 @@ function resolveHarnessModel(harnessId, modelId) {
916
924
  return {
917
925
  modelId: normalizedModelId,
918
926
  cliModel: model?.cliModel ?? adaptedModel,
919
- reasoningEffort: selection?.reasoningEffort ?? model?.reasoningEffort
927
+ reasoningEffort: selection?.reasoningEffort ?? model?.reasoningEffort ?? (harnessId === "codex" ? "xhigh" : void 0)
920
928
  };
921
929
  }
922
930
  function getAuthMethodsForHarness(harnessId) {
@@ -50,7 +50,7 @@ const BUILTIN_AGENT_BLUEPRINTS = [
50
50
  description: 'OpenAI Codex agent — code generation and analysis',
51
51
  adapter: 'codex',
52
52
  baseHarnessId: 'codex',
53
- baseModelId: 'openai/gpt-5.4',
53
+ baseModelId: 'openai/gpt-5.5',
54
54
  },
55
55
  {
56
56
  id: 'opencode',
@@ -58,7 +58,7 @@ const BUILTIN_AGENT_BLUEPRINTS = [
58
58
  description: 'Open-source agent — multi-provider coding assistant',
59
59
  adapter: 'opencode',
60
60
  baseHarnessId: 'opencode',
61
- baseModelId: 'opencode-anthropic-claude-sonnet-4-6',
61
+ baseModelId: 'opencode-deepseek-deepseek-v4-pro',
62
62
  },
63
63
  {
64
64
  id: 'pi',
@@ -85,7 +85,7 @@ const BUILTIN_AGENTS = BUILTIN_AGENT_BLUEPRINTS.map((agent) => ({
85
85
  status: DEFAULT_AGENT_STATUS,
86
86
  }));
87
87
 
88
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
88
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
89
89
 
90
90
  function nowIso() {
91
91
  return new Date().toISOString();
@@ -406,7 +406,7 @@ function seedBuiltinAgents(options = {}) {
406
406
  description: existing?.description ?? blueprint.description,
407
407
  adapter: existing?.adapter || blueprint.adapter,
408
408
  baseHarnessId: existing?.baseHarnessId || blueprint.baseHarnessId,
409
- baseModelId: existing?.baseModelId || blueprint.baseModelId,
409
+ baseModelId: blueprint.baseModelId,
410
410
  systemPrompt: existing?.systemPrompt || '',
411
411
  files: existing?.files || [],
412
412
  skills: existing?.skills || [],
@@ -423,7 +423,7 @@ function seedBuiltinAgents(options = {}) {
423
423
  authDetails: existing?.authDetails,
424
424
  configDir: existing?.configDir,
425
425
  createdAt: existing?.createdAt,
426
- authMethod: defaultAgentAuthMethod(existing?.baseHarnessId || blueprint.baseHarnessId, existing),
426
+ authMethod: persistedAuthMethod(existing?.baseHarnessId || blueprint.baseHarnessId),
427
427
  }, existing);
428
428
  if (existing && agentsEqualForWrite(existing, next)) continue;
429
429
  upsertAgentRow(db, next);
@@ -3,7 +3,7 @@ const {
3
3
  COMPUTER_RECORD_FILE,
4
4
  GATEWAY_BASE_URL,
5
5
  GATEWAY_INTERNAL_SECRET,
6
- ARTIFACT_ROUTE_SYNC_INTERVAL_MS,
6
+ APP_ROUTE_SYNC_INTERVAL_MS,
7
7
  } = require('../config');
8
8
  const { readJson } = require('../lib/storage');
9
9
  const { getConnectedRoutes } = require('./store');
@@ -31,19 +31,34 @@ function loadComputerRecord() {
31
31
  };
32
32
  }
33
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
- },
34
+ async function postAppRoutes(computerId, routes, authHeaders) {
35
+ const body = JSON.stringify({ routes });
36
+ const headers = {
37
+ 'content-type': 'application/json',
38
+ ...authHeaders,
39
+ };
40
+ let response = await fetch(
41
+ `${GATEWAY_BASE_URL}/internal/computers/${encodeURIComponent(computerId)}/app-routes`,
42
+ { method: 'POST', headers, body },
45
43
  );
46
44
 
45
+ if (response.status === 404) {
46
+ response = await fetch(
47
+ `${GATEWAY_BASE_URL}/internal/computers/${encodeURIComponent(computerId)}/artifact-routes`,
48
+ {
49
+ method: 'POST',
50
+ headers,
51
+ body: JSON.stringify({
52
+ routes: routes.map((route) => ({
53
+ artifact_ref: route.app_ref,
54
+ port: route.port,
55
+ name: route.name,
56
+ })),
57
+ }),
58
+ },
59
+ );
60
+ }
61
+
47
62
  if (response.ok) {
48
63
  lastFailure = '';
49
64
  return response.json().catch(() => ({ ok: true, routesCount: routes.length }));
@@ -53,7 +68,7 @@ async function postArtifactRoutes(computerId, routes, authHeaders) {
53
68
  throw new Error(`Gateway route sync failed (${response.status}): ${text.slice(0, 300)}`);
54
69
  }
55
70
 
56
- function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
71
+ function syncAppRoutesToGateway(reason = 'manual', options = {}) {
57
72
  const { throwOnError = false } = options;
58
73
 
59
74
  syncQueue = syncQueue
@@ -67,7 +82,7 @@ function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
67
82
  if (!computerId) {
68
83
  warnOnce(
69
84
  'missing-computer',
70
- `[AmalgmMCP] Skipping artifact route sync: missing computer_id in ${COMPUTER_RECORD_FILE}.`,
85
+ `[AmalgmMCP] Skipping app route sync: missing computer_id in ${COMPUTER_RECORD_FILE}.`,
71
86
  );
72
87
  return { ok: false, skipped: 'missing-computer' };
73
88
  }
@@ -82,7 +97,7 @@ function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
82
97
  if (!authHeaders) {
83
98
  warnOnce(
84
99
  'missing-auth',
85
- '[AmalgmMCP] Skipping artifact route sync: neither tunnel_token nor AMALGM_GATEWAY_INTERNAL_SECRET is configured.',
100
+ '[AmalgmMCP] Skipping app route sync: neither tunnel_token nor AMALGM_GATEWAY_INTERNAL_SECRET is configured.',
86
101
  );
87
102
  return { ok: false, skipped: 'missing-auth' };
88
103
  }
@@ -90,17 +105,17 @@ function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
90
105
  const routes = getConnectedRoutes();
91
106
 
92
107
  try {
93
- const payload = await postArtifactRoutes(computerId, routes, authHeaders);
108
+ const payload = await postAppRoutes(computerId, routes, authHeaders);
94
109
  if (reason !== 'interval') {
95
110
  console.log(
96
- `[AmalgmMCP] Synced artifact routes (${reason}): computer=${computerId} count=${routes.length}`,
111
+ `[AmalgmMCP] Synced app routes (${reason}): computer=${computerId} count=${routes.length}`,
97
112
  );
98
113
  }
99
114
  return { ok: true, computerId, routes, payload };
100
115
  } catch (err) {
101
116
  const message = err instanceof Error ? err.message : String(err);
102
117
  if (message !== lastFailure || reason !== 'interval') {
103
- console.warn(`[AmalgmMCP] Artifact route sync failed (${reason}): ${message}`);
118
+ console.warn(`[AmalgmMCP] App route sync failed (${reason}): ${message}`);
104
119
  }
105
120
  lastFailure = message;
106
121
  if (throwOnError) throw err;
@@ -111,13 +126,13 @@ function syncArtifactRoutesToGateway(reason = 'manual', options = {}) {
111
126
  return syncQueue;
112
127
  }
113
128
 
114
- function startArtifactRouteSyncLoop() {
129
+ function startAppRouteSyncLoop() {
115
130
  if (routeSyncTimer) return routeSyncTimer;
116
131
 
117
- void syncArtifactRoutesToGateway('boot');
132
+ void syncAppRoutesToGateway('boot');
118
133
  routeSyncTimer = setInterval(() => {
119
- void syncArtifactRoutesToGateway('interval');
120
- }, ARTIFACT_ROUTE_SYNC_INTERVAL_MS);
134
+ void syncAppRoutesToGateway('interval');
135
+ }, APP_ROUTE_SYNC_INTERVAL_MS);
121
136
 
122
137
  if (typeof routeSyncTimer.unref === 'function') {
123
138
  routeSyncTimer.unref();
@@ -127,6 +142,6 @@ function startArtifactRouteSyncLoop() {
127
142
  }
128
143
 
129
144
  module.exports = {
130
- startArtifactRouteSyncLoop,
131
- syncArtifactRoutesToGateway,
145
+ startAppRouteSyncLoop,
146
+ syncAppRoutesToGateway,
132
147
  };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * /apps/* REST routes for the Next.js API and local tunnel runtime.
3
+ */
4
+
5
+ const { getConnectedRoutes, loadApps } = require('./store');
6
+ const {
7
+ deleteApp,
8
+ connectDns,
9
+ disconnectDns,
10
+ redeployApp,
11
+ registerApp,
12
+ startApp,
13
+ stopApp,
14
+ } = require('./supervisor');
15
+
16
+ function appIdFromBody(body) {
17
+ return body?.app_id || body?.appId || body?.artifact_id || body?.artifactId;
18
+ }
19
+
20
+ function legacyApp(app) {
21
+ if (!app) return app;
22
+ return {
23
+ ...app,
24
+ kind: app.kind === 'app' ? 'artifact' : app.kind,
25
+ artifactRef: app.appRef,
26
+ artifact_ref: app.app_ref,
27
+ artifactUrl: app.appUrl || app.publicUrl,
28
+ };
29
+ }
30
+
31
+ function appPayload(app, options = {}) {
32
+ return options.legacy ? { artifact: legacyApp(app) } : { app };
33
+ }
34
+
35
+ async function handleList(sendJson, options = {}) {
36
+ const data = loadApps();
37
+ if (options.legacy) {
38
+ return sendJson(200, {
39
+ version: data.version,
40
+ artifacts: data.apps.map(legacyApp),
41
+ });
42
+ }
43
+ sendJson(200, data);
44
+ }
45
+
46
+ async function handleRoutes(sendJson, options = {}) {
47
+ const routes = getConnectedRoutes();
48
+ sendJson(200, {
49
+ routes: options.legacy
50
+ ? routes.map((route) => ({
51
+ artifact_ref: route.app_ref,
52
+ port: route.port,
53
+ name: route.name,
54
+ }))
55
+ : routes,
56
+ });
57
+ }
58
+
59
+ async function handleRegister(body, sendJson, options = {}) {
60
+ try {
61
+ const app = await registerApp(body || {});
62
+ sendJson(200, appPayload(app, options));
63
+ } catch (err) {
64
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
65
+ }
66
+ }
67
+
68
+ async function handleRedeploy(body, sendJson, options = {}) {
69
+ try {
70
+ const appId = appIdFromBody(body);
71
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
72
+ const app = await redeployApp(appId, body);
73
+ sendJson(200, appPayload(app, options));
74
+ } catch (err) {
75
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
76
+ }
77
+ }
78
+
79
+ async function handleStart(body, sendJson, options = {}) {
80
+ try {
81
+ const appId = appIdFromBody(body);
82
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
83
+ const app = await startApp(appId);
84
+ sendJson(200, appPayload(app, options));
85
+ } catch (err) {
86
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
87
+ }
88
+ }
89
+
90
+ async function handleStop(body, sendJson, options = {}) {
91
+ try {
92
+ const appId = appIdFromBody(body);
93
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
94
+ const app = await stopApp(appId);
95
+ sendJson(200, appPayload(app, options));
96
+ } catch (err) {
97
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
98
+ }
99
+ }
100
+
101
+ async function handleDelete(body, sendJson, options = {}) {
102
+ try {
103
+ const appId = appIdFromBody(body);
104
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
105
+ const app = await deleteApp(appId);
106
+ sendJson(200, appPayload(app, options));
107
+ } catch (err) {
108
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
109
+ }
110
+ }
111
+
112
+ async function handleConnectDns(body, sendJson, options = {}) {
113
+ try {
114
+ const appId = appIdFromBody(body);
115
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
116
+ const app = await connectDns(appId);
117
+ sendJson(200, appPayload(app, options));
118
+ } catch (err) {
119
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
120
+ }
121
+ }
122
+
123
+ async function handleDisconnectDns(body, sendJson, options = {}) {
124
+ try {
125
+ const appId = appIdFromBody(body);
126
+ if (!appId) return sendJson(400, { error: 'app_id is required' });
127
+ const app = await disconnectDns(appId);
128
+ sendJson(200, appPayload(app, options));
129
+ } catch (err) {
130
+ sendJson(400, { error: err instanceof Error ? err.message : String(err) });
131
+ }
132
+ }
133
+
134
+ module.exports = {
135
+ handleConnectDns,
136
+ handleDelete,
137
+ handleDisconnectDns,
138
+ handleList,
139
+ handleRedeploy,
140
+ handleRegister,
141
+ handleRoutes,
142
+ handleStart,
143
+ handleStop,
144
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * App storage — ~/.amalgm/apps.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
+ APPS_DIR,
12
+ APPS_FILE,
13
+ APPS_DOMAIN,
14
+ APP_PORT_MIN,
15
+ APP_PORT_MAX,
16
+ LEGACY_ARTIFACTS_FILE,
17
+ } = require('../config');
18
+ const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
19
+ const { appendStateEvent } = require('../state/events');
20
+
21
+ function ensureAppsDirs() {
22
+ ensureDir(APPS_DIR);
23
+ if (!readJson(APPS_FILE, null)) {
24
+ const legacy = readJson(LEGACY_ARTIFACTS_FILE, null);
25
+ const legacyApps = Array.isArray(legacy?.artifacts) ? legacy.artifacts : [];
26
+ writeJsonAtomic(APPS_FILE, { version: 1, apps: legacyApps.map(normalizeApp) });
27
+ }
28
+ }
29
+
30
+ function publicUrlForRef(appRef) {
31
+ return `https://${appRef}.${APPS_DOMAIN}/`;
32
+ }
33
+
34
+ function genAppRef() {
35
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
36
+ const bytes = crypto.randomBytes(12);
37
+ let out = '';
38
+ for (let i = 0; i < 12; i += 1) {
39
+ out += chars[bytes[i] % chars.length];
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function normalizeApp(app) {
45
+ const appRef = app.appRef || app.app_ref || app.artifactRef || app.artifact_ref || genAppRef();
46
+ const savedUrl = app.appUrl || app.publicUrl || '';
47
+ const publicUrl = savedUrl && !String(savedUrl).includes('.artifacts.')
48
+ ? savedUrl
49
+ : publicUrlForRef(appRef);
50
+ const dnsConnected =
51
+ app.dnsConnected !== undefined
52
+ ? app.dnsConnected !== false
53
+ : app.connected !== false;
54
+
55
+ return {
56
+ ...app,
57
+ kind: 'app',
58
+ appRef,
59
+ app_ref: appRef,
60
+ appUrl: publicUrl,
61
+ publicUrl,
62
+ dnsConnected,
63
+ autostart: app.autostart !== false,
64
+ keepAlive: app.keepAlive !== false,
65
+ desiredState: app.desiredState || (app.status === 'stopped' ? 'stopped' : 'running'),
66
+ status: app.status || 'stopped',
67
+ pid: app.pid || null,
68
+ error: app.error || null,
69
+ };
70
+ }
71
+
72
+ function loadApps() {
73
+ let data = readJson(APPS_FILE, null);
74
+ if (!data) {
75
+ const legacy = readJson(LEGACY_ARTIFACTS_FILE, null);
76
+ data = Array.isArray(legacy?.artifacts)
77
+ ? { version: 1, apps: legacy.artifacts }
78
+ : { version: 1, apps: [] };
79
+ }
80
+ const apps = Array.isArray(data.apps) ? data.apps.map(normalizeApp) : [];
81
+ return { version: 1, apps };
82
+ }
83
+
84
+ function publishAppsChange(data, source = 'apps') {
85
+ try {
86
+ appendStateEvent({
87
+ resource: 'apps',
88
+ op: 'replace',
89
+ value: Array.isArray(data?.apps) ? data.apps.map(normalizeApp) : [],
90
+ source,
91
+ });
92
+ } catch (error) {
93
+ console.warn('[Apps] Local Live Store publish failed:', error.message);
94
+ }
95
+ }
96
+
97
+ function saveApps(data, options = {}) {
98
+ ensureDir(APPS_DIR);
99
+ const normalizedData = {
100
+ version: 1,
101
+ apps: Array.isArray(data.apps) ? data.apps.map(normalizeApp) : [],
102
+ };
103
+ writeJsonAtomic(APPS_FILE, normalizedData);
104
+ publishAppsChange(normalizedData, options.source || 'apps:save');
105
+ }
106
+
107
+ function updateAppMeta(appId, updates) {
108
+ const data = loadApps();
109
+ const app = data.apps.find((item) => item.id === appId);
110
+ if (!app) return null;
111
+ Object.assign(app, updates, { updatedAt: new Date().toISOString() });
112
+ saveApps(data);
113
+ return normalizeApp(app);
114
+ }
115
+
116
+ function getApp(appId) {
117
+ return loadApps().apps.find((app) => app.id === appId) || null;
118
+ }
119
+
120
+ function allocatePort(preferredPort) {
121
+ if (preferredPort !== undefined && preferredPort !== null) {
122
+ const parsed = Number(preferredPort);
123
+ if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed;
124
+ return null;
125
+ }
126
+
127
+ const used = new Set(loadApps().apps.map((app) => Number(app.port)));
128
+ for (let port = APP_PORT_MIN; port <= APP_PORT_MAX; port += 1) {
129
+ if (!used.has(port)) return port;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function allocateUniqueAppRef() {
135
+ const used = new Set(loadApps().apps.map((app) => app.appRef));
136
+ for (let attempt = 0; attempt < 12; attempt += 1) {
137
+ const candidate = genAppRef();
138
+ if (!used.has(candidate)) return candidate;
139
+ }
140
+ throw new Error('Could not allocate app DNS ref');
141
+ }
142
+
143
+ function getConnectedRoutes() {
144
+ return loadApps()
145
+ .apps
146
+ .filter((app) =>
147
+ app.dnsConnected
148
+ && app.desiredState === 'running'
149
+ && app.status !== 'stopped'
150
+ && app.status !== 'error'
151
+ && app.port
152
+ && app.appRef,
153
+ )
154
+ .map((app) => ({
155
+ app_ref: app.appRef,
156
+ port: app.port,
157
+ name: app.name,
158
+ }));
159
+ }
160
+
161
+ module.exports = {
162
+ allocatePort,
163
+ allocateUniqueAppRef,
164
+ ensureAppsDirs,
165
+ getApp,
166
+ getConnectedRoutes,
167
+ loadApps,
168
+ publicUrlForRef,
169
+ saveApps,
170
+ updateAppMeta,
171
+ };