@strav/cli 0.4.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "CLI framework and code generators for the Strav framework",
6
6
  "license": "MIT",
@@ -33,11 +33,11 @@
33
33
  "strav": "./src/cli/strav.ts"
34
34
  },
35
35
  "peerDependencies": {
36
- "@strav/kernel": "0.4.0",
37
- "@strav/http": "0.4.0",
38
- "@strav/database": "0.4.0",
39
- "@strav/queue": "0.4.0",
40
- "@strav/signal": "0.4.0"
36
+ "@strav/kernel": "0.4.4",
37
+ "@strav/http": "0.4.4",
38
+ "@strav/database": "0.4.4",
39
+ "@strav/queue": "0.4.4",
40
+ "@strav/signal": "0.4.4"
41
41
  },
42
42
  "dependencies": {
43
43
  "chalk": "^5.6.2",
@@ -8,7 +8,7 @@ import TestGenerator from '../generators/test_generator.ts'
8
8
  import DocGenerator from '../generators/doc_generator.ts'
9
9
  import type { ApiRoutingConfig } from '../generators/route_generator.ts'
10
10
  import type { GeneratorConfig } from '../generators/config.ts'
11
- import { loadGeneratorConfig, getDatabasePaths } from '../config/loader.ts'
11
+ import { loadGeneratorConfig, getDatabasePaths, loadTenantIdType } from '../config/loader.ts'
12
12
 
13
13
  export function register(program: Command): void {
14
14
  program
@@ -17,7 +17,8 @@ export function register(program: Command): void {
17
17
  .description(
18
18
  'Generate services, controllers, policies, validators, events, and routes from schemas'
19
19
  )
20
- .action(async () => {
20
+ .option('-f, --force', 'Overwrite existing generated files')
21
+ .action(async ({ force }: { force?: boolean }) => {
21
22
  try {
22
23
  console.log(chalk.cyan('Generating API layer from schemas...'))
23
24
 
@@ -29,13 +30,14 @@ export function register(program: Command): void {
29
30
  registry.validate()
30
31
 
31
32
  const schemas = registry.resolve()
32
- const representation = registry.buildRepresentation()
33
+ const tenantIdType = await loadTenantIdType()
34
+ const representation = registry.buildRepresentation(tenantIdType)
33
35
 
34
36
  // Load generator config (if available)
35
37
  const config = await loadGeneratorConfig()
36
38
 
37
39
  const apiGen = new ApiGenerator(schemas, representation, config)
38
- const apiFiles = await apiGen.writeAll()
40
+ const apiResult = await apiGen.writeAll(force)
39
41
 
40
42
  // Load API routing config from config/http.ts (if available)
41
43
  let apiConfig: Partial<ApiRoutingConfig> | undefined
@@ -47,24 +49,41 @@ export function register(program: Command): void {
47
49
  }
48
50
 
49
51
  const routeGen = new RouteGenerator(schemas, config, apiConfig)
50
- const routeFiles = await routeGen.writeAll()
52
+ const routeResult = await routeGen.writeAll(force)
51
53
 
52
54
  const testGen = new TestGenerator(schemas, representation, config, apiConfig)
53
- const testFiles = await testGen.writeAll()
55
+ const testResult = await testGen.writeAll(force)
54
56
 
55
57
  const docGen = new DocGenerator(schemas, representation, config, apiConfig)
56
- const docFiles = await docGen.writeAll()
58
+ const docResult = await docGen.writeAll(force)
57
59
 
58
- const files = [...apiFiles, ...routeFiles, ...testFiles, ...docFiles]
60
+ const written = [
61
+ ...apiResult.written,
62
+ ...routeResult.written,
63
+ ...testResult.written,
64
+ ...docResult.written,
65
+ ]
66
+ const skipped = [
67
+ ...apiResult.skipped,
68
+ ...routeResult.skipped,
69
+ ...testResult.skipped,
70
+ ...docResult.skipped,
71
+ ]
59
72
 
60
- if (files.length === 0) {
73
+ if (written.length === 0 && skipped.length === 0) {
61
74
  console.log(chalk.yellow('No API files to generate.'))
62
75
  return
63
76
  }
64
77
 
65
- console.log(chalk.green(`\nGenerated ${files.length} file(s):`))
66
- for (const file of files) {
67
- console.log(chalk.dim(` ${file.path}`))
78
+ for (const file of written) {
79
+ console.log(chalk.green(` CREATE `) + chalk.dim(file.path))
80
+ }
81
+ for (const file of skipped) {
82
+ console.log(chalk.yellow(` SKIP `) + chalk.dim(file.path) + chalk.dim(' (already exists)'))
83
+ }
84
+
85
+ if (skipped.length > 0) {
86
+ console.log(chalk.dim(`\nSkipped ${skipped.length} existing file(s). Use --force to overwrite.`))
68
87
  }
69
88
  } catch (err) {
70
89
  console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
@@ -2,14 +2,15 @@ import type { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import SchemaRegistry from '@strav/database/schema/registry'
4
4
  import ModelGenerator from '../generators/model_generator.ts'
5
- import { loadGeneratorConfig, getDatabasePaths } from '../config/loader.ts'
5
+ import { loadGeneratorConfig, getDatabasePaths, loadTenantIdType } from '../config/loader.ts'
6
6
 
7
7
  export function register(program: Command): void {
8
8
  program
9
9
  .command('generate:models')
10
10
  .alias('g:models')
11
11
  .description('Generate model classes and enums from schema definitions')
12
- .action(async () => {
12
+ .option('-f, --force', 'Overwrite existing generated files')
13
+ .action(async ({ force }: { force?: boolean }) => {
13
14
  try {
14
15
  const dbPaths = await getDatabasePaths()
15
16
  const config = await loadGeneratorConfig()
@@ -20,18 +21,25 @@ export function register(program: Command): void {
20
21
  await registry.discover(dbPaths.schemas)
21
22
  registry.validate()
22
23
 
23
- const representation = registry.buildRepresentation()
24
+ const tenantIdType = await loadTenantIdType()
25
+ const representation = registry.buildRepresentation(tenantIdType)
24
26
  const generator = new ModelGenerator(registry.all(), representation, config)
25
- const files = await generator.writeAll()
27
+ const { written, skipped } = await generator.writeAll(force)
26
28
 
27
- if (files.length === 0) {
29
+ if (written.length === 0 && skipped.length === 0) {
28
30
  console.log(chalk.yellow('No models to generate.'))
29
31
  return
30
32
  }
31
33
 
32
- console.log(chalk.green(`\nGenerated ${files.length} file(s):`))
33
- for (const file of files) {
34
- console.log(chalk.dim(` ${file.path}`))
34
+ for (const file of written) {
35
+ console.log(chalk.green(` CREATE `) + chalk.dim(file.path))
36
+ }
37
+ for (const file of skipped) {
38
+ console.log(chalk.yellow(` SKIP `) + chalk.dim(file.path) + chalk.dim(' (already exists)'))
39
+ }
40
+
41
+ if (skipped.length > 0) {
42
+ console.log(chalk.dim(`\nSkipped ${skipped.length} existing file(s). Use --force to overwrite.`))
35
43
  }
36
44
  } catch (err) {
37
45
  console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
@@ -16,7 +16,7 @@ export function register(program: Command): void {
16
16
 
17
17
  console.log(chalk.cyan('Comparing schema with database...\n'))
18
18
 
19
- const desired = registry.buildRepresentation()
19
+ const desired = registry.buildRepresentation(database.tenantIdType)
20
20
  const actual = await introspector.introspect()
21
21
  const diff = new SchemaDiffer().diff(desired, actual)
22
22
 
@@ -53,11 +53,11 @@ export async function freshDatabase(
53
53
 
54
54
  console.log(chalk.cyan('Generating fresh migration...'))
55
55
 
56
- const desired = registry.buildRepresentation()
56
+ const desired = registry.buildRepresentation(db.tenantIdType)
57
57
  const actual = await introspector.introspect()
58
58
  const diff = new SchemaDiffer().diff(desired, actual)
59
59
 
60
- const sql = new SqlGenerator().generate(diff)
60
+ const sql = new SqlGenerator(db.tenantIdType).generate(diff)
61
61
  const version = Date.now().toString()
62
62
  const tableOrder = desired.tables.map(t => t.name)
63
63
 
@@ -21,7 +21,7 @@ export function register(program: Command): void {
21
21
 
22
22
  console.log(chalk.cyan('Comparing schema with database...'))
23
23
 
24
- const desired = registry.buildRepresentation()
24
+ const desired = registry.buildRepresentation(database.tenantIdType)
25
25
  const actual = await introspector.introspect()
26
26
  const diff = new SchemaDiffer().diff(desired, actual)
27
27
 
@@ -36,7 +36,7 @@ export function register(program: Command): void {
36
36
  return
37
37
  }
38
38
 
39
- const sql = new SqlGenerator().generate(diff)
39
+ const sql = new SqlGenerator(database.tenantIdType).generate(diff)
40
40
  const version = Date.now().toString()
41
41
  const tableOrder = desired.tables.map(t => t.name)
42
42
 
@@ -16,7 +16,7 @@ export function register(program: Command): void {
16
16
  const { db: database } = await bootstrap()
17
17
  db = database
18
18
 
19
- await ensureTenantTable(db.bypass)
19
+ await ensureTenantTable(db.bypass, db.tenantIdType)
20
20
  const manager = new TenantManager(db)
21
21
 
22
22
  const tenant = await manager.create({ slug: opts.slug, name: opts.name })
@@ -16,7 +16,7 @@ export function register(program: Command): void {
16
16
  const { db: database } = await bootstrap()
17
17
  db = database
18
18
 
19
- await ensureTenantTable(db.bypass)
19
+ await ensureTenantTable(db.bypass, db.tenantIdType)
20
20
  const manager = new TenantManager(db)
21
21
 
22
22
  const tenant = await manager.find(id)
@@ -14,7 +14,7 @@ export function register(program: Command): void {
14
14
  const { db: database } = await bootstrap()
15
15
  db = database
16
16
 
17
- await ensureTenantTable(db.bypass)
17
+ await ensureTenantTable(db.bypass, db.tenantIdType)
18
18
  const manager = new TenantManager(db)
19
19
  const tenants = await manager.list()
20
20
 
@@ -1,6 +1,10 @@
1
1
  import { join } from 'node:path'
2
2
  import type { GeneratorConfig, GeneratorPaths } from '../generators/config.ts'
3
3
  import { resolvePaths } from '../generators/config.ts'
4
+ import {
5
+ type TenantIdType,
6
+ DEFAULT_TENANT_ID_TYPE,
7
+ } from '@strav/database/database/tenant/id_type'
4
8
 
5
9
  /**
6
10
  * Load the generator configuration from the project's config/generators.ts file.
@@ -30,3 +34,17 @@ export async function getAllPaths(): Promise<GeneratorPaths> {
30
34
  const config = await loadGeneratorConfig()
31
35
  return resolvePaths(config)
32
36
  }
37
+
38
+ /**
39
+ * Read `database.tenant.idType` from `config/database.ts` for code-only
40
+ * generators (generate:models, generate:api) that don't connect to the DB.
41
+ * Falls back to the framework default if the config file or key is absent.
42
+ */
43
+ export async function loadTenantIdType(): Promise<TenantIdType> {
44
+ try {
45
+ const dbConfig = (await import(join(process.cwd(), 'config/database.ts'))).default
46
+ return (dbConfig?.tenant?.idType as TenantIdType | undefined) ?? DEFAULT_TENANT_ID_TYPE
47
+ } catch {
48
+ return DEFAULT_TENANT_ID_TYPE
49
+ }
50
+ }
@@ -10,7 +10,7 @@ import type { FieldDefinition, FieldValidator } from '@strav/database/schema/fie
10
10
  import type { PostgreSQLCustomType } from '@strav/database/schema/postgres'
11
11
  import { toSnakeCase, toCamelCase, toPascalCase } from '@strav/kernel/helpers/strings'
12
12
  import type { GeneratedFile } from './model_generator.ts'
13
- import type { GeneratorConfig, GeneratorPaths } from './config.ts'
13
+ import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
14
14
  import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
15
15
 
16
16
  // ---------------------------------------------------------------------------
@@ -208,10 +208,9 @@ export default class ApiGenerator {
208
208
  }
209
209
 
210
210
  /** Generate, format with Prettier, and write all files to disk. */
211
- async writeAll(): Promise<GeneratedFile[]> {
211
+ async writeAll(force?: boolean): Promise<WriteResult> {
212
212
  const files = this.generate()
213
- await formatAndWrite(files)
214
- return files
213
+ return formatAndWrite(files, { force })
215
214
  }
216
215
 
217
216
  // ---------------------------------------------------------------------------
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { resolve, relative } from 'node:path'
2
3
  import type { GeneratedFile } from './model_generator.ts'
3
4
 
@@ -66,11 +67,22 @@ export function relativeImport(fromDir: string, toDir: string): string {
66
67
  return rel.startsWith('.') ? rel : './' + rel
67
68
  }
68
69
 
70
+ export interface WriteResult {
71
+ written: GeneratedFile[]
72
+ skipped: GeneratedFile[]
73
+ }
74
+
69
75
  /**
70
76
  * Format generated files with Prettier and write them to disk.
71
77
  * Falls back to writing unformatted content if Prettier is not installed.
78
+ *
79
+ * Skips files that already exist unless `force` is true. Returns the
80
+ * partition of written and skipped files so callers can report them.
72
81
  */
73
- export async function formatAndWrite(files: GeneratedFile[]): Promise<void> {
82
+ export async function formatAndWrite(
83
+ files: GeneratedFile[],
84
+ options: { force?: boolean } = {}
85
+ ): Promise<WriteResult> {
74
86
  let prettier: typeof import('prettier') | null = null
75
87
  try {
76
88
  prettier = await import('prettier')
@@ -78,13 +90,24 @@ export async function formatAndWrite(files: GeneratedFile[]): Promise<void> {
78
90
  // Prettier not installed — write unformatted
79
91
  }
80
92
 
93
+ const written: GeneratedFile[] = []
94
+ const skipped: GeneratedFile[] = []
95
+
81
96
  for (const file of files) {
97
+ if (existsSync(file.path) && !options.force) {
98
+ skipped.push(file)
99
+ continue
100
+ }
101
+
82
102
  let content = file.content
83
103
  if (prettier) {
84
104
  const filePath = resolve(file.path)
85
- const options = await prettier.resolveConfig(filePath)
86
- content = await prettier.format(content, { ...options, filepath: filePath })
105
+ const prettierOpts = await prettier.resolveConfig(filePath)
106
+ content = await prettier.format(content, { ...prettierOpts, filepath: filePath })
87
107
  }
88
108
  await Bun.write(file.path, content)
109
+ written.push(file)
89
110
  }
111
+
112
+ return { written, skipped }
90
113
  }
@@ -14,8 +14,9 @@ import {
14
14
  toPascalCase,
15
15
  pluralize,
16
16
  } from '@strav/kernel/helpers/strings'
17
+ import { existsSync } from 'node:fs'
17
18
  import type { GeneratedFile } from './model_generator.ts'
18
- import type { GeneratorConfig, GeneratorPaths } from './config.ts'
19
+ import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
19
20
  import { resolvePaths } from './config.ts'
20
21
  import { ApiRouting, toRouteSegment, toChildSegment } from './route_generator.ts'
21
22
  import type { ApiRoutingConfig } from './route_generator.ts'
@@ -91,12 +92,21 @@ export default class DocGenerator {
91
92
  return [this.generateIndexPage()]
92
93
  }
93
94
 
94
- async writeAll(): Promise<GeneratedFile[]> {
95
+ async writeAll(force?: boolean): Promise<WriteResult> {
95
96
  const files = this.generate()
97
+ const written: GeneratedFile[] = []
98
+ const skipped: GeneratedFile[] = []
99
+
96
100
  for (const file of files) {
101
+ if (existsSync(file.path) && !force) {
102
+ skipped.push(file)
103
+ continue
104
+ }
97
105
  await Bun.write(file.path, file.content)
106
+ written.push(file)
98
107
  }
99
- return files
108
+
109
+ return { written, skipped }
100
110
  }
101
111
 
102
112
  // ---------------------------------------------------------------------------
@@ -9,7 +9,7 @@ import type {
9
9
  } from '@strav/database/schema/database_representation'
10
10
  import type { PostgreSQLCustomType } from '@strav/database/schema/postgres'
11
11
  import { toSnakeCase, toCamelCase, toPascalCase } from '@strav/kernel/helpers/strings'
12
- import type { GeneratorConfig, GeneratorPaths } from './config.ts'
12
+ import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
13
13
  import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
14
14
 
15
15
  export interface GeneratedFile {
@@ -54,10 +54,9 @@ export default class ModelGenerator {
54
54
  }
55
55
 
56
56
  /** Generate, format with Prettier, and write all files to disk. */
57
- async writeAll(): Promise<GeneratedFile[]> {
57
+ async writeAll(force?: boolean): Promise<WriteResult> {
58
58
  const files = this.generate()
59
- await formatAndWrite(files)
60
- return files
59
+ return formatAndWrite(files, { force })
61
60
  }
62
61
 
63
62
  // ---------------------------------------------------------------------------
@@ -3,7 +3,7 @@ import { Archetype } from '@strav/database/schema/types'
3
3
  import type { SchemaDefinition } from '@strav/database/schema/types'
4
4
  import { toSnakeCase, toPascalCase, pluralize } from '@strav/kernel/helpers/strings'
5
5
  import type { GeneratedFile } from './model_generator.ts'
6
- import type { GeneratorConfig, GeneratorPaths } from './config.ts'
6
+ import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
7
7
  import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
8
8
 
9
9
  /** Archetypes that sit under a parent's /:parentId group. */
@@ -66,10 +66,9 @@ export default class RouteGenerator {
66
66
  }
67
67
 
68
68
  /** Generate, format with Prettier, and write the route file to disk. */
69
- async writeAll(): Promise<GeneratedFile[]> {
69
+ async writeAll(force?: boolean): Promise<WriteResult> {
70
70
  const files = this.generate()
71
- await formatAndWrite(files)
72
- return files
71
+ return formatAndWrite(files, { force })
73
72
  }
74
73
 
75
74
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@ import {
13
13
  pluralize,
14
14
  } from '@strav/kernel/helpers/strings'
15
15
  import type { GeneratedFile } from './model_generator.ts'
16
- import type { GeneratorConfig, GeneratorPaths } from './config.ts'
16
+ import type { GeneratorConfig, GeneratorPaths, WriteResult } from './config.ts'
17
17
  import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
18
18
  import { ApiRouting, toRouteSegment, toChildSegment } from './route_generator.ts'
19
19
  import type { ApiRoutingConfig } from './route_generator.ts'
@@ -82,10 +82,9 @@ export default class TestGenerator {
82
82
  return files
83
83
  }
84
84
 
85
- async writeAll(): Promise<GeneratedFile[]> {
85
+ async writeAll(force?: boolean): Promise<WriteResult> {
86
86
  const files = this.generate()
87
- await formatAndWrite(files)
88
- return files
87
+ return formatAndWrite(files, { force })
89
88
  }
90
89
 
91
90
  // ---------------------------------------------------------------------------