@codemation/cli 0.0.1

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 (64) hide show
  1. package/README.md +148 -0
  2. package/bin/codemation.js +24 -0
  3. package/bin/codemation.ts +5 -0
  4. package/dist/CliBin-vjSSUDWE.js +2304 -0
  5. package/dist/bin.d.ts +1 -0
  6. package/dist/bin.js +9 -0
  7. package/dist/index.d.ts +23456 -0
  8. package/dist/index.js +4 -0
  9. package/package.json +56 -0
  10. package/src/CliBin.ts +17 -0
  11. package/src/CliProgramFactory.ts +118 -0
  12. package/src/Program.ts +157 -0
  13. package/src/bin.ts +6 -0
  14. package/src/bootstrap/CodemationCliApplicationSession.ts +60 -0
  15. package/src/build/ConsumerBuildArtifactsPublisher.ts +77 -0
  16. package/src/build/ConsumerBuildOptionsParser.ts +26 -0
  17. package/src/commands/BuildCommand.ts +31 -0
  18. package/src/commands/DbMigrateCommand.ts +19 -0
  19. package/src/commands/DevCommand.ts +391 -0
  20. package/src/commands/ServeWebCommand.ts +72 -0
  21. package/src/commands/ServeWorkerCommand.ts +40 -0
  22. package/src/commands/UserCreateCommand.ts +25 -0
  23. package/src/commands/UserListCommand.ts +59 -0
  24. package/src/commands/devCommandLifecycle.types.ts +32 -0
  25. package/src/consumer/ConsumerCliTsconfigPreparation.ts +26 -0
  26. package/src/consumer/ConsumerEnvLoader.ts +47 -0
  27. package/src/consumer/ConsumerOutputBuilder.ts +898 -0
  28. package/src/consumer/Loader.ts +8 -0
  29. package/src/consumer/consumerBuildOptions.types.ts +12 -0
  30. package/src/database/ConsumerDatabaseConnectionResolver.ts +18 -0
  31. package/src/database/DatabaseMigrationsApplyService.ts +76 -0
  32. package/src/database/HostPackageRootResolver.ts +26 -0
  33. package/src/database/PrismaMigrateDeployInvoker.ts +24 -0
  34. package/src/dev/Builder.ts +45 -0
  35. package/src/dev/ConsumerEnvDotenvFilePredicate.ts +12 -0
  36. package/src/dev/DevAuthSettingsLoader.ts +27 -0
  37. package/src/dev/DevBootstrapSummaryFetcher.ts +15 -0
  38. package/src/dev/DevCliBannerRenderer.ts +106 -0
  39. package/src/dev/DevConsumerPublishBootstrap.ts +30 -0
  40. package/src/dev/DevHttpProbe.ts +54 -0
  41. package/src/dev/DevLock.ts +98 -0
  42. package/src/dev/DevNextHostEnvironmentBuilder.ts +49 -0
  43. package/src/dev/DevSessionPortsResolver.ts +23 -0
  44. package/src/dev/DevSessionServices.ts +29 -0
  45. package/src/dev/DevSourceRestartCoordinator.ts +48 -0
  46. package/src/dev/DevSourceWatcher.ts +102 -0
  47. package/src/dev/DevTrackedProcessTreeKiller.ts +107 -0
  48. package/src/dev/DevelopmentGatewayNotifier.ts +35 -0
  49. package/src/dev/Factory.ts +7 -0
  50. package/src/dev/LoopbackPortAllocator.ts +20 -0
  51. package/src/dev/Runner.ts +7 -0
  52. package/src/dev/RuntimeToolEntrypointResolver.ts +47 -0
  53. package/src/dev/WatchRootsResolver.ts +26 -0
  54. package/src/index.ts +12 -0
  55. package/src/path/CliPathResolver.ts +41 -0
  56. package/src/runtime/ListenPortResolver.ts +35 -0
  57. package/src/runtime/SourceMapNodeOptions.ts +12 -0
  58. package/src/runtime/TypeScriptRuntimeConfigurator.ts +8 -0
  59. package/src/user/CliDatabaseUrlDescriptor.ts +33 -0
  60. package/src/user/LocalUserCreator.ts +29 -0
  61. package/src/user/UserAdminCliBootstrap.ts +67 -0
  62. package/src/user/UserAdminCliOptionsParser.ts +24 -0
  63. package/src/user/UserAdminConsumerDotenvLoader.ts +24 -0
  64. package/tsconfig.json +10 -0
@@ -0,0 +1,8 @@
1
+ import type { ConsumerBuildOptions } from "./consumerBuildOptions.types";
2
+ import { ConsumerOutputBuilder } from "./ConsumerOutputBuilder";
3
+
4
+ export class ConsumerOutputBuilderLoader {
5
+ create(consumerRoot: string, buildOptions: ConsumerBuildOptions): ConsumerOutputBuilder {
6
+ return new ConsumerOutputBuilder(consumerRoot, undefined, buildOptions);
7
+ }
8
+ }
@@ -0,0 +1,12 @@
1
+ export type EcmaScriptBuildTarget = "es2020" | "es2022";
2
+
3
+ /**
4
+ * Options for `codemation build` / programmatic {@link ConsumerOutputBuilder} output.
5
+ * Mirrors common production-build toggles (source maps, emit target) similar in spirit to Next.js build tuning.
6
+ */
7
+ export type ConsumerBuildOptions = Readonly<{
8
+ /** When true, emit `.js.map` (and inline sources in maps) for transpiled workflow modules. */
9
+ sourceMaps: boolean;
10
+ /** ECMAScript language version for emitted workflow JavaScript. */
11
+ target: EcmaScriptBuildTarget;
12
+ }>;
@@ -0,0 +1,18 @@
1
+ import type { CodemationConfig } from "@codemation/host";
2
+ import { DatabasePersistenceResolver } from "@codemation/host/persistence";
3
+ import type { ResolvedDatabasePersistence } from "@codemation/host/persistence";
4
+
5
+ /**
6
+ * Resolves TCP PostgreSQL vs PGlite vs none from env + {@link CodemationConfig} (same rules as the host runtime).
7
+ */
8
+ export class ConsumerDatabaseConnectionResolver {
9
+ private readonly resolver = new DatabasePersistenceResolver();
10
+
11
+ resolve(processEnv: NodeJS.ProcessEnv, config: CodemationConfig, consumerRoot: string): ResolvedDatabasePersistence {
12
+ return this.resolver.resolve({
13
+ runtimeConfig: config.runtime ?? {},
14
+ env: processEnv,
15
+ consumerRoot,
16
+ });
17
+ }
18
+ }
@@ -0,0 +1,76 @@
1
+ import { CodemationConsumerConfigLoader } from "@codemation/host/server";
2
+ import type { ResolvedDatabasePersistence } from "@codemation/host/persistence";
3
+ import type { Logger } from "@codemation/host/next/server";
4
+
5
+ import { ConsumerCliTsconfigPreparation } from "../consumer/ConsumerCliTsconfigPreparation";
6
+ import { ConsumerDatabaseConnectionResolver } from "./ConsumerDatabaseConnectionResolver";
7
+ import type { CliDatabaseUrlDescriptor } from "../user/CliDatabaseUrlDescriptor";
8
+ import type { UserAdminConsumerDotenvLoader } from "../user/UserAdminConsumerDotenvLoader";
9
+
10
+ export type DatabaseMigrationDeployer = {
11
+ deployPersistence(persistence: ResolvedDatabasePersistence, env?: Readonly<NodeJS.ProcessEnv>): Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * Loads consumer config + env, resolves persistence, and runs Prisma migrations.
16
+ * Shared by `codemation db migrate` and `codemation dev` (cold start only).
17
+ */
18
+ export class DatabaseMigrationsApplyService {
19
+ constructor(
20
+ private readonly cliLogger: Logger,
21
+ private readonly consumerDotenvLoader: UserAdminConsumerDotenvLoader,
22
+ private readonly tsconfigPreparation: ConsumerCliTsconfigPreparation,
23
+ private readonly configLoader: CodemationConsumerConfigLoader,
24
+ private readonly databaseConnectionResolver: ConsumerDatabaseConnectionResolver,
25
+ private readonly databaseUrlDescriptor: CliDatabaseUrlDescriptor,
26
+ private readonly hostPackageRoot: string,
27
+ private readonly migrationDeployer: DatabaseMigrationDeployer,
28
+ ) {}
29
+
30
+ /**
31
+ * Applies migrations when persistence is configured; no-op when there is no database (in-memory dev).
32
+ */
33
+ async applyForConsumer(consumerRoot: string, options?: Readonly<{ configPath?: string }>): Promise<void> {
34
+ await this.applyInternal(consumerRoot, options, false);
35
+ }
36
+
37
+ /**
38
+ * Same as {@link applyForConsumer} but throws when no database is configured (for `db migrate`).
39
+ */
40
+ async applyForConsumerRequiringPersistence(
41
+ consumerRoot: string,
42
+ options?: Readonly<{ configPath?: string }>,
43
+ ): Promise<void> {
44
+ await this.applyInternal(consumerRoot, options, true);
45
+ }
46
+
47
+ private async applyInternal(
48
+ consumerRoot: string,
49
+ options: Readonly<{ configPath?: string }> | undefined,
50
+ requirePersistence: boolean,
51
+ ): Promise<void> {
52
+ this.consumerDotenvLoader.load(consumerRoot);
53
+ this.tsconfigPreparation.applyWorkspaceTsconfigForTsxIfPresent(consumerRoot);
54
+ const resolution = await this.configLoader.load({
55
+ consumerRoot,
56
+ configPathOverride: options?.configPath,
57
+ });
58
+ const persistence = this.databaseConnectionResolver.resolve(process.env, resolution.config, consumerRoot);
59
+ if (persistence.kind === "none") {
60
+ if (requirePersistence) {
61
+ throw new Error(
62
+ "Database persistence is not configured. Set CodemationConfig.runtime.database (postgresql URL or PGlite).",
63
+ );
64
+ }
65
+ return;
66
+ }
67
+ process.env.CODEMATION_HOST_PACKAGE_ROOT = this.hostPackageRoot;
68
+ this.cliLogger.debug(
69
+ `Applying database migrations (${this.databaseUrlDescriptor.describePersistence(persistence)})`,
70
+ );
71
+ await this.migrationDeployer.deployPersistence(persistence, process.env);
72
+ this.cliLogger.info(
73
+ `Database migrations applied (${this.databaseUrlDescriptor.describePersistence(persistence)}).`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * Locates the installed `@codemation/host` package root (contains `prisma/` and Prisma CLI).
7
+ * Uses ESM resolution (`@codemation/host` does not expose a CJS `require` entry).
8
+ */
9
+ export class HostPackageRootResolver {
10
+ resolveHostPackageRoot(): string {
11
+ const entryUrl = import.meta.resolve("@codemation/host");
12
+ const entry = fileURLToPath(entryUrl);
13
+ let dir = path.dirname(entry);
14
+ for (let depth = 0; depth < 8; depth += 1) {
15
+ if (existsSync(path.join(dir, "prisma", "schema.prisma"))) {
16
+ return dir;
17
+ }
18
+ const parent = path.dirname(dir);
19
+ if (parent === dir) {
20
+ break;
21
+ }
22
+ dir = parent;
23
+ }
24
+ throw new Error(`Could not locate prisma/schema.prisma near @codemation/host entry: ${entry}`);
25
+ }
26
+ }
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type PrismaMigrateDeployResult = Readonly<{
4
+ status: number | null;
5
+ }>;
6
+
7
+ export interface PrismaMigrateDeployRunner {
8
+ run(args: Readonly<{ hostPackageRoot: string; env: NodeJS.ProcessEnv }>): PrismaMigrateDeployResult;
9
+ }
10
+
11
+ /**
12
+ * Runs `pnpm exec prisma migrate deploy` in the host package (where the Prisma schema lives).
13
+ */
14
+ export class PrismaMigrateDeployInvoker implements PrismaMigrateDeployRunner {
15
+ run(args: Readonly<{ hostPackageRoot: string; env: NodeJS.ProcessEnv }>): PrismaMigrateDeployResult {
16
+ const result = spawnSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
17
+ cwd: args.hostPackageRoot,
18
+ env: args.env,
19
+ stdio: "inherit",
20
+ shell: false,
21
+ });
22
+ return { status: result.status };
23
+ }
24
+ }
@@ -0,0 +1,45 @@
1
+ import { CodemationConsumerConfigLoader } from "@codemation/host/server";
2
+ import type { ServerLoggerFactory } from "@codemation/host/next/server";
3
+
4
+ import { ConsumerEnvLoader } from "../consumer/ConsumerEnvLoader";
5
+ import { ListenPortResolver } from "../runtime/ListenPortResolver";
6
+ import { SourceMapNodeOptions } from "../runtime/SourceMapNodeOptions";
7
+
8
+ import { DevelopmentGatewayNotifier } from "./DevelopmentGatewayNotifier";
9
+ import { DevAuthSettingsLoader } from "./DevAuthSettingsLoader";
10
+ import { DevHttpProbe } from "./DevHttpProbe";
11
+ import { DevNextHostEnvironmentBuilder } from "./DevNextHostEnvironmentBuilder";
12
+ import { DevSessionPortsResolver } from "./DevSessionPortsResolver";
13
+ import { DevSessionServices } from "./DevSessionServices";
14
+ import { DevSourceRestartCoordinator } from "./DevSourceRestartCoordinator";
15
+ import { LoopbackPortAllocator } from "./LoopbackPortAllocator";
16
+ import { RuntimeToolEntrypointResolver } from "./RuntimeToolEntrypointResolver";
17
+ import { WatchRootsResolver } from "./WatchRootsResolver";
18
+
19
+ export class DevSessionServicesBuilder {
20
+ constructor(private readonly loggerFactory: ServerLoggerFactory) {}
21
+
22
+ build(): DevSessionServices {
23
+ const consumerEnvLoader = new ConsumerEnvLoader();
24
+ const sourceMapNodeOptions = new SourceMapNodeOptions();
25
+ const listenPortResolver = new ListenPortResolver();
26
+ const loopbackPortAllocator = new LoopbackPortAllocator();
27
+ const cliLogger = this.loggerFactory.create("codemation-cli");
28
+ return new DevSessionServices(
29
+ consumerEnvLoader,
30
+ sourceMapNodeOptions,
31
+ new DevSessionPortsResolver(listenPortResolver, loopbackPortAllocator),
32
+ loopbackPortAllocator,
33
+ new DevHttpProbe(),
34
+ new RuntimeToolEntrypointResolver(),
35
+ new DevAuthSettingsLoader(new CodemationConsumerConfigLoader()),
36
+ new DevNextHostEnvironmentBuilder(consumerEnvLoader, sourceMapNodeOptions),
37
+ new WatchRootsResolver(),
38
+ new DevSourceRestartCoordinator(
39
+ new DevelopmentGatewayNotifier(cliLogger),
40
+ this.loggerFactory.createPerformanceDiagnostics("codemation-cli.performance"),
41
+ cliLogger,
42
+ ),
43
+ );
44
+ }
45
+ }
@@ -0,0 +1,12 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * True when `filePath` names a consumer dotenv file (`.env`, `.env.local`, …).
5
+ * Used by `codemation dev` to distinguish env-only changes from source rebuilds.
6
+ */
7
+ export class ConsumerEnvDotenvFilePredicate {
8
+ matches(filePath: string): boolean {
9
+ const fileName = path.basename(filePath);
10
+ return fileName === ".env" || fileName.startsWith(".env.");
11
+ }
12
+ }
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { CodemationConsumerConfigLoader } from "@codemation/host/server";
4
+
5
+ export type DevResolvedAuthSettings = Readonly<{
6
+ authConfigJson: string;
7
+ skipUiAuth: boolean;
8
+ }>;
9
+
10
+ export class DevAuthSettingsLoader {
11
+ constructor(private readonly configLoader: CodemationConsumerConfigLoader) {}
12
+
13
+ resolveDevelopmentServerToken(rawToken: string | undefined): string {
14
+ if (rawToken && rawToken.trim().length > 0) {
15
+ return rawToken;
16
+ }
17
+ return randomUUID();
18
+ }
19
+
20
+ async loadForConsumer(consumerRoot: string): Promise<DevResolvedAuthSettings> {
21
+ const resolution = await this.configLoader.load({ consumerRoot });
22
+ return {
23
+ authConfigJson: JSON.stringify(resolution.config.auth ?? null),
24
+ skipUiAuth: resolution.config.auth?.allowUnauthenticatedInDevelopment === true,
25
+ };
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { DevBootstrapSummaryJson } from "@codemation/host/next/server";
2
+
3
+ /**
4
+ * Fetches {@link DevBootstrapSummaryJson} from the dev gateway (proxied to runtime-dev).
5
+ */
6
+ export class DevBootstrapSummaryFetcher {
7
+ async fetch(gatewayBaseUrl: string): Promise<DevBootstrapSummaryJson | null> {
8
+ const normalized = gatewayBaseUrl.replace(/\/$/, "");
9
+ const response = await fetch(`${normalized}/api/dev/bootstrap-summary`);
10
+ if (!response.ok) {
11
+ return null;
12
+ }
13
+ return (await response.json()) as DevBootstrapSummaryJson;
14
+ }
15
+ }
@@ -0,0 +1,106 @@
1
+ import boxen from "boxen";
2
+ import chalk from "chalk";
3
+ import figlet from "figlet";
4
+ import type { DevBootstrapSummaryJson } from "@codemation/host/next/server";
5
+
6
+ /**
7
+ * Dev-only stdout branding (not the structured host logger).
8
+ * Renders the figlet banner once on cold start; append-only compact block after hot reload.
9
+ */
10
+ export class DevCliBannerRenderer {
11
+ /**
12
+ * Figlet header only — call early so branding appears before migrations / gateway work.
13
+ */
14
+ renderBrandHeader(): void {
15
+ const titleLines = this.renderFigletTitle();
16
+ const subtitle = chalk.dim.italic("AI Automation framework");
17
+ const headerInner = `${titleLines}\n${subtitle}`;
18
+ const headerBox = boxen(headerInner, {
19
+ padding: { top: 0, bottom: 1, left: 1, right: 1 },
20
+ margin: { bottom: 0 },
21
+ borderStyle: "double",
22
+ borderColor: "cyan",
23
+ textAlignment: "center",
24
+ });
25
+ process.stdout.write(`${headerBox}\n`);
26
+ }
27
+
28
+ /**
29
+ * Runtime detail + active workflows (after bootstrap summary is available).
30
+ */
31
+ renderRuntimeSummary(summary: DevBootstrapSummaryJson): void {
32
+ const detailBody = this.buildDetailBody(summary);
33
+ const detailBox = boxen(detailBody, {
34
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
35
+ margin: { top: 1, bottom: 0 },
36
+ borderStyle: "round",
37
+ borderColor: "gray",
38
+ dimBorder: true,
39
+ title: chalk.bold("Runtime"),
40
+ titleAlignment: "center",
41
+ });
42
+ const activeSection = this.buildActiveWorkflowsSection(summary);
43
+ process.stdout.write(`${detailBox}\n${activeSection}\n`);
44
+ }
45
+
46
+ renderFull(summary: DevBootstrapSummaryJson): void {
47
+ this.renderBrandHeader();
48
+ this.renderRuntimeSummary(summary);
49
+ }
50
+
51
+ /**
52
+ * Shown after hot reload / watcher restarts (no figlet).
53
+ */
54
+ renderCompact(summary: DevBootstrapSummaryJson): void {
55
+ const body = this.buildDetailBody(summary);
56
+ const detailBox = boxen(body, {
57
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
58
+ margin: { top: 1, bottom: 0 },
59
+ borderStyle: "round",
60
+ borderColor: "gray",
61
+ dimBorder: true,
62
+ title: chalk.bold("Runtime (updated)"),
63
+ titleAlignment: "center",
64
+ });
65
+ const activeSection = this.buildActiveWorkflowsSection(summary);
66
+ process.stdout.write(`\n${detailBox}\n${activeSection}\n`);
67
+ }
68
+
69
+ private renderFigletTitle(): string {
70
+ try {
71
+ return chalk.cyan(figlet.textSync("Codemation", { font: "Slant" }));
72
+ } catch {
73
+ return chalk.cyan.bold("Codemation");
74
+ }
75
+ }
76
+
77
+ private buildDetailBody(summary: DevBootstrapSummaryJson): string {
78
+ const label = (text: string) => chalk.hex("#9ca3af")(text);
79
+ const value = (text: string) => chalk.whiteBright(text);
80
+ const lines = [
81
+ `${label("Log level")} ${value(summary.logLevel)}`,
82
+ `${label("Database")} ${value(summary.databaseLabel)}`,
83
+ `${label("Scheduler")} ${value(summary.schedulerLabel)}`,
84
+ `${label("Event bus")} ${value(summary.eventBusLabel)}`,
85
+ ];
86
+ if (summary.redisUrlRedacted) {
87
+ lines.push(`${label("Redis")} ${value(summary.redisUrlRedacted)}`);
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+
92
+ private buildActiveWorkflowsSection(summary: DevBootstrapSummaryJson): string {
93
+ const lines =
94
+ summary.activeWorkflows.length === 0
95
+ ? [chalk.dim(" (none active)")]
96
+ : summary.activeWorkflows.map((w) => `${chalk.whiteBright(` • ${w.name} `)}${chalk.dim(`(${w.id})`)}`);
97
+ return boxen(lines.join("\n"), {
98
+ padding: { top: 0, bottom: 0, left: 0, right: 0 },
99
+ margin: { top: 1, bottom: 0 },
100
+ borderStyle: "single",
101
+ borderColor: "magenta",
102
+ title: chalk.bold("Active workflows"),
103
+ titleAlignment: "left",
104
+ });
105
+ }
106
+ }
@@ -0,0 +1,30 @@
1
+ import type { Logger } from "@codemation/host/next/server";
2
+ import { CodemationPluginDiscovery } from "@codemation/host/server";
3
+
4
+ import { ConsumerBuildArtifactsPublisher } from "../build/ConsumerBuildArtifactsPublisher";
5
+ import { ConsumerBuildOptionsParser } from "../build/ConsumerBuildOptionsParser";
6
+ import { ConsumerOutputBuilderLoader } from "../consumer/Loader";
7
+ import type { CliPaths } from "../path/CliPathResolver";
8
+
9
+ /**
10
+ * Ensures `.codemation/output/current.json` and transpiled consumer config exist before the Next host boots.
11
+ * Without this, `codemation dev` can serve a stale built `codemation.config.js` (e.g. missing whitelabel).
12
+ */
13
+ export class DevConsumerPublishBootstrap {
14
+ constructor(
15
+ private readonly cliLogger: Logger,
16
+ private readonly pluginDiscovery: CodemationPluginDiscovery,
17
+ private readonly artifactsPublisher: ConsumerBuildArtifactsPublisher,
18
+ private readonly outputBuilderLoader: ConsumerOutputBuilderLoader,
19
+ private readonly buildOptionsParser: ConsumerBuildOptionsParser,
20
+ ) {}
21
+
22
+ async ensurePublished(paths: CliPaths): Promise<void> {
23
+ const buildOptions = this.buildOptionsParser.parse({});
24
+ const builder = this.outputBuilderLoader.create(paths.consumerRoot, buildOptions);
25
+ const snapshot = await builder.ensureBuilt();
26
+ const discoveredPlugins = await this.pluginDiscovery.discover(paths.consumerRoot);
27
+ await this.artifactsPublisher.publish(snapshot, discoveredPlugins);
28
+ this.cliLogger.debug(`Dev: consumer output published (${snapshot.buildVersion}).`);
29
+ }
30
+ }
@@ -0,0 +1,54 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ export class DevHttpProbe {
4
+ async waitUntilUrlRespondsOk(url: string): Promise<void> {
5
+ for (let attempt = 0; attempt < 200; attempt += 1) {
6
+ try {
7
+ const response = await fetch(url);
8
+ if (response.ok || response.status === 404) {
9
+ return;
10
+ }
11
+ } catch {
12
+ // not listening yet
13
+ }
14
+ await delay(50);
15
+ }
16
+ throw new Error(`Timed out waiting for HTTP response from ${url}`);
17
+ }
18
+
19
+ async waitUntilGatewayHealthy(gatewayBaseUrl: string): Promise<void> {
20
+ const normalizedBase = gatewayBaseUrl.replace(/\/$/, "");
21
+ for (let attempt = 0; attempt < 200; attempt += 1) {
22
+ try {
23
+ const response = await fetch(`${normalizedBase}/api/dev/health`);
24
+ if (response.ok) {
25
+ return;
26
+ }
27
+ } catch {
28
+ // Server not listening yet.
29
+ }
30
+ await delay(50);
31
+ }
32
+ throw new Error("Timed out waiting for dev gateway HTTP health check.");
33
+ }
34
+
35
+ /**
36
+ * Polls until the runtime child serves bootstrap summary (after gateway is up, the disposable runtime may still be wiring).
37
+ */
38
+ async waitUntilBootstrapSummaryReady(gatewayBaseUrl: string): Promise<void> {
39
+ const normalizedBase = gatewayBaseUrl.replace(/\/$/, "");
40
+ const url = `${normalizedBase}/api/dev/bootstrap-summary`;
41
+ for (let attempt = 0; attempt < 200; attempt += 1) {
42
+ try {
43
+ const response = await fetch(url);
44
+ if (response.ok) {
45
+ return;
46
+ }
47
+ } catch {
48
+ // Runtime child restarting or not listening yet.
49
+ }
50
+ await delay(50);
51
+ }
52
+ throw new Error("Timed out waiting for dev runtime bootstrap summary.");
53
+ }
54
+ }
@@ -0,0 +1,98 @@
1
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+
5
+ type DevLockRecord = Readonly<{
6
+ pid: number;
7
+ startedAt: string;
8
+ consumerRoot: string;
9
+ nextPort: number;
10
+ }>;
11
+
12
+ export class DevLock {
13
+ private lockPath: string | null = null;
14
+
15
+ async acquire(args: Readonly<{ consumerRoot: string; nextPort: number }>): Promise<void> {
16
+ const lockPath = this.resolveLockPath(args.consumerRoot);
17
+ await mkdir(path.dirname(lockPath), { recursive: true });
18
+ const record: DevLockRecord = {
19
+ pid: process.pid,
20
+ startedAt: new Date().toISOString(),
21
+ consumerRoot: args.consumerRoot,
22
+ nextPort: args.nextPort,
23
+ };
24
+ try {
25
+ await this.writeExclusive(lockPath, JSON.stringify(record, null, 2));
26
+ this.lockPath = lockPath;
27
+ return;
28
+ } catch (error) {
29
+ const errorWithCode = error as Error & Readonly<{ code?: unknown }>;
30
+ if (errorWithCode.code !== "EEXIST") {
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ const existingRecord = await this.readExistingRecord(lockPath);
36
+ if (existingRecord && this.isProcessAlive(existingRecord.pid)) {
37
+ throw new Error(
38
+ `codemation dev is already running for ${args.consumerRoot} (pid=${existingRecord.pid}, port=${existingRecord.nextPort}). Stop it before starting a new dev server.`,
39
+ );
40
+ }
41
+
42
+ await rm(lockPath, { force: true }).catch(() => null);
43
+ await this.writeExclusive(lockPath, JSON.stringify(record, null, 2));
44
+ this.lockPath = lockPath;
45
+ }
46
+
47
+ async release(): Promise<void> {
48
+ if (!this.lockPath) {
49
+ return;
50
+ }
51
+ const lockPath = this.lockPath;
52
+ this.lockPath = null;
53
+ await rm(lockPath, { force: true }).catch(() => null);
54
+ }
55
+
56
+ private resolveLockPath(consumerRoot: string): string {
57
+ return path.resolve(consumerRoot, ".codemation", "dev.lock");
58
+ }
59
+
60
+ private async writeExclusive(filePath: string, contents: string): Promise<void> {
61
+ const handle = await open(filePath, "wx");
62
+ try {
63
+ await handle.writeFile(contents, "utf8");
64
+ } finally {
65
+ await handle.close().catch(() => null);
66
+ }
67
+ }
68
+
69
+ private async readExistingRecord(lockPath: string): Promise<DevLockRecord | null> {
70
+ try {
71
+ const raw = await readFile(lockPath, "utf8");
72
+ const parsed = JSON.parse(raw) as Partial<DevLockRecord>;
73
+ if (
74
+ typeof parsed.pid !== "number" ||
75
+ typeof parsed.startedAt !== "string" ||
76
+ typeof parsed.consumerRoot !== "string" ||
77
+ typeof parsed.nextPort !== "number"
78
+ ) {
79
+ return null;
80
+ }
81
+ return parsed as DevLockRecord;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ private isProcessAlive(pid: number): boolean {
88
+ if (!Number.isInteger(pid) || pid <= 0) {
89
+ return false;
90
+ }
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,49 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+
4
+ import { ConsumerEnvLoader } from "../consumer/ConsumerEnvLoader";
5
+ import { SourceMapNodeOptions } from "../runtime/SourceMapNodeOptions";
6
+
7
+ export class DevNextHostEnvironmentBuilder {
8
+ constructor(
9
+ private readonly consumerEnvLoader: ConsumerEnvLoader,
10
+ private readonly sourceMapNodeOptions: SourceMapNodeOptions,
11
+ ) {}
12
+
13
+ build(
14
+ args: Readonly<{
15
+ authConfigJson: string;
16
+ consumerRoot: string;
17
+ developmentServerToken: string;
18
+ nextPort: number;
19
+ skipUiAuth: boolean;
20
+ websocketPort: number;
21
+ runtimeDevUrl?: string;
22
+ /** Same manifest as `codemation build` / serve-web so @codemation/next-host can load consumer config (whitelabel, etc.). */
23
+ consumerOutputManifestPath?: string;
24
+ }>,
25
+ ): NodeJS.ProcessEnv {
26
+ const merged = this.consumerEnvLoader.mergeConsumerRootIntoProcessEnvironment(args.consumerRoot, process.env);
27
+ const manifestPath =
28
+ args.consumerOutputManifestPath ?? path.resolve(args.consumerRoot, ".codemation", "output", "current.json");
29
+ return {
30
+ ...merged,
31
+ PORT: String(args.nextPort),
32
+ CODEMATION_AUTH_CONFIG_JSON: args.authConfigJson,
33
+ CODEMATION_CONSUMER_ROOT: args.consumerRoot,
34
+ CODEMATION_CONSUMER_OUTPUT_MANIFEST_PATH: manifestPath,
35
+ CODEMATION_SKIP_UI_AUTH: args.skipUiAuth ? "true" : "false",
36
+ NEXT_PUBLIC_CODEMATION_SKIP_UI_AUTH: args.skipUiAuth ? "true" : "false",
37
+ CODEMATION_WS_PORT: String(args.websocketPort),
38
+ NEXT_PUBLIC_CODEMATION_WS_PORT: String(args.websocketPort),
39
+ CODEMATION_DEV_SERVER_TOKEN: args.developmentServerToken,
40
+ CODEMATION_SKIP_STARTUP_MIGRATIONS: "true",
41
+ NODE_OPTIONS: this.sourceMapNodeOptions.appendToNodeOptions(process.env.NODE_OPTIONS),
42
+ WS_NO_BUFFER_UTIL: "1",
43
+ WS_NO_UTF_8_VALIDATE: "1",
44
+ ...(args.runtimeDevUrl !== undefined && args.runtimeDevUrl.trim().length > 0
45
+ ? { CODEMATION_RUNTIME_DEV_URL: args.runtimeDevUrl.trim() }
46
+ : {}),
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,23 @@
1
+ import { ListenPortResolver } from "../runtime/ListenPortResolver";
2
+ import { LoopbackPortAllocator } from "./LoopbackPortAllocator";
3
+
4
+ export class DevSessionPortsResolver {
5
+ constructor(
6
+ private readonly listenPorts: ListenPortResolver,
7
+ private readonly loopbackPorts: LoopbackPortAllocator,
8
+ ) {}
9
+
10
+ async resolve(
11
+ args: Readonly<{
12
+ devMode: "consumer" | "framework";
13
+ portEnv: string | undefined;
14
+ gatewayPortEnv: string | undefined;
15
+ }>,
16
+ ): Promise<Readonly<{ nextPort: number; gatewayPort: number }>> {
17
+ const nextPort = this.listenPorts.resolvePrimaryApplicationPort(args.portEnv);
18
+ const gatewayPort =
19
+ this.listenPorts.parsePositiveInteger(args.gatewayPortEnv) ??
20
+ (args.devMode === "consumer" ? nextPort : await this.loopbackPorts.allocate());
21
+ return { nextPort, gatewayPort };
22
+ }
23
+ }
@@ -0,0 +1,29 @@
1
+ import { ConsumerEnvLoader } from "../consumer/ConsumerEnvLoader";
2
+ import { SourceMapNodeOptions } from "../runtime/SourceMapNodeOptions";
3
+
4
+ import { DevAuthSettingsLoader } from "./DevAuthSettingsLoader";
5
+ import { DevHttpProbe } from "./DevHttpProbe";
6
+ import { DevNextHostEnvironmentBuilder } from "./DevNextHostEnvironmentBuilder";
7
+ import { DevSessionPortsResolver } from "./DevSessionPortsResolver";
8
+ import { DevSourceRestartCoordinator } from "./DevSourceRestartCoordinator";
9
+ import { LoopbackPortAllocator } from "./LoopbackPortAllocator";
10
+ import { RuntimeToolEntrypointResolver } from "./RuntimeToolEntrypointResolver";
11
+ import { WatchRootsResolver } from "./WatchRootsResolver";
12
+
13
+ /**
14
+ * Bundles dependencies for {@link DevCommand} so the command stays a thin orchestrator.
15
+ */
16
+ export class DevSessionServices {
17
+ constructor(
18
+ readonly consumerEnvLoader: ConsumerEnvLoader,
19
+ readonly sourceMapNodeOptions: SourceMapNodeOptions,
20
+ readonly sessionPorts: DevSessionPortsResolver,
21
+ readonly loopbackPortAllocator: LoopbackPortAllocator,
22
+ readonly devHttpProbe: DevHttpProbe,
23
+ readonly runtimeEntrypointResolver: RuntimeToolEntrypointResolver,
24
+ readonly devAuthLoader: DevAuthSettingsLoader,
25
+ readonly nextHostEnvBuilder: DevNextHostEnvironmentBuilder,
26
+ readonly watchRootsResolver: WatchRootsResolver,
27
+ readonly sourceRestartCoordinator: DevSourceRestartCoordinator,
28
+ ) {}
29
+ }