code-foundry 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +3 -4
- package/.github/CONTRIBUTING.md +10 -13
- package/.github/actions/setup/action.yml +50 -3
- package/.github/code-foundry.yml +6 -1
- package/.github/workflows/ci.yml +20 -23
- package/.github/workflows/ci_self-ci.yml +1 -1
- package/.github/workflows/codeql.yml +23 -29
- package/.github/workflows/codeql_self-ci.yml +1 -1
- package/.github/workflows/draft-pr.yml +2 -2
- package/.github/workflows/release-pr.yml +4 -4
- package/.github/workflows/release.yml +17 -29
- package/.github/workflows/security.yml +24 -19
- package/.github/workflows/security_self-ci.yml +1 -1
- package/.github/workflows/test.yml +20 -20
- package/.github/workflows/test_self-ci.yml +1 -1
- package/AGENTS.md +13 -10
- package/CHANGELOG.md +21 -0
- package/README.md +16 -6
- package/docs/CONFIGURATION.md +10 -1
- package/docs/INITIALIZATION.md +6 -5
- package/docs/RELEASES.md +5 -0
- package/docs/WORKFLOWS.md +12 -5
- package/package.json +7 -3
- package/src/cli.mjs +29 -25
- package/src/commands/doctor.mjs +70 -0
- package/src/commands/sync.mjs +224 -0
- package/src/lib/config.mjs +38 -0
- package/src/lib/profile.mjs +114 -0
- package/src/runtime.mjs +316 -0
- package/.github/scripts/bootstrap.sh +0 -21
- package/.github/scripts/changed-files.sh +0 -96
- package/.github/scripts/ci.sh +0 -641
- package/.github/scripts/codeql-languages.sh +0 -91
- package/.github/scripts/doctor.sh +0 -98
- package/.github/scripts/format-fast-path.sh +0 -80
- package/.github/scripts/init-repo.sh +0 -268
- package/.github/scripts/pre-commit.sh +0 -67
- package/.github/scripts/profile.sh +0 -186
- package/.github/scripts/security.sh +0 -214
- package/.github/scripts/sitecustomize.py +0 -17
- package/.github/scripts/sync-codeowners.sh +0 -76
- package/.github/scripts/sync-protection.sh +0 -138
- package/.github/scripts/sync-template.sh +0 -748
- package/.github/scripts/turbo-cache-probe.sh +0 -102
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { includesValue, readConfig } from '../lib/config.mjs'
|
|
7
|
+
import { resolveProfile } from '../lib/profile.mjs'
|
|
8
|
+
|
|
9
|
+
/** @param {string} root */
|
|
10
|
+
export function doctor(root) {
|
|
11
|
+
const target = resolve(root)
|
|
12
|
+
const config = readConfig(join(target, '.github/code-foundry.yml'))
|
|
13
|
+
const profile = resolveProfile(target)
|
|
14
|
+
let errors = 0
|
|
15
|
+
/** @param {string} message */
|
|
16
|
+
const error = (message) => { console.error(`ERROR: ${message}`); errors += 1 }
|
|
17
|
+
/** @param {string} message */
|
|
18
|
+
const warn = (message) => console.warn(`WARN: ${message}`)
|
|
19
|
+
|
|
20
|
+
const toolchain = config.toolchain ?? 'auto'
|
|
21
|
+
if (!['auto', 'native', 'mise'].includes(toolchain)) error(`unsupported toolchain: ${toolchain}`)
|
|
22
|
+
if (toolchain === 'mise' && !existsSync(join(target, '.mise.toml'))) error('.mise.toml is required when toolchain: mise')
|
|
23
|
+
if (toolchain === 'auto') {
|
|
24
|
+
console.log(existsSync(join(target, '.mise.toml'))
|
|
25
|
+
? 'Toolchain: mise (detected from existing .mise.toml).'
|
|
26
|
+
: 'Toolchain: native (mise not configured).')
|
|
27
|
+
}
|
|
28
|
+
const hooks = git(target, ['config', '--get', 'core.hooksPath'])
|
|
29
|
+
if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
|
|
30
|
+
|
|
31
|
+
const packageFile = join(target, 'package.json')
|
|
32
|
+
if (existsSync(packageFile)) {
|
|
33
|
+
let packageJson
|
|
34
|
+
try { packageJson = JSON.parse(readFileSync(packageFile, 'utf8')) }
|
|
35
|
+
catch { error('package.json is not valid JSON'); packageJson = {} }
|
|
36
|
+
const lockfiles = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].filter((file) => existsSync(join(target, file)))
|
|
37
|
+
const groups = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
38
|
+
if (!lockfiles.length && groups.some((key) => Object.keys(packageJson[key] ?? {}).length)) error('package.json exists but no supported lockfile was found')
|
|
39
|
+
if (lockfiles.length > 1) error('multiple JavaScript lockfiles found; keep one package manager')
|
|
40
|
+
if (packageJson.packageManager && lockfiles.length) {
|
|
41
|
+
const declared = String(packageJson.packageManager).split('@')[0]
|
|
42
|
+
const actual = lockfiles[0]?.startsWith('bun') ? 'bun' : lockfiles[0]?.split('-')[0].replace('.yaml', '')
|
|
43
|
+
if (actual && declared !== actual) error(`packageManager (${declared}) does not match ${actual} lockfile`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (profile.languages.split(',').includes('rust') && !commandExists('cargo')) error('Cargo is required for this repository')
|
|
48
|
+
if (profile.languages.split(',').includes('rust')) {
|
|
49
|
+
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], { cwd: target, stdio: 'ignore' })
|
|
50
|
+
if (metadata.status !== 0) error('cargo metadata failed')
|
|
51
|
+
}
|
|
52
|
+
if (profile.languages.split(',').includes('python') && !commandExists('python') && !existsSync(join(target, '.venv/bin/python'))) error('Python is required for this repository')
|
|
53
|
+
|
|
54
|
+
for (const workflow of ['ci', 'codeql', 'security', 'test', 'draft-pr', 'release-pr', 'release']) {
|
|
55
|
+
if (includesValue(config.features ?? 'all', workflow) && !existsSync(join(target, `.github/workflows/${workflow}.yml`))) error(`missing enabled workflow: ${workflow}.yml`)
|
|
56
|
+
}
|
|
57
|
+
console.log('Remote CI, Test, Security, CodeQL, and release runtimes are loaded by reusable workflow wrappers.')
|
|
58
|
+
if (errors) throw new Error(`Repository doctor found ${errors} error(s).`)
|
|
59
|
+
console.log('Repository doctor passed.')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} root @param {string[]} args */
|
|
63
|
+
function git(root, args) {
|
|
64
|
+
return spawnSync('git', args, { cwd: root, encoding: 'utf8' }).stdout?.trim() ?? ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string} command */
|
|
68
|
+
function commandExists(command) {
|
|
69
|
+
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0
|
|
70
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { detectLanguages, detectPackageManager, detectProfile } from '../lib/profile.mjs'
|
|
7
|
+
import { configured, includesValue, readConfig } from '../lib/config.mjs'
|
|
8
|
+
|
|
9
|
+
const standardFiles = [
|
|
10
|
+
'.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
|
|
11
|
+
'.githooks/pre-commit', 'AGENTS.md', 'LICENSE', 'NOTICE', 'ruff.toml', '.prettierrc',
|
|
12
|
+
'.github/CODEOWNERS', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
|
|
13
|
+
'.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', '.github/dependabot.yml',
|
|
14
|
+
'.github/ISSUE_TEMPLATE/bug_report.yml', '.github/ISSUE_TEMPLATE/config.yml',
|
|
15
|
+
'.github/ISSUE_TEMPLATE/feature_request.yml',
|
|
16
|
+
'.github/workflows/ci.yml', '.github/workflows/codeql.yml', '.github/workflows/draft-pr.yml',
|
|
17
|
+
'.github/workflows/release-pr.yml', '.github/workflows/release.yml',
|
|
18
|
+
'.github/workflows/security.yml', '.github/workflows/test.yml',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const protectedFiles = new Set([
|
|
22
|
+
'AGENTS.md', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
|
|
23
|
+
'.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', 'NOTICE',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const legacyFiles = [
|
|
27
|
+
'.github/code-foundry.yml.example', '.github/template.yml', '.github/template.yml.example',
|
|
28
|
+
'.github/scripts/bootstrap.sh', '.github/scripts/changed-files.sh', '.github/scripts/ci.sh',
|
|
29
|
+
'.github/scripts/codeql-languages.sh', '.github/scripts/doctor.sh', '.github/scripts/format-fast-path.sh',
|
|
30
|
+
'.github/scripts/init-repo.sh', '.github/scripts/pre-commit.sh', '.github/scripts/profile.sh',
|
|
31
|
+
'.github/scripts/security.sh', '.github/scripts/sitecustomize.py', '.github/scripts/sync-codeowners.sh',
|
|
32
|
+
'.github/scripts/sync-protection.sh', '.github/scripts/sync-template.sh', '.github/scripts/turbo-cache-probe.sh',
|
|
33
|
+
'.github/licenses/MIT.txt',
|
|
34
|
+
'.github/licenses/GPL-3.0-or-later.txt', '.github/licenses/AGPL-3.0-or-later.txt',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** @typedef {{ target: string, source: string, dryRun?: boolean, force?: boolean, init?: boolean }} SyncOptions */
|
|
38
|
+
|
|
39
|
+
/** @param {SyncOptions} options */
|
|
40
|
+
export function syncRepository(options) {
|
|
41
|
+
const target = resolve(options.target)
|
|
42
|
+
const source = resolve(options.source)
|
|
43
|
+
const dryRun = options.dryRun ?? false
|
|
44
|
+
const force = options.force ?? false
|
|
45
|
+
const configPath = join(target, '.github/code-foundry.yml')
|
|
46
|
+
const existingConfig = readConfig(configPath)
|
|
47
|
+
if (!Object.keys(existingConfig).length && !options.init) throw new Error('Missing .github/code-foundry.yml; run init first.')
|
|
48
|
+
const defaults = createDefaultConfig(target, source)
|
|
49
|
+
let config = { ...defaults, ...existingConfig }
|
|
50
|
+
if (!Object.keys(existingConfig).length) {
|
|
51
|
+
writeOrReport(configPath, renderConfig(config), dryRun)
|
|
52
|
+
} else {
|
|
53
|
+
const missing = Object.keys(defaults).filter((key) => !(key in existingConfig))
|
|
54
|
+
if (missing.length) {
|
|
55
|
+
const current = readFileSync(configPath, 'utf8').trimEnd()
|
|
56
|
+
const additions = missing.map((key) => `${key}: ${defaults[key]}`).join('\n')
|
|
57
|
+
writeOrReport(configPath, `${current}\n${additions}\n`, dryRun)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const languages = configured(config.languages, detectLanguages(target).join(','))
|
|
62
|
+
const features = configured(config.features, 'all')
|
|
63
|
+
const runtimeRepository = configured(config.runtime_repository, '0xPlayerOne/code-foundry')
|
|
64
|
+
const runtimeRef = configured(config.runtime_ref, `v${readPackageVersion(source)}`)
|
|
65
|
+
const toolchain = configured(config.toolchain, 'auto')
|
|
66
|
+
if (!['auto', 'native', 'mise'].includes(toolchain)) {
|
|
67
|
+
throw new Error(`Unsupported toolchain: ${toolchain}; use auto, native, or mise.`)
|
|
68
|
+
}
|
|
69
|
+
const license = configured(config.license, existsSync(join(target, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later')
|
|
70
|
+
const changed = []
|
|
71
|
+
|
|
72
|
+
for (const file of standardFiles) {
|
|
73
|
+
if (!shouldInclude(file, languages, features)) continue
|
|
74
|
+
const sourceFile = sourcePath(source, file)
|
|
75
|
+
if (!existsSync(sourceFile)) throw new Error(`Template file missing: ${file}`)
|
|
76
|
+
const destination = join(target, file)
|
|
77
|
+
if (!force && protectedFiles.has(file) && existsSync(destination)) {
|
|
78
|
+
const existing = readFileSync(destination, 'utf8')
|
|
79
|
+
if (!isLegacyManagedDoc(file, existing)) continue
|
|
80
|
+
}
|
|
81
|
+
if ((file === 'LICENSE' || file === 'NOTICE') && license === 'preserve' && existsSync(destination)) continue
|
|
82
|
+
if ((file === 'LICENSE' || file === 'NOTICE') && license === 'none') continue
|
|
83
|
+
if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
|
|
84
|
+
let content = readFileSync(sourceFile)
|
|
85
|
+
if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
|
|
86
|
+
content = Buffer.from(renderWorkflow(content.toString('utf8'), config, runtimeRepository, runtimeRef))
|
|
87
|
+
}
|
|
88
|
+
if (file === '.gitignore' && existsSync(destination)) {
|
|
89
|
+
content = Buffer.from(mergeGitignore(content.toString('utf8'), readFileSync(destination, 'utf8')))
|
|
90
|
+
}
|
|
91
|
+
if (!existsSync(destination) || !buffersEqual(content, readFileSync(destination))) {
|
|
92
|
+
changed.push(file)
|
|
93
|
+
writeOrReport(destination, content, dryRun)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (license !== 'preserve' && license !== 'none') {
|
|
98
|
+
const licenseFile = license === 'mit' ? 'MIT.txt' : license === 'agpl-3.0-or-later' ? 'AGPL-3.0-or-later.txt' : 'GPL-3.0-or-later.txt'
|
|
99
|
+
const sourceLicense = join(source, '.github/licenses', licenseFile)
|
|
100
|
+
if (!existsSync(sourceLicense)) throw new Error(`License template missing: ${sourceLicense}`)
|
|
101
|
+
writeOrReport(join(target, 'LICENSE'), readFileSync(sourceLicense), dryRun)
|
|
102
|
+
if (!existsSync(join(target, 'NOTICE'))) writeOrReport(join(target, 'NOTICE'), readFileSync(join(source, 'NOTICE')), dryRun)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const file of legacyFiles) {
|
|
106
|
+
const destination = join(target, file)
|
|
107
|
+
if (existsSync(destination)) {
|
|
108
|
+
changed.push(file)
|
|
109
|
+
if (dryRun) console.log(`Would remove ${file}`)
|
|
110
|
+
else rmSync(destination, { force: true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const file of ['ruff.toml', '.prettierrc', '.prettierignore']) {
|
|
114
|
+
const relevant = file === 'ruff.toml' ? includesValue(languages, 'python') : includesValue(languages, 'typescript')
|
|
115
|
+
if (!relevant && existsSync(join(target, file))) {
|
|
116
|
+
changed.push(file)
|
|
117
|
+
if (dryRun) console.log(`Would remove irrelevant language configuration ${file}`)
|
|
118
|
+
else rmSync(join(target, file), { force: true })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!dryRun && existsSync(join(target, '.githooks/pre-commit'))) {
|
|
122
|
+
chmodSync(join(target, '.githooks/pre-commit'), 0o755)
|
|
123
|
+
git(target, ['config', 'core.hooksPath', '.githooks'])
|
|
124
|
+
}
|
|
125
|
+
console.log(`${changed.length} baseline file(s) differ.`)
|
|
126
|
+
return { changed, config }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {string} file @param {string} languages @param {string} features */
|
|
130
|
+
function shouldInclude(file, languages, features) {
|
|
131
|
+
if (file === 'ruff.toml') return includesValue(languages, 'python')
|
|
132
|
+
if (file === '.prettierrc') return includesValue(languages, 'typescript')
|
|
133
|
+
if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
|
|
134
|
+
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
135
|
+
return !workflow || includesValue(features, workflow)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {string} source @param {string} file */
|
|
139
|
+
function sourcePath(source, file) {
|
|
140
|
+
if (file.startsWith('.github/workflows/')) {
|
|
141
|
+
const name = file.slice('.github/workflows/'.length, -4)
|
|
142
|
+
return join(source, '.github/workflows', `${name}_self-ci.yml`)
|
|
143
|
+
}
|
|
144
|
+
return join(source, file)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {string} content @param {Record<string,string>} config @param {string} repository @param {string} ref */
|
|
148
|
+
function renderWorkflow(content, config, repository, ref) {
|
|
149
|
+
const localPrefix = 'uses: ./.github/workflows/'
|
|
150
|
+
const remotePrefix = `uses: ${repository}/.github/workflows/`
|
|
151
|
+
let rendered = content.replaceAll(localPrefix, remotePrefix)
|
|
152
|
+
rendered = rendered.replace(new RegExp(`${escapeRegExp(remotePrefix)}([^\\s@]+)`, 'g'), `$&@${ref}`)
|
|
153
|
+
/** @type {Record<string, string|undefined>} */
|
|
154
|
+
const runners = {
|
|
155
|
+
ci: config.ci_runner ?? config.runner,
|
|
156
|
+
test: config.test_runner ?? config.runner,
|
|
157
|
+
security: config.security_runner ?? config.runner,
|
|
158
|
+
codeql: config.codeql_runner ?? config.runner,
|
|
159
|
+
'draft-pr': config.pr_runner ?? config.runner,
|
|
160
|
+
'release-pr': config.pr_runner ?? config.runner,
|
|
161
|
+
release: config.release_runner ?? config.runner,
|
|
162
|
+
}
|
|
163
|
+
const workflow = content.match(/\.github\/workflows\/([^/]+)\.yml/)?.[1]
|
|
164
|
+
const runner = workflow ? runners[workflow] : undefined
|
|
165
|
+
if (runner) rendered = rendered.replace(/^(\s+runner:)\s+.*$/m, `$1 ${runner}`)
|
|
166
|
+
return rendered
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {string} value */
|
|
170
|
+
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
|
171
|
+
|
|
172
|
+
/** @param {string} baseline @param {string} existing */
|
|
173
|
+
function mergeGitignore(baseline, existing) {
|
|
174
|
+
const marker = '# Repository-specific rules'
|
|
175
|
+
const custom = existing.includes(marker) ? existing.slice(existing.indexOf(marker) + marker.length).trim() : ''
|
|
176
|
+
return custom ? `${baseline.trimEnd()}\n\n${marker}\n${custom}\n` : baseline
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @param {string} file @param {string} content */
|
|
180
|
+
function isLegacyManagedDoc(file, content) {
|
|
181
|
+
if (file === 'AGENTS.md') {
|
|
182
|
+
return content.includes('.github/scripts/bootstrap.sh') && content.includes('bash .github/scripts/ci.sh')
|
|
183
|
+
}
|
|
184
|
+
if (file === '.github/CONTRIBUTING.md') {
|
|
185
|
+
return content.includes('.github/scripts/bootstrap.sh') && content.includes('.github/template.yml')
|
|
186
|
+
}
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {string} target @param {string[]} args */
|
|
191
|
+
function git(target, args) { spawnSync('git', args, { cwd: target, stdio: 'ignore' }) }
|
|
192
|
+
|
|
193
|
+
/** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
|
|
194
|
+
function writeOrReport(file, content, dryRun) {
|
|
195
|
+
if (dryRun) { console.log(`Would sync ${file}`); return }
|
|
196
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
197
|
+
writeFileSync(file, content)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @param {Buffer} a @param {Buffer} b */
|
|
201
|
+
function buffersEqual(a, b) { return a.equals(b) }
|
|
202
|
+
|
|
203
|
+
/** @param {string} root @param {string} source @returns {Record<string,string>} */
|
|
204
|
+
function createDefaultConfig(root, source) {
|
|
205
|
+
const languages = detectLanguages(root).join(',')
|
|
206
|
+
const packageManager = detectPackageManager(root)
|
|
207
|
+
return {
|
|
208
|
+
version: '1', profile: detectProfile(root), languages, features: 'all', codeql: 'auto', dependency_review: 'auto', package_manager: packageManager,
|
|
209
|
+
runtime_repository: '0xPlayerOne/code-foundry', runtime_ref: `v${readPackageVersion(source)}`,
|
|
210
|
+
runner: 'ubuntu-latest', unit_runner: 'ubuntu-slim', ci_runner: 'ubuntu-latest', test_runner: 'ubuntu-latest',
|
|
211
|
+
toolchain: 'auto',
|
|
212
|
+
security_runner: 'ubuntu-slim', codeql_runner: 'ubuntu-latest', pr_runner: 'ubuntu-slim', release_runner: 'ubuntu-slim',
|
|
213
|
+
prune_standard: 'false', cache_packages: 'auto', cache_build: 'auto', coverage_minimum: '80', turbo_remote: 'auto',
|
|
214
|
+
release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node', npm_publish: 'false',
|
|
215
|
+
license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase',
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @param {Record<string,string>} config */
|
|
220
|
+
function renderConfig(config) { return `${Object.entries(config).map(([key, value]) => `${key}: ${value}`).join('\n')}\n` }
|
|
221
|
+
/** @param {string} root */
|
|
222
|
+
function readPackageVersion(root) {
|
|
223
|
+
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version ?? '0.0.0' } catch { return '0.0.0' }
|
|
224
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read the intentionally small YAML subset used by code-foundry.yml.
|
|
7
|
+
* Values are scalar strings; this keeps the CLI dependency-free.
|
|
8
|
+
* @param {string} file
|
|
9
|
+
* @returns {Record<string, string>}
|
|
10
|
+
*/
|
|
11
|
+
export function readConfig(file) {
|
|
12
|
+
if (!existsSync(file)) return {}
|
|
13
|
+
|
|
14
|
+
/** @type {Record<string, string>} */
|
|
15
|
+
const config = {}
|
|
16
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
17
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
18
|
+
if (!match) continue
|
|
19
|
+
const value = match[2].replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '')
|
|
20
|
+
config[match[1]] = value
|
|
21
|
+
}
|
|
22
|
+
return config
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} value @param {string} fallback */
|
|
26
|
+
export function configured(value, fallback) {
|
|
27
|
+
return value === undefined || value === '' ? fallback : value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} value @returns {string[]} */
|
|
31
|
+
export function listValue(value) {
|
|
32
|
+
return value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {string} value @param {string} item */
|
|
36
|
+
export function includesValue(value, item) {
|
|
37
|
+
return value === 'all' || listValue(value).includes(item)
|
|
38
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { configured, includesValue, readConfig } from './config.mjs'
|
|
6
|
+
|
|
7
|
+
const ignoredDirectories = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'.code-foundry',
|
|
10
|
+
'.kilo',
|
|
11
|
+
'.mise',
|
|
12
|
+
'.next',
|
|
13
|
+
'.nuxt',
|
|
14
|
+
'.venv',
|
|
15
|
+
'node_modules',
|
|
16
|
+
'target',
|
|
17
|
+
'vendor',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
/** @param {string} root @returns {string[]} */
|
|
21
|
+
function sourceFiles(root) {
|
|
22
|
+
/** @type {string[]} */
|
|
23
|
+
const files = []
|
|
24
|
+
/** @param {string} directory */
|
|
25
|
+
function visit(directory) {
|
|
26
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
27
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue
|
|
28
|
+
const path = join(directory, entry.name)
|
|
29
|
+
if (entry.isDirectory()) visit(path)
|
|
30
|
+
else files.push(path)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
visit(root)
|
|
34
|
+
return files
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {string} root @param {RegExp} pattern */
|
|
38
|
+
function hasSource(root, pattern) {
|
|
39
|
+
return sourceFiles(root).some((file) => pattern.test(file))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} root */
|
|
43
|
+
export function detectLanguages(root) {
|
|
44
|
+
const files = sourceFiles(root)
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
const languages = []
|
|
47
|
+
if (
|
|
48
|
+
existsSync(join(root, 'package.json')) ||
|
|
49
|
+
files.some((file) => /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(file)) ||
|
|
50
|
+
files.some((file) => /(?:bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|package-lock\.json)$/.test(file))
|
|
51
|
+
) languages.push('typescript')
|
|
52
|
+
if (existsSync(join(root, 'Cargo.toml')) || hasSource(root, /\.rs$/)) languages.push('rust')
|
|
53
|
+
if (
|
|
54
|
+
existsSync(join(root, 'pyproject.toml')) ||
|
|
55
|
+
existsSync(join(root, 'requirements.txt')) ||
|
|
56
|
+
existsSync(join(root, 'requirements-dev.txt')) ||
|
|
57
|
+
hasSource(root, /\.py$/)
|
|
58
|
+
) languages.push('python')
|
|
59
|
+
if (files.some((file) => file.endsWith('.sol'))) languages.push('solidity')
|
|
60
|
+
return languages.length ? languages : ['none']
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} root */
|
|
64
|
+
export function detectPackageManager(root) {
|
|
65
|
+
if (existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))) return 'bun'
|
|
66
|
+
if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
67
|
+
if (existsSync(join(root, 'yarn.lock'))) return 'yarn'
|
|
68
|
+
if (existsSync(join(root, 'package-lock.json'))) return 'npm'
|
|
69
|
+
if (existsSync(join(root, 'package.json'))) return 'bun'
|
|
70
|
+
return 'none'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {string} root */
|
|
74
|
+
export function detectProfile(root) {
|
|
75
|
+
if (existsSync(join(root, 'turbo.json')) || existsSync(join(root, 'pnpm-workspace.yaml'))) return 'monorepo'
|
|
76
|
+
if (existsSync(join(root, 'package.json'))) {
|
|
77
|
+
try {
|
|
78
|
+
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
79
|
+
if (Array.isArray(packageJson.workspaces) || typeof packageJson.workspaces === 'object') return 'monorepo'
|
|
80
|
+
} catch {
|
|
81
|
+
// Doctor reports malformed package.json separately.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (existsSync(join(root, 'package.json')) || existsSync(join(root, 'pyproject.toml')) || existsSync(join(root, 'Cargo.toml'))) return 'application'
|
|
85
|
+
return 'minimal'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} root */
|
|
89
|
+
export function resolveProfile(root) {
|
|
90
|
+
const config = readConfig(join(root, '.github/code-foundry.yml'))
|
|
91
|
+
const languages = configured(config.languages, 'auto')
|
|
92
|
+
const profile = configured(config.profile, 'auto')
|
|
93
|
+
const packageManager = configured(config.package_manager, 'auto')
|
|
94
|
+
return {
|
|
95
|
+
profile: profile === 'auto' ? detectProfile(root) : profile,
|
|
96
|
+
languages: languages === 'auto' ? detectLanguages(root).join(',') : languages,
|
|
97
|
+
package_manager: packageManager === 'auto' ? detectPackageManager(root) : packageManager,
|
|
98
|
+
features: configured(config.features, 'all'),
|
|
99
|
+
release_type: configured(config.release_type, detectReleaseType(root)),
|
|
100
|
+
npm_publish: configured(config.npm_publish, 'false'),
|
|
101
|
+
runner: configured(config.runner, 'ubuntu-latest'),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} root */
|
|
106
|
+
function detectReleaseType(root) {
|
|
107
|
+
if (existsSync(join(root, 'package.json'))) return 'node'
|
|
108
|
+
if (existsSync(join(root, 'pyproject.toml'))) return 'python'
|
|
109
|
+
if (existsSync(join(root, 'Cargo.toml'))) return 'rust'
|
|
110
|
+
if (existsSync(join(root, 'version.txt'))) return 'simple'
|
|
111
|
+
return 'none'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { includesValue }
|