amalgm 0.1.153 → 0.1.154

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.
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
4
+ require('node:path').join(require('node:os').tmpdir(), 'amalgm-apps-portable-'),
5
+ );
6
+
7
+ const assert = require('node:assert/strict');
8
+ const fs = require('node:fs');
9
+ const net = require('node:net');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const test = require('node:test');
13
+
14
+ const { APP_MANIFEST_FILE } = require('../apps/manifest');
15
+ const { inspectPortableApp, packPortableApp } = require('../apps/portable');
16
+ const { loadApps } = require('../apps/store');
17
+ const { deleteApp, registerApp } = require('../apps/supervisor');
18
+
19
+ function makeApp() {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-portable-app-'));
21
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
22
+ fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
23
+ fs.writeFileSync(path.join(dir, 'src', 'server.js'), [
24
+ "const http = require('http');",
25
+ "http.createServer((_request, response) => response.end('ok')).listen(Number(process.env.PORT), '0.0.0.0');",
26
+ '',
27
+ ].join('\n'));
28
+ fs.writeFileSync(path.join(dir, 'data', 'private.json'), '{"user":"state"}\n');
29
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=never-share\n');
30
+ return dir;
31
+ }
32
+
33
+ function writeManifest(dir, overrides = {}) {
34
+ const manifest = {
35
+ schemaVersion: 1,
36
+ name: 'Portable Test App',
37
+ description: 'Exercises the portable app boundary',
38
+ buildCommand: null,
39
+ startCommand: 'node src/server.js',
40
+ toolIds: ['portable.test-tool'],
41
+ package: {
42
+ include: ['src/**'],
43
+ exclude: [],
44
+ },
45
+ ...overrides,
46
+ };
47
+ fs.writeFileSync(path.join(dir, APP_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
48
+ return manifest;
49
+ }
50
+
51
+ async function availablePort() {
52
+ const server = net.createServer();
53
+ await new Promise((resolve, reject) => {
54
+ server.once('error', reject);
55
+ server.listen(0, '127.0.0.1', resolve);
56
+ });
57
+ const port = server.address().port;
58
+ await new Promise((resolve) => server.close(resolve));
59
+ return port;
60
+ }
61
+
62
+ test('portable package uses the manifest allowlist and always carries the manifest', () => {
63
+ const dir = makeApp();
64
+ writeManifest(dir);
65
+
66
+ const packed = packPortableApp(dir);
67
+ assert.deepEqual(packed.files.map((file) => file.path), [APP_MANIFEST_FILE, 'src/server.js']);
68
+ assert.equal(packed.files.some((file) => file.path === 'data/private.json'), false);
69
+ assert.equal(packed.files.some((file) => file.path === '.env'), false);
70
+ assert.deepEqual(packed.manifest.toolIds, ['portable.test-tool']);
71
+ });
72
+
73
+ test('portable package rejects invalid manifests and stale include patterns', () => {
74
+ const dir = makeApp();
75
+ writeManifest(dir, { package: { include: ['missing/**'], exclude: [] } });
76
+ assert.throws(() => inspectPortableApp(dir), /pattern matched no files: missing\/\*\*/);
77
+
78
+ writeManifest(dir, { package: { include: ['src/**'], exclude: [APP_MANIFEST_FILE] } });
79
+ assert.throws(() => inspectPortableApp(dir), /cannot be excluded/);
80
+
81
+ writeManifest(dir, { schemaVersion: 99 });
82
+ assert.throws(() => inspectPortableApp(dir), /schemaVersion must be 1/);
83
+ });
84
+
85
+ test('portable package reports selected symlinks without following them', () => {
86
+ const dir = makeApp();
87
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-portable-outside-'));
88
+ fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside\n');
89
+ fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
90
+ writeManifest(dir, { package: { include: ['src/**', 'linked/**'], exclude: [] } });
91
+
92
+ const packed = packPortableApp(dir);
93
+ assert.equal(packed.files.some((file) => file.path.includes('secret.txt')), false);
94
+ assert.deepEqual(packed.excluded.find((entry) => entry.path === 'linked'), {
95
+ path: 'linked',
96
+ reason: 'symlink',
97
+ });
98
+ });
99
+
100
+ test('registration rejects non-manifest inputs before persisting an app', async () => {
101
+ const dir = makeApp();
102
+ await assert.rejects(() => registerApp({ cwd: dir }), /amalgm\.app\.json is required/);
103
+ assert.equal(loadApps().apps.length, 0);
104
+
105
+ writeManifest(dir);
106
+ await assert.rejects(
107
+ () => registerApp({ cwd: dir, start_command: 'node src/server.js' }),
108
+ /reads start_command from amalgm\.app\.json/,
109
+ );
110
+ assert.equal(loadApps().apps.length, 0);
111
+ });
112
+
113
+ test('registration validates once, stores manifest values, and starts the app', async () => {
114
+ const dir = makeApp();
115
+ writeManifest(dir);
116
+
117
+ const app = await registerApp({ cwd: dir, port: await availablePort(), connect_dns: false });
118
+ try {
119
+ assert.equal(app.name, 'Portable Test App');
120
+ assert.equal(app.startCommand, 'node src/server.js');
121
+ assert.deepEqual(app.toolIds, ['portable.test-tool']);
122
+ assert.equal(app.status, 'running');
123
+ assert.equal(app.dnsConnected, false);
124
+ } finally {
125
+ await deleteApp(app.id);
126
+ }
127
+ });
@@ -12,9 +12,9 @@ const test = require('node:test');
12
12
 
13
13
  const { createAutomation, getAutomation, listAutomations } = require('../automations/store');
14
14
  const { loadApps, saveApps } = require('../apps/store');
15
+ const { inspectPortableApp, packPortableApp, unpackAppFiles } = require('../apps/portable');
15
16
  const { exportAutomationEntry, installAutomationEntry } = require('../agent-bundles/automation-entries');
16
17
  const { exportAppEntry, materializeAppEntry } = require('../agent-bundles/app-entries');
17
- const { packAppFiles, unpackAppFiles } = require('../agent-bundles/app-files');
18
18
  const { createBundle, installBundle, validateBundle } = require('../agent-bundles/bundles');
19
19
 
20
20
  function makeTempDir(prefix) {
@@ -32,19 +32,49 @@ function writeSampleApp(dir) {
32
32
  fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
33
33
  fs.writeFileSync(path.join(dir, 'logo.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]));
34
34
  fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\necho run\n', { mode: 0o755 });
35
+ writeAppManifest(dir, { include: ['**'] });
36
+ }
37
+
38
+ function writeAppManifest(dir, overrides = {}) {
39
+ const include = overrides.include || fs.readdirSync(dir, { withFileTypes: true })
40
+ .filter((entry) => !['amalgm.app.json', '.env', '.git', 'node_modules'].includes(entry.name))
41
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/**` : entry.name));
42
+ const manifest = {
43
+ schemaVersion: 1,
44
+ name: overrides.name || 'Sample App',
45
+ description: overrides.description || 'A sample app',
46
+ buildCommand: Object.prototype.hasOwnProperty.call(overrides, 'buildCommand')
47
+ ? overrides.buildCommand
48
+ : 'npm install && npm run build',
49
+ startCommand: overrides.startCommand || 'npm run start -- --port {port}',
50
+ toolIds: overrides.toolIds || [],
51
+ package: { include, exclude: overrides.exclude || [] },
52
+ };
53
+ fs.writeFileSync(path.join(dir, 'amalgm.app.json'), `${JSON.stringify(manifest, null, 2)}\n`);
54
+ return manifest;
35
55
  }
36
56
 
37
57
  function registerSampleApp(cwd, overrides = {}) {
58
+ const manifest = writeAppManifest(cwd, {
59
+ name: overrides.name,
60
+ description: overrides.description,
61
+ buildCommand: Object.prototype.hasOwnProperty.call(overrides, 'buildCommand')
62
+ ? overrides.buildCommand
63
+ : 'npm install && npm run build',
64
+ startCommand: overrides.startCommand,
65
+ toolIds: overrides.toolIds,
66
+ });
38
67
  const data = loadApps();
39
68
  const app = {
40
69
  id: 'app-sample-test',
41
70
  kind: 'app',
42
- name: 'Sample App',
43
- description: 'A sample app',
71
+ name: manifest.name,
72
+ description: manifest.description,
44
73
  cwd,
45
74
  port: 43210,
46
- buildCommand: 'npm run build',
47
- startCommand: 'npm run start -- --port {port}',
75
+ buildCommand: manifest.buildCommand,
76
+ startCommand: manifest.startCommand,
77
+ toolIds: manifest.toolIds,
48
78
  appRef: 'sampleref0001',
49
79
  publicUrl: 'https://sampleref0001.apps.amalgm.ai/',
50
80
  dnsConnected: true,
@@ -124,23 +154,23 @@ test('automation entry install rejects entries without workflow text', () => {
124
154
  );
125
155
  });
126
156
 
127
- test('app file packing excludes dependencies, env files, and vcs state', () => {
157
+ test('portable app packaging follows the manifest and applies platform exclusions', () => {
128
158
  const dir = makeTempDir('bundle-app-src-');
129
159
  writeSampleApp(dir);
130
160
 
131
- const packed = packAppFiles(dir);
161
+ const packed = packPortableApp(dir);
132
162
  const paths = packed.files.map((file) => file.path);
133
163
 
134
- assert.deepEqual(paths, ['logo.bin', 'package.json', 'run.sh', 'src/index.js']);
164
+ assert.deepEqual(paths, ['amalgm.app.json', 'logo.bin', 'package.json', 'run.sh', 'src/index.js']);
135
165
  assert.equal(packed.files.find((file) => file.path === 'logo.bin').encoding, 'base64');
136
166
  assert.equal(packed.files.find((file) => file.path === 'run.sh').executable, true);
137
- assert.equal(packed.skipped.some((item) => item.path === '.env'), true);
138
- assert.equal(packed.skipped.some((item) => item.path === 'node_modules/'), true);
139
- assert.equal(packed.skipped.some((item) => item.path === '.git/'), true);
167
+ assert.equal(packed.excluded.some((item) => item.path === '.env'), true);
168
+ assert.equal(packed.excluded.some((item) => item.path === 'node_modules/'), true);
169
+ assert.equal(packed.excluded.some((item) => item.path === '.git/'), true);
140
170
 
141
171
  const target = makeTempDir('bundle-app-dst-');
142
172
  const result = unpackAppFiles(packed.files, target);
143
- assert.equal(result.written, 4);
173
+ assert.equal(result.written, 5);
144
174
  assert.equal(fs.readFileSync(path.join(target, 'src', 'index.js'), 'utf8'), 'console.log("hi");\n');
145
175
  assert.equal(fs.existsSync(path.join(target, '.env')), false);
146
176
  assert.equal(Boolean(fs.statSync(path.join(target, 'run.sh')).mode & 0o100), true);
@@ -154,7 +184,7 @@ test('unpacking rejects paths that escape the install directory', () => {
154
184
  const target = makeTempDir('bundle-app-escape-');
155
185
  assert.throws(
156
186
  () => unpackAppFiles([{ path: '../evil.txt', encoding: 'utf8', content: 'nope' }], target),
157
- /escapes the install directory/,
187
+ /Invalid app file path/,
158
188
  );
159
189
  });
160
190
 
@@ -166,59 +196,38 @@ test('app entry export carries the portable record and install materializes a ru
166
196
  const { entry } = exportAppEntry(app.id);
167
197
  assert.equal(entry.app.name, 'Sample App');
168
198
  assert.equal(entry.app.startCommand, 'npm run start -- --port {port}');
169
- assert.equal(entry.app.buildCommand, 'npm run build');
199
+ assert.equal(entry.app.buildCommand, 'npm install && npm run build');
200
+ assert.deepEqual(entry.manifest, inspectPortableApp(dir).manifest);
170
201
  assert.equal('cwd' in entry.app, false);
171
202
  assert.equal('port' in entry.app, false);
172
203
  assert.equal('appRef' in entry.app, false);
173
- assert.equal(entry.files.length, 4);
204
+ assert.equal(entry.files.length, 5);
174
205
 
175
206
  const rootDir = makeTempDir('bundle-app-install-root-');
176
207
  const registerInput = materializeAppEntry(entry, { rootDir });
177
- assert.equal(registerInput.start_command, entry.app.startCommand);
178
- assert.equal(registerInput.build_command, 'npm install && npm run build');
208
+ assert.deepEqual(Object.keys(registerInput).sort(), ['autostart', 'cwd', 'keep_alive']);
179
209
  assert.equal(registerInput.cwd.startsWith(rootDir), true);
180
210
  assert.equal(fs.readFileSync(path.join(registerInput.cwd, 'package.json'), 'utf8'), JSON.stringify({ name: 'sample-app' }));
211
+ assert.deepEqual(inspectPortableApp(registerInput.cwd).manifest, entry.manifest);
181
212
 
182
213
  // A second install of the same entry gets its own directory.
183
214
  const secondInput = materializeAppEntry(entry, { rootDir });
184
215
  assert.notEqual(secondInput.cwd, registerInput.cwd);
185
216
  });
186
217
 
187
- test('materializeAppEntry bootstraps node dependencies for imported apps without duplicating install steps', () => {
218
+ test('materializeAppEntry preserves explicit lifecycle commands without package-manager guessing', () => {
188
219
  const rootDir = makeTempDir('bundle-app-bootstrap-root-');
189
-
190
220
  const buildDir = makeTempDir('bundle-app-bootstrap-src-');
191
221
  fs.writeFileSync(path.join(buildDir, 'package.json'), JSON.stringify({ name: 'bootstrap-build' }));
192
222
  fs.writeFileSync(path.join(buildDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-build', lockfileVersion: 3 }));
193
223
  fs.writeFileSync(path.join(buildDir, 'server.js'), 'console.log("serve");\n');
194
224
  const buildApp = registerSampleApp(buildDir, {
195
- buildCommand: 'npm run build',
225
+ buildCommand: 'npm ci && npm run build',
196
226
  startCommand: 'node server.js',
197
227
  });
198
228
  const buildEntry = exportAppEntry(buildApp.id).entry;
199
- assert.equal(materializeAppEntry(buildEntry, { rootDir }).build_command, 'npm install && npm run build');
200
-
201
- const preinstallDir = makeTempDir('bundle-app-bootstrap-preinstall-src-');
202
- fs.writeFileSync(path.join(preinstallDir, 'package.json'), JSON.stringify({ name: 'bootstrap-preinstall' }));
203
- fs.writeFileSync(path.join(preinstallDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-preinstall', lockfileVersion: 3 }));
204
- fs.writeFileSync(path.join(preinstallDir, 'server.js'), 'console.log("serve");\n');
205
- const preinstallApp = registerSampleApp(preinstallDir, {
206
- buildCommand: 'npm install && npm run build',
207
- startCommand: 'node server.js',
208
- });
209
- const preinstallEntry = exportAppEntry(preinstallApp.id).entry;
210
- assert.equal(materializeAppEntry(preinstallEntry, { rootDir }).build_command, 'npm install && npm run build');
211
-
212
- const startOnlyDir = makeTempDir('bundle-app-bootstrap-startonly-src-');
213
- fs.writeFileSync(path.join(startOnlyDir, 'package.json'), JSON.stringify({ name: 'bootstrap-startonly' }));
214
- fs.writeFileSync(path.join(startOnlyDir, 'package-lock.json'), JSON.stringify({ name: 'bootstrap-startonly', lockfileVersion: 3 }));
215
- fs.writeFileSync(path.join(startOnlyDir, 'server.js'), 'console.log("serve");\n');
216
- const startOnlyApp = registerSampleApp(startOnlyDir, {
217
- buildCommand: null,
218
- startCommand: 'node server.js',
219
- });
220
- const startOnlyEntry = exportAppEntry(startOnlyApp.id).entry;
221
- assert.equal(materializeAppEntry(startOnlyEntry, { rootDir }).build_command, 'npm install');
229
+ const materialized = materializeAppEntry(buildEntry, { rootDir });
230
+ assert.equal(inspectPortableApp(materialized.cwd).manifest.buildCommand, 'npm ci && npm run build');
222
231
  });
223
232
 
224
233
  test('mixed bundle carries automations and apps with heads and installs everything', async () => {
@@ -254,9 +263,9 @@ test('mixed bundle carries automations and apps with heads and installs everythi
254
263
  assert.equal('content' in appElement.data.files[0], false);
255
264
  assert.equal(preview.automations.length, 1);
256
265
  assert.equal(preview.apps.length, 1);
257
- assert.equal(preview.apps[0].files, 4);
258
- assert.equal(bundle.apps[0].app.autostart, false);
259
- assert.equal(bundle.apps[0].app.keepAlive, false);
266
+ assert.equal(preview.apps[0].files, 5);
267
+ assert.equal(bundle.apps[0].app.autostart, true);
268
+ assert.equal(bundle.apps[0].app.keepAlive, true);
260
269
 
261
270
  // Round-trip through JSON like the storage path does.
262
271
  const revived = JSON.parse(JSON.stringify(bundle));
@@ -294,6 +303,21 @@ test('legacy agent-only bundles still validate and empty bundles are rejected',
294
303
  );
295
304
  });
296
305
 
306
+ test('bundle validation rejects duplicate canonical apps and manifest drift', () => {
307
+ const dir = makeTempDir('bundle-app-invariants-');
308
+ writeSampleApp(dir);
309
+ const app = registerSampleApp(dir, { id: 'app-invariant-test' });
310
+ const { bundle } = createBundle({ appIds: [app.id] });
311
+
312
+ const duplicated = JSON.parse(JSON.stringify(bundle));
313
+ duplicated.apps.push(JSON.parse(JSON.stringify(duplicated.apps[0])));
314
+ assert.throws(() => validateBundle(duplicated), /duplicate app id/);
315
+
316
+ const drifted = JSON.parse(JSON.stringify(bundle));
317
+ drifted.apps[0].manifest.name = 'Misleading name';
318
+ assert.throws(() => validateBundle(drifted), /manifest summary does not match/);
319
+ });
320
+
297
321
  test('standalone tool bundle carries the tool as a head and installs it', async () => {
298
322
  const { defaultToolboxService } = require('../toolbox/service');
299
323
  defaultToolboxService.registerTool({
@@ -602,7 +626,7 @@ test('sharing an app pulls its owned tools, nested under the app in the graph',
602
626
  return { id: 'app-installed-owned', ...input, status: 'running' };
603
627
  },
604
628
  });
605
- assert.deepEqual(registered[0].tool_ids, ['appowned.tool']);
629
+ assert.deepEqual(inspectPortableApp(registered[0].cwd).manifest.toolIds, ['appowned.tool']);
606
630
  const installedTool = result.installedTools.find((tool) => tool.id === 'appowned.tool');
607
631
  assert.equal(installedTool.appId, 'app-installed-owned');
608
632
  assert.equal(installedTool.source.cwd, result.installedApps[0].cwd);
@@ -56,3 +56,18 @@ test('platform context teaches rich media embeds with GIF video previews', () =>
56
56
  assert.equal(text.includes('<name>.poster.png'), true);
57
57
  assert.equal(text.includes('≤20MB'), true);
58
58
  });
59
+
60
+ test('platform context teaches the manifest-owned app registration boundary', () => {
61
+ const text = fs.readFileSync(CONTEXT_PATH, 'utf8');
62
+
63
+ for (const required of [
64
+ 'amalgm.app.json',
65
+ '`apps_validate`',
66
+ 'The manifest is the single source of truth',
67
+ '`package.include` is an allowlist',
68
+ 'Amalgm does not guess a package manager',
69
+ 'they are not portable and cannot be shared',
70
+ ]) {
71
+ assert.equal(text.includes(required), true, `platform context should include ${required}`);
72
+ }
73
+ });
@@ -113,7 +113,7 @@ function titleFromMcp(tool, input = {}) {
113
113
  if (action === 'record_stop') return 'Saving browser recording';
114
114
  if (action === 'record_list') return 'Listing browser recordings';
115
115
  if (action === 'auth_list') return 'Listing browser logins';
116
- if (name === 'apps_register') return input.name ? `Registering app ${compactText(input.name, 60)}` : 'Registering app';
116
+ if (name === 'apps_register') return input.cwd ? `Registering app from ${compactText(input.cwd, 60)}` : 'Registering app';
117
117
  if (name === 'apps_start') return 'Starting app';
118
118
  if (name === 'apps_stop') return 'Stopping app';
119
119
  if (name === 'apps_list') return 'Listing apps';
@@ -54,30 +54,53 @@ Apps are **user-hosted services**. They run on the user's connected computer, no
54
54
 
55
55
  **Mental model:**
56
56
  - **App** — runs on user compute and is exposed on `https://{app_ref}.apps.amalgm.ai`
57
- - **App** — hyperscaler-hosted and lives on `*.apps.amalgm.ai`
58
57
 
59
58
  At the moment, the app flow is the one you can directly control from the MCP.
60
59
 
61
60
  **What an app requires:**
62
- 1. It points to Amalgm app DNS.
63
- 2. It must run as a production service. Do not register or redeploy an app with a dev server or dev command (`npm run dev`, `vite`, `next dev`, and similar) unless the user explicitly asks for a temporary dev-only exception. Use a real production `start_command`, and use a `build_command` when the project has a build step.
61
+ 1. Its project root contains a valid `amalgm.app.json`.
62
+ 2. It must run as a production service. Do not register or redeploy an app with a dev server or dev command (`npm run dev`, `vite`, `next dev`, and similar). Use a real production `startCommand` and a `buildCommand` when setup or compilation is required.
64
63
  3. It is registered locally so Amalgm knows to start it on boot and keep it alive while the computer is on.
65
64
 
65
+ **Portable app manifest:**
66
+
67
+ ```json
68
+ {
69
+ "schemaVersion": 1,
70
+ "name": "Design Canvas",
71
+ "description": "A local design workspace",
72
+ "buildCommand": "npm ci && npm run build",
73
+ "startCommand": "npm run start -- --host 0.0.0.0 --port {port}",
74
+ "toolIds": ["amalgm.design-canvas"],
75
+ "package": {
76
+ "include": ["package.json", "package-lock.json", "src/**", "public/**", "tool/**"],
77
+ "exclude": ["data/**", "exports/**"]
78
+ }
79
+ }
80
+ ```
81
+
82
+ The manifest is the single source of truth for portable app metadata, lifecycle commands, owned tools, and shared files. `package.include` is an allowlist; `package.exclude` subtracts from it. `amalgm.app.json` is always included automatically. Amalgm also excludes dependencies, VCS state, env files, credentials, logs, and symlinks. Put generated data and user content outside the include set. If a clean recipient needs dependencies, the declared `buildCommand` must install them; Amalgm does not guess a package manager.
83
+
84
+ Create or update the manifest before registration, then call `apps_validate` and fix every error before `apps_register`. Do not pass name, description, build/start commands, or tool ids to registration; Apps reads them from the manifest. Existing legacy registrations without a manifest can keep running, but they are not portable and cannot be shared.
85
+
66
86
  **Tools:**
67
- - `apps_register` — Register an existing local project as an app. Stores the record in `~/.amalgm/apps.json`, starts it, and connects DNS by default.
87
+ - `apps_validate` — Dry-run the exact package that registration and sharing use. Reports selected files, exclusions, and total bytes without mutating anything.
88
+ - `apps_register` — Validate and register the manifest in an existing local project. Stores the record in `~/.amalgm/apps.json`, starts it, and connects DNS by default.
68
89
  - `apps_list` — List registered local apps.
69
90
  - `apps_routes` — Show app routes currently advertised by this computer.
70
91
  - `apps_start` — Start a stopped app with its production start command.
71
92
  - `apps_stop` — Stop a registered app and mark it intentionally stopped.
72
- - `apps_redeploy` — Update commands/port if needed, rerun the build command if configured, and restart the production process.
93
+ - `apps_redeploy` — Reread and validate the manifest, update the app record, rerun `buildCommand`, and restart the production process. It can also update port/lifecycle settings.
73
94
  - `apps_connect_dns` — Reconnect an app to `*.apps.amalgm.ai` without changing the local registration.
74
95
  - `apps_disconnect_dns` — Remove public routing without deleting the app or stopping the local process.
75
96
 
76
97
  **How to think about app setup:**
77
98
  - Prefer using the user's existing project directory instead of generating a new framework wrapper.
78
- - If the project has a build step, require a real `build_command`.
79
- - Require a real production `start_command` that binds to `PORT` or `{port}`.
80
- - Do not use dev servers for apps by default. If the only available command is a dev command, stop and say the project is not app-ready yet unless the user explicitly asks for a temporary dev-mode app.
99
+ - Create `amalgm.app.json` with the smallest explicit include set that makes a clean copy build and run.
100
+ - If the project needs dependencies or a build step, declare the complete production `buildCommand`.
101
+ - Require a real production `startCommand` that binds to `PORT` or `{port}`.
102
+ - Do not use dev servers for apps. If the only available command is a dev command, stop and say the project is not app-ready yet.
103
+ - Run `apps_validate` before `apps_register` and after changing the manifest.
81
104
  - If the framework needs an explicit host, `0.0.0.0` is a good default. The important thing is that the service reliably binds to the assigned port.
82
105
  - Random app refs are fine. Do not block on naming.
83
106
 
@@ -164,8 +187,8 @@ Don't just answer questions — **build systems**. If the user's need is ongoing
164
187
 
165
188
  Apps should be treated as **registered production services on user compute**, not as temporary dev servers.
166
189
 
167
- - Use `apps_register` only with a real production `start_command`.
168
- - Require a production `build_command` when the project supports one.
190
+ - Use `apps_register` only after `apps_validate` succeeds.
191
+ - Keep production `buildCommand` and `startCommand` in `amalgm.app.json`.
169
192
  - Reject `npm run dev`, `vite`, `next dev`, and similar dev-only commands for apps unless the user explicitly asks for a temporary exception.
170
193
  - Use `apps_redeploy` when code or commands change.
171
194
  - Use `apps_start` and `apps_stop` to control the registered service lifecycle.
@@ -1,160 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Pack and unpack an app's file tree for portable bundles.
5
- *
6
- * Files travel inline in the bundle JSON (utf8 or base64), so packing is
7
- * conservative: dependency dirs, VCS state, env files, and build outputs are
8
- * excluded — the recipient's install re-runs the app's build command — and
9
- * per-file/total size caps keep the bundle under the storage object limit.
10
- * Everything skipped is reported so the share UI can surface it.
11
- */
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
-
16
- const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
17
- const DEFAULT_MAX_TOTAL_BYTES = 12 * 1024 * 1024;
18
-
19
- const EXCLUDED_DIR_NAMES = new Set([
20
- 'node_modules',
21
- '.git',
22
- '.hg',
23
- '.svn',
24
- '.amalgm',
25
- '.next',
26
- '.nuxt',
27
- '.turbo',
28
- '.vercel',
29
- '.cache',
30
- '.parcel-cache',
31
- '.venv',
32
- 'venv',
33
- '__pycache__',
34
- 'dist',
35
- 'build',
36
- 'out',
37
- 'coverage',
38
- 'tmp',
39
- ]);
40
-
41
- const EXCLUDED_FILE_NAMES = new Set([
42
- '.DS_Store',
43
- 'Thumbs.db',
44
- 'npm-debug.log',
45
- 'yarn-error.log',
46
- ]);
47
-
48
- function isExcludedFileName(name) {
49
- if (EXCLUDED_FILE_NAMES.has(name)) return true;
50
- // .env, .env.local, .env.production, … — never ship credentials.
51
- if (name === '.env' || name.startsWith('.env.')) return true;
52
- if (name.endsWith('.log')) return true;
53
- return false;
54
- }
55
-
56
- function isBinary(buffer) {
57
- const sample = buffer.subarray(0, 8192);
58
- if (sample.includes(0)) return true;
59
- return !buffer.equals(Buffer.from(buffer.toString('utf8'), 'utf8'));
60
- }
61
-
62
- function toPosixPath(relativePath) {
63
- return relativePath.split(path.sep).join('/');
64
- }
65
-
66
- function packAppFiles(rootDir, options = {}) {
67
- const root = path.resolve(rootDir);
68
- if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
69
- throw new Error(`App directory not found: ${root}`);
70
- }
71
-
72
- const maxFileBytes = options.maxFileBytes || DEFAULT_MAX_FILE_BYTES;
73
- const maxTotalBytes = options.maxTotalBytes || DEFAULT_MAX_TOTAL_BYTES;
74
- const files = [];
75
- const skipped = [];
76
- let totalBytes = 0;
77
-
78
- function visit(dir) {
79
- const entries = fs.readdirSync(dir, { withFileTypes: true })
80
- .sort((left, right) => left.name.localeCompare(right.name));
81
- for (const dirent of entries) {
82
- const absolute = path.join(dir, dirent.name);
83
- const relative = toPosixPath(path.relative(root, absolute));
84
-
85
- if (dirent.isSymbolicLink()) {
86
- skipped.push({ path: relative, reason: 'symlink' });
87
- continue;
88
- }
89
- if (dirent.isDirectory()) {
90
- if (EXCLUDED_DIR_NAMES.has(dirent.name)) {
91
- skipped.push({ path: `${relative}/`, reason: 'excluded' });
92
- continue;
93
- }
94
- visit(absolute);
95
- continue;
96
- }
97
- if (!dirent.isFile()) continue;
98
- if (isExcludedFileName(dirent.name)) {
99
- skipped.push({ path: relative, reason: 'excluded' });
100
- continue;
101
- }
102
-
103
- const stat = fs.statSync(absolute);
104
- if (stat.size > maxFileBytes) {
105
- skipped.push({ path: relative, reason: 'file-too-large', size: stat.size });
106
- continue;
107
- }
108
- if (totalBytes + stat.size > maxTotalBytes) {
109
- skipped.push({ path: relative, reason: 'bundle-size-limit', size: stat.size });
110
- continue;
111
- }
112
-
113
- const buffer = fs.readFileSync(absolute);
114
- const binary = isBinary(buffer);
115
- totalBytes += stat.size;
116
- files.push({
117
- path: relative,
118
- encoding: binary ? 'base64' : 'utf8',
119
- content: binary ? buffer.toString('base64') : buffer.toString('utf8'),
120
- size: stat.size,
121
- ...(stat.mode & 0o111 ? { executable: true } : {}),
122
- });
123
- }
124
- }
125
-
126
- visit(root);
127
- return { files, totalBytes, skipped };
128
- }
129
-
130
- function assertInsideTarget(targetRoot, relativePath) {
131
- const normalized = String(relativePath || '');
132
- const resolved = path.resolve(targetRoot, normalized);
133
- if (resolved !== targetRoot && !resolved.startsWith(targetRoot + path.sep)) {
134
- throw new Error(`App file escapes the install directory: ${normalized}`);
135
- }
136
- return resolved;
137
- }
138
-
139
- function unpackAppFiles(files, targetDir) {
140
- const targetRoot = path.resolve(targetDir);
141
- fs.mkdirSync(targetRoot, { recursive: true });
142
-
143
- let written = 0;
144
- for (const file of Array.isArray(files) ? files : []) {
145
- if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') continue;
146
- const destination = assertInsideTarget(targetRoot, file.path);
147
- fs.mkdirSync(path.dirname(destination), { recursive: true });
148
- const buffer = file.encoding === 'base64'
149
- ? Buffer.from(file.content, 'base64')
150
- : Buffer.from(file.content, 'utf8');
151
- fs.writeFileSync(destination, buffer, { mode: file.executable ? 0o755 : 0o644 });
152
- written += 1;
153
- }
154
- return { written, targetDir: targetRoot };
155
- }
156
-
157
- module.exports = {
158
- packAppFiles,
159
- unpackAppFiles,
160
- };