@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { $ } from 'bun'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import type { MirrorConfig, MirrorJsonObject } from './types'
|
|
8
|
+
import { MirrorError } from './errors'
|
|
9
|
+
import { assertValidSemver, sortSemverDescending } from './version'
|
|
10
|
+
import { resolveMirrorPath } from './config'
|
|
11
|
+
|
|
12
|
+
export const supportedGitTagTemplates = ['v{version}', '{name}@{version}', '{name}/v{version}'] as const
|
|
13
|
+
|
|
14
|
+
export const readPackageJson = async (path: string): Promise<MirrorJsonObject> => readJsonObject(path, 'package.json')
|
|
15
|
+
export const readJsrJson = async (path: string): Promise<MirrorJsonObject> => readJsonObject(path, 'jsr.json')
|
|
16
|
+
|
|
17
|
+
export const writeJsonObject = async (path: string, object: MirrorJsonObject) => {
|
|
18
|
+
await Bun.write(path, `${JSON.stringify(object, null, 2)}\n`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const readPackageVersion = async (config: MirrorConfig) => readVersionField(resolveMirrorPath(config.cwd, config.package.path), 'package.json')
|
|
22
|
+
export const readJsrVersion = async (config: MirrorConfig) => readVersionField(resolveMirrorPath(config.cwd, config.jsr.path), 'jsr.json')
|
|
23
|
+
export const readPackageName = async (config: MirrorConfig) => readNameField(resolveMirrorPath(config.cwd, config.package.path), 'package.json')
|
|
24
|
+
export const readJsrName = async (config: MirrorConfig) => readNameField(resolveMirrorPath(config.cwd, config.jsr.path), 'jsr.json')
|
|
25
|
+
|
|
26
|
+
export const writePackageVersion = async (config: MirrorConfig, nextVersion: string) =>
|
|
27
|
+
writeVersionField(resolveMirrorPath(config.cwd, config.package.path), 'package.json', nextVersion)
|
|
28
|
+
|
|
29
|
+
export const writeJsrVersion = async (config: MirrorConfig, nextVersion: string) =>
|
|
30
|
+
writeVersionField(resolveMirrorPath(config.cwd, config.jsr.path), 'jsr.json', nextVersion)
|
|
31
|
+
|
|
32
|
+
export const ensureAdapterFiles = async (config: MirrorConfig) => {
|
|
33
|
+
if (usesAdapter(config, 'package.json')) ensureFile(resolveMirrorPath(config.cwd, config.package.path), 'package.json')
|
|
34
|
+
if (usesAdapter(config, 'jsr.json')) ensureFile(resolveMirrorPath(config.cwd, config.jsr.path), 'jsr.json')
|
|
35
|
+
if (usesAdapter(config, 'git')) await ensureGitRepository(config.cwd)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const resolveProjectName = async (config: MirrorConfig) => {
|
|
39
|
+
if (config.project.name) return config.project.name
|
|
40
|
+
if (config.project.nameSource === 'package.json') return readPackageName(config)
|
|
41
|
+
if (config.project.nameSource === 'jsr.json') return readJsrName(config)
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const readCurrentVersion = async (config: MirrorConfig, projectName?: string) => {
|
|
46
|
+
if (config.version.source === 'package.json') return readPackageVersion(config)
|
|
47
|
+
if (config.version.source === 'jsr.json') return readJsrVersion(config)
|
|
48
|
+
return readGitVersion(config, projectName)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const readGitVersion = async (config: MirrorConfig, projectName?: string) => {
|
|
52
|
+
await ensureGitRepository(config.cwd)
|
|
53
|
+
|
|
54
|
+
const tagsOutput = await $`git -C ${config.cwd} tag --list`.text()
|
|
55
|
+
const versions = tagsOutput
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((tag) => versionFromTag(config.git.tagTemplate, tag, projectName))
|
|
60
|
+
.filter((version): version is string => Boolean(version))
|
|
61
|
+
|
|
62
|
+
if (versions.length === 0) throw new MirrorError(`No Git tags match template: ${config.git.tagTemplate}`)
|
|
63
|
+
|
|
64
|
+
return sortSemverDescending(versions)[0] ?? ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const renderGitTag = (template: string, version: string, projectName?: string) => {
|
|
68
|
+
assertSupportedGitTagTemplate(template)
|
|
69
|
+
assertValidSemver(version, 'Git tag version')
|
|
70
|
+
|
|
71
|
+
if (template.includes('{name}') && !projectName) throw new MirrorError(`Tag template requires a project name: ${template}`)
|
|
72
|
+
|
|
73
|
+
return template.replaceAll('{version}', version).replaceAll('{name}', projectName ?? '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const versionFromTag = (template: string, tag: string, projectName?: string) => {
|
|
77
|
+
assertSupportedGitTagTemplate(template)
|
|
78
|
+
|
|
79
|
+
if (template.includes('{name}') && !projectName) throw new MirrorError(`Tag template requires a project name: ${template}`)
|
|
80
|
+
|
|
81
|
+
const escapedTemplate = escapeRegex(template)
|
|
82
|
+
.replaceAll('\\{version\\}', '(?<version>.+)')
|
|
83
|
+
.replaceAll('\\{name\\}', escapeRegex(projectName ?? ''))
|
|
84
|
+
const match = new RegExp(`^${escapedTemplate}$`).exec(tag)
|
|
85
|
+
const version = match?.groups?.['version']
|
|
86
|
+
|
|
87
|
+
if (!version) return undefined
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
assertValidSemver(version, 'Git tag version')
|
|
91
|
+
return version
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const assertSupportedGitTagTemplate = (template: string) => {
|
|
98
|
+
if (!(supportedGitTagTemplates as readonly string[]).includes(template)) {
|
|
99
|
+
throw new MirrorError(`Unsupported Git tag template: ${template}. Expected v{version}, {name}@{version}, or {name}/v{version}.`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const isGitRepository = async (cwd: string) => {
|
|
104
|
+
try {
|
|
105
|
+
await $`git -C ${cwd} rev-parse --is-inside-work-tree`.quiet()
|
|
106
|
+
return true
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const isGitDirty = async (cwd: string) => {
|
|
113
|
+
const output = await $`git -C ${cwd} status --porcelain`.quiet().text()
|
|
114
|
+
return output.trim().length > 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const createGitCommit = async (cwd: string, paths: string[], message: string) => {
|
|
118
|
+
for (const path of paths) await $`git -C ${cwd} add ${path}`.quiet()
|
|
119
|
+
await $`git -C ${cwd} commit -m ${message}`.quiet()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const createGitTag = async (cwd: string, tag: string) => {
|
|
123
|
+
await $`git -C ${cwd} tag ${tag} -m ${`Release ${tag}`}`.quiet()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const pushGitRefs = async (cwd: string, includeCommit: boolean, includeTags: boolean) => {
|
|
127
|
+
if (includeCommit) await $`git -C ${cwd} push`.quiet()
|
|
128
|
+
if (includeTags) await $`git -C ${cwd} push --tags`.quiet()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const readJsonObject = async (path: string, label: string): Promise<MirrorJsonObject> => {
|
|
132
|
+
ensureFile(path, label)
|
|
133
|
+
const json = await Bun.file(path).json()
|
|
134
|
+
|
|
135
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) throw new MirrorError(`${label} must contain a JSON object: ${path}`)
|
|
136
|
+
|
|
137
|
+
return json as MirrorJsonObject
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const readVersionField = async (path: string, label: string): Promise<string> => {
|
|
141
|
+
const json = await readJsonObject(path, label)
|
|
142
|
+
const version = json['version']
|
|
143
|
+
|
|
144
|
+
if (typeof version !== 'string') throw new MirrorError(`${label} must contain a string version field: ${path}`)
|
|
145
|
+
assertValidSemver(version, `${label} version`)
|
|
146
|
+
|
|
147
|
+
return version
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const readNameField = async (path: string, label: string): Promise<string> => {
|
|
151
|
+
const json = await readJsonObject(path, label)
|
|
152
|
+
const name = json['name']
|
|
153
|
+
|
|
154
|
+
if (typeof name !== 'string' || name.length === 0) throw new MirrorError(`${label} must contain a string name field: ${path}`)
|
|
155
|
+
|
|
156
|
+
return name
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const writeVersionField = async (path: string, label: string, nextVersion: string) => {
|
|
160
|
+
const json = await readJsonObject(path, label)
|
|
161
|
+
json['version'] = nextVersion
|
|
162
|
+
await writeJsonObject(path, json)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ensureFile = (path: string, label: string) => {
|
|
166
|
+
if (!existsSync(path)) throw new MirrorError(`${label} file not found: ${path}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ensureGitRepository = async (cwd: string) => {
|
|
170
|
+
if (!(await isGitRepository(cwd))) throw new MirrorError(`Not a Git repository: ${cwd}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const usesAdapter = (config: MirrorConfig, adapter: 'package.json' | 'jsr.json' | 'git') =>
|
|
174
|
+
config.version.source === adapter || config.version.output.includes(adapter)
|
|
175
|
+
|
|
176
|
+
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
package/source/cli.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineCommand, runMain } from 'citty'
|
|
6
|
+
import type { ArgsDef } from 'citty'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { resolve } from 'node:path'
|
|
9
|
+
import { MirrorError } from './errors'
|
|
10
|
+
import { readCurrentVersion, resolveProjectName } from './adapters'
|
|
11
|
+
import { configPathForDisplay, discoverMirrorConfig, loadMirrorConfig, relativeFromCwd, writeInitConfig } from './config'
|
|
12
|
+
import { executeVersionPlan } from './executor'
|
|
13
|
+
import { parseMirrorCliOptions } from './flags'
|
|
14
|
+
import { buildVersionPlan, validateMirrorConfig } from './plan'
|
|
15
|
+
import { mirrorBanner, reportConfig, reportConfigSchema, reportExecution, reportExecutionSummary, reportPlan, reportValue } from './reporter'
|
|
16
|
+
import type { MirrorAdapterName, MirrorCliOptions } from './types'
|
|
17
|
+
import { resolveNextVersion } from './version'
|
|
18
|
+
|
|
19
|
+
const mirrorVersion = readInstalledVersion()
|
|
20
|
+
|
|
21
|
+
const globalArgs = {
|
|
22
|
+
config: { type: 'string', description: 'Path to mirror.config.toml' },
|
|
23
|
+
cwd: { type: 'string', description: 'Run as if Mirror started in this directory' },
|
|
24
|
+
format: { type: 'enum', options: ['text', 'json'], default: 'text', description: 'Output format' },
|
|
25
|
+
'no-color': { type: 'boolean', description: 'Disable color output' },
|
|
26
|
+
} satisfies ArgsDef
|
|
27
|
+
|
|
28
|
+
const overrideArgs = {
|
|
29
|
+
...globalArgs,
|
|
30
|
+
source: { type: 'enum', options: ['package.json', 'jsr.json', 'git'], description: 'Override version source' },
|
|
31
|
+
output: { type: 'string', description: 'Override version output. Repeat or comma-separate values.' },
|
|
32
|
+
'package-file': { type: 'string', description: 'Override package.json path' },
|
|
33
|
+
'jsr-file': { type: 'string', description: 'Override jsr.json path' },
|
|
34
|
+
preid: { type: 'string', description: 'Override prerelease identifier' },
|
|
35
|
+
} satisfies ArgsDef
|
|
36
|
+
|
|
37
|
+
const applyArgs = {
|
|
38
|
+
...overrideArgs,
|
|
39
|
+
'dry-run': { type: 'boolean', alias: 'dy', description: 'Build and print the plan without applying it' },
|
|
40
|
+
commit: { type: 'boolean', description: 'Create a release commit when file outputs changed' },
|
|
41
|
+
push: { type: 'boolean', description: 'Create the release commit when needed, then push release refs' },
|
|
42
|
+
'allow-dirty': { type: 'boolean', description: 'Allow release in a dirty Git worktree' },
|
|
43
|
+
yes: { type: 'boolean', alias: 'y', description: 'Apply without interactive confirmation' },
|
|
44
|
+
} satisfies ArgsDef
|
|
45
|
+
|
|
46
|
+
const targetArg = {
|
|
47
|
+
target: { type: 'positional', description: 'Release target or exact semantic version', required: true },
|
|
48
|
+
} satisfies ArgsDef
|
|
49
|
+
|
|
50
|
+
export const createMirrorCommand = () =>
|
|
51
|
+
defineCommand({
|
|
52
|
+
meta: {
|
|
53
|
+
name: 'mirror',
|
|
54
|
+
version: mirrorVersion,
|
|
55
|
+
description: 'Open source project versioning for Bun, npm, JSR, and Git.',
|
|
56
|
+
},
|
|
57
|
+
args: globalArgs,
|
|
58
|
+
subCommands: {
|
|
59
|
+
init: createInitCommand(),
|
|
60
|
+
config: createConfigCommand(),
|
|
61
|
+
version: createVersionCommand(),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const runMirrorCli = async (rawArgs = process.argv.slice(2)) => {
|
|
66
|
+
const effectiveArgs = rawArgs.length === 0 ? ['--help'] : rawArgs
|
|
67
|
+
const restoreColorOutput = effectiveArgs.includes('--no-color') ? stripColorFromProcessOutput() : () => {}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
if (effectiveArgs.includes('--no-color')) process.env['NO_COLOR'] = '1'
|
|
71
|
+
|
|
72
|
+
if (effectiveArgs.includes('--help')) {
|
|
73
|
+
const parsed = parseMirrorCliOptions(effectiveArgs)
|
|
74
|
+
const cwd = resolve(parsed.cwd ?? process.cwd())
|
|
75
|
+
const discovery = await discoverMirrorConfig(cwd, parsed.config)
|
|
76
|
+
const configDisplay = discovery.path ? relativeFromCwd(cwd, discovery.path) : ''
|
|
77
|
+
process.stdout.write(mirrorBanner(configDisplay))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (rawArgs.length === 0) {
|
|
81
|
+
process.on('exit', () => {
|
|
82
|
+
process.stdout.write([
|
|
83
|
+
'EXAMPLES',
|
|
84
|
+
'',
|
|
85
|
+
' mirror version current # Print the current version',
|
|
86
|
+
' mirror version plan patch # Preview a patch release plan',
|
|
87
|
+
' mirror version apply minor --commit # Apply a minor release with commit',
|
|
88
|
+
' mirror version plan patch --output=package.json,jsr.json,git # Plan with package, jsr, and git',
|
|
89
|
+
' mirror config schema # Print the configuration file reference',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n') + '\n')
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await runMain(createMirrorCommand(), { rawArgs: effectiveArgs })
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof MirrorError) {
|
|
98
|
+
console.error(error.message)
|
|
99
|
+
process.exit(error.exitCode)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw error
|
|
103
|
+
} finally {
|
|
104
|
+
restoreColorOutput()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const createInitCommand = () =>
|
|
109
|
+
defineCommand({
|
|
110
|
+
meta: { name: 'init', description: 'Create a Mirror configuration file.' },
|
|
111
|
+
subCommands: {
|
|
112
|
+
'package.json': createInitKindCommand('package.json'),
|
|
113
|
+
'jsr.json': createInitKindCommand('jsr.json'),
|
|
114
|
+
git: createInitKindCommand('git'),
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const createInitKindCommand = (kind: MirrorAdapterName) =>
|
|
119
|
+
defineCommand({
|
|
120
|
+
meta: { name: kind, description: `Create ${kind} project configuration.` },
|
|
121
|
+
args: {
|
|
122
|
+
...globalArgs,
|
|
123
|
+
yes: { type: 'boolean', description: 'Overwrite existing mirror.config.toml' },
|
|
124
|
+
},
|
|
125
|
+
async run(context) {
|
|
126
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
127
|
+
const path = await writeInitConfig(kind, options.cwd ?? process.cwd(), Boolean(options.yes))
|
|
128
|
+
process.stdout.write(reportValue(`created ${path}`, options.format))
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const createConfigCommand = () =>
|
|
133
|
+
defineCommand({
|
|
134
|
+
meta: { name: 'config', description: 'Inspect and validate Mirror configuration.' },
|
|
135
|
+
subCommands: {
|
|
136
|
+
show: defineCommand({
|
|
137
|
+
meta: { name: 'show', description: 'Print the resolved configuration.' },
|
|
138
|
+
args: overrideArgs,
|
|
139
|
+
async run(context) {
|
|
140
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
141
|
+
const config = await loadMirrorConfig(options)
|
|
142
|
+
if (options.format !== 'json') process.stdout.write(mirrorBanner(configPathForDisplay(config)))
|
|
143
|
+
process.stdout.write(reportConfig(config, options.format))
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
check: defineCommand({
|
|
147
|
+
meta: { name: 'check', description: 'Validate the resolved configuration.' },
|
|
148
|
+
args: overrideArgs,
|
|
149
|
+
async run(context) {
|
|
150
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
151
|
+
await validateMirrorConfig(options)
|
|
152
|
+
process.stdout.write(reportValue('ok', options.format))
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
schema: defineCommand({
|
|
156
|
+
meta: { name: 'schema', description: 'Print the configuration file reference.' },
|
|
157
|
+
args: globalArgs,
|
|
158
|
+
run(context) {
|
|
159
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
160
|
+
if (options.format !== 'json') process.stdout.write(mirrorBanner())
|
|
161
|
+
process.stdout.write(reportConfigSchema(options.format))
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const createVersionCommand = () =>
|
|
168
|
+
defineCommand({
|
|
169
|
+
meta: { name: 'version', description: 'Read, plan, and apply version changes.' },
|
|
170
|
+
subCommands: {
|
|
171
|
+
current: defineCommand({
|
|
172
|
+
meta: { name: 'current', description: 'Print the current project version.' },
|
|
173
|
+
args: overrideArgs,
|
|
174
|
+
async run(context) {
|
|
175
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
176
|
+
const config = await loadMirrorConfig(options)
|
|
177
|
+
const projectName = await resolveProjectName(config)
|
|
178
|
+
process.stdout.write(reportValue(await readCurrentVersion(config, projectName), options.format))
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
next: defineCommand({
|
|
182
|
+
meta: { name: 'next', description: 'Print the next version without checking outputs.' },
|
|
183
|
+
args: { ...overrideArgs, ...targetArg },
|
|
184
|
+
async run(context) {
|
|
185
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
186
|
+
const config = await loadMirrorConfig(options)
|
|
187
|
+
const projectName = await resolveProjectName(config)
|
|
188
|
+
const currentVersion = await readCurrentVersion(config, projectName)
|
|
189
|
+
process.stdout.write(reportValue(resolveNextVersion(currentVersion, String(context.args['target']), config.version.prereleaseId), options.format))
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
plan: defineCommand({
|
|
193
|
+
meta: { name: 'plan', description: 'Print the release plan without writing anything.' },
|
|
194
|
+
args: { ...overrideArgs, ...targetArg },
|
|
195
|
+
async run(context) {
|
|
196
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
197
|
+
const plan = await buildVersionPlan(String(context.args['target']), options)
|
|
198
|
+
if (options.format !== 'json') process.stdout.write(mirrorBanner(plan.configPath ? plan.configPath : ''))
|
|
199
|
+
process.stdout.write(reportPlan(plan, options.format))
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
apply: defineCommand({
|
|
203
|
+
meta: { name: 'apply', description: 'Apply the release plan.' },
|
|
204
|
+
args: { ...applyArgs, ...targetArg },
|
|
205
|
+
async run(context) {
|
|
206
|
+
const options = cliOptions(context.rawArgs, context.args)
|
|
207
|
+
const plan = await buildVersionPlan(String(context.args['target']), options)
|
|
208
|
+
|
|
209
|
+
if (options.format !== 'json') process.stdout.write(mirrorBanner(plan.configPath ? plan.configPath : ''))
|
|
210
|
+
if (options.format !== 'json') process.stdout.write(reportPlan(plan, options.format))
|
|
211
|
+
|
|
212
|
+
const result = await executeVersionPlan(plan, options)
|
|
213
|
+
process.stdout.write(options.format === 'json' ? reportExecution(result, options.format) : reportExecutionSummary(result, options.format))
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const cliOptions = (rawArgs: string[], args: Record<string, unknown>): MirrorCliOptions => {
|
|
220
|
+
const parsed = parseMirrorCliOptions(rawArgs)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...parsed,
|
|
224
|
+
config: parsed.config ?? stringArg(args['config']),
|
|
225
|
+
cwd: parsed.cwd ?? stringArg(args['cwd']),
|
|
226
|
+
format: parsed.format ?? (args['format'] === 'json' ? 'json' : 'text'),
|
|
227
|
+
source: parsed.source ?? adapterArg(args['source']),
|
|
228
|
+
output: parsed.output ?? outputArg(args['output']),
|
|
229
|
+
packageFile: parsed.packageFile ?? stringArg(args['packageFile']),
|
|
230
|
+
jsrFile: parsed.jsrFile ?? stringArg(args['jsrFile']),
|
|
231
|
+
preid: parsed.preid ?? stringArg(args['preid']),
|
|
232
|
+
dryRun: parsed.dryRun || args['dryRun'] === true,
|
|
233
|
+
commit: parsed.commit || args['commit'] === true,
|
|
234
|
+
push: parsed.push || args['push'] === true,
|
|
235
|
+
allowDirty: parsed.allowDirty || args['allowDirty'] === true,
|
|
236
|
+
yes: parsed.yes || args['yes'] === true,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const stringArg = (value: unknown) => (typeof value === 'string' ? value : undefined)
|
|
241
|
+
|
|
242
|
+
const adapterArg = (value: unknown) => {
|
|
243
|
+
if (value === 'package.json' || value === 'jsr.json' || value === 'git') return value
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const outputArg = (value: unknown): MirrorCliOptions['output'] => {
|
|
248
|
+
if (typeof value !== 'string') return undefined
|
|
249
|
+
|
|
250
|
+
const values = value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
251
|
+
|
|
252
|
+
if (values.length === 0) return undefined
|
|
253
|
+
|
|
254
|
+
return values.map((item) => {
|
|
255
|
+
const adapter = adapterArg(item)
|
|
256
|
+
if (!adapter) throw new MirrorError(`Invalid --output value: ${item}`)
|
|
257
|
+
return adapter
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readInstalledVersion() {
|
|
262
|
+
try {
|
|
263
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as Record<string, unknown>
|
|
264
|
+
return typeof packageJson['version'] === 'string' ? packageJson['version'] : '0.0.0'
|
|
265
|
+
} catch {
|
|
266
|
+
return '0.0.0'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const stripAnsi = (value: string) => value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
271
|
+
|
|
272
|
+
const stripColorFromProcessOutput = () => {
|
|
273
|
+
const originalLog = console.log
|
|
274
|
+
const originalError = console.error
|
|
275
|
+
|
|
276
|
+
console.log = (...values: unknown[]) => originalLog(...values.map(stripAnsiValue))
|
|
277
|
+
console.error = (...values: unknown[]) => originalError(...values.map(stripAnsiValue))
|
|
278
|
+
|
|
279
|
+
return () => {
|
|
280
|
+
console.log = originalLog
|
|
281
|
+
console.error = originalError
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const stripAnsiValue = (value: unknown) => (typeof value === 'string' ? stripAnsi(value) : value)
|
package/source/config.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Copyright (c) 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
7
|
+
import type {
|
|
8
|
+
MirrorAdapterName,
|
|
9
|
+
MirrorCliOptions,
|
|
10
|
+
MirrorConfig,
|
|
11
|
+
MirrorConfigDiscovery,
|
|
12
|
+
MirrorProjectNameSource,
|
|
13
|
+
MirrorRawConfig,
|
|
14
|
+
} from './types'
|
|
15
|
+
import { MirrorError } from './errors'
|
|
16
|
+
|
|
17
|
+
const adapters = new Set(['package.json', 'jsr.json', 'git'])
|
|
18
|
+
const projectNameSources = new Set(['package.json', 'jsr.json'])
|
|
19
|
+
|
|
20
|
+
export const resolveMirrorPath = (cwd: string, path: string) => (isAbsolute(path) ? path : resolve(cwd, path))
|
|
21
|
+
|
|
22
|
+
export const relativeFromCwd = (cwd: string, path: string) => {
|
|
23
|
+
const relativePath = relative(cwd, resolveMirrorPath(cwd, path))
|
|
24
|
+
return relativePath || '.'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const discoverMirrorConfig = async (cwd: string, explicitPath?: string): Promise<MirrorConfigDiscovery> => {
|
|
28
|
+
if (explicitPath) {
|
|
29
|
+
const configPath = resolveMirrorPath(cwd, explicitPath)
|
|
30
|
+
return { path: configPath, raw: await readConfigFile(configPath) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rootConfigPath = resolve(cwd, 'mirror.config.toml')
|
|
34
|
+
if (existsSync(rootConfigPath)) return { path: rootConfigPath, raw: await readConfigFile(rootConfigPath) }
|
|
35
|
+
|
|
36
|
+
const nestedConfigPath = resolve(cwd, 'config', 'mirror.config.toml')
|
|
37
|
+
if (existsSync(nestedConfigPath)) return { path: nestedConfigPath, raw: await readConfigFile(nestedConfigPath) }
|
|
38
|
+
|
|
39
|
+
return {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const readConfigFile = async (path: string): Promise<MirrorRawConfig> => {
|
|
43
|
+
if (!existsSync(path)) throw new MirrorError(`Configuration file not found: ${path}`)
|
|
44
|
+
|
|
45
|
+
const parsed = Bun.TOML.parse(await Bun.file(path).text())
|
|
46
|
+
|
|
47
|
+
if (!isRecord(parsed)) throw new MirrorError(`Configuration file must contain a TOML object: ${path}`)
|
|
48
|
+
|
|
49
|
+
return parsed as MirrorRawConfig
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const loadMirrorConfig = async (options: MirrorCliOptions = {}): Promise<MirrorConfig> => {
|
|
53
|
+
const cwd = resolve(options.cwd ?? process.cwd())
|
|
54
|
+
const discovered = await discoverMirrorConfig(cwd, options.config)
|
|
55
|
+
|
|
56
|
+
if (!discovered.raw) throw new MirrorError('Mirror configuration not found. Run `mirror init package`, `mirror init jsr`, or `mirror init git`.')
|
|
57
|
+
|
|
58
|
+
return normalizeMirrorConfig(discovered.raw, cwd, discovered.path, options)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const normalizeMirrorConfig = (
|
|
62
|
+
raw: MirrorRawConfig,
|
|
63
|
+
cwd: string,
|
|
64
|
+
configPath: string | undefined,
|
|
65
|
+
options: MirrorCliOptions = {},
|
|
66
|
+
): MirrorConfig => {
|
|
67
|
+
if (raw.schema !== 1) throw new MirrorError('Unsupported or missing configuration schema. Expected `schema = 1`.')
|
|
68
|
+
if (raw.version?.scheme !== undefined && raw.version.scheme !== 'semver') throw new MirrorError('Only `version.scheme = "semver"` is supported.')
|
|
69
|
+
|
|
70
|
+
const source = options.source ?? assertAdapter(raw.version?.source, 'version.source')
|
|
71
|
+
const output = dedupeAdapters(options.output ?? assertOutput(raw.version?.output))
|
|
72
|
+
const nameSource: MirrorProjectNameSource | undefined = raw.project?.name_source
|
|
73
|
+
? assertProjectNameSource(raw.project.name_source, 'project.name_source')
|
|
74
|
+
: undefined
|
|
75
|
+
const projectName = optionalString(raw.project?.name, 'project.name')
|
|
76
|
+
const prereleaseId = options.preid ?? optionalString(raw.version?.prerelease_id, 'version.prerelease_id') ?? ''
|
|
77
|
+
const packagePath = options.packageFile ?? optionalString(raw.package?.path, 'package.path') ?? 'package.json'
|
|
78
|
+
const jsrPath = options.jsrFile ?? optionalString(raw.jsr?.path, 'jsr.path') ?? 'jsr.json'
|
|
79
|
+
const tagTemplate = optionalString(raw.git?.tag_template, 'git.tag_template') ?? 'v{version}'
|
|
80
|
+
const gitCommit = optionalBoolean(raw.git?.commit, 'git.commit') === true
|
|
81
|
+
const gitPush = optionalBoolean(raw.git?.push, 'git.push') === true
|
|
82
|
+
const gitAllowDirty = optionalBoolean(raw.git?.allow_dirty, 'git.allow_dirty') === true
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
schema: 1,
|
|
86
|
+
cwd,
|
|
87
|
+
configPath,
|
|
88
|
+
project: {
|
|
89
|
+
name: projectName,
|
|
90
|
+
nameSource,
|
|
91
|
+
},
|
|
92
|
+
version: {
|
|
93
|
+
scheme: 'semver',
|
|
94
|
+
source,
|
|
95
|
+
output,
|
|
96
|
+
prereleaseId,
|
|
97
|
+
},
|
|
98
|
+
package: {
|
|
99
|
+
path: packagePath,
|
|
100
|
+
},
|
|
101
|
+
jsr: {
|
|
102
|
+
path: jsrPath,
|
|
103
|
+
},
|
|
104
|
+
git: {
|
|
105
|
+
tagTemplate,
|
|
106
|
+
commit: options.commit === true || options.push === true || gitCommit || gitPush,
|
|
107
|
+
push: options.push === true || gitPush,
|
|
108
|
+
allowDirty: options.allowDirty === true || gitAllowDirty,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const createInitConfig = (kind: MirrorAdapterName, cwd: string) => {
|
|
114
|
+
const projectName = basename(cwd)
|
|
115
|
+
|
|
116
|
+
if (kind === 'package.json') {
|
|
117
|
+
return `schema = 1
|
|
118
|
+
|
|
119
|
+
[project]
|
|
120
|
+
name_source = "package.json"
|
|
121
|
+
|
|
122
|
+
[version]
|
|
123
|
+
scheme = "semver"
|
|
124
|
+
source = "package.json"
|
|
125
|
+
output = ["package.json"]
|
|
126
|
+
prerelease_id = ""
|
|
127
|
+
|
|
128
|
+
[package]
|
|
129
|
+
path = "package.json"
|
|
130
|
+
|
|
131
|
+
[jsr]
|
|
132
|
+
path = "jsr.json"
|
|
133
|
+
|
|
134
|
+
[git]
|
|
135
|
+
tag_template = "{name}@{version}"
|
|
136
|
+
commit = false
|
|
137
|
+
push = false
|
|
138
|
+
allow_dirty = false
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (kind === 'jsr.json') {
|
|
143
|
+
return `schema = 1
|
|
144
|
+
|
|
145
|
+
[project]
|
|
146
|
+
name_source = "jsr.json"
|
|
147
|
+
|
|
148
|
+
[version]
|
|
149
|
+
scheme = "semver"
|
|
150
|
+
source = "jsr.json"
|
|
151
|
+
output = ["jsr.json"]
|
|
152
|
+
prerelease_id = ""
|
|
153
|
+
|
|
154
|
+
[jsr]
|
|
155
|
+
path = "jsr.json"
|
|
156
|
+
|
|
157
|
+
[git]
|
|
158
|
+
tag_template = "{name}@{version}"
|
|
159
|
+
commit = false
|
|
160
|
+
push = false
|
|
161
|
+
allow_dirty = false
|
|
162
|
+
`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return `schema = 1
|
|
166
|
+
|
|
167
|
+
[project]
|
|
168
|
+
name = "${projectName}"
|
|
169
|
+
|
|
170
|
+
[version]
|
|
171
|
+
scheme = "semver"
|
|
172
|
+
source = "git"
|
|
173
|
+
output = ["git"]
|
|
174
|
+
prerelease_id = ""
|
|
175
|
+
|
|
176
|
+
[git]
|
|
177
|
+
tag_template = "v{version}"
|
|
178
|
+
commit = false
|
|
179
|
+
push = false
|
|
180
|
+
allow_dirty = false
|
|
181
|
+
`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const writeInitConfig = async (kind: MirrorAdapterName, cwd: string, overwrite = false) => {
|
|
185
|
+
const path = join(cwd, 'mirror.config.toml')
|
|
186
|
+
|
|
187
|
+
if (existsSync(path) && !overwrite) throw new MirrorError(`Configuration already exists: ${path}`)
|
|
188
|
+
|
|
189
|
+
await Bun.write(path, createInitConfig(kind, cwd))
|
|
190
|
+
return path
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const configPathForDisplay = (config: MirrorConfig) => (config.configPath ? relativeFromCwd(config.cwd, config.configPath) : '(none)')
|
|
194
|
+
|
|
195
|
+
const assertAdapter = (value: unknown, key: string): MirrorAdapterName => {
|
|
196
|
+
if (typeof value !== 'string' || !adapters.has(value)) throw new MirrorError(`Invalid or missing ${key}. Expected package.json, jsr.json, or git.`)
|
|
197
|
+
return value as MirrorAdapterName
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const assertProjectNameSource = (value: unknown, key: string): MirrorProjectNameSource => {
|
|
201
|
+
if (typeof value !== 'string' || !projectNameSources.has(value)) throw new MirrorError(`Invalid ${key}. Expected package.json or jsr.json.`)
|
|
202
|
+
return value as MirrorProjectNameSource
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const assertOutput = (value: unknown): MirrorAdapterName[] => {
|
|
206
|
+
if (!Array.isArray(value) || value.length === 0) throw new MirrorError('Invalid or missing version.output. Expected at least one output adapter.')
|
|
207
|
+
return value.map((item) => assertAdapter(item, 'version.output'))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const dedupeAdapters = (value: MirrorAdapterName[]) => [...new Set(value)]
|
|
211
|
+
|
|
212
|
+
const optionalString = (value: unknown, key: string) => {
|
|
213
|
+
if (value === undefined) return undefined
|
|
214
|
+
if (typeof value !== 'string') throw new MirrorError(`Invalid ${key}. Expected a string.`)
|
|
215
|
+
return value
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const optionalBoolean = (value: unknown, key: string) => {
|
|
219
|
+
if (value === undefined) return undefined
|
|
220
|
+
if (typeof value !== 'boolean') throw new MirrorError(`Invalid ${key}. Expected true or false.`)
|
|
221
|
+
return value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|