@shazhou/proman-core 0.9.0

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 (129) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/dist/commands/bump.d.ts +13 -0
  4. package/dist/commands/bump.d.ts.map +1 -0
  5. package/dist/commands/bump.js +115 -0
  6. package/dist/commands/deploy.d.ts +9 -0
  7. package/dist/commands/deploy.d.ts.map +1 -0
  8. package/dist/commands/deploy.js +42 -0
  9. package/dist/commands/dev.d.ts +15 -0
  10. package/dist/commands/dev.d.ts.map +1 -0
  11. package/dist/commands/dev.js +175 -0
  12. package/dist/commands/index.d.ts +7 -0
  13. package/dist/commands/index.d.ts.map +1 -0
  14. package/dist/commands/index.js +7 -0
  15. package/dist/commands/init.d.ts +5 -0
  16. package/dist/commands/init.d.ts.map +1 -0
  17. package/dist/commands/init.js +262 -0
  18. package/dist/commands/link.d.ts +19 -0
  19. package/dist/commands/link.d.ts.map +1 -0
  20. package/dist/commands/link.js +155 -0
  21. package/dist/commands/publish.d.ts +18 -0
  22. package/dist/commands/publish.d.ts.map +1 -0
  23. package/dist/commands/publish.js +125 -0
  24. package/dist/config/index.d.ts +4 -0
  25. package/dist/config/index.d.ts.map +1 -0
  26. package/dist/config/index.js +2 -0
  27. package/dist/config/load-config.d.ts +6 -0
  28. package/dist/config/load-config.d.ts.map +1 -0
  29. package/dist/config/load-config.js +29 -0
  30. package/dist/config/types.d.ts +17 -0
  31. package/dist/config/types.d.ts.map +1 -0
  32. package/dist/config/types.js +1 -0
  33. package/dist/config/validate-config.d.ts +7 -0
  34. package/dist/config/validate-config.d.ts.map +1 -0
  35. package/dist/config/validate-config.js +72 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +6 -0
  39. package/dist/utils/changeset.d.ts +16 -0
  40. package/dist/utils/changeset.d.ts.map +1 -0
  41. package/dist/utils/changeset.js +80 -0
  42. package/dist/utils/fingerprint.d.ts +38 -0
  43. package/dist/utils/fingerprint.d.ts.map +1 -0
  44. package/dist/utils/fingerprint.js +182 -0
  45. package/dist/utils/git.d.ts +23 -0
  46. package/dist/utils/git.d.ts.map +1 -0
  47. package/dist/utils/git.js +105 -0
  48. package/dist/utils/index.d.ts +8 -0
  49. package/dist/utils/index.d.ts.map +1 -0
  50. package/dist/utils/index.js +8 -0
  51. package/dist/utils/npm.d.ts +30 -0
  52. package/dist/utils/npm.d.ts.map +1 -0
  53. package/dist/utils/npm.js +85 -0
  54. package/dist/utils/smoke-test.d.ts +7 -0
  55. package/dist/utils/smoke-test.d.ts.map +1 -0
  56. package/dist/utils/smoke-test.js +59 -0
  57. package/dist/utils/version.d.ts +5 -0
  58. package/dist/utils/version.d.ts.map +1 -0
  59. package/dist/utils/version.js +36 -0
  60. package/dist/utils/workspace.d.ts +21 -0
  61. package/dist/utils/workspace.d.ts.map +1 -0
  62. package/dist/utils/workspace.js +73 -0
  63. package/package.json +45 -0
  64. package/src/commands/bump.ts +131 -0
  65. package/src/commands/deploy.ts +52 -0
  66. package/src/commands/dev.ts +214 -0
  67. package/src/commands/index.ts +7 -0
  68. package/src/commands/init.integration.test.ts +59 -0
  69. package/src/commands/init.test.ts +179 -0
  70. package/src/commands/init.ts +290 -0
  71. package/src/commands/link.ts +195 -0
  72. package/src/commands/publish.ts +168 -0
  73. package/src/config/index.ts +8 -0
  74. package/src/config/load-config.ts +33 -0
  75. package/src/config/types.ts +19 -0
  76. package/src/config/validate-config.ts +81 -0
  77. package/src/index.ts +29 -0
  78. package/src/utils/changeset.ts +98 -0
  79. package/src/utils/fingerprint.ts +199 -0
  80. package/src/utils/git.ts +119 -0
  81. package/src/utils/index.ts +8 -0
  82. package/src/utils/npm.ts +110 -0
  83. package/src/utils/smoke-test.ts +79 -0
  84. package/src/utils/version.ts +41 -0
  85. package/src/utils/workspace.ts +94 -0
  86. package/tests/build-fingerprint-integration.test.ts +403 -0
  87. package/tests/bump.test.ts +261 -0
  88. package/tests/changeset.test.ts +147 -0
  89. package/tests/deploy.test.ts +98 -0
  90. package/tests/dev.test.ts +756 -0
  91. package/tests/fingerprint.test.ts +316 -0
  92. package/tests/fixtures/api-only/packages/api/.gitkeep +0 -0
  93. package/tests/fixtures/api-only/proman.yaml +4 -0
  94. package/tests/fixtures/bad-packages/proman.yaml +1 -0
  95. package/tests/fixtures/bun-project/packages/a/.gitkeep +0 -0
  96. package/tests/fixtures/bun-project/proman.yaml +4 -0
  97. package/tests/fixtures/defaults/proman.yaml +3 -0
  98. package/tests/fixtures/no-deployable/packages/core/.gitkeep +0 -0
  99. package/tests/fixtures/no-deployable/packages/mycli/.gitkeep +0 -0
  100. package/tests/fixtures/no-deployable/proman.yaml +7 -0
  101. package/tests/fixtures/node-runtime/packages/a/package.json +5 -0
  102. package/tests/fixtures/node-runtime/proman.yaml +3 -0
  103. package/tests/fixtures/pnpm-project/packages/a/package.json +1 -0
  104. package/tests/fixtures/pnpm-project/pnpm-lock.yaml +0 -0
  105. package/tests/fixtures/pnpm-project/proman.yaml +3 -0
  106. package/tests/fixtures/typed/packages/api/.gitkeep +0 -0
  107. package/tests/fixtures/typed/packages/core/.gitkeep +0 -0
  108. package/tests/fixtures/typed/packages/dashboard/.gitkeep +0 -0
  109. package/tests/fixtures/typed/packages/mycli/.gitkeep +0 -0
  110. package/tests/fixtures/typed/proman.yaml +13 -0
  111. package/tests/fixtures/valid/packages/cli/package.json +5 -0
  112. package/tests/fixtures/valid/packages/core/package.json +5 -0
  113. package/tests/fixtures/valid/packages/fs/package.json +5 -0
  114. package/tests/fixtures/valid/proman.yaml +13 -0
  115. package/tests/fixtures/webui-only/packages/dashboard/.gitkeep +0 -0
  116. package/tests/fixtures/webui-only/proman.yaml +4 -0
  117. package/tests/link.test.ts +419 -0
  118. package/tests/load-config.test.ts +44 -0
  119. package/tests/npm.test.ts +199 -0
  120. package/tests/publish.test.ts +599 -0
  121. package/tests/smoke-test.test.ts +211 -0
  122. package/tests/validate-config.test.ts +67 -0
  123. package/tests/version.test.ts +86 -0
  124. package/tests/workflow-schema.test.ts +72 -0
  125. package/tests/workspace.test.ts +160 -0
  126. package/tsconfig.build.json +14 -0
  127. package/tsconfig.json +8 -0
  128. package/tsconfig.tsbuildinfo +1 -0
  129. package/vitest.config.ts +8 -0
@@ -0,0 +1,214 @@
1
+ import { chmodSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { loadConfig } from '../config/index.js'
4
+ import {
5
+ computeBuildFingerprints,
6
+ computeRootFingerprint,
7
+ fingerprintPath,
8
+ readFingerprint,
9
+ writeFingerprint,
10
+ } from '../utils/fingerprint.js'
11
+ import { defaultSpawn, runOrThrow, type SpawnFn } from '../utils/npm.js'
12
+
13
+ export type DevCommandOptions = {
14
+ cwd: string
15
+ spawn?: SpawnFn
16
+ /** When provided, enables fingerprint caching.
17
+ * - false: check fingerprint, skip if match
18
+ * - true: always run (--force / CI)
19
+ * - undefined: legacy behavior — always run, no fingerprint logic */
20
+ force?: boolean
21
+ }
22
+
23
+ function pnpmExec(bin: string, ...args: string[]): string[] {
24
+ return ['pnpm', 'exec', bin, ...args]
25
+ }
26
+
27
+ export async function build(opts: DevCommandOptions): Promise<void> {
28
+ const spawn = opts.spawn ?? defaultSpawn
29
+ const cwd = resolve(opts.cwd)
30
+ const cfg = loadConfig(cwd)
31
+ const useFingerprint = opts.force !== undefined
32
+ const force = opts.force ?? false
33
+
34
+ // Compute fingerprints only when fingerprint caching is enabled
35
+ const fingerprints = useFingerprint ? computeBuildFingerprints(cwd, cfg.packages) : null
36
+
37
+ // Determine which packages to build
38
+ const toRun: { idx: number; pkgDir: string; fpPath: string; fpValue: string }[] = []
39
+
40
+ for (let i = 0; i < cfg.packages.length; i++) {
41
+ const pkg = cfg.packages[i] as (typeof cfg.packages)[number]
42
+ const pkgDir = resolve(cwd, pkg.path)
43
+
44
+ const fpPath = fingerprintPath(pkgDir, 'build', pkg.name)
45
+ const fpValue = fingerprints?.get(pkg.name) ?? ''
46
+
47
+ if (useFingerprint && !force) {
48
+ const stored = readFingerprint(fpPath)
49
+ if (stored === fpValue) {
50
+ console.log(`⏭ build: ${pkg.name} (unchanged)`)
51
+ continue // skip — fingerprint matches
52
+ }
53
+ }
54
+
55
+ toRun.push({ idx: i, pkgDir, fpPath, fpValue })
56
+ }
57
+
58
+ // Execute builds
59
+ for (const { idx, pkgDir } of toRun) {
60
+ const pkg = cfg.packages[idx] as (typeof cfg.packages)[number]
61
+ // Clean output dir + tsbuildinfo before build to prevent stale artifacts.
62
+ // Note: since build fingerprints live inside dist/ (see fingerprintPath()),
63
+ // removing dist/ intentionally invalidates the build cache for this package.
64
+ const outDir = join(pkgDir, 'dist')
65
+ if (existsSync(outDir)) {
66
+ rmSync(outDir, { recursive: true })
67
+ }
68
+ const buildInfo = join(pkgDir, 'tsconfig.tsbuildinfo')
69
+ if (existsSync(buildInfo)) {
70
+ rmSync(buildInfo)
71
+ }
72
+ let argv: string[]
73
+ switch (pkg.type) {
74
+ case 'webui':
75
+ argv = pnpmExec('vite', 'build')
76
+ break
77
+ case 'cli':
78
+ // cli → use package's own build script (may be esbuild, tsc, etc.)
79
+ argv = ['pnpm', 'run', 'build']
80
+ break
81
+ default:
82
+ // lib | api → tsc --build
83
+ argv = pnpmExec('tsc', '--build')
84
+ break
85
+ }
86
+ await runOrThrow(spawn, argv, pkgDir)
87
+
88
+ // chmod +x bin entries so linked CLIs survive tsc rebuild
89
+ chmodBinEntries(pkgDir)
90
+ }
91
+
92
+ // Write fingerprints only after ALL builds succeed (and only when enabled)
93
+ if (useFingerprint) {
94
+ for (const { fpPath, fpValue } of toRun) {
95
+ writeFingerprint(fpPath, fpValue)
96
+ }
97
+ }
98
+ }
99
+
100
+ function chmodBinEntries(pkgDir: string): void {
101
+ const pkgJsonPath = join(pkgDir, 'package.json')
102
+ if (!existsSync(pkgJsonPath)) return
103
+ const json = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
104
+ const bin: unknown = json.bin
105
+ if (bin == null) return
106
+ const paths =
107
+ typeof bin === 'string'
108
+ ? [bin]
109
+ : typeof bin === 'object'
110
+ ? Object.values(bin as Record<string, string>)
111
+ : []
112
+ for (const rel of paths) {
113
+ const abs = resolve(pkgDir, rel)
114
+ if (existsSync(abs)) {
115
+ chmodSync(abs, 0o755)
116
+ }
117
+ }
118
+ }
119
+
120
+ export async function runTests(opts: DevCommandOptions): Promise<void> {
121
+ const spawn = opts.spawn ?? defaultSpawn
122
+ const cwd = resolve(opts.cwd)
123
+ const useFingerprint = opts.force !== undefined
124
+ const force = opts.force ?? false
125
+
126
+ if (useFingerprint) {
127
+ const fpPath = fingerprintPath(cwd, 'test')
128
+ const fpValue = computeRootFingerprint(cwd, 'test')
129
+
130
+ if (!force) {
131
+ const stored = readFingerprint(fpPath)
132
+ if (stored === fpValue) {
133
+ console.log('⏭ test (unchanged)')
134
+ return // skip
135
+ }
136
+ }
137
+
138
+ await runOrThrow(spawn, pnpmExec('vitest', 'run'), cwd)
139
+ writeFingerprint(fpPath, fpValue)
140
+ } else {
141
+ await runOrThrow(spawn, pnpmExec('vitest', 'run'), cwd)
142
+ }
143
+ }
144
+
145
+ export async function check(opts: DevCommandOptions): Promise<void> {
146
+ const spawn = opts.spawn ?? defaultSpawn
147
+ const cwd = resolve(opts.cwd)
148
+ const useFingerprint = opts.force !== undefined
149
+ const force = opts.force ?? false
150
+
151
+ if (useFingerprint) {
152
+ const fpPath = fingerprintPath(cwd, 'check')
153
+ const fpValue = computeRootFingerprint(cwd, 'check')
154
+
155
+ if (!force) {
156
+ const stored = readFingerprint(fpPath)
157
+ if (stored === fpValue) {
158
+ console.log('⏭ check (unchanged)')
159
+ return // skip
160
+ }
161
+ }
162
+
163
+ await runOrThrow(spawn, pnpmExec('biome', 'check', '.'), cwd)
164
+ await validateWorkflows(spawn, cwd)
165
+ writeFingerprint(fpPath, fpValue)
166
+ } else {
167
+ await runOrThrow(spawn, pnpmExec('biome', 'check', '.'), cwd)
168
+ await validateWorkflows(spawn, cwd)
169
+ }
170
+ }
171
+
172
+ export async function format(opts: DevCommandOptions): Promise<void> {
173
+ const spawn = opts.spawn ?? defaultSpawn
174
+ const cwd = resolve(opts.cwd)
175
+ await runOrThrow(spawn, pnpmExec('biome', 'format', '--write', '.'), cwd)
176
+ }
177
+
178
+ /** Discover .workflows/*.yaml and validate each with `uwf workflow validate`. Skips if uwf is not installed. */
179
+ async function validateWorkflows(spawn: SpawnFn, cwd: string): Promise<void> {
180
+ const dirs = [join(cwd, '.workflows'), join(cwd, '.workflow')]
181
+ const files: string[] = []
182
+
183
+ for (const dir of dirs) {
184
+ if (!existsSync(dir)) continue
185
+ for (const entry of readdirSync(dir)) {
186
+ if (entry.endsWith('.yaml') || entry.endsWith('.yml')) {
187
+ files.push(join(dir, entry))
188
+ }
189
+ }
190
+ }
191
+
192
+ if (files.length === 0) return
193
+
194
+ // Check if uwf is available
195
+ const { code: uwfCheck } = await spawn(['which', 'uwf'], cwd)
196
+ if (uwfCheck !== 0) {
197
+ console.log('⚠ uwf not installed, skipping workflow validation')
198
+ return
199
+ }
200
+
201
+ const errors: string[] = []
202
+ for (const file of files) {
203
+ const { code, stderr } = await spawn(['uwf', 'workflow', 'validate', file], cwd)
204
+ if (code !== 0) {
205
+ errors.push(`${file}: ${stderr.trim() || 'validation failed'}`)
206
+ }
207
+ }
208
+
209
+ if (errors.length > 0) {
210
+ throw new Error(`Workflow validation failed:\n${errors.join('\n')}`)
211
+ }
212
+
213
+ console.log(`✓ ${files.length} workflow(s) validated`)
214
+ }
@@ -0,0 +1,7 @@
1
+ // Export all command functions
2
+ export { type BumpOptions, bump } from './bump.js'
3
+ export { type DeployCommandOptions, deploy } from './deploy.js'
4
+ export { build, check, type DevCommandOptions, format, runTests } from './dev.js'
5
+ export { type InitOptions, init } from './init.js'
6
+ export { type LinkCommandOptions, link, linkStatus, unlink } from './link.js'
7
+ export { type PublishOptions, publish } from './publish.js'
@@ -0,0 +1,59 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest'
6
+
7
+ // Skip: beforeAll pnpm install times out on CI (cold cache, 60s limit)
8
+ // Unit tests in init.test.ts cover structure; this only adds smoke-test of generated project
9
+ // See: https://git.shazhou.work/shazhou/proman/issues/171
10
+ describe.skip('proman init integration', () => {
11
+ let testDir: string
12
+ let projectDir: string
13
+
14
+ beforeAll(() => {
15
+ // Create a unique temp directory for integration test
16
+ testDir = join(
17
+ tmpdir(),
18
+ `proman-init-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`,
19
+ )
20
+ mkdirSync(testDir, { recursive: true })
21
+ projectDir = join(testDir, 'test-project')
22
+
23
+ // Get the path to the proman CLI (used only for init — the generated project uses its own)
24
+ const promanBin = join(process.cwd(), 'dist', 'cli.js')
25
+
26
+ // Run proman init
27
+ execSync(`node ${promanBin} init test-project`, { cwd: testDir })
28
+
29
+ // Install dependencies — this installs @shazhou/proman as a devDep inside the generated project.
30
+ // 60s timeout: pnpm install can be slow on first run (cold cache, registry fetch).
31
+ execSync('pnpm install', { cwd: projectDir, stdio: 'inherit' })
32
+ }, 60_000)
33
+
34
+ afterAll(() => {
35
+ // Clean up the test directory
36
+ if (existsSync(testDir)) {
37
+ rmSync(testDir, { recursive: true, force: true })
38
+ }
39
+ })
40
+
41
+ test('proman build succeeds', () => {
42
+ // Use the generated project's own proman (installed as devDep), not the parent's
43
+ expect(() => {
44
+ execSync('pnpm exec proman build', { cwd: projectDir, stdio: 'inherit' })
45
+ }).not.toThrow()
46
+ })
47
+
48
+ test('proman test succeeds', () => {
49
+ expect(() => {
50
+ execSync('pnpm exec proman test', { cwd: projectDir, stdio: 'inherit' })
51
+ }).not.toThrow()
52
+ })
53
+
54
+ test('proman check succeeds', () => {
55
+ expect(() => {
56
+ execSync('pnpm exec proman check', { cwd: projectDir, stdio: 'inherit' })
57
+ }).not.toThrow()
58
+ })
59
+ })
@@ -0,0 +1,179 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
5
+ import { init } from './init.js'
6
+
7
+ describe('proman init', () => {
8
+ let testDir: string
9
+
10
+ beforeEach(() => {
11
+ // Create a unique temp directory for each test
12
+ testDir = join(
13
+ tmpdir(),
14
+ `proman-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
15
+ )
16
+ mkdirSync(testDir, { recursive: true })
17
+ })
18
+
19
+ afterEach(() => {
20
+ // Clean up the test directory
21
+ if (existsSync(testDir)) {
22
+ rmSync(testDir, { recursive: true, force: true })
23
+ }
24
+ })
25
+
26
+ test('creates full monorepo structure with target directory', async () => {
27
+ const projectDir = join(testDir, 'my-project')
28
+
29
+ await init({ targetDir: projectDir })
30
+
31
+ // Verify root directory exists
32
+ expect(existsSync(projectDir)).toBe(true)
33
+
34
+ // Verify root files
35
+ const rootPackageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'))
36
+ expect(rootPackageJson.private).toBe(true)
37
+ expect(rootPackageJson.scripts).toBeDefined()
38
+ expect(rootPackageJson.scripts.build).toBe('proman build')
39
+ expect(rootPackageJson.scripts.test).toBe('proman test')
40
+ expect(rootPackageJson.scripts.check).toBe('proman check')
41
+ expect(rootPackageJson.scripts.format).toBe('proman format')
42
+ expect(rootPackageJson.devDependencies['@shazhou/proman']).toBeDefined()
43
+
44
+ expect(existsSync(join(projectDir, 'proman.yaml'))).toBe(true)
45
+ expect(existsSync(join(projectDir, 'pnpm-workspace.yaml'))).toBe(true)
46
+ expect(existsSync(join(projectDir, 'biome.json'))).toBe(true)
47
+ expect(existsSync(join(projectDir, 'tsconfig.json'))).toBe(true)
48
+ expect(existsSync(join(projectDir, '.gitignore'))).toBe(true)
49
+
50
+ // Verify gitignore content
51
+ const gitignore = readFileSync(join(projectDir, '.gitignore'), 'utf-8')
52
+ expect(gitignore).toContain('node_modules')
53
+ expect(gitignore).toContain('dist')
54
+ expect(gitignore).toContain('.proman')
55
+ expect(gitignore).toContain('*.tsbuildinfo')
56
+
57
+ // Verify pnpm-workspace.yaml
58
+ const workspace = readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf-8')
59
+ expect(workspace).toContain('packages/*')
60
+
61
+ // Verify core package
62
+ const corePackageJson = JSON.parse(
63
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
64
+ )
65
+ expect(corePackageJson.name).toBe('@my-project/core')
66
+ expect(corePackageJson.type).toBe('module')
67
+ expect(corePackageJson.exports).toBeDefined()
68
+
69
+ expect(existsSync(join(projectDir, 'packages/core/tsconfig.json'))).toBe(true)
70
+ expect(existsSync(join(projectDir, 'packages/core/src/index.ts'))).toBe(true)
71
+ expect(existsSync(join(projectDir, 'packages/core/src/index.test.ts'))).toBe(true)
72
+
73
+ const coreIndex = readFileSync(join(projectDir, 'packages/core/src/index.ts'), 'utf-8')
74
+ expect(coreIndex).toContain('export function hello()')
75
+
76
+ const coreTest = readFileSync(join(projectDir, 'packages/core/src/index.test.ts'), 'utf-8')
77
+ expect(coreTest).toContain('import')
78
+ expect(coreTest).toContain('hello')
79
+
80
+ // Verify CLI package
81
+ const cliPackageJson = JSON.parse(
82
+ readFileSync(join(projectDir, 'packages/cli/package.json'), 'utf-8'),
83
+ )
84
+ expect(cliPackageJson.name).toBe('@my-project/cli')
85
+ expect(cliPackageJson.bin).toEqual({ 'my-project': 'dist/cli.js' })
86
+ expect(cliPackageJson.dependencies).toEqual({ '@my-project/core': 'workspace:*' })
87
+
88
+ expect(existsSync(join(projectDir, 'packages/cli/tsconfig.json'))).toBe(true)
89
+ expect(existsSync(join(projectDir, 'packages/cli/src/cli.ts'))).toBe(true)
90
+ expect(existsSync(join(projectDir, 'packages/cli/src/cli.test.ts'))).toBe(true)
91
+
92
+ const cliSrc = readFileSync(join(projectDir, 'packages/cli/src/cli.ts'), 'utf-8')
93
+ expect(cliSrc).toContain('#!/usr/bin/env node')
94
+ expect(cliSrc).toContain('@my-project/core')
95
+
96
+ const cliTest = readFileSync(join(projectDir, 'packages/cli/src/cli.test.ts'), 'utf-8')
97
+ expect(cliTest).toContain('test')
98
+ })
99
+
100
+ test('init in current directory when no arg provided', async () => {
101
+ const projectDir = join(testDir, 'test-repo')
102
+ mkdirSync(projectDir)
103
+
104
+ await init({ targetDir: projectDir })
105
+
106
+ // Verify packages use directory name
107
+ const corePackageJson = JSON.parse(
108
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
109
+ )
110
+ expect(corePackageJson.name).toBe('@test-repo/core')
111
+
112
+ const cliPackageJson = JSON.parse(
113
+ readFileSync(join(projectDir, 'packages/cli/package.json'), 'utf-8'),
114
+ )
115
+ expect(cliPackageJson.name).toBe('@test-repo/cli')
116
+ expect(cliPackageJson.bin).toEqual({ 'test-repo': 'dist/cli.js' })
117
+ })
118
+
119
+ test('fails when target directory is not empty', async () => {
120
+ const projectDir = join(testDir, 'existing-project')
121
+ mkdirSync(projectDir, { recursive: true })
122
+ writeFileSync(join(projectDir, 'README.md'), '# existing')
123
+
124
+ await expect(init({ targetDir: projectDir })).rejects.toThrow(
125
+ /Directory is not empty.*existing-project/,
126
+ )
127
+ })
128
+
129
+ test('fails when target directory exists with content', async () => {
130
+ const projectDir = join(testDir, 'my-project')
131
+ mkdirSync(projectDir, { recursive: true })
132
+ writeFileSync(join(projectDir, 'package.json'), '{}')
133
+
134
+ await expect(init({ targetDir: projectDir })).rejects.toThrow(/not empty/)
135
+ })
136
+
137
+ test('sanitizes directory name with uppercase and special chars', async () => {
138
+ const projectDir = join(testDir, 'My-Project_v2!')
139
+ await init({ targetDir: projectDir })
140
+
141
+ const corePackageJson = JSON.parse(
142
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
143
+ )
144
+ expect(corePackageJson.name).toBe('@my-project_v2-/core')
145
+ })
146
+
147
+ test('sanitizes directory name starting with dot', async () => {
148
+ const projectDir = join(testDir, '.hidden-project')
149
+ await init({ targetDir: projectDir })
150
+
151
+ const corePackageJson = JSON.parse(
152
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
153
+ )
154
+ expect(corePackageJson.name).toBe('@hidden-project/core')
155
+ })
156
+
157
+ test('strips tilde from directory name', async () => {
158
+ const projectDir = join(testDir, 'my~project')
159
+ await init({ targetDir: projectDir })
160
+
161
+ const corePackageJson = JSON.parse(
162
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
163
+ )
164
+ expect(corePackageJson.name).toBe('@my-project/core')
165
+ })
166
+
167
+ test('truncates package name at 214 characters', async () => {
168
+ const longName = 'a'.repeat(250)
169
+ const projectDir = join(testDir, longName)
170
+ await init({ targetDir: projectDir })
171
+
172
+ const corePackageJson = JSON.parse(
173
+ readFileSync(join(projectDir, 'packages/core/package.json'), 'utf-8'),
174
+ )
175
+ // scope '@' + name + '/core' — the name segment itself is capped at 214
176
+ const nameSegment = corePackageJson.name.replace(/^@/, '').replace(/\/core$/, '')
177
+ expect(nameSegment.length).toBeLessThanOrEqual(214)
178
+ })
179
+ })