@companyhelm/cli 0.1.4 → 0.1.5
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/dist/commands/dependencies.d.ts +4 -0
- package/dist/commands/dependencies.js +168 -36
- package/dist/commands/up.js +27 -2
- package/dist/core/bootstrap/DeploymentBootstrapper.d.ts +6 -1
- package/dist/core/bootstrap/DeploymentBootstrapper.js +9 -5
- package/dist/core/docker/ComposeTemplateRenderer.d.ts +3 -0
- package/dist/core/docker/ComposeTemplateRenderer.js +30 -5
- package/dist/core/docker/DockerStackManager.d.ts +3 -0
- package/dist/core/docker/DockerStackManager.js +4 -1
- package/dist/core/local/ApiLocalService.d.ts +22 -0
- package/dist/core/local/ApiLocalService.js +65 -0
- package/dist/core/local/LocalRepoSourceResolver.d.ts +24 -0
- package/dist/core/local/LocalRepoSourceResolver.js +33 -0
- package/dist/core/local/LocalServiceProcessManager.d.ts +18 -0
- package/dist/core/local/LocalServiceProcessManager.js +83 -0
- package/dist/core/local/WebLocalService.d.ts +23 -0
- package/dist/core/local/WebLocalService.js +101 -0
- package/dist/core/process/CommandRunner.d.ts +2 -2
- package/dist/core/process/CommandRunner.js +10 -2
- package/dist/core/runner/RunnerSupervisor.d.ts +2 -0
- package/dist/core/runner/RunnerSupervisor.js +12 -0
- package/dist/core/runtime/RuntimePaths.d.ts +2 -0
- package/dist/core/runtime/RuntimePaths.js +7 -1
- package/dist/core/runtime/RuntimeState.d.ts +14 -0
- package/dist/core/runtime/RuntimeStateStore.d.ts +1 -0
- package/dist/core/runtime/RuntimeStateStore.js +26 -3
- package/dist/core/status/StatusService.d.ts +4 -1
- package/dist/core/status/StatusService.js +12 -3
- package/dist/core/ui/TerminalRenderer.js +2 -2
- package/dist/templates/docker-compose.yaml.tpl +2 -17
- package/package.json +1 -1
- package/src/templates/docker-compose.yaml.tpl +2 -17
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type RuntimeVersions } from "../core/runtime/VersionCatalog.js";
|
|
2
2
|
import { type StatusSnapshot } from "../core/status/StatusService.js";
|
|
3
3
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
4
|
+
export type LocalRepoOptionValue = string | true | undefined;
|
|
4
5
|
export interface UpOptions {
|
|
5
6
|
logLevel?: LogLevel;
|
|
7
|
+
useHostDockerRuntime?: boolean;
|
|
8
|
+
apiRepoPath?: LocalRepoOptionValue;
|
|
9
|
+
webRepoPath?: LocalRepoOptionValue;
|
|
6
10
|
}
|
|
7
11
|
export interface ResetOptions {
|
|
8
12
|
removeGithubAppConfig?: boolean;
|
|
@@ -5,6 +5,10 @@ import { DeploymentBootstrapper } from "../core/bootstrap/DeploymentBootstrapper
|
|
|
5
5
|
import { ApiEnvFileWriter } from "../core/config/ApiEnvFileWriter.js";
|
|
6
6
|
import { GithubAppConfigStore } from "../core/config/GithubAppConfigStore.js";
|
|
7
7
|
import { DockerStackManager } from "../core/docker/DockerStackManager.js";
|
|
8
|
+
import { ApiLocalService } from "../core/local/ApiLocalService.js";
|
|
9
|
+
import { LocalRepoSourceResolver } from "../core/local/LocalRepoSourceResolver.js";
|
|
10
|
+
import { LocalServiceProcessManager } from "../core/local/LocalServiceProcessManager.js";
|
|
11
|
+
import { WebLocalService } from "../core/local/WebLocalService.js";
|
|
8
12
|
import { LogsService } from "../core/logs/LogsService.js";
|
|
9
13
|
import { CommandRunner } from "../core/process/CommandRunner.js";
|
|
10
14
|
import { ProjectPaths } from "../core/runtime/ProjectPaths.js";
|
|
@@ -31,8 +35,24 @@ export function createDefaultDependencies() {
|
|
|
31
35
|
const githubAppConfigStore = new GithubAppConfigStore();
|
|
32
36
|
const apiEnvFileWriter = new ApiEnvFileWriter(process.cwd());
|
|
33
37
|
const projectPaths = new ProjectPaths(process.cwd());
|
|
38
|
+
const localRepoSourceResolver = new LocalRepoSourceResolver(process.cwd());
|
|
39
|
+
const localServiceProcessManager = new LocalServiceProcessManager();
|
|
40
|
+
const apiLocalService = new ApiLocalService(localServiceProcessManager, commandRunner);
|
|
41
|
+
const webLocalService = new WebLocalService(localServiceProcessManager, commandRunner);
|
|
34
42
|
const versionCatalog = new VersionCatalog();
|
|
35
43
|
const statusService = new StatusService(() => dockerStackManager.runningServices(), {
|
|
44
|
+
api: () => {
|
|
45
|
+
const state = stateStore.load();
|
|
46
|
+
return state?.services.api.source === "local"
|
|
47
|
+
? localServiceProcessManager.isRunning(state.services.api)
|
|
48
|
+
: undefined;
|
|
49
|
+
},
|
|
50
|
+
frontend: () => {
|
|
51
|
+
const state = stateStore.load();
|
|
52
|
+
return state?.services.frontend.source === "local"
|
|
53
|
+
? localServiceProcessManager.isRunning(state.services.frontend)
|
|
54
|
+
: undefined;
|
|
55
|
+
},
|
|
36
56
|
runner: async () => {
|
|
37
57
|
try {
|
|
38
58
|
const statusCommand = runnerSupervisor.buildStatusArgs();
|
|
@@ -51,56 +71,140 @@ export function createDefaultDependencies() {
|
|
|
51
71
|
}
|
|
52
72
|
return;
|
|
53
73
|
}
|
|
74
|
+
const state = stateStore.load();
|
|
75
|
+
if (service === "api" && state?.services.api.source === "local") {
|
|
76
|
+
localServiceProcessManager.printLogs(state.services.api);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (service === "frontend" && state?.services.frontend.source === "local") {
|
|
80
|
+
localServiceProcessManager.printLogs(state.services.frontend);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
54
83
|
await dockerStackManager.logs(service);
|
|
55
84
|
});
|
|
56
85
|
return {
|
|
57
86
|
async up(options = {}) {
|
|
58
87
|
const logLevel = options.logLevel ?? "info";
|
|
88
|
+
const useHostDockerRuntime = options.useHostDockerRuntime ?? false;
|
|
59
89
|
const githubAppConfig = await ensureGithubAppConfig(githubAppConfigStore, process.stdin, process.stdout);
|
|
60
90
|
const state = stateStore.initialize();
|
|
91
|
+
process.stdout.write(`${renderer.renderBanner()}\n`);
|
|
92
|
+
const runnerAlreadyRunning = await isRunnerRunning(commandRunner, runnerSupervisor);
|
|
93
|
+
const desiredSources = localRepoSourceResolver.resolve(options);
|
|
61
94
|
const versions = versionCatalog.resolve();
|
|
62
95
|
const passwordRecord = createPasswordHash(state.auth.password);
|
|
96
|
+
const startedLocalServices = [];
|
|
97
|
+
let runnerStarted = false;
|
|
63
98
|
fs.mkdirSync(root, { recursive: true });
|
|
99
|
+
fs.mkdirSync(runtimePaths.serviceRuntimePath(), { recursive: true });
|
|
64
100
|
apiEnvFileWriter.write(githubAppConfig);
|
|
65
101
|
bootstrapper.writeSeedSql(root, state, passwordRecord.passwordHash, passwordRecord.passwordSalt);
|
|
66
|
-
bootstrapper.writeApiConfig(root, state, logLevel
|
|
67
|
-
|
|
68
|
-
process.stdout.write(`${renderer.renderBanner()}\n`);
|
|
69
|
-
await dockerStackManager.up(state, { frontendLogLevel: logLevel });
|
|
70
|
-
process.stdout.write(`${renderer.progress("Initializing the database...")}\n`);
|
|
71
|
-
process.stdout.write(`${renderer.progress("Waiting for database migrations...")}\n`);
|
|
72
|
-
await dockerStackManager.applySeedSql(state.auth.username);
|
|
73
|
-
const configureSdkCommand = runnerSupervisor.buildUseHostAuthArgs();
|
|
74
|
-
process.stdout.write(`${renderer.progress("Configuring runner authentication...")}\n`);
|
|
75
|
-
await commandRunner.run(configureSdkCommand.command, configureSdkCommand.args);
|
|
76
|
-
const startCommand = runnerSupervisor.buildStartArgs({
|
|
77
|
-
serverUrl: `127.0.0.1:${state.ports.runnerGrpc}`,
|
|
78
|
-
agentApiUrl: `127.0.0.1:${state.ports.agentCliGrpc}`,
|
|
79
|
-
logPath: runtimePaths.runnerLogPath(),
|
|
80
|
-
secret: state.runner.secret,
|
|
81
|
-
logLevel
|
|
102
|
+
bootstrapper.writeApiConfig(root, state, logLevel, {
|
|
103
|
+
databaseHost: desiredSources.api.source === "local" ? "127.0.0.1" : "postgres"
|
|
82
104
|
});
|
|
83
|
-
|
|
84
|
-
await
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
bootstrapper.writeFrontendConfig(root, state);
|
|
106
|
+
await stopLocalServicesFromState(stateStore.load(), localServiceProcessManager);
|
|
107
|
+
try {
|
|
108
|
+
await dockerStackManager.up(state, {
|
|
109
|
+
frontendLogLevel: logLevel,
|
|
110
|
+
includeApi: desiredSources.api.source === "docker",
|
|
111
|
+
includeFrontend: desiredSources.frontend.source === "docker",
|
|
112
|
+
exposePostgresPort: desiredSources.api.source === "local"
|
|
113
|
+
});
|
|
114
|
+
const apiUrl = `http://127.0.0.1:${state.ports.apiHttp}/graphql`;
|
|
115
|
+
const uiUrl = `http://127.0.0.1:${state.ports.ui}`;
|
|
116
|
+
if (desiredSources.api.source === "local") {
|
|
117
|
+
process.stdout.write(`${renderer.progress(`Starting companyhelm-api from ${desiredSources.api.repoPath}...`)}\n`);
|
|
118
|
+
state.services.api = await apiLocalService.start({
|
|
119
|
+
repoPath: desiredSources.api.repoPath,
|
|
120
|
+
configPath: runtimePaths.apiConfigPath(),
|
|
121
|
+
graphqlUrl: apiUrl,
|
|
122
|
+
logPath: runtimePaths.serviceLogPath("api"),
|
|
123
|
+
githubAppConfig,
|
|
124
|
+
state,
|
|
125
|
+
logLevel
|
|
126
|
+
});
|
|
127
|
+
startedLocalServices.push("api");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
state.services.api = { source: "docker" };
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(`${renderer.progress("Initializing the database...")}\n`);
|
|
133
|
+
process.stdout.write(`${renderer.progress("Waiting for database migrations...")}\n`);
|
|
134
|
+
await dockerStackManager.applySeedSql(state.auth.username);
|
|
135
|
+
if (runnerAlreadyRunning) {
|
|
136
|
+
process.stdout.write(`${renderer.progress("Runner already running; skipping startup.")}\n`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const configureSdkCommand = runnerSupervisor.buildUseHostAuthArgs();
|
|
140
|
+
process.stdout.write(`${renderer.progress("Configuring runner authentication...")}\n`);
|
|
141
|
+
await commandRunner.run(configureSdkCommand.command, configureSdkCommand.args);
|
|
142
|
+
const startCommand = runnerSupervisor.buildStartArgs({
|
|
143
|
+
serverUrl: `127.0.0.1:${state.ports.runnerGrpc}`,
|
|
144
|
+
agentApiUrl: `127.0.0.1:${state.ports.agentCliGrpc}`,
|
|
145
|
+
logPath: runtimePaths.runnerLogPath(),
|
|
146
|
+
secret: state.runner.secret,
|
|
147
|
+
logLevel,
|
|
148
|
+
useHostDockerRuntime
|
|
149
|
+
});
|
|
150
|
+
process.stdout.write(`${renderer.progress("Starting the runner...")}\n`);
|
|
151
|
+
await commandRunner.run(startCommand.command, startCommand.args);
|
|
152
|
+
runnerStarted = true;
|
|
153
|
+
}
|
|
154
|
+
if (desiredSources.frontend.source === "local") {
|
|
155
|
+
process.stdout.write(`${renderer.progress(`Starting companyhelm-web from ${desiredSources.frontend.repoPath}...`)}\n`);
|
|
156
|
+
state.services.frontend = await webLocalService.start({
|
|
157
|
+
repoPath: desiredSources.frontend.repoPath,
|
|
158
|
+
configPath: runtimePaths.frontendConfigPath(),
|
|
159
|
+
url: uiUrl,
|
|
160
|
+
uiPort: state.ports.ui,
|
|
161
|
+
logPath: runtimePaths.serviceLogPath("frontend"),
|
|
162
|
+
logLevel
|
|
163
|
+
});
|
|
164
|
+
startedLocalServices.push("frontend");
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
state.services.frontend = { source: "docker" };
|
|
168
|
+
}
|
|
169
|
+
stateStore.persist(state);
|
|
170
|
+
process.stdout.write(`${renderer.success(`API ready: ${apiUrl}`)}\n`);
|
|
171
|
+
process.stdout.write(`CompanyHelm CLI: ${versions.cliPackage}\n`);
|
|
172
|
+
process.stdout.write(`Runner package: ${versions.runnerPackage}\n`);
|
|
173
|
+
process.stdout.write(desiredSources.api.source === "local"
|
|
174
|
+
? `API repo: ${desiredSources.api.repoPath}\n`
|
|
175
|
+
: `API image: ${versions.images.api}\n`);
|
|
176
|
+
process.stdout.write(desiredSources.frontend.source === "local"
|
|
177
|
+
? `companyhelm-web repo: ${desiredSources.frontend.repoPath}\n`
|
|
178
|
+
: `companyhelm-web image: ${versions.images.frontend}\n`);
|
|
179
|
+
process.stdout.write(`Postgres image: ${versions.images.postgres}\n`);
|
|
180
|
+
process.stdout.write(`\n${renderer.success("CompanyHelm started successfully.")}\n`);
|
|
181
|
+
process.stdout.write(`${renderer.successHighlight("UI URL")}\n`);
|
|
182
|
+
process.stdout.write(`${renderer.clickableUrl(uiUrl)}\n`);
|
|
183
|
+
process.stdout.write(`${renderer.successHighlight("Login credentials")}\n`);
|
|
184
|
+
process.stdout.write(`username: ${state.auth.username}\n`);
|
|
185
|
+
process.stdout.write(`password: ${state.auth.password}\n`);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (runnerStarted) {
|
|
189
|
+
const stopCommand = runnerSupervisor.buildStopArgs();
|
|
190
|
+
try {
|
|
191
|
+
await commandRunner.run(stopCommand.command, stopCommand.args);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Ignore runner stop failures during cleanup.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const service of startedLocalServices.reverse()) {
|
|
198
|
+
const runtime = service === "api" ? state.services.api : state.services.frontend;
|
|
199
|
+
if (runtime.source === "local") {
|
|
200
|
+
await localServiceProcessManager.stop(runtime);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await dockerStackManager.down();
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
99
206
|
},
|
|
100
207
|
async down() {
|
|
101
|
-
if (!stateStore.load()) {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
208
|
const stopCommand = runnerSupervisor.buildStopArgs();
|
|
105
209
|
try {
|
|
106
210
|
await commandRunner.run(stopCommand.command, stopCommand.args);
|
|
@@ -108,6 +212,11 @@ export function createDefaultDependencies() {
|
|
|
108
212
|
catch {
|
|
109
213
|
// Ignore runner stop failures during teardown so container cleanup still happens.
|
|
110
214
|
}
|
|
215
|
+
const state = stateStore.load();
|
|
216
|
+
if (!state) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await stopLocalServicesFromState(state, localServiceProcessManager);
|
|
111
220
|
await dockerStackManager.down();
|
|
112
221
|
},
|
|
113
222
|
async status() {
|
|
@@ -125,7 +234,8 @@ export function createDefaultDependencies() {
|
|
|
125
234
|
return logsService.stream(service);
|
|
126
235
|
},
|
|
127
236
|
async reset(options = {}) {
|
|
128
|
-
|
|
237
|
+
const state = stateStore.load();
|
|
238
|
+
if (state) {
|
|
129
239
|
const stopCommand = runnerSupervisor.buildStopArgs();
|
|
130
240
|
try {
|
|
131
241
|
await commandRunner.run(stopCommand.command, stopCommand.args);
|
|
@@ -133,6 +243,7 @@ export function createDefaultDependencies() {
|
|
|
133
243
|
catch {
|
|
134
244
|
// Ignore runner stop failures during teardown so docker cleanup still runs.
|
|
135
245
|
}
|
|
246
|
+
await stopLocalServicesFromState(state, localServiceProcessManager);
|
|
136
247
|
}
|
|
137
248
|
await dockerStackManager.down({ removeVolumes: true });
|
|
138
249
|
fs.rmSync(projectPaths.apiEnvPath(), { force: true });
|
|
@@ -143,3 +254,24 @@ export function createDefaultDependencies() {
|
|
|
143
254
|
}
|
|
144
255
|
};
|
|
145
256
|
}
|
|
257
|
+
async function stopLocalServicesFromState(state, processManager) {
|
|
258
|
+
if (!state) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (state.services.api.source === "local") {
|
|
262
|
+
await processManager.stop(state.services.api);
|
|
263
|
+
}
|
|
264
|
+
if (state.services.frontend.source === "local") {
|
|
265
|
+
await processManager.stop(state.services.frontend);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function isRunnerRunning(commandRunner, runnerSupervisor) {
|
|
269
|
+
try {
|
|
270
|
+
const statusCommand = runnerSupervisor.buildStatusArgs();
|
|
271
|
+
const output = await commandRunner.capture(statusCommand.command, statusCommand.args);
|
|
272
|
+
return output.includes("Daemon: running");
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
package/dist/commands/up.js
CHANGED
|
@@ -3,12 +3,37 @@ export function registerUpCommand(program, dependencies) {
|
|
|
3
3
|
program
|
|
4
4
|
.command("up")
|
|
5
5
|
.description("Start or reconcile the local deployment.")
|
|
6
|
-
.option("--log-level <level>", "Set log level for api,
|
|
6
|
+
.option("--log-level <level>", "Set log level for api, companyhelm-web, and runner.", "info")
|
|
7
|
+
.option("--use-host-docker-runtime", "Run thread containers against the host Docker runtime instead of DinD sidecars.")
|
|
8
|
+
.option("--api-repo-path [path]", "Start the API from a local repo path. Defaults to ../companyhelm-api when provided without a value.")
|
|
9
|
+
.option("--web-repo-path [path]", "Start companyhelm-web from a local repo path. Defaults to ../companyhelm-web when provided without a value.")
|
|
7
10
|
.action(async (options) => {
|
|
8
11
|
const logLevel = String(options.logLevel || "").trim().toLowerCase();
|
|
9
12
|
if (!LOG_LEVELS.has(logLevel)) {
|
|
10
13
|
throw new Error(`Unsupported log level "${options.logLevel}". Expected one of: debug, info, warn, error.`);
|
|
11
14
|
}
|
|
12
|
-
|
|
15
|
+
const upOptions = {
|
|
16
|
+
logLevel: logLevel,
|
|
17
|
+
useHostDockerRuntime: Boolean(options.useHostDockerRuntime)
|
|
18
|
+
};
|
|
19
|
+
const apiRepoPath = normalizeLocalRepoOption(options.apiRepoPath);
|
|
20
|
+
const webRepoPath = normalizeLocalRepoOption(options.webRepoPath);
|
|
21
|
+
if (apiRepoPath !== undefined) {
|
|
22
|
+
upOptions.apiRepoPath = apiRepoPath;
|
|
23
|
+
}
|
|
24
|
+
if (webRepoPath !== undefined) {
|
|
25
|
+
upOptions.webRepoPath = webRepoPath;
|
|
26
|
+
}
|
|
27
|
+
await dependencies.up(upOptions);
|
|
13
28
|
});
|
|
14
29
|
}
|
|
30
|
+
function normalizeLocalRepoOption(option) {
|
|
31
|
+
if (option === true) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (typeof option === "string") {
|
|
35
|
+
const trimmed = option.trim();
|
|
36
|
+
return trimmed.length > 0 ? trimmed : true;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -3,7 +3,12 @@ import type { RuntimeState } from "../runtime/RuntimeState.js";
|
|
|
3
3
|
export declare class DeploymentBootstrapper {
|
|
4
4
|
private readonly renderer;
|
|
5
5
|
writeSeedSql(root: string, state: RuntimeState, passwordHash: string, passwordSalt: string): string;
|
|
6
|
-
writeApiConfig(root: string, state: RuntimeState, logLevel?: LogLevel
|
|
6
|
+
writeApiConfig(root: string, state: RuntimeState, logLevel?: LogLevel, options?: {
|
|
7
|
+
databaseHost?: string;
|
|
8
|
+
appPort?: number;
|
|
9
|
+
runnerGrpcPort?: number;
|
|
10
|
+
agentGrpcPort?: number;
|
|
11
|
+
}): string;
|
|
7
12
|
writeFrontendConfig(root: string, state: RuntimeState): string;
|
|
8
13
|
private indentBlock;
|
|
9
14
|
}
|
|
@@ -18,28 +18,32 @@ export class DeploymentBootstrapper {
|
|
|
18
18
|
fs.writeFileSync(outputPath, sql, "utf8");
|
|
19
19
|
return outputPath;
|
|
20
20
|
}
|
|
21
|
-
writeApiConfig(root, state, logLevel = "info") {
|
|
21
|
+
writeApiConfig(root, state, logLevel = "info", options = {}) {
|
|
22
22
|
const runtimePaths = new RuntimePaths(root);
|
|
23
23
|
const outputPath = runtimePaths.apiConfigPath();
|
|
24
|
+
const appPort = options.appPort ?? state.ports.apiHttp;
|
|
25
|
+
const runnerGrpcPort = options.runnerGrpcPort ?? state.ports.runnerGrpc;
|
|
26
|
+
const agentGrpcPort = options.agentGrpcPort ?? state.ports.agentCliGrpc;
|
|
27
|
+
const databaseHost = options.databaseHost ?? "postgres";
|
|
24
28
|
const yaml = [
|
|
25
29
|
"app:",
|
|
26
30
|
' host: "0.0.0.0"',
|
|
27
|
-
|
|
31
|
+
` port: ${appPort}`,
|
|
28
32
|
' graphqlEndpoint: "/graphql"',
|
|
29
33
|
" graphiql: true",
|
|
30
34
|
" grpc:",
|
|
31
35
|
' host: "0.0.0.0"',
|
|
32
|
-
|
|
36
|
+
` port: ${runnerGrpcPort}`,
|
|
33
37
|
" heartbeat:",
|
|
34
38
|
" intervalMs: 20000",
|
|
35
39
|
" jitterMs: 10000",
|
|
36
40
|
"agent:",
|
|
37
41
|
" grpc:",
|
|
38
42
|
' host: "0.0.0.0"',
|
|
39
|
-
|
|
43
|
+
` port: ${agentGrpcPort}`,
|
|
40
44
|
"database:",
|
|
41
45
|
' name: "companyhelm"',
|
|
42
|
-
|
|
46
|
+
` host: "${databaseHost}"`,
|
|
43
47
|
" port: 5432",
|
|
44
48
|
" roles:",
|
|
45
49
|
" app_runtime:",
|
|
@@ -13,6 +13,9 @@ export interface ComposePaths {
|
|
|
13
13
|
}
|
|
14
14
|
export interface ComposeRenderOptions {
|
|
15
15
|
frontendLogLevel?: LogLevel;
|
|
16
|
+
includeApi?: boolean;
|
|
17
|
+
includeFrontend?: boolean;
|
|
18
|
+
exposePostgresPort?: boolean;
|
|
16
19
|
}
|
|
17
20
|
export declare class ComposeTemplateRenderer {
|
|
18
21
|
render(ports: ComposePorts, paths: ComposePaths, options?: ComposeRenderOptions): string;
|
|
@@ -10,11 +10,35 @@ export class ComposeTemplateRenderer {
|
|
|
10
10
|
const template = fs.readFileSync(templatePath, "utf8");
|
|
11
11
|
const images = new ImageCatalog().resolve();
|
|
12
12
|
const frontendLogLevel = options.frontendLogLevel ?? "info";
|
|
13
|
-
const
|
|
13
|
+
const includeApi = options.includeApi ?? true;
|
|
14
|
+
const includeFrontend = options.includeFrontend ?? true;
|
|
15
|
+
const postgresPortsBlock = options.exposePostgresPort ? [
|
|
16
|
+
" ports:",
|
|
17
|
+
' - "5432:5432"'
|
|
18
|
+
].join("\n") : "";
|
|
19
|
+
const apiBlock = includeApi ? [
|
|
20
|
+
" api:",
|
|
21
|
+
` image: ${images.api}`,
|
|
22
|
+
" platform: linux/amd64",
|
|
23
|
+
" depends_on:",
|
|
24
|
+
" - postgres",
|
|
25
|
+
" env_file:",
|
|
26
|
+
` - "${paths.apiEnvPath}"`,
|
|
27
|
+
" environment:",
|
|
28
|
+
" COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml",
|
|
29
|
+
" ports:",
|
|
30
|
+
` - "${ports.apiHttpPort}:4000"`,
|
|
31
|
+
` - "${ports.runnerGrpcPort}:${ports.runnerGrpcPort}"`,
|
|
32
|
+
` - "${ports.agentCliGrpcPort}:${ports.agentCliGrpcPort}"`,
|
|
33
|
+
" volumes:",
|
|
34
|
+
` - "${paths.apiConfigPath}:/run/companyhelm/config.yaml:ro"`,
|
|
35
|
+
" networks:",
|
|
36
|
+
" - companyhelm"
|
|
37
|
+
].join("\n") : "";
|
|
38
|
+
const frontendBlock = includeFrontend ? [
|
|
14
39
|
" frontend:",
|
|
15
40
|
` image: ${images.frontend}`,
|
|
16
|
-
" depends_on:",
|
|
17
|
-
" - api",
|
|
41
|
+
...(includeApi ? [" depends_on:", " - api"] : []),
|
|
18
42
|
" environment:",
|
|
19
43
|
" COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml",
|
|
20
44
|
` COMPANYHELM_LOG_LEVEL: "${frontendLogLevel}"`,
|
|
@@ -26,10 +50,11 @@ export class ComposeTemplateRenderer {
|
|
|
26
50
|
` - "${paths.frontendConfigPath}:/run/companyhelm/config.yaml:ro"`,
|
|
27
51
|
" networks:",
|
|
28
52
|
" - companyhelm"
|
|
29
|
-
].join("\n");
|
|
53
|
+
].join("\n") : "";
|
|
30
54
|
return template
|
|
31
|
-
.replaceAll("{{API_IMAGE}}", images.api)
|
|
32
55
|
.replaceAll("{{POSTGRES_IMAGE}}", images.postgres)
|
|
56
|
+
.replace("{{POSTGRES_PORTS_BLOCK}}", postgresPortsBlock)
|
|
57
|
+
.replace("{{API_SERVICE_BLOCK}}", apiBlock)
|
|
33
58
|
.replaceAll("{{API_CONFIG_PATH}}", paths.apiConfigPath)
|
|
34
59
|
.replaceAll("{{API_ENV_PATH}}", paths.apiEnvPath)
|
|
35
60
|
.replaceAll("{{SEED_FILE_PATH}}", paths.seedFilePath)
|
|
@@ -4,6 +4,9 @@ import type { RuntimeState } from "../runtime/RuntimeState.js";
|
|
|
4
4
|
import { ComposeTemplateRenderer } from "./ComposeTemplateRenderer.js";
|
|
5
5
|
export interface DockerStackUpOptions {
|
|
6
6
|
frontendLogLevel?: LogLevel;
|
|
7
|
+
includeApi?: boolean;
|
|
8
|
+
includeFrontend?: boolean;
|
|
9
|
+
exposePostgresPort?: boolean;
|
|
7
10
|
}
|
|
8
11
|
export interface DockerStackDownOptions {
|
|
9
12
|
removeVolumes?: boolean;
|
|
@@ -27,7 +27,10 @@ export class DockerStackManager {
|
|
|
27
27
|
frontendConfigPath: this.runtimePaths.frontendConfigPath(),
|
|
28
28
|
seedFilePath: this.runtimePaths.seedFilePath()
|
|
29
29
|
}, {
|
|
30
|
-
frontendLogLevel: options.frontendLogLevel
|
|
30
|
+
frontendLogLevel: options.frontendLogLevel,
|
|
31
|
+
includeApi: options.includeApi,
|
|
32
|
+
includeFrontend: options.includeFrontend,
|
|
33
|
+
exposePostgresPort: options.exposePostgresPort
|
|
31
34
|
}), "utf8");
|
|
32
35
|
await this.commandRunner.run("docker", [
|
|
33
36
|
"compose",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LocalServiceProcessManager } from "./LocalServiceProcessManager.js";
|
|
2
|
+
import { CommandRunner } from "../process/CommandRunner.js";
|
|
3
|
+
import type { LogLevel } from "../../commands/dependencies.js";
|
|
4
|
+
import type { GithubAppConfig } from "../config/GithubAppConfig.js";
|
|
5
|
+
import type { LocalManagedServiceRuntime, RuntimeState } from "../runtime/RuntimeState.js";
|
|
6
|
+
export interface ApiLocalServiceStartInput {
|
|
7
|
+
repoPath: string;
|
|
8
|
+
configPath: string;
|
|
9
|
+
graphqlUrl: string;
|
|
10
|
+
logPath: string;
|
|
11
|
+
githubAppConfig: GithubAppConfig;
|
|
12
|
+
state: RuntimeState;
|
|
13
|
+
logLevel: LogLevel;
|
|
14
|
+
}
|
|
15
|
+
export declare class ApiLocalService {
|
|
16
|
+
private readonly processManager;
|
|
17
|
+
private readonly commandRunner;
|
|
18
|
+
constructor(processManager?: LocalServiceProcessManager, commandRunner?: CommandRunner);
|
|
19
|
+
start(input: ApiLocalServiceStartInput): Promise<LocalManagedServiceRuntime>;
|
|
20
|
+
private ensureNodeModules;
|
|
21
|
+
private waitForReadiness;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { LocalServiceProcessManager } from "./LocalServiceProcessManager.js";
|
|
3
|
+
import { CommandRunner } from "../process/CommandRunner.js";
|
|
4
|
+
export class ApiLocalService {
|
|
5
|
+
processManager;
|
|
6
|
+
commandRunner;
|
|
7
|
+
constructor(processManager = new LocalServiceProcessManager(), commandRunner = new CommandRunner()) {
|
|
8
|
+
this.processManager = processManager;
|
|
9
|
+
this.commandRunner = commandRunner;
|
|
10
|
+
}
|
|
11
|
+
async start(input) {
|
|
12
|
+
await this.ensureNodeModules(input.repoPath);
|
|
13
|
+
const runtime = this.processManager.start({
|
|
14
|
+
serviceName: "api",
|
|
15
|
+
repoPath: input.repoPath,
|
|
16
|
+
command: process.execPath,
|
|
17
|
+
args: [
|
|
18
|
+
"./node_modules/tsx/dist/cli.mjs",
|
|
19
|
+
"watch",
|
|
20
|
+
"src/server.ts",
|
|
21
|
+
"--config-path",
|
|
22
|
+
input.configPath
|
|
23
|
+
],
|
|
24
|
+
logPath: input.logPath,
|
|
25
|
+
env: {
|
|
26
|
+
APP_ENV: "local",
|
|
27
|
+
GITHUB_APP_CLIENT_ID: input.githubAppConfig.appClientId,
|
|
28
|
+
GITHUB_APP_URL: input.githubAppConfig.appUrl,
|
|
29
|
+
GITHUB_APP_PRIVATE_KEY_PEM: input.githubAppConfig.appPrivateKeyPem,
|
|
30
|
+
COMPANYHELM_JWT_PRIVATE_KEY_PEM: input.state.auth.jwtPrivateKeyPem,
|
|
31
|
+
COMPANYHELM_JWT_PUBLIC_KEY_PEM: input.state.auth.jwtPublicKeyPem,
|
|
32
|
+
COMPANYHELM_LOG_LEVEL: input.logLevel
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
await this.waitForReadiness(input.graphqlUrl, runtime, "API");
|
|
36
|
+
return runtime;
|
|
37
|
+
}
|
|
38
|
+
async ensureNodeModules(repoPath) {
|
|
39
|
+
if (fs.existsSync(`${repoPath}/node_modules`)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await this.commandRunner.run("npm", ["install"], repoPath);
|
|
43
|
+
}
|
|
44
|
+
async waitForReadiness(url, runtime, serviceName) {
|
|
45
|
+
const deadline = Date.now() + 60_000;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (!this.processManager.isRunning(runtime)) {
|
|
48
|
+
throw new Error(`${serviceName} exited before becoming ready.`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(url, { method: "OPTIONS" });
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Retry until the deadline.
|
|
58
|
+
}
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
setTimeout(resolve, 1000);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`${serviceName} did not become ready: ${url}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LocalRepoOptionValue } from "../../commands/dependencies.js";
|
|
2
|
+
export interface DockerServiceSource {
|
|
3
|
+
source: "docker";
|
|
4
|
+
}
|
|
5
|
+
export interface LocalRepoServiceSource {
|
|
6
|
+
source: "local";
|
|
7
|
+
repoPath: string;
|
|
8
|
+
}
|
|
9
|
+
export type ResolvedServiceSource = DockerServiceSource | LocalRepoServiceSource;
|
|
10
|
+
export interface ResolvedServiceSources {
|
|
11
|
+
api: ResolvedServiceSource;
|
|
12
|
+
frontend: ResolvedServiceSource;
|
|
13
|
+
}
|
|
14
|
+
export interface LocalRepoSourceOptions {
|
|
15
|
+
apiRepoPath?: LocalRepoOptionValue;
|
|
16
|
+
webRepoPath?: LocalRepoOptionValue;
|
|
17
|
+
}
|
|
18
|
+
export declare class LocalRepoSourceResolver {
|
|
19
|
+
private readonly companyhelmRoot;
|
|
20
|
+
constructor(companyhelmRoot?: string);
|
|
21
|
+
resolve(options: LocalRepoSourceOptions): ResolvedServiceSources;
|
|
22
|
+
private resolveService;
|
|
23
|
+
private assertRepoPathExists;
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class LocalRepoSourceResolver {
|
|
4
|
+
companyhelmRoot;
|
|
5
|
+
constructor(companyhelmRoot = process.cwd()) {
|
|
6
|
+
this.companyhelmRoot = companyhelmRoot;
|
|
7
|
+
}
|
|
8
|
+
resolve(options) {
|
|
9
|
+
return {
|
|
10
|
+
api: this.resolveService("api", options.apiRepoPath, "../companyhelm-api"),
|
|
11
|
+
frontend: this.resolveService("frontend", options.webRepoPath, "../companyhelm-web")
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
resolveService(service, option, defaultRelativePath) {
|
|
15
|
+
if (option === undefined) {
|
|
16
|
+
return {
|
|
17
|
+
source: "docker"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const repoPath = path.resolve(this.companyhelmRoot, option === true ? defaultRelativePath : option);
|
|
21
|
+
this.assertRepoPathExists(service, repoPath);
|
|
22
|
+
return {
|
|
23
|
+
source: "local",
|
|
24
|
+
repoPath
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
assertRepoPathExists(service, repoPath) {
|
|
28
|
+
if (fs.existsSync(repoPath) && fs.statSync(repoPath).isDirectory()) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Local ${service} repo path does not exist: ${repoPath}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LocalManagedServiceRuntime } from "../runtime/RuntimeState.js";
|
|
2
|
+
export interface LocalProcessStartInput {
|
|
3
|
+
serviceName: string;
|
|
4
|
+
repoPath: string;
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
logPath: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
}
|
|
10
|
+
export declare class LocalServiceProcessManager {
|
|
11
|
+
start(input: LocalProcessStartInput): LocalManagedServiceRuntime;
|
|
12
|
+
isRunning(runtime: LocalManagedServiceRuntime): boolean;
|
|
13
|
+
stop(runtime: LocalManagedServiceRuntime): Promise<void>;
|
|
14
|
+
printLogs(runtime: LocalManagedServiceRuntime): void;
|
|
15
|
+
private kill;
|
|
16
|
+
private isPidRunning;
|
|
17
|
+
private waitForExit;
|
|
18
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export class LocalServiceProcessManager {
|
|
5
|
+
start(input) {
|
|
6
|
+
fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
|
|
7
|
+
fs.writeFileSync(input.logPath, `\n[companyhelm] starting ${input.serviceName}: ${input.command} ${input.args.join(" ")}\n`, { flag: "a" });
|
|
8
|
+
const logFd = fs.openSync(input.logPath, "a");
|
|
9
|
+
const child = spawn(input.command, input.args, {
|
|
10
|
+
cwd: input.repoPath,
|
|
11
|
+
env: {
|
|
12
|
+
...process.env,
|
|
13
|
+
...input.env
|
|
14
|
+
},
|
|
15
|
+
stdio: ["ignore", logFd, logFd],
|
|
16
|
+
detached: true
|
|
17
|
+
});
|
|
18
|
+
child.unref();
|
|
19
|
+
fs.closeSync(logFd);
|
|
20
|
+
return {
|
|
21
|
+
source: "local",
|
|
22
|
+
repoPath: input.repoPath,
|
|
23
|
+
logPath: input.logPath,
|
|
24
|
+
pid: child.pid ?? 0
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
isRunning(runtime) {
|
|
28
|
+
return runtime.pid > 0 && this.isPidRunning(runtime.pid);
|
|
29
|
+
}
|
|
30
|
+
async stop(runtime) {
|
|
31
|
+
if (!this.isRunning(runtime)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.kill(runtime.pid, "SIGTERM");
|
|
35
|
+
const exitedAfterSigTerm = await this.waitForExit(runtime.pid, 5000);
|
|
36
|
+
if (!exitedAfterSigTerm) {
|
|
37
|
+
this.kill(runtime.pid, "SIGKILL");
|
|
38
|
+
await this.waitForExit(runtime.pid, 2000);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
printLogs(runtime) {
|
|
42
|
+
if (!fs.existsSync(runtime.logPath)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write(fs.readFileSync(runtime.logPath, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
kill(pid, signal) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-pid, signal);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Fall through to direct child kill.
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, signal);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Ignore stale pid files.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
isPidRunning(pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async waitForExit(pid, timeoutMs) {
|
|
72
|
+
const deadline = Date.now() + timeoutMs;
|
|
73
|
+
while (Date.now() < deadline) {
|
|
74
|
+
if (!this.isPidRunning(pid)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
await new Promise((resolve) => {
|
|
78
|
+
setTimeout(resolve, 100);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return !this.isPidRunning(pid);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LocalServiceProcessManager } from "./LocalServiceProcessManager.js";
|
|
2
|
+
import { CommandRunner } from "../process/CommandRunner.js";
|
|
3
|
+
import type { LogLevel } from "../../commands/dependencies.js";
|
|
4
|
+
import type { LocalManagedServiceRuntime } from "../runtime/RuntimeState.js";
|
|
5
|
+
export interface WebLocalServiceStartInput {
|
|
6
|
+
repoPath: string;
|
|
7
|
+
configPath: string;
|
|
8
|
+
url: string;
|
|
9
|
+
uiPort: number;
|
|
10
|
+
logPath: string;
|
|
11
|
+
logLevel: LogLevel;
|
|
12
|
+
}
|
|
13
|
+
export declare class WebLocalService {
|
|
14
|
+
private readonly processManager;
|
|
15
|
+
private readonly commandRunner;
|
|
16
|
+
private static readonly SERVICE_NAME;
|
|
17
|
+
constructor(processManager?: LocalServiceProcessManager, commandRunner?: CommandRunner);
|
|
18
|
+
start(input: WebLocalServiceStartInput): Promise<LocalManagedServiceRuntime>;
|
|
19
|
+
private ensureNodeModules;
|
|
20
|
+
private waitForReadiness;
|
|
21
|
+
private assertUiPortAvailable;
|
|
22
|
+
private buildStartupFailureMessage;
|
|
23
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { LocalServiceProcessManager } from "./LocalServiceProcessManager.js";
|
|
4
|
+
import { CommandRunner } from "../process/CommandRunner.js";
|
|
5
|
+
export class WebLocalService {
|
|
6
|
+
processManager;
|
|
7
|
+
commandRunner;
|
|
8
|
+
static SERVICE_NAME = "companyhelm-web";
|
|
9
|
+
constructor(processManager = new LocalServiceProcessManager(), commandRunner = new CommandRunner()) {
|
|
10
|
+
this.processManager = processManager;
|
|
11
|
+
this.commandRunner = commandRunner;
|
|
12
|
+
}
|
|
13
|
+
async start(input) {
|
|
14
|
+
await this.assertUiPortAvailable(input.uiPort);
|
|
15
|
+
await this.ensureNodeModules(input.repoPath);
|
|
16
|
+
await this.commandRunner.run("npm", ["run", "config:generate", "--", "--config-path", input.configPath], input.repoPath, {
|
|
17
|
+
APP_ENV: "local"
|
|
18
|
+
});
|
|
19
|
+
const runtime = this.processManager.start({
|
|
20
|
+
serviceName: WebLocalService.SERVICE_NAME,
|
|
21
|
+
repoPath: input.repoPath,
|
|
22
|
+
command: process.execPath,
|
|
23
|
+
args: [
|
|
24
|
+
"./node_modules/vite/bin/vite.js",
|
|
25
|
+
"dev",
|
|
26
|
+
"--host",
|
|
27
|
+
"0.0.0.0",
|
|
28
|
+
"--port",
|
|
29
|
+
String(input.uiPort)
|
|
30
|
+
],
|
|
31
|
+
logPath: input.logPath,
|
|
32
|
+
env: {
|
|
33
|
+
APP_ENV: "local",
|
|
34
|
+
COMPANYHELM_LOG_LEVEL: input.logLevel,
|
|
35
|
+
npm_config_loglevel: input.logLevel
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
await this.waitForReadiness(input.url, runtime);
|
|
39
|
+
return runtime;
|
|
40
|
+
}
|
|
41
|
+
async ensureNodeModules(repoPath) {
|
|
42
|
+
if (fs.existsSync(`${repoPath}/node_modules`)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await this.commandRunner.run("npm", ["install"], repoPath);
|
|
46
|
+
}
|
|
47
|
+
async waitForReadiness(url, runtime) {
|
|
48
|
+
const deadline = Date.now() + 60_000;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
if (!this.processManager.isRunning(runtime)) {
|
|
51
|
+
throw new Error(this.buildStartupFailureMessage(runtime, `${WebLocalService.SERVICE_NAME} exited before becoming ready.`));
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(url);
|
|
55
|
+
if (response.ok) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Retry until the deadline.
|
|
61
|
+
}
|
|
62
|
+
await new Promise((resolve) => {
|
|
63
|
+
setTimeout(resolve, 1000);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`companyhelm-web did not become ready: ${url}`);
|
|
67
|
+
}
|
|
68
|
+
async assertUiPortAvailable(port) {
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const server = net.createServer();
|
|
71
|
+
server.once("error", (error) => {
|
|
72
|
+
if (error.code === "EADDRINUSE") {
|
|
73
|
+
reject(new Error(`companyhelm-web cannot start because port ${port} is already in use.`));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
reject(new Error(`companyhelm-web cannot verify port ${port}: ${error.message}`));
|
|
77
|
+
});
|
|
78
|
+
server.once("listening", () => {
|
|
79
|
+
server.close((closeError) => {
|
|
80
|
+
if (closeError) {
|
|
81
|
+
reject(closeError);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.listen(port, "0.0.0.0");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
buildStartupFailureMessage(runtime, summary) {
|
|
91
|
+
if (!fs.existsSync(runtime.logPath)) {
|
|
92
|
+
return summary;
|
|
93
|
+
}
|
|
94
|
+
const startupLog = fs.readFileSync(runtime.logPath, "utf8").trim();
|
|
95
|
+
if (!startupLog) {
|
|
96
|
+
return summary;
|
|
97
|
+
}
|
|
98
|
+
const tail = startupLog.split(/\r?\n/).slice(-20).join("\n");
|
|
99
|
+
return `${summary}\nStartup log:\n${tail}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare class CommandRunner {
|
|
2
|
-
run(command: string, args: string[], cwd?: string): Promise<void>;
|
|
3
|
-
capture(command: string, args: string[], cwd?: string): Promise<string>;
|
|
2
|
+
run(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
3
|
+
capture(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<string>;
|
|
4
4
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
export class CommandRunner {
|
|
3
|
-
run(command, args, cwd) {
|
|
3
|
+
run(command, args, cwd, env) {
|
|
4
4
|
return new Promise((resolve, reject) => {
|
|
5
5
|
const child = spawn(command, args, {
|
|
6
6
|
cwd,
|
|
7
|
+
env: {
|
|
8
|
+
...process.env,
|
|
9
|
+
...env
|
|
10
|
+
},
|
|
7
11
|
stdio: "inherit"
|
|
8
12
|
});
|
|
9
13
|
child.on("error", reject);
|
|
@@ -16,10 +20,14 @@ export class CommandRunner {
|
|
|
16
20
|
});
|
|
17
21
|
});
|
|
18
22
|
}
|
|
19
|
-
capture(command, args, cwd) {
|
|
23
|
+
capture(command, args, cwd, env) {
|
|
20
24
|
return new Promise((resolve, reject) => {
|
|
21
25
|
const child = spawn(command, args, {
|
|
22
26
|
cwd,
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
...env
|
|
30
|
+
},
|
|
23
31
|
stdio: ["ignore", "pipe", "pipe"]
|
|
24
32
|
});
|
|
25
33
|
let stdout = "";
|
|
@@ -5,6 +5,7 @@ export interface RunnerStartInput {
|
|
|
5
5
|
logPath: string;
|
|
6
6
|
secret: string;
|
|
7
7
|
logLevel?: LogLevel;
|
|
8
|
+
useHostDockerRuntime?: boolean;
|
|
8
9
|
}
|
|
9
10
|
export interface RunnerStartCommand {
|
|
10
11
|
command: string;
|
|
@@ -18,4 +19,5 @@ export declare class RunnerSupervisor {
|
|
|
18
19
|
buildStopArgs(): RunnerStartCommand;
|
|
19
20
|
buildStatusArgs(): RunnerStartCommand;
|
|
20
21
|
private resolveRunnerCliPath;
|
|
22
|
+
private resolveHostDockerPath;
|
|
21
23
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
const require = createRequire(import.meta.url);
|
|
4
|
+
const DEFAULT_HOST_DOCKER_PATH = "unix:///var/run/docker.sock";
|
|
4
5
|
export class RunnerSupervisor {
|
|
5
6
|
configPath;
|
|
6
7
|
constructor(configPath) {
|
|
@@ -16,6 +17,9 @@ export class RunnerSupervisor {
|
|
|
16
17
|
buildStartArgs(input) {
|
|
17
18
|
const runnerCliPath = this.resolveRunnerCliPath();
|
|
18
19
|
const logLevel = (input.logLevel ?? "info").toUpperCase();
|
|
20
|
+
const hostDockerArgs = input.useHostDockerRuntime
|
|
21
|
+
? ["--use-host-docker-runtime", "--host-docker-path", this.resolveHostDockerPath()]
|
|
22
|
+
: [];
|
|
19
23
|
return {
|
|
20
24
|
command: process.execPath,
|
|
21
25
|
args: [
|
|
@@ -30,6 +34,7 @@ export class RunnerSupervisor {
|
|
|
30
34
|
input.agentApiUrl,
|
|
31
35
|
"--log-path",
|
|
32
36
|
input.logPath,
|
|
37
|
+
...hostDockerArgs,
|
|
33
38
|
"--secret",
|
|
34
39
|
input.secret,
|
|
35
40
|
"--log-level",
|
|
@@ -58,4 +63,11 @@ export class RunnerSupervisor {
|
|
|
58
63
|
const packageJsonPath = require.resolve("@companyhelm/runner/package.json");
|
|
59
64
|
return path.resolve(path.dirname(packageJsonPath), "dist/cli.js");
|
|
60
65
|
}
|
|
66
|
+
resolveHostDockerPath() {
|
|
67
|
+
const dockerHost = String(process.env.DOCKER_HOST || "").trim();
|
|
68
|
+
if (dockerHost) {
|
|
69
|
+
return dockerHost;
|
|
70
|
+
}
|
|
71
|
+
return DEFAULT_HOST_DOCKER_PATH;
|
|
72
|
+
}
|
|
61
73
|
}
|
|
@@ -5,7 +5,7 @@ export class RuntimePaths {
|
|
|
5
5
|
this.root = root;
|
|
6
6
|
}
|
|
7
7
|
stateFilePath() {
|
|
8
|
-
return path.join(this.root, "state.
|
|
8
|
+
return path.join(this.root, "state.yaml");
|
|
9
9
|
}
|
|
10
10
|
composeFilePath() {
|
|
11
11
|
return path.join(this.root, "docker-compose.yaml");
|
|
@@ -28,4 +28,10 @@ export class RuntimePaths {
|
|
|
28
28
|
runnerLogPath() {
|
|
29
29
|
return path.join(this.runnerConfigPath(), "daemon.log");
|
|
30
30
|
}
|
|
31
|
+
serviceRuntimePath() {
|
|
32
|
+
return path.join(this.root, "services");
|
|
33
|
+
}
|
|
34
|
+
serviceLogPath(service) {
|
|
35
|
+
return path.join(this.serviceRuntimePath(), `${service}.log`);
|
|
36
|
+
}
|
|
31
37
|
}
|
|
@@ -4,6 +4,16 @@ export interface RuntimePorts {
|
|
|
4
4
|
runnerGrpc: number;
|
|
5
5
|
agentCliGrpc: number;
|
|
6
6
|
}
|
|
7
|
+
export interface DockerManagedServiceRuntime {
|
|
8
|
+
source: "docker";
|
|
9
|
+
}
|
|
10
|
+
export interface LocalManagedServiceRuntime {
|
|
11
|
+
source: "local";
|
|
12
|
+
repoPath: string;
|
|
13
|
+
logPath: string;
|
|
14
|
+
pid: number;
|
|
15
|
+
}
|
|
16
|
+
export type ManagedServiceRuntime = DockerManagedServiceRuntime | LocalManagedServiceRuntime;
|
|
7
17
|
export interface RuntimeState {
|
|
8
18
|
version: 1;
|
|
9
19
|
company: {
|
|
@@ -21,4 +31,8 @@ export interface RuntimeState {
|
|
|
21
31
|
secret: string;
|
|
22
32
|
};
|
|
23
33
|
ports: RuntimePorts;
|
|
34
|
+
services: {
|
|
35
|
+
api: ManagedServiceRuntime;
|
|
36
|
+
frontend: ManagedServiceRuntime;
|
|
37
|
+
};
|
|
24
38
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import YAML from "yaml";
|
|
2
3
|
import { PortAllocator } from "./PortAllocator.js";
|
|
3
4
|
import { RuntimePaths } from "./RuntimePaths.js";
|
|
4
5
|
import { createPemKeyPair, randomCompanyId, randomSecret } from "./Secrets.js";
|
|
@@ -33,7 +34,15 @@ export class RuntimeStateStore {
|
|
|
33
34
|
name: "local-runner",
|
|
34
35
|
secret: randomSecret()
|
|
35
36
|
},
|
|
36
|
-
ports: new PortAllocator().allocate()
|
|
37
|
+
ports: new PortAllocator().allocate(),
|
|
38
|
+
services: {
|
|
39
|
+
api: {
|
|
40
|
+
source: "docker"
|
|
41
|
+
},
|
|
42
|
+
frontend: {
|
|
43
|
+
source: "docker"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
37
46
|
};
|
|
38
47
|
this.save(state);
|
|
39
48
|
return state;
|
|
@@ -42,15 +51,29 @@ export class RuntimeStateStore {
|
|
|
42
51
|
if (!fs.existsSync(this.runtimePaths.stateFilePath())) {
|
|
43
52
|
return null;
|
|
44
53
|
}
|
|
45
|
-
const state =
|
|
54
|
+
const state = YAML.parse(fs.readFileSync(this.runtimePaths.stateFilePath(), "utf8"));
|
|
46
55
|
if (state.auth.username !== RuntimeStateStore.DEFAULT_USERNAME && state.auth.username === "admin") {
|
|
47
56
|
state.auth.username = RuntimeStateStore.DEFAULT_USERNAME;
|
|
48
57
|
this.save(state);
|
|
49
58
|
}
|
|
59
|
+
if (!state.services) {
|
|
60
|
+
state.services = {
|
|
61
|
+
api: {
|
|
62
|
+
source: "docker"
|
|
63
|
+
},
|
|
64
|
+
frontend: {
|
|
65
|
+
source: "docker"
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
this.save(state);
|
|
69
|
+
}
|
|
50
70
|
return state;
|
|
51
71
|
}
|
|
72
|
+
persist(state) {
|
|
73
|
+
this.save(state);
|
|
74
|
+
}
|
|
52
75
|
save(state) {
|
|
53
|
-
fs.writeFileSync(this.runtimePaths.stateFilePath(),
|
|
76
|
+
fs.writeFileSync(this.runtimePaths.stateFilePath(), YAML.stringify(state), {
|
|
54
77
|
encoding: "utf8",
|
|
55
78
|
mode: 0o600
|
|
56
79
|
});
|
|
@@ -6,11 +6,14 @@ export interface StatusSnapshot {
|
|
|
6
6
|
runner: ManagedServiceStatus;
|
|
7
7
|
}
|
|
8
8
|
export interface StatusOverrides {
|
|
9
|
-
|
|
9
|
+
api?: () => Promise<boolean | undefined> | boolean | undefined;
|
|
10
|
+
frontend?: () => Promise<boolean | undefined> | boolean | undefined;
|
|
11
|
+
runner?: () => Promise<boolean | undefined> | boolean | undefined;
|
|
10
12
|
}
|
|
11
13
|
export declare class StatusService {
|
|
12
14
|
private readonly listRunningServices;
|
|
13
15
|
private readonly overrides;
|
|
14
16
|
constructor(listRunningServices: () => Promise<string>, overrides?: StatusOverrides);
|
|
15
17
|
read(): Promise<StatusSnapshot>;
|
|
18
|
+
private resolveServiceRunning;
|
|
16
19
|
}
|
|
@@ -10,12 +10,21 @@ export class StatusService {
|
|
|
10
10
|
.split("\n")
|
|
11
11
|
.map((service) => service.trim())
|
|
12
12
|
.filter(Boolean));
|
|
13
|
-
const
|
|
13
|
+
const apiRunning = await this.resolveServiceRunning("api", this.overrides.api, running);
|
|
14
|
+
const frontendRunning = await this.resolveServiceRunning("frontend", this.overrides.frontend, running);
|
|
15
|
+
const runnerRunning = await this.resolveServiceRunning("runner", this.overrides.runner, running);
|
|
14
16
|
return {
|
|
15
17
|
postgres: running.has("postgres") ? "running" : "stopped",
|
|
16
|
-
api:
|
|
17
|
-
frontend:
|
|
18
|
+
api: apiRunning ? "running" : "stopped",
|
|
19
|
+
frontend: frontendRunning ? "running" : "stopped",
|
|
18
20
|
runner: runnerRunning ? "running" : "stopped"
|
|
19
21
|
};
|
|
20
22
|
}
|
|
23
|
+
async resolveServiceRunning(service, override, running) {
|
|
24
|
+
if (!override) {
|
|
25
|
+
return running.has(service);
|
|
26
|
+
}
|
|
27
|
+
const result = await override();
|
|
28
|
+
return typeof result === "boolean" ? result : running.has(service);
|
|
29
|
+
}
|
|
21
30
|
}
|
|
@@ -36,13 +36,13 @@ export class TerminalRenderer {
|
|
|
36
36
|
const lines = ["Status"];
|
|
37
37
|
lines.push(this.renderServiceLine("Postgres", report.services.postgres));
|
|
38
38
|
lines.push(this.renderServiceLine("API", report.services.api, report.apiUrl));
|
|
39
|
-
lines.push(this.renderServiceLine("
|
|
39
|
+
lines.push(this.renderServiceLine("companyhelm-web", report.services.frontend, report.uiUrl));
|
|
40
40
|
lines.push(this.renderServiceLine("Runner", report.services.runner));
|
|
41
41
|
if (report.versions) {
|
|
42
42
|
lines.push(`CompanyHelm CLI: ${report.versions.cliPackage}`);
|
|
43
43
|
lines.push(`Runner package: ${report.versions.runnerPackage}`);
|
|
44
44
|
lines.push(`API image: ${report.versions.images.api}`);
|
|
45
|
-
lines.push(`
|
|
45
|
+
lines.push(`companyhelm-web image: ${report.versions.images.frontend}`);
|
|
46
46
|
lines.push(`Postgres image: ${report.versions.images.postgres}`);
|
|
47
47
|
}
|
|
48
48
|
if (report.username) {
|
|
@@ -5,29 +5,14 @@ services:
|
|
|
5
5
|
POSTGRES_USER: postgres
|
|
6
6
|
POSTGRES_PASSWORD: postgres
|
|
7
7
|
POSTGRES_DB: companyhelm
|
|
8
|
+
{{POSTGRES_PORTS_BLOCK}}
|
|
8
9
|
volumes:
|
|
9
10
|
- companyhelm_postgres_data:/var/lib/postgresql/data
|
|
10
11
|
- "{{SEED_FILE_PATH}}:/run/companyhelm/seed.sql:ro"
|
|
11
12
|
networks:
|
|
12
13
|
- companyhelm
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
image: {{API_IMAGE}}
|
|
16
|
-
platform: linux/amd64
|
|
17
|
-
depends_on:
|
|
18
|
-
- postgres
|
|
19
|
-
env_file:
|
|
20
|
-
- "{{API_ENV_PATH}}"
|
|
21
|
-
environment:
|
|
22
|
-
COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml
|
|
23
|
-
ports:
|
|
24
|
-
- "{{API_HTTP_PORT}}:4000"
|
|
25
|
-
- "{{RUNNER_GRPC_PORT}}:{{RUNNER_GRPC_PORT}}"
|
|
26
|
-
- "{{AGENT_CLI_GRPC_PORT}}:{{AGENT_CLI_GRPC_PORT}}"
|
|
27
|
-
volumes:
|
|
28
|
-
- "{{API_CONFIG_PATH}}:/run/companyhelm/config.yaml:ro"
|
|
29
|
-
networks:
|
|
30
|
-
- companyhelm
|
|
15
|
+
{{API_SERVICE_BLOCK}}
|
|
31
16
|
|
|
32
17
|
{{FRONTEND_SERVICE_BLOCK}}
|
|
33
18
|
|
package/package.json
CHANGED
|
@@ -5,29 +5,14 @@ services:
|
|
|
5
5
|
POSTGRES_USER: postgres
|
|
6
6
|
POSTGRES_PASSWORD: postgres
|
|
7
7
|
POSTGRES_DB: companyhelm
|
|
8
|
+
{{POSTGRES_PORTS_BLOCK}}
|
|
8
9
|
volumes:
|
|
9
10
|
- companyhelm_postgres_data:/var/lib/postgresql/data
|
|
10
11
|
- "{{SEED_FILE_PATH}}:/run/companyhelm/seed.sql:ro"
|
|
11
12
|
networks:
|
|
12
13
|
- companyhelm
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
image: {{API_IMAGE}}
|
|
16
|
-
platform: linux/amd64
|
|
17
|
-
depends_on:
|
|
18
|
-
- postgres
|
|
19
|
-
env_file:
|
|
20
|
-
- "{{API_ENV_PATH}}"
|
|
21
|
-
environment:
|
|
22
|
-
COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml
|
|
23
|
-
ports:
|
|
24
|
-
- "{{API_HTTP_PORT}}:4000"
|
|
25
|
-
- "{{RUNNER_GRPC_PORT}}:{{RUNNER_GRPC_PORT}}"
|
|
26
|
-
- "{{AGENT_CLI_GRPC_PORT}}:{{AGENT_CLI_GRPC_PORT}}"
|
|
27
|
-
volumes:
|
|
28
|
-
- "{{API_CONFIG_PATH}}:/run/companyhelm/config.yaml:ro"
|
|
29
|
-
networks:
|
|
30
|
-
- companyhelm
|
|
15
|
+
{{API_SERVICE_BLOCK}}
|
|
31
16
|
|
|
32
17
|
{{FRONTEND_SERVICE_BLOCK}}
|
|
33
18
|
|