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 +1 -1
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +36 -77
- package/runtime/scripts/amalgm-mcp/agent-bundles/export.js +5 -5
- package/runtime/scripts/amalgm-mcp/agent-bundles/graph.js +1 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/install.js +60 -0
- package/runtime/scripts/amalgm-mcp/apps/manifest.js +135 -0
- package/runtime/scripts/amalgm-mcp/apps/portable.js +334 -0
- package/runtime/scripts/amalgm-mcp/apps/rest.js +9 -1
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +63 -16
- package/runtime/scripts/amalgm-mcp/apps/tools.js +37 -10
- package/runtime/scripts/amalgm-mcp/tests/apps-portable.test.js +127 -0
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +71 -47
- package/runtime/scripts/amalgm-mcp/tests/platform-context.test.js +15 -0
- package/runtime/scripts/chat-core/tool-shape.js +1 -1
- package/runtime/scripts/platform-context.txt +33 -10
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +0 -160
|
@@ -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
|
-
|
|
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
|
|
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
|
|
539
|
-
|
|
540
|
-
const
|
|
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:
|
|
571
|
+
name: manifest.name,
|
|
572
|
+
description: manifest.description,
|
|
554
573
|
cwd,
|
|
555
574
|
port,
|
|
556
|
-
buildCommand:
|
|
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:
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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: ['
|
|
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
|
|
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
|
},
|