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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.153",
3
+ "version": "0.1.154",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -3,11 +3,10 @@
3
3
  /**
4
4
  * Portable app bundle entries.
5
5
  *
6
- * An exported entry carries the app's registration record (commands, lifecycle
7
- * flags) plus its packed file tree. Machine-bound state cwd, port, appRef,
8
- * public URL, pid/status stays behind: install writes the files into a fresh
9
- * local directory and registers through the normal supervisor path, which
10
- * allocates a new port and DNS ref, runs the build, and starts the process.
6
+ * The app manifest is canonical. An exported entry carries that manifest and
7
+ * its selected files; install writes them into a fresh directory and calls the
8
+ * normal Apps registration path, which validates, builds, starts, and routes
9
+ * the recipient's copy.
11
10
  */
12
11
 
13
12
  const fs = require('fs');
@@ -15,37 +14,12 @@ const path = require('path');
15
14
 
16
15
  const { getApp } = require('../apps/store');
17
16
  const { registerApp } = require('../apps/supervisor');
18
- const { packAppFiles, unpackAppFiles } = require('./app-files');
17
+ const { inspectPortableApp, packPortableApp, unpackAppFiles } = require('../apps/portable');
19
18
 
20
19
  function isObject(value) {
21
20
  return !!value && typeof value === 'object' && !Array.isArray(value);
22
21
  }
23
22
 
24
- function cleanString(value) {
25
- return typeof value === 'string' ? value.trim() : '';
26
- }
27
-
28
- function uniqueToolIds(value) {
29
- if (!Array.isArray(value)) return [];
30
- const out = [];
31
- for (const item of value) {
32
- const id = cleanString(item);
33
- if (id && !out.includes(id)) out.push(id);
34
- }
35
- return out;
36
- }
37
-
38
- const NODE_INSTALL_COMMANDS = [
39
- ['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
40
- ['yarn.lock', 'yarn install --frozen-lockfile'],
41
- ['bun.lockb', 'bun install --frozen-lockfile'],
42
- ['bun.lock', 'bun install --frozen-lockfile'],
43
- ['package-lock.json', 'npm install'],
44
- ['npm-shrinkwrap.json', 'npm install'],
45
- ];
46
-
47
- const NODE_INSTALL_PATTERN = /\b(?:npm\s+(?:ci|install)|pnpm\s+install|yarn\s+install|bun\s+install)\b/;
48
-
49
23
  function slugify(value) {
50
24
  return String(value || '')
51
25
  .toLowerCase()
@@ -54,54 +28,39 @@ function slugify(value) {
54
28
  .slice(0, 60) || 'app';
55
29
  }
56
30
 
57
- function nodeBootstrapCommand(targetDir) {
58
- if (!fs.existsSync(path.join(targetDir, 'package.json'))) return null;
59
- for (const [marker, command] of NODE_INSTALL_COMMANDS) {
60
- if (fs.existsSync(path.join(targetDir, marker))) return command;
61
- }
62
- return 'npm install';
63
- }
64
-
65
- function prepareBuildCommand(targetDir, buildCommand) {
66
- const cleanedBuild = cleanString(buildCommand);
67
- const bootstrap = nodeBootstrapCommand(targetDir);
68
- if (!bootstrap) return cleanedBuild || null;
69
- if (cleanedBuild && NODE_INSTALL_PATTERN.test(cleanedBuild)) return cleanedBuild;
70
- return cleanedBuild ? `${bootstrap} && ${cleanedBuild}` : bootstrap;
71
- }
72
-
73
31
  function exportAppEntry(appId, options = {}) {
74
32
  const app = getApp(appId);
75
33
  if (!app) throw new Error(`App not found: ${appId}`);
76
- if (!cleanString(app.startCommand)) {
77
- throw new Error(`App has no start command and cannot be shared: ${appId}`);
78
- }
79
34
  if (!app.cwd) throw new Error(`App has no working directory: ${appId}`);
80
35
 
81
- const packed = packAppFiles(app.cwd, options);
82
- if (packed.files.length === 0) {
83
- throw new Error(`App directory has no shareable files: ${app.cwd}`);
36
+ let packed;
37
+ try {
38
+ packed = packPortableApp(app.cwd, options);
39
+ } catch (error) {
40
+ throw new Error(`App ${app.name || appId} is not portable: ${error.message}`);
84
41
  }
42
+ const { manifest } = packed;
85
43
 
86
44
  return {
87
45
  entry: {
88
46
  sourceAppId: app.id,
47
+ manifest,
48
+ // Derived summary for bundle renderers. Install never trusts this copy;
49
+ // it reads the materialized amalgm.app.json through Apps registration.
89
50
  app: {
90
- name: app.name || 'App',
91
- description: app.description || '',
92
- buildCommand: app.buildCommand || null,
93
- startCommand: app.startCommand,
94
- autostart: app.autostart !== false,
95
- keepAlive: app.keepAlive !== false,
51
+ name: manifest.name,
52
+ description: manifest.description,
53
+ buildCommand: manifest.buildCommand,
54
+ startCommand: manifest.startCommand,
55
+ autostart: true,
56
+ keepAlive: true,
96
57
  },
97
- // Tools that belong to this app; ids are stable across install, so the
98
- // recipient's registration points at the same tool ids.
99
- toolIds: uniqueToolIds(app.toolIds),
58
+ toolIds: manifest.toolIds,
100
59
  files: packed.files,
101
60
  totalBytes: packed.totalBytes,
102
- skipped: packed.skipped,
61
+ skipped: packed.excluded,
103
62
  },
104
- skipped: packed.skipped,
63
+ skipped: packed.excluded,
105
64
  // Export-time only (never stored in the bundle): lets the exporter bind
106
65
  // tool paths that live inside this app's directory.
107
66
  sourceCwd: app.cwd,
@@ -110,10 +69,6 @@ function exportAppEntry(appId, options = {}) {
110
69
 
111
70
  function assertInstallableEntry(entry) {
112
71
  if (!isObject(entry)) throw new Error('app entry must be an object');
113
- if (!isObject(entry.app)) throw new Error('app entry is missing its app record');
114
- if (!cleanString(entry.app.startCommand)) {
115
- throw new Error('app entry must include a start command');
116
- }
117
72
  if (!Array.isArray(entry.files) || entry.files.length === 0) {
118
73
  throw new Error('app entry must include files');
119
74
  }
@@ -136,17 +91,15 @@ function allocateInstallDir(rootDir, name) {
136
91
  */
137
92
  function materializeAppEntry(entry, options = {}) {
138
93
  assertInstallableEntry(entry);
139
- if (!cleanString(options.rootDir)) throw new Error('rootDir is required');
140
- const targetDir = allocateInstallDir(path.resolve(options.rootDir), entry.app.name);
94
+ if (typeof options.rootDir !== 'string' || !options.rootDir.trim()) throw new Error('rootDir is required');
95
+ const targetDir = allocateInstallDir(path.resolve(options.rootDir), entry.manifest?.name || entry.app?.name);
141
96
  unpackAppFiles(entry.files, targetDir);
142
- const buildCommand = prepareBuildCommand(targetDir, entry.app.buildCommand);
97
+ const portable = inspectPortableApp(targetDir);
98
+ if (entry.manifest && JSON.stringify(entry.manifest) !== JSON.stringify(portable.manifest)) {
99
+ throw new Error('App bundle manifest summary does not match amalgm.app.json');
100
+ }
143
101
  return {
144
- name: entry.app.name || 'App',
145
- description: entry.app.description || '',
146
102
  cwd: targetDir,
147
- build_command: buildCommand,
148
- start_command: entry.app.startCommand,
149
- tool_ids: uniqueToolIds(entry.toolIds),
150
103
  // A downloaded app is a fresh registration: it should come up now and
151
104
  // keep coming back with the recipient runtime.
152
105
  autostart: true,
@@ -161,7 +114,13 @@ function materializeAppEntry(entry, options = {}) {
161
114
  async function installAppEntry(entry, options = {}) {
162
115
  const register = options.register || registerApp;
163
116
  const input = materializeAppEntry(entry, options);
164
- const record = await register(input);
117
+ let record;
118
+ try {
119
+ record = await register(input);
120
+ } catch (error) {
121
+ fs.rmSync(input.cwd, { recursive: true, force: true });
122
+ throw error;
123
+ }
165
124
  // The materialized directory anchors app-bound tool path rewrites; make
166
125
  // sure it survives register implementations that omit cwd.
167
126
  return { ...record, cwd: record?.cwd || input.cwd };
@@ -155,7 +155,7 @@ function bundleTitleAndDescription(agents, automations, apps, sharedTools) {
155
155
  return { title: 'Shared bundle', description: '' };
156
156
  }
157
157
 
158
- function bundleWarnings({ hooks, tools, automations, apps, skippedAppFiles }) {
158
+ function bundleWarnings({ hooks, tools, automations, apps, excludedAppFiles }) {
159
159
  return [
160
160
  'Instructions, skill text, tool descriptions, and hook commands are shared as plain text.',
161
161
  'Secrets pasted into free-text instructions or files are not automatically detected.',
@@ -163,7 +163,7 @@ function bundleWarnings({ hooks, tools, automations, apps, skippedAppFiles }) {
163
163
  ...(tools > 0 ? ['Tools can call external services or local commands. Recipients should reconnect credentials on their own machine.'] : []),
164
164
  ...(automations > 0 ? ['This bundle includes automations. Their workflows run code and call tools on the importing machine — review workflow scripts before importing.'] : []),
165
165
  ...(apps > 0 ? ['This bundle includes apps. Their build and start commands execute on install — review commands before importing.'] : []),
166
- ...(skippedAppFiles > 0 ? [`${skippedAppFiles} app file${skippedAppFiles === 1 ? ' was' : 's were'} excluded from the bundle (dependencies, build outputs, env files, or size limits). Builds re-run on install.`] : []),
166
+ ...(excludedAppFiles > 0 ? [`${excludedAppFiles} app path${excludedAppFiles === 1 ? ' was' : 's were'} excluded by the app manifest or platform safety rules.`] : []),
167
167
  ];
168
168
  }
169
169
 
@@ -238,13 +238,13 @@ function createBundle(input = {}, options = {}) {
238
238
 
239
239
  const apps = [];
240
240
  const appCwdBySourceId = new Map();
241
- let skippedAppFiles = 0;
241
+ let excludedAppFiles = 0;
242
242
  const appToolIds = [];
243
243
  for (const appId of [...appIds, ...dependencyAppIds]) {
244
244
  const exported = exportAppEntry(appId);
245
245
  apps.push(exported.entry);
246
246
  appCwdBySourceId.set(exported.entry.sourceAppId, exported.sourceCwd);
247
- skippedAppFiles += exported.skipped.length;
247
+ excludedAppFiles += exported.skipped.length;
248
248
  // App → tool closure: tools that belong to a bundled app travel with it.
249
249
  appToolIds.push(...(exported.entry.toolIds || []));
250
250
  }
@@ -276,7 +276,7 @@ function createBundle(input = {}, options = {}) {
276
276
  tools: tools.length,
277
277
  automations: automations.length,
278
278
  apps: apps.length,
279
- skippedAppFiles,
279
+ excludedAppFiles,
280
280
  }),
281
281
  ...appLinkWarnings,
282
282
  ];
@@ -164,6 +164,7 @@ function standaloneToolIds(bundle) {
164
164
  function appElementData(entry) {
165
165
  return {
166
166
  sourceAppId: appEntryId(entry),
167
+ manifest: cloneJson(entry.manifest || {}),
167
168
  app: cloneJson(entry.app || {}),
168
169
  files: (Array.isArray(entry.files) ? entry.files : []).map((file) => ({
169
170
  path: file.path,
@@ -6,6 +6,8 @@ const { upsertAgentConfig, agentFieldsFromConfig } = require('../agent-config/st
6
6
  const { normalizeAgentConfig } = require('../agent-config/schema');
7
7
  const { createAgent, getAllAgentsWithBuiltins } = require('../agents/store');
8
8
  const { defaultToolboxService } = require('../toolbox/service');
9
+ const { APP_MANIFEST_FILE, normalizeAppManifest } = require('../apps/manifest');
10
+ const { validatePackedAppFiles } = require('../apps/portable');
9
11
  const { installAutomationEntry } = require('./automation-entries');
10
12
  const { installAppEntry } = require('./app-entries');
11
13
  const { rewriteAppBoundPaths } = require('./tool-bindings');
@@ -15,6 +17,7 @@ const {
15
17
  LEGACY_AGENT_BUNDLE_KIND,
16
18
  agentEntryId,
17
19
  appEntryId,
20
+ automationEntryId,
18
21
  isObject,
19
22
  nowIso,
20
23
  standaloneToolIds,
@@ -22,6 +25,55 @@ const {
22
25
  withBundleGraph,
23
26
  } = require('./graph');
24
27
 
28
+ function assertUniqueRecords(label, records, idForRecord) {
29
+ const seen = new Set();
30
+ for (const record of records) {
31
+ const id = idForRecord(record);
32
+ if (!id) throw new Error(`${label} entries must include ids`);
33
+ if (seen.has(id)) throw new Error(`Bundle contains duplicate ${label} id: ${id}`);
34
+ seen.add(id);
35
+ }
36
+ }
37
+
38
+ function assertPortableAppEntry(entry) {
39
+ const files = Array.isArray(entry?.files) ? entry.files : [];
40
+ const validated = validatePackedAppFiles(files);
41
+ if (entry.totalBytes !== validated.totalBytes) {
42
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} totalBytes does not match its files`);
43
+ }
44
+ const manifestFiles = files.filter((file) => file?.path === APP_MANIFEST_FILE);
45
+ if (manifestFiles.length !== 1) {
46
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} must contain exactly one ${APP_MANIFEST_FILE}`);
47
+ }
48
+ const manifestFile = manifestFiles[0];
49
+ if (manifestFile.encoding !== 'utf8' || typeof manifestFile.content !== 'string') {
50
+ throw new Error(`${APP_MANIFEST_FILE} must be a UTF-8 app file`);
51
+ }
52
+
53
+ let manifest;
54
+ try {
55
+ manifest = normalizeAppManifest(JSON.parse(manifestFile.content));
56
+ } catch (error) {
57
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} has an invalid manifest: ${error.message}`);
58
+ }
59
+
60
+ if (JSON.stringify(entry.manifest) !== JSON.stringify(manifest)) {
61
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} manifest summary does not match ${APP_MANIFEST_FILE}`);
62
+ }
63
+ if (JSON.stringify(entry.toolIds || []) !== JSON.stringify(manifest.toolIds)) {
64
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} tool ids do not match ${APP_MANIFEST_FILE}`);
65
+ }
66
+ const summary = entry.app || {};
67
+ if (
68
+ summary.name !== manifest.name
69
+ || summary.description !== manifest.description
70
+ || summary.buildCommand !== manifest.buildCommand
71
+ || summary.startCommand !== manifest.startCommand
72
+ ) {
73
+ throw new Error(`Bundle app ${appEntryId(entry) || '(unknown)'} summary does not match ${APP_MANIFEST_FILE}`);
74
+ }
75
+ }
76
+
25
77
  function uniqueAgentName(name) {
26
78
  const base = String(name || 'Imported agent').trim() || 'Imported agent';
27
79
  const existing = new Set(
@@ -78,10 +130,18 @@ function validateBundle(bundle) {
78
130
  const agents = Array.isArray(bundle.agents) ? bundle.agents : [];
79
131
  const automations = Array.isArray(bundle.automations) ? bundle.automations : [];
80
132
  const apps = Array.isArray(bundle.apps) ? bundle.apps : [];
133
+ const tools = Array.isArray(bundle.tools) ? bundle.tools : [];
134
+ const toolActions = Array.isArray(bundle.toolActions) ? bundle.toolActions : [];
81
135
  const toolHeads = standaloneToolIds(bundle);
82
136
  if (agents.length === 0 && automations.length === 0 && apps.length === 0 && toolHeads.length === 0) {
83
137
  throw new Error('bundle must include at least one agent, automation, app, or tool');
84
138
  }
139
+ assertUniqueRecords('agent', agents, agentEntryId);
140
+ assertUniqueRecords('automation', automations, automationEntryId);
141
+ assertUniqueRecords('app', apps, appEntryId);
142
+ assertUniqueRecords('tool', tools, (tool) => String(tool?.id || '').trim());
143
+ assertUniqueRecords('tool action', toolActions, (action) => String(action?.id || '').trim());
144
+ apps.forEach(assertPortableAppEntry);
85
145
  if (agents.length > 0) assertClosedAgentBundleGraph(bundle);
86
146
  return withBundleGraph(bundle);
87
147
  }
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const APP_MANIFEST_FILE = 'amalgm.app.json';
7
+ const APP_MANIFEST_SCHEMA_VERSION = 1;
8
+
9
+ const TOP_LEVEL_FIELDS = new Set([
10
+ 'schemaVersion',
11
+ 'name',
12
+ 'description',
13
+ 'buildCommand',
14
+ 'startCommand',
15
+ 'toolIds',
16
+ 'package',
17
+ ]);
18
+ const PACKAGE_FIELDS = new Set(['include', 'exclude']);
19
+
20
+ function isObject(value) {
21
+ return !!value && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+
24
+ function cleanString(value) {
25
+ return typeof value === 'string' ? value.trim() : '';
26
+ }
27
+
28
+ function uniqueStrings(value, field, errors, options = {}) {
29
+ if (value === undefined && options.optional) return [];
30
+ if (!Array.isArray(value)) {
31
+ errors.push(`${field} must be an array of strings`);
32
+ return [];
33
+ }
34
+
35
+ const result = [];
36
+ value.forEach((item, index) => {
37
+ const cleaned = cleanString(item);
38
+ if (!cleaned) {
39
+ errors.push(`${field}[${index}] must be a non-empty string`);
40
+ } else if (!result.includes(cleaned)) {
41
+ result.push(cleaned);
42
+ }
43
+ });
44
+ return result;
45
+ }
46
+
47
+ function unknownFields(value, allowed, prefix) {
48
+ if (!isObject(value)) return [];
49
+ return Object.keys(value)
50
+ .filter((key) => !allowed.has(key))
51
+ .map((key) => `${prefix}${key} is not supported`);
52
+ }
53
+
54
+ function normalizeAppManifest(value) {
55
+ if (!isObject(value)) throw new Error(`${APP_MANIFEST_FILE} must contain a JSON object`);
56
+
57
+ const errors = unknownFields(value, TOP_LEVEL_FIELDS, '');
58
+ if (value.schemaVersion !== APP_MANIFEST_SCHEMA_VERSION) {
59
+ errors.push(`schemaVersion must be ${APP_MANIFEST_SCHEMA_VERSION}`);
60
+ }
61
+
62
+ const name = cleanString(value.name);
63
+ if (!name) errors.push('name must be a non-empty string');
64
+
65
+ const description = value.description === undefined ? '' : cleanString(value.description);
66
+ if (value.description !== undefined && typeof value.description !== 'string') {
67
+ errors.push('description must be a string');
68
+ }
69
+
70
+ const buildCommand = value.buildCommand == null ? null : cleanString(value.buildCommand);
71
+ if (value.buildCommand != null && !buildCommand) {
72
+ errors.push('buildCommand must be a non-empty string or null');
73
+ }
74
+
75
+ const startCommand = cleanString(value.startCommand);
76
+ if (!startCommand) errors.push('startCommand must be a non-empty string');
77
+
78
+ const toolIds = uniqueStrings(value.toolIds, 'toolIds', errors, { optional: true });
79
+
80
+ if (!isObject(value.package)) {
81
+ errors.push('package must be an object');
82
+ } else {
83
+ errors.push(...unknownFields(value.package, PACKAGE_FIELDS, 'package.'));
84
+ }
85
+ const include = uniqueStrings(value.package?.include, 'package.include', errors);
86
+ const exclude = uniqueStrings(value.package?.exclude, 'package.exclude', errors, { optional: true });
87
+ if (include.length === 0) errors.push('package.include must select at least one app file');
88
+
89
+ if (errors.length > 0) {
90
+ throw new Error(`${APP_MANIFEST_FILE} is invalid:\n- ${errors.join('\n- ')}`);
91
+ }
92
+
93
+ return {
94
+ schemaVersion: APP_MANIFEST_SCHEMA_VERSION,
95
+ name,
96
+ description,
97
+ buildCommand,
98
+ startCommand,
99
+ toolIds,
100
+ package: { include, exclude },
101
+ };
102
+ }
103
+
104
+ function appManifestPath(rootDir) {
105
+ return path.join(path.resolve(rootDir), APP_MANIFEST_FILE);
106
+ }
107
+
108
+ function hasAppManifest(rootDir) {
109
+ if (typeof rootDir !== 'string' || !rootDir.trim()) return false;
110
+ return fs.existsSync(appManifestPath(rootDir));
111
+ }
112
+
113
+ function readAppManifest(rootDir) {
114
+ const manifestPath = appManifestPath(rootDir);
115
+ if (!fs.existsSync(manifestPath)) {
116
+ throw new Error(`${APP_MANIFEST_FILE} is required for app registration and sharing: ${manifestPath}`);
117
+ }
118
+
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
122
+ } catch (error) {
123
+ throw new Error(`${APP_MANIFEST_FILE} is not valid JSON: ${error.message}`);
124
+ }
125
+ return normalizeAppManifest(parsed);
126
+ }
127
+
128
+ module.exports = {
129
+ APP_MANIFEST_FILE,
130
+ APP_MANIFEST_SCHEMA_VERSION,
131
+ appManifestPath,
132
+ hasAppManifest,
133
+ normalizeAppManifest,
134
+ readAppManifest,
135
+ };