@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,48 @@
1
+ import type { Logger } from "@codemation/host/next/server";
2
+ import process from "node:process";
3
+
4
+ import { DevelopmentGatewayNotifier } from "./DevelopmentGatewayNotifier";
5
+
6
+ export class DevSourceRestartCoordinator {
7
+ constructor(
8
+ private readonly gatewayNotifier: DevelopmentGatewayNotifier,
9
+ private readonly performanceDiagnosticsLogger: Logger,
10
+ private readonly cliLogger: Logger,
11
+ ) {}
12
+
13
+ async runHandshakeAfterSourceChange(gatewayBaseUrl: string, developmentServerToken: string): Promise<void> {
14
+ const restartStarted = performance.now();
15
+ try {
16
+ await this.gatewayNotifier.notify({
17
+ gatewayBaseUrl,
18
+ developmentServerToken,
19
+ payload: {
20
+ kind: "buildStarted",
21
+ },
22
+ });
23
+ await this.gatewayNotifier.notify({
24
+ gatewayBaseUrl,
25
+ developmentServerToken,
26
+ payload: {
27
+ kind: "buildCompleted",
28
+ buildVersion: `${Date.now()}-${process.pid}`,
29
+ },
30
+ });
31
+ const totalMs = performance.now() - restartStarted;
32
+ this.performanceDiagnosticsLogger.info(
33
+ `triggered source-based runtime restart timingMs={total:${totalMs.toFixed(1)}}`,
34
+ );
35
+ } catch (error) {
36
+ const exception = error instanceof Error ? error : new Error(String(error));
37
+ await this.gatewayNotifier.notify({
38
+ gatewayBaseUrl,
39
+ developmentServerToken,
40
+ payload: {
41
+ kind: "buildFailed",
42
+ message: exception.message,
43
+ },
44
+ });
45
+ this.cliLogger.error("source-based runtime restart request failed", exception);
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,102 @@
1
+ import type { FSWatcher } from "chokidar";
2
+ import { watch } from "chokidar";
3
+ import path from "node:path";
4
+
5
+ export class DevSourceWatcher {
6
+ private static readonly ignoredDirectoryNames = new Set([
7
+ ".codemation",
8
+ ".git",
9
+ ".next",
10
+ "coverage",
11
+ "dist",
12
+ "node_modules",
13
+ ]);
14
+
15
+ private watcher: FSWatcher | null = null;
16
+ private debounceTimeout: NodeJS.Timeout | null = null;
17
+ private readonly changedPathsBuffer = new Set<string>();
18
+
19
+ async start(
20
+ args: Readonly<{
21
+ roots: ReadonlyArray<string>;
22
+ onChange: (ctx: Readonly<{ changedPaths: ReadonlyArray<string> }>) => Promise<void>;
23
+ }>,
24
+ ): Promise<void> {
25
+ if (this.watcher) {
26
+ return;
27
+ }
28
+ this.watcher = watch([...args.roots], {
29
+ ignoreInitial: true,
30
+ ignored: (watchPath: string) => this.isIgnoredPath(watchPath),
31
+ });
32
+ this.watcher.on("all", (_eventName, watchPath) => {
33
+ if (typeof watchPath !== "string" || watchPath.length === 0) {
34
+ return;
35
+ }
36
+ if (!this.isRelevantPath(watchPath)) {
37
+ return;
38
+ }
39
+ this.changedPathsBuffer.add(path.resolve(watchPath));
40
+ this.scheduleDebouncedChange(args.onChange);
41
+ });
42
+ }
43
+
44
+ async stop(): Promise<void> {
45
+ if (this.debounceTimeout) {
46
+ clearTimeout(this.debounceTimeout);
47
+ this.debounceTimeout = null;
48
+ }
49
+ if (this.watcher) {
50
+ await this.watcher.close();
51
+ this.watcher = null;
52
+ }
53
+ }
54
+
55
+ private scheduleDebouncedChange(
56
+ onChange: (ctx: Readonly<{ changedPaths: ReadonlyArray<string> }>) => Promise<void>,
57
+ ): void {
58
+ if (this.debounceTimeout) {
59
+ clearTimeout(this.debounceTimeout);
60
+ }
61
+ this.debounceTimeout = setTimeout(() => {
62
+ this.debounceTimeout = null;
63
+ void this.flushPendingChange(onChange);
64
+ }, 75);
65
+ }
66
+
67
+ private async flushPendingChange(
68
+ onChange: (ctx: Readonly<{ changedPaths: ReadonlyArray<string> }>) => Promise<void>,
69
+ ): Promise<void> {
70
+ if (this.changedPathsBuffer.size === 0) {
71
+ return;
72
+ }
73
+ const changedPaths = [...this.changedPathsBuffer];
74
+ this.changedPathsBuffer.clear();
75
+ await onChange({ changedPaths });
76
+ }
77
+
78
+ private isIgnoredPath(watchPath: string): boolean {
79
+ const normalized = path.resolve(watchPath).replace(/\\/g, "/");
80
+ return normalized.split("/").some((segment: string) => DevSourceWatcher.ignoredDirectoryNames.has(segment));
81
+ }
82
+
83
+ private isRelevantPath(watchPath: string): boolean {
84
+ const fileName = path.basename(watchPath);
85
+ if (fileName === ".env" || fileName.startsWith(".env.")) {
86
+ return true;
87
+ }
88
+ const extension = path.extname(watchPath).toLowerCase();
89
+ return (
90
+ extension === ".cts" ||
91
+ extension === ".cjs" ||
92
+ extension === ".js" ||
93
+ extension === ".json" ||
94
+ extension === ".jsx" ||
95
+ extension === ".mts" ||
96
+ extension === ".mjs" ||
97
+ extension === ".prisma" ||
98
+ extension === ".ts" ||
99
+ extension === ".tsx"
100
+ );
101
+ }
102
+ }
@@ -0,0 +1,107 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ /**
5
+ * Stops a spawned dev child and every descendant process.
6
+ *
7
+ * On Unix, children are expected to have been created with `spawn({ detached: true })` so the root
8
+ * child is the process-group leader; we first send `SIGTERM` to the whole group and only escalate to
9
+ * `SIGKILL` when the process does not exit within the grace period.
10
+ * On Windows, uses `taskkill /F /T` to terminate the process tree.
11
+ */
12
+ export class DevTrackedProcessTreeKiller {
13
+ constructor(private readonly terminationGracePeriodMs = 1500) {}
14
+
15
+ async killProcessTreeRootedAt(child: ChildProcess): Promise<void> {
16
+ const pid = child.pid;
17
+ if (pid === undefined) {
18
+ if (!(await this.trySigTerm(child))) {
19
+ this.trySigKill(child);
20
+ await this.waitForExit(child);
21
+ }
22
+ return;
23
+ }
24
+ if (process.platform === "win32") {
25
+ await this.killWindowsProcessTree(pid);
26
+ await this.waitForExit(child);
27
+ } else {
28
+ if (!(await this.trySigTermProcessGroup(pid, child))) {
29
+ this.trySigKill(child);
30
+ this.trySigKillProcessGroup(pid);
31
+ await this.waitForExit(child);
32
+ }
33
+ }
34
+ }
35
+
36
+ private trySigKill(child: ChildProcess): void {
37
+ try {
38
+ child.kill("SIGKILL");
39
+ } catch {
40
+ // Process may already be gone.
41
+ }
42
+ }
43
+
44
+ private killWindowsProcessTree(pid: number): Promise<void> {
45
+ return new Promise((resolve) => {
46
+ const proc = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
47
+ stdio: "ignore",
48
+ windowsHide: true,
49
+ });
50
+ proc.once("exit", () => {
51
+ resolve();
52
+ });
53
+ proc.once("error", () => {
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+
59
+ private async trySigTerm(child: ChildProcess): Promise<boolean> {
60
+ try {
61
+ child.kill("SIGTERM");
62
+ } catch {
63
+ return child.exitCode !== null || child.signalCode !== null;
64
+ }
65
+ return await this.waitForExit(child, this.terminationGracePeriodMs);
66
+ }
67
+
68
+ private async trySigTermProcessGroup(pid: number, child: ChildProcess): Promise<boolean> {
69
+ try {
70
+ process.kill(-pid, "SIGTERM");
71
+ } catch {
72
+ return await this.trySigTerm(child);
73
+ }
74
+ return await this.waitForExit(child, this.terminationGracePeriodMs);
75
+ }
76
+
77
+ private trySigKillProcessGroup(pid: number): void {
78
+ try {
79
+ process.kill(-pid, "SIGKILL");
80
+ } catch {
81
+ // Process group may already be gone.
82
+ }
83
+ }
84
+
85
+ private waitForExit(child: ChildProcess, timeoutMs?: number): Promise<boolean> {
86
+ if (child.exitCode !== null || child.signalCode !== null) {
87
+ return Promise.resolve(true);
88
+ }
89
+ return new Promise<boolean>((resolve) => {
90
+ let timeout: NodeJS.Timeout | undefined;
91
+ const onExit = () => {
92
+ if (timeout) {
93
+ clearTimeout(timeout);
94
+ }
95
+ resolve(true);
96
+ };
97
+ child.once("exit", onExit);
98
+ if (timeoutMs === undefined) {
99
+ return;
100
+ }
101
+ timeout = setTimeout(() => {
102
+ child.removeListener("exit", onExit);
103
+ resolve(child.exitCode !== null || child.signalCode !== null);
104
+ }, timeoutMs);
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,35 @@
1
+ import { ApiPaths } from "@codemation/host";
2
+ import type { Logger } from "@codemation/host/next/server";
3
+
4
+ export class DevelopmentGatewayNotifier {
5
+ constructor(private readonly cliLogger: Logger) {}
6
+
7
+ async notify(
8
+ args: Readonly<{
9
+ gatewayBaseUrl: string;
10
+ developmentServerToken: string;
11
+ payload: Readonly<{
12
+ kind: "buildStarted" | "buildCompleted" | "buildFailed";
13
+ buildVersion?: string;
14
+ message?: string;
15
+ }>;
16
+ }>,
17
+ ): Promise<void> {
18
+ const targetUrl = `${args.gatewayBaseUrl.replace(/\/$/, "")}${ApiPaths.devGatewayNotify()}`;
19
+ try {
20
+ const response = await fetch(targetUrl, {
21
+ method: "POST",
22
+ headers: {
23
+ "content-type": "application/json",
24
+ "x-codemation-dev-token": args.developmentServerToken,
25
+ },
26
+ body: JSON.stringify(args.payload),
27
+ });
28
+ if (!response.ok) {
29
+ this.cliLogger.warn(`failed to notify dev gateway status=${response.status}`);
30
+ }
31
+ } catch (error) {
32
+ this.cliLogger.warn(`failed to notify dev gateway: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ import { DevLock } from "./DevLock";
2
+
3
+ export class DevLockFactory {
4
+ create(): DevLock {
5
+ return new DevLock();
6
+ }
7
+ }
@@ -0,0 +1,20 @@
1
+ import { createServer } from "node:net";
2
+
3
+ export class LoopbackPortAllocator {
4
+ async allocate(): Promise<number> {
5
+ return await new Promise<number>((resolve, reject) => {
6
+ const server = createServer();
7
+ server.once("error", reject);
8
+ server.listen(0, "127.0.0.1", () => {
9
+ const address = server.address();
10
+ server.close(() => {
11
+ if (address && typeof address === "object") {
12
+ resolve(address.port);
13
+ return;
14
+ }
15
+ reject(new Error("Failed to resolve a free TCP port."));
16
+ });
17
+ });
18
+ });
19
+ }
20
+ }
@@ -0,0 +1,7 @@
1
+ import { DevSourceWatcher } from "./DevSourceWatcher";
2
+
3
+ export class DevSourceWatcherFactory {
4
+ create(): DevSourceWatcher {
5
+ return new DevSourceWatcher();
6
+ }
7
+ }
@@ -0,0 +1,47 @@
1
+ import { access } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ export type ResolvedRuntimeToolEntrypoint = Readonly<{
7
+ args: ReadonlyArray<string>;
8
+ command: string;
9
+ env: Readonly<Record<string, string>>;
10
+ }>;
11
+
12
+ export class RuntimeToolEntrypointResolver {
13
+ private readonly require = createRequire(import.meta.url);
14
+
15
+ async resolve(
16
+ args: Readonly<{
17
+ packageName: string;
18
+ repoRoot: string;
19
+ sourceEntrypoint: string;
20
+ }>,
21
+ ): Promise<ResolvedRuntimeToolEntrypoint> {
22
+ const sourceEntrypointPath = path.resolve(args.repoRoot, args.sourceEntrypoint);
23
+ if (await this.exists(sourceEntrypointPath)) {
24
+ return {
25
+ command: process.execPath,
26
+ args: ["--import", "tsx", sourceEntrypointPath],
27
+ env: {
28
+ TSX_TSCONFIG_PATH: path.resolve(args.repoRoot, "tsconfig.codemation-tsx.json"),
29
+ },
30
+ };
31
+ }
32
+ return {
33
+ command: process.execPath,
34
+ args: [this.require.resolve(args.packageName)],
35
+ env: {},
36
+ };
37
+ }
38
+
39
+ private async exists(filePath: string): Promise<boolean> {
40
+ try {
41
+ await access(filePath);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+
3
+ export class WatchRootsResolver {
4
+ resolve(
5
+ args: Readonly<{
6
+ consumerRoot: string;
7
+ devMode: "consumer" | "framework";
8
+ repoRoot: string;
9
+ }>,
10
+ ): ReadonlyArray<string> {
11
+ if (args.devMode === "consumer") {
12
+ return [args.consumerRoot];
13
+ }
14
+ return [
15
+ args.consumerRoot,
16
+ path.resolve(args.repoRoot, "packages", "core"),
17
+ path.resolve(args.repoRoot, "packages", "core-nodes"),
18
+ path.resolve(args.repoRoot, "packages", "core-nodes-gmail"),
19
+ path.resolve(args.repoRoot, "packages", "eventbus-redis"),
20
+ path.resolve(args.repoRoot, "packages", "host"),
21
+ path.resolve(args.repoRoot, "packages", "node-example"),
22
+ path.resolve(args.repoRoot, "packages", "queue-bullmq"),
23
+ path.resolve(args.repoRoot, "packages", "runtime-dev"),
24
+ ];
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { CodemationCliApplicationSession } from "./bootstrap/CodemationCliApplicationSession";
2
+ export { CliBin } from "./CliBin";
3
+ export { CliProgramFactory } from "./CliProgramFactory";
4
+ export { CliPathResolver, type CliPaths } from "./path/CliPathResolver";
5
+ export { CliProgram } from "./Program";
6
+ export { ConsumerBuildOptionsParser } from "./build/ConsumerBuildOptionsParser";
7
+ export { ConsumerOutputBuilder } from "./consumer/ConsumerOutputBuilder";
8
+ export type { ConsumerOutputBuildSnapshot } from "./consumer/ConsumerOutputBuilder";
9
+ export type { ConsumerBuildOptions, EcmaScriptBuildTarget } from "./consumer/consumerBuildOptions.types";
10
+ export type { CodemationDiscoveredPluginPackage, CodemationResolvedPluginPackage } from "@codemation/host/server";
11
+ export { CodemationPluginDiscovery } from "@codemation/host/server";
12
+ export type { LocalUserCreateOptions } from "./user/LocalUserCreator";
@@ -0,0 +1,41 @@
1
+ import { access } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export type CliPaths = Readonly<{
5
+ consumerRoot: string;
6
+ repoRoot: string;
7
+ }>;
8
+
9
+ export class CliPathResolver {
10
+ async resolve(consumerStartPath: string): Promise<CliPaths> {
11
+ const consumerRoot = path.resolve(consumerStartPath);
12
+ const repoRoot = await this.detectWorkspaceRoot(consumerRoot);
13
+ return {
14
+ consumerRoot,
15
+ repoRoot: repoRoot ?? consumerRoot,
16
+ };
17
+ }
18
+
19
+ private async detectWorkspaceRoot(startDirectory: string): Promise<string | null> {
20
+ let currentDirectory = path.resolve(startDirectory);
21
+ while (true) {
22
+ if (await this.exists(path.resolve(currentDirectory, "pnpm-workspace.yaml"))) {
23
+ return currentDirectory;
24
+ }
25
+ const parentDirectory = path.dirname(currentDirectory);
26
+ if (parentDirectory === currentDirectory) {
27
+ return null;
28
+ }
29
+ currentDirectory = parentDirectory;
30
+ }
31
+ }
32
+
33
+ private async exists(filePath: string): Promise<boolean> {
34
+ try {
35
+ await access(filePath);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared HTTP listen port parsing for CLI commands (dev server, serve web, etc.).
3
+ */
4
+ export class ListenPortResolver {
5
+ resolvePrimaryApplicationPort(rawPort: string | undefined): number {
6
+ const parsedPort = Number(rawPort);
7
+ if (Number.isInteger(parsedPort) && parsedPort > 0) {
8
+ return parsedPort;
9
+ }
10
+ return 3000;
11
+ }
12
+
13
+ parsePositiveInteger(raw: string | undefined): number | null {
14
+ const parsed = Number(raw);
15
+ if (Number.isInteger(parsed) && parsed > 0) {
16
+ return parsed;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ resolveWebsocketPortRelativeToHttp(
22
+ args: Readonly<{
23
+ nextPort: number;
24
+ publicWebsocketPort: string | undefined;
25
+ websocketPort: string | undefined;
26
+ }>,
27
+ ): number {
28
+ const explicit =
29
+ this.parsePositiveInteger(args.publicWebsocketPort) ?? this.parsePositiveInteger(args.websocketPort);
30
+ if (explicit !== null) {
31
+ return explicit;
32
+ }
33
+ return args.nextPort + 1;
34
+ }
35
+ }
@@ -0,0 +1,12 @@
1
+ export class SourceMapNodeOptions {
2
+ appendToNodeOptions(existingNodeOptions: string | undefined): string {
3
+ const sourceMapOption = "--enable-source-maps";
4
+ if (!existingNodeOptions || existingNodeOptions.trim().length === 0) {
5
+ return sourceMapOption;
6
+ }
7
+ if (existingNodeOptions.includes(sourceMapOption)) {
8
+ return existingNodeOptions;
9
+ }
10
+ return `${existingNodeOptions} ${sourceMapOption}`.trim();
11
+ }
12
+ }
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+
4
+ export class TypeScriptRuntimeConfigurator {
5
+ configure(repoRoot: string): void {
6
+ process.env.CODEMATION_TSCONFIG_PATH = path.resolve(repoRoot, "tsconfig.base.json");
7
+ }
8
+ }
@@ -0,0 +1,33 @@
1
+ import type { ResolvedDatabasePersistence } from "@codemation/host/persistence";
2
+
3
+ /**
4
+ * Formats a database URL for CLI messages without exposing credentials (no user/password).
5
+ */
6
+ export class CliDatabaseUrlDescriptor {
7
+ describePersistence(persistence: ResolvedDatabasePersistence): string {
8
+ if (persistence.kind === "none") {
9
+ return "none";
10
+ }
11
+ if (persistence.kind === "postgresql") {
12
+ return this.describeForDisplay(persistence.databaseUrl);
13
+ }
14
+ return `PGlite (${persistence.dataDir})`;
15
+ }
16
+
17
+ describeForDisplay(databaseUrl: string | undefined): string {
18
+ if (!databaseUrl || databaseUrl.trim().length === 0) {
19
+ return "unknown database target";
20
+ }
21
+ try {
22
+ const u = new URL(databaseUrl);
23
+ const pathPart = u.pathname.replace(/^\//, "").split(/[?#]/)[0] ?? "";
24
+ const databaseName = pathPart.length > 0 ? pathPart : "(default)";
25
+ const defaultPort = u.protocol === "postgresql:" || u.protocol === "postgres:" ? "5432" : "";
26
+ const port = u.port || defaultPort;
27
+ const hostPort = port ? `${u.hostname}:${port}` : u.hostname;
28
+ return `database "${databaseName}" on ${hostPort}`;
29
+ } catch {
30
+ return "configured database (URL not shown)";
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,29 @@
1
+ import { UpsertLocalBootstrapUserCommand } from "@codemation/host";
2
+ import { logLevelPolicyFactory, ServerLoggerFactory } from "@codemation/host/next/server";
3
+
4
+ import type { UserAdminCliBootstrap, UserAdminCliOptions } from "./UserAdminCliBootstrap";
5
+
6
+ export type LocalUserCreateOptions = Readonly<
7
+ UserAdminCliOptions & {
8
+ email: string;
9
+ password: string;
10
+ }
11
+ >;
12
+
13
+ export class LocalUserCreator {
14
+ private readonly log = new ServerLoggerFactory(logLevelPolicyFactory).create("codemation-cli.user");
15
+
16
+ constructor(private readonly userAdminBootstrap: UserAdminCliBootstrap) {}
17
+
18
+ async run(options: LocalUserCreateOptions): Promise<void> {
19
+ const email = options.email;
20
+ const password = options.password;
21
+ await this.userAdminBootstrap.withSession(
22
+ { consumerRoot: options.consumerRoot, configPath: options.configPath },
23
+ async (session) => {
24
+ const result = await session.getCommandBus().execute(new UpsertLocalBootstrapUserCommand(email, password));
25
+ this.log.info(result.outcome === "created" ? `Created local user: ${email}` : `Updated local user: ${email}`);
26
+ },
27
+ );
28
+ }
29
+ }
@@ -0,0 +1,67 @@
1
+ import { CodemationBootstrapRequest } from "@codemation/host";
2
+ import { DatabasePersistenceResolver } from "@codemation/host/persistence";
3
+ import { CodemationConsumerConfigLoader } from "@codemation/host/server";
4
+
5
+ import { CodemationCliApplicationSession } from "../bootstrap/CodemationCliApplicationSession";
6
+ import type { ConsumerCliTsconfigPreparation } from "../consumer/ConsumerCliTsconfigPreparation";
7
+ import { CliPathResolver } from "../path/CliPathResolver";
8
+ import type { UserAdminConsumerDotenvLoader } from "./UserAdminConsumerDotenvLoader";
9
+
10
+ export type UserAdminCliOptions = Readonly<{
11
+ consumerRoot?: string;
12
+ configPath?: string;
13
+ }>;
14
+
15
+ /**
16
+ * Shared env/config/session wiring for `codemation user *` commands (local auth + database).
17
+ */
18
+ export class UserAdminCliBootstrap {
19
+ constructor(
20
+ private readonly configLoader: CodemationConsumerConfigLoader,
21
+ private readonly pathResolver: CliPathResolver,
22
+ private readonly consumerDotenvLoader: UserAdminConsumerDotenvLoader,
23
+ private readonly tsconfigPreparation: ConsumerCliTsconfigPreparation,
24
+ private readonly databasePersistenceResolver: DatabasePersistenceResolver,
25
+ ) {}
26
+
27
+ async withSession<T>(
28
+ options: UserAdminCliOptions,
29
+ fn: (session: CodemationCliApplicationSession) => Promise<T>,
30
+ ): Promise<T> {
31
+ const consumerRoot = options.consumerRoot ?? process.cwd();
32
+ this.consumerDotenvLoader.load(consumerRoot);
33
+ this.tsconfigPreparation.applyWorkspaceTsconfigForTsxIfPresent(consumerRoot);
34
+ const resolution = await this.configLoader.load({
35
+ consumerRoot,
36
+ configPathOverride: options.configPath,
37
+ });
38
+ if (resolution.config.auth?.kind !== "local") {
39
+ throw new Error('Codemation user commands require CodemationConfig.auth.kind to be "local".');
40
+ }
41
+ const persistence = this.databasePersistenceResolver.resolve({
42
+ runtimeConfig: resolution.config.runtime ?? {},
43
+ env: process.env,
44
+ consumerRoot,
45
+ });
46
+ if (persistence.kind === "none") {
47
+ throw new Error(
48
+ "Database persistence is not configured. Set CodemationConfig.runtime.database (postgresql URL or PGlite).",
49
+ );
50
+ }
51
+ const paths = await this.pathResolver.resolve(consumerRoot);
52
+ const session = await CodemationCliApplicationSession.open({
53
+ resolution,
54
+ bootstrap: new CodemationBootstrapRequest({
55
+ repoRoot: paths.repoRoot,
56
+ consumerRoot,
57
+ env: process.env,
58
+ workflowSources: resolution.workflowSources,
59
+ }),
60
+ });
61
+ try {
62
+ return await fn(session);
63
+ } finally {
64
+ await session.close();
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+
4
+ import type { UserAdminCliOptions } from "./UserAdminCliBootstrap";
5
+
6
+ /**
7
+ * Normalizes Commander flags (`--consumer-root`, `--config`) into {@link UserAdminCliOptions}
8
+ * for {@link UserAdminCliBootstrap.withSession}.
9
+ */
10
+ export type UserAdminCliCommandOptionsRaw = Readonly<{
11
+ consumerRoot?: string;
12
+ config?: string;
13
+ }>;
14
+
15
+ export class UserAdminCliOptionsParser {
16
+ parse(opts: UserAdminCliCommandOptionsRaw): UserAdminCliOptions {
17
+ const consumerRoot =
18
+ opts.consumerRoot !== undefined && opts.consumerRoot.trim().length > 0
19
+ ? path.resolve(process.cwd(), opts.consumerRoot.trim())
20
+ : undefined;
21
+ const configPath = opts.config !== undefined && opts.config.trim().length > 0 ? opts.config.trim() : undefined;
22
+ return { consumerRoot, configPath };
23
+ }
24
+ }