@0xmaxma/claude-gateway 1.1.9 → 1.2.0
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/README.md +61 -1
- package/dist/agent/builtin-commands.d.ts +10 -0
- package/dist/agent/builtin-commands.d.ts.map +1 -0
- package/dist/agent/builtin-commands.js +38 -0
- package/dist/agent/builtin-commands.js.map +1 -0
- package/dist/agent/runner.d.ts +7 -5
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +56 -30
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +9 -4
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/apps-router.d.ts +7 -0
- package/dist/api/apps-router.d.ts.map +1 -0
- package/dist/api/apps-router.js +242 -0
- package/dist/api/apps-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +17 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +171 -2
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +385 -4
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts +76 -0
- package/dist/apps/agent-manager.d.ts.map +1 -0
- package/dist/apps/agent-manager.js +311 -0
- package/dist/apps/agent-manager.js.map +1 -0
- package/dist/apps/compose-generator.d.ts +108 -0
- package/dist/apps/compose-generator.d.ts.map +1 -0
- package/dist/apps/compose-generator.js +687 -0
- package/dist/apps/compose-generator.js.map +1 -0
- package/dist/apps/installer.d.ts +101 -0
- package/dist/apps/installer.d.ts.map +1 -0
- package/dist/apps/installer.js +898 -0
- package/dist/apps/installer.js.map +1 -0
- package/dist/apps/registry-client.d.ts +37 -0
- package/dist/apps/registry-client.d.ts.map +1 -0
- package/dist/apps/registry-client.js +106 -0
- package/dist/apps/registry-client.js.map +1 -0
- package/dist/apps/registry.d.ts +54 -0
- package/dist/apps/registry.d.ts.map +1 -0
- package/dist/apps/registry.js +182 -0
- package/dist/apps/registry.js.map +1 -0
- package/dist/apps/socket-server.d.ts +46 -0
- package/dist/apps/socket-server.d.ts.map +1 -0
- package/dist/apps/socket-server.js +297 -0
- package/dist/apps/socket-server.js.map +1 -0
- package/dist/config/watcher.d.ts +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +55 -2
- package/dist/config/watcher.js.map +1 -1
- package/dist/index.js +170 -3
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +47 -6
- package/dist/session/process.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/mcp/server.ts +2 -0
- package/mcp/tools/apps/client.ts +78 -0
- package/mcp/tools/apps/module.ts +211 -0
- package/mcp/tools/apps/skills/app-status/SKILL.md +48 -0
- package/mcp/tools/apps/skills/create-app-yaml/SKILL.md +94 -0
- package/mcp/tools/apps/skills/install-app/SKILL.md +102 -0
- package/mcp/tools/apps/skills/list-apps/SKILL.md +34 -0
- package/mcp/tools/telegram/receiver-server.ts +36 -2
- package/package.json +1 -1
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseAppYaml = parseAppYaml;
|
|
37
|
+
exports.generateCompose = generateCompose;
|
|
38
|
+
exports.processAppYaml = processAppYaml;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const os = __importStar(require("node:os"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
const yaml = __importStar(require("js-yaml"));
|
|
43
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
44
|
+
const BANNED_PORTS = new Set([22, 80, 443, 10850]);
|
|
45
|
+
const ALLOWED_SERVICE_FIELDS = new Set([
|
|
46
|
+
'build', 'image', 'command', 'entrypoint', 'working_dir', 'user',
|
|
47
|
+
'environment', 'volumes', 'ports', 'depends_on', 'healthcheck', 'gateway_api',
|
|
48
|
+
'cap_add', 'resources',
|
|
49
|
+
]);
|
|
50
|
+
// Capabilities that are safe to grant back after cap_drop ALL.
|
|
51
|
+
// Needed by e.g. postgres (CHOWN, SETUID, SETGID) and services that bind low ports (NET_BIND_SERVICE).
|
|
52
|
+
const ALLOWED_CAPS = new Set([
|
|
53
|
+
'CHOWN', 'DAC_OVERRIDE', 'DAC_READ_SEARCH', 'FOWNER', 'SETUID', 'SETGID',
|
|
54
|
+
'NET_BIND_SERVICE', 'KILL', 'AUDIT_WRITE',
|
|
55
|
+
]);
|
|
56
|
+
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
57
|
+
const IMAGE_RE = /^[a-z0-9._\-/:@]+$/;
|
|
58
|
+
const NAMED_VOLUME_RE = /^[a-z0-9_-]+$/;
|
|
59
|
+
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
60
|
+
const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
61
|
+
// Use homedir so the socket path is on the host-mounted volume and accessible
|
|
62
|
+
// to remote Docker daemons (e.g. docker-builder DinD) via a shared bind mount.
|
|
63
|
+
const SOCKET_DIR = path.join(os.homedir(), '.claude-gateway', 'sockets');
|
|
64
|
+
const DEFAULT_CPU = 1.0;
|
|
65
|
+
const DEFAULT_MEMORY = '256M';
|
|
66
|
+
const MAX_CPU = 4.0;
|
|
67
|
+
const MAX_MEMORY_MB = 2048;
|
|
68
|
+
// ─── Parsing ─────────────────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Parse and validate app.yaml content.
|
|
71
|
+
* appDir is used to resolve and range-check build/script paths.
|
|
72
|
+
*/
|
|
73
|
+
function parseAppYaml(content, appDir) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = yaml.load(content, { schema: yaml.DEFAULT_SCHEMA });
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
throw new Error(`Failed to parse app.yaml: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
82
|
+
throw new Error('app.yaml must be a YAML object');
|
|
83
|
+
}
|
|
84
|
+
const obj = raw;
|
|
85
|
+
requireString(obj, 'apiVersion');
|
|
86
|
+
requireString(obj, 'name');
|
|
87
|
+
requireString(obj, 'version');
|
|
88
|
+
if (typeof obj['services'] !== 'object' || obj['services'] === null) {
|
|
89
|
+
throw new Error('app.yaml: "services" must be an object');
|
|
90
|
+
}
|
|
91
|
+
const services = obj['services'];
|
|
92
|
+
for (const [svcName, svcDef] of Object.entries(services)) {
|
|
93
|
+
if (svcName === 'agent') {
|
|
94
|
+
validateAgentServiceDecl(svcDef);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
validateService(svcName, svcDef, appDir);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (obj['resources'] !== undefined) {
|
|
101
|
+
validateResources(obj['resources']);
|
|
102
|
+
}
|
|
103
|
+
return raw;
|
|
104
|
+
}
|
|
105
|
+
// ─── Generation ───────────────────────────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Generate docker-compose.yml from a parsed app.yaml.
|
|
108
|
+
* Writes the file to outputPath and returns metadata for the installer.
|
|
109
|
+
*/
|
|
110
|
+
function generateCompose(appYaml, appName, appDir, outputPath) {
|
|
111
|
+
if (!APP_NAME_RE.test(appName)) {
|
|
112
|
+
throw new Error(`Invalid app name: "${appName}"`);
|
|
113
|
+
}
|
|
114
|
+
const result = {
|
|
115
|
+
ports: [],
|
|
116
|
+
sockets: [],
|
|
117
|
+
secretKeys: [],
|
|
118
|
+
agentDeclaration: null,
|
|
119
|
+
warnings: [],
|
|
120
|
+
};
|
|
121
|
+
const namedVolumes = new Set();
|
|
122
|
+
const composeServices = {};
|
|
123
|
+
// Defense-in-depth: re-validate resource limits even if parseAppYaml was skipped
|
|
124
|
+
if (appYaml.resources !== undefined) {
|
|
125
|
+
validateResources(appYaml.resources);
|
|
126
|
+
}
|
|
127
|
+
const cpu = Math.min(appYaml.resources?.cpu ?? DEFAULT_CPU, MAX_CPU);
|
|
128
|
+
const rawMem = appYaml.resources?.memory ?? DEFAULT_MEMORY;
|
|
129
|
+
const memMB = parseMemoryMB(rawMem);
|
|
130
|
+
if (memMB > MAX_MEMORY_MB) {
|
|
131
|
+
throw new Error(`resources.memory ${rawMem} exceeds maximum of 2G`);
|
|
132
|
+
}
|
|
133
|
+
const memStr = rawMem;
|
|
134
|
+
// Two-pass: collect healthcheck presence for depends_on resolution
|
|
135
|
+
const hasHealthcheck = new Set();
|
|
136
|
+
for (const [svcName, svcDef] of Object.entries(appYaml.services)) {
|
|
137
|
+
if (svcName !== 'agent') {
|
|
138
|
+
const svc = svcDef;
|
|
139
|
+
if (svc.healthcheck)
|
|
140
|
+
hasHealthcheck.add(svcName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const [svcName, svcDef] of Object.entries(appYaml.services)) {
|
|
144
|
+
if (svcName === 'agent') {
|
|
145
|
+
const agentSvc = svcDef;
|
|
146
|
+
result.agentDeclaration = { path: agentSvc.path, name: agentSvc.name };
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const svc = svcDef;
|
|
150
|
+
const containerName = `${appName}-${svcName}`;
|
|
151
|
+
const composeSvc = {
|
|
152
|
+
container_name: containerName,
|
|
153
|
+
restart: 'unless-stopped',
|
|
154
|
+
cap_drop: ['ALL'],
|
|
155
|
+
env_file: '.env',
|
|
156
|
+
};
|
|
157
|
+
// Allow explicitly declared caps back (whitelist only)
|
|
158
|
+
if (svc.cap_add && svc.cap_add.length > 0) {
|
|
159
|
+
const denied = svc.cap_add.filter((c) => !ALLOWED_CAPS.has(c));
|
|
160
|
+
if (denied.length > 0) {
|
|
161
|
+
throw new Error(`Service "${svcName}".cap_add contains disallowed capabilities: ${denied.join(', ')}. Allowed: ${[...ALLOWED_CAPS].join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
composeSvc.cap_add = svc.cap_add;
|
|
164
|
+
}
|
|
165
|
+
// build or image
|
|
166
|
+
if (svc.build) {
|
|
167
|
+
const resolved = path.resolve(appDir, svc.build);
|
|
168
|
+
if (!resolved.startsWith(appDir + path.sep) && resolved !== appDir) {
|
|
169
|
+
throw new Error(`Service "${svcName}".build must be within the app directory`);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const realResolved = fs.realpathSync(resolved);
|
|
173
|
+
const realAppDir = fs.realpathSync(appDir);
|
|
174
|
+
if (!realResolved.startsWith(realAppDir + path.sep) && realResolved !== realAppDir) {
|
|
175
|
+
throw new Error(`Service "${svcName}".build escapes the app directory via a symlink`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (e.code !== 'ENOENT')
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
const buildConfig = { context: svc.build, network: 'host' };
|
|
183
|
+
const webPort = (svc.ports ?? []).find((p) => (p.type ?? 'api') === 'web');
|
|
184
|
+
if (webPort) {
|
|
185
|
+
buildConfig.args = { BASE_PATH: `/app/${appName}/${webPort.name}` };
|
|
186
|
+
}
|
|
187
|
+
composeSvc.build = buildConfig;
|
|
188
|
+
}
|
|
189
|
+
else if (svc.image) {
|
|
190
|
+
if (svc.image.endsWith(':latest')) {
|
|
191
|
+
result.warnings.push(`Service "${svcName}" uses floating image tag ":latest" — pin to a specific version for reproducible installs`);
|
|
192
|
+
}
|
|
193
|
+
composeSvc.image = svc.image;
|
|
194
|
+
}
|
|
195
|
+
// command / entrypoint (pass through; already validated in parse)
|
|
196
|
+
if (svc.command !== undefined)
|
|
197
|
+
composeSvc.command = svc.command;
|
|
198
|
+
if (svc.entrypoint !== undefined)
|
|
199
|
+
composeSvc.entrypoint = svc.entrypoint;
|
|
200
|
+
if (svc.working_dir !== undefined)
|
|
201
|
+
composeSvc.working_dir = svc.working_dir;
|
|
202
|
+
if (svc.user !== undefined)
|
|
203
|
+
composeSvc.user = svc.user;
|
|
204
|
+
// environment — static vars only (secrets go to .env via env_file)
|
|
205
|
+
const staticEnv = [];
|
|
206
|
+
for (const envEntry of svc.environment ?? []) {
|
|
207
|
+
const eqIdx = envEntry.indexOf('=');
|
|
208
|
+
if (eqIdx === -1) {
|
|
209
|
+
// Secret key — not in compose, recorded for installer to prompt
|
|
210
|
+
const key = envEntry.trim();
|
|
211
|
+
if (!result.secretKeys.includes(key)) {
|
|
212
|
+
result.secretKeys.push(key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
staticEnv.push(envEntry);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (staticEnv.length > 0)
|
|
220
|
+
composeSvc.environment = staticEnv;
|
|
221
|
+
// volumes
|
|
222
|
+
const composeVolumes = [];
|
|
223
|
+
for (const vol of svc.volumes ?? []) {
|
|
224
|
+
const [src, ...rest] = vol.split(':');
|
|
225
|
+
if (src.startsWith('./') || src.startsWith('../')) {
|
|
226
|
+
// Relative bind mount — resolve to absolute path so Docker daemon gets a real path
|
|
227
|
+
const absPath = path.resolve(appDir, src);
|
|
228
|
+
composeVolumes.push([absPath, ...rest].join(':'));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
composeVolumes.push(vol);
|
|
232
|
+
if (!src.startsWith('/') && !src.startsWith('${')) {
|
|
233
|
+
namedVolumes.add(src);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (composeVolumes.length > 0)
|
|
238
|
+
composeSvc.volumes = composeVolumes;
|
|
239
|
+
for (const portDef of svc.ports ?? []) {
|
|
240
|
+
const containerPort = portDef.container;
|
|
241
|
+
const hostPort = portDef.host;
|
|
242
|
+
composeSvc.ports = composeSvc.ports ?? [];
|
|
243
|
+
composeSvc.ports.push(`${hostPort}:${containerPort}`);
|
|
244
|
+
result.ports.push({
|
|
245
|
+
name: portDef.name,
|
|
246
|
+
service: svcName,
|
|
247
|
+
hostPort,
|
|
248
|
+
containerPort,
|
|
249
|
+
type: portDef.type ?? 'api',
|
|
250
|
+
rateLimit: portDef.rate_limit ?? 200,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// depends_on
|
|
254
|
+
if (svc.depends_on && svc.depends_on.length > 0) {
|
|
255
|
+
const dependsObj = {};
|
|
256
|
+
for (const dep of svc.depends_on) {
|
|
257
|
+
dependsObj[dep] = {
|
|
258
|
+
condition: hasHealthcheck.has(dep) ? 'service_healthy' : 'service_started',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
composeSvc.depends_on = dependsObj;
|
|
262
|
+
}
|
|
263
|
+
// healthcheck
|
|
264
|
+
if (svc.healthcheck) {
|
|
265
|
+
let healthTest = svc.healthcheck.test;
|
|
266
|
+
const webPort = (svc.ports ?? []).find((p) => (p.type ?? 'api') === 'web');
|
|
267
|
+
if (webPort) {
|
|
268
|
+
const basePath = `/app/${appName}/${webPort.name}`;
|
|
269
|
+
healthTest = healthTest.replace(/(https?:\/\/(?:127\.0\.0\.1|localhost):\d+)(\/)/g, `$1${basePath}/`);
|
|
270
|
+
}
|
|
271
|
+
composeSvc.healthcheck = {
|
|
272
|
+
test: ['CMD-SHELL', healthTest],
|
|
273
|
+
interval: svc.healthcheck.interval ?? '30s',
|
|
274
|
+
timeout: svc.healthcheck.timeout ?? '10s',
|
|
275
|
+
retries: svc.healthcheck.retries ?? 3,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// gateway_api — inject socket directory volume, record for installer
|
|
279
|
+
if (svc.gateway_api) {
|
|
280
|
+
const hostSockDir = path.join(SOCKET_DIR, `${appName}-${svcName}`);
|
|
281
|
+
const hostSockPath = path.join(hostSockDir, 'gateway.sock');
|
|
282
|
+
const containerSockPath = svc.gateway_api.socket;
|
|
283
|
+
// Mount the parent directory so Docker bind-mount captures the directory inode
|
|
284
|
+
// (stable), not the socket file inode (recreated on each gateway restart).
|
|
285
|
+
const containerSockDir = containerSockPath.replace(/\/[^/]+$/, '') || '/';
|
|
286
|
+
const sockVol = `${hostSockDir}:${containerSockDir}`;
|
|
287
|
+
composeSvc.volumes = [
|
|
288
|
+
...(composeSvc.volumes ?? []),
|
|
289
|
+
sockVol,
|
|
290
|
+
];
|
|
291
|
+
const scripts = {};
|
|
292
|
+
for (const [scriptName, scriptDef] of Object.entries(svc.gateway_api.scripts ?? {})) {
|
|
293
|
+
scripts[scriptName] = {
|
|
294
|
+
path: scriptDef.path,
|
|
295
|
+
timeout: scriptDef.timeout ?? '30s',
|
|
296
|
+
args: scriptDef.args,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
result.sockets.push({
|
|
300
|
+
service: svcName,
|
|
301
|
+
hostSocketPath: hostSockPath,
|
|
302
|
+
scripts,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// Resource limits: per-service overrides global default
|
|
306
|
+
const svcCpu = svc.resources?.cpu !== undefined
|
|
307
|
+
? Math.min(svc.resources.cpu, MAX_CPU)
|
|
308
|
+
: cpu;
|
|
309
|
+
const svcMemRaw = svc.resources?.memory ?? memStr;
|
|
310
|
+
const svcMemMB = parseMemoryMB(svcMemRaw);
|
|
311
|
+
if (svcMemMB > MAX_MEMORY_MB) {
|
|
312
|
+
throw new Error(`Service "${svcName}".resources.memory ${svcMemRaw} exceeds maximum of 2G`);
|
|
313
|
+
}
|
|
314
|
+
composeSvc.deploy = {
|
|
315
|
+
resources: {
|
|
316
|
+
limits: {
|
|
317
|
+
cpus: String(svcCpu),
|
|
318
|
+
memory: svcMemRaw,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
composeServices[svcName] = composeSvc;
|
|
323
|
+
}
|
|
324
|
+
// Top-level volumes for named volumes
|
|
325
|
+
const composeDoc = {
|
|
326
|
+
services: composeServices,
|
|
327
|
+
};
|
|
328
|
+
if (namedVolumes.size > 0) {
|
|
329
|
+
const volsObj = {};
|
|
330
|
+
for (const v of namedVolumes)
|
|
331
|
+
volsObj[v] = null;
|
|
332
|
+
composeDoc.volumes = volsObj;
|
|
333
|
+
}
|
|
334
|
+
const banner = '# generated by claude-gateway — do not edit\n';
|
|
335
|
+
const composeYaml = banner + yaml.dump(composeDoc, { lineWidth: 120 });
|
|
336
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
337
|
+
fs.writeFileSync(outputPath, composeYaml, 'utf-8');
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
// ─── Convenience wrapper ─────────────────────────────────────────────────────
|
|
341
|
+
/** Read app.yaml from appDir, parse, validate, and generate docker-compose.yml. */
|
|
342
|
+
function processAppYaml(appDir, appName, outputPath) {
|
|
343
|
+
const content = fs.readFileSync(path.join(appDir, 'app.yaml'), 'utf-8');
|
|
344
|
+
const appYaml = parseAppYaml(content, appDir);
|
|
345
|
+
return generateCompose(appYaml, appName, appDir, outputPath);
|
|
346
|
+
}
|
|
347
|
+
// ─── Validators ───────────────────────────────────────────────────────────────
|
|
348
|
+
function requireString(obj, key) {
|
|
349
|
+
if (typeof obj[key] !== 'string' || !obj[key]) {
|
|
350
|
+
throw new Error(`app.yaml: "${key}" is required and must be a non-empty string`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function validateAgentServiceDecl(svcDef) {
|
|
354
|
+
if (typeof svcDef !== 'object' || svcDef === null) {
|
|
355
|
+
throw new Error('Agent service declaration must be an object');
|
|
356
|
+
}
|
|
357
|
+
const obj = svcDef;
|
|
358
|
+
const allowed = new Set(['path', 'name']);
|
|
359
|
+
for (const k of Object.keys(obj)) {
|
|
360
|
+
if (!allowed.has(k)) {
|
|
361
|
+
throw new Error(`Agent service declaration has invalid field "${k}" — only "path" and "name" are allowed`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (typeof obj['path'] !== 'string' || !obj['path']) {
|
|
365
|
+
throw new Error('Agent service declaration: "path" is required');
|
|
366
|
+
}
|
|
367
|
+
if (typeof obj['name'] !== 'string' || !AGENT_NAME_RE.test(obj['name'])) {
|
|
368
|
+
throw new Error(`Agent service declaration: "name" must match ${AGENT_NAME_RE} (got "${obj['name']}")`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function validateService(svcName, svcDef, appDir) {
|
|
372
|
+
if (typeof svcDef !== 'object' || svcDef === null) {
|
|
373
|
+
throw new Error(`Service "${svcName}" must be an object`);
|
|
374
|
+
}
|
|
375
|
+
const obj = svcDef;
|
|
376
|
+
for (const key of Object.keys(obj)) {
|
|
377
|
+
if (!ALLOWED_SERVICE_FIELDS.has(key)) {
|
|
378
|
+
throw new Error(`Service "${svcName}" has banned field: "${key}"`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!obj['build'] && !obj['image']) {
|
|
382
|
+
throw new Error(`Service "${svcName}" must have "build" or "image"`);
|
|
383
|
+
}
|
|
384
|
+
if (obj['build'] && obj['image']) {
|
|
385
|
+
throw new Error(`Service "${svcName}" cannot have both "build" and "image"`);
|
|
386
|
+
}
|
|
387
|
+
if (obj['build'] !== undefined) {
|
|
388
|
+
if (typeof obj['build'] !== 'string') {
|
|
389
|
+
throw new Error(`Service "${svcName}".build must be a string`);
|
|
390
|
+
}
|
|
391
|
+
const resolved = path.resolve(appDir, obj['build']);
|
|
392
|
+
if (!resolved.startsWith(appDir + path.sep) && resolved !== appDir) {
|
|
393
|
+
throw new Error(`Service "${svcName}".build must be within the app directory`);
|
|
394
|
+
}
|
|
395
|
+
// Resolve symlinks to prevent path traversal via symlinks inside the app dir
|
|
396
|
+
try {
|
|
397
|
+
const realResolved = fs.realpathSync(resolved);
|
|
398
|
+
const realAppDir = fs.realpathSync(appDir);
|
|
399
|
+
if (!realResolved.startsWith(realAppDir + path.sep) && realResolved !== realAppDir) {
|
|
400
|
+
throw new Error(`Service "${svcName}".build escapes the app directory via a symlink`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
if (e.code !== 'ENOENT')
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (obj['image'] !== undefined) {
|
|
409
|
+
if (typeof obj['image'] !== 'string') {
|
|
410
|
+
throw new Error(`Service "${svcName}".image must be a string`);
|
|
411
|
+
}
|
|
412
|
+
if (!IMAGE_RE.test(obj['image'])) {
|
|
413
|
+
throw new Error(`Service "${svcName}".image contains invalid characters (only lowercase, digits, ._-/:@ allowed)`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
validateCommand(svcName, 'command', obj['command']);
|
|
417
|
+
validateCommand(svcName, 'entrypoint', obj['entrypoint']);
|
|
418
|
+
if (obj['working_dir'] !== undefined && typeof obj['working_dir'] !== 'string') {
|
|
419
|
+
throw new Error(`Service "${svcName}".working_dir must be a string`);
|
|
420
|
+
}
|
|
421
|
+
if (obj['user'] !== undefined && typeof obj['user'] !== 'string') {
|
|
422
|
+
throw new Error(`Service "${svcName}".user must be a string`);
|
|
423
|
+
}
|
|
424
|
+
if (obj['environment'] !== undefined) {
|
|
425
|
+
validateEnvironment(svcName, obj['environment']);
|
|
426
|
+
}
|
|
427
|
+
if (obj['volumes'] !== undefined) {
|
|
428
|
+
validateVolumes(svcName, obj['volumes']);
|
|
429
|
+
}
|
|
430
|
+
if (obj['ports'] !== undefined) {
|
|
431
|
+
validatePorts(svcName, obj['ports']);
|
|
432
|
+
}
|
|
433
|
+
if (obj['depends_on'] !== undefined) {
|
|
434
|
+
if (!Array.isArray(obj['depends_on'])) {
|
|
435
|
+
throw new Error(`Service "${svcName}".depends_on must be an array`);
|
|
436
|
+
}
|
|
437
|
+
for (const dep of obj['depends_on']) {
|
|
438
|
+
if (typeof dep !== 'string') {
|
|
439
|
+
throw new Error(`Service "${svcName}".depends_on entries must be strings`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (obj['healthcheck'] !== undefined) {
|
|
444
|
+
validateHealthcheck(svcName, obj['healthcheck']);
|
|
445
|
+
}
|
|
446
|
+
if (obj['gateway_api'] !== undefined) {
|
|
447
|
+
validateGatewayApi(svcName, obj['gateway_api'], appDir);
|
|
448
|
+
}
|
|
449
|
+
if (obj['cap_add'] !== undefined) {
|
|
450
|
+
if (!Array.isArray(obj['cap_add'])) {
|
|
451
|
+
throw new Error(`Service "${svcName}".cap_add must be an array`);
|
|
452
|
+
}
|
|
453
|
+
for (const cap of obj['cap_add']) {
|
|
454
|
+
if (typeof cap !== 'string') {
|
|
455
|
+
throw new Error(`Service "${svcName}".cap_add entries must be strings`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (obj['resources'] !== undefined) {
|
|
460
|
+
try {
|
|
461
|
+
validateResources(obj['resources']);
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
throw new Error(`Service "${svcName}".${e.message.replace(/^app\.yaml: /, '')}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return svcDef;
|
|
468
|
+
}
|
|
469
|
+
function validateCommand(svcName, field, val) {
|
|
470
|
+
if (val === undefined)
|
|
471
|
+
return;
|
|
472
|
+
const tokens = Array.isArray(val) ? val : [val];
|
|
473
|
+
for (const token of tokens) {
|
|
474
|
+
if (typeof token !== 'string') {
|
|
475
|
+
throw new Error(`Service "${svcName}".${field} tokens must be strings`);
|
|
476
|
+
}
|
|
477
|
+
if (/[\n\r\x00]/.test(token)) {
|
|
478
|
+
throw new Error(`Service "${svcName}".${field} contains invalid characters (newline/null)`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function validateEnvironment(svcName, envList) {
|
|
483
|
+
if (!Array.isArray(envList)) {
|
|
484
|
+
throw new Error(`Service "${svcName}".environment must be an array`);
|
|
485
|
+
}
|
|
486
|
+
for (const entry of envList) {
|
|
487
|
+
if (typeof entry !== 'string') {
|
|
488
|
+
throw new Error(`Service "${svcName}".environment entries must be strings`);
|
|
489
|
+
}
|
|
490
|
+
const key = entry.includes('=') ? entry.slice(0, entry.indexOf('=')).trim() : entry.trim();
|
|
491
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
492
|
+
throw new Error(`Service "${svcName}".environment key "${key}" must match ${ENV_KEY_RE}`);
|
|
493
|
+
}
|
|
494
|
+
// Strip any newlines from value (value after =)
|
|
495
|
+
if (entry.includes('=') && /[\n\r]/.test(entry.slice(entry.indexOf('=') + 1))) {
|
|
496
|
+
throw new Error(`Service "${svcName}".environment entry "${key}" value contains newline characters`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function validateVolumes(svcName, volList) {
|
|
501
|
+
if (!Array.isArray(volList)) {
|
|
502
|
+
throw new Error(`Service "${svcName}".volumes must be an array`);
|
|
503
|
+
}
|
|
504
|
+
for (const vol of volList) {
|
|
505
|
+
if (typeof vol !== 'string') {
|
|
506
|
+
throw new Error(`Service "${svcName}".volumes entries must be strings`);
|
|
507
|
+
}
|
|
508
|
+
const parts = vol.split(':');
|
|
509
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
510
|
+
throw new Error(`Service "${svcName}".volumes entry is malformed: "${vol}"`);
|
|
511
|
+
}
|
|
512
|
+
const [src, target, mode] = parts;
|
|
513
|
+
if (!src || !target) {
|
|
514
|
+
throw new Error(`Service "${svcName}".volumes entry is malformed: "${vol}"`);
|
|
515
|
+
}
|
|
516
|
+
if (src.startsWith('/')) {
|
|
517
|
+
// Host mount — validate absolute, no traversal, no symlinks
|
|
518
|
+
const resolved = path.resolve(src);
|
|
519
|
+
if (resolved !== src) {
|
|
520
|
+
throw new Error(`Service "${svcName}".volumes source contains path traversal: "${src}"`);
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
if (fs.lstatSync(src).isSymbolicLink()) {
|
|
524
|
+
throw new Error(`Service "${svcName}".volumes source must not be a symlink: "${src}"`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
const e = err;
|
|
529
|
+
if (e.code !== 'ENOENT')
|
|
530
|
+
throw e; // path doesn't exist yet — OK
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}(\/[^.][^:]*)?(\/\.\.)?/.test(src) && src.startsWith('${')) {
|
|
534
|
+
// Env var substitution: ${VAR} or ${VAR:-default} — Docker Compose expands at runtime.
|
|
535
|
+
// Reject if the suffix contains traversal sequences.
|
|
536
|
+
const suffix = src.replace(/^\$\{[^}]+\}/, '');
|
|
537
|
+
if (suffix.includes('..')) {
|
|
538
|
+
throw new Error(`Service "${svcName}".volumes source contains path traversal: "${src}"`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else if (src.startsWith('./') || src.startsWith('../')) {
|
|
542
|
+
// Relative bind mount (e.g. ./postgres) — validate no double traversal beyond ./
|
|
543
|
+
if (src.includes('/../') || src.endsWith('/..')) {
|
|
544
|
+
throw new Error(`Service "${svcName}".volumes source contains path traversal: "${src}"`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
// Named volume
|
|
549
|
+
if (!NAMED_VOLUME_RE.test(src)) {
|
|
550
|
+
throw new Error(`Service "${svcName}".volumes named volume must match ${NAMED_VOLUME_RE}: "${src}"`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (!target.startsWith('/')) {
|
|
554
|
+
throw new Error(`Service "${svcName}".volumes target must be an absolute path: "${target}"`);
|
|
555
|
+
}
|
|
556
|
+
if (target.includes('..')) {
|
|
557
|
+
throw new Error(`Service "${svcName}".volumes target contains path traversal: "${target}"`);
|
|
558
|
+
}
|
|
559
|
+
if (mode !== undefined && mode !== 'ro' && mode !== 'rw') {
|
|
560
|
+
throw new Error(`Service "${svcName}".volumes mode must be "ro" or "rw": "${mode}"`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function validatePorts(svcName, portList) {
|
|
565
|
+
if (!Array.isArray(portList)) {
|
|
566
|
+
throw new Error(`Service "${svcName}".ports must be an array`);
|
|
567
|
+
}
|
|
568
|
+
const seenContainer = new Set();
|
|
569
|
+
const seenHost = new Set();
|
|
570
|
+
for (const portDef of portList) {
|
|
571
|
+
if (typeof portDef !== 'object' || portDef === null) {
|
|
572
|
+
throw new Error(`Service "${svcName}".ports entries must be objects`);
|
|
573
|
+
}
|
|
574
|
+
const p = portDef;
|
|
575
|
+
if (typeof p['name'] !== 'string' || !p['name']) {
|
|
576
|
+
throw new Error(`Service "${svcName}".ports entry missing "name"`);
|
|
577
|
+
}
|
|
578
|
+
if (typeof p['container'] !== 'number' || !Number.isInteger(p['container'])) {
|
|
579
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].container must be an integer`);
|
|
580
|
+
}
|
|
581
|
+
const containerPort = p['container'];
|
|
582
|
+
if (containerPort < 1024 || BANNED_PORTS.has(containerPort)) {
|
|
583
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].container ${containerPort} is banned`);
|
|
584
|
+
}
|
|
585
|
+
if (seenContainer.has(containerPort)) {
|
|
586
|
+
throw new Error(`Service "${svcName}".ports has duplicate container port ${containerPort}`);
|
|
587
|
+
}
|
|
588
|
+
seenContainer.add(containerPort);
|
|
589
|
+
if (typeof p['host'] !== 'number' || !Number.isInteger(p['host'])) {
|
|
590
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].host is required and must be an integer`);
|
|
591
|
+
}
|
|
592
|
+
const hostPort = p['host'];
|
|
593
|
+
if (hostPort < 1024 || BANNED_PORTS.has(hostPort)) {
|
|
594
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].host ${hostPort} is banned`);
|
|
595
|
+
}
|
|
596
|
+
if (seenHost.has(hostPort)) {
|
|
597
|
+
throw new Error(`Service "${svcName}".ports has duplicate host port ${hostPort}`);
|
|
598
|
+
}
|
|
599
|
+
seenHost.add(hostPort);
|
|
600
|
+
if (p['type'] !== undefined && p['type'] !== 'api' && p['type'] !== 'web') {
|
|
601
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].type must be "api" or "web"`);
|
|
602
|
+
}
|
|
603
|
+
if (p['rate_limit'] !== undefined) {
|
|
604
|
+
if (typeof p['rate_limit'] !== 'number' || p['rate_limit'] <= 0) {
|
|
605
|
+
throw new Error(`Service "${svcName}".ports["${p['name']}"].rate_limit must be a positive number`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function validateHealthcheck(svcName, hc) {
|
|
611
|
+
if (typeof hc !== 'object' || hc === null) {
|
|
612
|
+
throw new Error(`Service "${svcName}".healthcheck must be an object`);
|
|
613
|
+
}
|
|
614
|
+
const obj = hc;
|
|
615
|
+
if (typeof obj['test'] !== 'string' || !obj['test']) {
|
|
616
|
+
throw new Error(`Service "${svcName}".healthcheck.test must be a non-empty string`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function validateGatewayApi(svcName, gatewayApi, appDir) {
|
|
620
|
+
if (typeof gatewayApi !== 'object' || gatewayApi === null) {
|
|
621
|
+
throw new Error(`Service "${svcName}".gateway_api must be an object`);
|
|
622
|
+
}
|
|
623
|
+
const obj = gatewayApi;
|
|
624
|
+
if (typeof obj['socket'] !== 'string' || !obj['socket']) {
|
|
625
|
+
throw new Error(`Service "${svcName}".gateway_api.socket must be a non-empty string`);
|
|
626
|
+
}
|
|
627
|
+
if (obj['scripts'] !== undefined) {
|
|
628
|
+
if (typeof obj['scripts'] !== 'object' || obj['scripts'] === null) {
|
|
629
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts must be an object`);
|
|
630
|
+
}
|
|
631
|
+
for (const [scriptName, scriptDef] of Object.entries(obj['scripts'])) {
|
|
632
|
+
validateScript(svcName, scriptName, scriptDef, appDir);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function validateScript(svcName, scriptName, scriptDef, appDir) {
|
|
637
|
+
if (typeof scriptDef !== 'object' || scriptDef === null) {
|
|
638
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts["${scriptName}"] must be an object`);
|
|
639
|
+
}
|
|
640
|
+
const obj = scriptDef;
|
|
641
|
+
if (typeof obj['path'] !== 'string' || !obj['path']) {
|
|
642
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts["${scriptName}"].path must be a string`);
|
|
643
|
+
}
|
|
644
|
+
const scriptPath = obj['path'];
|
|
645
|
+
if (scriptPath.startsWith('/')) {
|
|
646
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts["${scriptName}"].path must be relative (not absolute)`);
|
|
647
|
+
}
|
|
648
|
+
if (!scriptPath.endsWith('.sh')) {
|
|
649
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts["${scriptName}"].path must be a .sh file`);
|
|
650
|
+
}
|
|
651
|
+
const resolved = path.resolve(appDir, scriptPath);
|
|
652
|
+
if (!resolved.startsWith(appDir + path.sep) && resolved !== appDir) {
|
|
653
|
+
throw new Error(`Service "${svcName}".gateway_api.scripts["${scriptName}"].path must be within the app directory`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function validateResources(resources) {
|
|
657
|
+
if (typeof resources !== 'object' || resources === null) {
|
|
658
|
+
throw new Error('app.yaml: "resources" must be an object');
|
|
659
|
+
}
|
|
660
|
+
const obj = resources;
|
|
661
|
+
if (obj['cpu'] !== undefined) {
|
|
662
|
+
if (typeof obj['cpu'] !== 'number' || obj['cpu'] <= 0) {
|
|
663
|
+
throw new Error('app.yaml: resources.cpu must be a positive number');
|
|
664
|
+
}
|
|
665
|
+
if (obj['cpu'] > MAX_CPU) {
|
|
666
|
+
throw new Error(`app.yaml: resources.cpu ${obj['cpu']} exceeds maximum of ${MAX_CPU}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (obj['memory'] !== undefined) {
|
|
670
|
+
if (typeof obj['memory'] !== 'string') {
|
|
671
|
+
throw new Error('app.yaml: resources.memory must be a string (e.g. "256M", "1G")');
|
|
672
|
+
}
|
|
673
|
+
const mb = parseMemoryMB(obj['memory']);
|
|
674
|
+
if (mb > MAX_MEMORY_MB) {
|
|
675
|
+
throw new Error(`app.yaml: resources.memory ${obj['memory']} exceeds maximum of 2G`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function parseMemoryMB(mem) {
|
|
680
|
+
const m = mem.match(/^(\d+(?:\.\d+)?)\s*([MG])$/i);
|
|
681
|
+
if (!m) {
|
|
682
|
+
throw new Error(`Invalid memory format: "${mem}" (expected e.g. "256M", "1G")`);
|
|
683
|
+
}
|
|
684
|
+
const [, num, unit] = m;
|
|
685
|
+
return unit.toUpperCase() === 'G' ? parseFloat(num) * 1024 : parseFloat(num);
|
|
686
|
+
}
|
|
687
|
+
//# sourceMappingURL=compose-generator.js.map
|