@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -33
  3. package/dist/cli.js +11 -1
  4. package/dist/commands/dependencies.d.ts +22 -3
  5. package/dist/commands/dependencies.js +218 -23
  6. package/dist/commands/interactive.d.ts +6 -0
  7. package/dist/commands/interactive.js +22 -0
  8. package/dist/commands/logs.js +6 -1
  9. package/dist/commands/register-commands.js +4 -0
  10. package/dist/commands/reset.d.ts +4 -0
  11. package/dist/commands/reset.js +43 -4
  12. package/dist/commands/set-image-version.d.ts +31 -0
  13. package/dist/commands/set-image-version.js +87 -0
  14. package/dist/commands/setup-github-app.d.ts +10 -0
  15. package/dist/commands/setup-github-app.js +211 -0
  16. package/dist/commands/status.js +3 -1
  17. package/dist/commands/up.js +36 -2
  18. package/dist/core/bootstrap/DeploymentBootstrapper.d.ts +7 -2
  19. package/dist/core/bootstrap/DeploymentBootstrapper.js +13 -11
  20. package/dist/core/bootstrap/SeedSqlRenderer.js +23 -5
  21. package/dist/core/config/ApiEnvFileWriter.d.ts +6 -0
  22. package/dist/core/config/ApiEnvFileWriter.js +26 -0
  23. package/dist/core/config/GithubAppConfig.d.ts +6 -0
  24. package/dist/core/config/GithubAppConfig.js +26 -0
  25. package/dist/core/config/GithubAppConfigStore.d.ts +11 -0
  26. package/dist/core/config/GithubAppConfigStore.js +65 -0
  27. package/dist/core/docker/ComposeTemplateRenderer.d.ts +9 -1
  28. package/dist/core/docker/ComposeTemplateRenderer.js +48 -5
  29. package/dist/core/docker/DockerStackManager.d.ts +18 -3
  30. package/dist/core/docker/DockerStackManager.js +70 -8
  31. package/dist/core/local/ApiLocalService.d.ts +22 -0
  32. package/dist/core/local/ApiLocalService.js +65 -0
  33. package/dist/core/local/LocalRepoSourceResolver.d.ts +24 -0
  34. package/dist/core/local/LocalRepoSourceResolver.js +33 -0
  35. package/dist/core/local/LocalServiceProcessManager.d.ts +18 -0
  36. package/dist/core/local/LocalServiceProcessManager.js +83 -0
  37. package/dist/core/local/WebLocalService.d.ts +23 -0
  38. package/dist/core/local/WebLocalService.js +101 -0
  39. package/dist/core/process/CommandRunner.d.ts +2 -2
  40. package/dist/core/process/CommandRunner.js +10 -2
  41. package/dist/core/runner/RunnerSupervisor.d.ts +6 -0
  42. package/dist/core/runner/RunnerSupervisor.js +31 -3
  43. package/dist/core/runtime/ImageCatalog.js +5 -2
  44. package/dist/core/runtime/LocalConfigStore.d.ts +16 -0
  45. package/dist/core/runtime/LocalConfigStore.js +59 -0
  46. package/dist/core/runtime/ManagedImages.d.ts +10 -0
  47. package/dist/core/runtime/ManagedImages.js +27 -0
  48. package/dist/core/runtime/ProjectPaths.d.ts +7 -0
  49. package/dist/core/runtime/ProjectPaths.js +16 -0
  50. package/dist/core/runtime/PublicImageTagRegistry.d.ts +16 -0
  51. package/dist/core/runtime/PublicImageTagRegistry.js +148 -0
  52. package/dist/core/runtime/RuntimePaths.d.ts +2 -0
  53. package/dist/core/runtime/RuntimePaths.js +7 -1
  54. package/dist/core/runtime/RuntimeState.d.ts +15 -1
  55. package/dist/core/runtime/RuntimeStateStore.d.ts +2 -0
  56. package/dist/core/runtime/RuntimeStateStore.js +33 -4
  57. package/dist/core/runtime/VersionCatalog.d.ts +10 -0
  58. package/dist/core/runtime/VersionCatalog.js +21 -0
  59. package/dist/core/status/StatusService.d.ts +8 -1
  60. package/dist/core/status/StatusService.js +16 -4
  61. package/dist/core/ui/TerminalRenderer.d.ts +10 -0
  62. package/dist/core/ui/TerminalRenderer.js +48 -0
  63. package/dist/templates/docker-compose.yaml.tpl +3 -27
  64. package/dist/templates/seed.sql.tpl +32 -13
  65. package/package.json +7 -3
  66. package/src/templates/docker-compose.yaml.tpl +3 -27
  67. 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("{{FRONTEND_CONFIG_PATH}}", paths.frontendConfigPath)
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
- down(): Promise<void>;
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 < 20; attempt += 1) {
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 new Promise((resolve) => {
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 down() {
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
- await this.commandRunner.run("docker", [
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
  }