@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,898 @@
|
|
|
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.AppInstaller = void 0;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const os = __importStar(require("node:os"));
|
|
40
|
+
const crypto = __importStar(require("node:crypto"));
|
|
41
|
+
const node_child_process_1 = require("node:child_process");
|
|
42
|
+
const compose_generator_1 = require("./compose-generator");
|
|
43
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
44
|
+
const DEFAULT_APPS_DIR = path.join(os.homedir(), '.claude-gateway', 'apps');
|
|
45
|
+
const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
46
|
+
const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
47
|
+
// Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
|
|
48
|
+
const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
|
|
49
|
+
// ─── Installer ────────────────────────────────────────────────────────────────
|
|
50
|
+
class AppInstaller {
|
|
51
|
+
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager) {
|
|
52
|
+
this.registry = registry;
|
|
53
|
+
this.registryClient = registryClient;
|
|
54
|
+
this.callbacks = callbacks;
|
|
55
|
+
this.spawn = spawn;
|
|
56
|
+
this.agentManager = agentManager;
|
|
57
|
+
this.jobs = new Map();
|
|
58
|
+
/** Tracks app names currently being installed to prevent concurrent installs of the same name. */
|
|
59
|
+
this.installingNames = new Set();
|
|
60
|
+
this.appsDir = appsDir ?? DEFAULT_APPS_DIR;
|
|
61
|
+
}
|
|
62
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
63
|
+
/** Start an async install job. Returns jobId immediately. */
|
|
64
|
+
install(options) {
|
|
65
|
+
this.pruneOldJobs();
|
|
66
|
+
// Check synchronously before spawning async job to prevent races
|
|
67
|
+
const tentativeName = options.registryApp ?? options.githubUrl ?? options.localPath ?? 'unknown';
|
|
68
|
+
if (this.installingNames.has(tentativeName)) {
|
|
69
|
+
throw new Error(`App "${tentativeName}" is already being installed`);
|
|
70
|
+
}
|
|
71
|
+
this.installingNames.add(tentativeName);
|
|
72
|
+
const jobId = crypto.randomUUID();
|
|
73
|
+
const job = {
|
|
74
|
+
id: jobId,
|
|
75
|
+
status: 'pending',
|
|
76
|
+
logs: [],
|
|
77
|
+
startedAt: Date.now(),
|
|
78
|
+
updatedAt: Date.now(),
|
|
79
|
+
};
|
|
80
|
+
this.jobs.set(jobId, job);
|
|
81
|
+
// Run in background — no await
|
|
82
|
+
void this.runInstall(job, options).catch((err) => {
|
|
83
|
+
this.failJob(job, err instanceof Error ? err.message : String(err));
|
|
84
|
+
}).finally(() => {
|
|
85
|
+
this.installingNames.delete(tentativeName);
|
|
86
|
+
});
|
|
87
|
+
return jobId;
|
|
88
|
+
}
|
|
89
|
+
getJob(jobId) {
|
|
90
|
+
return this.jobs.get(jobId);
|
|
91
|
+
}
|
|
92
|
+
/** Start an async update job. Returns jobId immediately. */
|
|
93
|
+
update(appName) {
|
|
94
|
+
this.pruneOldJobs();
|
|
95
|
+
if (this.installingNames.has(appName)) {
|
|
96
|
+
throw new Error(`App "${appName}" is already being installed or updated`);
|
|
97
|
+
}
|
|
98
|
+
this.installingNames.add(appName);
|
|
99
|
+
const jobId = crypto.randomUUID();
|
|
100
|
+
const job = {
|
|
101
|
+
id: jobId,
|
|
102
|
+
status: 'pending',
|
|
103
|
+
logs: [],
|
|
104
|
+
startedAt: Date.now(),
|
|
105
|
+
updatedAt: Date.now(),
|
|
106
|
+
};
|
|
107
|
+
this.jobs.set(jobId, job);
|
|
108
|
+
void this.runUpdate(job, appName).catch((err) => {
|
|
109
|
+
this.failJob(job, err instanceof Error ? err.message : String(err));
|
|
110
|
+
}).finally(() => {
|
|
111
|
+
this.installingNames.delete(appName);
|
|
112
|
+
});
|
|
113
|
+
return jobId;
|
|
114
|
+
}
|
|
115
|
+
async uninstall(appName) {
|
|
116
|
+
const entry = await this.registry.get(appName);
|
|
117
|
+
// Orphaned install: directory exists on disk but not in registry — clean up filesystem only
|
|
118
|
+
if (!entry) {
|
|
119
|
+
const orphanDir = path.join(this.appsDir, appName);
|
|
120
|
+
if (!fs.existsSync(orphanDir)) {
|
|
121
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
122
|
+
}
|
|
123
|
+
const stat = fs.lstatSync(orphanDir);
|
|
124
|
+
const resolvedDir = stat.isSymbolicLink() ? fs.realpathSync(orphanDir) : orphanDir;
|
|
125
|
+
// Bring down any running containers before touching the filesystem
|
|
126
|
+
const orphanCompose = path.join(resolvedDir, 'docker-compose.yml');
|
|
127
|
+
if (fs.existsSync(orphanCompose)) {
|
|
128
|
+
try {
|
|
129
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all'], resolvedDir, 120000);
|
|
130
|
+
}
|
|
131
|
+
catch { /* best-effort — proceed with cleanup regardless */ }
|
|
132
|
+
}
|
|
133
|
+
if (stat.isSymbolicLink()) {
|
|
134
|
+
fs.unlinkSync(orphanDir);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.rmrf(orphanDir);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const appDir = entry.installPath;
|
|
142
|
+
// docker compose down --rmi all (graceful fallback if dir is already gone)
|
|
143
|
+
if (fs.existsSync(appDir)) {
|
|
144
|
+
try {
|
|
145
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all'], appDir, 120000);
|
|
146
|
+
}
|
|
147
|
+
catch { /* best-effort — continue cleanup */ }
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// Dir gone — stop containers by project label only (no compose file needed, no --rmi all)
|
|
151
|
+
try {
|
|
152
|
+
this.run(['docker', 'compose', '-p', appName, 'down'], os.tmpdir(), 120000);
|
|
153
|
+
}
|
|
154
|
+
catch { /* best-effort */ }
|
|
155
|
+
}
|
|
156
|
+
// Remove proxy routes + sockets
|
|
157
|
+
this.callbacks.deregisterRoutes(appName);
|
|
158
|
+
this.callbacks.stopSockets(appName);
|
|
159
|
+
// Remove agent symlink + config.json entry if this was an agent app
|
|
160
|
+
if (this.agentManager) {
|
|
161
|
+
await this.agentManager.deleteAgent(entry);
|
|
162
|
+
}
|
|
163
|
+
// Remove app files — symlink only for local-dev installs, full rmrf for cloned installs
|
|
164
|
+
let appDirStat = null;
|
|
165
|
+
try {
|
|
166
|
+
appDirStat = fs.lstatSync(appDir);
|
|
167
|
+
}
|
|
168
|
+
catch { /* already gone */ }
|
|
169
|
+
if (appDirStat) {
|
|
170
|
+
if (appDirStat.isSymbolicLink()) {
|
|
171
|
+
fs.unlinkSync(appDir);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.rmrf(appDir);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await this.registry.remove(appName);
|
|
178
|
+
}
|
|
179
|
+
async startStopRestart(appName, action) {
|
|
180
|
+
const entry = await this.registry.get(appName);
|
|
181
|
+
if (!entry)
|
|
182
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
183
|
+
if (action === 'stop') {
|
|
184
|
+
this.run(['docker', 'compose', '-p', appName, 'stop'], entry.installPath, 60000);
|
|
185
|
+
await this.registry.updateStatus(appName, 'stopped');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// start / restart: stop conflicting containers and wait for healthcheck
|
|
189
|
+
this.composeUp(appName, entry.installPath);
|
|
190
|
+
await this.registry.updateStatus(appName, 'running');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ─── Internal install pipeline ────────────────────────────────────────────
|
|
194
|
+
async runInstall(job, options) {
|
|
195
|
+
job.status = 'running';
|
|
196
|
+
job.updatedAt = Date.now();
|
|
197
|
+
const tentativeName = options.registryApp ?? options.githubUrl ?? options.localPath ?? 'unknown';
|
|
198
|
+
const { localPath } = options;
|
|
199
|
+
// ── Resolve app dir and commit ────────────────────────────────────────
|
|
200
|
+
let appDir;
|
|
201
|
+
let appName;
|
|
202
|
+
let commit;
|
|
203
|
+
let githubUrl;
|
|
204
|
+
let source;
|
|
205
|
+
let version = options.version ?? '0.0.0';
|
|
206
|
+
if (localPath) {
|
|
207
|
+
// Mode B — local dev path (symlinked into appsDir)
|
|
208
|
+
const resolved = path.resolve(localPath);
|
|
209
|
+
if (!fs.existsSync(resolved)) {
|
|
210
|
+
throw new Error(`local_path does not exist: "${resolved}"`);
|
|
211
|
+
}
|
|
212
|
+
// Read app.yaml from local path first to get canonical app name
|
|
213
|
+
const localYamlPath = path.join(resolved, 'app.yaml');
|
|
214
|
+
if (!fs.existsSync(localYamlPath)) {
|
|
215
|
+
throw new Error(`app.yaml not found in "${resolved}"`);
|
|
216
|
+
}
|
|
217
|
+
const localYamlContent = fs.readFileSync(localYamlPath, 'utf-8');
|
|
218
|
+
const localAppYaml = (0, compose_generator_1.parseAppYaml)(localYamlContent, resolved);
|
|
219
|
+
appName = localAppYaml.name;
|
|
220
|
+
appDir = path.join(this.appsDir, appName);
|
|
221
|
+
const diskExists = fs.existsSync(appDir);
|
|
222
|
+
const registryEntry = await this.registry.get(appName);
|
|
223
|
+
if (diskExists) {
|
|
224
|
+
if (registryEntry) {
|
|
225
|
+
throw new Error(`App "${appName}" is already installed. Uninstall first.`);
|
|
226
|
+
}
|
|
227
|
+
// Orphaned directory (registry missing) — bring down containers first
|
|
228
|
+
const stat = fs.lstatSync(appDir);
|
|
229
|
+
const resolvedAppDir = stat.isSymbolicLink() ? fs.realpathSync(appDir) : appDir;
|
|
230
|
+
const orphanCompose = path.join(resolvedAppDir, 'docker-compose.yml');
|
|
231
|
+
if (fs.existsSync(orphanCompose)) {
|
|
232
|
+
try {
|
|
233
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all'], resolvedAppDir, 120000);
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
this.log(job, `Warning: orphan container cleanup failed: ${e.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (stat.isSymbolicLink()) {
|
|
240
|
+
fs.unlinkSync(appDir);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.rmrf(appDir);
|
|
244
|
+
}
|
|
245
|
+
this.log(job, `Removed orphaned app directory for "${appName}"`);
|
|
246
|
+
}
|
|
247
|
+
else if (registryEntry) {
|
|
248
|
+
// Orphaned registry entry: disk is gone but apps.json still has the app.
|
|
249
|
+
// Clean up before creating symlink so install can proceed.
|
|
250
|
+
await this.registry.remove(appName).catch(() => { });
|
|
251
|
+
this.log(job, `Cleaned up orphaned registry entry for "${appName}"`);
|
|
252
|
+
}
|
|
253
|
+
fs.symlinkSync(resolved, appDir);
|
|
254
|
+
commit = 'local';
|
|
255
|
+
githubUrl = '';
|
|
256
|
+
source = 'local';
|
|
257
|
+
this.log(job, `Symlinked ${resolved} → ${appDir}`);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// Mode A — registry or GitHub
|
|
261
|
+
({ appName, commit, githubUrl, source, version } = await this.resolveSource(job, options, version));
|
|
262
|
+
appDir = path.join(this.appsDir, appName);
|
|
263
|
+
// Check for existing install
|
|
264
|
+
if (fs.existsSync(appDir)) {
|
|
265
|
+
const registryEntry = await this.registry.get(appName);
|
|
266
|
+
if (registryEntry) {
|
|
267
|
+
throw new Error(`App "${appName}" is already installed. Use update to upgrade.`);
|
|
268
|
+
}
|
|
269
|
+
// Orphaned directory (registry missing) — bring down containers first
|
|
270
|
+
const orphanCompose = path.join(appDir, 'docker-compose.yml');
|
|
271
|
+
if (fs.existsSync(orphanCompose)) {
|
|
272
|
+
try {
|
|
273
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all'], appDir, 120000);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
this.log(job, `Warning: orphan container cleanup failed: ${e.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
this.rmrf(appDir);
|
|
280
|
+
this.log(job, `Removed orphaned app directory for "${appName}"`);
|
|
281
|
+
}
|
|
282
|
+
// Shallow fetch of specific commit — avoids downloading full repo history
|
|
283
|
+
this.log(job, `Cloning ${githubUrl}`);
|
|
284
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
285
|
+
this.run(['git', 'init'], appDir);
|
|
286
|
+
this.run(['git', 'remote', 'add', 'origin', githubUrl], appDir);
|
|
287
|
+
this.run(['git', 'fetch', '--depth', '1', 'origin', commit], appDir);
|
|
288
|
+
this.run(['git', 'checkout', 'FETCH_HEAD'], appDir);
|
|
289
|
+
this.log(job, `Checked out commit ${commit.slice(0, 8)}`);
|
|
290
|
+
}
|
|
291
|
+
// Track registered agent name for rollback (set after upsertAgent succeeds)
|
|
292
|
+
let registeredAgentName;
|
|
293
|
+
// From here — appDir exists. Wrap in try so any failure cleans it up.
|
|
294
|
+
try {
|
|
295
|
+
// Validate app name from app.yaml matches
|
|
296
|
+
this.log(job, 'Validating app.yaml');
|
|
297
|
+
const yamlContent = fs.readFileSync(path.join(appDir, 'app.yaml'), 'utf-8');
|
|
298
|
+
const appYaml = (0, compose_generator_1.parseAppYaml)(yamlContent, appDir);
|
|
299
|
+
if (!APP_NAME_RE.test(appYaml.name)) {
|
|
300
|
+
throw new Error(`Invalid app name in app.yaml: "${appYaml.name}"`);
|
|
301
|
+
}
|
|
302
|
+
// Switch lock to canonical app name (atomic: add canonical before removing tentative)
|
|
303
|
+
appName = appYaml.name;
|
|
304
|
+
if (this.installingNames.has(appName) && appName !== tentativeName) {
|
|
305
|
+
throw new Error(`App "${appName}" is already being installed`);
|
|
306
|
+
}
|
|
307
|
+
this.installingNames.add(appName);
|
|
308
|
+
this.installingNames.delete(tentativeName);
|
|
309
|
+
// Conflict check — app name (atomic with install lock held)
|
|
310
|
+
const existing = await this.registry.get(appName);
|
|
311
|
+
if (existing) {
|
|
312
|
+
if (fs.existsSync(appDir)) {
|
|
313
|
+
throw new Error(`App "${appName}" is already installed`);
|
|
314
|
+
}
|
|
315
|
+
// Orphaned registry entry: disk is gone but apps.json still has the app.
|
|
316
|
+
// Clean up the stale entry so install can proceed cleanly.
|
|
317
|
+
await this.registry.remove(appName).catch(() => { });
|
|
318
|
+
this.log(job, `Cleaned up orphaned registry entry for "${appName}"`);
|
|
319
|
+
}
|
|
320
|
+
// ── Generate docker-compose.yml ───────────────────────────────────────
|
|
321
|
+
this.log(job, 'Generating docker-compose.yml');
|
|
322
|
+
const composePath = path.join(appDir, 'docker-compose.yml');
|
|
323
|
+
const generated = (0, compose_generator_1.generateCompose)(appYaml, appName, appDir, composePath);
|
|
324
|
+
// Conflict check — host port uniqueness across all installed apps
|
|
325
|
+
const installedApps = await this.registry.list();
|
|
326
|
+
const usedHostPorts = new Map();
|
|
327
|
+
for (const app of installedApps) {
|
|
328
|
+
for (const port of app.ports) {
|
|
329
|
+
usedHostPorts.set(port.hostPort, app.name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const port of generated.ports) {
|
|
333
|
+
const owner = usedHostPorts.get(port.hostPort);
|
|
334
|
+
if (owner) {
|
|
335
|
+
throw new Error(`Host port ${port.hostPort} (port "${port.name}") is already used by app "${owner}"`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Conflict check — agent name (if app declares an agent), inside install lock
|
|
339
|
+
if (generated.agentDeclaration && this.agentManager) {
|
|
340
|
+
const conflict = await this.agentManager.findAgentByName(generated.agentDeclaration.name);
|
|
341
|
+
if (conflict) {
|
|
342
|
+
throw new Error(`Agent name "${generated.agentDeclaration.name}" is already registered — agent name conflict`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const w of generated.warnings) {
|
|
346
|
+
this.log(job, `Warning: ${w}`);
|
|
347
|
+
}
|
|
348
|
+
// ── Write .env ────────────────────────────────────────────────────────
|
|
349
|
+
this.log(job, 'Writing .env');
|
|
350
|
+
const envVars = options.envVars ?? {};
|
|
351
|
+
const envLines = [];
|
|
352
|
+
// Inject BASE_PATH for web-type ports
|
|
353
|
+
for (const port of generated.ports) {
|
|
354
|
+
if (port.type === 'web') {
|
|
355
|
+
envVars[`BASE_PATH`] = `/app/${appName}/${port.name}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const key of generated.secretKeys) {
|
|
359
|
+
const val = (envVars[key] ?? '').replace(/[\r\n]/g, '');
|
|
360
|
+
envLines.push(`${key}=${val}`);
|
|
361
|
+
}
|
|
362
|
+
// Also write any explicitly provided vars not already declared as secrets
|
|
363
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
364
|
+
if (!generated.secretKeys.includes(k)) {
|
|
365
|
+
envLines.push(`${k}=${v.replace(/[\r\n]/g, '')}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const envPath = path.join(appDir, '.env');
|
|
369
|
+
try {
|
|
370
|
+
fs.writeFileSync(envPath, envLines.join('\n') + '\n', { mode: 0o600 });
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
throw new Error(`Failed to write .env: ${err.message}`);
|
|
374
|
+
}
|
|
375
|
+
// ── Create socket files ───────────────────────────────────────────────
|
|
376
|
+
// Use homedir so sockets are on the host-mounted volume and visible to remote
|
|
377
|
+
// Docker daemons (e.g. docker-builder DinD) via a shared bind mount.
|
|
378
|
+
const SOCK_DIR = path.join(os.homedir(), '.claude-gateway', 'sockets');
|
|
379
|
+
if (generated.sockets.length > 0) {
|
|
380
|
+
fs.mkdirSync(SOCK_DIR, { recursive: true });
|
|
381
|
+
}
|
|
382
|
+
for (const sock of generated.sockets) {
|
|
383
|
+
const sockPath = sock.hostSocketPath;
|
|
384
|
+
try {
|
|
385
|
+
await this.callbacks.startSocket(sockPath, sock, sock.scripts, appDir);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
throw new Error(`Failed to start socket for service "${sock.service}": ${err.message}`);
|
|
389
|
+
}
|
|
390
|
+
this.log(job, `Socket ready: ${path.basename(sockPath)}`);
|
|
391
|
+
}
|
|
392
|
+
// ── Register in apps.json (status: building) ──────────────────────────
|
|
393
|
+
this.log(job, 'Registering app');
|
|
394
|
+
const socketMap = {};
|
|
395
|
+
for (const s of generated.sockets) {
|
|
396
|
+
socketMap[s.service] = s.hostSocketPath;
|
|
397
|
+
}
|
|
398
|
+
const portEntries = generated.ports.map((p) => ({
|
|
399
|
+
name: p.name,
|
|
400
|
+
service: p.service,
|
|
401
|
+
hostPort: p.hostPort,
|
|
402
|
+
containerPort: p.containerPort,
|
|
403
|
+
type: p.type,
|
|
404
|
+
rateLimit: p.rateLimit,
|
|
405
|
+
}));
|
|
406
|
+
// ── Agent path detection + service injection ─────────────────────────
|
|
407
|
+
let agentPaths;
|
|
408
|
+
if (generated.agentDeclaration && this.agentManager) {
|
|
409
|
+
this.log(job, 'Detecting agent binary paths');
|
|
410
|
+
agentPaths = this.agentManager.detectAgentPaths();
|
|
411
|
+
}
|
|
412
|
+
const entry = {
|
|
413
|
+
name: appName,
|
|
414
|
+
version,
|
|
415
|
+
commit,
|
|
416
|
+
githubUrl,
|
|
417
|
+
installPath: appDir,
|
|
418
|
+
ports: portEntries,
|
|
419
|
+
sockets: socketMap,
|
|
420
|
+
installedAt: new Date().toISOString(),
|
|
421
|
+
updatedAt: new Date().toISOString(),
|
|
422
|
+
status: 'building',
|
|
423
|
+
source,
|
|
424
|
+
...(generated.agentDeclaration !== null ? { agentDeclaration: generated.agentDeclaration } : {}),
|
|
425
|
+
...(agentPaths ? { agentPaths } : {}),
|
|
426
|
+
};
|
|
427
|
+
// Inject agent service into docker-compose.yml before build
|
|
428
|
+
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
429
|
+
this.agentManager.injectAgentService(entry);
|
|
430
|
+
this.log(job, `Agent service injected for ${generated.agentDeclaration.name}`);
|
|
431
|
+
// Pre-pull the agent base image so compose up --wait doesn't time out during pull
|
|
432
|
+
this.log(job, 'Pre-pulling agent base image');
|
|
433
|
+
try {
|
|
434
|
+
this.run(['docker', 'pull', 'debian:stable-slim'], appDir, 300000);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// non-fatal — compose up will attempt its own pull
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
await this.registry.upsert(entry);
|
|
441
|
+
// ── Create agent workspace symlink + config.json entry (before compose up) ──
|
|
442
|
+
// Symlink is created early so it's visible during the container startup wait
|
|
443
|
+
// and so the gateway can hot-reload the agent config while containers spin up.
|
|
444
|
+
if (generated.agentDeclaration && this.agentManager) {
|
|
445
|
+
await this.agentManager.upsertAgent(entry);
|
|
446
|
+
registeredAgentName = generated.agentDeclaration.name;
|
|
447
|
+
this.log(job, `Agent "${generated.agentDeclaration.name}" registered`);
|
|
448
|
+
await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
// ── docker compose build ──────────────────────────────────────────────
|
|
452
|
+
this.log(job, 'Building images');
|
|
453
|
+
this.run(['docker', 'compose', '-p', appName, 'build'], appDir, 600000);
|
|
454
|
+
// ── docker compose up -d ──────────────────────────────────────────────
|
|
455
|
+
this.log(job, 'Starting containers');
|
|
456
|
+
this.composeUp(appName, appDir, job);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
this.log(job, 'Build/start failed — rolling back');
|
|
460
|
+
throw err; // outer catch handles full cleanup
|
|
461
|
+
}
|
|
462
|
+
// ── Update status to running ──────────────────────────────────────────
|
|
463
|
+
await this.registry.updateStatus(appName, 'running');
|
|
464
|
+
this.log(job, 'Containers healthy');
|
|
465
|
+
// ── Register proxy routes ─────────────────────────────────────────────
|
|
466
|
+
this.callbacks.registerRoutes(appName, generated.ports);
|
|
467
|
+
// ── Build result ──────────────────────────────────────────────────────
|
|
468
|
+
const proxyUrls = {};
|
|
469
|
+
for (const p of generated.ports) {
|
|
470
|
+
proxyUrls[p.name] = `/app/${appName}/${p.name}/`;
|
|
471
|
+
}
|
|
472
|
+
const result = {
|
|
473
|
+
appName,
|
|
474
|
+
proxyUrls,
|
|
475
|
+
secretKeys: generated.secretKeys,
|
|
476
|
+
agentDeclaration: generated.agentDeclaration,
|
|
477
|
+
};
|
|
478
|
+
job.status = 'completed';
|
|
479
|
+
job.result = result;
|
|
480
|
+
job.updatedAt = Date.now();
|
|
481
|
+
this.log(job, `Install complete: ${JSON.stringify(proxyUrls)}`);
|
|
482
|
+
this.installingNames.delete(appName);
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
// Outer rollback: clean up appDir and all registered resources
|
|
486
|
+
this.installingNames.delete(appName);
|
|
487
|
+
this.installingNames.delete(tentativeName);
|
|
488
|
+
await this.registry.remove(appName).catch(() => { });
|
|
489
|
+
if (registeredAgentName && this.agentManager) {
|
|
490
|
+
await this.agentManager.deleteAgentByName(registeredAgentName).catch(() => { });
|
|
491
|
+
}
|
|
492
|
+
this.callbacks.stopSockets(appName);
|
|
493
|
+
this.callbacks.deregisterRoutes(appName);
|
|
494
|
+
try {
|
|
495
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all', '--volumes'], appDir, 60000);
|
|
496
|
+
}
|
|
497
|
+
catch { /* containers may not have started yet */ }
|
|
498
|
+
try {
|
|
499
|
+
const stat = fs.lstatSync(appDir);
|
|
500
|
+
if (stat.isSymbolicLink()) {
|
|
501
|
+
fs.unlinkSync(appDir);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
fs.rmSync(appDir, { recursive: true, force: true });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch { /* already gone */ }
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// ─── Update pipeline ──────────────────────────────────────────────────────
|
|
512
|
+
async runUpdate(job, appName) {
|
|
513
|
+
job.status = 'running';
|
|
514
|
+
job.updatedAt = Date.now();
|
|
515
|
+
const entry = await this.registry.get(appName);
|
|
516
|
+
if (!entry)
|
|
517
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
518
|
+
if (entry.source !== 'registry') {
|
|
519
|
+
throw new Error('Only registry-installed apps can be updated via this endpoint');
|
|
520
|
+
}
|
|
521
|
+
// Resolve latest version
|
|
522
|
+
const app = await this.registryClient.findApp(appName);
|
|
523
|
+
if (!app)
|
|
524
|
+
throw new Error(`App "${appName}" not found in registry`);
|
|
525
|
+
const latest = selectLatest(app.versions);
|
|
526
|
+
if (!latest)
|
|
527
|
+
throw new Error(`No versions available for "${appName}"`);
|
|
528
|
+
if (latest.commit === entry.commit) {
|
|
529
|
+
job.status = 'completed';
|
|
530
|
+
job.result = {
|
|
531
|
+
appName,
|
|
532
|
+
proxyUrls: {},
|
|
533
|
+
secretKeys: [],
|
|
534
|
+
agentDeclaration: entry.agentDeclaration ?? null,
|
|
535
|
+
};
|
|
536
|
+
job.updatedAt = Date.now();
|
|
537
|
+
this.log(job, `Already at latest version ${entry.version}`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.log(job, `Updating ${appName} from ${entry.version} → ${latest.version}`);
|
|
541
|
+
const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
|
|
542
|
+
try {
|
|
543
|
+
// ── Shallow fetch of specific commit into tmp dir ─────────────────────
|
|
544
|
+
this.log(job, `Cloning ${app.repo}`);
|
|
545
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
546
|
+
this.run(['git', 'init'], tmpDir);
|
|
547
|
+
this.run(['git', 'remote', 'add', 'origin', app.repo], tmpDir);
|
|
548
|
+
this.run(['git', 'fetch', '--depth', '1', 'origin', latest.commit], tmpDir);
|
|
549
|
+
this.run(['git', 'checkout', 'FETCH_HEAD'], tmpDir);
|
|
550
|
+
const yamlContent = fs.readFileSync(path.join(tmpDir, 'app.yaml'), 'utf-8');
|
|
551
|
+
const appYaml = (0, compose_generator_1.parseAppYaml)(yamlContent, tmpDir);
|
|
552
|
+
const composePath = path.join(tmpDir, 'docker-compose.yml');
|
|
553
|
+
const generated = (0, compose_generator_1.generateCompose)(appYaml, appName, tmpDir, composePath);
|
|
554
|
+
for (const w of generated.warnings) {
|
|
555
|
+
this.log(job, `Warning: ${w}`);
|
|
556
|
+
}
|
|
557
|
+
// ── Copy .env from old install to preserve secrets ───────────────────
|
|
558
|
+
const oldEnvPath = path.join(entry.installPath, '.env');
|
|
559
|
+
if (fs.existsSync(oldEnvPath)) {
|
|
560
|
+
fs.copyFileSync(oldEnvPath, path.join(tmpDir, '.env'));
|
|
561
|
+
}
|
|
562
|
+
// ── Detect agent paths + inject agent service if needed ───────────────
|
|
563
|
+
let agentPaths = entry.agentPaths;
|
|
564
|
+
if (generated.agentDeclaration && this.agentManager && !agentPaths) {
|
|
565
|
+
agentPaths = this.agentManager.detectAgentPaths();
|
|
566
|
+
}
|
|
567
|
+
const newEntry = {
|
|
568
|
+
...entry,
|
|
569
|
+
version: latest.version,
|
|
570
|
+
commit: latest.commit,
|
|
571
|
+
installPath: tmpDir,
|
|
572
|
+
...(generated.agentDeclaration !== null ? { agentDeclaration: generated.agentDeclaration } : {}),
|
|
573
|
+
...(agentPaths ? { agentPaths } : {}),
|
|
574
|
+
};
|
|
575
|
+
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
576
|
+
this.agentManager.injectAgentService(newEntry);
|
|
577
|
+
}
|
|
578
|
+
// ── Build new images in tmp dir ───────────────────────────────────────
|
|
579
|
+
this.log(job, 'Building new images');
|
|
580
|
+
this.run(['docker', 'compose', '-p', appName, 'build'], tmpDir, 600000);
|
|
581
|
+
// ── Backup MEMORY.md before any disruption ────────────────────────────
|
|
582
|
+
let memoryBackup = null;
|
|
583
|
+
if (entry.agentDeclaration && this.agentManager) {
|
|
584
|
+
memoryBackup = this.agentManager.backupMemory(entry.agentDeclaration.name);
|
|
585
|
+
if (memoryBackup !== null) {
|
|
586
|
+
this.log(job, 'MEMORY.md backed up');
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── Deregister old routes before taking down containers ───────────────
|
|
590
|
+
this.callbacks.deregisterRoutes(appName);
|
|
591
|
+
this.callbacks.stopSockets(appName);
|
|
592
|
+
// ── Bring old containers down (keeps images for rollback) ─────────────
|
|
593
|
+
this.log(job, 'Stopping old containers');
|
|
594
|
+
this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
|
|
595
|
+
// ── Start new containers ──────────────────────────────────────────────
|
|
596
|
+
this.log(job, 'Starting new containers');
|
|
597
|
+
try {
|
|
598
|
+
this.composeUp(appName, tmpDir, job);
|
|
599
|
+
}
|
|
600
|
+
catch (upErr) {
|
|
601
|
+
// Rollback: bring old containers back up from old install path
|
|
602
|
+
this.log(job, 'New containers failed — rolling back to previous version');
|
|
603
|
+
let rollbackFailed = false;
|
|
604
|
+
try {
|
|
605
|
+
this.run(['docker', 'compose', '-p', appName, 'up', '-d'], entry.installPath, 120000);
|
|
606
|
+
this.callbacks.registerRoutes(appName, entry.ports.map((p) => ({
|
|
607
|
+
name: p.name,
|
|
608
|
+
service: p.service,
|
|
609
|
+
hostPort: p.hostPort,
|
|
610
|
+
containerPort: p.containerPort,
|
|
611
|
+
type: p.type,
|
|
612
|
+
rateLimit: p.rateLimit,
|
|
613
|
+
})));
|
|
614
|
+
await this.registry.updateStatus(appName, 'running');
|
|
615
|
+
}
|
|
616
|
+
catch (rollbackErr) {
|
|
617
|
+
rollbackFailed = true;
|
|
618
|
+
this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
|
|
619
|
+
}
|
|
620
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
621
|
+
if (rollbackFailed) {
|
|
622
|
+
throw new Error(`Update failed and rollback also failed — app "${appName}" may be in a broken state. Check job logs for details.`);
|
|
623
|
+
}
|
|
624
|
+
throw upErr;
|
|
625
|
+
}
|
|
626
|
+
// ── Swap dirs ─────────────────────────────────────────────────────────
|
|
627
|
+
this.log(job, 'Swapping app directories');
|
|
628
|
+
const finalDir = path.join(this.appsDir, appName);
|
|
629
|
+
const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
|
|
630
|
+
fs.renameSync(finalDir, oldBackupDir);
|
|
631
|
+
fs.renameSync(tmpDir, finalDir);
|
|
632
|
+
// ── Restore MEMORY.md ─────────────────────────────────────────────────
|
|
633
|
+
if (memoryBackup !== null && generated.agentDeclaration && this.agentManager) {
|
|
634
|
+
this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
|
|
635
|
+
this.log(job, 'MEMORY.md restored');
|
|
636
|
+
}
|
|
637
|
+
// ── Update registry ───────────────────────────────────────────────────
|
|
638
|
+
const finalEntry = {
|
|
639
|
+
...newEntry,
|
|
640
|
+
installPath: finalDir,
|
|
641
|
+
updatedAt: new Date().toISOString(),
|
|
642
|
+
status: 'running',
|
|
643
|
+
};
|
|
644
|
+
await this.registry.upsert(finalEntry);
|
|
645
|
+
// ── Re-create agent symlink + config.json entry ───────────────────────
|
|
646
|
+
if (generated.agentDeclaration && this.agentManager) {
|
|
647
|
+
await this.agentManager.upsertAgent(finalEntry);
|
|
648
|
+
this.log(job, `Agent "${generated.agentDeclaration.name}" re-registered`);
|
|
649
|
+
await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
|
|
650
|
+
}
|
|
651
|
+
// ── Re-register proxy routes + sockets ───────────────────────────────
|
|
652
|
+
this.callbacks.registerRoutes(appName, generated.ports);
|
|
653
|
+
for (const sock of generated.sockets) {
|
|
654
|
+
const sockPath = sock.hostSocketPath;
|
|
655
|
+
await this.callbacks.startSocket(sockPath, sock, sock.scripts, finalDir);
|
|
656
|
+
}
|
|
657
|
+
// ── Clean up old backup (best-effort) ─────────────────────────────────
|
|
658
|
+
try {
|
|
659
|
+
this.run(['docker', 'compose', '-p', appName, 'down', '--rmi', 'all'], oldBackupDir, 120000);
|
|
660
|
+
}
|
|
661
|
+
catch { /* non-fatal */ }
|
|
662
|
+
fs.rmSync(oldBackupDir, { recursive: true, force: true });
|
|
663
|
+
// ── Build result ──────────────────────────────────────────────────────
|
|
664
|
+
const proxyUrls = {};
|
|
665
|
+
for (const p of generated.ports) {
|
|
666
|
+
proxyUrls[p.name] = `/app/${appName}/${p.name}/`;
|
|
667
|
+
}
|
|
668
|
+
job.status = 'completed';
|
|
669
|
+
job.result = {
|
|
670
|
+
appName,
|
|
671
|
+
proxyUrls,
|
|
672
|
+
secretKeys: generated.secretKeys,
|
|
673
|
+
agentDeclaration: generated.agentDeclaration,
|
|
674
|
+
};
|
|
675
|
+
job.updatedAt = Date.now();
|
|
676
|
+
this.log(job, `Update complete → ${latest.version}`);
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
if (fs.existsSync(tmpDir)) {
|
|
680
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
681
|
+
}
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async resolveSource(job, options, defaultVersion) {
|
|
686
|
+
if (options.registryApp) {
|
|
687
|
+
// Registry install
|
|
688
|
+
const ver = await this.registryClient.findVersion(options.registryApp, options.version ?? '');
|
|
689
|
+
if (!ver && options.version) {
|
|
690
|
+
// Try to find the specific version
|
|
691
|
+
const app = await this.registryClient.findApp(options.registryApp);
|
|
692
|
+
if (!app)
|
|
693
|
+
throw new Error(`App "${options.registryApp}" not found in registry`);
|
|
694
|
+
const v = app.versions.find((v) => v.version === options.version);
|
|
695
|
+
if (!v)
|
|
696
|
+
throw new Error(`Version "${options.version}" not found for "${options.registryApp}"`);
|
|
697
|
+
return {
|
|
698
|
+
appName: options.registryApp,
|
|
699
|
+
commit: v.commit,
|
|
700
|
+
githubUrl: app.repo,
|
|
701
|
+
source: 'registry',
|
|
702
|
+
version: v.version,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
if (!ver) {
|
|
706
|
+
// No version specified — use latest
|
|
707
|
+
const app = await this.registryClient.findApp(options.registryApp);
|
|
708
|
+
if (!app)
|
|
709
|
+
throw new Error(`App "${options.registryApp}" not found in registry`);
|
|
710
|
+
const latest = selectLatest(app.versions);
|
|
711
|
+
if (!latest)
|
|
712
|
+
throw new Error(`No versions available for "${options.registryApp}"`);
|
|
713
|
+
this.log(job, `Using latest version ${latest.version}`);
|
|
714
|
+
return {
|
|
715
|
+
appName: options.registryApp,
|
|
716
|
+
commit: latest.commit,
|
|
717
|
+
githubUrl: app.repo,
|
|
718
|
+
source: 'registry',
|
|
719
|
+
version: latest.version,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
appName: options.registryApp,
|
|
724
|
+
commit: ver.ver.commit,
|
|
725
|
+
githubUrl: ver.app.repo,
|
|
726
|
+
source: 'registry',
|
|
727
|
+
version: ver.ver.version,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (options.githubUrl) {
|
|
731
|
+
if (!GITHUB_URL_RE.test(options.githubUrl)) {
|
|
732
|
+
throw new Error(`githubUrl must be a valid https://github.com/<owner>/<repo> URL`);
|
|
733
|
+
}
|
|
734
|
+
let commit;
|
|
735
|
+
if (options.commit) {
|
|
736
|
+
if (!COMMIT_RE.test(options.commit)) {
|
|
737
|
+
throw new Error(`commit must be a 40-char hex string — branch names are not allowed`);
|
|
738
|
+
}
|
|
739
|
+
commit = options.commit;
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
// Auto-resolve HEAD commit via git ls-remote
|
|
743
|
+
this.log(job, `Resolving HEAD commit for ${options.githubUrl}`);
|
|
744
|
+
const { stdout } = this.run(['git', 'ls-remote', options.githubUrl, 'HEAD'], process.cwd());
|
|
745
|
+
const match = stdout.trim().match(/^([0-9a-f]{40})\s+HEAD/);
|
|
746
|
+
if (!match)
|
|
747
|
+
throw new Error(`Could not resolve HEAD commit for ${options.githubUrl}`);
|
|
748
|
+
commit = match[1];
|
|
749
|
+
this.log(job, `Resolved HEAD → ${commit.slice(0, 8)}`);
|
|
750
|
+
}
|
|
751
|
+
const appName = options.githubUrl.split('/').pop()?.replace(/\.git$/, '') ?? 'app';
|
|
752
|
+
return {
|
|
753
|
+
appName,
|
|
754
|
+
commit,
|
|
755
|
+
githubUrl: options.githubUrl,
|
|
756
|
+
source: 'custom',
|
|
757
|
+
version: defaultVersion,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
throw new Error('Install requires one of: registryApp, githubUrl+commit, or localPath');
|
|
761
|
+
}
|
|
762
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
763
|
+
/** Remove a directory recursively. Falls back to `sudo rm -rf` for root-owned files. */
|
|
764
|
+
rmrf(dirPath) {
|
|
765
|
+
try {
|
|
766
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
if (err.code === 'EACCES') {
|
|
770
|
+
console.warn(`[installer] EACCES removing "${dirPath}" — falling back to sudo rm -rf`);
|
|
771
|
+
this.run(['sudo', 'rm', '-rf', dirPath]);
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
throw err;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Stop conflicting containers then run `docker compose up -d --wait`.
|
|
780
|
+
* Captures container logs into the job on failure before rethrowing.
|
|
781
|
+
* job is optional — when omitted (e.g. startStopRestart) logs go to stderr.
|
|
782
|
+
*/
|
|
783
|
+
composeUp(appName, dir, job) {
|
|
784
|
+
this.stopConflictingContainers(appName);
|
|
785
|
+
try {
|
|
786
|
+
this.run(['docker', 'compose', '-p', appName, 'up', '-d', '--wait'], dir, 600000);
|
|
787
|
+
}
|
|
788
|
+
catch (upErr) {
|
|
789
|
+
if (job) {
|
|
790
|
+
try {
|
|
791
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'logs', '--no-color', '--tail=50'], dir, 10000);
|
|
792
|
+
if (stdout.trim()) {
|
|
793
|
+
for (const line of stdout.trim().split('\n')) {
|
|
794
|
+
this.log(job, ` ${line}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch { /* ignore log capture errors */ }
|
|
799
|
+
}
|
|
800
|
+
throw upErr;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/** Evict terminal jobs older than 24 hours to bound memory growth. */
|
|
804
|
+
pruneOldJobs() {
|
|
805
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
806
|
+
for (const [id, job] of this.jobs) {
|
|
807
|
+
if ((job.status === 'completed' || job.status === 'failed') && job.updatedAt < cutoff) {
|
|
808
|
+
this.jobs.delete(id);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Stop and remove any containers whose name matches `${appName}-*` but belong
|
|
814
|
+
* to a different compose project. Prevents "container name already in use" when
|
|
815
|
+
* the same app was previously started from a different path/project name.
|
|
816
|
+
*/
|
|
817
|
+
stopConflictingContainers(appName) {
|
|
818
|
+
let output;
|
|
819
|
+
try {
|
|
820
|
+
const result = this.run(['docker', 'ps', '-a',
|
|
821
|
+
'--filter', `name=^${appName}-`,
|
|
822
|
+
'--format', '{{.ID}}\t{{.Names}}\t{{.Label "com.docker.compose.project"}}'], os.tmpdir(), 15000);
|
|
823
|
+
output = result.stdout.trim();
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (!output)
|
|
829
|
+
return;
|
|
830
|
+
for (const line of output.split('\n')) {
|
|
831
|
+
const parts = line.split('\t');
|
|
832
|
+
const id = parts[0];
|
|
833
|
+
const project = parts[2];
|
|
834
|
+
if (!id || !project || project === appName)
|
|
835
|
+
continue;
|
|
836
|
+
try {
|
|
837
|
+
this.run(['docker', 'stop', id], os.tmpdir(), 15000);
|
|
838
|
+
}
|
|
839
|
+
catch { /* ignore */ }
|
|
840
|
+
try {
|
|
841
|
+
this.run(['docker', 'rm', id], os.tmpdir(), 15000);
|
|
842
|
+
}
|
|
843
|
+
catch { /* ignore */ }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
run(args, cwd, timeoutMs = 30000) {
|
|
847
|
+
const opts = {
|
|
848
|
+
encoding: 'utf-8',
|
|
849
|
+
timeout: timeoutMs,
|
|
850
|
+
...(cwd ? { cwd } : {}),
|
|
851
|
+
};
|
|
852
|
+
const result = this.spawn(args[0], args.slice(1), opts);
|
|
853
|
+
if (result.status !== 0) {
|
|
854
|
+
const errDetail = (result.stderr.trim() || result.stdout.trim()).slice(-2000);
|
|
855
|
+
throw new Error(`Command failed: ${args[0]} ${args[1]} — ${errDetail}`);
|
|
856
|
+
}
|
|
857
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
858
|
+
}
|
|
859
|
+
log(job, message) {
|
|
860
|
+
job.logs.push(`[${new Date().toISOString()}] ${message}`);
|
|
861
|
+
job.updatedAt = Date.now();
|
|
862
|
+
}
|
|
863
|
+
failJob(job, error) {
|
|
864
|
+
job.status = 'failed';
|
|
865
|
+
job.error = error;
|
|
866
|
+
job.updatedAt = Date.now();
|
|
867
|
+
this.log(job, `FAILED: ${error}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
exports.AppInstaller = AppInstaller;
|
|
871
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
872
|
+
/**
|
|
873
|
+
* Select the latest version from a registry versions array.
|
|
874
|
+
* Sorts by approved_at (ISO string comparison is correct for ISO dates).
|
|
875
|
+
* Falls back to last array element when approved_at is absent.
|
|
876
|
+
*/
|
|
877
|
+
function selectLatest(versions) {
|
|
878
|
+
if (versions.length === 0)
|
|
879
|
+
return undefined;
|
|
880
|
+
const withDate = versions.filter((v) => v.approved_at);
|
|
881
|
+
if (withDate.length > 0) {
|
|
882
|
+
return withDate.reduce((a, b) => (a.approved_at > b.approved_at ? a : b));
|
|
883
|
+
}
|
|
884
|
+
return versions[versions.length - 1];
|
|
885
|
+
}
|
|
886
|
+
// ─── Default spawn implementation ─────────────────────────────────────────────
|
|
887
|
+
function defaultSpawn(cmd, args, opts) {
|
|
888
|
+
const result = (0, node_child_process_1.spawnSync)(cmd, args, {
|
|
889
|
+
encoding: 'utf-8',
|
|
890
|
+
...opts,
|
|
891
|
+
});
|
|
892
|
+
return {
|
|
893
|
+
stdout: result.stdout ?? '',
|
|
894
|
+
stderr: result.stderr ?? '',
|
|
895
|
+
status: result.status,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
//# sourceMappingURL=installer.js.map
|