@guiho/mirror 3.0.0-alpha.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE.md +23 -0
  3. package/README.md +190 -0
  4. package/bin/mirror.exe +0 -0
  5. package/jsr.json +12 -0
  6. package/library/adapters.d.ts +27 -0
  7. package/library/adapters.d.ts.map +1 -0
  8. package/library/adapters.js +152 -0
  9. package/library/cli.d.ts +25 -0
  10. package/library/cli.d.ts.map +1 -0
  11. package/library/cli.js +258 -0
  12. package/library/config.d.ts +14 -0
  13. package/library/config.d.ts.map +1 -0
  14. package/library/config.js +193 -0
  15. package/library/errors.d.ts +9 -0
  16. package/library/errors.d.ts.map +1 -0
  17. package/library/errors.js +15 -0
  18. package/library/executor.d.ts +7 -0
  19. package/library/executor.d.ts.map +1 -0
  20. package/library/executor.js +34 -0
  21. package/library/flags.d.ts +6 -0
  22. package/library/flags.d.ts.map +1 -0
  23. package/library/flags.js +69 -0
  24. package/library/guiho-mirror-bin.d.ts +6 -0
  25. package/library/guiho-mirror-bin.d.ts.map +1 -0
  26. package/library/guiho-mirror-bin.js +6 -0
  27. package/library/guiho-mirror.d.ts +14 -0
  28. package/library/guiho-mirror.d.ts.map +1 -0
  29. package/library/guiho-mirror.js +12 -0
  30. package/library/plan.d.ts +10 -0
  31. package/library/plan.d.ts.map +1 -0
  32. package/library/plan.js +81 -0
  33. package/library/reporter.d.ts +12 -0
  34. package/library/reporter.d.ts.map +1 -0
  35. package/library/reporter.js +121 -0
  36. package/library/types.d.ts +123 -0
  37. package/library/types.d.ts.map +1 -0
  38. package/library/types.js +4 -0
  39. package/library/version.d.ts +10 -0
  40. package/library/version.d.ts.map +1 -0
  41. package/library/version.js +31 -0
  42. package/package.json +81 -0
  43. package/source/adapters.ts +176 -0
  44. package/source/cli.ts +285 -0
  45. package/source/config.ts +224 -0
  46. package/source/errors.ts +17 -0
  47. package/source/executor.ts +39 -0
  48. package/source/flags.ts +84 -0
  49. package/source/guiho-mirror-bin.ts +8 -0
  50. package/source/guiho-mirror.spec.ts +501 -0
  51. package/source/guiho-mirror.ts +44 -0
  52. package/source/plan.ts +98 -0
  53. package/source/reporter.ts +127 -0
  54. package/source/types.ts +128 -0
  55. package/source/version.ts +39 -0
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ export class MirrorError extends Error {
6
+ readonly exitCode: number
7
+
8
+ constructor(message: string, exitCode = 1) {
9
+ super(message)
10
+ this.name = 'MirrorError'
11
+ this.exitCode = exitCode
12
+ }
13
+ }
14
+
15
+ export const invariant = (condition: unknown, message: string): asserts condition => {
16
+ if (!condition) throw new MirrorError(message)
17
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ import type { MirrorCliOptions, MirrorExecutionResult } from './types'
6
+ import { MirrorError } from './errors'
7
+ import { createGitCommit, createGitTag, isGitDirty, isGitRepository, pushGitRefs, writeJsrVersion, writePackageVersion } from './adapters'
8
+ import { loadMirrorConfig } from './config'
9
+ import { buildVersionPlan } from './plan'
10
+
11
+ export const applyVersionPlan = async (target: string, options: MirrorCliOptions = {}): Promise<MirrorExecutionResult> => {
12
+ const plan = await buildVersionPlan(target, options)
13
+
14
+ return executeVersionPlan(plan, options)
15
+ }
16
+
17
+ export const executeVersionPlan = async (
18
+ plan: MirrorExecutionResult['plan'],
19
+ options: MirrorCliOptions = {},
20
+ ): Promise<MirrorExecutionResult> => {
21
+ if (options.dryRun) return { plan, applied: false, dryRun: true }
22
+ if (!plan.allowDirty && (await isGitRepository(plan.cwd)) && (await isGitDirty(plan.cwd))) {
23
+ throw new MirrorError('Git worktree is dirty. Commit changes or pass --allow-dirty.')
24
+ }
25
+ if (!options.yes) throw new MirrorError('Refusing to apply without confirmation. Pass --yes to apply the plan.')
26
+
27
+ const config = await loadMirrorConfig(options)
28
+
29
+ if (config.version.output.includes('package.json')) await writePackageVersion(config, plan.nextVersion)
30
+ if (config.version.output.includes('jsr.json')) await writeJsrVersion(config, plan.nextVersion)
31
+
32
+ for (const action of plan.actions) {
33
+ if (action.type === 'git-commit') await createGitCommit(plan.cwd, action.paths, action.message)
34
+ if (action.type === 'git-tag') await createGitTag(plan.cwd, action.tag)
35
+ if (action.type === 'git-push') await pushGitRefs(plan.cwd, action.includeCommit, action.includeTags)
36
+ }
37
+
38
+ return { plan, applied: true, dryRun: false }
39
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ import type { MirrorAdapterName, MirrorCliOptions, MirrorFormat } from './types'
6
+ import { MirrorError } from './errors'
7
+
8
+ const booleanFlags = new Set(['dry-run', 'commit', 'push', 'allow-dirty', 'yes', 'no-color', 'help', 'version'])
9
+ const adapterNames = new Set(['package.json', 'jsr.json', 'git'])
10
+
11
+ const shortFlagAliases: Record<string, string> = {
12
+ '-dy': '--dry-run',
13
+ '-y': '--yes',
14
+ }
15
+
16
+ const normalizeKey = (key: string) => key.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase())
17
+
18
+ const expandShortFlags = (rawArgs: string[]) => rawArgs.map((token) => shortFlagAliases[token] ?? token)
19
+
20
+ export const parseMirrorCliOptions = (rawArgs: string[]): MirrorCliOptions => {
21
+ const parsed: Record<string, string | boolean | string[]> = {}
22
+ const args = expandShortFlags(rawArgs)
23
+
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const token = args[index]
26
+
27
+ if (!token?.startsWith('--')) continue
28
+
29
+ const withoutPrefix = token.slice(2)
30
+ const equalsIndex = withoutPrefix.indexOf('=')
31
+ const rawKey = equalsIndex >= 0 ? withoutPrefix.slice(0, equalsIndex) : withoutPrefix
32
+ const key = normalizeKey(rawKey)
33
+
34
+ if (booleanFlags.has(rawKey)) {
35
+ parsed[key] = true
36
+ continue
37
+ }
38
+
39
+ const value =
40
+ equalsIndex >= 0
41
+ ? withoutPrefix.slice(equalsIndex + 1)
42
+ : args[index + 1] && !args[index + 1]?.startsWith('-')
43
+ ? args[++index] ?? ''
44
+ : ''
45
+
46
+ if (!value) throw new MirrorError(`Missing value for --${rawKey}`)
47
+
48
+ if (key === 'output') {
49
+ const nextValues = value.split(',').map((item) => item.trim()).filter(Boolean)
50
+ const current = parsed['output']
51
+ parsed['output'] = [...(Array.isArray(current) ? current : current ? [String(current)] : []), ...nextValues]
52
+ continue
53
+ }
54
+
55
+ parsed[key] = value
56
+ }
57
+
58
+ return {
59
+ cwd: typeof parsed['cwd'] === 'string' ? parsed['cwd'] : undefined,
60
+ config: typeof parsed['config'] === 'string' ? parsed['config'] : undefined,
61
+ format: typeof parsed['format'] === 'string' ? assertFormat(parsed['format']) : undefined,
62
+ noColor: parsed['noColor'] === true,
63
+ source: typeof parsed['source'] === 'string' ? assertAdapter(parsed['source'], '--source') : undefined,
64
+ output: Array.isArray(parsed['output']) ? parsed['output'].map((value) => assertAdapter(value, '--output')) : undefined,
65
+ packageFile: typeof parsed['packageFile'] === 'string' ? parsed['packageFile'] : undefined,
66
+ jsrFile: typeof parsed['jsrFile'] === 'string' ? parsed['jsrFile'] : undefined,
67
+ preid: typeof parsed['preid'] === 'string' ? parsed['preid'] : undefined,
68
+ dryRun: parsed['dryRun'] === true,
69
+ commit: parsed['commit'] === true,
70
+ push: parsed['push'] === true,
71
+ allowDirty: parsed['allowDirty'] === true,
72
+ yes: parsed['yes'] === true,
73
+ }
74
+ }
75
+
76
+ const assertAdapter = (value: string, flagName: string): MirrorAdapterName => {
77
+ if (!adapterNames.has(value)) throw new MirrorError(`Invalid ${flagName} value: ${value}`)
78
+ return value as MirrorAdapterName
79
+ }
80
+
81
+ const assertFormat = (value: string): MirrorFormat => {
82
+ if (value !== 'text' && value !== 'json') throw new MirrorError(`Invalid --format value: ${value}`)
83
+ return value
84
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
4
+ */
5
+
6
+ import { runMirrorCli } from './guiho-mirror'
7
+
8
+ await runMirrorCli()
@@ -0,0 +1,501 @@
1
+ /**
2
+ * @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test'
6
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import {
10
+ applyVersionPlan,
11
+ buildVersionPlan,
12
+ loadMirrorConfig,
13
+ parseMirrorCliOptions,
14
+ readJsrName,
15
+ readJsrVersion,
16
+ readPackageName,
17
+ readPackageVersion,
18
+ renderGitTag,
19
+ resolveNextVersion,
20
+ validateMirrorConfig,
21
+ versionFromTag,
22
+ writeJsrVersion,
23
+ writePackageVersion,
24
+ } from './guiho-mirror'
25
+
26
+ const temporaryDirectories: string[] = []
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
30
+ })
31
+
32
+ describe('Mirror v3', () => {
33
+ test('parses operational and override flags', () => {
34
+ const options = parseMirrorCliOptions([
35
+ '--source',
36
+ 'package.json',
37
+ '--output',
38
+ 'package.json',
39
+ '--output=jsr.json,git',
40
+ '--package-file=custom-package.json',
41
+ '--jsr-file',
42
+ 'custom-jsr.json',
43
+ '--preid',
44
+ 'alpha',
45
+ '--dry-run',
46
+ '--commit',
47
+ '--push',
48
+ '--allow-dirty',
49
+ '--yes',
50
+ ])
51
+
52
+ expect(options).toMatchObject({
53
+ source: 'package.json',
54
+ output: ['package.json', 'jsr.json', 'git'],
55
+ packageFile: 'custom-package.json',
56
+ jsrFile: 'custom-jsr.json',
57
+ preid: 'alpha',
58
+ dryRun: true,
59
+ commit: true,
60
+ push: true,
61
+ allowDirty: true,
62
+ yes: true,
63
+ })
64
+ })
65
+
66
+ test('expands short flag aliases -dy and -y', () => {
67
+ const options = parseMirrorCliOptions(['-dy', '-y'])
68
+
69
+ expect(options.dryRun).toBe(true)
70
+ expect(options.yes).toBe(true)
71
+ })
72
+
73
+ test('discovers explicit, root, and nested configs with root precedence', async () => {
74
+ const cwd = await createTempDir()
75
+ await mkdir(join(cwd, 'config'), { recursive: true })
76
+ await writeText(join(cwd, 'explicit.toml'), packageConfig({ output: ['jsr.json'], source: 'jsr.json' }))
77
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'] }))
78
+ await writeText(join(cwd, 'config', 'mirror.config.toml'), gitConfig())
79
+
80
+ const explicit = await loadMirrorConfig({ cwd, config: 'explicit.toml' })
81
+ const root = await loadMirrorConfig({ cwd })
82
+
83
+ await rm(join(cwd, 'mirror.config.toml'))
84
+ const nested = await loadMirrorConfig({ cwd })
85
+
86
+ expect(explicit.configPath).toBe(join(cwd, 'explicit.toml'))
87
+ expect(explicit.version.source).toBe('jsr.json')
88
+ expect(root.configPath).toBe(join(cwd, 'mirror.config.toml'))
89
+ expect(root.version.source).toBe('package.json')
90
+ expect(nested.configPath).toBe(join(cwd, 'config', 'mirror.config.toml'))
91
+ expect(nested.version.source).toBe('git')
92
+ })
93
+
94
+ test('validates schema, adapter names, project name sources, and tag templates', async () => {
95
+ const cwd = await createPackageAndJsrFixture()
96
+
97
+ await writeText(join(cwd, 'mirror.config.toml'), 'schema = 2\n')
98
+ await expect(loadMirrorConfig({ cwd })).rejects.toThrow('schema')
99
+
100
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['npm'] }))
101
+ await expect(loadMirrorConfig({ cwd })).rejects.toThrow('version.output')
102
+
103
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'], nameSource: 'git' }))
104
+ await expect(loadMirrorConfig({ cwd })).rejects.toThrow('project.name_source')
105
+
106
+ await initializeGitRepository(cwd)
107
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['git'], tagTemplate: 'release-{version}' }))
108
+ await expect(validateMirrorConfig({ cwd })).rejects.toThrow('Unsupported Git tag template')
109
+ })
110
+
111
+ test('merges CLI overrides over config values', async () => {
112
+ const cwd = await createPackageAndJsrFixture()
113
+ await writeText(join(cwd, 'custom-package.json'), JSON.stringify({ name: 'custom-package', version: '2.0.0' }, null, 2))
114
+ await writeText(join(cwd, 'custom-jsr.json'), JSON.stringify({ name: 'custom-jsr', version: '2.3.0' }, null, 2))
115
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'], preid: 'beta' }))
116
+
117
+ const config = await loadMirrorConfig({
118
+ cwd,
119
+ source: 'jsr.json',
120
+ output: ['jsr.json', 'git'],
121
+ packageFile: 'custom-package.json',
122
+ jsrFile: 'custom-jsr.json',
123
+ preid: 'alpha',
124
+ push: true,
125
+ allowDirty: true,
126
+ })
127
+
128
+ expect(config.version.source).toBe('jsr.json')
129
+ expect(config.version.output).toEqual(['jsr.json', 'git'])
130
+ expect(config.package.path).toBe('custom-package.json')
131
+ expect(config.jsr.path).toBe('custom-jsr.json')
132
+ expect(config.version.prereleaseId).toBe('alpha')
133
+ expect(config.git.commit).toBe(true)
134
+ expect(config.git.push).toBe(true)
135
+ expect(config.git.allowDirty).toBe(true)
136
+ })
137
+
138
+ test('reads and writes package and JSR names and versions', async () => {
139
+ const cwd = await createPackageAndJsrFixture()
140
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'jsr.json'] }))
141
+ const config = await loadMirrorConfig({ cwd })
142
+
143
+ expect(await readPackageName(config)).toBe('@guiho/mirror')
144
+ expect(await readJsrName(config)).toBe('@guiho/mirror')
145
+ expect(await readPackageVersion(config)).toBe('1.0.0')
146
+ expect(await readJsrVersion(config)).toBe('1.0.0')
147
+
148
+ await writePackageVersion(config, '1.0.1')
149
+ await writeJsrVersion(config, '1.0.1')
150
+
151
+ expect(await readPackageVersion(config)).toBe('1.0.1')
152
+ expect(await readJsrVersion(config)).toBe('1.0.1')
153
+ })
154
+
155
+ test('resolves semantic version targets and prerelease identifiers', () => {
156
+ expect(resolveNextVersion('1.0.0', 'patch')).toBe('1.0.1')
157
+ expect(resolveNextVersion('1.0.0', 'prepatch')).toBe('1.0.1-0')
158
+ expect(resolveNextVersion('1.0.0', 'prepatch', 'alpha')).toBe('1.0.1-alpha.0')
159
+ expect(resolveNextVersion('1.0.0', '2.3.4')).toBe('2.3.4')
160
+ })
161
+
162
+ test('extracts and renders versions with supported Git tag templates', () => {
163
+ expect(versionFromTag('v{version}', 'v1.2.3')).toBe('1.2.3')
164
+ expect(versionFromTag('{name}@{version}', '@guiho/mirror@1.2.3', '@guiho/mirror')).toBe('1.2.3')
165
+ expect(versionFromTag('v{version}', 'not-a-version')).toBeUndefined()
166
+ expect(renderGitTag('v{version}', '1.2.3')).toBe('v1.2.3')
167
+ expect(renderGitTag('{name}@{version}', '1.2.3', '@guiho/mirror')).toBe('@guiho/mirror@1.2.3')
168
+ })
169
+
170
+ test('plans package and JSR file outputs', async () => {
171
+ const cwd = await createPackageAndJsrFixture()
172
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'jsr.json'] }))
173
+
174
+ const plan = await buildVersionPlan('patch', { cwd })
175
+
176
+ expect(plan.currentVersion).toBe('1.0.0')
177
+ expect(plan.nextVersion).toBe('1.0.1')
178
+ expect(plan.actions.map((action) => action.type)).toEqual(['write-file', 'write-file'])
179
+ })
180
+
181
+ test('applies package and JSR file outputs outside Git', async () => {
182
+ const cwd = await createPackageAndJsrFixture()
183
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'jsr.json'] }))
184
+
185
+ const result = await applyVersionPlan('minor', { cwd, yes: true })
186
+
187
+ expect(result.applied).toBe(true)
188
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.1.0')
189
+ expect(await readJsrVersion(await loadMirrorConfig({ cwd }))).toBe('1.1.0')
190
+ })
191
+
192
+ test('dry-run apply does not mutate files and does not require confirmation', async () => {
193
+ const cwd = await createPackageAndJsrFixture()
194
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'] }))
195
+
196
+ const result = await applyVersionPlan('patch', { cwd, dryRun: true })
197
+
198
+ expect(result.applied).toBe(false)
199
+ expect(result.dryRun).toBe(true)
200
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.0.0')
201
+ })
202
+
203
+ test('reads the current version from matching Git tags', async () => {
204
+ const cwd = await createGitFixture()
205
+ await git(cwd, 'tag', 'v1.0.0')
206
+ await git(cwd, 'tag', 'v1.2.0')
207
+ await writeText(join(cwd, 'mirror.config.toml'), gitConfig())
208
+
209
+ const plan = await buildVersionPlan('patch', { cwd })
210
+
211
+ expect(plan.currentVersion).toBe('1.2.0')
212
+ expect(plan.nextVersion).toBe('1.2.1')
213
+ expect(plan.gitTag).toBe('v1.2.1')
214
+ })
215
+
216
+ test('requires commit or push when file outputs and Git tag output are combined', async () => {
217
+ const cwd = await createPackageAndJsrFixture()
218
+ await initializeGitRepository(cwd)
219
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'git'] }))
220
+
221
+ await expect(buildVersionPlan('patch', { cwd })).rejects.toThrow('requires --commit or --push')
222
+
223
+ const packageJson = await Bun.file(join(cwd, 'package.json')).json()
224
+ expect(packageJson.version).toBe('1.0.0')
225
+ })
226
+
227
+ test('fails on dirty Git worktrees unless allow-dirty is set', async () => {
228
+ const cwd = await createPackageAndJsrFixture()
229
+ await initializeGitRepository(cwd)
230
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'] }))
231
+ await commitAll(cwd, 'Add Mirror config')
232
+ await writeText(join(cwd, 'README.md'), '# dirty fixture\n')
233
+
234
+ await expect(applyVersionPlan('patch', { cwd, yes: true })).rejects.toThrow('dirty')
235
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.0.0')
236
+
237
+ const result = await applyVersionPlan('patch', { cwd, yes: true, allowDirty: true })
238
+ expect(result.applied).toBe(true)
239
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.0.1')
240
+ })
241
+
242
+ test('applies file output, release commit, and Git tag with --commit', async () => {
243
+ const cwd = await createPackageAndJsrFixture()
244
+ await initializeGitRepository(cwd)
245
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'git'] }))
246
+ await commitAll(cwd, 'Add Mirror config')
247
+
248
+ const result = await applyVersionPlan('patch', { cwd, commit: true, yes: true })
249
+
250
+ expect(result.applied).toBe(true)
251
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.0.1')
252
+ expect((await gitText(cwd, 'tag', '--list')).trim()).toBe('@guiho/mirror@1.0.1')
253
+ expect((await gitText(cwd, 'status', '--porcelain')).trim()).toBe('')
254
+ })
255
+
256
+ test('push implies commit and pushes the release tag', async () => {
257
+ const remote = await createBareGitRepository()
258
+ const cwd = await createPackageAndJsrFixture()
259
+ await initializeGitRepository(cwd)
260
+ await git(cwd, 'remote', 'add', 'origin', remote)
261
+ await git(cwd, 'push', '-u', 'origin', 'HEAD')
262
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json', 'git'] }))
263
+ await commitAll(cwd, 'Add Mirror config')
264
+
265
+ const result = await applyVersionPlan('patch', { cwd, push: true, yes: true })
266
+
267
+ expect(result.plan.commitEnabled).toBe(true)
268
+ expect(result.plan.pushEnabled).toBe(true)
269
+ expect((await gitText(remote, 'tag', '--list')).trim()).toBe('@guiho/mirror@1.0.1')
270
+ })
271
+
272
+ test('git-only releases with --commit create tags without empty commits', async () => {
273
+ const cwd = await createGitFixture()
274
+ await git(cwd, 'tag', 'v1.0.0')
275
+ await writeText(join(cwd, 'mirror.config.toml'), gitConfig())
276
+ await commitAll(cwd, 'Add Mirror config')
277
+ const commitsBefore = (await gitText(cwd, 'rev-list', '--count', 'HEAD')).trim()
278
+
279
+ const result = await applyVersionPlan('patch', { cwd, commit: true, yes: true })
280
+
281
+ expect(result.applied).toBe(true)
282
+ expect((await gitText(cwd, 'rev-list', '--count', 'HEAD')).trim()).toBe(commitsBefore)
283
+ expect((await gitText(cwd, 'tag', '--list')).trim().split(/\r?\n/).sort()).toEqual(['v1.0.0', 'v1.0.1'])
284
+ })
285
+
286
+ test('runs CLI config show and config check', async () => {
287
+ const cwd = await createGitFixture()
288
+ await writeText(join(cwd, 'mirror.config.toml'), gitConfig())
289
+
290
+ const show = await runMirrorCli('config', 'show', '--cwd', cwd)
291
+ const check = await runMirrorCli('config', 'check', '--cwd', cwd)
292
+
293
+ expect(show.exitCode).toBe(0)
294
+ expect(show.stdout).toContain('source: git')
295
+ expect(check.exitCode).toBe(0)
296
+ expect(check.stdout.trim()).toBe('ok')
297
+ })
298
+
299
+ test('runs the top-level CLI as successful help output', async () => {
300
+ const result = await runMirrorCli()
301
+
302
+ expect(result.exitCode).toBe(0)
303
+ expect(result.stdout).toMatch(/mirror v\d+\.\d+\.\d+/)
304
+ expect(result.stdout).toContain('USAGE')
305
+ })
306
+
307
+ test('runs CLI help without ANSI colors when no-color is set', async () => {
308
+ const result = await runMirrorCli('--no-color', '--help')
309
+
310
+ expect(result.exitCode).toBe(0)
311
+ expect(result.stdout).toContain('USAGE')
312
+ expect(result.stdout).not.toContain('\u001B[')
313
+ })
314
+
315
+ test('runs CLI version current, next, plan, and apply', async () => {
316
+ const cwd = await createPackageAndJsrFixture()
317
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'] }))
318
+
319
+ const current = await runMirrorCli('version', 'current', '--cwd', cwd)
320
+ const next = await runMirrorCli('version', 'next', 'patch', '--cwd', cwd)
321
+ const plan = await runMirrorCli('version', 'plan', 'patch', '--cwd', cwd)
322
+ const apply = await runMirrorCli('version', 'apply', 'patch', '--cwd', cwd, '--yes')
323
+
324
+ expect(current.exitCode).toBe(0)
325
+ expect(current.stdout.trim()).toBe('1.0.0')
326
+ expect(next.exitCode).toBe(0)
327
+ expect(next.stdout.trim()).toBe('1.0.1')
328
+ expect(plan.exitCode).toBe(0)
329
+ expect(plan.stdout).toContain('next: 1.0.1')
330
+ expect(apply.exitCode).toBe(0)
331
+ expect(apply.stdout).toContain('next: 1.0.1')
332
+ expect(apply.stdout).toContain('applied: true')
333
+ expect(await readPackageVersion(await loadMirrorConfig({ cwd }))).toBe('1.0.1')
334
+ })
335
+
336
+ test('runs CLI source and repeated output overrides', async () => {
337
+ const cwd = await createPackageAndJsrFixture()
338
+ await writeText(join(cwd, 'mirror.config.toml'), packageConfig({ output: ['package.json'] }))
339
+
340
+ const result = await runMirrorCli(
341
+ 'version',
342
+ 'plan',
343
+ 'patch',
344
+ '--cwd',
345
+ cwd,
346
+ '--source',
347
+ 'package.json',
348
+ '--output',
349
+ 'package.json',
350
+ '--output',
351
+ 'jsr.json',
352
+ )
353
+
354
+ expect(result.exitCode).toBe(0)
355
+ expect(result.stdout).toContain('output: package.json, jsr.json')
356
+ expect(result.stdout).toContain('next: 1.0.1')
357
+ })
358
+ })
359
+
360
+ const createTempDir = async () => {
361
+ const path = await mkdtemp(join(tmpdir(), 'guiho-mirror-'))
362
+ temporaryDirectories.push(path)
363
+ return path
364
+ }
365
+
366
+ const createPackageAndJsrFixture = async () => {
367
+ const cwd = await createTempDir()
368
+ await writeJson(join(cwd, 'package.json'), {
369
+ name: '@guiho/mirror',
370
+ version: '1.0.0',
371
+ })
372
+ await writeJson(join(cwd, 'jsr.json'), {
373
+ name: '@guiho/mirror',
374
+ version: '1.0.0',
375
+ exports: './source/guiho-mirror.ts',
376
+ })
377
+ return cwd
378
+ }
379
+
380
+ const createGitFixture = async () => {
381
+ const cwd = await createTempDir()
382
+ await initializeGitRepository(cwd)
383
+ return cwd
384
+ }
385
+
386
+ const createBareGitRepository = async () => {
387
+ const cwd = await createTempDir()
388
+ await git(cwd, 'init', '--bare')
389
+ return cwd
390
+ }
391
+
392
+ const initializeGitRepository = async (cwd: string) => {
393
+ await git(cwd, 'init')
394
+ await git(cwd, 'config', 'user.email', 'mirror@example.com')
395
+ await git(cwd, 'config', 'user.name', 'Mirror Test')
396
+ await writeText(join(cwd, 'README.md'), '# fixture\n')
397
+ await git(cwd, 'add', '.')
398
+ await git(cwd, 'commit', '-m', 'Initial commit')
399
+ }
400
+
401
+ const commitAll = async (cwd: string, message: string) => {
402
+ await git(cwd, 'add', '.')
403
+ await git(cwd, 'commit', '-m', message)
404
+ }
405
+
406
+ const writeText = async (path: string, content: string) => {
407
+ await Bun.write(path, content)
408
+ }
409
+
410
+ const writeJson = async (path: string, object: Record<string, unknown>) => {
411
+ await writeText(path, `${JSON.stringify(object, null, 2)}\n`)
412
+ }
413
+
414
+ const packageConfig = ({
415
+ output,
416
+ source = 'package.json',
417
+ nameSource = 'package.json',
418
+ tagTemplate = '{name}@{version}',
419
+ preid = '',
420
+ }: {
421
+ output: string[]
422
+ source?: string
423
+ nameSource?: string
424
+ tagTemplate?: string
425
+ preid?: string
426
+ }) => `schema = 1
427
+
428
+ [project]
429
+ name_source = "${nameSource}"
430
+
431
+ [version]
432
+ scheme = "semver"
433
+ source = "${source}"
434
+ output = [${output.map((value) => `"${value}"`).join(', ')}]
435
+ prerelease_id = "${preid}"
436
+
437
+ [package]
438
+ path = "package.json"
439
+
440
+ [jsr]
441
+ path = "jsr.json"
442
+
443
+ [git]
444
+ tag_template = "${tagTemplate}"
445
+ commit = false
446
+ push = false
447
+ allow_dirty = false
448
+ `
449
+
450
+ const gitConfig = () => `schema = 1
451
+
452
+ [project]
453
+ name = "fixture"
454
+
455
+ [version]
456
+ scheme = "semver"
457
+ source = "git"
458
+ output = ["git"]
459
+ prerelease_id = ""
460
+
461
+ [git]
462
+ tag_template = "v{version}"
463
+ commit = false
464
+ push = false
465
+ allow_dirty = false
466
+ `
467
+
468
+ const git = async (cwd: string, ...args: string[]) => {
469
+ const result = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
470
+ const exitCode = await result.exited
471
+
472
+ if (exitCode !== 0) {
473
+ // @ts-expect-error
474
+ throw new Error(`git ${args.join(' ')} failed: ${await result.stderr.text()}`)
475
+ }
476
+ }
477
+
478
+ const gitText = async (cwd: string, ...args: string[]) => {
479
+ const result = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
480
+ const exitCode = await result.exited
481
+ // @ts-expect-error
482
+ const stdout = await result.stdout.text()
483
+
484
+ if (exitCode !== 0) {
485
+ // @ts-expect-error
486
+ throw new Error(`git ${args.join(' ')} failed: ${await result.stderr.text()}`)
487
+ }
488
+
489
+ return stdout
490
+ }
491
+
492
+ const runMirrorCli = async (...args: string[]) => {
493
+ const result = Bun.spawn(['bun', join(import.meta.dir, 'guiho-mirror-bin.ts'), ...args], {
494
+ stdout: 'pipe',
495
+ stderr: 'pipe',
496
+ })
497
+ // @ts-expect-error
498
+ const [exitCode, stdout, stderr] = await Promise.all([result.exited, result.stdout.text(), result.stderr.text()])
499
+
500
+ return { exitCode, stdout, stderr }
501
+ }