@rtorcato/repo-tooling 3.31.1 → 3.31.2

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.
@@ -11,6 +11,7 @@ import fs from 'fs-extra';
11
11
  import { realGitExec } from '../../base/git-identity.js';
12
12
  import { getPackageRoot } from '../utils/copy-preset.js';
13
13
  import { shellQuote } from '../utils/shell.js';
14
+ import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
14
15
  /**
15
16
  * Skills this package owns the content of and keeps up to date. The loop first —
16
17
  * it is the pipeline; the other three are its drivers (burst, on-ramp, status).
@@ -115,47 +116,6 @@ export function classifySkillContent(installed, shipped) {
115
116
  return 'unknown';
116
117
  return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
117
118
  }
118
- function versionParts(version) {
119
- return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
120
- }
121
- /** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
122
- export function isNewerVersion(a, b) {
123
- const [left, right] = [versionParts(a), versionParts(b)];
124
- for (let i = 0; i < 3; i++) {
125
- const [x, y] = [left[i] ?? 0, right[i] ?? 0];
126
- if (x !== y)
127
- return x > y;
128
- }
129
- return false;
130
- }
131
- /**
132
- * The version to stamp, given what `package.json` claims.
133
- *
134
- * In a published tarball that field is authoritative — `@semantic-release/npm`
135
- * rewrites it before packing. In a *git checkout* it is not: this repo runs
136
- * semantic-release without `@semantic-release/git` (#417), so nothing ever
137
- * writes the released version back and the field sits at whatever it was last
138
- * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
139
- * ten minor versions of drift.
140
- *
141
- * That mattered because the stamp feeds the downgrade guard below: a skill
142
- * installed from npm could not be updated from a local checkout, and the
143
- * refusal claimed the installed copy was "newer" when only its *label* was.
144
- *
145
- * So when the package root is a git checkout, take the nearest tag as well and
146
- * keep whichever is higher. Monotonic on purpose — this can only ever raise the
147
- * answer, so a tagless, shallow, or git-less environment keeps today's
148
- * behaviour rather than silently stamping something lower.
149
- */
150
- export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
151
- if (!(await fs.pathExists(path.join(root, '.git'))))
152
- return pkgVersion;
153
- const described = await git(['describe', '--tags', '--abbrev=0'], root);
154
- const tag = described?.trim().replace(/^v/, '');
155
- if (!tag || !/^\d+\.\d+/.test(tag))
156
- return pkgVersion;
157
- return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
158
- }
159
119
  /** The skill source and the package version that will be stamped into it. */
160
120
  export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
161
121
  const root = getPackageRoot();
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import fs from 'fs-extra';
2
3
  import path from 'node:path';
3
4
  import { PNPM_FALLBACK_VERSION, detectPnpmVersion } from './misc.js';
@@ -8,6 +9,8 @@ export async function generatePackageJson(config, targetDir) {
8
9
  existingPackageJson = await fs.readJson(packageJsonPath);
9
10
  }
10
11
  const includeTreeshake = Boolean(config.treeshakeCheck && config.projectType === 'library');
12
+ const generatedScripts = getScripts(config, { includeTreeshake });
13
+ const existingScripts = existingPackageJson?.scripts ?? {};
11
14
  const packageJson = {
12
15
  name: config.projectName,
13
16
  version: '0.1.0',
@@ -17,10 +20,7 @@ export async function generatePackageJson(config, targetDir) {
17
20
  // generated workflows carry no `version:` input (#364).
18
21
  packageManager: `pnpm@${detectPnpmVersion() ?? PNPM_FALLBACK_VERSION}`,
19
22
  ...existingPackageJson,
20
- scripts: {
21
- ...getScripts(config, { includeTreeshake }),
22
- ...existingPackageJson?.scripts,
23
- },
23
+ scripts: resolveScripts(config, generatedScripts, existingScripts),
24
24
  dependencies: {
25
25
  ...existingPackageJson?.dependencies,
26
26
  },
@@ -66,6 +66,30 @@ export async function generatePackageJson(config, targetDir) {
66
66
  // (see ensureBuildApprovals in build.ts).
67
67
  await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
68
68
  }
69
+ /**
70
+ * Merge the generated scripts with what the package.json already had. Existing
71
+ * values win — that merge-don't-clobber policy is what makes re-running `setup`
72
+ * on a live repo safe.
73
+ *
74
+ * `build` on a library is the one exception. The library branch of
75
+ * generatePackageJson overwrites the whole publish contract
76
+ * (main/module/types/exports) unconditionally, and that contract names files
77
+ * only the preset's bundler emits: a preserved `build: tsc` produces
78
+ * dist/index.js + dist/index.d.ts and never the ./dist/index.cjs that `main`
79
+ * and every `require` condition point at, so `pnpm build && npm publish` ships
80
+ * a package whose CJS entry points 404 (#570). Owning the contract means owning
81
+ * its producer; the replacement is announced rather than silent.
82
+ */
83
+ function resolveScripts(config, generated, existing) {
84
+ const scripts = { ...generated, ...existing };
85
+ if (config.projectType !== 'library' || !generated.build)
86
+ return scripts;
87
+ if (existing.build && existing.build !== generated.build) {
88
+ console.warn(chalk.yellow(`⚠️ Replaced "build": "${existing.build}" with "${generated.build}" — the library exports map names files only ${config.bundler} emits.`));
89
+ }
90
+ scripts.build = generated.build;
91
+ return scripts;
92
+ }
69
93
  function getScripts(config, opts = {}) {
70
94
  const scripts = {};
71
95
  // TypeScript scripts
package/dist/cli/index.js CHANGED
@@ -3,12 +3,12 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { Command } from 'commander';
5
5
  import fs from 'fs-extra';
6
- import packageJson from '../../package.json' with { type: 'json' };
7
6
  import { doctorCommand } from './commands/doctor.js';
8
7
  import { fixCommand } from './commands/fix.js';
9
8
  import { loopGuardCommand } from './commands/loop-guard.js';
10
9
  import { setupProject } from './commands/setup.js';
11
10
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
11
+ import { getToolVersion } from './utils/version.js';
12
12
  async function isSelfRepo(dir) {
13
13
  try {
14
14
  const pkg = await fs.readJson(path.join(dir, 'package.json'));
@@ -22,7 +22,7 @@ const program = new Command();
22
22
  program
23
23
  .name('@rtorcato/repo-tooling')
24
24
  .description('🛠️ JavaScript and TypeScript tooling setup for modern projects')
25
- .version(packageJson.version);
25
+ .version(await getToolVersion());
26
26
  program
27
27
  .command('setup')
28
28
  .alias('init')
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
- import packageJson from '../../../package.json' with { type: 'json' };
4
3
  import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
5
4
  import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
5
+ import { getToolVersion } from './version.js';
6
6
  export const LOCKFILE_NAME = '.repo-tooling.json';
7
7
  // Package and bin name used before the js-tooling→repo-tooling rename (#272).
8
8
  // The bin no longer exists and the package is 404 on the registry, so any
@@ -243,7 +243,7 @@ export async function writeLockfile(dir, config, assets) {
243
243
  record: {
244
244
  config,
245
245
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
246
- writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
246
+ writtenBy: `@rtorcato/repo-tooling@${await getToolVersion()}`,
247
247
  writtenAt: new Date().toISOString(),
248
248
  },
249
249
  ...(existing?.rules ? { rules: existing.rules } : {}),
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one answer to "what version of this package is running" (#572).
3
+ *
4
+ * There were two. `--version` and the lockfile's `writtenBy` read the imported
5
+ * `package.json` directly; the Claude-skills stamp went through
6
+ * `resolveShippedVersion`, which corrects for the drift documented below. A
7
+ * single `doctor` run could report both — the skills check saying 3.31.0 while
8
+ * `writtenBy` in the same run said 3.11.0. Everything now calls
9
+ * `getToolVersion()`.
10
+ */
11
+ import path from 'node:path';
12
+ import fs from 'fs-extra';
13
+ import packageJson from '../../../package.json' with { type: 'json' };
14
+ import { realGitExec } from '../../base/git-identity.js';
15
+ import { getPackageRoot } from './copy-preset.js';
16
+ function versionParts(version) {
17
+ return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
18
+ }
19
+ /** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
20
+ export function isNewerVersion(a, b) {
21
+ const [left, right] = [versionParts(a), versionParts(b)];
22
+ for (let i = 0; i < 3; i++) {
23
+ const [x, y] = [left[i] ?? 0, right[i] ?? 0];
24
+ if (x !== y)
25
+ return x > y;
26
+ }
27
+ return false;
28
+ }
29
+ /**
30
+ * The version to report, given what `package.json` claims.
31
+ *
32
+ * In a published tarball that field is authoritative — `@semantic-release/npm`
33
+ * rewrites it before packing. In a *git checkout* it is not: this repo runs
34
+ * semantic-release without `@semantic-release/git` (#417), so nothing ever
35
+ * writes the released version back and the field sits at whatever it was last
36
+ * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
37
+ * ten minor versions of drift.
38
+ *
39
+ * That mattered first because the stamp feeds the skills downgrade guard: a
40
+ * skill installed from npm could not be updated from a local checkout, and the
41
+ * refusal claimed the installed copy was "newer" when only its *label* was.
42
+ *
43
+ * So when the package root is a git checkout, take the nearest tag as well and
44
+ * keep whichever is higher. Monotonic on purpose — this can only ever raise the
45
+ * answer, so a tagless, shallow, or git-less environment keeps today's
46
+ * behaviour rather than silently reporting something lower.
47
+ */
48
+ export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
49
+ if (!(await fs.pathExists(path.join(root, '.git'))))
50
+ return pkgVersion;
51
+ const described = await git(['describe', '--tags', '--abbrev=0'], root);
52
+ const tag = described?.trim().replace(/^v/, '');
53
+ if (!tag || !/^\d+\.\d+/.test(tag))
54
+ return pkgVersion;
55
+ return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
56
+ }
57
+ let cached;
58
+ /**
59
+ * This package's version, for `--version`, `writtenBy`, and the skill stamps.
60
+ *
61
+ * Memoized: in a checkout `resolveShippedVersion` spawns `git describe`, and
62
+ * nothing about the answer changes mid-process. An npm install has no `.git`
63
+ * under the package root, so that path costs one `pathExists` and no subprocess.
64
+ */
65
+ export function getToolVersion() {
66
+ cached ??= resolveShippedVersion(getPackageRoot(), String(packageJson.version));
67
+ return cached;
68
+ }
@@ -18,7 +18,7 @@ import { generateEditorConfig } from '../../cli/generators/misc.js';
18
18
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../../cli/generators/security.js';
19
19
  import { copyPreset } from '../../cli/utils/copy-preset.js';
20
20
  import { LANGUAGES } from '../registry.js';
21
- import { readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow } from './ci.js';
21
+ import { parsePackageSwift, readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow, } from './ci.js';
22
22
  import { SWIFT_HOOKS_DIR, installSwiftGitHooks } from './git-hooks.js';
23
23
  import { ensureSwiftGitignore } from './gitignore.js';
24
24
  /**
@@ -196,6 +196,10 @@ git tag 1.0.0 && git push origin 1.0.0
196
196
  /**
197
197
  * Relative paths `setup --dry-run` reports for a Swift config, in write order.
198
198
  * Kept beside the writer so the two can't drift.
199
+ *
200
+ * This is the greenfield list. Against a directory that already has a
201
+ * Package.swift the writer preserves it and skips the target scaffolding, so the
202
+ * preview over-reports there — the safe direction for a dry run.
199
203
  */
200
204
  export function swiftFileList(config) {
201
205
  const module = swiftModuleName(config.projectName);
@@ -225,12 +229,29 @@ export function swiftFileList(config) {
225
229
  files.push('README.md');
226
230
  return files;
227
231
  }
228
- /** Scaffold a SwiftPM library. The Swift arm of `generateConfigs`. */
232
+ /**
233
+ * Scaffold a SwiftPM library. The Swift arm of `generateConfigs`.
234
+ *
235
+ * Package.swift is source, not config (#574): rewriting one that already exists
236
+ * renames the package after the *directory* and leaves the real `Sources/<Target>/`
237
+ * declared by no target — and `swift build` still exits 0, because SwiftPM just
238
+ * ignores the orphaned directory. So an existing manifest is left byte-identical
239
+ * and the module name is read back off it rather than derived from the directory,
240
+ * the same way the JS presets merge into a package.json instead of replacing it.
241
+ */
229
242
  export async function generateSwiftProject(config, targetDir) {
230
- const module = swiftModuleName(config.projectName);
231
- await fs.writeFile(path.join(targetDir, 'Package.swift'), packageSwift(module));
232
- await fs.outputFile(path.join(targetDir, 'Sources', module, `${module}.swift`), sourceFile(module));
233
- await fs.outputFile(path.join(targetDir, 'Tests', `${module}Tests`, `${module}Tests.swift`), testFile(module));
243
+ const manifestPath = path.join(targetDir, 'Package.swift');
244
+ const existingManifest = (await fs.pathExists(manifestPath))
245
+ ? parsePackageSwift(await fs.readFile(manifestPath, 'utf-8'))
246
+ : null;
247
+ const module = existingManifest?.products[0] ?? swiftModuleName(config.projectName);
248
+ // Nothing is scaffolded under Sources/ or Tests/ either: those would be
249
+ // directories for a target the existing manifest doesn't declare.
250
+ if (!existingManifest) {
251
+ await fs.writeFile(manifestPath, packageSwift(module));
252
+ await fs.outputFile(path.join(targetDir, 'Sources', module, `${module}.swift`), sourceFile(module));
253
+ await fs.outputFile(path.join(targetDir, 'Tests', `${module}Tests`, `${module}Tests.swift`), testFile(module));
254
+ }
234
255
  await copyPreset('swiftlint', targetDir);
235
256
  await copyPreset('periphery', targetDir);
236
257
  await ensureSwiftGitignore(targetDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.31.1",
3
+ "version": "3.31.2",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [