@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.
Files changed (32) hide show
  1. package/dist/commands/dependencies.d.ts +4 -0
  2. package/dist/commands/dependencies.js +168 -36
  3. package/dist/commands/up.js +27 -2
  4. package/dist/core/bootstrap/DeploymentBootstrapper.d.ts +6 -1
  5. package/dist/core/bootstrap/DeploymentBootstrapper.js +9 -5
  6. package/dist/core/docker/ComposeTemplateRenderer.d.ts +3 -0
  7. package/dist/core/docker/ComposeTemplateRenderer.js +30 -5
  8. package/dist/core/docker/DockerStackManager.d.ts +3 -0
  9. package/dist/core/docker/DockerStackManager.js +4 -1
  10. package/dist/core/local/ApiLocalService.d.ts +22 -0
  11. package/dist/core/local/ApiLocalService.js +65 -0
  12. package/dist/core/local/LocalRepoSourceResolver.d.ts +24 -0
  13. package/dist/core/local/LocalRepoSourceResolver.js +33 -0
  14. package/dist/core/local/LocalServiceProcessManager.d.ts +18 -0
  15. package/dist/core/local/LocalServiceProcessManager.js +83 -0
  16. package/dist/core/local/WebLocalService.d.ts +23 -0
  17. package/dist/core/local/WebLocalService.js +101 -0
  18. package/dist/core/process/CommandRunner.d.ts +2 -2
  19. package/dist/core/process/CommandRunner.js +10 -2
  20. package/dist/core/runner/RunnerSupervisor.d.ts +2 -0
  21. package/dist/core/runner/RunnerSupervisor.js +12 -0
  22. package/dist/core/runtime/RuntimePaths.d.ts +2 -0
  23. package/dist/core/runtime/RuntimePaths.js +7 -1
  24. package/dist/core/runtime/RuntimeState.d.ts +14 -0
  25. package/dist/core/runtime/RuntimeStateStore.d.ts +1 -0
  26. package/dist/core/runtime/RuntimeStateStore.js +26 -3
  27. package/dist/core/status/StatusService.d.ts +4 -1
  28. package/dist/core/status/StatusService.js +12 -3
  29. package/dist/core/ui/TerminalRenderer.js +2 -2
  30. package/dist/templates/docker-compose.yaml.tpl +2 -17
  31. package/package.json +1 -1
  32. 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
- bootstrapper.writeFrontendConfig(root, state);
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
- process.stdout.write(`${renderer.progress("Starting the runner...")}\n`);
84
- await commandRunner.run(startCommand.command, startCommand.args);
85
- const apiUrl = `http://127.0.0.1:${state.ports.apiHttp}/graphql`;
86
- const uiUrl = `http://127.0.0.1:${state.ports.ui}`;
87
- process.stdout.write(`${renderer.success(`API ready: ${apiUrl}`)}\n`);
88
- process.stdout.write(`CompanyHelm CLI: ${versions.cliPackage}\n`);
89
- process.stdout.write(`Runner package: ${versions.runnerPackage}\n`);
90
- process.stdout.write(`API image: ${versions.images.api}\n`);
91
- process.stdout.write(`Frontend image: ${versions.images.frontend}\n`);
92
- process.stdout.write(`Postgres image: ${versions.images.postgres}\n`);
93
- process.stdout.write(`\n${renderer.success("CompanyHelm started successfully.")}\n`);
94
- process.stdout.write(`${renderer.successHighlight("UI URL")}\n`);
95
- process.stdout.write(`${renderer.clickableUrl(uiUrl)}\n`);
96
- process.stdout.write(`${renderer.successHighlight("Login credentials")}\n`);
97
- process.stdout.write(`username: ${state.auth.username}\n`);
98
- process.stdout.write(`password: ${state.auth.password}\n`);
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
- if (stateStore.load()) {
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
+ }
@@ -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, frontend, and runner.", "info")
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
- await dependencies.up({ logLevel: logLevel });
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): string;
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
- ' port: 4000',
31
+ ` port: ${appPort}`,
28
32
  ' graphqlEndpoint: "/graphql"',
29
33
  " graphiql: true",
30
34
  " grpc:",
31
35
  ' host: "0.0.0.0"',
32
- " port: 50051",
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
- " port: 50052",
43
+ ` port: ${agentGrpcPort}`,
40
44
  "database:",
41
45
  ' name: "companyhelm"',
42
- ' host: "postgres"',
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 frontendBlock = [
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
  }
@@ -9,4 +9,6 @@ export declare class RuntimePaths {
9
9
  runnerConfigPath(): string;
10
10
  runnerStateDbPath(): string;
11
11
  runnerLogPath(): string;
12
+ serviceRuntimePath(): string;
13
+ serviceLogPath(service: "api" | "frontend"): string;
12
14
  }
@@ -5,7 +5,7 @@ export class RuntimePaths {
5
5
  this.root = root;
6
6
  }
7
7
  stateFilePath() {
8
- return path.join(this.root, "state.json");
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
  }
@@ -6,5 +6,6 @@ export declare class RuntimeStateStore {
6
6
  constructor(root: string);
7
7
  initialize(): RuntimeState;
8
8
  load(): RuntimeState | null;
9
+ persist(state: RuntimeState): void;
9
10
  private save;
10
11
  }
@@ -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 = JSON.parse(fs.readFileSync(this.runtimePaths.stateFilePath(), "utf8"));
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(), `${JSON.stringify(state, null, 2)}\n`, {
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
- runner?: () => Promise<boolean> | boolean;
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 runnerRunning = this.overrides.runner ? await this.overrides.runner() : running.has("runner");
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: running.has("api") ? "running" : "stopped",
17
- frontend: running.has("frontend") ? "running" : "stopped",
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("Frontend", report.services.frontend, report.uiUrl));
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(`Frontend image: ${report.versions.images.frontend}`);
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
- api:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companyhelm/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Bootstrap and manage a local CompanyHelm deployment.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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
- api:
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