@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE.md +23 -0
- package/README.md +190 -0
- package/bin/mirror.exe +0 -0
- package/jsr.json +12 -0
- package/library/adapters.d.ts +27 -0
- package/library/adapters.d.ts.map +1 -0
- package/library/adapters.js +152 -0
- package/library/cli.d.ts +25 -0
- package/library/cli.d.ts.map +1 -0
- package/library/cli.js +258 -0
- package/library/config.d.ts +14 -0
- package/library/config.d.ts.map +1 -0
- package/library/config.js +193 -0
- package/library/errors.d.ts +9 -0
- package/library/errors.d.ts.map +1 -0
- package/library/errors.js +15 -0
- package/library/executor.d.ts +7 -0
- package/library/executor.d.ts.map +1 -0
- package/library/executor.js +34 -0
- package/library/flags.d.ts +6 -0
- package/library/flags.d.ts.map +1 -0
- package/library/flags.js +69 -0
- package/library/guiho-mirror-bin.d.ts +6 -0
- package/library/guiho-mirror-bin.d.ts.map +1 -0
- package/library/guiho-mirror-bin.js +6 -0
- package/library/guiho-mirror.d.ts +14 -0
- package/library/guiho-mirror.d.ts.map +1 -0
- package/library/guiho-mirror.js +12 -0
- package/library/plan.d.ts +10 -0
- package/library/plan.d.ts.map +1 -0
- package/library/plan.js +81 -0
- package/library/reporter.d.ts +12 -0
- package/library/reporter.d.ts.map +1 -0
- package/library/reporter.js +121 -0
- package/library/types.d.ts +123 -0
- package/library/types.d.ts.map +1 -0
- package/library/types.js +4 -0
- package/library/version.d.ts +10 -0
- package/library/version.d.ts.map +1 -0
- package/library/version.js +31 -0
- package/package.json +81 -0
- package/source/adapters.ts +176 -0
- package/source/cli.ts +285 -0
- package/source/config.ts +224 -0
- package/source/errors.ts +17 -0
- package/source/executor.ts +39 -0
- package/source/flags.ts +84 -0
- package/source/guiho-mirror-bin.ts +8 -0
- package/source/guiho-mirror.spec.ts +501 -0
- package/source/guiho-mirror.ts +44 -0
- package/source/plan.ts +98 -0
- package/source/reporter.ts +127 -0
- package/source/types.ts +128 -0
- package/source/version.ts +39 -0
package/source/errors.ts
ADDED
|
@@ -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
|
+
}
|
package/source/flags.ts
ADDED
|
@@ -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,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
|
+
}
|