@codemation/cli 0.0.5 → 0.0.11
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/README.md +20 -26
- package/dist/{CliBin-C3ar49fj.js → CliBin-BAnFX1wL.js} +1105 -366
- package/dist/bin.js +1 -1
- package/dist/index.d.ts +655 -207
- package/dist/index.js +1 -1
- package/package.json +14 -6
- package/src/CliProgramFactory.ts +23 -8
- package/src/Program.ts +7 -3
- package/src/bootstrap/CodemationCliApplicationSession.ts +17 -19
- package/src/commands/DevCommand.ts +203 -171
- package/src/commands/ServeWebCommand.ts +26 -1
- package/src/commands/ServeWorkerCommand.ts +46 -30
- package/src/commands/devCommandLifecycle.types.ts +7 -11
- package/src/database/ConsumerDatabaseConnectionResolver.ts +55 -9
- package/src/database/DatabaseMigrationsApplyService.ts +2 -2
- package/src/dev/Builder.ts +1 -14
- package/src/dev/CliDevProxyServer.ts +457 -0
- package/src/dev/CliDevProxyServerFactory.ts +10 -0
- package/src/dev/DevApiRuntimeFactory.ts +44 -0
- package/src/dev/DevApiRuntimeHost.ts +130 -0
- package/src/dev/DevApiRuntimeServer.ts +107 -0
- package/src/dev/DevApiRuntimeTypes.ts +24 -0
- package/src/dev/DevAuthSettingsLoader.ts +9 -3
- package/src/dev/DevBootstrapSummaryFetcher.ts +1 -1
- package/src/dev/DevHttpProbe.ts +2 -2
- package/src/dev/DevNextHostEnvironmentBuilder.ts +35 -5
- package/src/dev/DevRebuildQueue.ts +54 -0
- package/src/dev/DevRebuildQueueFactory.ts +7 -0
- package/src/dev/DevSessionPortsResolver.ts +2 -2
- package/src/dev/DevSessionServices.ts +0 -4
- package/src/dev/DevSourceChangeClassifier.ts +33 -13
- package/src/dev/ListenPortConflictDescriber.ts +83 -0
- package/src/dev/WatchRootsResolver.ts +6 -4
- package/src/runtime/NextHostConsumerServerCommandFactory.ts +11 -2
- package/src/user/CliDatabaseUrlDescriptor.ts +2 -2
- package/src/user/UserAdminCliBootstrap.ts +9 -21
- package/codemation-cli-0.0.3.tgz +0 -0
- package/src/dev/DevSourceRestartCoordinator.ts +0 -48
- package/src/dev/DevelopmentGatewayNotifier.ts +0 -35
- package/src/dev/RuntimeToolEntrypointResolver.ts +0 -47
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { CodemationHonoApiApp, ServerLoggerFactory, logLevelPolicyFactory } from "@codemation/host/next/server";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
|
|
5
|
+
import type { DevApiRuntimeContext } from "./DevApiRuntimeTypes";
|
|
6
|
+
import type { DevApiRuntimeHost } from "./DevApiRuntimeHost";
|
|
7
|
+
|
|
8
|
+
type ClosableServer = Readonly<{
|
|
9
|
+
close(callback?: (error?: Error) => void): unknown;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
export class DevApiRuntimeServer {
|
|
13
|
+
private readonly bootstrapLogger = new ServerLoggerFactory(logLevelPolicyFactory).create(
|
|
14
|
+
"codemation-cli.dev-runtime",
|
|
15
|
+
);
|
|
16
|
+
private server: ClosableServer | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly httpPort: number,
|
|
20
|
+
private readonly workflowWebSocketPort: number,
|
|
21
|
+
private readonly host: DevApiRuntimeHost,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async start(): Promise<DevApiRuntimeContext> {
|
|
25
|
+
const root = new Hono();
|
|
26
|
+
root.get("/health", (c) => c.json({ ok: true }));
|
|
27
|
+
root.all("*", async (c) => {
|
|
28
|
+
const context = await this.host.prepare();
|
|
29
|
+
return context.container.resolve(CodemationHonoApiApp).fetch(c.req.raw);
|
|
30
|
+
});
|
|
31
|
+
await this.listen(root);
|
|
32
|
+
try {
|
|
33
|
+
return await this.host.prepare();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await this.stop();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async stop(): Promise<void> {
|
|
41
|
+
const server = this.server;
|
|
42
|
+
this.server = null;
|
|
43
|
+
const failures: Error[] = [];
|
|
44
|
+
if (server) {
|
|
45
|
+
try {
|
|
46
|
+
await this.closeServer(server);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
failures.push(this.normalizeError(error));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await this.host.stop();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
failures.push(this.normalizeError(error));
|
|
55
|
+
}
|
|
56
|
+
if (failures.length > 0) {
|
|
57
|
+
throw failures[0];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async listen(root: Hono): Promise<void> {
|
|
62
|
+
if (this.server) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await new Promise<void>((resolve, reject) => {
|
|
66
|
+
let resolved = false;
|
|
67
|
+
const server = serve(
|
|
68
|
+
{
|
|
69
|
+
fetch: root.fetch,
|
|
70
|
+
port: this.httpPort,
|
|
71
|
+
hostname: "127.0.0.1",
|
|
72
|
+
},
|
|
73
|
+
() => {
|
|
74
|
+
resolved = true;
|
|
75
|
+
this.server = server;
|
|
76
|
+
resolve();
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
server.on("error", (error) => {
|
|
80
|
+
if (resolved) {
|
|
81
|
+
this.bootstrapLogger.error("runtime HTTP server error", this.normalizeError(error));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
reject(error);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
this.bootstrapLogger.debug(
|
|
88
|
+
`runtime listening httpPort=${this.httpPort} workflowWebSocketPort=${this.workflowWebSocketPort}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private closeServer(server: ClosableServer): Promise<void> {
|
|
93
|
+
return new Promise<void>((resolve, reject) => {
|
|
94
|
+
server.close((error) => {
|
|
95
|
+
if (error) {
|
|
96
|
+
reject(error);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private normalizeError(error: unknown): Error {
|
|
105
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Container } from "@codemation/core";
|
|
2
|
+
|
|
3
|
+
export type DevApiRuntimeContext = Readonly<{
|
|
4
|
+
buildVersion: string;
|
|
5
|
+
container: Container;
|
|
6
|
+
consumerRoot: string;
|
|
7
|
+
repoRoot: string;
|
|
8
|
+
workflowIds: ReadonlyArray<string>;
|
|
9
|
+
workflowSources: ReadonlyArray<string>;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
export type DevApiRuntimeFactoryArgs = Readonly<{
|
|
13
|
+
consumerRoot: string;
|
|
14
|
+
env: NodeJS.ProcessEnv;
|
|
15
|
+
runtimeWorkingDirectory: string;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
export type DevApiRuntimeServerHandle = Readonly<{
|
|
19
|
+
buildVersion: string;
|
|
20
|
+
httpPort: number;
|
|
21
|
+
stop: () => Promise<void>;
|
|
22
|
+
workflowIds: ReadonlyArray<string>;
|
|
23
|
+
workflowWebSocketPort: number;
|
|
24
|
+
}>;
|
|
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { CodemationConsumerConfigLoader } from "@codemation/host/server";
|
|
4
4
|
|
|
5
|
+
import type { ConsumerEnvLoader } from "../consumer/ConsumerEnvLoader";
|
|
6
|
+
|
|
5
7
|
export type DevResolvedAuthSettings = Readonly<{
|
|
6
8
|
authConfigJson: string;
|
|
7
9
|
authSecret: string;
|
|
@@ -11,7 +13,10 @@ export type DevResolvedAuthSettings = Readonly<{
|
|
|
11
13
|
export class DevAuthSettingsLoader {
|
|
12
14
|
static readonly defaultDevelopmentAuthSecret = "codemation-dev-auth-secret-not-for-production";
|
|
13
15
|
|
|
14
|
-
constructor(
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly configLoader: CodemationConsumerConfigLoader,
|
|
18
|
+
private readonly consumerEnvLoader: ConsumerEnvLoader,
|
|
19
|
+
) {}
|
|
15
20
|
|
|
16
21
|
resolveDevelopmentServerToken(rawToken: string | undefined): string {
|
|
17
22
|
if (rawToken && rawToken.trim().length > 0) {
|
|
@@ -22,15 +27,16 @@ export class DevAuthSettingsLoader {
|
|
|
22
27
|
|
|
23
28
|
async loadForConsumer(consumerRoot: string): Promise<DevResolvedAuthSettings> {
|
|
24
29
|
const resolution = await this.configLoader.load({ consumerRoot });
|
|
30
|
+
const envForAuthSecret = this.consumerEnvLoader.mergeConsumerRootIntoProcessEnvironment(consumerRoot, process.env);
|
|
25
31
|
return {
|
|
26
32
|
authConfigJson: JSON.stringify(resolution.config.auth ?? null),
|
|
27
|
-
authSecret: this.resolveDevelopmentAuthSecret(
|
|
33
|
+
authSecret: this.resolveDevelopmentAuthSecret(envForAuthSecret),
|
|
28
34
|
skipUiAuth: resolution.config.auth?.allowUnauthenticatedInDevelopment === true,
|
|
29
35
|
};
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
resolveDevelopmentAuthSecret(env: NodeJS.ProcessEnv): string {
|
|
33
|
-
const configuredSecret = env.AUTH_SECRET
|
|
39
|
+
const configuredSecret = env.AUTH_SECRET;
|
|
34
40
|
if (configuredSecret && configuredSecret.trim().length > 0) {
|
|
35
41
|
return configuredSecret;
|
|
36
42
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DevBootstrapSummaryJson } from "@codemation/host/next/server";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Fetches {@link DevBootstrapSummaryJson} from the
|
|
4
|
+
* Fetches {@link DevBootstrapSummaryJson} from the stable CLI-owned dev endpoint.
|
|
5
5
|
*/
|
|
6
6
|
export class DevBootstrapSummaryFetcher {
|
|
7
7
|
async fetch(gatewayBaseUrl: string): Promise<DevBootstrapSummaryJson | null> {
|
package/src/dev/DevHttpProbe.ts
CHANGED
|
@@ -29,11 +29,11 @@ export class DevHttpProbe {
|
|
|
29
29
|
}
|
|
30
30
|
await delay(50);
|
|
31
31
|
}
|
|
32
|
-
throw new Error("Timed out waiting for dev
|
|
32
|
+
throw new Error("Timed out waiting for the stable dev HTTP health check.");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Polls until the runtime
|
|
36
|
+
* Polls until the active disposable runtime serves bootstrap summary through the stable CLI dev endpoint.
|
|
37
37
|
*/
|
|
38
38
|
async waitUntilBootstrapSummaryReady(gatewayBaseUrl: string): Promise<void> {
|
|
39
39
|
const normalizedBase = gatewayBaseUrl.replace(/\/$/, "");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import type { CodemationAuthConfig } from "@codemation/host";
|
|
4
|
+
import { CodemationFrontendAuthSnapshotFactory, FrontendAppConfigJsonCodec } from "@codemation/host";
|
|
3
5
|
|
|
4
6
|
import { ConsumerEnvLoader } from "../consumer/ConsumerEnvLoader";
|
|
5
7
|
import { SourceMapNodeOptions } from "../runtime/SourceMapNodeOptions";
|
|
@@ -8,6 +10,8 @@ export class DevNextHostEnvironmentBuilder {
|
|
|
8
10
|
constructor(
|
|
9
11
|
private readonly consumerEnvLoader: ConsumerEnvLoader,
|
|
10
12
|
private readonly sourceMapNodeOptions: SourceMapNodeOptions,
|
|
13
|
+
private readonly frontendAuthSnapshotFactory: CodemationFrontendAuthSnapshotFactory = new CodemationFrontendAuthSnapshotFactory(),
|
|
14
|
+
private readonly frontendAppConfigJsonCodec: FrontendAppConfigJsonCodec = new FrontendAppConfigJsonCodec(),
|
|
11
15
|
) {}
|
|
12
16
|
|
|
13
17
|
buildConsumerUiProxy(
|
|
@@ -27,6 +31,7 @@ export class DevNextHostEnvironmentBuilder {
|
|
|
27
31
|
return {
|
|
28
32
|
...this.build({
|
|
29
33
|
authConfigJson: args.authConfigJson,
|
|
34
|
+
authSecret: args.authSecret,
|
|
30
35
|
consumerRoot: args.consumerRoot,
|
|
31
36
|
developmentServerToken: args.developmentServerToken,
|
|
32
37
|
nextPort: args.nextPort,
|
|
@@ -35,16 +40,18 @@ export class DevNextHostEnvironmentBuilder {
|
|
|
35
40
|
websocketPort: args.websocketPort,
|
|
36
41
|
consumerOutputManifestPath: args.consumerOutputManifestPath,
|
|
37
42
|
}),
|
|
43
|
+
// Standalone `server.js` uses `process.env.HOSTNAME || '0.0.0.0'` for bind; Docker sets HOSTNAME to the
|
|
44
|
+
// container id, which breaks loopback health checks — force IPv4 loopback for the UI child only.
|
|
45
|
+
HOSTNAME: "127.0.0.1",
|
|
38
46
|
AUTH_SECRET: args.authSecret,
|
|
39
47
|
AUTH_URL: args.publicBaseUrl,
|
|
40
|
-
NEXTAUTH_SECRET: args.authSecret,
|
|
41
|
-
NEXTAUTH_URL: args.publicBaseUrl,
|
|
42
48
|
};
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
build(
|
|
46
52
|
args: Readonly<{
|
|
47
53
|
authConfigJson: string;
|
|
54
|
+
authSecret?: string;
|
|
48
55
|
consumerRoot: string;
|
|
49
56
|
developmentServerToken: string;
|
|
50
57
|
nextPort: number;
|
|
@@ -58,14 +65,29 @@ export class DevNextHostEnvironmentBuilder {
|
|
|
58
65
|
const merged = this.consumerEnvLoader.mergeConsumerRootIntoProcessEnvironment(args.consumerRoot, process.env);
|
|
59
66
|
const manifestPath =
|
|
60
67
|
args.consumerOutputManifestPath ?? path.resolve(args.consumerRoot, ".codemation", "output", "current.json");
|
|
68
|
+
const authSecret = args.authSecret ?? merged.AUTH_SECRET;
|
|
69
|
+
const authSnapshot = this.frontendAuthSnapshotFactory.createFromResolvedInputs({
|
|
70
|
+
authConfig: this.parseAuthConfig(args.authConfigJson),
|
|
71
|
+
env: {
|
|
72
|
+
...merged,
|
|
73
|
+
...(typeof authSecret === "string" && authSecret.trim().length > 0
|
|
74
|
+
? {
|
|
75
|
+
AUTH_SECRET: authSecret,
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
},
|
|
79
|
+
uiAuthEnabled: !args.skipUiAuth,
|
|
80
|
+
});
|
|
61
81
|
return {
|
|
62
82
|
...merged,
|
|
63
83
|
PORT: String(args.nextPort),
|
|
64
|
-
CODEMATION_AUTH_CONFIG_JSON: args.authConfigJson,
|
|
65
84
|
CODEMATION_CONSUMER_ROOT: args.consumerRoot,
|
|
66
85
|
CODEMATION_CONSUMER_OUTPUT_MANIFEST_PATH: manifestPath,
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
CODEMATION_FRONTEND_APP_CONFIG_JSON: this.frontendAppConfigJsonCodec.serialize({
|
|
87
|
+
auth: authSnapshot,
|
|
88
|
+
productName: "Codemation",
|
|
89
|
+
logoUrl: null,
|
|
90
|
+
}),
|
|
69
91
|
CODEMATION_WS_PORT: String(args.websocketPort),
|
|
70
92
|
NEXT_PUBLIC_CODEMATION_WS_PORT: String(args.websocketPort),
|
|
71
93
|
CODEMATION_DEV_SERVER_TOKEN: args.developmentServerToken,
|
|
@@ -78,4 +100,12 @@ export class DevNextHostEnvironmentBuilder {
|
|
|
78
100
|
: {}),
|
|
79
101
|
};
|
|
80
102
|
}
|
|
103
|
+
|
|
104
|
+
private parseAuthConfig(authConfigJson: string): CodemationAuthConfig | undefined {
|
|
105
|
+
if (authConfigJson.trim().length === 0) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const parsed = JSON.parse(authConfigJson) as CodemationAuthConfig | null;
|
|
109
|
+
return parsed ?? undefined;
|
|
110
|
+
}
|
|
81
111
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type DevRebuildRequest = Readonly<{
|
|
2
|
+
changedPaths: ReadonlyArray<string>;
|
|
3
|
+
shouldRepublishConsumerOutput: boolean;
|
|
4
|
+
shouldRestartUi: boolean;
|
|
5
|
+
}>;
|
|
6
|
+
|
|
7
|
+
export interface DevRebuildHandler {
|
|
8
|
+
run(request: DevRebuildRequest): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DevRebuildQueue {
|
|
12
|
+
private pendingRequest: DevRebuildRequest | null = null;
|
|
13
|
+
private drainPromise: Promise<void> | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(private readonly handler: DevRebuildHandler) {}
|
|
16
|
+
|
|
17
|
+
async enqueue(request: DevRebuildRequest): Promise<void> {
|
|
18
|
+
this.pendingRequest = this.mergePendingRequest(this.pendingRequest, request);
|
|
19
|
+
if (!this.drainPromise) {
|
|
20
|
+
this.drainPromise = this.drain();
|
|
21
|
+
}
|
|
22
|
+
return await this.drainPromise;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async drain(): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
while (this.pendingRequest) {
|
|
28
|
+
const nextRequest = this.pendingRequest;
|
|
29
|
+
this.pendingRequest = null;
|
|
30
|
+
await this.handler.run(nextRequest);
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
this.drainPromise = null;
|
|
34
|
+
if (this.pendingRequest) {
|
|
35
|
+
this.drainPromise = this.drain();
|
|
36
|
+
await this.drainPromise;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private mergePendingRequest(current: DevRebuildRequest | null, next: DevRebuildRequest): DevRebuildRequest {
|
|
42
|
+
if (!current) {
|
|
43
|
+
return {
|
|
44
|
+
...next,
|
|
45
|
+
changedPaths: [...next.changedPaths],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
changedPaths: [...new Set([...current.changedPaths, ...next.changedPaths])],
|
|
50
|
+
shouldRepublishConsumerOutput: current.shouldRepublishConsumerOutput || next.shouldRepublishConsumerOutput,
|
|
51
|
+
shouldRestartUi: current.shouldRestartUi || next.shouldRestartUi,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -9,7 +9,7 @@ export class DevSessionPortsResolver {
|
|
|
9
9
|
|
|
10
10
|
async resolve(
|
|
11
11
|
args: Readonly<{
|
|
12
|
-
devMode: "
|
|
12
|
+
devMode: "packaged-ui" | "watch-framework";
|
|
13
13
|
portEnv: string | undefined;
|
|
14
14
|
gatewayPortEnv: string | undefined;
|
|
15
15
|
}>,
|
|
@@ -17,7 +17,7 @@ export class DevSessionPortsResolver {
|
|
|
17
17
|
const nextPort = this.listenPorts.resolvePrimaryApplicationPort(args.portEnv);
|
|
18
18
|
const gatewayPort =
|
|
19
19
|
this.listenPorts.parsePositiveInteger(args.gatewayPortEnv) ??
|
|
20
|
-
(args.devMode === "
|
|
20
|
+
(args.devMode === "packaged-ui" ? nextPort : await this.loopbackPorts.allocate());
|
|
21
21
|
return { nextPort, gatewayPort };
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -6,9 +6,7 @@ import { DevHttpProbe } from "./DevHttpProbe";
|
|
|
6
6
|
import { DevNextHostEnvironmentBuilder } from "./DevNextHostEnvironmentBuilder";
|
|
7
7
|
import { DevSessionPortsResolver } from "./DevSessionPortsResolver";
|
|
8
8
|
import { DevSourceChangeClassifier } from "./DevSourceChangeClassifier";
|
|
9
|
-
import { DevSourceRestartCoordinator } from "./DevSourceRestartCoordinator";
|
|
10
9
|
import { LoopbackPortAllocator } from "./LoopbackPortAllocator";
|
|
11
|
-
import { RuntimeToolEntrypointResolver } from "./RuntimeToolEntrypointResolver";
|
|
12
10
|
import { WatchRootsResolver } from "./WatchRootsResolver";
|
|
13
11
|
|
|
14
12
|
/**
|
|
@@ -21,11 +19,9 @@ export class DevSessionServices {
|
|
|
21
19
|
readonly sessionPorts: DevSessionPortsResolver,
|
|
22
20
|
readonly loopbackPortAllocator: LoopbackPortAllocator,
|
|
23
21
|
readonly devHttpProbe: DevHttpProbe,
|
|
24
|
-
readonly runtimeEntrypointResolver: RuntimeToolEntrypointResolver,
|
|
25
22
|
readonly devAuthLoader: DevAuthSettingsLoader,
|
|
26
23
|
readonly nextHostEnvBuilder: DevNextHostEnvironmentBuilder,
|
|
27
24
|
readonly watchRootsResolver: WatchRootsResolver,
|
|
28
25
|
readonly sourceChangeClassifier: DevSourceChangeClassifier,
|
|
29
|
-
readonly sourceRestartCoordinator: DevSourceRestartCoordinator,
|
|
30
26
|
) {}
|
|
31
27
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
export class DevSourceChangeClassifier {
|
|
4
|
+
private static readonly configFileNames = new Set([
|
|
5
|
+
"codemation.config.ts",
|
|
6
|
+
"codemation.config.js",
|
|
7
|
+
"codemation.config.mjs",
|
|
8
|
+
]);
|
|
9
|
+
|
|
4
10
|
shouldRepublishConsumerOutput(
|
|
5
11
|
args: Readonly<{
|
|
6
12
|
changedPaths: ReadonlyArray<string>;
|
|
@@ -11,24 +17,16 @@ export class DevSourceChangeClassifier {
|
|
|
11
17
|
return args.changedPaths.some((changedPath) => this.isPathInsideDirectory(changedPath, resolvedConsumerRoot));
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
requiresUiRestart(
|
|
15
21
|
args: Readonly<{
|
|
16
22
|
changedPaths: ReadonlyArray<string>;
|
|
17
23
|
consumerRoot: string;
|
|
18
24
|
}>,
|
|
19
25
|
): boolean {
|
|
20
|
-
const
|
|
21
|
-
return args.changedPaths.some((changedPath) =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
private resolveConfigPaths(consumerRoot: string): ReadonlyArray<string> {
|
|
25
|
-
const resolvedConsumerRoot = path.resolve(consumerRoot);
|
|
26
|
-
return [
|
|
27
|
-
path.resolve(resolvedConsumerRoot, "codemation.config.ts"),
|
|
28
|
-
path.resolve(resolvedConsumerRoot, "codemation.config.js"),
|
|
29
|
-
path.resolve(resolvedConsumerRoot, "src", "codemation.config.ts"),
|
|
30
|
-
path.resolve(resolvedConsumerRoot, "src", "codemation.config.js"),
|
|
31
|
-
];
|
|
26
|
+
const resolvedConsumerRoot = path.resolve(args.consumerRoot);
|
|
27
|
+
return args.changedPaths.some((changedPath) =>
|
|
28
|
+
this.pathRequiresUiRestart(path.resolve(changedPath), resolvedConsumerRoot),
|
|
29
|
+
);
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
private isPathInsideDirectory(filePath: string, directoryPath: string): boolean {
|
|
@@ -36,4 +34,26 @@ export class DevSourceChangeClassifier {
|
|
|
36
34
|
const relativePath = path.relative(directoryPath, resolvedFilePath);
|
|
37
35
|
return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
38
36
|
}
|
|
37
|
+
|
|
38
|
+
private pathRequiresUiRestart(resolvedPath: string, consumerRoot: string): boolean {
|
|
39
|
+
if (!this.isPathInsideDirectory(resolvedPath, consumerRoot)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const relativePath = path.relative(consumerRoot, resolvedPath);
|
|
43
|
+
if (DevSourceChangeClassifier.configFileNames.has(path.basename(relativePath))) {
|
|
44
|
+
// Config changes affect auth and branding projections consumed by the packaged Next host.
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (relativePath.startsWith(path.join("src", "workflows"))) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (relativePath.startsWith(path.join("src", "plugins"))) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (relativePath.includes("credential")) {
|
|
54
|
+
// Credential type edits still require a packaged UI restart until the credentials screen live-refreshes.
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
39
59
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
type PortOccupant = Readonly<{
|
|
5
|
+
pid: number;
|
|
6
|
+
command: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
}>;
|
|
9
|
+
|
|
10
|
+
export class ListenPortConflictDescriber {
|
|
11
|
+
constructor(private readonly platform: NodeJS.Platform = process.platform) {}
|
|
12
|
+
|
|
13
|
+
async describeLoopbackPort(port: number): Promise<string | null> {
|
|
14
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (this.platform !== "linux" && this.platform !== "darwin") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const raw = await this.readLsofOutput(port);
|
|
22
|
+
if (raw === null) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const occupants = this.parseLsofOutput(raw);
|
|
26
|
+
if (occupants.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return occupants
|
|
31
|
+
.map((occupant) => `pid=${occupant.pid} command=${occupant.command} endpoint=${occupant.endpoint}`)
|
|
32
|
+
.join("; ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async readLsofOutput(port: number): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
return await new Promise<string>((resolve, reject) => {
|
|
38
|
+
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpcn"], (error, stdout) => {
|
|
39
|
+
if (error) {
|
|
40
|
+
reject(error);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
resolve(stdout);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private parseLsofOutput(raw: string): ReadonlyArray<PortOccupant> {
|
|
52
|
+
const occupants: PortOccupant[] = [];
|
|
53
|
+
let currentPid: number | null = null;
|
|
54
|
+
let currentCommand: string | null = null;
|
|
55
|
+
|
|
56
|
+
for (const line of raw.split("\n")) {
|
|
57
|
+
if (line.length < 2) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const prefix = line[0];
|
|
61
|
+
const value = line.slice(1).trim();
|
|
62
|
+
|
|
63
|
+
if (prefix === "p") {
|
|
64
|
+
currentPid = Number.parseInt(value, 10);
|
|
65
|
+
currentCommand = null;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (prefix === "c") {
|
|
69
|
+
currentCommand = value;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (prefix === "n" && currentPid !== null && currentCommand !== null) {
|
|
73
|
+
occupants.push({
|
|
74
|
+
pid: currentPid,
|
|
75
|
+
command: currentCommand,
|
|
76
|
+
endpoint: value,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return occupants;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -4,23 +4,25 @@ export class WatchRootsResolver {
|
|
|
4
4
|
resolve(
|
|
5
5
|
args: Readonly<{
|
|
6
6
|
consumerRoot: string;
|
|
7
|
-
devMode: "
|
|
7
|
+
devMode: "packaged-ui" | "watch-framework";
|
|
8
8
|
repoRoot: string;
|
|
9
9
|
}>,
|
|
10
10
|
): ReadonlyArray<string> {
|
|
11
|
-
if (args.devMode === "
|
|
11
|
+
if (args.devMode === "packaged-ui") {
|
|
12
|
+
// Packaged UI mode watches only the app itself. Framework packages are consumed from their built output.
|
|
12
13
|
return [args.consumerRoot];
|
|
13
14
|
}
|
|
15
|
+
// Watch-framework mode is framework-author development: watch the app plus the workspace packages that
|
|
16
|
+
// feed the consumer output and packaged Next host.
|
|
14
17
|
return [
|
|
15
18
|
args.consumerRoot,
|
|
19
|
+
path.resolve(args.repoRoot, "packages", "cli"),
|
|
16
20
|
path.resolve(args.repoRoot, "packages", "core"),
|
|
17
21
|
path.resolve(args.repoRoot, "packages", "core-nodes"),
|
|
18
22
|
path.resolve(args.repoRoot, "packages", "core-nodes-gmail"),
|
|
19
23
|
path.resolve(args.repoRoot, "packages", "eventbus-redis"),
|
|
20
24
|
path.resolve(args.repoRoot, "packages", "host"),
|
|
21
25
|
path.resolve(args.repoRoot, "packages", "node-example"),
|
|
22
|
-
path.resolve(args.repoRoot, "packages", "queue-bullmq"),
|
|
23
|
-
path.resolve(args.repoRoot, "packages", "runtime-dev"),
|
|
24
26
|
];
|
|
25
27
|
}
|
|
26
28
|
}
|
|
@@ -10,7 +10,14 @@ export type NextHostConsumerServerCommand = Readonly<{
|
|
|
10
10
|
|
|
11
11
|
export class NextHostConsumerServerCommandFactory {
|
|
12
12
|
async create(args: Readonly<{ nextHostRoot: string }>): Promise<NextHostConsumerServerCommand> {
|
|
13
|
-
const standaloneServerPath = path.resolve(
|
|
13
|
+
const standaloneServerPath = path.resolve(
|
|
14
|
+
args.nextHostRoot,
|
|
15
|
+
".next",
|
|
16
|
+
"standalone",
|
|
17
|
+
"packages",
|
|
18
|
+
"next-host",
|
|
19
|
+
"server.js",
|
|
20
|
+
);
|
|
14
21
|
if (await this.exists(standaloneServerPath)) {
|
|
15
22
|
return {
|
|
16
23
|
command: process.execPath,
|
|
@@ -20,7 +27,9 @@ export class NextHostConsumerServerCommandFactory {
|
|
|
20
27
|
}
|
|
21
28
|
return {
|
|
22
29
|
command: "pnpm",
|
|
23
|
-
|
|
30
|
+
// Bind loopback so `codemation dev` health probes (`http://127.0.0.1:<port>`) work in Docker,
|
|
31
|
+
// where `process.env.HOSTNAME` is the container id and would otherwise be used by `next start`.
|
|
32
|
+
args: ["exec", "next", "start", "-H", "127.0.0.1"],
|
|
24
33
|
cwd: args.nextHostRoot,
|
|
25
34
|
};
|
|
26
35
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AppPersistenceConfig } from "@codemation/host/persistence";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Formats a database URL for CLI messages without exposing credentials (no user/password).
|
|
5
5
|
*/
|
|
6
6
|
export class CliDatabaseUrlDescriptor {
|
|
7
|
-
describePersistence(persistence:
|
|
7
|
+
describePersistence(persistence: AppPersistenceConfig): string {
|
|
8
8
|
if (persistence.kind === "none") {
|
|
9
9
|
return "none";
|
|
10
10
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DatabasePersistenceResolver } from "@codemation/host/persistence";
|
|
3
|
-
import { CodemationConsumerConfigLoader } from "@codemation/host/server";
|
|
1
|
+
import { AppConfigLoader } from "@codemation/host/server";
|
|
4
2
|
|
|
5
3
|
import { CodemationCliApplicationSession } from "../bootstrap/CodemationCliApplicationSession";
|
|
6
4
|
import type { ConsumerCliTsconfigPreparation } from "../consumer/ConsumerCliTsconfigPreparation";
|
|
@@ -17,11 +15,10 @@ export type UserAdminCliOptions = Readonly<{
|
|
|
17
15
|
*/
|
|
18
16
|
export class UserAdminCliBootstrap {
|
|
19
17
|
constructor(
|
|
20
|
-
private readonly
|
|
18
|
+
private readonly appConfigLoader: AppConfigLoader,
|
|
21
19
|
private readonly pathResolver: CliPathResolver,
|
|
22
20
|
private readonly consumerDotenvLoader: UserAdminConsumerDotenvLoader,
|
|
23
21
|
private readonly tsconfigPreparation: ConsumerCliTsconfigPreparation,
|
|
24
|
-
private readonly databasePersistenceResolver: DatabasePersistenceResolver,
|
|
25
22
|
) {}
|
|
26
23
|
|
|
27
24
|
async withSession<T>(
|
|
@@ -31,32 +28,23 @@ export class UserAdminCliBootstrap {
|
|
|
31
28
|
const consumerRoot = options.consumerRoot ?? process.cwd();
|
|
32
29
|
this.consumerDotenvLoader.load(consumerRoot);
|
|
33
30
|
this.tsconfigPreparation.applyWorkspaceTsconfigForTsxIfPresent(consumerRoot);
|
|
34
|
-
const
|
|
31
|
+
const paths = await this.pathResolver.resolve(consumerRoot);
|
|
32
|
+
const loadResult = await this.appConfigLoader.load({
|
|
35
33
|
consumerRoot,
|
|
34
|
+
repoRoot: paths.repoRoot,
|
|
35
|
+
env: process.env,
|
|
36
36
|
configPathOverride: options.configPath,
|
|
37
37
|
});
|
|
38
|
-
if (
|
|
38
|
+
if (loadResult.appConfig.auth?.kind !== "local") {
|
|
39
39
|
throw new Error('Codemation user commands require CodemationConfig.auth.kind to be "local".');
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
runtimeConfig: resolution.config.runtime ?? {},
|
|
43
|
-
env: process.env,
|
|
44
|
-
consumerRoot,
|
|
45
|
-
});
|
|
46
|
-
if (persistence.kind === "none") {
|
|
41
|
+
if (loadResult.appConfig.persistence.kind === "none") {
|
|
47
42
|
throw new Error(
|
|
48
43
|
"Database persistence is not configured. Set CodemationConfig.runtime.database (postgresql URL or PGlite).",
|
|
49
44
|
);
|
|
50
45
|
}
|
|
51
|
-
const paths = await this.pathResolver.resolve(consumerRoot);
|
|
52
46
|
const session = await CodemationCliApplicationSession.open({
|
|
53
|
-
|
|
54
|
-
bootstrap: new CodemationBootstrapRequest({
|
|
55
|
-
repoRoot: paths.repoRoot,
|
|
56
|
-
consumerRoot,
|
|
57
|
-
env: process.env,
|
|
58
|
-
workflowSources: resolution.workflowSources,
|
|
59
|
-
}),
|
|
47
|
+
appConfig: loadResult.appConfig,
|
|
60
48
|
});
|
|
61
49
|
try {
|
|
62
50
|
return await fn(session);
|