@companyhelm/cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -33
- package/dist/cli.js +11 -1
- package/dist/commands/dependencies.d.ts +18 -3
- package/dist/commands/dependencies.js +76 -13
- package/dist/commands/interactive.d.ts +6 -0
- package/dist/commands/interactive.js +22 -0
- package/dist/commands/logs.js +6 -1
- package/dist/commands/register-commands.js +4 -0
- package/dist/commands/reset.d.ts +4 -0
- package/dist/commands/reset.js +43 -4
- package/dist/commands/set-image-version.d.ts +31 -0
- package/dist/commands/set-image-version.js +87 -0
- package/dist/commands/setup-github-app.d.ts +10 -0
- package/dist/commands/setup-github-app.js +211 -0
- package/dist/commands/status.js +3 -1
- package/dist/commands/up.js +11 -2
- package/dist/core/bootstrap/DeploymentBootstrapper.d.ts +2 -2
- package/dist/core/bootstrap/DeploymentBootstrapper.js +5 -7
- package/dist/core/bootstrap/SeedSqlRenderer.js +23 -5
- package/dist/core/config/ApiEnvFileWriter.d.ts +6 -0
- package/dist/core/config/ApiEnvFileWriter.js +26 -0
- package/dist/core/config/GithubAppConfig.d.ts +6 -0
- package/dist/core/config/GithubAppConfig.js +26 -0
- package/dist/core/config/GithubAppConfigStore.d.ts +11 -0
- package/dist/core/config/GithubAppConfigStore.js +65 -0
- package/dist/core/docker/ComposeTemplateRenderer.d.ts +6 -1
- package/dist/core/docker/ComposeTemplateRenderer.js +22 -4
- package/dist/core/docker/DockerStackManager.d.ts +15 -3
- package/dist/core/docker/DockerStackManager.js +67 -8
- package/dist/core/runner/RunnerSupervisor.d.ts +4 -0
- package/dist/core/runner/RunnerSupervisor.js +19 -3
- package/dist/core/runtime/ImageCatalog.js +5 -2
- package/dist/core/runtime/LocalConfigStore.d.ts +16 -0
- package/dist/core/runtime/LocalConfigStore.js +59 -0
- package/dist/core/runtime/ManagedImages.d.ts +10 -0
- package/dist/core/runtime/ManagedImages.js +27 -0
- package/dist/core/runtime/ProjectPaths.d.ts +7 -0
- package/dist/core/runtime/ProjectPaths.js +16 -0
- package/dist/core/runtime/PublicImageTagRegistry.d.ts +16 -0
- package/dist/core/runtime/PublicImageTagRegistry.js +148 -0
- package/dist/core/runtime/RuntimeState.d.ts +1 -1
- package/dist/core/runtime/RuntimeStateStore.d.ts +1 -0
- package/dist/core/runtime/RuntimeStateStore.js +8 -2
- package/dist/core/runtime/VersionCatalog.d.ts +10 -0
- package/dist/core/runtime/VersionCatalog.js +21 -0
- package/dist/core/status/StatusService.d.ts +5 -1
- package/dist/core/status/StatusService.js +5 -2
- package/dist/core/ui/TerminalRenderer.d.ts +10 -0
- package/dist/core/ui/TerminalRenderer.js +48 -0
- package/dist/templates/docker-compose.yaml.tpl +4 -13
- package/dist/templates/seed.sql.tpl +32 -13
- package/package.json +7 -3
- package/src/templates/docker-compose.yaml.tpl +4 -13
- package/src/templates/seed.sql.tpl +32 -13
|
@@ -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
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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.
|
|
4
|
+
const renderer = new TerminalRenderer(process.stdout.isTTY);
|
|
5
|
+
process.stdout.write(`${renderer.renderStatus(await dependencies.status())}\n`);
|
|
4
6
|
});
|
|
5
7
|
}
|
package/dist/commands/up.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
|
|
1
2
|
export function registerUpCommand(program, dependencies) {
|
|
2
|
-
program
|
|
3
|
-
|
|
3
|
+
program
|
|
4
|
+
.command("up")
|
|
5
|
+
.description("Start or reconcile the local deployment.")
|
|
6
|
+
.option("--log-level <level>", "Set log level for api, frontend, and runner.", "info")
|
|
7
|
+
.action(async (options) => {
|
|
8
|
+
const logLevel = String(options.logLevel || "").trim().toLowerCase();
|
|
9
|
+
if (!LOG_LEVELS.has(logLevel)) {
|
|
10
|
+
throw new Error(`Unsupported log level "${options.logLevel}". Expected one of: debug, info, warn, error.`);
|
|
11
|
+
}
|
|
12
|
+
await dependencies.up({ logLevel: logLevel });
|
|
4
13
|
});
|
|
5
14
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
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): string;
|
|
7
7
|
writeFrontendConfig(root: string, state: RuntimeState): string;
|
|
8
8
|
private indentBlock;
|
|
9
9
|
}
|
|
@@ -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,7 +18,7 @@ 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") {
|
|
23
22
|
const runtimePaths = new RuntimePaths(root);
|
|
24
23
|
const outputPath = runtimePaths.apiConfigPath();
|
|
25
24
|
const yaml = [
|
|
@@ -50,10 +49,9 @@ export class DeploymentBootstrapper {
|
|
|
50
49
|
' username: "postgres"',
|
|
51
50
|
' password: "postgres"',
|
|
52
51
|
"github:",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
' app_link: "https://github.com/apps/companyhelm-local"',
|
|
52
|
+
' app_client_id: "${GITHUB_APP_CLIENT_ID}"',
|
|
53
|
+
' app_private_key_pem: "${GITHUB_APP_PRIVATE_KEY_PEM}"',
|
|
54
|
+
' app_link: "${GITHUB_APP_URL}"',
|
|
57
55
|
'authProvider: "companyhelm"',
|
|
58
56
|
"auth:",
|
|
59
57
|
" companyhelm:",
|
|
@@ -67,7 +65,7 @@ export class DeploymentBootstrapper {
|
|
|
67
65
|
"security:",
|
|
68
66
|
" encryption:",
|
|
69
67
|
' key: "companyhelm-local-encryption-key"',
|
|
70
|
-
|
|
68
|
+
`log_level: "${logLevel}"`,
|
|
71
69
|
"log_pretty: false",
|
|
72
70
|
""
|
|
73
71
|
].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 =
|
|
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}}",
|
|
17
|
-
.replaceAll("{{USER_AUTH_ID}}",
|
|
18
|
-
.replaceAll("{{USER_FIRST_NAME}}",
|
|
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}}",
|
|
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,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,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,13 @@ 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
|
+
}
|
|
12
17
|
export declare class ComposeTemplateRenderer {
|
|
13
|
-
render(ports: ComposePorts, paths: ComposePaths): string;
|
|
18
|
+
render(ports: ComposePorts, paths: ComposePaths, options?: ComposeRenderOptions): string;
|
|
14
19
|
}
|
|
@@ -5,20 +5,38 @@ 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 frontendBlock = [
|
|
14
|
+
" frontend:",
|
|
15
|
+
` image: ${images.frontend}`,
|
|
16
|
+
" depends_on:",
|
|
17
|
+
" - api",
|
|
18
|
+
" environment:",
|
|
19
|
+
" COMPANYHELM_CONFIG_PATH: /run/companyhelm/config.yaml",
|
|
20
|
+
` COMPANYHELM_LOG_LEVEL: "${frontendLogLevel}"`,
|
|
21
|
+
` PORT: "${ports.uiPort}"`,
|
|
22
|
+
` npm_config_loglevel: "${frontendLogLevel}"`,
|
|
23
|
+
" ports:",
|
|
24
|
+
` - "${ports.uiPort}:${ports.uiPort}"`,
|
|
25
|
+
" volumes:",
|
|
26
|
+
` - "${paths.frontendConfigPath}:/run/companyhelm/config.yaml:ro"`,
|
|
27
|
+
" networks:",
|
|
28
|
+
" - companyhelm"
|
|
29
|
+
].join("\n");
|
|
12
30
|
return template
|
|
13
31
|
.replaceAll("{{API_IMAGE}}", images.api)
|
|
14
|
-
.replaceAll("{{FRONTEND_IMAGE}}", images.frontend)
|
|
15
32
|
.replaceAll("{{POSTGRES_IMAGE}}", images.postgres)
|
|
16
33
|
.replaceAll("{{API_CONFIG_PATH}}", paths.apiConfigPath)
|
|
17
|
-
.replaceAll("{{
|
|
34
|
+
.replaceAll("{{API_ENV_PATH}}", paths.apiEnvPath)
|
|
18
35
|
.replaceAll("{{SEED_FILE_PATH}}", paths.seedFilePath)
|
|
19
36
|
.replaceAll("{{API_HTTP_PORT}}", String(ports.apiHttpPort))
|
|
20
37
|
.replaceAll("{{UI_PORT}}", String(ports.uiPort))
|
|
21
38
|
.replaceAll("{{RUNNER_GRPC_PORT}}", String(ports.runnerGrpcPort))
|
|
22
|
-
.replaceAll("{{AGENT_CLI_GRPC_PORT}}", String(ports.agentCliGrpcPort))
|
|
39
|
+
.replaceAll("{{AGENT_CLI_GRPC_PORT}}", String(ports.agentCliGrpcPort))
|
|
40
|
+
.replace("{{FRONTEND_SERVICE_BLOCK}}", frontendBlock);
|
|
23
41
|
}
|
|
24
42
|
}
|
|
@@ -1,14 +1,26 @@
|
|
|
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
|
+
}
|
|
8
|
+
export interface DockerStackDownOptions {
|
|
9
|
+
removeVolumes?: boolean;
|
|
10
|
+
}
|
|
4
11
|
export declare class DockerStackManager {
|
|
5
12
|
private readonly commandRunner;
|
|
6
13
|
private readonly composeRenderer;
|
|
14
|
+
private static readonly BOOTSTRAP_RETRY_COUNT;
|
|
15
|
+
private static readonly BOOTSTRAP_RETRY_DELAY_MS;
|
|
7
16
|
private readonly runtimePaths;
|
|
8
17
|
constructor(root: string, commandRunner?: CommandRunner, composeRenderer?: ComposeTemplateRenderer);
|
|
9
|
-
up(state: RuntimeState): Promise<void>;
|
|
10
|
-
applySeedSql(): Promise<void>;
|
|
11
|
-
|
|
18
|
+
up(state: RuntimeState, options?: DockerStackUpOptions): Promise<void>;
|
|
19
|
+
applySeedSql(seedEmail: string): Promise<void>;
|
|
20
|
+
private seedSchemaReady;
|
|
21
|
+
private seedAlreadyApplied;
|
|
22
|
+
private waitForNextBootstrapAttempt;
|
|
23
|
+
down(options?: DockerStackDownOptions): Promise<void>;
|
|
12
24
|
logs(service: "postgres" | "api" | "frontend"): Promise<void>;
|
|
13
25
|
runningServices(): Promise<string>;
|
|
14
26
|
}
|