@bensandee/tooling 0.15.0 → 0.17.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/README.md +48 -24
- package/dist/bin.mjs +568 -544
- package/dist/docker-verify/index.mjs +1 -1
- package/dist/index.d.mts +0 -2
- package/package.json +3 -4
- package/CHANGELOG.md +0 -236
package/dist/bin.mjs
CHANGED
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
import { t as isExecSyncError } from "./exec-CC49vrkM.mjs";
|
|
3
3
|
import { defineCommand, runMain } from "citty";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
|
-
import { execSync } from "node:child_process";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
7
|
import JSON5 from "json5";
|
|
9
8
|
import { parse } from "jsonc-parser";
|
|
10
9
|
import { z } from "zod";
|
|
11
10
|
import { isMap, isSeq, parseDocument } from "yaml";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
12
|
import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
|
|
13
13
|
//#region src/types.ts
|
|
14
|
-
/** Default CI platform when not explicitly chosen. */
|
|
15
|
-
const DEFAULT_CI = "forgejo";
|
|
16
14
|
const LEGACY_TOOLS = [
|
|
17
15
|
"eslint",
|
|
18
16
|
"prettier",
|
|
@@ -141,10 +139,6 @@ function readPackageJson(targetDir) {
|
|
|
141
139
|
return;
|
|
142
140
|
}
|
|
143
141
|
}
|
|
144
|
-
/** Detect whether the project is a monorepo. */
|
|
145
|
-
function detectMonorepo(targetDir) {
|
|
146
|
-
return existsSync(path.join(targetDir, "pnpm-workspace.yaml"));
|
|
147
|
-
}
|
|
148
142
|
/** Detect project type from package.json signals. */
|
|
149
143
|
function detectProjectType(targetDir) {
|
|
150
144
|
const pkg = readPackageJson(targetDir);
|
|
@@ -185,6 +179,32 @@ function hasWebUIDeps(targetDir) {
|
|
|
185
179
|
if (!pkg) return false;
|
|
186
180
|
return packageHasWebUIDeps(pkg);
|
|
187
181
|
}
|
|
182
|
+
/** Detect CI platform from existing workflow directories. */
|
|
183
|
+
function detectCiPlatform(targetDir) {
|
|
184
|
+
if (existsSync(path.join(targetDir, ".forgejo", "workflows"))) return "forgejo";
|
|
185
|
+
if (existsSync(path.join(targetDir, ".github", "workflows"))) return "github";
|
|
186
|
+
return "none";
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Compute convention-based defaults for a project directory.
|
|
190
|
+
* These are the values the tool would use when .tooling.json says nothing.
|
|
191
|
+
*/
|
|
192
|
+
function computeDefaults(targetDir) {
|
|
193
|
+
const detected = detectProject(targetDir);
|
|
194
|
+
const isMonorepo = detected.hasPnpmWorkspace;
|
|
195
|
+
const hasPrettier = detected.legacyConfigs.some((l) => l.tool === "prettier");
|
|
196
|
+
return {
|
|
197
|
+
structure: isMonorepo ? "monorepo" : "single",
|
|
198
|
+
useEslintPlugin: true,
|
|
199
|
+
formatter: hasPrettier ? "prettier" : "oxfmt",
|
|
200
|
+
setupVitest: !isMonorepo && !detected.hasVitestConfig,
|
|
201
|
+
ci: detectCiPlatform(targetDir),
|
|
202
|
+
setupRenovate: true,
|
|
203
|
+
releaseStrategy: isMonorepo ? "changesets" : "simple",
|
|
204
|
+
projectType: isMonorepo ? "default" : detectProjectType(targetDir),
|
|
205
|
+
detectPackageTypes: true
|
|
206
|
+
};
|
|
207
|
+
}
|
|
188
208
|
/** List packages in a monorepo's packages/ directory. */
|
|
189
209
|
function getMonorepoPackages(targetDir) {
|
|
190
210
|
const packagesDir = path.join(targetDir, "packages");
|
|
@@ -210,175 +230,99 @@ function isCancelled(value) {
|
|
|
210
230
|
return p.isCancel(value);
|
|
211
231
|
}
|
|
212
232
|
async function runInitPrompts(targetDir, saved) {
|
|
213
|
-
p.intro("@bensandee/tooling repo:
|
|
233
|
+
p.intro("@bensandee/tooling repo:sync");
|
|
214
234
|
const existingPkg = readPackageJson(targetDir);
|
|
215
235
|
const detected = detectProject(targetDir);
|
|
236
|
+
const defaults = computeDefaults(targetDir);
|
|
216
237
|
const isExisting = detected.hasPackageJson;
|
|
238
|
+
const isFirstInit = !saved;
|
|
217
239
|
const name = existingPkg?.name ?? path.basename(targetDir);
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
p.cancel("Cancelled.");
|
|
240
|
-
process.exit(0);
|
|
241
|
-
}
|
|
242
|
-
const hasExistingPrettier = detected.legacyConfigs.some((l) => l.tool === "prettier");
|
|
243
|
-
const formatter = await p.select({
|
|
244
|
-
message: "Formatter",
|
|
245
|
-
initialValue: saved?.formatter ?? (hasExistingPrettier ? "prettier" : "oxfmt"),
|
|
246
|
-
options: [{
|
|
247
|
-
value: "oxfmt",
|
|
248
|
-
label: "oxfmt",
|
|
249
|
-
hint: "fast, Rust-based"
|
|
250
|
-
}, {
|
|
251
|
-
value: "prettier",
|
|
252
|
-
label: "Prettier"
|
|
253
|
-
}]
|
|
254
|
-
});
|
|
255
|
-
if (isCancelled(formatter)) {
|
|
256
|
-
p.cancel("Cancelled.");
|
|
257
|
-
process.exit(0);
|
|
258
|
-
}
|
|
259
|
-
const setupVitest = await p.confirm({
|
|
260
|
-
message: "Set up vitest with a starter test?",
|
|
261
|
-
initialValue: saved?.setupVitest ?? !isExisting
|
|
262
|
-
});
|
|
263
|
-
if (isCancelled(setupVitest)) {
|
|
264
|
-
p.cancel("Cancelled.");
|
|
265
|
-
process.exit(0);
|
|
266
|
-
}
|
|
267
|
-
const ci = await p.select({
|
|
268
|
-
message: "CI workflow",
|
|
269
|
-
initialValue: saved?.ci,
|
|
270
|
-
options: [
|
|
271
|
-
{
|
|
272
|
-
value: "forgejo",
|
|
273
|
-
label: "Forgejo Actions"
|
|
274
|
-
},
|
|
275
|
-
{
|
|
276
|
-
value: "github",
|
|
277
|
-
label: "GitHub Actions"
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
value: "none",
|
|
281
|
-
label: "None"
|
|
282
|
-
}
|
|
283
|
-
]
|
|
284
|
-
});
|
|
285
|
-
if (isCancelled(ci)) {
|
|
286
|
-
p.cancel("Cancelled.");
|
|
287
|
-
process.exit(0);
|
|
288
|
-
}
|
|
289
|
-
let setupRenovate = true;
|
|
290
|
-
if (ci === "github") {
|
|
291
|
-
const renovateAnswer = await p.confirm({
|
|
292
|
-
message: "Set up Renovate for automated dependency updates?",
|
|
293
|
-
initialValue: saved?.setupRenovate ?? true
|
|
240
|
+
const structure = saved?.structure ?? defaults.structure;
|
|
241
|
+
const useEslintPlugin = saved?.useEslintPlugin ?? defaults.useEslintPlugin;
|
|
242
|
+
let formatter = saved?.formatter ?? defaults.formatter;
|
|
243
|
+
const setupVitest = saved?.setupVitest ?? defaults.setupVitest;
|
|
244
|
+
let ci = saved?.ci ?? defaults.ci;
|
|
245
|
+
const setupRenovate = saved?.setupRenovate ?? defaults.setupRenovate;
|
|
246
|
+
let releaseStrategy = saved?.releaseStrategy ?? defaults.releaseStrategy;
|
|
247
|
+
const projectType = saved?.projectType ?? defaults.projectType;
|
|
248
|
+
const detectPackageTypes = saved?.detectPackageTypes ?? defaults.detectPackageTypes;
|
|
249
|
+
if (detected.legacyConfigs.some((l) => l.tool === "prettier") && isFirstInit) {
|
|
250
|
+
const formatterAnswer = await p.select({
|
|
251
|
+
message: "Existing Prettier config found. Keep Prettier or migrate to oxfmt?",
|
|
252
|
+
initialValue: "prettier",
|
|
253
|
+
options: [{
|
|
254
|
+
value: "prettier",
|
|
255
|
+
label: "Keep Prettier"
|
|
256
|
+
}, {
|
|
257
|
+
value: "oxfmt",
|
|
258
|
+
label: "Migrate to oxfmt",
|
|
259
|
+
hint: "fast, Rust-based"
|
|
260
|
+
}]
|
|
294
261
|
});
|
|
295
|
-
if (isCancelled(
|
|
262
|
+
if (isCancelled(formatterAnswer)) {
|
|
296
263
|
p.cancel("Cancelled.");
|
|
297
264
|
process.exit(0);
|
|
298
265
|
}
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
const releaseStrategy = await p.select({
|
|
302
|
-
message: "Release management",
|
|
303
|
-
initialValue: saved?.releaseStrategy ?? "none",
|
|
304
|
-
options: [
|
|
305
|
-
{
|
|
306
|
-
value: "none",
|
|
307
|
-
label: "None"
|
|
308
|
-
},
|
|
309
|
-
{
|
|
310
|
-
value: "release-it",
|
|
311
|
-
label: "release-it",
|
|
312
|
-
hint: "interactive, conventional commits"
|
|
313
|
-
},
|
|
314
|
-
{
|
|
315
|
-
value: "changesets",
|
|
316
|
-
label: "Changesets",
|
|
317
|
-
hint: "PR-based versioning"
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
value: "simple",
|
|
321
|
-
label: "Simple",
|
|
322
|
-
hint: "uses commit-and-tag-version internally"
|
|
323
|
-
}
|
|
324
|
-
]
|
|
325
|
-
});
|
|
326
|
-
if (isCancelled(releaseStrategy)) {
|
|
327
|
-
p.cancel("Cancelled.");
|
|
328
|
-
process.exit(0);
|
|
266
|
+
formatter = formatterAnswer;
|
|
329
267
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
268
|
+
const detectedCi = detectCiPlatform(targetDir);
|
|
269
|
+
if (isFirstInit && detectedCi === "none") {
|
|
270
|
+
const ciAnswer = await p.select({
|
|
271
|
+
message: "CI workflow",
|
|
272
|
+
initialValue: "forgejo",
|
|
273
|
+
options: [
|
|
274
|
+
{
|
|
275
|
+
value: "forgejo",
|
|
276
|
+
label: "Forgejo Actions"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
value: "github",
|
|
280
|
+
label: "GitHub Actions"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
value: "none",
|
|
284
|
+
label: "None"
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
});
|
|
288
|
+
if (isCancelled(ciAnswer)) {
|
|
289
|
+
p.cancel("Cancelled.");
|
|
290
|
+
process.exit(0);
|
|
349
291
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
292
|
+
ci = ciAnswer;
|
|
293
|
+
}
|
|
294
|
+
const hasExistingRelease = detected.hasReleaseItConfig || detected.hasSimpleReleaseConfig || detected.hasChangesetsConfig;
|
|
295
|
+
if (isFirstInit && !hasExistingRelease) {
|
|
296
|
+
const releaseAnswer = await p.select({
|
|
297
|
+
message: "Release management",
|
|
298
|
+
initialValue: defaults.releaseStrategy,
|
|
354
299
|
options: [
|
|
355
300
|
{
|
|
356
|
-
value: "
|
|
357
|
-
label: "
|
|
358
|
-
hint: "strictest base, no runtime assumptions"
|
|
301
|
+
value: "none",
|
|
302
|
+
label: "None"
|
|
359
303
|
},
|
|
360
304
|
{
|
|
361
|
-
value: "
|
|
362
|
-
label: "
|
|
363
|
-
hint: "
|
|
305
|
+
value: "release-it",
|
|
306
|
+
label: "release-it",
|
|
307
|
+
hint: "interactive, conventional commits"
|
|
364
308
|
},
|
|
365
309
|
{
|
|
366
|
-
value: "
|
|
367
|
-
label: "
|
|
368
|
-
hint: "
|
|
310
|
+
value: "changesets",
|
|
311
|
+
label: "Changesets",
|
|
312
|
+
hint: "PR-based versioning"
|
|
369
313
|
},
|
|
370
314
|
{
|
|
371
|
-
value: "
|
|
372
|
-
label: "
|
|
373
|
-
hint: "
|
|
315
|
+
value: "simple",
|
|
316
|
+
label: "Simple",
|
|
317
|
+
hint: "uses commit-and-tag-version internally"
|
|
374
318
|
}
|
|
375
319
|
]
|
|
376
320
|
});
|
|
377
|
-
if (isCancelled(
|
|
321
|
+
if (isCancelled(releaseAnswer)) {
|
|
378
322
|
p.cancel("Cancelled.");
|
|
379
323
|
process.exit(0);
|
|
380
324
|
}
|
|
381
|
-
|
|
325
|
+
releaseStrategy = releaseAnswer;
|
|
382
326
|
}
|
|
383
327
|
p.outro("Configuration complete!");
|
|
384
328
|
return {
|
|
@@ -393,7 +337,6 @@ async function runInitPrompts(targetDir, saved) {
|
|
|
393
337
|
releaseStrategy,
|
|
394
338
|
projectType,
|
|
395
339
|
detectPackageTypes,
|
|
396
|
-
setupDocker: saved?.setupDocker ?? false,
|
|
397
340
|
targetDir
|
|
398
341
|
};
|
|
399
342
|
}
|
|
@@ -401,19 +344,13 @@ async function runInitPrompts(targetDir, saved) {
|
|
|
401
344
|
function buildDefaultConfig(targetDir, flags) {
|
|
402
345
|
const existingPkg = readPackageJson(targetDir);
|
|
403
346
|
const detected = detectProject(targetDir);
|
|
347
|
+
const defaults = computeDefaults(targetDir);
|
|
404
348
|
return {
|
|
405
349
|
name: existingPkg?.name ?? path.basename(targetDir),
|
|
406
350
|
isNew: !detected.hasPackageJson,
|
|
407
|
-
|
|
408
|
-
useEslintPlugin: flags.eslintPlugin
|
|
409
|
-
|
|
410
|
-
setupVitest: !detected.hasVitestConfig,
|
|
411
|
-
ci: flags.noCi ? "none" : DEFAULT_CI,
|
|
412
|
-
setupRenovate: true,
|
|
413
|
-
releaseStrategy: detected.hasReleaseItConfig ? "release-it" : detected.hasSimpleReleaseConfig ? "simple" : detected.hasChangesetsConfig ? "changesets" : "none",
|
|
414
|
-
projectType: "default",
|
|
415
|
-
detectPackageTypes: true,
|
|
416
|
-
setupDocker: false,
|
|
351
|
+
...defaults,
|
|
352
|
+
...flags.eslintPlugin !== void 0 && { useEslintPlugin: flags.eslintPlugin },
|
|
353
|
+
...flags.noCi && { ci: "none" },
|
|
417
354
|
targetDir
|
|
418
355
|
};
|
|
419
356
|
}
|
|
@@ -512,6 +449,125 @@ function createDryRunContext(config) {
|
|
|
512
449
|
};
|
|
513
450
|
}
|
|
514
451
|
//#endregion
|
|
452
|
+
//#region src/utils/tooling-config.ts
|
|
453
|
+
const CONFIG_FILE = ".tooling.json";
|
|
454
|
+
const DeclarativeHealthCheckSchema = z.object({
|
|
455
|
+
name: z.string(),
|
|
456
|
+
url: z.string(),
|
|
457
|
+
status: z.number().int().optional()
|
|
458
|
+
});
|
|
459
|
+
const DockerVerifyConfigSchema = z.object({
|
|
460
|
+
composeFiles: z.array(z.string()).optional(),
|
|
461
|
+
envFile: z.string().optional(),
|
|
462
|
+
services: z.array(z.string()).optional(),
|
|
463
|
+
healthChecks: z.array(DeclarativeHealthCheckSchema).optional(),
|
|
464
|
+
buildCommand: z.string().optional(),
|
|
465
|
+
buildCwd: z.string().optional(),
|
|
466
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
467
|
+
pollIntervalMs: z.number().int().positive().optional()
|
|
468
|
+
});
|
|
469
|
+
const ToolingConfigSchema = z.object({
|
|
470
|
+
structure: z.enum(["single", "monorepo"]).optional(),
|
|
471
|
+
useEslintPlugin: z.boolean().optional(),
|
|
472
|
+
formatter: z.enum(["oxfmt", "prettier"]).optional(),
|
|
473
|
+
setupVitest: z.boolean().optional(),
|
|
474
|
+
ci: z.enum([
|
|
475
|
+
"github",
|
|
476
|
+
"forgejo",
|
|
477
|
+
"none"
|
|
478
|
+
]).optional(),
|
|
479
|
+
setupRenovate: z.boolean().optional(),
|
|
480
|
+
releaseStrategy: z.enum([
|
|
481
|
+
"release-it",
|
|
482
|
+
"simple",
|
|
483
|
+
"changesets",
|
|
484
|
+
"none"
|
|
485
|
+
]).optional(),
|
|
486
|
+
projectType: z.enum([
|
|
487
|
+
"default",
|
|
488
|
+
"node",
|
|
489
|
+
"react",
|
|
490
|
+
"library"
|
|
491
|
+
]).optional(),
|
|
492
|
+
detectPackageTypes: z.boolean().optional(),
|
|
493
|
+
setupDocker: z.boolean().optional(),
|
|
494
|
+
docker: z.record(z.string(), z.object({
|
|
495
|
+
dockerfile: z.string(),
|
|
496
|
+
context: z.string().default(".")
|
|
497
|
+
})).optional(),
|
|
498
|
+
dockerVerify: DockerVerifyConfigSchema.optional()
|
|
499
|
+
});
|
|
500
|
+
/** Load saved tooling config from the target directory. Returns undefined if missing or invalid. */
|
|
501
|
+
function loadToolingConfig(targetDir) {
|
|
502
|
+
const fullPath = path.join(targetDir, CONFIG_FILE);
|
|
503
|
+
if (!existsSync(fullPath)) return void 0;
|
|
504
|
+
try {
|
|
505
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
506
|
+
const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
|
|
507
|
+
return result.success ? result.data : void 0;
|
|
508
|
+
} catch {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Config fields that can be overridden in .tooling.json. */
|
|
513
|
+
const OVERRIDE_KEYS = [
|
|
514
|
+
"structure",
|
|
515
|
+
"useEslintPlugin",
|
|
516
|
+
"formatter",
|
|
517
|
+
"setupVitest",
|
|
518
|
+
"ci",
|
|
519
|
+
"setupRenovate",
|
|
520
|
+
"releaseStrategy",
|
|
521
|
+
"projectType",
|
|
522
|
+
"detectPackageTypes"
|
|
523
|
+
];
|
|
524
|
+
/** Keys that have no effect for monorepos (generators ignore them). */
|
|
525
|
+
const MONOREPO_IGNORED_KEYS = new Set(["setupVitest", "projectType"]);
|
|
526
|
+
/**
|
|
527
|
+
* Save only the fields that differ from detected defaults to .tooling.json.
|
|
528
|
+
* A fully conventional project produces `{}` (or a minimal set of overrides).
|
|
529
|
+
* Keys that have no effect for the current structure are omitted.
|
|
530
|
+
*/
|
|
531
|
+
function saveToolingConfig(ctx, config) {
|
|
532
|
+
const defaults = computeDefaults(config.targetDir);
|
|
533
|
+
const isMonorepo = config.structure === "monorepo";
|
|
534
|
+
const overrides = {};
|
|
535
|
+
for (const key of OVERRIDE_KEYS) {
|
|
536
|
+
if (isMonorepo && MONOREPO_IGNORED_KEYS.has(key)) continue;
|
|
537
|
+
if (config[key] !== defaults[key]) overrides[key] = config[key];
|
|
538
|
+
}
|
|
539
|
+
const content = JSON.stringify(overrides, null, 2) + "\n";
|
|
540
|
+
const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
|
|
541
|
+
if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
|
|
542
|
+
filePath: CONFIG_FILE,
|
|
543
|
+
action: "skipped",
|
|
544
|
+
description: "Already up to date"
|
|
545
|
+
};
|
|
546
|
+
ctx.write(CONFIG_FILE, content);
|
|
547
|
+
return {
|
|
548
|
+
filePath: CONFIG_FILE,
|
|
549
|
+
action: existing ? "updated" : "created",
|
|
550
|
+
description: "Saved tooling configuration"
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** Merge saved config over detected defaults. Saved values win when present. */
|
|
554
|
+
function mergeWithSavedConfig(detected, saved) {
|
|
555
|
+
return {
|
|
556
|
+
name: detected.name,
|
|
557
|
+
isNew: detected.isNew,
|
|
558
|
+
targetDir: detected.targetDir,
|
|
559
|
+
structure: saved.structure ?? detected.structure,
|
|
560
|
+
useEslintPlugin: saved.useEslintPlugin ?? detected.useEslintPlugin,
|
|
561
|
+
formatter: saved.formatter ?? detected.formatter,
|
|
562
|
+
setupVitest: saved.setupVitest ?? detected.setupVitest,
|
|
563
|
+
ci: saved.ci ?? detected.ci,
|
|
564
|
+
setupRenovate: saved.setupRenovate ?? detected.setupRenovate,
|
|
565
|
+
releaseStrategy: saved.releaseStrategy ?? detected.releaseStrategy,
|
|
566
|
+
projectType: saved.projectType ?? detected.projectType,
|
|
567
|
+
detectPackageTypes: saved.detectPackageTypes ?? detected.detectPackageTypes
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
//#endregion
|
|
515
571
|
//#region src/generators/package-json.ts
|
|
516
572
|
const STANDARD_SCRIPTS_SINGLE = {
|
|
517
573
|
build: "tsdown",
|
|
@@ -522,8 +578,8 @@ const STANDARD_SCRIPTS_SINGLE = {
|
|
|
522
578
|
knip: "knip",
|
|
523
579
|
check: "pnpm exec tooling checks:run",
|
|
524
580
|
"ci:check": "pnpm check",
|
|
525
|
-
"tooling:check": "pnpm exec tooling repo:check",
|
|
526
|
-
"tooling:
|
|
581
|
+
"tooling:check": "pnpm exec tooling repo:sync --check",
|
|
582
|
+
"tooling:sync": "pnpm exec tooling repo:sync"
|
|
527
583
|
};
|
|
528
584
|
const STANDARD_SCRIPTS_MONOREPO = {
|
|
529
585
|
build: "pnpm -r build",
|
|
@@ -533,16 +589,18 @@ const STANDARD_SCRIPTS_MONOREPO = {
|
|
|
533
589
|
knip: "knip",
|
|
534
590
|
check: "pnpm exec tooling checks:run",
|
|
535
591
|
"ci:check": "pnpm check",
|
|
536
|
-
"tooling:check": "pnpm exec tooling repo:check",
|
|
537
|
-
"tooling:
|
|
592
|
+
"tooling:check": "pnpm exec tooling repo:sync --check",
|
|
593
|
+
"tooling:sync": "pnpm exec tooling repo:sync"
|
|
538
594
|
};
|
|
539
595
|
/** Scripts that tooling owns — map from script name to keyword that must appear in the value. */
|
|
540
596
|
const MANAGED_SCRIPTS = {
|
|
541
597
|
check: "checks:run",
|
|
542
598
|
"ci:check": "pnpm check",
|
|
543
|
-
"tooling:check": "repo:check",
|
|
544
|
-
"tooling:
|
|
599
|
+
"tooling:check": "repo:sync --check",
|
|
600
|
+
"tooling:sync": "repo:sync"
|
|
545
601
|
};
|
|
602
|
+
/** Deprecated scripts to remove during migration. */
|
|
603
|
+
const DEPRECATED_SCRIPTS = ["tooling:init", "tooling:update"];
|
|
546
604
|
/** DevDeps that belong in every project (single repo) or per-package (monorepo). */
|
|
547
605
|
const PER_PACKAGE_DEV_DEPS = {
|
|
548
606
|
"@types/node": "25.3.2",
|
|
@@ -597,8 +655,8 @@ function addReleaseDeps(deps, config) {
|
|
|
597
655
|
function getAddedDevDepNames(config) {
|
|
598
656
|
const deps = { ...ROOT_DEV_DEPS };
|
|
599
657
|
if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
|
|
600
|
-
deps["@bensandee/config"] = "0.8.
|
|
601
|
-
deps["@bensandee/tooling"] = "0.
|
|
658
|
+
deps["@bensandee/config"] = "0.8.2";
|
|
659
|
+
deps["@bensandee/tooling"] = "0.17.0";
|
|
602
660
|
if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
|
|
603
661
|
if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
|
|
604
662
|
addReleaseDeps(deps, config);
|
|
@@ -618,8 +676,8 @@ async function generatePackageJson(ctx) {
|
|
|
618
676
|
if (ctx.config.releaseStrategy !== "none" && ctx.config.releaseStrategy !== "changesets") allScripts["trigger-release"] = "pnpm exec tooling release:trigger";
|
|
619
677
|
const devDeps = { ...ROOT_DEV_DEPS };
|
|
620
678
|
if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
|
|
621
|
-
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.
|
|
622
|
-
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.
|
|
679
|
+
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
|
|
680
|
+
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.17.0";
|
|
623
681
|
if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
|
|
624
682
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
|
|
625
683
|
if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
|
|
@@ -644,6 +702,10 @@ async function generatePackageJson(ctx) {
|
|
|
644
702
|
existingScripts[key] = value;
|
|
645
703
|
changes.push(`updated script: ${key}`);
|
|
646
704
|
}
|
|
705
|
+
for (const key of DEPRECATED_SCRIPTS) if (key in existingScripts) {
|
|
706
|
+
delete existingScripts[key];
|
|
707
|
+
changes.push(`removed deprecated script: ${key}`);
|
|
708
|
+
}
|
|
647
709
|
pkg.scripts = existingScripts;
|
|
648
710
|
const existingDevDeps = pkg.devDependencies ?? {};
|
|
649
711
|
for (const [key, value] of Object.entries(devDeps)) if (!(key in existingDevDeps)) {
|
|
@@ -688,175 +750,6 @@ async function generatePackageJson(ctx) {
|
|
|
688
750
|
};
|
|
689
751
|
}
|
|
690
752
|
//#endregion
|
|
691
|
-
//#region src/generators/migrate-prompt.ts
|
|
692
|
-
/**
|
|
693
|
-
* Generate a context-aware AI migration prompt based on what the CLI did.
|
|
694
|
-
* This prompt can be pasted into Claude Code (or similar) to finish the migration.
|
|
695
|
-
*/
|
|
696
|
-
function generateMigratePrompt(results, config, detected) {
|
|
697
|
-
const sections = [];
|
|
698
|
-
sections.push("# Migration Prompt");
|
|
699
|
-
sections.push("");
|
|
700
|
-
sections.push("The following prompt was generated by `@bensandee/tooling repo:init`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
|
|
701
|
-
sections.push("");
|
|
702
|
-
sections.push("> **Tip:** Before starting, run `/init` in Claude Code to generate a `CLAUDE.md` that gives the AI a complete picture of your repository's structure, conventions, and build commands.");
|
|
703
|
-
sections.push("");
|
|
704
|
-
sections.push("## What was changed");
|
|
705
|
-
sections.push("");
|
|
706
|
-
const created = results.filter((r) => r.action === "created");
|
|
707
|
-
const updated = results.filter((r) => r.action === "updated");
|
|
708
|
-
const skipped = results.filter((r) => r.action === "skipped");
|
|
709
|
-
const archived = results.filter((r) => r.action === "archived");
|
|
710
|
-
if (created.length > 0) {
|
|
711
|
-
sections.push("**Created:**");
|
|
712
|
-
for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
713
|
-
sections.push("");
|
|
714
|
-
}
|
|
715
|
-
if (updated.length > 0) {
|
|
716
|
-
sections.push("**Updated:**");
|
|
717
|
-
for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
718
|
-
sections.push("");
|
|
719
|
-
}
|
|
720
|
-
if (archived.length > 0) {
|
|
721
|
-
sections.push("**Archived:**");
|
|
722
|
-
for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
723
|
-
sections.push("");
|
|
724
|
-
}
|
|
725
|
-
if (skipped.length > 0) {
|
|
726
|
-
sections.push("**Skipped (review these):**");
|
|
727
|
-
for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
728
|
-
sections.push("");
|
|
729
|
-
}
|
|
730
|
-
sections.push("## Migration tasks");
|
|
731
|
-
sections.push("");
|
|
732
|
-
const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
|
|
733
|
-
if (legacyToRemove.length > 0) {
|
|
734
|
-
sections.push("### Remove legacy tooling");
|
|
735
|
-
sections.push("");
|
|
736
|
-
for (const legacy of legacyToRemove) {
|
|
737
|
-
const replacement = {
|
|
738
|
-
eslint: "oxlint",
|
|
739
|
-
prettier: "oxfmt",
|
|
740
|
-
jest: "vitest",
|
|
741
|
-
webpack: "tsdown",
|
|
742
|
-
rollup: "tsdown"
|
|
743
|
-
}[legacy.tool];
|
|
744
|
-
sections.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
|
|
745
|
-
sections.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
|
|
746
|
-
if (legacy.tool === "eslint") sections.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
|
|
747
|
-
if (legacy.tool === "jest") sections.push(" - Migrate any jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
|
|
748
|
-
}
|
|
749
|
-
sections.push("");
|
|
750
|
-
}
|
|
751
|
-
if (archived.length > 0) {
|
|
752
|
-
sections.push("### Review archived files");
|
|
753
|
-
sections.push("");
|
|
754
|
-
sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
|
|
755
|
-
sections.push("");
|
|
756
|
-
for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
|
|
757
|
-
sections.push("");
|
|
758
|
-
sections.push("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
|
|
759
|
-
sections.push("");
|
|
760
|
-
sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
|
|
761
|
-
sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
|
|
762
|
-
sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
|
|
763
|
-
sections.push("");
|
|
764
|
-
sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
|
|
765
|
-
sections.push("");
|
|
766
|
-
sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
|
|
767
|
-
sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
|
|
768
|
-
sections.push("");
|
|
769
|
-
}
|
|
770
|
-
const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
|
|
771
|
-
if (detected.hasLegacyOxlintJson) {
|
|
772
|
-
sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
|
|
773
|
-
sections.push("");
|
|
774
|
-
sections.push("A new `oxlint.config.ts` has been generated using `defineConfig` from the `oxlint` package. The existing `.oxlintrc.json` needs to be migrated:");
|
|
775
|
-
sections.push("");
|
|
776
|
-
sections.push("1. Read `.oxlintrc.json` and compare its `rules` against the rules provided by `@bensandee/config/oxlint/recommended` (check `node_modules/@bensandee/config`). Most standard rules are already included in the recommended config.");
|
|
777
|
-
sections.push("2. If there are any custom rules, overrides, settings, or `jsPlugins` not covered by the recommended config, add them to `oxlint.config.ts` alongside the `extends`.");
|
|
778
|
-
sections.push("3. Delete `.oxlintrc.json`.");
|
|
779
|
-
sections.push("4. Run `pnpm lint` to verify the new config works correctly.");
|
|
780
|
-
sections.push("");
|
|
781
|
-
} else if (oxlintWasSkipped && detected.hasOxlintConfig) {
|
|
782
|
-
sections.push("### Verify oxlint.config.ts includes recommended rules");
|
|
783
|
-
sections.push("");
|
|
784
|
-
sections.push("The existing `oxlint.config.ts` was kept as-is. Verify that it extends the recommended config from `@bensandee/config/oxlint`:");
|
|
785
|
-
sections.push("");
|
|
786
|
-
sections.push("1. Open `oxlint.config.ts` and check that it imports and extends `@bensandee/config/oxlint/recommended`.");
|
|
787
|
-
sections.push("2. The expected pattern is:");
|
|
788
|
-
sections.push(" ```ts");
|
|
789
|
-
sections.push(" import recommended from \"@bensandee/config/oxlint/recommended\";");
|
|
790
|
-
sections.push(" import { defineConfig } from \"oxlint\";");
|
|
791
|
-
sections.push("");
|
|
792
|
-
sections.push(" export default defineConfig({ extends: [recommended] });");
|
|
793
|
-
sections.push(" ```");
|
|
794
|
-
sections.push("3. If it uses a different pattern, update it to extend the recommended config while preserving any project-specific customizations.");
|
|
795
|
-
sections.push("4. Run `pnpm lint` to verify the config works correctly.");
|
|
796
|
-
sections.push("");
|
|
797
|
-
}
|
|
798
|
-
if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
|
|
799
|
-
sections.push("### Migrate to monorepo structure");
|
|
800
|
-
sections.push("");
|
|
801
|
-
sections.push("This project was converted from a single repo to a monorepo. Complete the migration:");
|
|
802
|
-
sections.push("");
|
|
803
|
-
sections.push("1. Move existing source into `packages/<name>/` (using the existing package name)");
|
|
804
|
-
sections.push("2. Split the root `package.json` into a root workspace manifest + package-level `package.json`");
|
|
805
|
-
sections.push("3. Move the existing `tsconfig.json` into the package and update the root tsconfig with project references");
|
|
806
|
-
sections.push("4. Create a package-level `tsdown.config.ts` in the new package");
|
|
807
|
-
sections.push("5. Update any import paths or build scripts affected by the move");
|
|
808
|
-
sections.push("");
|
|
809
|
-
}
|
|
810
|
-
const skippedConfigs = skipped.filter((r) => r.filePath !== "ci" && r.description !== "Not a monorepo");
|
|
811
|
-
if (skippedConfigs.length > 0) {
|
|
812
|
-
sections.push("### Review skipped files");
|
|
813
|
-
sections.push("");
|
|
814
|
-
sections.push("The following files were left unchanged. Review them for compatibility:");
|
|
815
|
-
sections.push("");
|
|
816
|
-
for (const r of skippedConfigs) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
817
|
-
sections.push("");
|
|
818
|
-
}
|
|
819
|
-
if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
|
|
820
|
-
sections.push("### Generate tests");
|
|
821
|
-
sections.push("");
|
|
822
|
-
sections.push("A starter test was created at `test/example.test.ts`. Now:");
|
|
823
|
-
sections.push("");
|
|
824
|
-
sections.push("1. Review the existing source code in `src/`");
|
|
825
|
-
sections.push("2. Create additional test files following the starter test's patterns (import style, describe/it structure)");
|
|
826
|
-
sections.push("3. Focus on edge cases and core business logic");
|
|
827
|
-
sections.push("4. Aim for meaningful coverage of exported functions and key code paths");
|
|
828
|
-
sections.push("");
|
|
829
|
-
}
|
|
830
|
-
sections.push("## Ground rules");
|
|
831
|
-
sections.push("");
|
|
832
|
-
sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) if they are needed to resolve errors.");
|
|
833
|
-
sections.push("");
|
|
834
|
-
sections.push("When resolving errors from the checklist below, prefer fixing the root cause over suppressing the issue. For example:");
|
|
835
|
-
sections.push("");
|
|
836
|
-
sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
|
|
837
|
-
sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
|
|
838
|
-
sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
|
|
839
|
-
sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
|
|
840
|
-
sections.push("");
|
|
841
|
-
sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
|
|
842
|
-
sections.push("");
|
|
843
|
-
sections.push("## Verification checklist");
|
|
844
|
-
sections.push("");
|
|
845
|
-
sections.push("Run each of these commands and fix any errors before moving on:");
|
|
846
|
-
sections.push("");
|
|
847
|
-
sections.push("1. `pnpm install`");
|
|
848
|
-
const updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
|
|
849
|
-
sections.push(`2. \`${updateCmd}\` — bump added dependencies to their latest versions`);
|
|
850
|
-
sections.push("3. `pnpm typecheck` — fix any type errors");
|
|
851
|
-
sections.push("4. `pnpm build` — fix any build errors");
|
|
852
|
-
sections.push("5. `pnpm test` — fix any test failures");
|
|
853
|
-
sections.push("6. `pnpm lint` — fix the code to satisfy lint rules");
|
|
854
|
-
sections.push("7. `pnpm knip` — remove unused exports, dependencies, and dead code");
|
|
855
|
-
sections.push("8. `pnpm format` — fix any formatting issues");
|
|
856
|
-
sections.push("");
|
|
857
|
-
return sections.join("\n");
|
|
858
|
-
}
|
|
859
|
-
//#endregion
|
|
860
753
|
//#region src/generators/tsconfig.ts
|
|
861
754
|
async function generateTsconfig(ctx) {
|
|
862
755
|
const filePath = "tsconfig.json";
|
|
@@ -1210,7 +1103,7 @@ async function generateTsdown(ctx) {
|
|
|
1210
1103
|
}
|
|
1211
1104
|
//#endregion
|
|
1212
1105
|
//#region src/generators/gitignore.ts
|
|
1213
|
-
/** Entries that every project should have — repo:check flags these as missing. */
|
|
1106
|
+
/** Entries that every project should have — repo:sync --check flags these as missing. */
|
|
1214
1107
|
const REQUIRED_ENTRIES = [
|
|
1215
1108
|
"node_modules/",
|
|
1216
1109
|
".pnpm-store/",
|
|
@@ -1220,7 +1113,7 @@ const REQUIRED_ENTRIES = [
|
|
|
1220
1113
|
".env.*",
|
|
1221
1114
|
"!.env.example"
|
|
1222
1115
|
];
|
|
1223
|
-
/** Tooling-specific entries added during init/update but not required for repo:check. */
|
|
1116
|
+
/** Tooling-specific entries added during init/update but not required for repo:sync --check. */
|
|
1224
1117
|
const OPTIONAL_ENTRIES = [".tooling-migrate.md", ".tooling-archived/"];
|
|
1225
1118
|
const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
|
|
1226
1119
|
/** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
|
|
@@ -2575,15 +2468,34 @@ function requiredDeploySteps() {
|
|
|
2575
2468
|
}
|
|
2576
2469
|
];
|
|
2577
2470
|
}
|
|
2471
|
+
/** Convention paths to check for Dockerfiles. */
|
|
2472
|
+
const CONVENTION_DOCKERFILE_PATHS$1 = ["Dockerfile", "docker/Dockerfile"];
|
|
2473
|
+
const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
|
|
2474
|
+
/** Check whether any Docker packages exist by convention or .tooling.json config. */
|
|
2475
|
+
function hasDockerPackages(ctx) {
|
|
2476
|
+
const configRaw = ctx.read(".tooling.json");
|
|
2477
|
+
if (configRaw) {
|
|
2478
|
+
const result = DockerMapSchema.safeParse(JSON.parse(configRaw));
|
|
2479
|
+
if (result.success && result.data.docker && Object.keys(result.data.docker).length > 0) return true;
|
|
2480
|
+
}
|
|
2481
|
+
if (ctx.config.structure === "monorepo") {
|
|
2482
|
+
const packages = getMonorepoPackages(ctx.targetDir);
|
|
2483
|
+
for (const pkg of packages) {
|
|
2484
|
+
const dirName = pkg.name.split("/").pop() ?? pkg.name;
|
|
2485
|
+
for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(`packages/${dirName}/${rel}`)) return true;
|
|
2486
|
+
}
|
|
2487
|
+
} else for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(rel)) return true;
|
|
2488
|
+
return false;
|
|
2489
|
+
}
|
|
2578
2490
|
async function generateDeployCi(ctx) {
|
|
2579
2491
|
const filePath = "deploy-ci";
|
|
2580
|
-
if (!ctx
|
|
2492
|
+
if (!hasDockerPackages(ctx) || ctx.config.ci === "none") return {
|
|
2581
2493
|
filePath,
|
|
2582
2494
|
action: "skipped",
|
|
2583
2495
|
description: "Deploy CI workflow not applicable"
|
|
2584
2496
|
};
|
|
2585
2497
|
const isGitHub = ctx.config.ci === "github";
|
|
2586
|
-
const workflowPath = isGitHub ? ".github/workflows/
|
|
2498
|
+
const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
|
|
2587
2499
|
const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
|
|
2588
2500
|
const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
|
|
2589
2501
|
if (ctx.exists(workflowPath)) {
|
|
@@ -2662,142 +2574,180 @@ async function runGenerators(ctx) {
|
|
|
2662
2574
|
results.push(await generateDeployCi(ctx));
|
|
2663
2575
|
results.push(...await generateVitest(ctx));
|
|
2664
2576
|
results.push(...await generateVscodeSettings(ctx));
|
|
2577
|
+
results.push(saveToolingConfig(ctx, ctx.config));
|
|
2665
2578
|
return results;
|
|
2666
2579
|
}
|
|
2667
2580
|
//#endregion
|
|
2668
|
-
//#region src/
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
"
|
|
2689
|
-
|
|
2690
|
-
"
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
});
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2581
|
+
//#region src/generators/migrate-prompt.ts
|
|
2582
|
+
/**
|
|
2583
|
+
* Generate a context-aware AI migration prompt based on what the CLI did.
|
|
2584
|
+
* This prompt can be pasted into Claude Code (or similar) to finish the migration.
|
|
2585
|
+
*/
|
|
2586
|
+
function generateMigratePrompt(results, config, detected) {
|
|
2587
|
+
const sections = [];
|
|
2588
|
+
sections.push("# Migration Prompt");
|
|
2589
|
+
sections.push("");
|
|
2590
|
+
sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
|
|
2591
|
+
sections.push("");
|
|
2592
|
+
sections.push("> **Tip:** Before starting, run `/init` in Claude Code to generate a `CLAUDE.md` that gives the AI a complete picture of your repository's structure, conventions, and build commands.");
|
|
2593
|
+
sections.push("");
|
|
2594
|
+
sections.push("## What was changed");
|
|
2595
|
+
sections.push("");
|
|
2596
|
+
const created = results.filter((r) => r.action === "created");
|
|
2597
|
+
const updated = results.filter((r) => r.action === "updated");
|
|
2598
|
+
const skipped = results.filter((r) => r.action === "skipped");
|
|
2599
|
+
const archived = results.filter((r) => r.action === "archived");
|
|
2600
|
+
if (created.length > 0) {
|
|
2601
|
+
sections.push("**Created:**");
|
|
2602
|
+
for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
2603
|
+
sections.push("");
|
|
2604
|
+
}
|
|
2605
|
+
if (updated.length > 0) {
|
|
2606
|
+
sections.push("**Updated:**");
|
|
2607
|
+
for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
2608
|
+
sections.push("");
|
|
2609
|
+
}
|
|
2610
|
+
if (archived.length > 0) {
|
|
2611
|
+
sections.push("**Archived:**");
|
|
2612
|
+
for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
2613
|
+
sections.push("");
|
|
2614
|
+
}
|
|
2615
|
+
if (skipped.length > 0) {
|
|
2616
|
+
sections.push("**Skipped (review these):**");
|
|
2617
|
+
for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
2618
|
+
sections.push("");
|
|
2619
|
+
}
|
|
2620
|
+
sections.push("## Migration tasks");
|
|
2621
|
+
sections.push("");
|
|
2622
|
+
const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
|
|
2623
|
+
if (legacyToRemove.length > 0) {
|
|
2624
|
+
sections.push("### Remove legacy tooling");
|
|
2625
|
+
sections.push("");
|
|
2626
|
+
for (const legacy of legacyToRemove) {
|
|
2627
|
+
const replacement = {
|
|
2628
|
+
eslint: "oxlint",
|
|
2629
|
+
prettier: "oxfmt",
|
|
2630
|
+
jest: "vitest",
|
|
2631
|
+
webpack: "tsdown",
|
|
2632
|
+
rollup: "tsdown"
|
|
2633
|
+
}[legacy.tool];
|
|
2634
|
+
sections.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
|
|
2635
|
+
sections.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
|
|
2636
|
+
if (legacy.tool === "eslint") sections.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
|
|
2637
|
+
if (legacy.tool === "jest") sections.push(" - Migrate any jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
|
|
2638
|
+
}
|
|
2639
|
+
sections.push("");
|
|
2710
2640
|
}
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2641
|
+
if (archived.length > 0) {
|
|
2642
|
+
sections.push("### Review archived files");
|
|
2643
|
+
sections.push("");
|
|
2644
|
+
sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
|
|
2645
|
+
sections.push("");
|
|
2646
|
+
for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
|
|
2647
|
+
sections.push("");
|
|
2648
|
+
sections.push("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
|
|
2649
|
+
sections.push("");
|
|
2650
|
+
sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
|
|
2651
|
+
sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
|
|
2652
|
+
sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
|
|
2653
|
+
sections.push("");
|
|
2654
|
+
sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
|
|
2655
|
+
sections.push("");
|
|
2656
|
+
sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
|
|
2657
|
+
sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
|
|
2658
|
+
sections.push("");
|
|
2659
|
+
}
|
|
2660
|
+
const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
|
|
2661
|
+
if (detected.hasLegacyOxlintJson) {
|
|
2662
|
+
sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
|
|
2663
|
+
sections.push("");
|
|
2664
|
+
sections.push("A new `oxlint.config.ts` has been generated using `defineConfig` from the `oxlint` package. The existing `.oxlintrc.json` needs to be migrated:");
|
|
2665
|
+
sections.push("");
|
|
2666
|
+
sections.push("1. Read `.oxlintrc.json` and compare its `rules` against the rules provided by `@bensandee/config/oxlint/recommended` (check `node_modules/@bensandee/config`). Most standard rules are already included in the recommended config.");
|
|
2667
|
+
sections.push("2. If there are any custom rules, overrides, settings, or `jsPlugins` not covered by the recommended config, add them to `oxlint.config.ts` alongside the `extends`.");
|
|
2668
|
+
sections.push("3. Delete `.oxlintrc.json`.");
|
|
2669
|
+
sections.push("4. Run `pnpm lint` to verify the new config works correctly.");
|
|
2670
|
+
sections.push("");
|
|
2671
|
+
} else if (oxlintWasSkipped && detected.hasOxlintConfig) {
|
|
2672
|
+
sections.push("### Verify oxlint.config.ts includes recommended rules");
|
|
2673
|
+
sections.push("");
|
|
2674
|
+
sections.push("The existing `oxlint.config.ts` was kept as-is. Verify that it extends the recommended config from `@bensandee/config/oxlint`:");
|
|
2675
|
+
sections.push("");
|
|
2676
|
+
sections.push("1. Open `oxlint.config.ts` and check that it imports and extends `@bensandee/config/oxlint/recommended`.");
|
|
2677
|
+
sections.push("2. The expected pattern is:");
|
|
2678
|
+
sections.push(" ```ts");
|
|
2679
|
+
sections.push(" import recommended from \"@bensandee/config/oxlint/recommended\";");
|
|
2680
|
+
sections.push(" import { defineConfig } from \"oxlint\";");
|
|
2681
|
+
sections.push("");
|
|
2682
|
+
sections.push(" export default defineConfig({ extends: [recommended] });");
|
|
2683
|
+
sections.push(" ```");
|
|
2684
|
+
sections.push("3. If it uses a different pattern, update it to extend the recommended config while preserving any project-specific customizations.");
|
|
2685
|
+
sections.push("4. Run `pnpm lint` to verify the config works correctly.");
|
|
2686
|
+
sections.push("");
|
|
2687
|
+
}
|
|
2688
|
+
if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
|
|
2689
|
+
sections.push("### Migrate to monorepo structure");
|
|
2690
|
+
sections.push("");
|
|
2691
|
+
sections.push("This project was converted from a single repo to a monorepo. Complete the migration:");
|
|
2692
|
+
sections.push("");
|
|
2693
|
+
sections.push("1. Move existing source into `packages/<name>/` (using the existing package name)");
|
|
2694
|
+
sections.push("2. Split the root `package.json` into a root workspace manifest + package-level `package.json`");
|
|
2695
|
+
sections.push("3. Move the existing `tsconfig.json` into the package and update the root tsconfig with project references");
|
|
2696
|
+
sections.push("4. Create a package-level `tsdown.config.ts` in the new package");
|
|
2697
|
+
sections.push("5. Update any import paths or build scripts affected by the move");
|
|
2698
|
+
sections.push("");
|
|
2699
|
+
}
|
|
2700
|
+
const skippedConfigs = skipped.filter((r) => r.filePath !== "ci" && r.description !== "Not a monorepo");
|
|
2701
|
+
if (skippedConfigs.length > 0) {
|
|
2702
|
+
sections.push("### Review skipped files");
|
|
2703
|
+
sections.push("");
|
|
2704
|
+
sections.push("The following files were left unchanged. Review them for compatibility:");
|
|
2705
|
+
sections.push("");
|
|
2706
|
+
for (const r of skippedConfigs) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
2707
|
+
sections.push("");
|
|
2708
|
+
}
|
|
2709
|
+
if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
|
|
2710
|
+
sections.push("### Generate tests");
|
|
2711
|
+
sections.push("");
|
|
2712
|
+
sections.push("A starter test was created at `test/example.test.ts`. Now:");
|
|
2713
|
+
sections.push("");
|
|
2714
|
+
sections.push("1. Review the existing source code in `src/`");
|
|
2715
|
+
sections.push("2. Create additional test files following the starter test's patterns (import style, describe/it structure)");
|
|
2716
|
+
sections.push("3. Focus on edge cases and core business logic");
|
|
2717
|
+
sections.push("4. Aim for meaningful coverage of exported functions and key code paths");
|
|
2718
|
+
sections.push("");
|
|
2719
|
+
}
|
|
2720
|
+
sections.push("## Ground rules");
|
|
2721
|
+
sections.push("");
|
|
2722
|
+
sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) if they are needed to resolve errors.");
|
|
2723
|
+
sections.push("");
|
|
2724
|
+
sections.push("When resolving errors from the checklist below, prefer fixing the root cause over suppressing the issue. For example:");
|
|
2725
|
+
sections.push("");
|
|
2726
|
+
sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
|
|
2727
|
+
sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
|
|
2728
|
+
sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
|
|
2729
|
+
sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
|
|
2730
|
+
sections.push("");
|
|
2731
|
+
sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
|
|
2732
|
+
sections.push("");
|
|
2733
|
+
sections.push("## Verification checklist");
|
|
2734
|
+
sections.push("");
|
|
2735
|
+
sections.push("Run each of these commands and fix any errors before moving on:");
|
|
2736
|
+
sections.push("");
|
|
2737
|
+
sections.push("1. `pnpm install`");
|
|
2738
|
+
const updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
|
|
2739
|
+
sections.push(`2. \`${updateCmd}\` — bump added dependencies to their latest versions`);
|
|
2740
|
+
sections.push("3. `pnpm typecheck` — fix any type errors");
|
|
2741
|
+
sections.push("4. `pnpm build` — fix any build errors");
|
|
2742
|
+
sections.push("5. `pnpm test` — fix any test failures");
|
|
2743
|
+
sections.push("6. `pnpm lint` — fix the code to satisfy lint rules");
|
|
2744
|
+
sections.push("7. `pnpm knip` — remove unused exports, dependencies, and dead code");
|
|
2745
|
+
sections.push("8. `pnpm format` — fix any formatting issues");
|
|
2746
|
+
sections.push("");
|
|
2747
|
+
return sections.join("\n");
|
|
2757
2748
|
}
|
|
2758
2749
|
//#endregion
|
|
2759
2750
|
//#region src/commands/repo-init.ts
|
|
2760
|
-
const initCommand = defineCommand({
|
|
2761
|
-
meta: {
|
|
2762
|
-
name: "repo:init",
|
|
2763
|
-
description: "Interactive setup wizard"
|
|
2764
|
-
},
|
|
2765
|
-
args: {
|
|
2766
|
-
dir: {
|
|
2767
|
-
type: "positional",
|
|
2768
|
-
description: "Target directory (default: current directory)",
|
|
2769
|
-
required: false
|
|
2770
|
-
},
|
|
2771
|
-
yes: {
|
|
2772
|
-
type: "boolean",
|
|
2773
|
-
alias: "y",
|
|
2774
|
-
description: "Accept all defaults (non-interactive)"
|
|
2775
|
-
},
|
|
2776
|
-
"eslint-plugin": {
|
|
2777
|
-
type: "boolean",
|
|
2778
|
-
description: "Include @bensandee/eslint-plugin (default: true)"
|
|
2779
|
-
},
|
|
2780
|
-
"no-ci": {
|
|
2781
|
-
type: "boolean",
|
|
2782
|
-
description: "Skip CI workflow generation"
|
|
2783
|
-
},
|
|
2784
|
-
"no-prompt": {
|
|
2785
|
-
type: "boolean",
|
|
2786
|
-
description: "Skip migration prompt generation"
|
|
2787
|
-
}
|
|
2788
|
-
},
|
|
2789
|
-
async run({ args }) {
|
|
2790
|
-
const targetDir = path.resolve(args.dir ?? ".");
|
|
2791
|
-
const saved = loadToolingConfig(targetDir);
|
|
2792
|
-
await runInit(args.yes ? (() => {
|
|
2793
|
-
const detected = buildDefaultConfig(targetDir, {
|
|
2794
|
-
eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
|
|
2795
|
-
noCi: args["no-ci"] === true ? true : void 0
|
|
2796
|
-
});
|
|
2797
|
-
return saved ? mergeWithSavedConfig(detected, saved) : detected;
|
|
2798
|
-
})() : await runInitPrompts(targetDir, saved), args["no-prompt"] === true ? { noPrompt: true } : {});
|
|
2799
|
-
}
|
|
2800
|
-
});
|
|
2801
2751
|
async function runInit(config, options = {}) {
|
|
2802
2752
|
const detected = detectProject(config.targetDir);
|
|
2803
2753
|
const s = p.spinner();
|
|
@@ -2819,7 +2769,6 @@ async function runInit(config, options = {}) {
|
|
|
2819
2769
|
}));
|
|
2820
2770
|
s.start("Generating configuration files...");
|
|
2821
2771
|
const results = await runGenerators(ctx);
|
|
2822
|
-
results.push(saveToolingConfig(ctx, config));
|
|
2823
2772
|
const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
|
|
2824
2773
|
for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
|
|
2825
2774
|
filePath: rel,
|
|
@@ -2828,7 +2777,6 @@ async function runInit(config, options = {}) {
|
|
|
2828
2777
|
});
|
|
2829
2778
|
const created = results.filter((r) => r.action === "created");
|
|
2830
2779
|
const updated = results.filter((r) => r.action === "updated");
|
|
2831
|
-
const skipped = results.filter((r) => r.action === "skipped");
|
|
2832
2780
|
const archived = results.filter((r) => r.action === "archived");
|
|
2833
2781
|
if (!(created.length > 0 || updated.length > 0 || archived.length > 0) && options.noPrompt) {
|
|
2834
2782
|
s.stop("Repository is up to date.");
|
|
@@ -2845,7 +2793,6 @@ async function runInit(config, options = {}) {
|
|
|
2845
2793
|
if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
|
|
2846
2794
|
if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
|
|
2847
2795
|
if (archived.length > 0) summaryLines.push(`Archived: ${archived.map((r) => r.filePath).join(", ")}`);
|
|
2848
|
-
if (skipped.length > 0) summaryLines.push(`Skipped: ${skipped.map((r) => r.filePath).join(", ")}`);
|
|
2849
2796
|
p.note(summaryLines.join("\n"), "Summary");
|
|
2850
2797
|
if (!options.noPrompt) {
|
|
2851
2798
|
const prompt = generateMigratePrompt(results, config, detected);
|
|
@@ -2878,57 +2825,68 @@ async function runInit(config, options = {}) {
|
|
|
2878
2825
|
return results;
|
|
2879
2826
|
}
|
|
2880
2827
|
//#endregion
|
|
2881
|
-
//#region src/commands/repo-
|
|
2882
|
-
const
|
|
2828
|
+
//#region src/commands/repo-sync.ts
|
|
2829
|
+
const syncCommand = defineCommand({
|
|
2883
2830
|
meta: {
|
|
2884
|
-
name: "repo:
|
|
2885
|
-
description: "
|
|
2831
|
+
name: "repo:sync",
|
|
2832
|
+
description: "Detect, generate, and sync project tooling (idempotent)"
|
|
2886
2833
|
},
|
|
2887
|
-
args: {
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2834
|
+
args: {
|
|
2835
|
+
dir: {
|
|
2836
|
+
type: "positional",
|
|
2837
|
+
description: "Target directory (default: current directory)",
|
|
2838
|
+
required: false
|
|
2839
|
+
},
|
|
2840
|
+
check: {
|
|
2841
|
+
type: "boolean",
|
|
2842
|
+
description: "Dry-run mode: report drift without writing files"
|
|
2843
|
+
},
|
|
2844
|
+
yes: {
|
|
2845
|
+
type: "boolean",
|
|
2846
|
+
alias: "y",
|
|
2847
|
+
description: "Accept all defaults (non-interactive)"
|
|
2848
|
+
},
|
|
2849
|
+
"eslint-plugin": {
|
|
2850
|
+
type: "boolean",
|
|
2851
|
+
description: "Include @bensandee/eslint-plugin (default: true)"
|
|
2852
|
+
},
|
|
2853
|
+
"no-ci": {
|
|
2854
|
+
type: "boolean",
|
|
2855
|
+
description: "Skip CI workflow generation"
|
|
2856
|
+
},
|
|
2857
|
+
"no-prompt": {
|
|
2858
|
+
type: "boolean",
|
|
2859
|
+
description: "Skip migration prompt generation"
|
|
2860
|
+
}
|
|
2914
2861
|
},
|
|
2915
|
-
args: { dir: {
|
|
2916
|
-
type: "positional",
|
|
2917
|
-
description: "Target directory (default: current directory)",
|
|
2918
|
-
required: false
|
|
2919
|
-
} },
|
|
2920
2862
|
async run({ args }) {
|
|
2921
|
-
const
|
|
2922
|
-
|
|
2863
|
+
const targetDir = path.resolve(args.dir ?? ".");
|
|
2864
|
+
if (args.check) {
|
|
2865
|
+
const exitCode = await runCheck(targetDir);
|
|
2866
|
+
process.exitCode = exitCode;
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
const saved = loadToolingConfig(targetDir);
|
|
2870
|
+
const isFirstRun = !saved;
|
|
2871
|
+
let config;
|
|
2872
|
+
if (args.yes || !isFirstRun) {
|
|
2873
|
+
const detected = buildDefaultConfig(targetDir, {
|
|
2874
|
+
eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
|
|
2875
|
+
noCi: args["no-ci"] === true ? true : void 0
|
|
2876
|
+
});
|
|
2877
|
+
config = saved ? mergeWithSavedConfig(detected, saved) : detected;
|
|
2878
|
+
} else config = await runInitPrompts(targetDir, saved);
|
|
2879
|
+
await runInit(config, {
|
|
2880
|
+
noPrompt: args["no-prompt"] === true || !isFirstRun,
|
|
2881
|
+
...!isFirstRun && { confirmOverwrite: async () => "overwrite" }
|
|
2882
|
+
});
|
|
2923
2883
|
}
|
|
2924
2884
|
});
|
|
2885
|
+
/** Run sync in check mode: dry-run drift detection. */
|
|
2925
2886
|
async function runCheck(targetDir) {
|
|
2926
2887
|
const saved = loadToolingConfig(targetDir);
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
return 1;
|
|
2930
|
-
}
|
|
2931
|
-
const { ctx, pendingWrites } = createDryRunContext(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved));
|
|
2888
|
+
const detected = buildDefaultConfig(targetDir, {});
|
|
2889
|
+
const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
|
|
2932
2890
|
const actionable = (await runGenerators(ctx)).filter((r) => {
|
|
2933
2891
|
if (r.action !== "created" && r.action !== "updated") return false;
|
|
2934
2892
|
const newContent = pendingWrites.get(r.filePath);
|
|
@@ -2943,7 +2901,7 @@ async function runCheck(targetDir) {
|
|
|
2943
2901
|
p.log.success("Repository is up to date.");
|
|
2944
2902
|
return 0;
|
|
2945
2903
|
}
|
|
2946
|
-
p.log.warn(`${actionable.length} file(s) would be changed by repo:
|
|
2904
|
+
p.log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
|
|
2947
2905
|
for (const r of actionable) {
|
|
2948
2906
|
p.log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
|
|
2949
2907
|
const newContent = pendingWrites.get(r.filePath);
|
|
@@ -2955,13 +2913,12 @@ async function runCheck(targetDir) {
|
|
|
2955
2913
|
p.log.info(` + ${lineCount} new lines`);
|
|
2956
2914
|
} else {
|
|
2957
2915
|
const diff = lineDiff(existing, newContent);
|
|
2958
|
-
|
|
2916
|
+
for (const line of diff) p.log.info(` ${line}`);
|
|
2959
2917
|
}
|
|
2960
2918
|
}
|
|
2961
2919
|
return 1;
|
|
2962
2920
|
}
|
|
2963
2921
|
const normalize = (line) => line.trimEnd();
|
|
2964
|
-
/** Produce a compact line-level diff summary, ignoring whitespace-only differences. */
|
|
2965
2922
|
function lineDiff(oldText, newText) {
|
|
2966
2923
|
const oldLines = oldText.split("\n").map(normalize);
|
|
2967
2924
|
const newLines = newText.split("\n").map(normalize);
|
|
@@ -3038,6 +2995,14 @@ function createRealExecutor() {
|
|
|
3038
2995
|
} catch (_error) {}
|
|
3039
2996
|
return packages;
|
|
3040
2997
|
},
|
|
2998
|
+
listPackageDirs(cwd) {
|
|
2999
|
+
const packagesDir = path.join(cwd, "packages");
|
|
3000
|
+
try {
|
|
3001
|
+
return readdirSync(packagesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
3002
|
+
} catch {
|
|
3003
|
+
return [];
|
|
3004
|
+
}
|
|
3005
|
+
},
|
|
3041
3006
|
readFile(filePath) {
|
|
3042
3007
|
try {
|
|
3043
3008
|
return readFileSync(filePath, "utf-8");
|
|
@@ -3986,34 +3951,95 @@ function readPackageInfo(executor, packageJsonPath) {
|
|
|
3986
3951
|
};
|
|
3987
3952
|
}
|
|
3988
3953
|
}
|
|
3954
|
+
/** Convention paths to check for Dockerfiles in a package directory. */
|
|
3955
|
+
const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
|
|
3956
|
+
/**
|
|
3957
|
+
* Find a Dockerfile at convention paths for a monorepo package.
|
|
3958
|
+
* Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
|
|
3959
|
+
*/
|
|
3960
|
+
function findConventionDockerfile(executor, cwd, dir) {
|
|
3961
|
+
for (const rel of CONVENTION_DOCKERFILE_PATHS) {
|
|
3962
|
+
const dockerfilePath = `packages/${dir}/${rel}`;
|
|
3963
|
+
if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
|
|
3964
|
+
dockerfile: dockerfilePath,
|
|
3965
|
+
context: "."
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3989
3969
|
/**
|
|
3990
|
-
*
|
|
3991
|
-
*
|
|
3970
|
+
* Find a Dockerfile at convention paths for a single-package repo.
|
|
3971
|
+
* Checks Dockerfile and docker/Dockerfile at the project root.
|
|
3972
|
+
*/
|
|
3973
|
+
function findRootDockerfile(executor, cwd) {
|
|
3974
|
+
for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
|
|
3975
|
+
dockerfile: rel,
|
|
3976
|
+
context: "."
|
|
3977
|
+
};
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* Discover Docker packages by convention and merge with .tooling.json overrides.
|
|
3981
|
+
*
|
|
3982
|
+
* Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
|
|
3983
|
+
* For monorepos, scans packages/{name}/. For single-package repos, scans the root.
|
|
3984
|
+
* The docker map in .tooling.json overrides convention-discovered config and can add
|
|
3985
|
+
* packages at non-standard locations.
|
|
3986
|
+
*
|
|
3992
3987
|
* Image names are derived from {root-name}-{package-name} using each package's package.json name.
|
|
3993
3988
|
* Versions are read from each package's own package.json.
|
|
3994
3989
|
*/
|
|
3995
3990
|
function detectDockerPackages(executor, cwd, repoName) {
|
|
3996
|
-
const
|
|
3991
|
+
const overrides = loadDockerMap(executor, cwd);
|
|
3992
|
+
const packageDirs = executor.listPackageDirs(cwd);
|
|
3997
3993
|
const packages = [];
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
dir
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
3994
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3995
|
+
if (packageDirs.length > 0) {
|
|
3996
|
+
for (const dir of packageDirs) {
|
|
3997
|
+
const convention = findConventionDockerfile(executor, cwd, dir);
|
|
3998
|
+
const docker = overrides[dir] ?? convention;
|
|
3999
|
+
if (docker) {
|
|
4000
|
+
const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
|
|
4001
|
+
packages.push({
|
|
4002
|
+
dir,
|
|
4003
|
+
imageName: `${repoName}-${name ?? dir}`,
|
|
4004
|
+
version,
|
|
4005
|
+
docker
|
|
4006
|
+
});
|
|
4007
|
+
seen.add(dir);
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
|
|
4011
|
+
const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
|
|
4012
|
+
packages.push({
|
|
4013
|
+
dir,
|
|
4014
|
+
imageName: `${repoName}-${name ?? dir}`,
|
|
4015
|
+
version,
|
|
4016
|
+
docker
|
|
4017
|
+
});
|
|
4018
|
+
}
|
|
4019
|
+
} else {
|
|
4020
|
+
const convention = findRootDockerfile(executor, cwd);
|
|
4021
|
+
const docker = overrides["."] ?? convention;
|
|
4022
|
+
if (docker) {
|
|
4023
|
+
const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
|
|
4024
|
+
packages.push({
|
|
4025
|
+
dir: ".",
|
|
4026
|
+
imageName: name ?? repoName,
|
|
4027
|
+
version,
|
|
4028
|
+
docker
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4006
4031
|
}
|
|
4007
4032
|
return packages;
|
|
4008
4033
|
}
|
|
4009
4034
|
/**
|
|
4010
|
-
* Read docker config for a single package
|
|
4011
|
-
* Used by the per-package image:build script.
|
|
4035
|
+
* Read docker config for a single package, checking convention paths first,
|
|
4036
|
+
* then .tooling.json overrides. Used by the per-package image:build script.
|
|
4012
4037
|
*/
|
|
4013
4038
|
function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
|
|
4014
4039
|
const dir = path.basename(path.resolve(cwd, packageDir));
|
|
4015
|
-
const
|
|
4016
|
-
|
|
4040
|
+
const convention = findConventionDockerfile(executor, cwd, dir);
|
|
4041
|
+
const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
|
|
4042
|
+
if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
|
|
4017
4043
|
const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
|
|
4018
4044
|
return {
|
|
4019
4045
|
dir,
|
|
@@ -4235,13 +4261,11 @@ const dockerBuildCommand = defineCommand({
|
|
|
4235
4261
|
const main = defineCommand({
|
|
4236
4262
|
meta: {
|
|
4237
4263
|
name: "tooling",
|
|
4238
|
-
version: "0.
|
|
4264
|
+
version: "0.17.0",
|
|
4239
4265
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
4240
4266
|
},
|
|
4241
4267
|
subCommands: {
|
|
4242
|
-
"repo:
|
|
4243
|
-
"repo:update": updateCommand,
|
|
4244
|
-
"repo:check": checkCommand,
|
|
4268
|
+
"repo:sync": syncCommand,
|
|
4245
4269
|
"checks:run": runChecksCommand,
|
|
4246
4270
|
"release:changesets": releaseForgejoCommand,
|
|
4247
4271
|
"release:trigger": releaseTriggerCommand,
|
|
@@ -4252,7 +4276,7 @@ const main = defineCommand({
|
|
|
4252
4276
|
"docker:build": dockerBuildCommand
|
|
4253
4277
|
}
|
|
4254
4278
|
});
|
|
4255
|
-
console.log(`@bensandee/tooling v0.
|
|
4279
|
+
console.log(`@bensandee/tooling v0.17.0`);
|
|
4256
4280
|
runMain(main);
|
|
4257
4281
|
//#endregion
|
|
4258
4282
|
export {};
|