@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
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { CommandRunner } from "../process/CommandRunner.js";
|
|
3
|
+
import { ProjectPaths } from "../runtime/ProjectPaths.js";
|
|
3
4
|
import { RuntimePaths } from "../runtime/RuntimePaths.js";
|
|
4
5
|
import { ComposeTemplateRenderer } from "./ComposeTemplateRenderer.js";
|
|
5
6
|
export class DockerStackManager {
|
|
6
7
|
commandRunner;
|
|
7
8
|
composeRenderer;
|
|
9
|
+
static BOOTSTRAP_RETRY_COUNT = 60;
|
|
10
|
+
static BOOTSTRAP_RETRY_DELAY_MS = 1000;
|
|
8
11
|
runtimePaths;
|
|
9
12
|
constructor(root, commandRunner = new CommandRunner(), composeRenderer = new ComposeTemplateRenderer()) {
|
|
10
13
|
this.commandRunner = commandRunner;
|
|
11
14
|
this.composeRenderer = composeRenderer;
|
|
12
15
|
this.runtimePaths = new RuntimePaths(root);
|
|
13
16
|
}
|
|
14
|
-
async up(state) {
|
|
17
|
+
async up(state, options = {}) {
|
|
15
18
|
fs.mkdirSync(this.runtimePaths.runnerConfigPath(), { recursive: true });
|
|
16
19
|
fs.writeFileSync(this.runtimePaths.composeFilePath(), this.composeRenderer.render({
|
|
17
20
|
apiHttpPort: state.ports.apiHttp,
|
|
@@ -20,8 +23,11 @@ export class DockerStackManager {
|
|
|
20
23
|
agentCliGrpcPort: state.ports.agentCliGrpc
|
|
21
24
|
}, {
|
|
22
25
|
apiConfigPath: this.runtimePaths.apiConfigPath(),
|
|
26
|
+
apiEnvPath: new ProjectPaths(process.cwd()).apiEnvPath(),
|
|
23
27
|
frontendConfigPath: this.runtimePaths.frontendConfigPath(),
|
|
24
28
|
seedFilePath: this.runtimePaths.seedFilePath()
|
|
29
|
+
}, {
|
|
30
|
+
frontendLogLevel: options.frontendLogLevel
|
|
25
31
|
}), "utf8");
|
|
26
32
|
await this.commandRunner.run("docker", [
|
|
27
33
|
"compose",
|
|
@@ -31,13 +37,20 @@ export class DockerStackManager {
|
|
|
31
37
|
"-d"
|
|
32
38
|
]);
|
|
33
39
|
}
|
|
34
|
-
async applySeedSql() {
|
|
40
|
+
async applySeedSql(seedEmail) {
|
|
35
41
|
if (!fs.existsSync(this.runtimePaths.seedFilePath())) {
|
|
36
42
|
return;
|
|
37
43
|
}
|
|
38
44
|
let lastError = null;
|
|
39
|
-
for (let attempt = 0; attempt <
|
|
45
|
+
for (let attempt = 0; attempt < DockerStackManager.BOOTSTRAP_RETRY_COUNT; attempt += 1) {
|
|
40
46
|
try {
|
|
47
|
+
if (!(await this.seedSchemaReady())) {
|
|
48
|
+
await this.waitForNextBootstrapAttempt();
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (await this.seedAlreadyApplied(seedEmail)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
41
54
|
await this.commandRunner.run("docker", [
|
|
42
55
|
"compose",
|
|
43
56
|
"-f",
|
|
@@ -57,23 +70,69 @@ export class DockerStackManager {
|
|
|
57
70
|
}
|
|
58
71
|
catch (error) {
|
|
59
72
|
lastError = error;
|
|
60
|
-
await
|
|
61
|
-
setTimeout(resolve, 1000);
|
|
62
|
-
});
|
|
73
|
+
await this.waitForNextBootstrapAttempt();
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
throw lastError ?? new Error("Failed to apply seed SQL.");
|
|
66
77
|
}
|
|
67
|
-
async
|
|
78
|
+
async seedSchemaReady() {
|
|
79
|
+
const output = await this.commandRunner.capture("docker", [
|
|
80
|
+
"compose",
|
|
81
|
+
"-f",
|
|
82
|
+
this.runtimePaths.composeFilePath(),
|
|
83
|
+
"exec",
|
|
84
|
+
"-T",
|
|
85
|
+
"postgres",
|
|
86
|
+
"psql",
|
|
87
|
+
"-U",
|
|
88
|
+
"postgres",
|
|
89
|
+
"-d",
|
|
90
|
+
"companyhelm",
|
|
91
|
+
"-tAc",
|
|
92
|
+
"SELECT to_regclass('public.user_auths') IS NOT NULL"
|
|
93
|
+
]);
|
|
94
|
+
return output.trim() === "t";
|
|
95
|
+
}
|
|
96
|
+
async seedAlreadyApplied(seedEmail) {
|
|
97
|
+
const escapedEmail = seedEmail.replaceAll("'", "''");
|
|
98
|
+
const output = await this.commandRunner.capture("docker", [
|
|
99
|
+
"compose",
|
|
100
|
+
"-f",
|
|
101
|
+
this.runtimePaths.composeFilePath(),
|
|
102
|
+
"exec",
|
|
103
|
+
"-T",
|
|
104
|
+
"postgres",
|
|
105
|
+
"psql",
|
|
106
|
+
"-U",
|
|
107
|
+
"postgres",
|
|
108
|
+
"-d",
|
|
109
|
+
"companyhelm",
|
|
110
|
+
"-tAc",
|
|
111
|
+
`SELECT 1 FROM user_auths WHERE email = '${escapedEmail}' LIMIT 1`
|
|
112
|
+
]);
|
|
113
|
+
return output.trim() === "1";
|
|
114
|
+
}
|
|
115
|
+
async waitForNextBootstrapAttempt() {
|
|
116
|
+
await new Promise((resolve) => {
|
|
117
|
+
setTimeout(resolve, DockerStackManager.BOOTSTRAP_RETRY_DELAY_MS);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async down(options = {}) {
|
|
68
121
|
if (!fs.existsSync(this.runtimePaths.composeFilePath())) {
|
|
69
122
|
return;
|
|
70
123
|
}
|
|
71
|
-
|
|
124
|
+
const args = [
|
|
72
125
|
"compose",
|
|
73
126
|
"-f",
|
|
74
127
|
this.runtimePaths.composeFilePath(),
|
|
75
128
|
"down",
|
|
76
129
|
"--remove-orphans"
|
|
130
|
+
];
|
|
131
|
+
if (options.removeVolumes) {
|
|
132
|
+
args.push("--volumes");
|
|
133
|
+
}
|
|
134
|
+
await this.commandRunner.run("docker", [
|
|
135
|
+
...args
|
|
77
136
|
]);
|
|
78
137
|
}
|
|
79
138
|
async logs(service) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { LogLevel } from "../../commands/dependencies.js";
|
|
1
2
|
export interface RunnerStartInput {
|
|
2
3
|
serverUrl: string;
|
|
3
4
|
agentApiUrl: string;
|
|
4
5
|
logPath: string;
|
|
5
6
|
secret: string;
|
|
7
|
+
logLevel?: LogLevel;
|
|
6
8
|
}
|
|
7
9
|
export interface RunnerStartCommand {
|
|
8
10
|
command: string;
|
|
@@ -11,7 +13,9 @@ export interface RunnerStartCommand {
|
|
|
11
13
|
export declare class RunnerSupervisor {
|
|
12
14
|
private readonly configPath;
|
|
13
15
|
constructor(configPath: string);
|
|
16
|
+
buildUseHostAuthArgs(): RunnerStartCommand;
|
|
14
17
|
buildStartArgs(input: RunnerStartInput): RunnerStartCommand;
|
|
15
18
|
buildStopArgs(): RunnerStartCommand;
|
|
19
|
+
buildStatusArgs(): RunnerStartCommand;
|
|
16
20
|
private resolveRunnerCliPath;
|
|
17
21
|
}
|
|
@@ -6,15 +6,22 @@ export class RunnerSupervisor {
|
|
|
6
6
|
constructor(configPath) {
|
|
7
7
|
this.configPath = configPath;
|
|
8
8
|
}
|
|
9
|
+
buildUseHostAuthArgs() {
|
|
10
|
+
const runnerCliPath = this.resolveRunnerCliPath();
|
|
11
|
+
return {
|
|
12
|
+
command: process.execPath,
|
|
13
|
+
args: [runnerCliPath, "--config-path", this.configPath, "sdk", "codex", "use-host-auth"]
|
|
14
|
+
};
|
|
15
|
+
}
|
|
9
16
|
buildStartArgs(input) {
|
|
10
17
|
const runnerCliPath = this.resolveRunnerCliPath();
|
|
18
|
+
const logLevel = (input.logLevel ?? "info").toUpperCase();
|
|
11
19
|
return {
|
|
12
20
|
command: process.execPath,
|
|
13
21
|
args: [
|
|
14
22
|
runnerCliPath,
|
|
15
23
|
"--config-path",
|
|
16
24
|
this.configPath,
|
|
17
|
-
"runner",
|
|
18
25
|
"start",
|
|
19
26
|
"--daemon",
|
|
20
27
|
"--server-url",
|
|
@@ -24,7 +31,9 @@ export class RunnerSupervisor {
|
|
|
24
31
|
"--log-path",
|
|
25
32
|
input.logPath,
|
|
26
33
|
"--secret",
|
|
27
|
-
input.secret
|
|
34
|
+
input.secret,
|
|
35
|
+
"--log-level",
|
|
36
|
+
logLevel
|
|
28
37
|
]
|
|
29
38
|
};
|
|
30
39
|
}
|
|
@@ -32,7 +41,14 @@ export class RunnerSupervisor {
|
|
|
32
41
|
const runnerCliPath = this.resolveRunnerCliPath();
|
|
33
42
|
return {
|
|
34
43
|
command: process.execPath,
|
|
35
|
-
args: [runnerCliPath, "--config-path", this.configPath, "
|
|
44
|
+
args: [runnerCliPath, "--config-path", this.configPath, "stop"]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
buildStatusArgs() {
|
|
48
|
+
const runnerCliPath = this.resolveRunnerCliPath();
|
|
49
|
+
return {
|
|
50
|
+
command: process.execPath,
|
|
51
|
+
args: [runnerCliPath, "--config-path", this.configPath, "status"]
|
|
36
52
|
};
|
|
37
53
|
}
|
|
38
54
|
resolveRunnerCliPath() {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { LocalConfigStore } from "./LocalConfigStore.js";
|
|
2
|
+
import { defaultManagedImageReference } from "./ManagedImages.js";
|
|
1
3
|
export class ImageCatalog {
|
|
2
4
|
resolve() {
|
|
5
|
+
const configuredImages = new LocalConfigStore().load().images;
|
|
3
6
|
return {
|
|
4
|
-
api: process.env.COMPANYHELM_API_IMAGE || "
|
|
5
|
-
frontend: process.env.COMPANYHELM_WEB_IMAGE || "
|
|
7
|
+
api: configuredImages.api || process.env.COMPANYHELM_API_IMAGE || defaultManagedImageReference("api"),
|
|
8
|
+
frontend: configuredImages.frontend || process.env.COMPANYHELM_WEB_IMAGE || defaultManagedImageReference("frontend"),
|
|
6
9
|
postgres: process.env.COMPANYHELM_POSTGRES_IMAGE || "postgres:16-alpine"
|
|
7
10
|
};
|
|
8
11
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ManagedImageService } from "./ManagedImages.js";
|
|
2
|
+
export interface LocalConfig {
|
|
3
|
+
images: Partial<Record<ManagedImageService, string>>;
|
|
4
|
+
}
|
|
5
|
+
export declare class LocalConfigStore {
|
|
6
|
+
private readonly root;
|
|
7
|
+
constructor(root?: string);
|
|
8
|
+
configPath(): string;
|
|
9
|
+
load(): LocalConfig;
|
|
10
|
+
setImage(service: ManagedImageService, image: string): {
|
|
11
|
+
configPath: string;
|
|
12
|
+
image: string;
|
|
13
|
+
};
|
|
14
|
+
save(config: LocalConfig): void;
|
|
15
|
+
private parse;
|
|
16
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class LocalConfigStore {
|
|
4
|
+
root;
|
|
5
|
+
constructor(root = process.cwd()) {
|
|
6
|
+
this.root = root;
|
|
7
|
+
}
|
|
8
|
+
configPath() {
|
|
9
|
+
return path.join(this.root, "config.yaml");
|
|
10
|
+
}
|
|
11
|
+
load() {
|
|
12
|
+
const configPath = this.configPath();
|
|
13
|
+
if (!fs.existsSync(configPath)) {
|
|
14
|
+
return { images: {} };
|
|
15
|
+
}
|
|
16
|
+
return this.parse(fs.readFileSync(configPath, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
setImage(service, image) {
|
|
19
|
+
const nextConfig = this.load();
|
|
20
|
+
nextConfig.images[service] = image;
|
|
21
|
+
this.save(nextConfig);
|
|
22
|
+
return { configPath: this.configPath(), image };
|
|
23
|
+
}
|
|
24
|
+
save(config) {
|
|
25
|
+
const lines = ["images:"];
|
|
26
|
+
if (config.images.api) {
|
|
27
|
+
lines.push(` api: ${config.images.api}`);
|
|
28
|
+
}
|
|
29
|
+
if (config.images.frontend) {
|
|
30
|
+
lines.push(` frontend: ${config.images.frontend}`);
|
|
31
|
+
}
|
|
32
|
+
fs.writeFileSync(this.configPath(), `${lines.join("\n")}\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
parse(content) {
|
|
35
|
+
const images = {};
|
|
36
|
+
let inImagesSection = false;
|
|
37
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
38
|
+
const line = rawLine.trimEnd();
|
|
39
|
+
if (line.trim().length === 0 || line.trimStart().startsWith("#")) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (line === "images:") {
|
|
43
|
+
inImagesSection = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (inImagesSection && /^[^\s]/.test(line)) {
|
|
47
|
+
inImagesSection = false;
|
|
48
|
+
}
|
|
49
|
+
if (!inImagesSection) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const match = line.match(/^ (api|frontend):\s*(.+)$/);
|
|
53
|
+
if (match) {
|
|
54
|
+
images[match[1]] = match[2];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { images };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const MANAGED_IMAGE_SERVICES: readonly ["api", "frontend"];
|
|
2
|
+
export type ManagedImageService = (typeof MANAGED_IMAGE_SERVICES)[number];
|
|
3
|
+
export interface ManagedImageDefinition {
|
|
4
|
+
imageUri: string;
|
|
5
|
+
repositoryPath: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function requireManagedImageService(value: string): ManagedImageService;
|
|
8
|
+
export declare function getManagedImageDefinition(service: ManagedImageService): ManagedImageDefinition;
|
|
9
|
+
export declare function buildManagedImageReference(service: ManagedImageService, tag: string): string;
|
|
10
|
+
export declare function defaultManagedImageReference(service: ManagedImageService): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const MANAGED_IMAGE_SERVICES = ["api", "frontend"];
|
|
2
|
+
const MANAGED_IMAGE_DEFINITIONS = {
|
|
3
|
+
api: {
|
|
4
|
+
imageUri: "public.ecr.aws/x6n0f2k4/companyhelm-api",
|
|
5
|
+
repositoryPath: "x6n0f2k4/companyhelm-api"
|
|
6
|
+
},
|
|
7
|
+
frontend: {
|
|
8
|
+
imageUri: "public.ecr.aws/x6n0f2k4/companyhelm-web",
|
|
9
|
+
repositoryPath: "x6n0f2k4/companyhelm-web"
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export function requireManagedImageService(value) {
|
|
13
|
+
const normalized = value.trim().toLowerCase();
|
|
14
|
+
if (normalized === "api" || normalized === "frontend") {
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unsupported image service "${value}". Expected one of: api, frontend.`);
|
|
18
|
+
}
|
|
19
|
+
export function getManagedImageDefinition(service) {
|
|
20
|
+
return MANAGED_IMAGE_DEFINITIONS[service];
|
|
21
|
+
}
|
|
22
|
+
export function buildManagedImageReference(service, tag) {
|
|
23
|
+
return `${MANAGED_IMAGE_DEFINITIONS[service].imageUri}:${tag}`;
|
|
24
|
+
}
|
|
25
|
+
export function defaultManagedImageReference(service) {
|
|
26
|
+
return buildManagedImageReference(service, "latest");
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export class ProjectPaths {
|
|
3
|
+
root;
|
|
4
|
+
constructor(root = process.cwd()) {
|
|
5
|
+
this.root = root;
|
|
6
|
+
}
|
|
7
|
+
companyhelmRootPath() {
|
|
8
|
+
return path.join(this.root, ".companyhelm");
|
|
9
|
+
}
|
|
10
|
+
apiDirectoryPath() {
|
|
11
|
+
return path.join(this.companyhelmRootPath(), "api");
|
|
12
|
+
}
|
|
13
|
+
apiEnvPath() {
|
|
14
|
+
return path.join(this.apiDirectoryPath(), ".env");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ManagedImageService } from "./ManagedImages.js";
|
|
2
|
+
interface PublicRegistryImageTag {
|
|
3
|
+
tag: string;
|
|
4
|
+
createdAt?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class PublicImageTagRegistry {
|
|
7
|
+
listAvailableTags(service: ManagedImageService, limit?: number): Promise<PublicRegistryImageTag[]>;
|
|
8
|
+
buildImageReference(service: ManagedImageService, tag: string): string;
|
|
9
|
+
private fetchToken;
|
|
10
|
+
private fetchCreatedAt;
|
|
11
|
+
private selectManifestDigest;
|
|
12
|
+
private fetchJson;
|
|
13
|
+
private parseRetryAfterMs;
|
|
14
|
+
private sleep;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { buildManagedImageReference, getManagedImageDefinition } from "./ManagedImages.js";
|
|
2
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
|
|
3
|
+
const MAX_FETCH_ATTEMPTS = 3;
|
|
4
|
+
const DEFAULT_RETRY_DELAY_MS = 250;
|
|
5
|
+
class PublicRegistryRequestError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
retryAfterMs;
|
|
8
|
+
constructor(url, status, retryAfterMs) {
|
|
9
|
+
super(`Public registry returned ${status} for ${url}.`);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.retryAfterMs = retryAfterMs;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class PublicImageTagRegistry {
|
|
15
|
+
async listAvailableTags(service, limit = 20) {
|
|
16
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
17
|
+
throw new Error("Image tag limit must be a positive integer.");
|
|
18
|
+
}
|
|
19
|
+
const { repositoryPath } = getManagedImageDefinition(service);
|
|
20
|
+
const token = await this.fetchToken(repositoryPath);
|
|
21
|
+
const response = await fetch(`https://public.ecr.aws/v2/${repositoryPath}/tags/list`, {
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${token}`
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`Public registry returned ${response.status} for ${service} tags.`);
|
|
28
|
+
}
|
|
29
|
+
const payload = (await response.json());
|
|
30
|
+
if (payload.errors?.length) {
|
|
31
|
+
throw new Error(payload.errors[0]?.message || `Unable to load ${service} image tags.`);
|
|
32
|
+
}
|
|
33
|
+
const uniqueTags = [...new Set(payload.tags ?? [])].map((tag, position) => ({ tag, position }));
|
|
34
|
+
const createdAtByDigest = new Map();
|
|
35
|
+
const tagsWithMetadata = [];
|
|
36
|
+
for (const { tag, position } of uniqueTags) {
|
|
37
|
+
tagsWithMetadata.push({
|
|
38
|
+
tag,
|
|
39
|
+
position,
|
|
40
|
+
createdAt: await this.fetchCreatedAt(repositoryPath, token, tag, createdAtByDigest)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return tagsWithMetadata
|
|
44
|
+
.sort((left, right) => {
|
|
45
|
+
const leftTimestamp = left.createdAt ? Date.parse(left.createdAt) : Number.NEGATIVE_INFINITY;
|
|
46
|
+
const rightTimestamp = right.createdAt ? Date.parse(right.createdAt) : Number.NEGATIVE_INFINITY;
|
|
47
|
+
if (rightTimestamp !== leftTimestamp) {
|
|
48
|
+
return rightTimestamp - leftTimestamp;
|
|
49
|
+
}
|
|
50
|
+
return left.position - right.position;
|
|
51
|
+
})
|
|
52
|
+
.slice(0, limit)
|
|
53
|
+
.map(({ tag, createdAt }) => ({ tag, createdAt }));
|
|
54
|
+
}
|
|
55
|
+
buildImageReference(service, tag) {
|
|
56
|
+
return buildManagedImageReference(service, tag);
|
|
57
|
+
}
|
|
58
|
+
async fetchToken(repositoryPath) {
|
|
59
|
+
const scope = `repository:${repositoryPath}:pull`;
|
|
60
|
+
const response = await fetch(`https://public.ecr.aws/token/?service=public.ecr.aws&scope=${encodeURIComponent(scope)}`);
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Public registry token request returned ${response.status}.`);
|
|
63
|
+
}
|
|
64
|
+
const payload = (await response.json());
|
|
65
|
+
if (!payload.token) {
|
|
66
|
+
throw new Error("Public registry token response did not include a token.");
|
|
67
|
+
}
|
|
68
|
+
return payload.token;
|
|
69
|
+
}
|
|
70
|
+
async fetchCreatedAt(repositoryPath, token, tag, createdAtByDigest) {
|
|
71
|
+
try {
|
|
72
|
+
const manifestReference = await this.fetchJson(`https://public.ecr.aws/v2/${repositoryPath}/manifests/${encodeURIComponent(tag)}`, {
|
|
73
|
+
Authorization: `Bearer ${token}`,
|
|
74
|
+
Accept: [
|
|
75
|
+
"application/vnd.oci.image.index.v1+json",
|
|
76
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
77
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
78
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
79
|
+
].join(", ")
|
|
80
|
+
});
|
|
81
|
+
const digest = this.selectManifestDigest(manifestReference);
|
|
82
|
+
const manifest = digest
|
|
83
|
+
? await this.fetchJson(`https://public.ecr.aws/v2/${repositoryPath}/manifests/${encodeURIComponent(digest)}`, {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
Accept: ["application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json"].join(", ")
|
|
86
|
+
})
|
|
87
|
+
: manifestReference;
|
|
88
|
+
const configDigest = "config" in manifest ? manifest.config?.digest : undefined;
|
|
89
|
+
if (!configDigest) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (createdAtByDigest.has(configDigest)) {
|
|
93
|
+
return createdAtByDigest.get(configDigest);
|
|
94
|
+
}
|
|
95
|
+
const config = await this.fetchJson(`https://public.ecr.aws/v2/${repositoryPath}/blobs/${configDigest}`, {
|
|
96
|
+
Authorization: `Bearer ${token}`
|
|
97
|
+
});
|
|
98
|
+
createdAtByDigest.set(configDigest, config.created);
|
|
99
|
+
return config.created;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof PublicRegistryRequestError) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
selectManifestDigest(manifest) {
|
|
109
|
+
if (!("manifests" in manifest) || !manifest.manifests?.length) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return (manifest.manifests.find((entry) => entry.platform?.os === "linux" && entry.platform?.architecture === "amd64")
|
|
113
|
+
?.digest ?? manifest.manifests[0]?.digest);
|
|
114
|
+
}
|
|
115
|
+
async fetchJson(url, headers) {
|
|
116
|
+
for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt += 1) {
|
|
117
|
+
const response = await fetch(url, { headers });
|
|
118
|
+
if (response.ok) {
|
|
119
|
+
return (await response.json());
|
|
120
|
+
}
|
|
121
|
+
const retryAfterMs = this.parseRetryAfterMs(response);
|
|
122
|
+
if (attempt < MAX_FETCH_ATTEMPTS && RETRYABLE_STATUS_CODES.has(response.status)) {
|
|
123
|
+
await this.sleep(retryAfterMs ?? DEFAULT_RETRY_DELAY_MS * attempt);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
throw new PublicRegistryRequestError(url, response.status, retryAfterMs);
|
|
127
|
+
}
|
|
128
|
+
throw new PublicRegistryRequestError(url, 500);
|
|
129
|
+
}
|
|
130
|
+
parseRetryAfterMs(response) {
|
|
131
|
+
const retryAfter = response.headers.get("retry-after");
|
|
132
|
+
if (!retryAfter) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
|
|
136
|
+
if (Number.isFinite(retryAfterSeconds)) {
|
|
137
|
+
return Math.max(retryAfterSeconds, 0) * 1000;
|
|
138
|
+
}
|
|
139
|
+
const retryAt = Date.parse(retryAfter);
|
|
140
|
+
if (Number.isNaN(retryAt)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return Math.max(retryAt - Date.now(), 0);
|
|
144
|
+
}
|
|
145
|
+
async sleep(delayMs) {
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { RuntimeState } from "./RuntimeState.js";
|
|
|
2
2
|
export declare class RuntimeStateStore {
|
|
3
3
|
private readonly root;
|
|
4
4
|
private readonly runtimePaths;
|
|
5
|
+
private static readonly DEFAULT_USERNAME;
|
|
5
6
|
constructor(root: string);
|
|
6
7
|
initialize(): RuntimeState;
|
|
7
8
|
load(): RuntimeState | null;
|
|
@@ -5,6 +5,7 @@ import { createPemKeyPair, randomCompanyId, randomSecret } from "./Secrets.js";
|
|
|
5
5
|
export class RuntimeStateStore {
|
|
6
6
|
root;
|
|
7
7
|
runtimePaths;
|
|
8
|
+
static DEFAULT_USERNAME = "admin@local";
|
|
8
9
|
constructor(root) {
|
|
9
10
|
this.root = root;
|
|
10
11
|
this.runtimePaths = new RuntimePaths(root);
|
|
@@ -23,7 +24,7 @@ export class RuntimeStateStore {
|
|
|
23
24
|
name: "Local CompanyHelm"
|
|
24
25
|
},
|
|
25
26
|
auth: {
|
|
26
|
-
username:
|
|
27
|
+
username: RuntimeStateStore.DEFAULT_USERNAME,
|
|
27
28
|
password: randomSecret(),
|
|
28
29
|
jwtPrivateKeyPem: authKeys.privateKeyPem,
|
|
29
30
|
jwtPublicKeyPem: authKeys.publicKeyPem
|
|
@@ -41,7 +42,12 @@ export class RuntimeStateStore {
|
|
|
41
42
|
if (!fs.existsSync(this.runtimePaths.stateFilePath())) {
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
+
const state = JSON.parse(fs.readFileSync(this.runtimePaths.stateFilePath(), "utf8"));
|
|
46
|
+
if (state.auth.username !== RuntimeStateStore.DEFAULT_USERNAME && state.auth.username === "admin") {
|
|
47
|
+
state.auth.username = RuntimeStateStore.DEFAULT_USERNAME;
|
|
48
|
+
this.save(state);
|
|
49
|
+
}
|
|
50
|
+
return state;
|
|
45
51
|
}
|
|
46
52
|
save(state) {
|
|
47
53
|
fs.writeFileSync(this.runtimePaths.stateFilePath(), `${JSON.stringify(state, null, 2)}\n`, {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RuntimeImages } from "./ImageCatalog.js";
|
|
2
|
+
export interface RuntimeVersions {
|
|
3
|
+
cliPackage: string;
|
|
4
|
+
runnerPackage: string;
|
|
5
|
+
images: RuntimeImages;
|
|
6
|
+
}
|
|
7
|
+
export declare class VersionCatalog {
|
|
8
|
+
resolve(): RuntimeVersions;
|
|
9
|
+
private readPackageVersion;
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ImageCatalog } from "./ImageCatalog.js";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
export class VersionCatalog {
|
|
10
|
+
resolve() {
|
|
11
|
+
return {
|
|
12
|
+
cliPackage: this.readPackageVersion(path.resolve(__dirname, "../../../package.json")),
|
|
13
|
+
runnerPackage: this.readPackageVersion(require.resolve("@companyhelm/runner/package.json")),
|
|
14
|
+
images: new ImageCatalog().resolve()
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
readPackageVersion(packageJsonPath) {
|
|
18
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
19
|
+
return `${packageJson.name}@${packageJson.version}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -5,8 +5,12 @@ export interface StatusSnapshot {
|
|
|
5
5
|
frontend: ManagedServiceStatus;
|
|
6
6
|
runner: ManagedServiceStatus;
|
|
7
7
|
}
|
|
8
|
+
export interface StatusOverrides {
|
|
9
|
+
runner?: () => Promise<boolean> | boolean;
|
|
10
|
+
}
|
|
8
11
|
export declare class StatusService {
|
|
9
12
|
private readonly listRunningServices;
|
|
10
|
-
|
|
13
|
+
private readonly overrides;
|
|
14
|
+
constructor(listRunningServices: () => Promise<string>, overrides?: StatusOverrides);
|
|
11
15
|
read(): Promise<StatusSnapshot>;
|
|
12
16
|
}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
export class StatusService {
|
|
2
2
|
listRunningServices;
|
|
3
|
-
|
|
3
|
+
overrides;
|
|
4
|
+
constructor(listRunningServices, overrides = {}) {
|
|
4
5
|
this.listRunningServices = listRunningServices;
|
|
6
|
+
this.overrides = overrides;
|
|
5
7
|
}
|
|
6
8
|
async read() {
|
|
7
9
|
const running = new Set((await this.listRunningServices())
|
|
8
10
|
.split("\n")
|
|
9
11
|
.map((service) => service.trim())
|
|
10
12
|
.filter(Boolean));
|
|
13
|
+
const runnerRunning = this.overrides.runner ? await this.overrides.runner() : running.has("runner");
|
|
11
14
|
return {
|
|
12
15
|
postgres: running.has("postgres") ? "running" : "stopped",
|
|
13
16
|
api: running.has("api") ? "running" : "stopped",
|
|
14
17
|
frontend: running.has("frontend") ? "running" : "stopped",
|
|
15
|
-
runner:
|
|
18
|
+
runner: runnerRunning ? "running" : "stopped"
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
21
|
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { StatusReport } from "../../commands/dependencies.js";
|
|
1
2
|
export declare class TerminalRenderer {
|
|
2
3
|
private readonly useColor;
|
|
4
|
+
private static readonly OSC;
|
|
5
|
+
private static readonly BEL;
|
|
3
6
|
constructor(useColor?: boolean);
|
|
4
7
|
renderBanner(): string;
|
|
5
8
|
success(message: string): string;
|
|
9
|
+
progress(message: string): string;
|
|
10
|
+
successHighlight(message: string): string;
|
|
11
|
+
clickableUrl(url: string): string;
|
|
12
|
+
renderStatus(report: StatusReport): string;
|
|
13
|
+
private renderServiceLine;
|
|
14
|
+
private warn;
|
|
15
|
+
private formatDetail;
|
|
6
16
|
private colorize;
|
|
7
17
|
}
|