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,334 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The portable-app boundary owned by Apps.
5
+ *
6
+ * Registration validates this package, sharing serializes it, and bundle
7
+ * install materializes it. Keeping those paths on one implementation makes
8
+ * portability a property of the app instead of a sharing-time guess.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const { APP_MANIFEST_FILE, readAppManifest } = require('./manifest');
15
+
16
+ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
17
+ const DEFAULT_MAX_TOTAL_BYTES = 12 * 1024 * 1024;
18
+
19
+ const PLATFORM_EXCLUDED_DIRS = new Set([
20
+ 'node_modules',
21
+ '.git',
22
+ '.hg',
23
+ '.svn',
24
+ '.amalgm',
25
+ ]);
26
+
27
+ const PLATFORM_EXCLUDED_FILES = new Set([
28
+ '.DS_Store',
29
+ 'Thumbs.db',
30
+ '.npmrc',
31
+ '.yarnrc',
32
+ '.pypirc',
33
+ '.netrc',
34
+ 'credentials.json',
35
+ 'service-account.json',
36
+ ]);
37
+
38
+ function toPosixPath(value) {
39
+ return String(value || '').split(path.sep).join('/');
40
+ }
41
+
42
+ function normalizePattern(value, field) {
43
+ let pattern = String(value || '').trim().replace(/\\/g, '/');
44
+ while (pattern.startsWith('./')) pattern = pattern.slice(2);
45
+ pattern = pattern.replace(/\/{2,}/g, '/');
46
+ if (!pattern || pattern.startsWith('/') || /^[A-Za-z]:\//.test(pattern)) {
47
+ throw new Error(`${field} must be relative to the app directory: ${value}`);
48
+ }
49
+ if (pattern.split('/').includes('..')) {
50
+ throw new Error(`${field} cannot leave the app directory: ${value}`);
51
+ }
52
+ return pattern.endsWith('/') ? `${pattern}**` : pattern;
53
+ }
54
+
55
+ function globRegex(pattern) {
56
+ let source = '^';
57
+ for (let index = 0; index < pattern.length; index += 1) {
58
+ const char = pattern[index];
59
+ if (char === '*') {
60
+ if (pattern[index + 1] === '*') {
61
+ index += 1;
62
+ if (pattern[index + 1] === '/') {
63
+ index += 1;
64
+ source += '(?:.*/)?';
65
+ } else {
66
+ source += '.*';
67
+ }
68
+ } else {
69
+ source += '[^/]*';
70
+ }
71
+ } else if (char === '?') {
72
+ source += '[^/]';
73
+ } else {
74
+ source += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
75
+ }
76
+ }
77
+ return new RegExp(`${source}$`);
78
+ }
79
+
80
+ function compilePatterns(values, field) {
81
+ return values.map((value, index) => {
82
+ const pattern = normalizePattern(value, `${field}[${index}]`);
83
+ return { pattern, regex: globRegex(pattern), matched: false };
84
+ });
85
+ }
86
+
87
+ function firstMatch(compiled, relativePath) {
88
+ return compiled.find((entry) => entry.regex.test(relativePath)) || null;
89
+ }
90
+
91
+ function directorySelection(compiled, relativeDir) {
92
+ const probes = [
93
+ `${relativeDir}/__amalgm_file__`,
94
+ `${relativeDir}/nested/__amalgm_file__`,
95
+ ];
96
+ return compiled.find((entry) => (
97
+ entry.pattern.startsWith(`${relativeDir}/`)
98
+ || probes.some((probe) => entry.regex.test(probe))
99
+ )) || null;
100
+ }
101
+
102
+ function isPlatformExcludedFile(relativePath) {
103
+ const name = path.posix.basename(relativePath);
104
+ if (PLATFORM_EXCLUDED_FILES.has(name)) return true;
105
+ if (name === '.env' || name.startsWith('.env.')) return true;
106
+ if (name.endsWith('.log') || name.endsWith('.pem') || name.endsWith('.key')) return true;
107
+ return relativePath === '.ssh' || relativePath.startsWith('.ssh/');
108
+ }
109
+
110
+ function isBinary(buffer) {
111
+ const sample = buffer.subarray(0, 8192);
112
+ if (sample.includes(0)) return true;
113
+ return !buffer.equals(Buffer.from(buffer.toString('utf8'), 'utf8'));
114
+ }
115
+
116
+ function packagePortableApp(rootDir, options = {}) {
117
+ const root = path.resolve(rootDir);
118
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
119
+ throw new Error(`App directory not found: ${root}`);
120
+ }
121
+
122
+ const manifest = readAppManifest(root);
123
+ const include = compilePatterns(manifest.package.include, 'package.include');
124
+ const exclude = compilePatterns(manifest.package.exclude, 'package.exclude');
125
+ if (firstMatch(exclude, APP_MANIFEST_FILE)) {
126
+ throw new Error(`${APP_MANIFEST_FILE} cannot be excluded; it is part of every portable app`);
127
+ }
128
+
129
+ const maxFileBytes = options.maxFileBytes || DEFAULT_MAX_FILE_BYTES;
130
+ const maxTotalBytes = options.maxTotalBytes || DEFAULT_MAX_TOTAL_BYTES;
131
+ const includeContent = options.includeContent === true;
132
+ const files = [];
133
+ const excluded = [];
134
+ const errors = [];
135
+ let totalBytes = 0;
136
+
137
+ function addFile(absolute, relative) {
138
+ const stat = fs.statSync(absolute);
139
+ if (stat.size > maxFileBytes) {
140
+ errors.push(`${relative} is ${stat.size} bytes; the per-file limit is ${maxFileBytes}`);
141
+ return;
142
+ }
143
+ if (totalBytes + stat.size > maxTotalBytes) {
144
+ errors.push(`adding ${relative} would exceed the ${maxTotalBytes}-byte app package limit`);
145
+ return;
146
+ }
147
+
148
+ const record = { path: relative, size: stat.size };
149
+ if (includeContent) {
150
+ const buffer = fs.readFileSync(absolute);
151
+ const binary = isBinary(buffer);
152
+ record.encoding = binary ? 'base64' : 'utf8';
153
+ record.content = binary ? buffer.toString('base64') : buffer.toString('utf8');
154
+ if (stat.mode & 0o111) record.executable = true;
155
+ }
156
+ totalBytes += stat.size;
157
+ files.push(record);
158
+ }
159
+
160
+ function visit(dir) {
161
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
162
+ .sort((left, right) => left.name.localeCompare(right.name));
163
+ for (const dirent of entries) {
164
+ const absolute = path.join(dir, dirent.name);
165
+ const relative = toPosixPath(path.relative(root, absolute));
166
+
167
+ if (dirent.isSymbolicLink()) {
168
+ const selected = firstMatch(include, relative) || directorySelection(include, relative);
169
+ if (selected) {
170
+ selected.matched = true;
171
+ excluded.push({ path: relative, reason: 'symlink' });
172
+ }
173
+ continue;
174
+ }
175
+
176
+ if (dirent.isDirectory()) {
177
+ if (PLATFORM_EXCLUDED_DIRS.has(dirent.name)) {
178
+ const selected = directorySelection(include, relative);
179
+ if (selected) {
180
+ selected.matched = true;
181
+ excluded.push({ path: `${relative}/`, reason: 'platform-excluded' });
182
+ }
183
+ continue;
184
+ }
185
+ visit(absolute);
186
+ continue;
187
+ }
188
+ if (!dirent.isFile()) continue;
189
+
190
+ const includedBy = firstMatch(include, relative);
191
+ if (!includedBy && relative !== APP_MANIFEST_FILE) continue;
192
+ if (includedBy) includedBy.matched = true;
193
+
194
+ const excludedBy = firstMatch(exclude, relative);
195
+ if (excludedBy) {
196
+ excludedBy.matched = true;
197
+ excluded.push({ path: relative, reason: 'manifest-excluded' });
198
+ continue;
199
+ }
200
+ if (relative !== APP_MANIFEST_FILE && isPlatformExcludedFile(relative)) {
201
+ excluded.push({ path: relative, reason: 'platform-excluded' });
202
+ continue;
203
+ }
204
+ addFile(absolute, relative);
205
+ }
206
+ }
207
+
208
+ visit(root);
209
+
210
+ for (const entry of include) {
211
+ if (!entry.matched && !entry.regex.test(APP_MANIFEST_FILE)) {
212
+ errors.push(`package.include pattern matched no files: ${entry.pattern}`);
213
+ }
214
+ }
215
+ if (files.every((file) => file.path === APP_MANIFEST_FILE)) {
216
+ errors.push('package.include selected no portable app files');
217
+ }
218
+ if (errors.length > 0) {
219
+ throw new Error(`App package is invalid:\n- ${errors.join('\n- ')}`);
220
+ }
221
+
222
+ files.sort((left, right) => left.path.localeCompare(right.path));
223
+ excluded.sort((left, right) => left.path.localeCompare(right.path));
224
+ return { rootDir: root, manifest, files, totalBytes, excluded };
225
+ }
226
+
227
+ function inspectPortableApp(rootDir, options = {}) {
228
+ return packagePortableApp(rootDir, { ...options, includeContent: false });
229
+ }
230
+
231
+ function packPortableApp(rootDir, options = {}) {
232
+ return packagePortableApp(rootDir, { ...options, includeContent: true });
233
+ }
234
+
235
+ function portabilityStatus(rootDir) {
236
+ try {
237
+ const result = inspectPortableApp(rootDir);
238
+ return { portable: true, files: result.files.length, totalBytes: result.totalBytes };
239
+ } catch (error) {
240
+ return { portable: false, error: error.message };
241
+ }
242
+ }
243
+
244
+ function assertInsideTarget(targetRoot, relativePath) {
245
+ const normalized = String(relativePath || '');
246
+ const resolved = path.resolve(targetRoot, normalized);
247
+ if (resolved !== targetRoot && !resolved.startsWith(targetRoot + path.sep)) {
248
+ throw new Error(`App file escapes the install directory: ${normalized}`);
249
+ }
250
+ return resolved;
251
+ }
252
+
253
+ function decodePackedFile(file) {
254
+ if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') {
255
+ throw new Error('App files must include path and content strings');
256
+ }
257
+ if (
258
+ !file.path
259
+ || file.path.includes('\\')
260
+ || path.posix.isAbsolute(file.path)
261
+ || /^[A-Za-z]:/.test(file.path)
262
+ || file.path.split('/').includes('..')
263
+ || path.posix.normalize(file.path) !== file.path
264
+ || file.path === '.'
265
+ ) {
266
+ throw new Error(`Invalid app file path: ${file.path}`);
267
+ }
268
+ if (file.encoding !== 'utf8' && file.encoding !== 'base64') {
269
+ throw new Error(`Unsupported app file encoding for ${file.path}: ${file.encoding}`);
270
+ }
271
+
272
+ let buffer;
273
+ if (file.encoding === 'base64') {
274
+ const normalized = file.content.replace(/=+$/, '');
275
+ buffer = Buffer.from(file.content, 'base64');
276
+ if (buffer.toString('base64').replace(/=+$/, '') !== normalized) {
277
+ throw new Error(`Invalid base64 app file content: ${file.path}`);
278
+ }
279
+ } else {
280
+ buffer = Buffer.from(file.content, 'utf8');
281
+ }
282
+ if (!Number.isInteger(file.size) || file.size !== buffer.length) {
283
+ throw new Error(`App file size does not match content: ${file.path}`);
284
+ }
285
+ return buffer;
286
+ }
287
+
288
+ function validatePackedAppFiles(files, options = {}) {
289
+ if (!Array.isArray(files) || files.length === 0) throw new Error('App bundle must include files');
290
+
291
+ const maxFileBytes = options.maxFileBytes || DEFAULT_MAX_FILE_BYTES;
292
+ const maxTotalBytes = options.maxTotalBytes || DEFAULT_MAX_TOTAL_BYTES;
293
+ const seen = new Set();
294
+ const decoded = [];
295
+ let totalBytes = 0;
296
+
297
+ for (const file of files) {
298
+ if (seen.has(file?.path)) throw new Error(`Duplicate app file in bundle: ${file?.path}`);
299
+ const buffer = decodePackedFile(file);
300
+ seen.add(file.path);
301
+ if (buffer.length > maxFileBytes) {
302
+ throw new Error(`${file.path} exceeds the ${maxFileBytes}-byte app file limit`);
303
+ }
304
+ totalBytes += buffer.length;
305
+ if (totalBytes > maxTotalBytes) {
306
+ throw new Error(`App files exceed the ${maxTotalBytes}-byte package limit`);
307
+ }
308
+ decoded.push({ file, buffer });
309
+ }
310
+ return { decoded, totalBytes };
311
+ }
312
+
313
+ function unpackAppFiles(files, targetDir) {
314
+ const targetRoot = path.resolve(targetDir);
315
+ const { decoded } = validatePackedAppFiles(files);
316
+ fs.mkdirSync(targetRoot, { recursive: true });
317
+
318
+ let written = 0;
319
+ for (const { file, buffer } of decoded) {
320
+ const destination = assertInsideTarget(targetRoot, file.path);
321
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
322
+ fs.writeFileSync(destination, buffer, { mode: file.executable ? 0o755 : 0o644 });
323
+ written += 1;
324
+ }
325
+ return { written, targetDir: targetRoot };
326
+ }
327
+
328
+ module.exports = {
329
+ inspectPortableApp,
330
+ packPortableApp,
331
+ portabilityStatus,
332
+ unpackAppFiles,
333
+ validatePackedAppFiles,
334
+ };
@@ -4,6 +4,7 @@
4
4
 
5
5
  const { getApp, getConnectedRoutes, loadApps, updateAppMeta } = require('./store');
6
6
  const { healthSnapshot, recordHeartbeat, recordReady } = require('./health');
7
+ const { hasAppManifest } = require('./manifest');
7
8
  const {
8
9
  deleteApp,
9
10
  connectDns,
@@ -122,7 +123,14 @@ async function handleUpdate(body, sendJson, options = {}) {
122
123
  try {
123
124
  const appId = appIdFromBody(body);
124
125
  if (!appId) return sendJson(400, { error: 'app_id is required' });
125
- if (!getApp(appId)) return sendJson(404, { error: 'App not found' });
126
+ const current = getApp(appId);
127
+ if (!current) return sendJson(404, { error: 'App not found' });
128
+
129
+ if (hasAppManifest(current.cwd)) {
130
+ return sendJson(400, {
131
+ error: 'This app reads name, description, and tool_ids from amalgm.app.json; edit the manifest and redeploy',
132
+ });
133
+ }
126
134
 
127
135
  const updates = {};
128
136
  if (typeof body?.name === 'string' && body.name.trim()) updates.name = body.name.trim();
@@ -8,10 +8,12 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { spawn } = require('child_process');
11
- const { APPS_DIR, DEFAULT_CWD } = require('../config');
11
+ const { APPS_DIR } = require('../config');
12
12
  const { runtimePort } = require('../../../lib/runtime-manifest');
13
13
  const { syncAppRoutesToGateway } = require('./advertise');
14
14
  const { clearAppHealth } = require('./health');
15
+ const { hasAppManifest } = require('./manifest');
16
+ const { inspectPortableApp } = require('./portable');
15
17
  const {
16
18
  allocatePort,
17
19
  allocateUniqueAppRef,
@@ -535,12 +537,29 @@ function clearAppLinkFromTools(appId) {
535
537
  }
536
538
 
537
539
  async function registerApp(input) {
538
- const name = String(input.name || '').trim() || 'App';
539
- const cwd = path.resolve(input.cwd || input.root_dir || DEFAULT_CWD);
540
- const startCommand = String(input.start_command || input.startCommand || '').trim();
541
- if (!startCommand) throw new Error('start_command is required');
540
+ const requestedCwd = input.cwd || input.root_dir;
541
+ if (!requestedCwd || !String(requestedCwd).trim()) throw new Error('cwd is required');
542
+ const cwd = path.resolve(requestedCwd);
542
543
  if (!fs.existsSync(cwd)) throw new Error(`cwd does not exist: ${cwd}`);
543
544
 
545
+ const legacyFields = [
546
+ 'name',
547
+ 'description',
548
+ 'build_command',
549
+ 'buildCommand',
550
+ 'start_command',
551
+ 'startCommand',
552
+ 'tool_ids',
553
+ 'toolIds',
554
+ ].filter((field) => input[field] !== undefined);
555
+ if (legacyFields.length > 0) {
556
+ throw new Error(
557
+ `App registration reads ${legacyFields.join(', ')} from amalgm.app.json; remove those fields from the request`,
558
+ );
559
+ }
560
+
561
+ const { manifest } = inspectPortableApp(cwd);
562
+
544
563
  const port = allocatePort(input.port);
545
564
  if (!port) throw new Error('No valid app port available');
546
565
 
@@ -549,19 +568,19 @@ async function registerApp(input) {
549
568
  const app = {
550
569
  id: input.id || cryptoRandomId(),
551
570
  kind: 'app',
552
- name,
553
- description: input.description || '',
571
+ name: manifest.name,
572
+ description: manifest.description,
554
573
  cwd,
555
574
  port,
556
- buildCommand: input.build_command || input.buildCommand || null,
557
- startCommand,
575
+ buildCommand: manifest.buildCommand,
576
+ startCommand: manifest.startCommand,
558
577
  appRef,
559
578
  app_ref: appRef,
560
579
  publicUrl: publicUrlForRef(appRef),
561
580
  dnsConnected: input.connect_dns !== false,
562
581
  autostart: input.autostart !== false,
563
582
  keepAlive: input.keep_alive !== false,
564
- toolIds: Array.isArray(input.tool_ids || input.toolIds) ? (input.tool_ids || input.toolIds) : [],
583
+ toolIds: manifest.toolIds,
565
584
  desiredState: 'running',
566
585
  status: 'registered',
567
586
  pid: null,
@@ -576,10 +595,16 @@ async function registerApp(input) {
576
595
  data.apps.push(app);
577
596
  saveApps(data);
578
597
 
579
- if (app.buildCommand) await runBuild(app);
580
- await startApp(app.id);
581
- const deployed = updateAppMeta(app.id, { lastDeployedAt: new Date().toISOString() });
582
- return deployed;
598
+ try {
599
+ if (app.buildCommand) await runBuild(app);
600
+ await startApp(app.id);
601
+ return updateAppMeta(app.id, { lastDeployedAt: new Date().toISOString() });
602
+ } catch (error) {
603
+ const current = loadApps();
604
+ current.apps = current.apps.filter((item) => item.id !== app.id);
605
+ saveApps(current, { source: 'apps:register-failed' });
606
+ throw new Error(`App registration failed: ${error.message}`);
607
+ }
583
608
  }
584
609
 
585
610
  async function redeployApp(appId, updates = {}) {
@@ -595,10 +620,32 @@ async function redeployApp(appId, updates = {}) {
595
620
  }
596
621
  patch.port = parsed;
597
622
  }
598
- if (updates.build_command !== undefined) patch.buildCommand = updates.build_command || null;
599
- if (updates.start_command !== undefined) patch.startCommand = updates.start_command;
600
623
  if (updates.keep_alive !== undefined) patch.keepAlive = updates.keep_alive !== false;
601
624
  if (updates.autostart !== undefined) patch.autostart = updates.autostart !== false;
625
+
626
+ const nextCwd = patch.cwd || app.cwd;
627
+ if (hasAppManifest(nextCwd)) {
628
+ const legacyFields = ['build_command', 'start_command', 'tool_ids']
629
+ .filter((field) => updates[field] !== undefined);
630
+ if (legacyFields.length > 0) {
631
+ throw new Error(
632
+ `Redeploy reads ${legacyFields.join(', ')} from amalgm.app.json; edit the manifest instead`,
633
+ );
634
+ }
635
+ const { manifest } = inspectPortableApp(nextCwd);
636
+ Object.assign(patch, {
637
+ name: manifest.name,
638
+ description: manifest.description,
639
+ buildCommand: manifest.buildCommand,
640
+ startCommand: manifest.startCommand,
641
+ toolIds: manifest.toolIds,
642
+ });
643
+ } else {
644
+ // Pre-manifest apps remain operable. They can start, stop, and redeploy,
645
+ // but sharing rejects them until their project gains a valid manifest.
646
+ if (updates.build_command !== undefined) patch.buildCommand = updates.build_command || null;
647
+ if (updates.start_command !== undefined) patch.startCommand = updates.start_command;
648
+ }
602
649
  if (Object.keys(patch).length > 0) app = updateAppMeta(appId, patch);
603
650
 
604
651
  if (!app.startCommand) throw new Error('startCommand is required');
@@ -9,6 +9,7 @@
9
9
  const { textResult, errorResult } = require('../lib/tool-result');
10
10
  const { getConnectedRoutes, loadApps } = require('./store');
11
11
  const { healthSnapshot } = require('./health');
12
+ const { inspectPortableApp, portabilityStatus } = require('./portable');
12
13
  const {
13
14
  connectDns,
14
15
  disconnectDns,
@@ -28,6 +29,7 @@ function healthSummary(app) {
28
29
  }
29
30
 
30
31
  function appSummary(app) {
32
+ const portability = portabilityStatus(app.cwd);
31
33
  return [
32
34
  `${app.name} (${app.id})`,
33
35
  `status: ${app.status}`,
@@ -36,6 +38,9 @@ function appSummary(app) {
36
38
  `dns: ${app.dnsConnected ? app.publicUrl : 'disconnected'}`,
37
39
  `autostart: ${app.autostart !== false}`,
38
40
  `keepAlive: ${app.keepAlive !== false}`,
41
+ portability.portable
42
+ ? `portable: yes (${portability.files} files, ${portability.totalBytes} bytes)`
43
+ : `portable: no (${String(portability.error || 'invalid app package').split('\n')[0]})`,
39
44
  healthSummary(app),
40
45
  app.error ? `error: ${app.error}` : null,
41
46
  ].filter(Boolean).join('\n');
@@ -45,21 +50,17 @@ module.exports = [
45
50
  {
46
51
  name: 'apps_register',
47
52
  description:
48
- 'Register a user-hosted app from an existing project on this computer. Apps should be production services: prefer passing a build_command such as "npm run build" and a production start_command such as "npm run start -- --host 0.0.0.0 --port {port}" or any command that binds to PORT/{port}. The MCP does not scaffold or enforce a framework; it stores the record in ~/.amalgm, starts it, keeps it alive while the computer is on, and connects it to app DNS by default.',
53
+ 'Validate and register a user-hosted app from an existing project. The project must contain amalgm.app.json; that manifest is the single source of truth for name, description, buildCommand, startCommand, toolIds, and portable package files. Registration fails before storing the app if the manifest or package is invalid, then runs the declared build, starts the app, keeps it alive, and connects app DNS by default.',
49
54
  inputSchema: {
50
55
  type: 'object',
51
56
  properties: {
52
- name: { type: 'string', description: 'Human-readable app name' },
53
- cwd: { type: 'string', description: 'Project directory on this computer' },
57
+ cwd: { type: 'string', description: 'Project directory containing amalgm.app.json' },
54
58
  port: { type: 'number', description: 'Port the app should bind to. If omitted, one is allocated.' },
55
- build_command: { type: 'string', description: 'Optional production build command, run before start/redeploy' },
56
- start_command: { type: 'string', description: 'Required production start command. Use {port} or PORT.' },
57
- description: { type: 'string' },
58
59
  connect_dns: { type: 'boolean', description: 'Whether to expose through app DNS immediately. Defaults true.' },
59
60
  autostart: { type: 'boolean', description: 'Start on Amalgm boot. Defaults true.' },
60
61
  keep_alive: { type: 'boolean', description: 'Restart if the process exits. Defaults true.' },
61
62
  },
62
- required: ['start_command'],
63
+ required: ['cwd'],
63
64
  },
64
65
  async handler(args) {
65
66
  try {
@@ -70,6 +71,32 @@ module.exports = [
70
71
  }
71
72
  },
72
73
  },
74
+ {
75
+ name: 'apps_validate',
76
+ description:
77
+ 'Dry-run the exact app package used by registration and sharing. Validates amalgm.app.json, resolves package.include/package.exclude, applies platform credential and dependency exclusions, and reports the files and byte size without registering or starting the app.',
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ cwd: { type: 'string', description: 'Project directory containing amalgm.app.json' },
82
+ },
83
+ required: ['cwd'],
84
+ },
85
+ async handler(args) {
86
+ try {
87
+ const result = inspectPortableApp(args.cwd);
88
+ return textResult(JSON.stringify({
89
+ portable: true,
90
+ manifest: result.manifest,
91
+ files: result.files,
92
+ totalBytes: result.totalBytes,
93
+ excluded: result.excluded,
94
+ }, null, 2));
95
+ } catch (err) {
96
+ return errorResult(err.message);
97
+ }
98
+ },
99
+ },
73
100
  {
74
101
  name: 'apps_list',
75
102
  description: 'List locally registered apps from ~/.amalgm/apps.json.',
@@ -92,15 +119,15 @@ module.exports = [
92
119
  {
93
120
  name: 'apps_redeploy',
94
121
  description:
95
- 'Redeploy an app: optionally update cwd/build/start/port settings, run its build command if configured, then restart the production process. DNS connection state is preserved.',
122
+ 'Redeploy an app. Manifest apps reread and validate amalgm.app.json, update their stored metadata/commands/tool ids, run buildCommand, and restart. cwd, port, and lifecycle settings may be updated; build_command/start_command are accepted only for pre-manifest legacy apps. DNS connection state is preserved.',
96
123
  inputSchema: {
97
124
  type: 'object',
98
125
  properties: {
99
126
  app_id: { type: 'string' },
100
127
  cwd: { type: 'string' },
101
128
  port: { type: 'number' },
102
- build_command: { type: 'string' },
103
- start_command: { type: 'string' },
129
+ build_command: { type: 'string', description: 'Legacy apps only; manifest apps read buildCommand from amalgm.app.json.' },
130
+ start_command: { type: 'string', description: 'Legacy apps only; manifest apps read startCommand from amalgm.app.json.' },
104
131
  autostart: { type: 'boolean' },
105
132
  keep_alive: { type: 'boolean' },
106
133
  },