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
package/package.json
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Portable app bundle entries.
|
|
5
5
|
*
|
|
6
|
-
* An exported entry carries
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
91
|
-
description:
|
|
92
|
-
buildCommand:
|
|
93
|
-
startCommand:
|
|
94
|
-
autostart:
|
|
95
|
-
keepAlive:
|
|
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
|
-
|
|
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.
|
|
61
|
+
skipped: packed.excluded,
|
|
103
62
|
},
|
|
104
|
-
skipped: packed.
|
|
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 (!
|
|
140
|
-
const targetDir = allocateInstallDir(path.resolve(options.rootDir), entry.app
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|