@0xmaxma/claude-gateway 1.5.4 → 1.5.6

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.
@@ -1,7 +1,7 @@
1
1
  import { SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
2
2
  import { AppsRegistry, AppEntry } from './registry';
3
3
  import { RegistryClient } from './registry-client';
4
- import { ComposePort, ComposeSocket } from './compose-generator';
4
+ import { ComposePort, ComposeSocket, GeneratedKey, AgentDeclaration } from './compose-generator';
5
5
  import { AgentManager } from './agent-manager';
6
6
  export interface InstallOptions {
7
7
  /** Registry app name (Mode A — registry install) */
@@ -16,6 +16,15 @@ export interface InstallOptions {
16
16
  localPath?: string;
17
17
  /** Pre-supplied env vars (secrets that would otherwise be prompted) */
18
18
  envVars?: Record<string, string>;
19
+ /** Host-port overrides per port name (default host comes from app.yaml) */
20
+ portOverrides?: Record<string, number>;
21
+ }
22
+ /** Options for {@link AppInstaller.reconfigure}. */
23
+ export interface ReconfigureOptions {
24
+ /** Env vars to merge into the existing .env (unsent keys are preserved) */
25
+ envVars?: Record<string, string>;
26
+ /** Host-port overrides per port name (unset = app.yaml default) */
27
+ portOverrides?: Record<string, number>;
19
28
  }
20
29
  export interface InstallResult {
21
30
  appName: string;
@@ -26,6 +35,24 @@ export interface InstallResult {
26
35
  name: string;
27
36
  } | null;
28
37
  }
38
+ /**
39
+ * Read-only preview of an install source, computed by fetching and parsing the
40
+ * app.yaml BEFORE any install. Surfaces the secrets an operator must supply
41
+ * ({@link InspectResult.secretKeys}) and the ones the gateway auto-generates
42
+ * ({@link InspectResult.generatedKeys}) so the pre-install summary is accurate
43
+ * even for a GitHub-URL app that has no registry entry.
44
+ */
45
+ export interface InspectResult {
46
+ name: string;
47
+ version: string;
48
+ source: AppEntry['source'];
49
+ commit: string;
50
+ secretKeys: string[];
51
+ generatedKeys: GeneratedKey[];
52
+ ports: ComposePort[];
53
+ agentDeclaration: AgentDeclaration | null;
54
+ warnings: string[];
55
+ }
29
56
  export interface JobState {
30
57
  id: string;
31
58
  status: 'pending' | 'running' | 'completed' | 'failed';
@@ -85,8 +112,36 @@ export declare class AppInstaller {
85
112
  /** Start an async install job. Returns jobId immediately. */
86
113
  install(options: InstallOptions): string;
87
114
  getJob(jobId: string): JobState | undefined;
115
+ /**
116
+ * Read-only inspection of an install source — no install side effects, no
117
+ * files left behind. Resolves the repo + commit, fetches the app.yaml
118
+ * (shallow clone into a tmp dir for registry/GitHub sources; direct read for
119
+ * a local path), parses it, and returns the metadata needed for an accurate
120
+ * pre-install summary: the required secrets (`secretKeys`, must be prompted)
121
+ * and the self-generated secrets (`generatedKeys`, auto-filled at install).
122
+ *
123
+ * This is what lets a GitHub-URL install surface its required secrets before
124
+ * installing — such apps have no registry entry, so `browse_registry` cannot
125
+ * reveal them.
126
+ */
127
+ inspectSource(options: InstallOptions): Promise<InspectResult>;
128
+ /**
129
+ * Parse the app.yaml in `appDir` and derive the pre-install metadata without
130
+ * mutating `appDir`. generateCompose writes the compose file to its output
131
+ * path, so we point it at a throwaway tmp file (removed here) to keep the
132
+ * inspection read-only even for a local source.
133
+ */
134
+ private inspectDir;
88
135
  /** Start an async update job. Returns jobId immediately. */
89
136
  update(appName: string): string;
137
+ /**
138
+ * Start an async reconfigure job — merge env vars and/or override host ports
139
+ * on an already-installed app, then force-recreate the container. Named
140
+ * volumes (and their data) survive because this is an `up --force-recreate`,
141
+ * never a `down -v`. Returns jobId immediately. Throws synchronously (409)
142
+ * if the app is mid install/update/reconfigure.
143
+ */
144
+ reconfigure(appName: string, options: ReconfigureOptions): string;
90
145
  uninstall(appName: string): Promise<void>;
91
146
  startStopRestart(appName: string, action: 'start' | 'stop' | 'restart'): Promise<void>;
92
147
  /**
@@ -130,6 +185,34 @@ export declare class AppInstaller {
130
185
  latestCommit: string | null;
131
186
  updateable: boolean;
132
187
  }>;
188
+ /**
189
+ * Reconfigure an installed app: merge env vars and/or override host ports,
190
+ * then force-recreate the container in place. No clone, no dir swap — the app
191
+ * stays at its current commit/appDir; only its .env and (optionally) its
192
+ * compose port mappings change. Named volumes and their data survive because
193
+ * this is an `up --force-recreate`, never a `down -v`.
194
+ */
195
+ private runReconfigure;
196
+ /**
197
+ * Return an error message if any of the given host ports is already bound by a
198
+ * *different* installed app, else null. Shared by install and reconfigure so
199
+ * the cross-app collision rule stays in one place.
200
+ */
201
+ findHostPortCollision(selfName: string, ports: Array<{
202
+ name: string;
203
+ hostPort: number;
204
+ }>): Promise<string | null>;
205
+ /**
206
+ * Write the app's .env file (mode 0600). Emits, in order: BASE_PATH for web
207
+ * ports, declared secretKeys, self-generating generatedKeys (a fresh random
208
+ * value unless already present in `envVars` — operator-pinned on install, or
209
+ * the existing value on reconfigure), then any extra vars. Returns the names
210
+ * of freshly generated secrets (for logging — never the values). Shared by
211
+ * runInstall and runReconfigure so the .env format cannot drift.
212
+ */
213
+ private writeEnvFile;
214
+ /** Parse an app's existing .env into a key→value map (empty if absent). */
215
+ private readEnvFile;
133
216
  /**
134
217
  * Resolve the repo URL and target commit to update an installed app to.
135
218
  * - `registry`: latest published version via the registry client.
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../../src/apps/installer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../../src/apps/installer.ts"],"names":[],"mappings":"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"}
@@ -99,6 +99,80 @@ class AppInstaller {
99
99
  getJob(jobId) {
100
100
  return this.jobs.get(jobId);
101
101
  }
102
+ /**
103
+ * Read-only inspection of an install source — no install side effects, no
104
+ * files left behind. Resolves the repo + commit, fetches the app.yaml
105
+ * (shallow clone into a tmp dir for registry/GitHub sources; direct read for
106
+ * a local path), parses it, and returns the metadata needed for an accurate
107
+ * pre-install summary: the required secrets (`secretKeys`, must be prompted)
108
+ * and the self-generated secrets (`generatedKeys`, auto-filled at install).
109
+ *
110
+ * This is what lets a GitHub-URL install surface its required secrets before
111
+ * installing — such apps have no registry entry, so `browse_registry` cannot
112
+ * reveal them.
113
+ */
114
+ async inspectSource(options) {
115
+ // Mode B — local path: read app.yaml directly, no clone.
116
+ if (options.localPath) {
117
+ const resolved = path.resolve(options.localPath);
118
+ if (!fs.existsSync(path.join(resolved, 'app.yaml'))) {
119
+ throw new Error(`app.yaml not found in "${resolved}"`);
120
+ }
121
+ return this.inspectDir(resolved, 'local', 'local');
122
+ }
123
+ // Mode A — registry or GitHub: resolve, then shallow-clone into a tmp dir.
124
+ // Passing a null job keeps resolveSource silent (there is no install job).
125
+ const resolved = await this.resolveSource(null, options, options.version ?? '0.0.0');
126
+ const tmpDir = path.join(os.tmpdir(), `cg-inspect-${crypto.randomUUID()}`);
127
+ try {
128
+ fs.mkdirSync(tmpDir, { recursive: true });
129
+ this.run(['git', 'init'], tmpDir);
130
+ this.run(['git', 'remote', 'add', 'origin', resolved.githubUrl], tmpDir);
131
+ this.run(['git', 'fetch', '--depth', '1', 'origin', resolved.commit], tmpDir);
132
+ this.run(['git', 'checkout', 'FETCH_HEAD'], tmpDir);
133
+ return this.inspectDir(tmpDir, resolved.source, resolved.commit, resolved.version);
134
+ }
135
+ finally {
136
+ try {
137
+ this.rmrf(tmpDir);
138
+ }
139
+ catch {
140
+ /* best-effort cleanup of a read-only tmp clone */
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Parse the app.yaml in `appDir` and derive the pre-install metadata without
146
+ * mutating `appDir`. generateCompose writes the compose file to its output
147
+ * path, so we point it at a throwaway tmp file (removed here) to keep the
148
+ * inspection read-only even for a local source.
149
+ */
150
+ inspectDir(appDir, source, commit, fallbackVersion) {
151
+ const appYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(path.join(appDir, 'app.yaml'), 'utf-8'), appDir);
152
+ const tmpCompose = path.join(os.tmpdir(), `cg-inspect-compose-${crypto.randomUUID()}.yml`);
153
+ try {
154
+ const generated = (0, compose_generator_1.generateCompose)(appYaml, appYaml.name, appDir, tmpCompose);
155
+ return {
156
+ name: appYaml.name,
157
+ version: appYaml.version || fallbackVersion || '0.0.0',
158
+ source,
159
+ commit,
160
+ secretKeys: generated.secretKeys,
161
+ generatedKeys: generated.generatedKeys,
162
+ ports: generated.ports,
163
+ agentDeclaration: generated.agentDeclaration,
164
+ warnings: generated.warnings,
165
+ };
166
+ }
167
+ finally {
168
+ try {
169
+ fs.rmSync(tmpCompose, { force: true });
170
+ }
171
+ catch {
172
+ /* best-effort cleanup of the throwaway compose file */
173
+ }
174
+ }
175
+ }
102
176
  /** Start an async update job. Returns jobId immediately. */
103
177
  update(appName) {
104
178
  this.pruneOldJobs();
@@ -122,6 +196,35 @@ class AppInstaller {
122
196
  });
123
197
  return jobId;
124
198
  }
199
+ /**
200
+ * Start an async reconfigure job — merge env vars and/or override host ports
201
+ * on an already-installed app, then force-recreate the container. Named
202
+ * volumes (and their data) survive because this is an `up --force-recreate`,
203
+ * never a `down -v`. Returns jobId immediately. Throws synchronously (409)
204
+ * if the app is mid install/update/reconfigure.
205
+ */
206
+ reconfigure(appName, options) {
207
+ this.pruneOldJobs();
208
+ if (this.installingNames.has(appName)) {
209
+ throw new Error(`App "${appName}" is already being installed or updated`);
210
+ }
211
+ this.installingNames.add(appName);
212
+ const jobId = crypto.randomUUID();
213
+ const job = {
214
+ id: jobId,
215
+ status: 'pending',
216
+ logs: [],
217
+ startedAt: Date.now(),
218
+ updatedAt: Date.now(),
219
+ };
220
+ this.jobs.set(jobId, job);
221
+ void this.runReconfigure(job, appName, options).catch((err) => {
222
+ this.failJob(job, err instanceof Error ? err.message : String(err));
223
+ }).finally(() => {
224
+ this.installingNames.delete(appName);
225
+ });
226
+ return jobId;
227
+ }
125
228
  async uninstall(appName) {
126
229
  const entry = await this.registry.get(appName);
127
230
  // Orphaned install: directory exists on disk but not in registry — clean up filesystem only
@@ -374,20 +477,11 @@ class AppInstaller {
374
477
  // ── Generate docker-compose.yml ───────────────────────────────────────
375
478
  this.log(job, 'Generating docker-compose.yml');
376
479
  const composePath = path.join(appDir, 'docker-compose.yml');
377
- const generated = (0, compose_generator_1.generateCompose)(appYaml, appName, appDir, composePath);
480
+ const generated = (0, compose_generator_1.generateCompose)(appYaml, appName, appDir, composePath, options.portOverrides);
378
481
  // Conflict check — host port uniqueness across all installed apps
379
- const installedApps = await this.registry.list();
380
- const usedHostPorts = new Map();
381
- for (const app of installedApps) {
382
- for (const port of app.ports) {
383
- usedHostPorts.set(port.hostPort, app.name);
384
- }
385
- }
386
- for (const port of generated.ports) {
387
- const owner = usedHostPorts.get(port.hostPort);
388
- if (owner) {
389
- throw new Error(`Host port ${port.hostPort} (port "${port.name}") is already used by app "${owner}"`);
390
- }
482
+ const collision = await this.findHostPortCollision(appName, generated.ports.map((p) => ({ name: p.name, hostPort: p.hostPort })));
483
+ if (collision) {
484
+ throw new Error(collision);
391
485
  }
392
486
  // Conflict check — agent name (if app declares an agent), inside install lock
393
487
  if (generated.agentDeclaration && this.agentManager) {
@@ -413,50 +507,10 @@ class AppInstaller {
413
507
  }
414
508
  // ── Write .env ────────────────────────────────────────────────────────
415
509
  this.log(job, 'Writing .env');
416
- const envVars = options.envVars ?? {};
417
- const envLines = [];
418
- // Inject BASE_PATH for web-type ports
419
- for (const port of generated.ports) {
420
- if (port.type === 'web') {
421
- envVars[`BASE_PATH`] = `/app/${appName}/${port.name}`;
422
- }
423
- }
424
- for (const key of generated.secretKeys) {
425
- const val = (envVars[key] ?? '').replace(/[\r\n]/g, '');
426
- envLines.push(`${key}=${val}`);
427
- }
428
- // Self-generating secrets: write a fresh random value unless the operator
429
- // pinned one via envVars. Log only the key names, never the values.
430
- const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
431
- const generatedNames = [];
432
- for (const g of generated.generatedKeys) {
433
- const pinned = envVars[g.key];
434
- let val;
435
- if (pinned !== undefined && pinned !== '') {
436
- val = pinned.replace(/[\r\n]/g, '');
437
- }
438
- else {
439
- val = (0, compose_generator_1.generateSecretValue)(g.encoding, g.bytes);
440
- generatedNames.push(g.key);
441
- }
442
- envLines.push(`${g.key}=${val}`);
443
- }
510
+ const generatedNames = this.writeEnvFile(appDir, appName, generated, options.envVars ?? {});
444
511
  if (generatedNames.length > 0) {
445
512
  this.log(job, `Generated secrets: ${generatedNames.join(', ')}`);
446
513
  }
447
- // Also write any explicitly provided vars not already declared as secrets/generated
448
- for (const [k, v] of Object.entries(envVars)) {
449
- if (!generated.secretKeys.includes(k) && !generatedKeySet.has(k)) {
450
- envLines.push(`${k}=${v.replace(/[\r\n]/g, '')}`);
451
- }
452
- }
453
- const envPath = path.join(appDir, '.env');
454
- try {
455
- fs.writeFileSync(envPath, envLines.join('\n') + '\n', { mode: 0o600 });
456
- }
457
- catch (err) {
458
- throw new Error(`Failed to write .env: ${err.message}`);
459
- }
460
514
  // ── Create socket files ───────────────────────────────────────────────
461
515
  // Use homedir so sockets are on the host-mounted volume and visible to remote
462
516
  // Docker daemons (e.g. docker-builder DinD) via a shared bind mount.
@@ -796,6 +850,266 @@ class AppInstaller {
796
850
  return { latestVersion: null, latestCommit: null, updateable: false };
797
851
  }
798
852
  }
853
+ /**
854
+ * Reconfigure an installed app: merge env vars and/or override host ports,
855
+ * then force-recreate the container in place. No clone, no dir swap — the app
856
+ * stays at its current commit/appDir; only its .env and (optionally) its
857
+ * compose port mappings change. Named volumes and their data survive because
858
+ * this is an `up --force-recreate`, never a `down -v`.
859
+ */
860
+ async runReconfigure(job, appName, options) {
861
+ job.status = 'running';
862
+ job.updatedAt = Date.now();
863
+ const entry = await this.registry.get(appName);
864
+ if (!entry)
865
+ throw new Error(`App "${appName}" is not installed`);
866
+ if (entry.source === 'local') {
867
+ throw new Error(`App "${appName}" is installed from a local path and cannot be reconfigured — reinstall from source instead`);
868
+ }
869
+ const appDir = entry.installPath;
870
+ const composePath = path.join(appDir, 'docker-compose.yml');
871
+ const portOverrides = options.portOverrides;
872
+ const hasPortChange = portOverrides !== undefined && Object.keys(portOverrides).length > 0;
873
+ // Parse the app's on-disk app.yaml (present from the original install/update).
874
+ const yamlPath = path.join(appDir, 'app.yaml');
875
+ if (!fs.existsSync(yamlPath)) {
876
+ throw new Error(`app.yaml not found for "${appName}" — cannot reconfigure`);
877
+ }
878
+ const appYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(yamlPath, 'utf-8'), appDir);
879
+ this.log(job, 'Preparing reconfigure');
880
+ // Snapshot the current on-disk state BEFORE any mutation so a failed
881
+ // recreate can be rolled back. Both an env-only and a port change rewrite
882
+ // .env and force-recreate the container; a port change additionally rewrites
883
+ // the live compose file and swaps proxy routes. If the new container never
884
+ // comes up the app would otherwise be left down (or, for a port change,
885
+ // unreachable with routes gone and compose/registry mismatched).
886
+ const envPath = path.join(appDir, '.env');
887
+ const oldComposeContent = hasPortChange && fs.existsSync(composePath) ? fs.readFileSync(composePath, 'utf-8') : null;
888
+ const oldEnvContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : null;
889
+ const oldPorts = entry.ports;
890
+ // Compute (and validate) the port metadata. Always generate to a TEMP file
891
+ // first: the live compose must not change until the overrides are validated
892
+ // (generateCompose checks them) and we are inside the guarded section below.
893
+ // Writing the live file here would leave the new ports on disk against the
894
+ // still-running old container if a later step (collision, agent inject)
895
+ // throws (finding F2). An env-only reconfigure never touches the compose.
896
+ let generated;
897
+ let newComposeContent;
898
+ {
899
+ const tmpCompose = path.join(os.tmpdir(), `cg-reconf-${appName}-${crypto.randomUUID()}.yml`);
900
+ try {
901
+ generated = (0, compose_generator_1.generateCompose)(appYaml, appName, appDir, tmpCompose, portOverrides);
902
+ newComposeContent = fs.readFileSync(tmpCompose, 'utf-8');
903
+ }
904
+ finally {
905
+ fs.rmSync(tmpCompose, { force: true });
906
+ }
907
+ }
908
+ // Host-port collision across other installed apps (only if ports changed).
909
+ // Runs before the live compose is touched, so a collision leaves nothing to
910
+ // undo on disk.
911
+ if (hasPortChange) {
912
+ const collision = await this.findHostPortCollision(appName, generated.ports.map((p) => ({ name: p.name, hostPort: p.hostPort })));
913
+ if (collision)
914
+ throw new Error(collision);
915
+ }
916
+ // Apply the reconfigure. Everything from here mutates live state (compose
917
+ // file, .env, proxy routes, the running container), so it is guarded: a
918
+ // reconfigure that fails to recreate is rolled back to the previous
919
+ // ports/compose/.env so the app stays reachable (planning §4.1 step 10 —
920
+ // best-effort reopen).
921
+ try {
922
+ // Swap in the newly-generated compose only now that we're inside the
923
+ // guard (finding F2). Regenerating drops the injected agent service, so we
924
+ // re-inject it. An env-only reconfigure leaves the compose untouched.
925
+ if (hasPortChange) {
926
+ this.log(job, 'Updating docker-compose.yml');
927
+ fs.writeFileSync(composePath, newComposeContent);
928
+ if (generated.agentDeclaration && this.agentManager) {
929
+ const agentPaths = entry.agentPaths ?? this.agentManager.detectAgentPaths();
930
+ this.agentManager.injectAgentService({ ...entry, agentPaths });
931
+ }
932
+ }
933
+ // Merge the new env vars onto the existing .env: keys not supplied are
934
+ // preserved, and existing generated-secret values are carried over rather
935
+ // than rotated (writeEnvFile treats an already-present value as pinned).
936
+ this.log(job, 'Writing .env');
937
+ const mergedEnv = { ...this.readEnvFile(appDir), ...(options.envVars ?? {}) };
938
+ this.writeEnvFile(appDir, appName, generated, mergedEnv);
939
+ // Deregister old proxy routes before the port mapping changes (the proxy is
940
+ // bound to the old hostPort).
941
+ if (hasPortChange) {
942
+ this.callbacks.deregisterRoutes(appName);
943
+ }
944
+ // Force-recreate so the container picks up the new .env / port mapping —
945
+ // compose does not detect an env_file content change on its own. This is an
946
+ // `up`, not a `down -v`, so named volumes (and their data) survive.
947
+ this.log(job, 'Recreating container');
948
+ this.composeUp(appName, appDir, job, { forceRecreate: true });
949
+ await this.registry.updateStatus(appName, 'running');
950
+ // Persist the reconfigure: always bump updatedAt so the registry reflects
951
+ // that the app was reconfigured; refresh the port mappings + re-register
952
+ // proxy routes only when a host port actually changed.
953
+ const updatedEntry = { ...entry, updatedAt: new Date().toISOString() };
954
+ if (hasPortChange) {
955
+ updatedEntry.ports = generated.ports.map((p) => ({
956
+ name: p.name,
957
+ service: p.service,
958
+ hostPort: p.hostPort,
959
+ containerPort: p.containerPort,
960
+ type: p.type,
961
+ rateLimit: p.rateLimit,
962
+ }));
963
+ }
964
+ await this.registry.upsert(updatedEntry);
965
+ if (hasPortChange) {
966
+ this.callbacks.registerRoutes(appName, generated.ports);
967
+ }
968
+ }
969
+ catch (reconfErr) {
970
+ // Roll back a failed reconfigure so the app stays reachable. Both a
971
+ // port change and an env-only change rewrite .env and force-recreate the
972
+ // container, so a bad value (failed healthcheck) or an unbindable port can
973
+ // leave the app down either way (finding F1). Restore the previous .env,
974
+ // restore the previous compose + re-register the old routes when a port
975
+ // change had swapped them, then bring the old container back on the old
976
+ // config. Best-effort: a failing rollback is logged, not thrown.
977
+ this.log(job, `Reconfigure failed — rolling back "${appName}"`);
978
+ try {
979
+ if (oldEnvContent !== null) {
980
+ fs.writeFileSync(envPath, oldEnvContent, { mode: 0o600 });
981
+ fs.chmodSync(envPath, 0o600);
982
+ }
983
+ if (hasPortChange && oldComposeContent !== null) {
984
+ fs.writeFileSync(composePath, oldComposeContent);
985
+ }
986
+ this.composeUp(appName, appDir, job, { forceRecreate: true });
987
+ if (hasPortChange) {
988
+ this.callbacks.registerRoutes(appName, oldPorts.map((p) => ({
989
+ name: p.name,
990
+ service: p.service,
991
+ hostPort: p.hostPort,
992
+ containerPort: p.containerPort,
993
+ type: p.type,
994
+ rateLimit: p.rateLimit,
995
+ })));
996
+ }
997
+ await this.registry.updateStatus(appName, 'running');
998
+ }
999
+ catch (rollbackErr) {
1000
+ this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
1001
+ }
1002
+ throw reconfErr;
1003
+ }
1004
+ const proxyUrls = {};
1005
+ for (const p of generated.ports) {
1006
+ proxyUrls[p.name] = `/app/${appName}/${p.name}/`;
1007
+ }
1008
+ job.status = 'completed';
1009
+ job.result = {
1010
+ appName,
1011
+ proxyUrls,
1012
+ secretKeys: generated.secretKeys,
1013
+ agentDeclaration: entry.agentDeclaration ?? null,
1014
+ };
1015
+ job.updatedAt = Date.now();
1016
+ this.log(job, `Reconfigure complete: ${JSON.stringify(proxyUrls)}`);
1017
+ }
1018
+ /**
1019
+ * Return an error message if any of the given host ports is already bound by a
1020
+ * *different* installed app, else null. Shared by install and reconfigure so
1021
+ * the cross-app collision rule stays in one place.
1022
+ */
1023
+ async findHostPortCollision(selfName, ports) {
1024
+ const installedApps = await this.registry.list();
1025
+ const usedHostPorts = new Map();
1026
+ for (const app of installedApps) {
1027
+ if (app.name === selfName)
1028
+ continue;
1029
+ for (const port of app.ports) {
1030
+ usedHostPorts.set(port.hostPort, app.name);
1031
+ }
1032
+ }
1033
+ for (const p of ports) {
1034
+ const owner = usedHostPorts.get(p.hostPort);
1035
+ if (owner) {
1036
+ return `Host port ${p.hostPort} (port "${p.name}") is already used by app "${owner}"`;
1037
+ }
1038
+ }
1039
+ return null;
1040
+ }
1041
+ /**
1042
+ * Write the app's .env file (mode 0600). Emits, in order: BASE_PATH for web
1043
+ * ports, declared secretKeys, self-generating generatedKeys (a fresh random
1044
+ * value unless already present in `envVars` — operator-pinned on install, or
1045
+ * the existing value on reconfigure), then any extra vars. Returns the names
1046
+ * of freshly generated secrets (for logging — never the values). Shared by
1047
+ * runInstall and runReconfigure so the .env format cannot drift.
1048
+ */
1049
+ writeEnvFile(appDir, appName, generated, envVars) {
1050
+ const merged = { ...envVars };
1051
+ // Inject BASE_PATH for web-type ports
1052
+ for (const port of generated.ports) {
1053
+ if (port.type === 'web') {
1054
+ merged['BASE_PATH'] = `/app/${appName}/${port.name}`;
1055
+ }
1056
+ }
1057
+ const envLines = [];
1058
+ for (const key of generated.secretKeys) {
1059
+ const val = (merged[key] ?? '').replace(/[\r\n]/g, '');
1060
+ envLines.push(`${key}=${val}`);
1061
+ }
1062
+ const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
1063
+ const generatedNames = [];
1064
+ for (const g of generated.generatedKeys) {
1065
+ const pinned = merged[g.key];
1066
+ let val;
1067
+ if (pinned !== undefined && pinned !== '') {
1068
+ val = pinned.replace(/[\r\n]/g, '');
1069
+ }
1070
+ else {
1071
+ val = (0, compose_generator_1.generateSecretValue)(g.encoding, g.bytes);
1072
+ generatedNames.push(g.key);
1073
+ }
1074
+ envLines.push(`${g.key}=${val}`);
1075
+ }
1076
+ // Any explicitly provided vars not already declared as secrets/generated.
1077
+ for (const [k, v] of Object.entries(merged)) {
1078
+ if (!generated.secretKeys.includes(k) && !generatedKeySet.has(k)) {
1079
+ envLines.push(`${k}=${v.replace(/[\r\n]/g, '')}`);
1080
+ }
1081
+ }
1082
+ const envPath = path.join(appDir, '.env');
1083
+ try {
1084
+ fs.writeFileSync(envPath, envLines.join('\n') + '\n', { mode: 0o600 });
1085
+ // writeFileSync's `mode` only applies when the file is created; on
1086
+ // reconfigure the .env already exists, so re-assert 0600 explicitly to
1087
+ // keep secrets owner-only regardless of the file's prior permissions.
1088
+ fs.chmodSync(envPath, 0o600);
1089
+ }
1090
+ catch (err) {
1091
+ throw new Error(`Failed to write .env: ${err.message}`);
1092
+ }
1093
+ return generatedNames;
1094
+ }
1095
+ /** Parse an app's existing .env into a key→value map (empty if absent). */
1096
+ readEnvFile(appDir) {
1097
+ const envPath = path.join(appDir, '.env');
1098
+ const out = {};
1099
+ if (!fs.existsSync(envPath))
1100
+ return out;
1101
+ const content = fs.readFileSync(envPath, 'utf-8');
1102
+ for (const rawLine of content.split('\n')) {
1103
+ const line = rawLine.trim();
1104
+ if (!line || line.startsWith('#'))
1105
+ continue;
1106
+ const eq = line.indexOf('=');
1107
+ if (eq <= 0)
1108
+ continue;
1109
+ out[line.slice(0, eq)] = line.slice(eq + 1);
1110
+ }
1111
+ return out;
1112
+ }
799
1113
  /**
800
1114
  * Resolve the repo URL and target commit to update an installed app to.
801
1115
  * - `registry`: latest published version via the registry client.
@@ -854,7 +1168,8 @@ class AppInstaller {
854
1168
  const latest = selectLatest(app.versions);
855
1169
  if (!latest)
856
1170
  throw new Error(`No versions available for "${options.registryApp}"`);
857
- this.log(job, `Using latest version ${latest.version}`);
1171
+ if (job)
1172
+ this.log(job, `Using latest version ${latest.version}`);
858
1173
  return {
859
1174
  appName: options.registryApp,
860
1175
  commit: latest.commit,
@@ -884,13 +1199,15 @@ class AppInstaller {
884
1199
  }
885
1200
  else {
886
1201
  // Auto-resolve HEAD commit via git ls-remote
887
- this.log(job, `Resolving HEAD commit for ${options.githubUrl}`);
1202
+ if (job)
1203
+ this.log(job, `Resolving HEAD commit for ${options.githubUrl}`);
888
1204
  const { stdout } = this.run(['git', 'ls-remote', options.githubUrl, 'HEAD'], process.cwd());
889
1205
  const match = stdout.trim().match(/^([0-9a-f]{40})\s+HEAD/);
890
1206
  if (!match)
891
1207
  throw new Error(`Could not resolve HEAD commit for ${options.githubUrl}`);
892
1208
  commit = match[1];
893
- this.log(job, `Resolved HEAD → ${commit.slice(0, 8)}`);
1209
+ if (job)
1210
+ this.log(job, `Resolved HEAD → ${commit.slice(0, 8)}`);
894
1211
  }
895
1212
  const appName = options.githubUrl.split('/').pop()?.replace(/\.git$/, '') ?? 'app';
896
1213
  return {
@@ -943,10 +1260,14 @@ class AppInstaller {
943
1260
  * Captures container logs into the job on failure before rethrowing.
944
1261
  * job is optional — when omitted (e.g. startStopRestart) logs go to stderr.
945
1262
  */
946
- composeUp(appName, dir, job) {
1263
+ composeUp(appName, dir, job, opts) {
947
1264
  this.stopConflictingContainers(appName);
1265
+ const args = ['docker', 'compose', '-p', appName, 'up', '-d', '--wait'];
1266
+ if (opts?.forceRecreate) {
1267
+ args.push('--force-recreate');
1268
+ }
948
1269
  try {
949
- this.run(['docker', 'compose', '-p', appName, 'up', '-d', '--wait'], dir, 600000);
1270
+ this.run(args, dir, 600000);
950
1271
  }
951
1272
  catch (upErr) {
952
1273
  if (job) {