@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
@@ -0,0 +1,87 @@
1
+ import * as clack from "@clack/prompts";
2
+ import { LocalConfigStore } from "../core/runtime/LocalConfigStore.js";
3
+ import { MANAGED_IMAGE_SERVICES, requireManagedImageService } from "../core/runtime/ManagedImages.js";
4
+ import { PublicImageTagRegistry } from "../core/runtime/PublicImageTagRegistry.js";
5
+ import { requireInteractiveTerminal, unwrapPromptResult } from "./interactive.js";
6
+ function parsePositiveInteger(value) {
7
+ const parsed = Number.parseInt(value, 10);
8
+ if (!Number.isInteger(parsed) || parsed < 1) {
9
+ throw new Error(`Expected a positive integer, received: ${value}`);
10
+ }
11
+ return parsed;
12
+ }
13
+ async function promptForSelection(message, options, input, output, defaultValue) {
14
+ requireInteractiveTerminal(input, output, "set-image-version requires a TTY so you can choose an image interactively.");
15
+ if (options.length === 0) {
16
+ throw new Error("No selectable options were provided.");
17
+ }
18
+ const selected = await clack.select({
19
+ message,
20
+ options: options.map((option, index) => ({
21
+ ...option,
22
+ hint: option.value === defaultValue ? "current" : undefined
23
+ })),
24
+ initialValue: defaultValue,
25
+ input,
26
+ output
27
+ });
28
+ return unwrapPromptResult(selected, "Image selection cancelled.", output);
29
+ }
30
+ async function loadAvailableTags(registry, service, limit, output) {
31
+ const spinner = clack.spinner({ output });
32
+ spinner.start(`Loading the latest ${limit} image tags for ${service}`);
33
+ let tags;
34
+ try {
35
+ tags = await registry.listAvailableTags(service, limit);
36
+ }
37
+ catch (error) {
38
+ spinner.stop("Unable to load image tags");
39
+ throw error;
40
+ }
41
+ if (tags.length === 0) {
42
+ spinner.stop("No image tags found");
43
+ throw new Error(`No image tags found for ${service}.`);
44
+ }
45
+ spinner.stop(`Loaded ${tags.length} image tag${tags.length === 1 ? "" : "s"}`);
46
+ return tags;
47
+ }
48
+ function formatTagTimestamp(createdAt) {
49
+ if (!createdAt) {
50
+ return "timestamp unavailable";
51
+ }
52
+ const timestamp = new Date(createdAt);
53
+ if (Number.isNaN(timestamp.valueOf())) {
54
+ return "timestamp unavailable";
55
+ }
56
+ return timestamp.toISOString().slice(0, 16).replace("T", " ") + " UTC";
57
+ }
58
+ export async function runSetImageVersion(options, dependencies = {}) {
59
+ const input = dependencies.input ?? process.stdin;
60
+ const output = dependencies.output ?? process.stdout;
61
+ const registry = dependencies.registry ?? new PublicImageTagRegistry();
62
+ const configStore = dependencies.configStore ?? new LocalConfigStore();
63
+ clack.intro("CompanyHelm image selection", { output });
64
+ const selectedService = options.service
65
+ ? requireManagedImageService(options.service)
66
+ : await promptForSelection("Which image do you want to pin?", MANAGED_IMAGE_SERVICES.map((service) => ({ value: service, label: service })), input, output).then((value) => requireManagedImageService(value));
67
+ const currentImage = configStore.load().images[selectedService];
68
+ clack.log.info(`Current configured image for ${selectedService}: ${currentImage ?? "default (latest)"}`, { output });
69
+ const tags = await loadAvailableTags(registry, selectedService, options.limit, output);
70
+ const currentTag = currentImage ? currentImage.slice(currentImage.lastIndexOf(":") + 1) : undefined;
71
+ const selectedTag = await promptForSelection(`Choose the ${selectedService} image tag`, tags.map((tag) => ({
72
+ value: tag.tag,
73
+ label: `${tag.tag} (${formatTagTimestamp(tag.createdAt)})`
74
+ })), input, output, currentTag);
75
+ const result = configStore.setImage(selectedService, registry.buildImageReference(selectedService, selectedTag));
76
+ clack.outro(`Updated ${result.configPath} to ${result.image}`, { output });
77
+ }
78
+ export function registerSetImageVersionCommand(program) {
79
+ program
80
+ .command("set-image-version")
81
+ .description("Interactively choose an API or frontend image tag and store it in local config.yaml.")
82
+ .option("-s, --service <service>", "Prefill the service to update (api or frontend)")
83
+ .option("-l, --limit <count>", "How many image tags to show", parsePositiveInteger, 20)
84
+ .action(async (options) => {
85
+ await runSetImageVersion(options);
86
+ });
87
+ }
@@ -0,0 +1,10 @@
1
+ import { Writable, type Readable } from "node:stream";
2
+ import type { Command } from "commander";
3
+ import { GithubAppConfigStore } from "../core/config/GithubAppConfigStore.js";
4
+ import { type GithubAppConfig } from "../core/config/GithubAppConfig.js";
5
+ type BrowserUrlOpener = (url: string) => Promise<void>;
6
+ export declare function readPemFromTerminal(input?: Readable, output?: Writable): Promise<string>;
7
+ export declare function promptGithubAppConfig(input?: Readable, output?: Writable, openBrowser?: BrowserUrlOpener): Promise<GithubAppConfig>;
8
+ export declare function ensureGithubAppConfig(store?: GithubAppConfigStore, input?: Readable, output?: Writable): Promise<GithubAppConfig>;
9
+ export declare function registerSetupGithubAppCommand(program: Command, store?: GithubAppConfigStore): void;
10
+ export {};
@@ -0,0 +1,211 @@
1
+ import * as clack from "@clack/prompts";
2
+ import chalk from "chalk";
3
+ import { spawn } from "node:child_process";
4
+ import { createInterface } from "node:readline";
5
+ import { Writable } from "node:stream";
6
+ import { unwrapPromptResult, requireInteractiveTerminal, InteractiveCommandCancelledError } from "./interactive.js";
7
+ import { GithubAppConfigStore } from "../core/config/GithubAppConfigStore.js";
8
+ import { normalizeGithubAppConfig } from "../core/config/GithubAppConfig.js";
9
+ const GITHUB_NEW_APP_URL = "https://github.com/settings/apps/new";
10
+ function createHiddenTerminalOutput() {
11
+ return new Writable({
12
+ write(_chunk, _encoding, callback) {
13
+ callback();
14
+ },
15
+ });
16
+ }
17
+ function getBrowserOpenCommand(url) {
18
+ if (process.platform === "darwin") {
19
+ return { command: "open", args: [url] };
20
+ }
21
+ if (process.platform === "win32") {
22
+ return { command: "cmd", args: ["/c", "start", "", url] };
23
+ }
24
+ return { command: "xdg-open", args: [url] };
25
+ }
26
+ async function openUrlInBrowser(url) {
27
+ const { command, args } = getBrowserOpenCommand(url);
28
+ await new Promise((resolve, reject) => {
29
+ const child = spawn(command, args, {
30
+ detached: true,
31
+ stdio: "ignore",
32
+ });
33
+ child.once("error", reject);
34
+ child.once("spawn", () => {
35
+ child.unref();
36
+ resolve();
37
+ });
38
+ });
39
+ }
40
+ function writeGithubAppCreationGuide(output) {
41
+ const divider = chalk.dim("=".repeat(68));
42
+ const label = (text) => chalk.bold(chalk.cyan(text));
43
+ const value = (text) => chalk.white(text);
44
+ output.write([
45
+ divider,
46
+ chalk.bold("Create a GitHub App before continuing"),
47
+ chalk.dim("CompanyHelm needs a local GitHub App before startup can continue."),
48
+ chalk.dim("Agents use that app to access and work on your repositories from isolated container workspaces."),
49
+ chalk.dim("That lets each agent clone repos and operate safely without reusing your host checkout."),
50
+ "",
51
+ `${label("New app page")} ${chalk.underline(chalk.green(GITHUB_NEW_APP_URL))}`,
52
+ "",
53
+ chalk.bold("Required form values"),
54
+ `${label("GitHub App name:")} ${value("Any name you like, e.g. companyhelm <your deployment name>")}`,
55
+ `${label("Public link:")} ${value("Paste your public link here")}`,
56
+ `${label("Setup URL:")} ${value("http://localhost:4173/github/install")}`,
57
+ `${label("Redirect on update:")} ${value("Checked")}`,
58
+ `${label("Webhook:")} ${value("Leave it inactive and uncheck the webhook option")}`,
59
+ `${label("Permissions:")} ${value("Grant at least Contents so CompanyHelm can download repositories; add any additional permissions your agents need")}`,
60
+ `${label("Where can this GitHub App be installed?")} ${value("Select Any account")}`,
61
+ "",
62
+ chalk.bold("Bring back after creation"),
63
+ value("App URL, Client ID, and private key PEM"),
64
+ "",
65
+ divider,
66
+ "",
67
+ ].join("\n"));
68
+ }
69
+ async function promptTextValue(message, input, output, validate) {
70
+ const value = await clack.text({
71
+ message,
72
+ input,
73
+ output,
74
+ validate,
75
+ });
76
+ return String(unwrapPromptResult(value, "GitHub App setup cancelled.", output)).trim();
77
+ }
78
+ export async function readPemFromTerminal(input = process.stdin, output = process.stdout) {
79
+ requireInteractiveTerminal(input, output, "setup-github-app requires an interactive terminal.");
80
+ output.write([
81
+ "Generate a private key.",
82
+ "Once downloaded copy the contents (e.g. cat ~/Downloads/{your-app-name}{date}.pem | pbcopy) and paste it here.",
83
+ "",
84
+ ].join("\n"));
85
+ const readline = createInterface({
86
+ input,
87
+ output: createHiddenTerminalOutput(),
88
+ terminal: false,
89
+ });
90
+ return await new Promise((resolve, reject) => {
91
+ const lines = [];
92
+ let settled = false;
93
+ const finish = (callback) => {
94
+ if (settled) {
95
+ return;
96
+ }
97
+ settled = true;
98
+ readline.close();
99
+ callback();
100
+ };
101
+ readline.on("line", (line) => {
102
+ lines.push(line);
103
+ if (/^-----END [A-Z0-9 ]+-----$/.test(line.trim())) {
104
+ finish(() => {
105
+ try {
106
+ const normalized = normalizeGithubAppConfig({
107
+ appUrl: "https://github.com/apps/placeholder",
108
+ appClientId: "placeholder",
109
+ appPrivateKeyPem: `${lines.join("\n")}\n`,
110
+ });
111
+ resolve(normalized.appPrivateKeyPem);
112
+ }
113
+ catch (error) {
114
+ reject(error);
115
+ }
116
+ });
117
+ }
118
+ });
119
+ readline.on("close", () => {
120
+ if (settled) {
121
+ return;
122
+ }
123
+ settled = true;
124
+ reject(new Error("GitHub App PEM input ended before a PEM end marker was received."));
125
+ });
126
+ readline.on("SIGINT", () => {
127
+ finish(() => {
128
+ reject(new InteractiveCommandCancelledError("GitHub App setup cancelled."));
129
+ });
130
+ });
131
+ readline.on("error", (error) => {
132
+ finish(() => {
133
+ reject(error);
134
+ });
135
+ });
136
+ });
137
+ }
138
+ export async function promptGithubAppConfig(input = process.stdin, output = process.stdout, openBrowser = openUrlInBrowser) {
139
+ requireInteractiveTerminal(input, output, "setup-github-app requires an interactive terminal.");
140
+ writeGithubAppCreationGuide(output);
141
+ const shouldOpenBrowser = unwrapPromptResult(await clack.confirm({
142
+ message: `Open ${GITHUB_NEW_APP_URL} in your browser now?`,
143
+ active: "Yes",
144
+ inactive: "No",
145
+ initialValue: true,
146
+ input,
147
+ output,
148
+ }), "GitHub App setup cancelled.", output);
149
+ if (shouldOpenBrowser) {
150
+ try {
151
+ await openBrowser(GITHUB_NEW_APP_URL);
152
+ output.write(`Opened ${GITHUB_NEW_APP_URL} in your browser.\n\n`);
153
+ }
154
+ catch {
155
+ output.write([
156
+ "Could not open a browser automatically.",
157
+ `Open this URL manually: ${GITHUB_NEW_APP_URL}`,
158
+ "",
159
+ ].join("\n"));
160
+ }
161
+ }
162
+ const appUrl = await promptTextValue("GitHub App URL", input, output, (value) => {
163
+ try {
164
+ new URL(String(value || "").trim());
165
+ return undefined;
166
+ }
167
+ catch {
168
+ return "Enter a valid GitHub App URL.";
169
+ }
170
+ });
171
+ const appClientId = await promptTextValue("GitHub App Client ID (not the App ID)", input, output, (value) => (String(value || "").trim() ? undefined : "Client ID is required."));
172
+ const appPrivateKeyPem = await readPemFromTerminal(input, output);
173
+ return normalizeGithubAppConfig({
174
+ appUrl,
175
+ appClientId,
176
+ appPrivateKeyPem,
177
+ });
178
+ }
179
+ export async function ensureGithubAppConfig(store = new GithubAppConfigStore(), input = process.stdin, output = process.stdout) {
180
+ const existingConfig = store.load();
181
+ if (existingConfig) {
182
+ return existingConfig;
183
+ }
184
+ requireInteractiveTerminal(input, output, `GitHub App config is not set up. Run \`companyhelm setup-github-app\` or rerun \`companyhelm up\` in a TTY.`);
185
+ clack.intro("CompanyHelm GitHub App setup", { output });
186
+ clack.note([
187
+ "No machine GitHub App config was found.",
188
+ "CompanyHelm will collect it now and then continue startup.",
189
+ ].join("\n"), "Setup required", { output });
190
+ const config = await promptGithubAppConfig(input, output);
191
+ const spinner = clack.spinner({ output });
192
+ spinner.start("Saving machine GitHub App config");
193
+ const configPath = store.save(config);
194
+ spinner.stop(`Saved GitHub App config to ${configPath}`);
195
+ clack.outro("GitHub App setup complete. Continuing startup.", { output });
196
+ return config;
197
+ }
198
+ export function registerSetupGithubAppCommand(program, store = new GithubAppConfigStore()) {
199
+ program
200
+ .command("setup-github-app")
201
+ .description("Save machine-wide GitHub App config for local deploys.")
202
+ .action(async () => {
203
+ clack.intro("CompanyHelm GitHub App setup");
204
+ const config = await promptGithubAppConfig();
205
+ const spinner = clack.spinner();
206
+ spinner.start("Saving machine GitHub App config");
207
+ const configPath = store.save(config);
208
+ spinner.stop(`Saved GitHub App config to ${configPath}`);
209
+ clack.outro(`Saved GitHub App config to ${configPath}.`);
210
+ });
211
+ }
@@ -1,5 +1,7 @@
1
+ import { TerminalRenderer } from "../core/ui/TerminalRenderer.js";
1
2
  export function registerStatusCommand(program, dependencies) {
2
3
  program.command("status").description("Show deployment status.").action(async () => {
3
- process.stdout.write(`${JSON.stringify(await dependencies.status())}\n`);
4
+ const renderer = new TerminalRenderer(process.stdout.isTTY);
5
+ process.stdout.write(`${renderer.renderStatus(await dependencies.status())}\n`);
4
6
  });
5
7
  }
@@ -1,5 +1,39 @@
1
+ const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
1
2
  export function registerUpCommand(program, dependencies) {
2
- program.command("up").description("Start or reconcile the local deployment.").action(async () => {
3
- await dependencies.up();
3
+ program
4
+ .command("up")
5
+ .description("Start or reconcile the local deployment.")
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.")
10
+ .action(async (options) => {
11
+ const logLevel = String(options.logLevel || "").trim().toLowerCase();
12
+ if (!LOG_LEVELS.has(logLevel)) {
13
+ throw new Error(`Unsupported log level "${options.logLevel}". Expected one of: debug, info, warn, error.`);
14
+ }
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);
4
28
  });
5
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
+ }
@@ -1,9 +1,14 @@
1
+ import type { LogLevel } from "../../commands/dependencies.js";
1
2
  import type { RuntimeState } from "../runtime/RuntimeState.js";
2
3
  export declare class DeploymentBootstrapper {
3
4
  private readonly renderer;
4
- private readonly githubAppClientId;
5
5
  writeSeedSql(root: string, state: RuntimeState, passwordHash: string, passwordSalt: string): string;
6
- writeApiConfig(root: string, state: RuntimeState): 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
  }
@@ -3,7 +3,6 @@ import { RuntimePaths } from "../runtime/RuntimePaths.js";
3
3
  import { SeedSqlRenderer } from "./SeedSqlRenderer.js";
4
4
  export class DeploymentBootstrapper {
5
5
  renderer = new SeedSqlRenderer();
6
- githubAppClientId = "Iv23lirb9vZAi67f5bSQ";
7
6
  writeSeedSql(root, state, passwordHash, passwordSalt) {
8
7
  const runtimePaths = new RuntimePaths(root);
9
8
  const outputPath = runtimePaths.seedFilePath();
@@ -19,28 +18,32 @@ export class DeploymentBootstrapper {
19
18
  fs.writeFileSync(outputPath, sql, "utf8");
20
19
  return outputPath;
21
20
  }
22
- writeApiConfig(root, state) {
21
+ writeApiConfig(root, state, logLevel = "info", options = {}) {
23
22
  const runtimePaths = new RuntimePaths(root);
24
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";
25
28
  const yaml = [
26
29
  "app:",
27
30
  ' host: "0.0.0.0"',
28
- ' port: 4000',
31
+ ` port: ${appPort}`,
29
32
  ' graphqlEndpoint: "/graphql"',
30
33
  " graphiql: true",
31
34
  " grpc:",
32
35
  ' host: "0.0.0.0"',
33
- " port: 50051",
36
+ ` port: ${runnerGrpcPort}`,
34
37
  " heartbeat:",
35
38
  " intervalMs: 20000",
36
39
  " jitterMs: 10000",
37
40
  "agent:",
38
41
  " grpc:",
39
42
  ' host: "0.0.0.0"',
40
- " port: 50052",
43
+ ` port: ${agentGrpcPort}`,
41
44
  "database:",
42
45
  ' name: "companyhelm"',
43
- ' host: "postgres"',
46
+ ` host: "${databaseHost}"`,
44
47
  " port: 5432",
45
48
  " roles:",
46
49
  " app_runtime:",
@@ -50,10 +53,9 @@ export class DeploymentBootstrapper {
50
53
  ' username: "postgres"',
51
54
  ' password: "postgres"',
52
55
  "github:",
53
- ` app_client_id: "${this.githubAppClientId}"`,
54
- " app_private_key_pem: |-",
55
- this.indentBlock(state.auth.jwtPrivateKeyPem, 4),
56
- ' app_link: "https://github.com/apps/companyhelm-local"',
56
+ ' app_client_id: "${GITHUB_APP_CLIENT_ID}"',
57
+ ' app_private_key_pem: "${GITHUB_APP_PRIVATE_KEY_PEM}"',
58
+ ' app_link: "${GITHUB_APP_URL}"',
57
59
  'authProvider: "companyhelm"',
58
60
  "auth:",
59
61
  " companyhelm:",
@@ -67,7 +69,7 @@ export class DeploymentBootstrapper {
67
69
  "security:",
68
70
  " encryption:",
69
71
  ' key: "companyhelm-local-encryption-key"',
70
- 'log_level: "info"',
72
+ `log_level: "${logLevel}"`,
71
73
  "log_pretty: false",
72
74
  ""
73
75
  ].join("\n");
@@ -8,19 +8,37 @@ export class SeedSqlRenderer {
8
8
  render(input) {
9
9
  const templatePath = path.resolve(__dirname, "../../templates/seed.sql.tpl");
10
10
  const template = fs.readFileSync(templatePath, "utf8");
11
- const email = `${input.username}@local.companyhelm`;
11
+ const email = input.username.trim();
12
+ const firstName = email.split("@")[0] || email;
12
13
  const runnerSecretHash = createHash("sha256").update(input.runnerSecret).digest("hex");
14
+ const userId = deriveUuid(input.companyId, "user");
15
+ const userAuthId = deriveUuid(input.companyId, "user-auth");
16
+ const runnerId = deriveUuid(input.companyId, "runner");
13
17
  return template
14
18
  .replaceAll("{{COMPANY_ID}}", input.companyId)
15
19
  .replaceAll("{{COMPANY_NAME}}", input.companyName)
16
- .replaceAll("{{USER_ID}}", `${input.companyId}-admin`)
17
- .replaceAll("{{USER_AUTH_ID}}", `${input.companyId}-auth`)
18
- .replaceAll("{{USER_FIRST_NAME}}", input.username)
20
+ .replaceAll("{{USER_ID}}", userId)
21
+ .replaceAll("{{USER_AUTH_ID}}", userAuthId)
22
+ .replaceAll("{{USER_FIRST_NAME}}", firstName)
19
23
  .replaceAll("{{USER_EMAIL}}", email)
20
24
  .replaceAll("{{PASSWORD_SALT}}", input.passwordSalt ?? "password-salt")
21
25
  .replaceAll("{{PASSWORD_HASH}}", input.passwordHash)
22
- .replaceAll("{{RUNNER_ID}}", `${input.companyId}-runner`)
26
+ .replaceAll("{{RUNNER_ID}}", runnerId)
23
27
  .replaceAll("{{RUNNER_NAME}}", input.runnerName)
24
28
  .replaceAll("{{RUNNER_SECRET_HASH}}", runnerSecretHash);
25
29
  }
26
30
  }
31
+ function deriveUuid(companyId, scope) {
32
+ const hex = createHash("sha256").update(`${companyId}:${scope}`).digest("hex");
33
+ const chars = hex.slice(0, 32).split("");
34
+ chars[12] = "4";
35
+ chars[16] = ((Number.parseInt(chars[16] ?? "0", 16) & 0x3) | 0x8).toString(16);
36
+ const normalized = chars.join("");
37
+ return [
38
+ normalized.slice(0, 8),
39
+ normalized.slice(8, 12),
40
+ normalized.slice(12, 16),
41
+ normalized.slice(16, 20),
42
+ normalized.slice(20, 32)
43
+ ].join("-");
44
+ }
@@ -0,0 +1,6 @@
1
+ import type { GithubAppConfig } from "./GithubAppConfig.js";
2
+ export declare class ApiEnvFileWriter {
3
+ private readonly projectPaths;
4
+ constructor(projectRoot?: string);
5
+ write(config: GithubAppConfig): string;
6
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import { ProjectPaths } from "../runtime/ProjectPaths.js";
3
+ function escapeEnvValue(value) {
4
+ return String(value || "")
5
+ .replace(/\\/g, "\\\\")
6
+ .replace(/\r\n/g, "\n")
7
+ .replace(/\r/g, "\n")
8
+ .replace(/\n/g, "\\n");
9
+ }
10
+ export class ApiEnvFileWriter {
11
+ projectPaths;
12
+ constructor(projectRoot = process.cwd()) {
13
+ this.projectPaths = new ProjectPaths(projectRoot);
14
+ }
15
+ write(config) {
16
+ fs.mkdirSync(this.projectPaths.apiDirectoryPath(), { recursive: true });
17
+ const contents = [
18
+ `GITHUB_APP_URL=${escapeEnvValue(config.appUrl)}`,
19
+ `GITHUB_APP_CLIENT_ID=${escapeEnvValue(config.appClientId)}`,
20
+ `GITHUB_APP_PRIVATE_KEY_PEM=${escapeEnvValue(config.appPrivateKeyPem)}`,
21
+ "",
22
+ ].join("\n");
23
+ fs.writeFileSync(this.projectPaths.apiEnvPath(), contents, "utf8");
24
+ return this.projectPaths.apiEnvPath();
25
+ }
26
+ }
@@ -0,0 +1,6 @@
1
+ export interface GithubAppConfig {
2
+ appUrl: string;
3
+ appClientId: string;
4
+ appPrivateKeyPem: string;
5
+ }
6
+ export declare function normalizeGithubAppConfig(config: GithubAppConfig): GithubAppConfig;
@@ -0,0 +1,26 @@
1
+ function normalizeRequiredValue(value, fieldName) {
2
+ const normalized = String(value || "").trim();
3
+ if (!normalized) {
4
+ throw new Error(`GitHub App config field "${fieldName}" is required.`);
5
+ }
6
+ return normalized;
7
+ }
8
+ export function normalizeGithubAppConfig(config) {
9
+ const appUrl = normalizeRequiredValue(config.appUrl, "appUrl");
10
+ try {
11
+ new URL(appUrl);
12
+ }
13
+ catch {
14
+ throw new Error(`GitHub App config field "appUrl" must be a valid URL.`);
15
+ }
16
+ const appClientId = normalizeRequiredValue(config.appClientId, "appClientId");
17
+ const rawPem = String(config.appPrivateKeyPem || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
18
+ if (!rawPem) {
19
+ throw new Error('GitHub App config field "appPrivateKeyPem" is required.');
20
+ }
21
+ return {
22
+ appUrl,
23
+ appClientId,
24
+ appPrivateKeyPem: `${rawPem}\n`,
25
+ };
26
+ }
@@ -0,0 +1,11 @@
1
+ import { type GithubAppConfig } from "./GithubAppConfig.js";
2
+ export declare class GithubAppConfigStore {
3
+ private readonly configRoot;
4
+ constructor(configRoot?: string);
5
+ configPath(): string;
6
+ hasConfig(): boolean;
7
+ save(config: GithubAppConfig): string;
8
+ load(): GithubAppConfig | null;
9
+ loadOrThrow(): GithubAppConfig;
10
+ delete(): void;
11
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { parse, stringify } from "yaml";
5
+ import { normalizeGithubAppConfig } from "./GithubAppConfig.js";
6
+ function defaultConfigRoot() {
7
+ const explicitRoot = String(process.env.COMPANYHELM_CONFIG_HOME || "").trim();
8
+ if (explicitRoot) {
9
+ return path.resolve(explicitRoot);
10
+ }
11
+ const xdgRoot = String(process.env.XDG_CONFIG_HOME || "").trim();
12
+ if (xdgRoot) {
13
+ return path.resolve(xdgRoot, "companyhelm");
14
+ }
15
+ return path.join(os.homedir(), ".config", "companyhelm");
16
+ }
17
+ export class GithubAppConfigStore {
18
+ configRoot;
19
+ constructor(configRoot = defaultConfigRoot()) {
20
+ this.configRoot = configRoot;
21
+ }
22
+ configPath() {
23
+ return path.join(this.configRoot, "github-app.yaml");
24
+ }
25
+ hasConfig() {
26
+ return fs.existsSync(this.configPath());
27
+ }
28
+ save(config) {
29
+ const normalized = normalizeGithubAppConfig(config);
30
+ fs.mkdirSync(this.configRoot, { recursive: true });
31
+ const tempPath = `${this.configPath()}.tmp`;
32
+ const yaml = stringify({
33
+ app_url: normalized.appUrl,
34
+ app_client_id: normalized.appClientId,
35
+ app_private_key_pem: normalized.appPrivateKeyPem,
36
+ });
37
+ fs.writeFileSync(tempPath, yaml, { encoding: "utf8", mode: 0o600 });
38
+ fs.renameSync(tempPath, this.configPath());
39
+ return this.configPath();
40
+ }
41
+ load() {
42
+ if (!this.hasConfig()) {
43
+ return null;
44
+ }
45
+ const parsed = parse(fs.readFileSync(this.configPath(), "utf8"));
46
+ if (!parsed || typeof parsed !== "object") {
47
+ throw new Error(`Machine GitHub App config at ${this.configPath()} is invalid.`);
48
+ }
49
+ return normalizeGithubAppConfig({
50
+ appUrl: String(parsed.app_url || ""),
51
+ appClientId: String(parsed.app_client_id || ""),
52
+ appPrivateKeyPem: String(parsed.app_private_key_pem || ""),
53
+ });
54
+ }
55
+ loadOrThrow() {
56
+ const config = this.load();
57
+ if (!config) {
58
+ throw new Error(`GitHub App config is not set up. Run \`companyhelm setup-github-app\` to create ${this.configPath()}.`);
59
+ }
60
+ return config;
61
+ }
62
+ delete() {
63
+ fs.rmSync(this.configPath(), { force: true });
64
+ }
65
+ }
@@ -1,3 +1,4 @@
1
+ import type { LogLevel } from "../../commands/dependencies.js";
1
2
  export interface ComposePorts {
2
3
  apiHttpPort: number;
3
4
  uiPort: number;
@@ -6,9 +7,16 @@ export interface ComposePorts {
6
7
  }
7
8
  export interface ComposePaths {
8
9
  apiConfigPath: string;
10
+ apiEnvPath: string;
9
11
  frontendConfigPath: string;
10
12
  seedFilePath: string;
11
13
  }
14
+ export interface ComposeRenderOptions {
15
+ frontendLogLevel?: LogLevel;
16
+ includeApi?: boolean;
17
+ includeFrontend?: boolean;
18
+ exposePostgresPort?: boolean;
19
+ }
12
20
  export declare class ComposeTemplateRenderer {
13
- render(ports: ComposePorts, paths: ComposePaths): string;
21
+ render(ports: ComposePorts, paths: ComposePaths, options?: ComposeRenderOptions): string;
14
22
  }