@companyhelm/cli 0.1.2 → 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/LICENSE +21 -0
- package/README.md +40 -33
- package/dist/cli.js +11 -1
- package/dist/commands/dependencies.d.ts +22 -3
- package/dist/commands/dependencies.js +218 -23
- package/dist/commands/interactive.d.ts +6 -0
- package/dist/commands/interactive.js +22 -0
- package/dist/commands/logs.js +6 -1
- package/dist/commands/register-commands.js +4 -0
- package/dist/commands/reset.d.ts +4 -0
- package/dist/commands/reset.js +43 -4
- package/dist/commands/set-image-version.d.ts +31 -0
- package/dist/commands/set-image-version.js +87 -0
- package/dist/commands/setup-github-app.d.ts +10 -0
- package/dist/commands/setup-github-app.js +211 -0
- package/dist/commands/status.js +3 -1
- package/dist/commands/up.js +36 -2
- package/dist/core/bootstrap/DeploymentBootstrapper.d.ts +7 -2
- package/dist/core/bootstrap/DeploymentBootstrapper.js +13 -11
- package/dist/core/bootstrap/SeedSqlRenderer.js +23 -5
- package/dist/core/config/ApiEnvFileWriter.d.ts +6 -0
- package/dist/core/config/ApiEnvFileWriter.js +26 -0
- package/dist/core/config/GithubAppConfig.d.ts +6 -0
- package/dist/core/config/GithubAppConfig.js +26 -0
- package/dist/core/config/GithubAppConfigStore.d.ts +11 -0
- package/dist/core/config/GithubAppConfigStore.js +65 -0
- package/dist/core/docker/ComposeTemplateRenderer.d.ts +9 -1
- package/dist/core/docker/ComposeTemplateRenderer.js +48 -5
- package/dist/core/docker/DockerStackManager.d.ts +18 -3
- package/dist/core/docker/DockerStackManager.js +70 -8
- 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 +6 -0
- package/dist/core/runner/RunnerSupervisor.js +31 -3
- package/dist/core/runtime/ImageCatalog.js +5 -2
- package/dist/core/runtime/LocalConfigStore.d.ts +16 -0
- package/dist/core/runtime/LocalConfigStore.js +59 -0
- package/dist/core/runtime/ManagedImages.d.ts +10 -0
- package/dist/core/runtime/ManagedImages.js +27 -0
- package/dist/core/runtime/ProjectPaths.d.ts +7 -0
- package/dist/core/runtime/ProjectPaths.js +16 -0
- package/dist/core/runtime/PublicImageTagRegistry.d.ts +16 -0
- package/dist/core/runtime/PublicImageTagRegistry.js +148 -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 +15 -1
- package/dist/core/runtime/RuntimeStateStore.d.ts +2 -0
- package/dist/core/runtime/RuntimeStateStore.js +33 -4
- package/dist/core/runtime/VersionCatalog.d.ts +10 -0
- package/dist/core/runtime/VersionCatalog.js +21 -0
- package/dist/core/status/StatusService.d.ts +8 -1
- package/dist/core/status/StatusService.js +16 -4
- package/dist/core/ui/TerminalRenderer.d.ts +10 -0
- package/dist/core/ui/TerminalRenderer.js +48 -0
- package/dist/templates/docker-compose.yaml.tpl +3 -27
- package/dist/templates/seed.sql.tpl +32 -13
- package/package.json +7 -3
- package/src/templates/docker-compose.yaml.tpl +3 -27
- package/src/templates/seed.sql.tpl +32 -13
|
@@ -5,20 +5,63 @@ import { ImageCatalog } from "../runtime/ImageCatalog.js";
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
export class ComposeTemplateRenderer {
|
|
8
|
-
render(ports, paths) {
|
|
8
|
+
render(ports, paths, options = {}) {
|
|
9
9
|
const templatePath = path.resolve(__dirname, "../../templates/docker-compose.yaml.tpl");
|
|
10
10
|
const template = fs.readFileSync(templatePath, "utf8");
|
|
11
11
|
const images = new ImageCatalog().resolve();
|
|
12
|
+
const frontendLogLevel = options.frontendLogLevel ?? "info";
|
|
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 ? [
|
|
39
|
+
" frontend:",
|
|
40
|
+
` image: ${images.frontend}`,
|
|
41
|
+
...(includeApi ? [" depends_on:", " - api"] : []),
|
|
42
|
+
" environment:",
|
|
43
|
+
" COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml",
|
|
44
|
+
` COMPANYHELM_LOG_LEVEL: "${frontendLogLevel}"`,
|
|
45
|
+
` PORT: "${ports.uiPort}"`,
|
|
46
|
+
` npm_config_loglevel: "${frontendLogLevel}"`,
|
|
47
|
+
" ports:",
|
|
48
|
+
` - "${ports.uiPort}:${ports.uiPort}"`,
|
|
49
|
+
" volumes:",
|
|
50
|
+
` - "${paths.frontendConfigPath}:/run/companyhelm/config.yaml:ro"`,
|
|
51
|
+
" networks:",
|
|
52
|
+
" - companyhelm"
|
|
53
|
+
].join("\n") : "";
|
|
12
54
|
return template
|
|
13
|
-
.replaceAll("{{API_IMAGE}}", images.api)
|
|
14
|
-
.replaceAll("{{FRONTEND_IMAGE}}", images.frontend)
|
|
15
55
|
.replaceAll("{{POSTGRES_IMAGE}}", images.postgres)
|
|
56
|
+
.replace("{{POSTGRES_PORTS_BLOCK}}", postgresPortsBlock)
|
|
57
|
+
.replace("{{API_SERVICE_BLOCK}}", apiBlock)
|
|
16
58
|
.replaceAll("{{API_CONFIG_PATH}}", paths.apiConfigPath)
|
|
17
|
-
.replaceAll("{{
|
|
59
|
+
.replaceAll("{{API_ENV_PATH}}", paths.apiEnvPath)
|
|
18
60
|
.replaceAll("{{SEED_FILE_PATH}}", paths.seedFilePath)
|
|
19
61
|
.replaceAll("{{API_HTTP_PORT}}", String(ports.apiHttpPort))
|
|
20
62
|
.replaceAll("{{UI_PORT}}", String(ports.uiPort))
|
|
21
63
|
.replaceAll("{{RUNNER_GRPC_PORT}}", String(ports.runnerGrpcPort))
|
|
22
|
-
.replaceAll("{{AGENT_CLI_GRPC_PORT}}", String(ports.agentCliGrpcPort))
|
|
64
|
+
.replaceAll("{{AGENT_CLI_GRPC_PORT}}", String(ports.agentCliGrpcPort))
|
|
65
|
+
.replace("{{FRONTEND_SERVICE_BLOCK}}", frontendBlock);
|
|
23
66
|
}
|
|
24
67
|
}
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
import type { LogLevel } from "../../commands/dependencies.js";
|
|
1
2
|
import { CommandRunner } from "../process/CommandRunner.js";
|
|
2
3
|
import type { RuntimeState } from "../runtime/RuntimeState.js";
|
|
3
4
|
import { ComposeTemplateRenderer } from "./ComposeTemplateRenderer.js";
|
|
5
|
+
export interface DockerStackUpOptions {
|
|
6
|
+
frontendLogLevel?: LogLevel;
|
|
7
|
+
includeApi?: boolean;
|
|
8
|
+
includeFrontend?: boolean;
|
|
9
|
+
exposePostgresPort?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface DockerStackDownOptions {
|
|
12
|
+
removeVolumes?: boolean;
|
|
13
|
+
}
|
|
4
14
|
export declare class DockerStackManager {
|
|
5
15
|
private readonly commandRunner;
|
|
6
16
|
private readonly composeRenderer;
|
|
17
|
+
private static readonly BOOTSTRAP_RETRY_COUNT;
|
|
18
|
+
private static readonly BOOTSTRAP_RETRY_DELAY_MS;
|
|
7
19
|
private readonly runtimePaths;
|
|
8
20
|
constructor(root: string, commandRunner?: CommandRunner, composeRenderer?: ComposeTemplateRenderer);
|
|
9
|
-
up(state: RuntimeState): Promise<void>;
|
|
10
|
-
applySeedSql(): Promise<void>;
|
|
11
|
-
|
|
21
|
+
up(state: RuntimeState, options?: DockerStackUpOptions): Promise<void>;
|
|
22
|
+
applySeedSql(seedEmail: string): Promise<void>;
|
|
23
|
+
private seedSchemaReady;
|
|
24
|
+
private seedAlreadyApplied;
|
|
25
|
+
private waitForNextBootstrapAttempt;
|
|
26
|
+
down(options?: DockerStackDownOptions): Promise<void>;
|
|
12
27
|
logs(service: "postgres" | "api" | "frontend"): Promise<void>;
|
|
13
28
|
runningServices(): Promise<string>;
|
|
14
29
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { CommandRunner } from "../process/CommandRunner.js";
|
|
3
|
+
import { ProjectPaths } from "../runtime/ProjectPaths.js";
|
|
3
4
|
import { RuntimePaths } from "../runtime/RuntimePaths.js";
|
|
4
5
|
import { ComposeTemplateRenderer } from "./ComposeTemplateRenderer.js";
|
|
5
6
|
export class DockerStackManager {
|
|
6
7
|
commandRunner;
|
|
7
8
|
composeRenderer;
|
|
9
|
+
static BOOTSTRAP_RETRY_COUNT = 60;
|
|
10
|
+
static BOOTSTRAP_RETRY_DELAY_MS = 1000;
|
|
8
11
|
runtimePaths;
|
|
9
12
|
constructor(root, commandRunner = new CommandRunner(), composeRenderer = new ComposeTemplateRenderer()) {
|
|
10
13
|
this.commandRunner = commandRunner;
|
|
11
14
|
this.composeRenderer = composeRenderer;
|
|
12
15
|
this.runtimePaths = new RuntimePaths(root);
|
|
13
16
|
}
|
|
14
|
-
async up(state) {
|
|
17
|
+
async up(state, options = {}) {
|
|
15
18
|
fs.mkdirSync(this.runtimePaths.runnerConfigPath(), { recursive: true });
|
|
16
19
|
fs.writeFileSync(this.runtimePaths.composeFilePath(), this.composeRenderer.render({
|
|
17
20
|
apiHttpPort: state.ports.apiHttp,
|
|
@@ -20,8 +23,14 @@ export class DockerStackManager {
|
|
|
20
23
|
agentCliGrpcPort: state.ports.agentCliGrpc
|
|
21
24
|
}, {
|
|
22
25
|
apiConfigPath: this.runtimePaths.apiConfigPath(),
|
|
26
|
+
apiEnvPath: new ProjectPaths(process.cwd()).apiEnvPath(),
|
|
23
27
|
frontendConfigPath: this.runtimePaths.frontendConfigPath(),
|
|
24
28
|
seedFilePath: this.runtimePaths.seedFilePath()
|
|
29
|
+
}, {
|
|
30
|
+
frontendLogLevel: options.frontendLogLevel,
|
|
31
|
+
includeApi: options.includeApi,
|
|
32
|
+
includeFrontend: options.includeFrontend,
|
|
33
|
+
exposePostgresPort: options.exposePostgresPort
|
|
25
34
|
}), "utf8");
|
|
26
35
|
await this.commandRunner.run("docker", [
|
|
27
36
|
"compose",
|
|
@@ -31,13 +40,20 @@ export class DockerStackManager {
|
|
|
31
40
|
"-d"
|
|
32
41
|
]);
|
|
33
42
|
}
|
|
34
|
-
async applySeedSql() {
|
|
43
|
+
async applySeedSql(seedEmail) {
|
|
35
44
|
if (!fs.existsSync(this.runtimePaths.seedFilePath())) {
|
|
36
45
|
return;
|
|
37
46
|
}
|
|
38
47
|
let lastError = null;
|
|
39
|
-
for (let attempt = 0; attempt <
|
|
48
|
+
for (let attempt = 0; attempt < DockerStackManager.BOOTSTRAP_RETRY_COUNT; attempt += 1) {
|
|
40
49
|
try {
|
|
50
|
+
if (!(await this.seedSchemaReady())) {
|
|
51
|
+
await this.waitForNextBootstrapAttempt();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (await this.seedAlreadyApplied(seedEmail)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
41
57
|
await this.commandRunner.run("docker", [
|
|
42
58
|
"compose",
|
|
43
59
|
"-f",
|
|
@@ -57,23 +73,69 @@ export class DockerStackManager {
|
|
|
57
73
|
}
|
|
58
74
|
catch (error) {
|
|
59
75
|
lastError = error;
|
|
60
|
-
await
|
|
61
|
-
setTimeout(resolve, 1000);
|
|
62
|
-
});
|
|
76
|
+
await this.waitForNextBootstrapAttempt();
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
79
|
throw lastError ?? new Error("Failed to apply seed SQL.");
|
|
66
80
|
}
|
|
67
|
-
async
|
|
81
|
+
async seedSchemaReady() {
|
|
82
|
+
const output = await this.commandRunner.capture("docker", [
|
|
83
|
+
"compose",
|
|
84
|
+
"-f",
|
|
85
|
+
this.runtimePaths.composeFilePath(),
|
|
86
|
+
"exec",
|
|
87
|
+
"-T",
|
|
88
|
+
"postgres",
|
|
89
|
+
"psql",
|
|
90
|
+
"-U",
|
|
91
|
+
"postgres",
|
|
92
|
+
"-d",
|
|
93
|
+
"companyhelm",
|
|
94
|
+
"-tAc",
|
|
95
|
+
"SELECT to_regclass('public.user_auths') IS NOT NULL"
|
|
96
|
+
]);
|
|
97
|
+
return output.trim() === "t";
|
|
98
|
+
}
|
|
99
|
+
async seedAlreadyApplied(seedEmail) {
|
|
100
|
+
const escapedEmail = seedEmail.replaceAll("'", "''");
|
|
101
|
+
const output = await this.commandRunner.capture("docker", [
|
|
102
|
+
"compose",
|
|
103
|
+
"-f",
|
|
104
|
+
this.runtimePaths.composeFilePath(),
|
|
105
|
+
"exec",
|
|
106
|
+
"-T",
|
|
107
|
+
"postgres",
|
|
108
|
+
"psql",
|
|
109
|
+
"-U",
|
|
110
|
+
"postgres",
|
|
111
|
+
"-d",
|
|
112
|
+
"companyhelm",
|
|
113
|
+
"-tAc",
|
|
114
|
+
`SELECT 1 FROM user_auths WHERE email = '${escapedEmail}' LIMIT 1`
|
|
115
|
+
]);
|
|
116
|
+
return output.trim() === "1";
|
|
117
|
+
}
|
|
118
|
+
async waitForNextBootstrapAttempt() {
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
setTimeout(resolve, DockerStackManager.BOOTSTRAP_RETRY_DELAY_MS);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async down(options = {}) {
|
|
68
124
|
if (!fs.existsSync(this.runtimePaths.composeFilePath())) {
|
|
69
125
|
return;
|
|
70
126
|
}
|
|
71
|
-
|
|
127
|
+
const args = [
|
|
72
128
|
"compose",
|
|
73
129
|
"-f",
|
|
74
130
|
this.runtimePaths.composeFilePath(),
|
|
75
131
|
"down",
|
|
76
132
|
"--remove-orphans"
|
|
133
|
+
];
|
|
134
|
+
if (options.removeVolumes) {
|
|
135
|
+
args.push("--volumes");
|
|
136
|
+
}
|
|
137
|
+
await this.commandRunner.run("docker", [
|
|
138
|
+
...args
|
|
77
139
|
]);
|
|
78
140
|
}
|
|
79
141
|
async logs(service) {
|
|
@@ -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 = "";
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { LogLevel } from "../../commands/dependencies.js";
|
|
1
2
|
export interface RunnerStartInput {
|
|
2
3
|
serverUrl: string;
|
|
3
4
|
agentApiUrl: string;
|
|
4
5
|
logPath: string;
|
|
5
6
|
secret: string;
|
|
7
|
+
logLevel?: LogLevel;
|
|
8
|
+
useHostDockerRuntime?: boolean;
|
|
6
9
|
}
|
|
7
10
|
export interface RunnerStartCommand {
|
|
8
11
|
command: string;
|
|
@@ -11,7 +14,10 @@ export interface RunnerStartCommand {
|
|
|
11
14
|
export declare class RunnerSupervisor {
|
|
12
15
|
private readonly configPath;
|
|
13
16
|
constructor(configPath: string);
|
|
17
|
+
buildUseHostAuthArgs(): RunnerStartCommand;
|
|
14
18
|
buildStartArgs(input: RunnerStartInput): RunnerStartCommand;
|
|
15
19
|
buildStopArgs(): RunnerStartCommand;
|
|
20
|
+
buildStatusArgs(): RunnerStartCommand;
|
|
16
21
|
private resolveRunnerCliPath;
|
|
22
|
+
private resolveHostDockerPath;
|
|
17
23
|
}
|