@ebalzan/ci 0.0.0-stage → 1.0.1

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/lib/log.mjs ADDED
@@ -0,0 +1,34 @@
1
+ // log.mjs — the CLI's terminal output, shared by sync and onboard: colors (off
2
+ // when stdout is not a TTY or NO_COLOR is set), the step/ok/warn/fail lines and
3
+ // the [y/N] prompt.
4
+
5
+ import process from 'node:process'
6
+ import { createInterface } from 'node:readline/promises'
7
+
8
+ const color = code => text =>
9
+ process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text
10
+ export const bold = color(1)
11
+ export const dim = color(2)
12
+ export const red = color(31)
13
+ export const green = color(32)
14
+ export const yellow = color(33)
15
+ export const cyan = color(36)
16
+
17
+ export const info = (text = '') => console.log(text)
18
+ export const step = text => console.log(`${cyan(bold('==>'))} ${text}`)
19
+ export const ok = text => console.log(`${green(' ✓')} ${text}`)
20
+ export const warn = text => console.log(yellow(` ! ${text}`))
21
+ export const fail = text => console.error(red(` ✗ ${text}`))
22
+
23
+ // confirm QUESTION — true only for an explicit y/Y; a closed stdin is a no.
24
+ export async function confirm(question) {
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
26
+ const closed = new Promise(done => rl.once('close', () => done('')))
27
+ try {
28
+ return /^y$/i.test(
29
+ (await Promise.race([rl.question(yellow(`${question} [y/N] `)), closed])).trim(),
30
+ )
31
+ } finally {
32
+ rl.close()
33
+ }
34
+ }
@@ -0,0 +1,526 @@
1
+ // onboard.mjs — `ebalzan-ci onboard`: bring a repo under the shared rules in
2
+ // one step. The templates/ files, the .gitignore entries and the npm wiring go
3
+ // into one local commit; then (unless --no-push) dev is created on GitHub when
4
+ // missing, `sync` runs, and the branch is pushed. Every step prints what it
5
+ // does; --dry-run stops after the plan.
6
+
7
+ import { spawnSync } from 'node:child_process'
8
+ import {
9
+ chmodSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from 'node:fs'
18
+ import { basename, dirname, join, resolve } from 'node:path'
19
+ import process from 'node:process'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import * as github from './github.mjs'
23
+ import { bold, confirm, dim, fail, info, ok, step, warn } from './log.mjs'
24
+ import { sync } from './sync.mjs'
25
+
26
+ const CI_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
27
+ const TEMPLATES_DIR = join(CI_ROOT, 'templates')
28
+ const CI_PKG = JSON.parse(readFileSync(join(CI_ROOT, 'package.json'), 'utf8'))
29
+ // The slug every caller's `uses:` names: package.json "repository" (change it there if you fork).
30
+ const CI_REPO = (CI_PKG.repository?.url ?? '').replace(/^.*github\.com[/:]|\.git$/g, '')
31
+ const CI_PACKAGE = CI_PKG.name
32
+ const CI_MAJOR = CI_PKG.version.split('.')[0]
33
+ const CONFIG_PACKAGE = '@ebalzan/config'
34
+ const DEFAULT_BRANCH = 'ci/onboard-ebalzan-ci'
35
+
36
+ // Templates the repo owns once written: never overwritten, even with --force.
37
+ const NEVER_OVERWRITE = ['.github/workflows/deploy.yml']
38
+
39
+ const PRETTIER_FILES = [
40
+ ...['', '.json', '.json5', '.yaml', '.yml', '.toml', '.js', '.cjs', '.mjs', '.ts'].map(
41
+ ext => `.prettierrc${ext}`,
42
+ ),
43
+ ...['js', 'cjs', 'mjs', 'ts'].map(ext => `prettier.config.${ext}`),
44
+ ]
45
+ const ESLINT_FILES = [
46
+ ...['js', 'cjs', 'mjs', 'ts', 'mts', 'cts'].map(ext => `eslint.config.${ext}`),
47
+ ...['', '.js', '.cjs', '.json', '.yaml', '.yml'].map(ext => `.eslintrc${ext}`),
48
+ ]
49
+ const RENOVATE_FILES = [
50
+ 'renovate.json',
51
+ 'renovate.json5',
52
+ '.renovaterc',
53
+ '.renovaterc.json',
54
+ '.github/renovate.json',
55
+ '.github/renovate.json5',
56
+ ]
57
+
58
+ // The lines added to the repo's .gitignore (step 3 of the help). Data, not a
59
+ // template file: it is appended, never rendered at its own path. This repo's
60
+ // own .gitignore ends with the same lines (test/onboard.test.mjs checks it).
61
+ export const GITIGNORE_SNIPPET = 'secrets.gitignore'
62
+
63
+ export const HELP = `Usage: ebalzan-ci onboard [options]
64
+
65
+ Bring a repo under the shared rules in one step. In the repo's checkout it:
66
+ 1. creates a branch (${DEFAULT_BRANCH}) from dev — or from the default
67
+ branch when dev does not exist yet;
68
+ 2. writes the templates/ files: the workflows, a starter
69
+ .github/actions.env, renovate.json and, for a Node repo, the husky
70
+ commit-msg hook. A file that already exists is kept (--force replaces
71
+ it), except .github/workflows/deploy.yml, which is the repo's own and is
72
+ never replaced;
73
+ 3. adds the .github/secrets*.env files to .gitignore when they are not
74
+ ignored yet (before any secret file exists);
75
+ 4. for a Node repo (package.json): installs this package and husky as
76
+ devDependencies, sets \`prepare: husky\` when no prepare script exists,
77
+ and wires the code-style rules of ${CONFIG_PACKAGE} — package.json
78
+ "prettier" and an eslint.config.mjs — where the repo has no Prettier or
79
+ ESLint config of its own (--no-config skips this);
80
+ 5. commits it all as \`ci: onboard <ci repo>\`.
81
+ Then, unless --no-push:
82
+ 6. creates the dev branch on GitHub if it is missing;
83
+ 7. runs \`ebalzan-ci sync\` (variables, secrets, settings, rulesets);
84
+ 8. pushes the branch. The auto-pr.yml it adds runs on that very push and
85
+ opens the PR into dev. Review and squash-merge it.
86
+
87
+ Options:
88
+ -C, --repo-dir DIR Checkout of the repo to onboard (default: .)
89
+ -R, --repo OWNER/REPO Its slug (default: from DIR's git remote)
90
+ --runs-on LABEL Runner label for every job (default: ubuntu-latest)
91
+ --ref REF Ref the callers pin (default: v${CI_MAJOR}, this package's major)
92
+ --branch NAME Branch to commit on (default: ${DEFAULT_BRANCH})
93
+ --force [FILE] Overwrite FILE (a template path, e.g.
94
+ .github/workflows/pr-checks.yml) when it exists;
95
+ repeat for more. Without FILE: every template file.
96
+ .github/workflows/deploy.yml is never overwritten.
97
+ --no-npm Skip the npm installs even when package.json exists
98
+ --no-config Do not wire ${CONFIG_PACKAGE} (Prettier, ESLint)
99
+ --no-push Stop after the local commit (no GitHub changes at all)
100
+ -n, --dry-run Show what would happen; change nothing
101
+ -y, --yes Skip the confirmations (also for the sync)
102
+ -h, --help Show this help
103
+
104
+ Requires git; gh authenticated with admin on the repo (not needed with
105
+ --no-push or --dry-run); npm for a Node repo. The CI repo itself is refused:
106
+ its callers would point it at a released copy of itself.`
107
+
108
+ // ---- args --------------------------------------------------------------------
109
+ // By hand rather than util.parseArgs: --force takes an optional value.
110
+ function parseOnboardArgs(argv) {
111
+ const opts = {
112
+ repoDir: '.',
113
+ repo: '',
114
+ runsOn: 'ubuntu-latest',
115
+ ref: `v${CI_MAJOR}`,
116
+ branch: DEFAULT_BRANCH,
117
+ forceAll: false,
118
+ forceFiles: [],
119
+ npm: true,
120
+ config: true,
121
+ push: true,
122
+ dryRun: false,
123
+ yes: false,
124
+ help: false,
125
+ }
126
+ for (let i = 0; i < argv.length; i++) {
127
+ const arg = argv[i]
128
+ const value = () => argv[++i] ?? ''
129
+ switch (arg) {
130
+ case '-C':
131
+ case '--repo-dir':
132
+ opts.repoDir = value()
133
+ break
134
+ case '-R':
135
+ case '--repo':
136
+ opts.repo = value()
137
+ break
138
+ case '--runs-on':
139
+ opts.runsOn = value()
140
+ break
141
+ case '--ref':
142
+ opts.ref = value()
143
+ break
144
+ case '--branch':
145
+ opts.branch = value()
146
+ break
147
+ case '--force':
148
+ // A following non-option argument is the file to force; else force all.
149
+ if (argv[i + 1] && !argv[i + 1].startsWith('-')) {
150
+ opts.forceFiles.push(value().replace(/^\.\//, ''))
151
+ } else {
152
+ opts.forceAll = true
153
+ }
154
+ break
155
+ case '--no-npm':
156
+ opts.npm = false
157
+ break
158
+ case '--no-config':
159
+ opts.config = false
160
+ break
161
+ case '--no-push':
162
+ opts.push = false
163
+ break
164
+ case '-n':
165
+ case '--dry-run':
166
+ opts.dryRun = true
167
+ break
168
+ case '-y':
169
+ case '--yes':
170
+ opts.yes = true
171
+ break
172
+ case '-h':
173
+ case '--help':
174
+ return { ...opts, help: true }
175
+ default:
176
+ throw new Error(`Unknown option: ${arg} (try --help)`)
177
+ }
178
+ }
179
+ return opts
180
+ }
181
+
182
+ // ---- helpers -----------------------------------------------------------------
183
+ // git -C DIR …: stdout, or a throw with git's own message.
184
+ function gitIn(dir) {
185
+ const run = args => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
186
+ const git = (...args) => {
187
+ const result = run(args)
188
+ if (result.status !== 0) throw new Error(result.stderr.trim() || `git ${args.join(' ')} failed`)
189
+ return result.stdout
190
+ }
191
+ git.ok = (...args) => run(args).status === 0
192
+ return git
193
+ }
194
+
195
+ // npm in DIR, its output shown; true when it succeeded.
196
+ const npmIn = dir => args =>
197
+ spawnSync('npm', args, { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' })
198
+ .status === 0
199
+
200
+ function readPackage(dir) {
201
+ try {
202
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
203
+ } catch {
204
+ return {}
205
+ }
206
+ }
207
+
208
+ const hasDependency = (pkg, name) =>
209
+ Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name])
210
+
211
+ // Every template file under templates/ (all but GITIGNORE_SNIPPET), as a
212
+ // sorted list of relative paths.
213
+ function templateFiles() {
214
+ return readdirSync(TEMPLATES_DIR, { recursive: true })
215
+ .map(String)
216
+ .filter(rel => rel !== GITIGNORE_SNIPPET && statSync(join(TEMPLATES_DIR, rel)).isFile())
217
+ .map(rel => rel.split('\\').join('/'))
218
+ .sort()
219
+ }
220
+
221
+ function render(rel, dir, opts) {
222
+ const src = join(TEMPLATES_DIR, rel)
223
+ const dest = join(dir, rel)
224
+ const text = readFileSync(src, 'utf8')
225
+ .replaceAll('__CI_REPO__', CI_REPO)
226
+ .replaceAll('__CI_REF__', opts.ref)
227
+ .replaceAll('__CI_PACKAGE__', CI_PACKAGE)
228
+ .replaceAll('__RUNS_ON__', opts.runsOn)
229
+ mkdirSync(dirname(dest), { recursive: true })
230
+ writeFileSync(dest, text)
231
+ if (statSync(src).mode & 0o111 || rel.startsWith('.husky/')) {
232
+ chmodSync(dest, statSync(dest).mode | 0o111)
233
+ }
234
+ }
235
+
236
+ const samePath = (a, b) => {
237
+ try {
238
+ return realpathSync(a) === realpathSync(b)
239
+ } catch {
240
+ return false
241
+ }
242
+ }
243
+
244
+ // ---- onboard -----------------------------------------------------------------
245
+ async function run(opts) {
246
+ if (!CI_REPO.includes('/')) {
247
+ throw new Error(`Could not read the CI repo from package.json "repository" (got '${CI_REPO}').`)
248
+ }
249
+ if (!opts.runsOn || !opts.ref || !opts.branch) {
250
+ throw new Error('--runs-on, --ref and --branch need a value.')
251
+ }
252
+
253
+ // ---- preconditions ---------------------------------------------------------
254
+ if (!github.isCheckout(opts.repoDir)) throw new Error(`${opts.repoDir} is not a git checkout.`)
255
+ const dir = resolve(opts.repoDir)
256
+ const git = gitIn(dir)
257
+ const npm = npmIn(dir)
258
+
259
+ if (opts.push && !opts.dryRun) github.requireAuth()
260
+ const repo = opts.repo || github.detectRepo(dir)
261
+ if (opts.push && !repo) {
262
+ throw new Error(
263
+ `Could not determine the repo for ${dir}. Pass --repo OWNER/REPO (or --no-push).`,
264
+ )
265
+ }
266
+ // The CI repo is not onboarded: the callers would point it at a released copy
267
+ // of itself. Caught by its checkout, its slug or its package name.
268
+ const isNode = existsSync(join(dir, 'package.json'))
269
+ if (
270
+ samePath(dir, CI_ROOT) ||
271
+ repo.toLowerCase() === CI_REPO.toLowerCase() ||
272
+ (isNode && readPackage(dir).name === CI_PACKAGE)
273
+ ) {
274
+ throw new Error(`${dir} is ${CI_REPO} itself, which is not onboarded.`)
275
+ }
276
+ if (!opts.dryRun && git('status', '--porcelain')) {
277
+ throw new Error(`${dir} has uncommitted changes. Commit or stash them first.`)
278
+ }
279
+ if (git.ok('rev-parse', '--verify', '--quiet', `refs/heads/${opts.branch}`)) {
280
+ throw new Error(`Branch ${opts.branch} already exists in ${dir}. Delete it or pass --branch.`)
281
+ }
282
+
283
+ // Branch point: dev if it exists, else the default branch, else HEAD (no remote).
284
+ const hasOrigin = git.ok('remote', 'get-url', 'origin')
285
+ if (hasOrigin && !opts.dryRun && !git.ok('fetch', '--quiet', 'origin')) {
286
+ warn('git fetch origin failed; using local refs.')
287
+ }
288
+ const devExists =
289
+ hasOrigin && git.ok('rev-parse', '--verify', '--quiet', 'refs/remotes/origin/dev')
290
+ let baseRef = 'HEAD'
291
+ if (devExists) baseRef = 'origin/dev'
292
+ else if (hasOrigin && git.ok('rev-parse', '--verify', '--quiet', 'refs/remotes/origin/HEAD')) {
293
+ baseRef = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD').trim()
294
+ }
295
+
296
+ const useConfig = isNode && opts.config
297
+ const present = files => files.filter(file => existsSync(join(dir, file))).join(' ')
298
+
299
+ // ---- plan ------------------------------------------------------------------
300
+ const neverOverwrite = rel => NEVER_OVERWRITE.includes(rel)
301
+ const forced = rel => !neverOverwrite(rel) && (opts.forceAll || opts.forceFiles.includes(rel))
302
+
303
+ const templates = templateFiles()
304
+ for (const rel of opts.forceFiles) {
305
+ if (neverOverwrite(rel)) {
306
+ throw new Error(
307
+ `--force ${rel}: it is the repo's own file and is never overwritten. Delete it to get the template again.`,
308
+ )
309
+ }
310
+ if (!templates.includes(rel)) {
311
+ throw new Error(`--force ${rel}: not a template file. One of: ${templates.join(' ')}`)
312
+ }
313
+ }
314
+
315
+ // Why template REL does not belong in this repo at all, or '' when it does.
316
+ const skipReason = rel => {
317
+ if (rel.startsWith('.husky/') && !isNode) return 'no package.json'
318
+ if (rel === 'eslint.config.mjs') {
319
+ if (!useConfig) return isNode ? '--no-config' : 'no package.json'
320
+ // A repo with its own ESLint config extends the preset there; never add a second one.
321
+ const own = present(ESLINT_FILES)
322
+ if (own && own !== 'eslint.config.mjs') {
323
+ return `repo has ${own} — extend ${CONFIG_PACKAGE}/eslint/node there`
324
+ }
325
+ }
326
+ // Any existing Renovate config is the repo's own; extend the preset there.
327
+ if (rel === 'renovate.json' && present(RENOVATE_FILES) && !forced(rel)) {
328
+ return 'repo has its own Renovate config'
329
+ }
330
+ return ''
331
+ }
332
+
333
+ const plan = templates.map(rel => {
334
+ const reason = skipReason(rel)
335
+ if (reason) return { action: 'skip', rel, reason }
336
+ if (!existsSync(join(dir, rel))) return { action: 'create', rel }
337
+ if (forced(rel)) return { action: 'overwrite', rel }
338
+ if (neverOverwrite(rel))
339
+ return { action: 'keep', rel, reason: "the repo's own — never overwritten" }
340
+ return { action: 'keep', rel, reason: `exists — --force ${rel} replaces it` }
341
+ })
342
+
343
+ // Secrets files must be ignored before the first one exists.
344
+ const gitignore =
345
+ !git.ok('check-ignore', '-q', '.github/secrets.env') ||
346
+ !git.ok('check-ignore', '-q', '.github/secrets.production.env')
347
+
348
+ // Prettier: point package.json at the shared config unless the repo has its own.
349
+ let prettierSet = false
350
+ let prettierOwn = ''
351
+ if (useConfig) {
352
+ prettierOwn = present(PRETTIER_FILES)
353
+ if (!prettierOwn && readPackage(dir).prettier === undefined) prettierSet = true
354
+ else if (!prettierOwn) prettierOwn = 'package.json "prettier"'
355
+ }
356
+ // TypeScript is reported, never edited: tsconfig files are JSON with comments.
357
+ const tsconfig = join(dir, 'tsconfig.json')
358
+ const tsconfigNote =
359
+ useConfig &&
360
+ existsSync(tsconfig) &&
361
+ !readFileSync(tsconfig, 'utf8').includes(`${CONFIG_PACKAGE}/tsconfig/`)
362
+ ? `tsconfig.json does not extend ${CONFIG_PACKAGE}/tsconfig/*.json — add "extends" by hand`
363
+ : ''
364
+
365
+ const ciRange = `${CI_PACKAGE}@^${CI_MAJOR}.0.0`
366
+ info()
367
+ info(
368
+ `${bold('Onboard')} ${dim(`${repo || basename(dir)} ← ${CI_REPO}@${opts.ref} (runs-on: ${opts.runsOn})`)}`,
369
+ )
370
+ info()
371
+ info(` ${bold('files')} ${dim(`(branch ${opts.branch} from ${baseRef})`)}`)
372
+ for (const { action, rel, reason } of plan) {
373
+ const line = `${action.padEnd(10)} ${rel}${reason ? ` (${reason})` : ''}`
374
+ info(` ${action === 'keep' || action === 'skip' ? dim(line) : line}`)
375
+ }
376
+ if (gitignore) info(` ${'update'.padEnd(10)} .gitignore (ignore .github/secrets*.env)`)
377
+ if (prettierSet) {
378
+ info(` ${'update'.padEnd(10)} package.json "prettier": "${CONFIG_PACKAGE}/prettier"`)
379
+ }
380
+ if (prettierOwn) {
381
+ info(
382
+ ` ${dim(`keep ${prettierOwn} (own Prettier config — extend ${CONFIG_PACKAGE}/prettier there)`)}`,
383
+ )
384
+ }
385
+ if (tsconfigNote) info(` ${dim(`note ${tsconfigNote}`)}`)
386
+ if (isNode && opts.npm) {
387
+ info(` ${bold('npm')}`)
388
+ info(` install --save-dev ${ciRange} (and husky, if missing)`)
389
+ if (useConfig) {
390
+ info(
391
+ ` install --save-dev --save-exact ${CONFIG_PACKAGE} (and eslint, prettier, if missing)`,
392
+ )
393
+ }
394
+ }
395
+ if (opts.push) {
396
+ info(` ${bold('GitHub')} ${dim(`(${repo})`)}`)
397
+ if (!devExists) info(` create branch dev from ${baseRef}`)
398
+ info(' sync: variables, secrets, settings, rulesets')
399
+ info(` push ${opts.branch} → auto-pr.yml opens the PR into dev`)
400
+ } else {
401
+ info(` ${dim('--no-push: GitHub is left untouched')}`)
402
+ }
403
+ info()
404
+
405
+ if (opts.dryRun) {
406
+ ok('Dry run complete — nothing was changed.')
407
+ return 0
408
+ }
409
+
410
+ // ---- local changes ---------------------------------------------------------
411
+ git('switch', '--quiet', '--no-track', '-c', opts.branch, baseRef)
412
+
413
+ for (const { action, rel } of plan) {
414
+ if (action === 'create' || action === 'overwrite') {
415
+ render(rel, dir, opts)
416
+ ok(`${action} ${rel}`)
417
+ }
418
+ }
419
+
420
+ if (gitignore) {
421
+ const file = join(dir, '.gitignore')
422
+ let text = existsSync(file) ? readFileSync(file, 'utf8') : ''
423
+ if (text && !text.endsWith('\n')) text += '\n'
424
+ writeFileSync(file, `${text}\n${readFileSync(join(TEMPLATES_DIR, GITIGNORE_SNIPPET), 'utf8')}`)
425
+ ok('update .gitignore')
426
+ }
427
+
428
+ if (prettierSet && npm(['pkg', 'set', `prettier=${CONFIG_PACKAGE}/prettier`])) {
429
+ ok('set package.json "prettier"')
430
+ }
431
+
432
+ if (isNode && opts.npm) {
433
+ step('Installing devDependencies')
434
+ if (!npm(['install', '--save-dev', '--no-audit', '--no-fund', ciRange])) {
435
+ warn(
436
+ `Could not install ${CI_PACKAGE} (is it published?). The hook skips the local check until it is installed.`,
437
+ )
438
+ }
439
+ if (!hasDependency(readPackage(dir), 'husky')) {
440
+ if (!npm(['install', '--save-dev', '--no-audit', '--no-fund', 'husky'])) {
441
+ warn('Could not install husky.')
442
+ }
443
+ }
444
+ if (
445
+ readPackage(dir).scripts?.prepare === undefined &&
446
+ npm(['pkg', 'set', 'scripts.prepare=husky'])
447
+ ) {
448
+ ok('set scripts.prepare = husky')
449
+ }
450
+ if (useConfig) {
451
+ // Its peers are pinned by the repo, so add the ones it does not list yet.
452
+ const pkg = readPackage(dir)
453
+ const packages = [
454
+ CONFIG_PACKAGE,
455
+ ...['eslint', 'prettier'].filter(peer => !hasDependency(pkg, peer)),
456
+ ]
457
+ if (!npm(['install', '--save-dev', '--save-exact', '--no-audit', '--no-fund', ...packages])) {
458
+ warn(
459
+ `Could not install ${packages.join(' ')}. Install them before running Prettier or ESLint.`,
460
+ )
461
+ }
462
+ }
463
+ }
464
+
465
+ git('add', '-A')
466
+ if (git.ok('diff', '--cached', '--quiet')) {
467
+ warn('Nothing to commit — every file already exists. Use --force FILE to replace one.')
468
+ git('switch', '--quiet', '-')
469
+ git('branch', '--quiet', '-D', opts.branch)
470
+ return 0
471
+ }
472
+ git('commit', '--quiet', '-m', `ci: onboard ${CI_REPO} shared workflows and rules`)
473
+ ok(`Committed on ${opts.branch}: ${git('log', '-1', '--pretty=%s').trim()}`)
474
+
475
+ if (!opts.push) {
476
+ info()
477
+ ok(`${bold('Local onboarding done.')} Review with: git -C ${dir} show --stat`)
478
+ info('Then push it yourself, or re-run without --no-push after deleting the branch.')
479
+ return 0
480
+ }
481
+
482
+ // ---- GitHub ----------------------------------------------------------------
483
+ if (
484
+ !opts.yes &&
485
+ !(await confirm(`Apply the GitHub steps above to ${repo} and push ${opts.branch}?`))
486
+ ) {
487
+ throw new Error(`Stopped. The local commit on ${opts.branch} is kept.`)
488
+ }
489
+
490
+ if (!devExists) {
491
+ const { from, sha } = github.createBranchFromDefault(repo, 'dev')
492
+ ok(`created branch dev from ${from} (${sha.slice(0, 7)})`)
493
+ }
494
+
495
+ const syncArgs = ['--repo-dir', dir, '--repo', repo, ...(opts.yes ? ['--yes'] : [])]
496
+ if ((await sync(syncArgs)) !== 0) {
497
+ warn(`Sync did not finish; fix the cause and re-run: ebalzan-ci sync -C ${dir}`)
498
+ }
499
+
500
+ git('push', '--quiet', '-u', 'origin', opts.branch)
501
+ ok(`pushed ${opts.branch}`)
502
+
503
+ info()
504
+ ok(`${bold(`${repo} is onboarded.`)}`)
505
+ info(' 1. The PR into dev opens within a minute (auto-pr.yml). If it does not:')
506
+ info(` https://github.com/${repo}/compare/dev...${opts.branch}`)
507
+ info(" 2. Add the repo's own checks to .github/workflows/pr-checks.yml (it has a")
508
+ info(' commented example).')
509
+ if (tsconfigNote) info(` · ${tsconfigNote}`)
510
+ info(' 3. Squash-merge the PR: the dev → main release PR opens.')
511
+ return 0
512
+ }
513
+
514
+ export async function onboard(argv) {
515
+ try {
516
+ const opts = parseOnboardArgs(argv)
517
+ if (opts.help) {
518
+ console.log(HELP)
519
+ return 0
520
+ }
521
+ return await run(opts)
522
+ } catch (error) {
523
+ fail(error.message)
524
+ return 1
525
+ }
526
+ }