@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,898 @@
1
+ import type { Logger } from "@codemation/host/next/server";
2
+ import { logLevelPolicyFactory, ServerLoggerFactory } from "@codemation/host/next/server";
3
+ import { WorkflowDiscoveryPathSegmentsComputer, WorkflowModulePathFinder } from "@codemation/host/server";
4
+ import type { FSWatcher } from "chokidar";
5
+ import { watch } from "chokidar";
6
+ import { randomUUID } from "node:crypto";
7
+ import { access, copyFile, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import ts from "typescript";
11
+
12
+ import type { ConsumerBuildOptions } from "./consumerBuildOptions.types";
13
+
14
+ export type ConsumerOutputBuildSnapshot = Readonly<{
15
+ buildVersion: string;
16
+ configSourcePath: string | null;
17
+ consumerRoot: string;
18
+ manifestPath: string;
19
+ outputEntryPath: string;
20
+ outputRoot: string;
21
+ /** Canonical emitted tree (`.codemation/output/build`): `app/`, `index.js`, etc. */
22
+ emitOutputRoot: string;
23
+ workflowSourcePaths: ReadonlyArray<string>;
24
+ workflowDiscoveryPathSegmentsList: ReadonlyArray<readonly string[]>;
25
+ }>;
26
+
27
+ type BuildConfigMetadata = Readonly<{
28
+ hasInlineWorkflows: boolean;
29
+ workflowDiscoveryDirectories: ReadonlyArray<string>;
30
+ }>;
31
+
32
+ const defaultConsumerOutputLogger = new ServerLoggerFactory(logLevelPolicyFactory).create(
33
+ "codemation-cli.consumer-output",
34
+ );
35
+
36
+ const defaultConsumerBuildOptions: ConsumerBuildOptions = Object.freeze({
37
+ sourceMaps: true,
38
+ target: "es2022",
39
+ });
40
+
41
+ export class ConsumerOutputBuilder {
42
+ private static readonly ignoredDirectoryNames = new Set([".codemation", ".git", "dist", "node_modules"]);
43
+ private static readonly supportedSourceExtensions = new Set([".ts", ".tsx", ".mts", ".cts"]);
44
+ private static readonly watchBuildDebounceMs = 75;
45
+ private readonly workflowModulePathFinder = new WorkflowModulePathFinder();
46
+
47
+ /** Last promoted build output used to copy-forward unchanged emitted files on incremental watch builds. */
48
+ private lastPromotedSnapshot: ConsumerOutputBuildSnapshot | null = null;
49
+ private pendingWatchEvents: Array<{ event: string; path: string }> = [];
50
+
51
+ private activeBuildPromise: Promise<ConsumerOutputBuildSnapshot> | null = null;
52
+ private watcher: FSWatcher | null = null;
53
+ private watchBuildLoopPromise: Promise<void> | null = null;
54
+ private watchBuildDebounceTimeout: NodeJS.Timeout | null = null;
55
+ private hasQueuedWatchEvent = false;
56
+ private hasPendingWatchBuild = false;
57
+ private lastIssuedBuildVersion = 0;
58
+
59
+ private readonly log: Logger;
60
+ private readonly buildOptions: ConsumerBuildOptions;
61
+
62
+ constructor(
63
+ private readonly consumerRoot: string,
64
+ logOverride?: Logger,
65
+ buildOptionsOverride?: ConsumerBuildOptions,
66
+ ) {
67
+ this.log = logOverride ?? defaultConsumerOutputLogger;
68
+ this.buildOptions = buildOptionsOverride ?? defaultConsumerBuildOptions;
69
+ }
70
+
71
+ async ensureBuilt(): Promise<ConsumerOutputBuildSnapshot> {
72
+ if (!this.activeBuildPromise) {
73
+ this.activeBuildPromise = this.buildInternal();
74
+ }
75
+ return await this.activeBuildPromise;
76
+ }
77
+
78
+ /**
79
+ * Stops the chokidar watcher and clears debounce timers. Safe to call when not watching.
80
+ * Used by tests and for clean shutdown when tearing down a dev session.
81
+ */
82
+ async disposeWatching(): Promise<void> {
83
+ if (this.watchBuildDebounceTimeout) {
84
+ clearTimeout(this.watchBuildDebounceTimeout);
85
+ this.watchBuildDebounceTimeout = null;
86
+ }
87
+ if (this.watcher) {
88
+ await this.watcher.close();
89
+ this.watcher = null;
90
+ }
91
+ this.watchBuildLoopPromise = null;
92
+ }
93
+
94
+ async ensureWatching(
95
+ args: Readonly<{
96
+ onBuildStarted?: () => Promise<void>;
97
+ onBuildCompleted: (snapshot: ConsumerOutputBuildSnapshot) => Promise<void>;
98
+ onBuildFailed?: (error: Error) => Promise<void>;
99
+ }>,
100
+ ): Promise<void> {
101
+ if (this.watcher) {
102
+ return;
103
+ }
104
+ this.watcher = watch([this.consumerRoot], {
105
+ ignoreInitial: true,
106
+ ignored: this.createIgnoredMatcher(),
107
+ });
108
+ this.watcher.on("all", (eventName, rawPath) => {
109
+ if (typeof rawPath === "string" && rawPath.length > 0) {
110
+ this.pendingWatchEvents.push({
111
+ event: eventName,
112
+ path: path.resolve(rawPath),
113
+ });
114
+ }
115
+ this.scheduleWatchBuild(args);
116
+ });
117
+ }
118
+
119
+ private async flushWatchBuilds(
120
+ args: Readonly<{
121
+ onBuildStarted?: () => Promise<void>;
122
+ onBuildCompleted: (snapshot: ConsumerOutputBuildSnapshot) => Promise<void>;
123
+ onBuildFailed?: (error: Error) => Promise<void>;
124
+ }>,
125
+ ): Promise<void> {
126
+ try {
127
+ while (this.hasPendingWatchBuild) {
128
+ this.hasPendingWatchBuild = false;
129
+ if (args.onBuildStarted) {
130
+ await args.onBuildStarted();
131
+ }
132
+ try {
133
+ const watchEvents = this.takePendingWatchEvents();
134
+ this.activeBuildPromise = this.buildInternal({ watchEvents });
135
+ await args.onBuildCompleted(await this.activeBuildPromise);
136
+ } catch (error) {
137
+ const exception = error instanceof Error ? error : new Error(String(error));
138
+ if (args.onBuildFailed && !this.hasPendingWatchBuild && !this.hasQueuedWatchEvent) {
139
+ await args.onBuildFailed(exception);
140
+ }
141
+ this.log.error("consumer output rebuild failed", exception);
142
+ }
143
+ }
144
+ } finally {
145
+ this.watchBuildLoopPromise = null;
146
+ if (this.hasPendingWatchBuild) {
147
+ this.watchBuildLoopPromise = this.flushWatchBuilds(args);
148
+ }
149
+ }
150
+ }
151
+
152
+ private takePendingWatchEvents(): ReadonlyArray<{ event: string; path: string }> {
153
+ const events = [...this.pendingWatchEvents];
154
+ this.pendingWatchEvents = [];
155
+ return events;
156
+ }
157
+
158
+ private async buildInternal(
159
+ options?: Readonly<{ watchEvents: ReadonlyArray<{ event: string; path: string }> }>,
160
+ ): Promise<ConsumerOutputBuildSnapshot> {
161
+ const watchEvents = options?.watchEvents ?? [];
162
+ const configSourcePath = await this.resolveConfigPath(this.consumerRoot);
163
+ if (
164
+ watchEvents.length > 0 &&
165
+ this.lastPromotedSnapshot !== null &&
166
+ configSourcePath !== null &&
167
+ !this.requiresFullConsumerRebuild(watchEvents, configSourcePath)
168
+ ) {
169
+ const changedSourcePaths = this.resolveIncrementalEmitSourcePaths(watchEvents);
170
+ if (changedSourcePaths.length > 0) {
171
+ try {
172
+ await access(this.lastPromotedSnapshot.emitOutputRoot);
173
+ const snapshot = await this.buildInternalIncremental(changedSourcePaths);
174
+ this.lastPromotedSnapshot = snapshot;
175
+ return snapshot;
176
+ } catch {
177
+ // Fall back to a full rebuild (missing output, emit failure, etc.).
178
+ }
179
+ }
180
+ }
181
+ const snapshot = await this.buildInternalFull();
182
+ this.lastPromotedSnapshot = snapshot;
183
+ return snapshot;
184
+ }
185
+
186
+ private requiresFullConsumerRebuild(
187
+ events: ReadonlyArray<{ event: string; path: string }>,
188
+ configSourcePath: string,
189
+ ): boolean {
190
+ const resolvedConfig = path.resolve(configSourcePath);
191
+ for (const entry of events) {
192
+ if (entry.event !== "change") {
193
+ return true;
194
+ }
195
+ if (path.resolve(entry.path) === resolvedConfig) {
196
+ return true;
197
+ }
198
+ if (this.shouldCopyAssetPath(entry.path)) {
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+
205
+ private resolveIncrementalEmitSourcePaths(
206
+ events: ReadonlyArray<{ event: string; path: string }>,
207
+ ): ReadonlyArray<string> {
208
+ const uniquePaths = new Set<string>();
209
+ for (const entry of events) {
210
+ if (entry.event !== "change") {
211
+ return [];
212
+ }
213
+ if (this.shouldCopyAssetPath(entry.path)) {
214
+ return [];
215
+ }
216
+ if (!this.shouldEmitSourcePath(entry.path)) {
217
+ continue;
218
+ }
219
+ uniquePaths.add(path.resolve(entry.path));
220
+ }
221
+ return [...uniquePaths];
222
+ }
223
+
224
+ private async prepareEmitBuildSnapshot(
225
+ args: Readonly<{
226
+ configSourcePath: string;
227
+ buildVersion: string;
228
+ }>,
229
+ ): Promise<
230
+ Readonly<{
231
+ stagedSnapshot: ConsumerOutputBuildSnapshot;
232
+ outputAppRoot: string;
233
+ finalBuildRoot: string;
234
+ stagingBuildRoot: string;
235
+ }>
236
+ > {
237
+ const outputRoot = this.resolveOutputRoot();
238
+ const finalBuildRoot = this.resolveFinalBuildOutputRoot();
239
+ const stagingBuildRoot = this.resolveStagingBuildRoot(args.buildVersion);
240
+ const outputAppRoot = path.resolve(stagingBuildRoot, "app");
241
+ const configMetadata = await this.loadConfigMetadata(args.configSourcePath);
242
+ const workflowSourcePaths = await this.resolveWorkflowSources(this.consumerRoot, configMetadata);
243
+ const pathSegmentsComputer = new WorkflowDiscoveryPathSegmentsComputer();
244
+ const workflowDiscoveryPathSegmentsList = workflowSourcePaths.map((sourcePath) => {
245
+ const segments = pathSegmentsComputer.compute({
246
+ consumerRoot: this.consumerRoot,
247
+ workflowDiscoveryDirectories: configMetadata.workflowDiscoveryDirectories,
248
+ absoluteWorkflowModulePath: sourcePath,
249
+ });
250
+ return segments ?? [];
251
+ });
252
+ const stagedSnapshot: ConsumerOutputBuildSnapshot = {
253
+ buildVersion: args.buildVersion,
254
+ configSourcePath: args.configSourcePath,
255
+ consumerRoot: this.consumerRoot,
256
+ manifestPath: this.resolveCurrentManifestPath(),
257
+ outputEntryPath: path.resolve(stagingBuildRoot, "index.js"),
258
+ outputRoot,
259
+ emitOutputRoot: stagingBuildRoot,
260
+ workflowSourcePaths,
261
+ workflowDiscoveryPathSegmentsList,
262
+ };
263
+ return { stagedSnapshot, outputAppRoot, finalBuildRoot, stagingBuildRoot };
264
+ }
265
+
266
+ private async emitStagingBuildAndPromote(
267
+ args: Readonly<{
268
+ configSourcePath: string;
269
+ stagedSnapshot: ConsumerOutputBuildSnapshot;
270
+ outputAppRoot: string;
271
+ finalBuildRoot: string;
272
+ stagingBuildRoot: string;
273
+ emitOutputFiles: () => Promise<void>;
274
+ }>,
275
+ ): Promise<ConsumerOutputBuildSnapshot> {
276
+ let promoted = false;
277
+ try {
278
+ await args.emitOutputFiles();
279
+ await this.writeEntryFile({
280
+ configSourcePath: args.configSourcePath,
281
+ outputAppRoot: args.outputAppRoot,
282
+ snapshot: args.stagedSnapshot,
283
+ });
284
+ await this.promoteStagingToFinalBuild({
285
+ finalBuildRoot: args.finalBuildRoot,
286
+ stagingBuildRoot: args.stagingBuildRoot,
287
+ });
288
+ promoted = true;
289
+ return {
290
+ ...args.stagedSnapshot,
291
+ outputEntryPath: path.resolve(args.finalBuildRoot, "index.js"),
292
+ emitOutputRoot: args.finalBuildRoot,
293
+ };
294
+ } finally {
295
+ if (!promoted) {
296
+ await rm(args.stagingBuildRoot, { force: true, recursive: true }).catch(() => null);
297
+ }
298
+ }
299
+ }
300
+
301
+ private async buildInternalIncremental(
302
+ changedSourcePaths: ReadonlyArray<string>,
303
+ ): Promise<ConsumerOutputBuildSnapshot> {
304
+ const previous = this.lastPromotedSnapshot;
305
+ if (!previous) {
306
+ throw new Error("Incremental consumer build requires a previous successful build output.");
307
+ }
308
+ const configSourcePath = await this.resolveConfigPath(this.consumerRoot);
309
+ if (!configSourcePath) {
310
+ throw new Error(
311
+ 'Codemation config not found. Expected "codemation.config.ts" in the consumer project root or "src/".',
312
+ );
313
+ }
314
+ const runtimeSourcePaths = await this.collectRuntimeSourcePaths();
315
+ const runtimeSourceSet = new Set(runtimeSourcePaths.map((sourcePath) => path.resolve(sourcePath)));
316
+ for (const changedPath of changedSourcePaths) {
317
+ if (!runtimeSourceSet.has(path.resolve(changedPath))) {
318
+ throw new Error("Incremental build saw a changed path outside the current runtime source set; rebuild full.");
319
+ }
320
+ }
321
+ const buildVersion = this.createBuildVersion();
322
+ const { stagedSnapshot, outputAppRoot, finalBuildRoot, stagingBuildRoot } = await this.prepareEmitBuildSnapshot({
323
+ configSourcePath,
324
+ buildVersion,
325
+ });
326
+ return await this.emitStagingBuildAndPromote({
327
+ configSourcePath,
328
+ stagedSnapshot,
329
+ outputAppRoot,
330
+ finalBuildRoot,
331
+ stagingBuildRoot,
332
+ emitOutputFiles: async () => {
333
+ await cp(previous.emitOutputRoot, stagingBuildRoot, { recursive: true });
334
+ for (const sourcePath of changedSourcePaths) {
335
+ await this.emitSourceFile({
336
+ outputAppRoot,
337
+ sourcePath,
338
+ });
339
+ }
340
+ },
341
+ });
342
+ }
343
+
344
+ private async buildInternalFull(): Promise<ConsumerOutputBuildSnapshot> {
345
+ const configSourcePath = await this.resolveConfigPath(this.consumerRoot);
346
+ if (!configSourcePath) {
347
+ throw new Error(
348
+ 'Codemation config not found. Expected "codemation.config.ts" in the consumer project root or "src/".',
349
+ );
350
+ }
351
+ const runtimeSourcePaths = await this.collectRuntimeSourcePaths();
352
+ const buildVersion = this.createBuildVersion();
353
+ const { stagedSnapshot, outputAppRoot, finalBuildRoot, stagingBuildRoot } = await this.prepareEmitBuildSnapshot({
354
+ configSourcePath,
355
+ buildVersion,
356
+ });
357
+ return await this.emitStagingBuildAndPromote({
358
+ configSourcePath,
359
+ stagedSnapshot,
360
+ outputAppRoot,
361
+ finalBuildRoot,
362
+ stagingBuildRoot,
363
+ emitOutputFiles: async () => {
364
+ for (const sourcePath of runtimeSourcePaths) {
365
+ if (this.shouldCopyAssetPath(sourcePath)) {
366
+ await this.copyAssetFile({
367
+ outputAppRoot,
368
+ sourcePath,
369
+ });
370
+ continue;
371
+ }
372
+ await this.emitSourceFile({
373
+ outputAppRoot,
374
+ sourcePath,
375
+ });
376
+ }
377
+ },
378
+ });
379
+ }
380
+
381
+ private scheduleWatchBuild(
382
+ args: Readonly<{
383
+ onBuildStarted?: () => Promise<void>;
384
+ onBuildCompleted: (snapshot: ConsumerOutputBuildSnapshot) => Promise<void>;
385
+ onBuildFailed?: (error: Error) => Promise<void>;
386
+ }>,
387
+ ): void {
388
+ this.hasQueuedWatchEvent = true;
389
+ if (this.watchBuildDebounceTimeout) {
390
+ clearTimeout(this.watchBuildDebounceTimeout);
391
+ }
392
+ this.watchBuildDebounceTimeout = setTimeout(() => {
393
+ this.watchBuildDebounceTimeout = null;
394
+ this.hasQueuedWatchEvent = false;
395
+ this.hasPendingWatchBuild = true;
396
+ if (!this.watchBuildLoopPromise) {
397
+ this.watchBuildLoopPromise = this.flushWatchBuilds(args);
398
+ }
399
+ }, ConsumerOutputBuilder.watchBuildDebounceMs);
400
+ }
401
+
402
+ private async collectRuntimeSourcePaths(): Promise<ReadonlyArray<string>> {
403
+ const sourcePaths: string[] = [];
404
+ await this.collectSourcePathsRecursively(this.consumerRoot, sourcePaths);
405
+ return sourcePaths
406
+ .filter((sourcePath: string) => this.shouldEmitSourcePath(sourcePath))
407
+ .sort((left: string, right: string) => left.localeCompare(right));
408
+ }
409
+
410
+ private async collectSourcePathsRecursively(directoryPath: string, sourcePaths: string[]): Promise<void> {
411
+ const entries = await readdir(directoryPath, { withFileTypes: true });
412
+ for (const entry of entries) {
413
+ const entryPath = path.resolve(directoryPath, entry.name);
414
+ if (entry.isDirectory()) {
415
+ if (ConsumerOutputBuilder.ignoredDirectoryNames.has(entry.name)) {
416
+ continue;
417
+ }
418
+ await this.collectSourcePathsRecursively(entryPath, sourcePaths);
419
+ continue;
420
+ }
421
+ sourcePaths.push(entryPath);
422
+ }
423
+ }
424
+
425
+ private shouldEmitSourcePath(sourcePath: string): boolean {
426
+ if (this.shouldCopyAssetPath(sourcePath)) {
427
+ return true;
428
+ }
429
+ if (sourcePath.endsWith(".d.ts")) {
430
+ return false;
431
+ }
432
+ const extension = path.extname(sourcePath);
433
+ return ConsumerOutputBuilder.supportedSourceExtensions.has(extension);
434
+ }
435
+
436
+ private shouldCopyAssetPath(sourcePath: string): boolean {
437
+ const fileName = path.basename(sourcePath);
438
+ return fileName === ".env" || fileName.startsWith(".env.");
439
+ }
440
+
441
+ private async copyAssetFile(args: Readonly<{ outputAppRoot: string; sourcePath: string }>): Promise<void> {
442
+ const outputPath = path.resolve(args.outputAppRoot, this.toConsumerRelativePath(args.sourcePath));
443
+ await mkdir(path.dirname(outputPath), { recursive: true });
444
+ await copyFile(args.sourcePath, outputPath);
445
+ }
446
+
447
+ private async emitSourceFile(args: Readonly<{ outputAppRoot: string; sourcePath: string }>): Promise<void> {
448
+ const sourceText = await readFile(args.sourcePath, "utf8");
449
+ const transpiledOutput = ts.transpileModule(sourceText, {
450
+ compilerOptions: this.createCompilerOptions(),
451
+ fileName: args.sourcePath,
452
+ reportDiagnostics: false,
453
+ });
454
+ const rewrittenOutputText = await this.rewriteRelativeImportSpecifiers(
455
+ args.sourcePath,
456
+ transpiledOutput.outputText,
457
+ );
458
+ const outputPath = this.resolveOutputPath(args.outputAppRoot, args.sourcePath);
459
+ await mkdir(path.dirname(outputPath), { recursive: true });
460
+ await writeFile(outputPath, rewrittenOutputText, "utf8");
461
+ if (transpiledOutput.sourceMapText) {
462
+ await writeFile(`${outputPath}.map`, transpiledOutput.sourceMapText, "utf8");
463
+ }
464
+ }
465
+
466
+ private createCompilerOptions(): ts.CompilerOptions {
467
+ const scriptTarget = this.buildOptions.target === "es2020" ? ts.ScriptTarget.ES2020 : ts.ScriptTarget.ES2022;
468
+ return {
469
+ emitDecoratorMetadata: true,
470
+ esModuleInterop: true,
471
+ experimentalDecorators: true,
472
+ inlineSources: this.buildOptions.sourceMaps,
473
+ jsx: ts.JsxEmit.ReactJSX,
474
+ module: ts.ModuleKind.ESNext,
475
+ sourceMap: this.buildOptions.sourceMaps,
476
+ target: scriptTarget,
477
+ useDefineForClassFields: false,
478
+ };
479
+ }
480
+
481
+ private async writeEntryFile(
482
+ args: Readonly<{
483
+ configSourcePath: string;
484
+ outputAppRoot: string;
485
+ snapshot: ConsumerOutputBuildSnapshot;
486
+ }>,
487
+ ): Promise<void> {
488
+ const configImportPath = this.resolveOutputImportPath(
489
+ args.outputAppRoot,
490
+ args.snapshot.outputEntryPath,
491
+ args.configSourcePath,
492
+ );
493
+ if (!configImportPath) {
494
+ throw new Error("Consumer output build requires a resolved codemation config source.");
495
+ }
496
+ const workflowImportBlocks = args.snapshot.workflowSourcePaths
497
+ .map((workflowSourcePath: string, index: number) => {
498
+ const importPath = this.resolveOutputImportPath(
499
+ args.outputAppRoot,
500
+ args.snapshot.outputEntryPath,
501
+ workflowSourcePath,
502
+ );
503
+ if (!importPath) {
504
+ throw new Error(`Could not resolve workflow output path: ${workflowSourcePath}`);
505
+ }
506
+ return `import * as workflowModule${index} from "${importPath}";`;
507
+ })
508
+ .join("\n");
509
+ const workflowModulesExpression =
510
+ args.snapshot.workflowSourcePaths.length > 0
511
+ ? `[${args.snapshot.workflowSourcePaths.map((_: string, index: number) => `workflowModule${index}`).join(", ")}]`
512
+ : "[]";
513
+ const workflowSourcePathsExpression =
514
+ args.snapshot.workflowSourcePaths.length > 0
515
+ ? `[${args.snapshot.workflowSourcePaths.map((workflowSourcePath: string) => JSON.stringify(workflowSourcePath)).join(", ")}]`
516
+ : "[]";
517
+ const workflowDiscoveryPathSegmentsListExpression = JSON.stringify(args.snapshot.workflowDiscoveryPathSegmentsList);
518
+ const outputText = [
519
+ `import * as configModule from "${configImportPath}";`,
520
+ 'import { CodemationConsumerAppResolver } from "@codemation/host/consumer";',
521
+ workflowImportBlocks,
522
+ "const resolver = new CodemationConsumerAppResolver();",
523
+ `export const codemationConsumerBuildVersion = ${JSON.stringify(args.snapshot.buildVersion)};`,
524
+ `export const codemationConsumerApp = resolver.resolve({`,
525
+ " configModule,",
526
+ ` workflowModules: ${workflowModulesExpression},`,
527
+ ` workflowSourcePaths: ${workflowSourcePathsExpression},`,
528
+ ` workflowDiscoveryPathSegmentsList: ${workflowDiscoveryPathSegmentsListExpression},`,
529
+ "});",
530
+ "export default codemationConsumerApp;",
531
+ "",
532
+ ]
533
+ .filter((line: string) => line.length > 0)
534
+ .join("\n");
535
+ await writeFile(args.snapshot.outputEntryPath, outputText, "utf8");
536
+ }
537
+
538
+ private async rewriteRelativeImportSpecifiers(sourcePath: string, outputText: string): Promise<string> {
539
+ let nextOutputText = await this.rewritePatternMatches(
540
+ sourcePath,
541
+ outputText,
542
+ /(from\s+["'])(\.{1,2}\/[^"']+)(["'])/g,
543
+ );
544
+ nextOutputText = await this.rewritePatternMatches(
545
+ sourcePath,
546
+ nextOutputText,
547
+ /(import\s+["'])(\.{1,2}\/[^"']+)(["'])/g,
548
+ );
549
+ nextOutputText = await this.rewritePatternMatches(
550
+ sourcePath,
551
+ nextOutputText,
552
+ /(import\s*\(\s*["'])(\.{1,2}\/[^"']+)(["']\s*\))/g,
553
+ );
554
+ return nextOutputText;
555
+ }
556
+
557
+ private async rewritePatternMatches(sourcePath: string, outputText: string, pattern: RegExp): Promise<string> {
558
+ const matches = [...outputText.matchAll(pattern)];
559
+ let rewrittenText = outputText;
560
+ for (const match of matches) {
561
+ const currentSpecifier = match[2];
562
+ const nextSpecifier = await this.resolveRelativeRuntimeSpecifier(sourcePath, currentSpecifier);
563
+ if (nextSpecifier === currentSpecifier) {
564
+ continue;
565
+ }
566
+ rewrittenText = rewrittenText.replace(
567
+ `${match[1]}${currentSpecifier}${match[3]}`,
568
+ `${match[1]}${nextSpecifier}${match[3]}`,
569
+ );
570
+ }
571
+ return rewrittenText;
572
+ }
573
+
574
+ private async resolveRelativeRuntimeSpecifier(sourcePath: string, importSpecifier: string): Promise<string> {
575
+ if (!importSpecifier.startsWith(".")) {
576
+ return importSpecifier;
577
+ }
578
+ const extension = path.extname(importSpecifier);
579
+ if (this.isRuntimeExtension(extension)) {
580
+ return importSpecifier;
581
+ }
582
+ if (this.isSourceExtension(extension)) {
583
+ return `${importSpecifier.slice(0, importSpecifier.length - extension.length)}${this.toJavascriptExtension(extension)}`;
584
+ }
585
+ const resolvedSpecifier = await this.resolveFileImportSpecifier(sourcePath, importSpecifier);
586
+ if (resolvedSpecifier) {
587
+ return resolvedSpecifier;
588
+ }
589
+ const resolvedIndexSpecifier = await this.resolveIndexImportSpecifier(sourcePath, importSpecifier);
590
+ return resolvedIndexSpecifier ?? importSpecifier;
591
+ }
592
+
593
+ private async resolveFileImportSpecifier(sourcePath: string, importSpecifier: string): Promise<string | null> {
594
+ const resolvedBasePath = path.resolve(path.dirname(sourcePath), importSpecifier);
595
+ for (const sourceExtension of ConsumerOutputBuilder.supportedSourceExtensions) {
596
+ if (await this.fileExists(`${resolvedBasePath}${sourceExtension}`)) {
597
+ return `${importSpecifier}${this.toJavascriptExtension(sourceExtension)}`;
598
+ }
599
+ }
600
+ return null;
601
+ }
602
+
603
+ private async resolveIndexImportSpecifier(sourcePath: string, importSpecifier: string): Promise<string | null> {
604
+ const resolvedDirectoryPath = path.resolve(path.dirname(sourcePath), importSpecifier);
605
+ for (const sourceExtension of ConsumerOutputBuilder.supportedSourceExtensions) {
606
+ const indexSourcePath = path.resolve(resolvedDirectoryPath, `index${sourceExtension}`);
607
+ if (await this.fileExists(indexSourcePath)) {
608
+ return `${importSpecifier}/index${this.toJavascriptExtension(sourceExtension)}`;
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+
614
+ private resolveOutputImportPath(
615
+ outputAppRoot: string,
616
+ outputEntryPath: string,
617
+ sourcePath: string | null,
618
+ ): string | null {
619
+ if (!sourcePath) {
620
+ return null;
621
+ }
622
+ const outputPath = this.resolveOutputPath(outputAppRoot, sourcePath);
623
+ const relativePath = path.relative(path.dirname(outputEntryPath), outputPath).replace(/\\/g, "/");
624
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
625
+ }
626
+
627
+ private resolveOutputPath(outputAppRoot: string, sourcePath: string): string {
628
+ const relativePath = this.toConsumerRelativePath(sourcePath);
629
+ const nextExtension = this.toJavascriptExtension(path.extname(relativePath));
630
+ const pathWithoutExtension = relativePath.slice(0, relativePath.length - path.extname(relativePath).length);
631
+ return path.resolve(outputAppRoot, `${pathWithoutExtension}${nextExtension}`);
632
+ }
633
+
634
+ private resolveOutputRoot(): string {
635
+ return path.resolve(this.consumerRoot, ".codemation", "output");
636
+ }
637
+
638
+ private resolveFinalBuildOutputRoot(): string {
639
+ return path.resolve(this.resolveOutputRoot(), "build");
640
+ }
641
+
642
+ private resolveStagingBuildRoot(buildVersion: string): string {
643
+ return path.resolve(this.resolveOutputRoot(), "staging", `${buildVersion}-${randomUUID()}`);
644
+ }
645
+
646
+ private resolveCurrentManifestPath(): string {
647
+ return path.resolve(this.resolveOutputRoot(), "current.json");
648
+ }
649
+
650
+ private async promoteStagingToFinalBuild(
651
+ args: Readonly<{ finalBuildRoot: string; stagingBuildRoot: string }>,
652
+ ): Promise<void> {
653
+ await mkdir(path.dirname(args.finalBuildRoot), { recursive: true });
654
+ await rm(args.finalBuildRoot, { force: true, recursive: true }).catch(() => null);
655
+ await rename(args.stagingBuildRoot, args.finalBuildRoot);
656
+ }
657
+
658
+ private isRuntimeExtension(extension: string): boolean {
659
+ return extension === ".cjs" || extension === ".js" || extension === ".json" || extension === ".mjs";
660
+ }
661
+
662
+ private isSourceExtension(extension: string): boolean {
663
+ return ConsumerOutputBuilder.supportedSourceExtensions.has(extension);
664
+ }
665
+
666
+ private toJavascriptExtension(extension: string): string {
667
+ if (extension === ".cts") {
668
+ return ".cjs";
669
+ }
670
+ if (extension === ".mts") {
671
+ return ".mjs";
672
+ }
673
+ return ".js";
674
+ }
675
+
676
+ private createIgnoredMatcher(): (watchPath: string) => boolean {
677
+ return (watchPath: string): boolean => {
678
+ const relativePath = path.relative(this.consumerRoot, watchPath);
679
+ if (relativePath.startsWith("..")) {
680
+ return false;
681
+ }
682
+ return relativePath
683
+ .replace(/\\/g, "/")
684
+ .split("/")
685
+ .some((segment: string) => ConsumerOutputBuilder.ignoredDirectoryNames.has(segment));
686
+ };
687
+ }
688
+
689
+ private toConsumerRelativePath(filePath: string): string {
690
+ return path.relative(this.consumerRoot, filePath);
691
+ }
692
+
693
+ private createBuildVersion(): string {
694
+ const nextBuildVersion = Math.max(Date.now(), this.lastIssuedBuildVersion + 1);
695
+ this.lastIssuedBuildVersion = nextBuildVersion;
696
+ return `${nextBuildVersion}-${process.pid}`;
697
+ }
698
+
699
+ private async resolveConfigPath(consumerRoot: string): Promise<string | null> {
700
+ for (const candidate of this.getConventionCandidates(consumerRoot)) {
701
+ if (await this.fileExists(candidate)) {
702
+ return candidate;
703
+ }
704
+ }
705
+ return null;
706
+ }
707
+
708
+ private getConventionCandidates(consumerRoot: string): ReadonlyArray<string> {
709
+ return [
710
+ path.resolve(consumerRoot, "codemation.config.ts"),
711
+ path.resolve(consumerRoot, "codemation.config.js"),
712
+ path.resolve(consumerRoot, "src", "codemation.config.ts"),
713
+ path.resolve(consumerRoot, "src", "codemation.config.js"),
714
+ ];
715
+ }
716
+
717
+ private async loadConfigMetadata(configSourcePath: string): Promise<BuildConfigMetadata> {
718
+ const sourceText = await readFile(configSourcePath, "utf8");
719
+ const sourceFile = ts.createSourceFile(
720
+ configSourcePath,
721
+ sourceText,
722
+ ts.ScriptTarget.Latest,
723
+ true,
724
+ this.resolveScriptKind(configSourcePath),
725
+ );
726
+ const configObjectLiteral = this.resolveConfigObjectLiteral(sourceFile);
727
+ if (!configObjectLiteral) {
728
+ return {
729
+ hasInlineWorkflows: false,
730
+ workflowDiscoveryDirectories: [...WorkflowModulePathFinder.defaultWorkflowDirectories],
731
+ };
732
+ }
733
+ const workflowDiscovery = this.readObjectLiteralProperty(configObjectLiteral, "workflowDiscovery");
734
+ const workflowDiscoveryDirectories = this.readStringArrayProperty(workflowDiscovery, "directories");
735
+ return {
736
+ hasInlineWorkflows: this.hasProperty(configObjectLiteral, "workflows"),
737
+ workflowDiscoveryDirectories:
738
+ workflowDiscoveryDirectories.length > 0
739
+ ? workflowDiscoveryDirectories
740
+ : [...WorkflowModulePathFinder.defaultWorkflowDirectories],
741
+ };
742
+ }
743
+
744
+ private resolveScriptKind(filePath: string): ts.ScriptKind {
745
+ const extension = path.extname(filePath);
746
+ if (extension === ".js" || extension === ".mjs" || extension === ".cjs") {
747
+ return ts.ScriptKind.JS;
748
+ }
749
+ if (extension === ".tsx") {
750
+ return ts.ScriptKind.TSX;
751
+ }
752
+ return ts.ScriptKind.TS;
753
+ }
754
+
755
+ private resolveConfigObjectLiteral(sourceFile: ts.SourceFile): ts.ObjectLiteralExpression | null {
756
+ const objectLiteralsByIdentifier = new Map<string, ts.ObjectLiteralExpression>();
757
+ const exportedObjectLiterals: ts.ObjectLiteralExpression[] = [];
758
+ for (const statement of sourceFile.statements) {
759
+ if (!ts.isVariableStatement(statement)) {
760
+ continue;
761
+ }
762
+ const isExported = this.hasExportModifier(statement);
763
+ for (const declaration of statement.declarationList.declarations) {
764
+ if (!ts.isIdentifier(declaration.name)) {
765
+ continue;
766
+ }
767
+ const objectLiteral = this.unwrapObjectLiteralExpression(declaration.initializer);
768
+ if (!objectLiteral) {
769
+ continue;
770
+ }
771
+ objectLiteralsByIdentifier.set(declaration.name.text, objectLiteral);
772
+ if (isExported) {
773
+ exportedObjectLiterals.push(objectLiteral);
774
+ }
775
+ }
776
+ }
777
+ for (const statement of sourceFile.statements) {
778
+ if (!ts.isExportAssignment(statement)) {
779
+ continue;
780
+ }
781
+ const directObjectLiteral = this.unwrapObjectLiteralExpression(statement.expression);
782
+ if (directObjectLiteral) {
783
+ return directObjectLiteral;
784
+ }
785
+ if (ts.isIdentifier(statement.expression)) {
786
+ const resolvedObjectLiteral = objectLiteralsByIdentifier.get(statement.expression.text);
787
+ if (resolvedObjectLiteral) {
788
+ return resolvedObjectLiteral;
789
+ }
790
+ }
791
+ }
792
+ const namedConfigLiteral =
793
+ objectLiteralsByIdentifier.get("codemationHost") ?? objectLiteralsByIdentifier.get("config");
794
+ if (namedConfigLiteral) {
795
+ return namedConfigLiteral;
796
+ }
797
+ return exportedObjectLiterals[0] ?? null;
798
+ }
799
+
800
+ private hasExportModifier(statement: ts.Node): boolean {
801
+ return ts.canHaveModifiers(statement)
802
+ ? (ts.getModifiers(statement)?.some((modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ??
803
+ false)
804
+ : false;
805
+ }
806
+
807
+ private unwrapObjectLiteralExpression(node: ts.Expression | undefined): ts.ObjectLiteralExpression | null {
808
+ if (!node) {
809
+ return null;
810
+ }
811
+ if (ts.isObjectLiteralExpression(node)) {
812
+ return node;
813
+ }
814
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
815
+ return this.unwrapObjectLiteralExpression(node.expression);
816
+ }
817
+ return null;
818
+ }
819
+
820
+ private hasProperty(objectLiteral: ts.ObjectLiteralExpression, propertyName: string): boolean {
821
+ return this.getPropertyAssignment(objectLiteral, propertyName) !== null;
822
+ }
823
+
824
+ private readObjectLiteralProperty(
825
+ objectLiteral: ts.ObjectLiteralExpression,
826
+ propertyName: string,
827
+ ): ts.ObjectLiteralExpression | null {
828
+ const property = this.getPropertyAssignment(objectLiteral, propertyName);
829
+ return this.unwrapObjectLiteralExpression(property?.initializer);
830
+ }
831
+
832
+ private readStringArrayProperty(
833
+ objectLiteral: ts.ObjectLiteralExpression | null,
834
+ propertyName: string,
835
+ ): ReadonlyArray<string> {
836
+ if (!objectLiteral) {
837
+ return [];
838
+ }
839
+ const property = this.getPropertyAssignment(objectLiteral, propertyName);
840
+ if (!property || !ts.isArrayLiteralExpression(property.initializer)) {
841
+ return [];
842
+ }
843
+ const values: string[] = [];
844
+ for (const element of property.initializer.elements) {
845
+ if (ts.isStringLiteralLike(element)) {
846
+ values.push(element.text);
847
+ }
848
+ }
849
+ return values;
850
+ }
851
+
852
+ private getPropertyAssignment(
853
+ objectLiteral: ts.ObjectLiteralExpression,
854
+ propertyName: string,
855
+ ): ts.PropertyAssignment | null {
856
+ for (const property of objectLiteral.properties) {
857
+ if (!ts.isPropertyAssignment(property)) {
858
+ continue;
859
+ }
860
+ const name = this.readPropertyName(property.name);
861
+ if (name === propertyName) {
862
+ return property;
863
+ }
864
+ }
865
+ return null;
866
+ }
867
+
868
+ private readPropertyName(propertyName: ts.PropertyName): string | null {
869
+ if (ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName)) {
870
+ return propertyName.text;
871
+ }
872
+ return null;
873
+ }
874
+
875
+ private async resolveWorkflowSources(
876
+ consumerRoot: string,
877
+ configMetadata: BuildConfigMetadata,
878
+ ): Promise<ReadonlyArray<string>> {
879
+ if (configMetadata.hasInlineWorkflows) {
880
+ return [];
881
+ }
882
+ const discoveredPaths = await this.workflowModulePathFinder.discoverModulePaths({
883
+ consumerRoot,
884
+ workflowDirectories: configMetadata.workflowDiscoveryDirectories,
885
+ exists: (absolutePath) => this.fileExists(absolutePath),
886
+ });
887
+ return [...discoveredPaths].sort((left: string, right: string) => left.localeCompare(right));
888
+ }
889
+
890
+ private async fileExists(filePath: string): Promise<boolean> {
891
+ try {
892
+ await stat(filePath);
893
+ return true;
894
+ } catch {
895
+ return false;
896
+ }
897
+ }
898
+ }