@langri-sha/projen-project 0.32.1 → 0.34.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/src/index.ts CHANGED
@@ -1,45 +1,6 @@
1
1
  import { createRequire } from 'node:module'
2
2
  import * as path from 'node:path'
3
3
 
4
- import { Babel, BabelOptions } from '@langri-sha/projen-babel'
5
- import { Beachball, BeachballOptions } from '@langri-sha/projen-beachball'
6
- import {
7
- type CargoOptions,
8
- CargoPackage,
9
- CargoWorkspace,
10
- } from '@langri-sha/projen-cargo'
11
- import {
12
- Codeowners,
13
- type CodeownersOptions,
14
- } from '@langri-sha/projen-codeowners'
15
- import { Dagger, type DaggerOptions } from '@langri-sha/projen-dagger'
16
- import {
17
- EditorConfig,
18
- type EditorConfigOptions,
19
- } from '@langri-sha/projen-editorconfig'
20
- import { ESLint, ESLintOptions } from '@langri-sha/projen-eslint'
21
- import { Husky, type HuskyOptions } from '@langri-sha/projen-husky'
22
- import { JestConfig, JestConfigOptions } from '@langri-sha/projen-jest-config'
23
- import { License } from '@langri-sha/projen-license'
24
- import { LintStaged, LintStagedOptions } from '@langri-sha/projen-lint-staged'
25
- import {
26
- LintSynthesized,
27
- type LintSynthesizedOptions,
28
- } from '@langri-sha/projen-lint-synthesized'
29
- import {
30
- PnpmWorkspace,
31
- PnpmWorkspaceOptions,
32
- } from '@langri-sha/projen-pnpm-workspace'
33
- import { Prettier, PrettierOptions } from '@langri-sha/projen-prettier'
34
- import { ReadmeFile, type ReadmeFileOptions } from '@langri-sha/projen-readme'
35
- import { Renovate, type RenovateOptions } from '@langri-sha/projen-renovate'
36
- import { SWCConfig, type SWCConfigOptions } from '@langri-sha/projen-swcrc'
37
- import {
38
- TypeScriptConfig,
39
- type TypeScriptConfigOptions,
40
- } from '@langri-sha/projen-typescript-config'
41
- import { type UvOptions, UvPackage, UvWorkspace } from '@langri-sha/projen-uv'
42
- import { Worktrunk, type WorktrunkOptions } from '@langri-sha/projen-worktrunk'
43
4
  import {
44
5
  Project as BaseProject,
45
6
  type ProjectOptions as BaseProjectOptions,
@@ -49,13 +10,41 @@ import {
49
10
  IgnoreFileOptions,
50
11
  javascript,
51
12
  } from 'projen'
13
+ import { Babel, BabelOptions } from 'projen-babel-config'
14
+ import { Beachball, BeachballOptions } from 'projen-beachball'
15
+ import { type CargoOptions, CargoPackage, CargoWorkspace } from 'projen-cargo'
16
+ import { Codeowners, type CodeownersOptions } from 'projen-codeowners'
17
+ import { Dagger, type DaggerOptions } from 'projen-dagger'
18
+ import { EditorConfig, type EditorConfigOptions } from 'projen-editorconfig'
19
+ import { ESLint, ESLintOptions } from 'projen-eslint'
20
+ import { Husky, type HuskyOptions } from 'projen-husky'
21
+ import { JestConfig, JestConfigOptions } from 'projen-jest-config'
22
+ import { License } from 'projen-license'
23
+ import { LintStaged, LintStagedOptions } from 'projen-lint-staged'
24
+ import {
25
+ LintSynthesized,
26
+ type LintSynthesizedOptions,
27
+ } from 'projen-lint-synthesized'
28
+ import { PnpmWorkspace, PnpmWorkspaceOptions } from 'projen-pnpm-workspace'
29
+ import { Prettier, PrettierOptions } from 'projen-prettier'
30
+ import { ReadmeFile, type ReadmeFileOptions } from 'projen-readme'
31
+ import { Renovate, type RenovateOptions } from 'projen-renovate'
32
+ import { Ruff, type RuffOptions } from 'projen-ruff'
33
+ import { SWCConfig, type SWCConfigOptions } from 'projen-swcrc'
34
+ import { type SupportedPythonVersion, Ty, type TyOptions } from 'projen-ty'
35
+ import {
36
+ TypeScriptConfig,
37
+ type TypeScriptConfigOptions,
38
+ } from 'projen-typescript-config'
39
+ import { type UvOptions, UvPackage, UvWorkspace } from 'projen-uv'
40
+ import { Worktrunk, type WorktrunkOptions, pipeline } from 'projen-worktrunk'
52
41
  import * as R from 'ramda'
53
42
  import { satisfies, valid } from 'semver'
54
43
 
55
44
  import { GitAttributesFile } from './lib/gitattributes.js'
56
45
  import { NodePackage, NodePackageOptions, ProjenrcFile } from './lib/index.js'
57
46
 
58
- export * from '@langri-sha/projen-typescript-config'
47
+ export * from 'projen-typescript-config'
59
48
 
60
49
  /**
61
50
  * The package manager assumed when a project does not name one.
@@ -77,6 +66,38 @@ const PROJEN_COMMANDS: Record<javascript.NodePackageManager, string> = {
77
66
  [javascript.NodePackageManager.YARN_CLASSIC]: 'yarn projen',
78
67
  }
79
68
 
69
+ /**
70
+ * The name of the command that installs dependencies in a worktree hook, per
71
+ * package manager. The binary rather than the enum value, so that every yarn
72
+ * version is `yarn`.
73
+ */
74
+ const PACKAGE_MANAGER_BINARIES: Record<javascript.NodePackageManager, string> =
75
+ {
76
+ [javascript.NodePackageManager.BUN]: 'bun',
77
+ [javascript.NodePackageManager.NPM]: 'npm',
78
+ [javascript.NodePackageManager.PNPM]: 'pnpm',
79
+ [javascript.NodePackageManager.YARN]: 'yarn',
80
+ [javascript.NodePackageManager.YARN2]: 'yarn',
81
+ [javascript.NodePackageManager.YARN_BERRY]: 'yarn',
82
+ [javascript.NodePackageManager.YARN_CLASSIC]: 'yarn',
83
+ }
84
+
85
+ /**
86
+ * The `sync` command of the default `pre-start` pipeline. Worktrunk fails on
87
+ * undefined variables, and leaves `remote` undefined in a repository with no
88
+ * remote and `base` undefined for a worktree on an existing branch, so the
89
+ * command is skipped there. Worktrunk approves a command by its exact text, so
90
+ * editing it asks every teammate to approve it again.
91
+ */
92
+ const WORKTRUNK_SYNC_COMMAND =
93
+ '{% if remote and base and base == default_branch %}git fetch {{ remote }} {{ default_branch }} && git merge --ff-only --quiet {{ remote }}/{{ default_branch }}{% endif %}'
94
+
95
+ /**
96
+ * Copies each tracked `.env.example` to a `.env` that is missing.
97
+ */
98
+ const WORKTRUNK_ENV_COMMAND =
99
+ 'git ls-files -- ":(glob)**/.env.example" | while read -r example; do target=${example%.example}; [ -e "$target" ] || cp "$example" "$target"; done'
100
+
80
101
  /**
81
102
  * How long a release must have been published before Renovate proposes it.
82
103
  *
@@ -204,11 +225,30 @@ export interface ProjectOptions extends Omit<
204
225
  */
205
226
  renovate?: RenovateOptions
206
227
 
228
+ /**
229
+ * Pass in to configure Ruff. Root projects only: Ruff takes the nearest
230
+ * configuration, so one at the root covers the whole workspace.
231
+ *
232
+ * With `lintStaged`, staged Python files are fixed and formatted on commit,
233
+ * through `uv run --frozen` when `uv` is set, so ruff comes from `uv.lock`:
234
+ * declare it in the workspace's `dev` group, or `uv run` falls back to
235
+ * whatever ruff is on the `PATH`.
236
+ */
237
+ ruff?: RuffOptions
238
+
207
239
  /*
208
240
  * Pass in to configure SWC.
209
241
  */
210
242
  swcrc?: SWCConfigOptions
211
243
 
244
+ /**
245
+ * Pass in to configure ty. Root projects only, like `ruff`.
246
+ *
247
+ * With `uv.pythonVersion` set, `environment.python-version` follows it; set
248
+ * it to `null` to leave the version to ty.
249
+ */
250
+ ty?: TyOptions
251
+
212
252
  /**
213
253
  * TypeScript configuration options.
214
254
  */
@@ -226,8 +266,8 @@ export interface ProjectOptions extends Omit<
226
266
  withTerraform?: boolean
227
267
 
228
268
  /**
229
- * Configures Worktrunk, when provided. No hooks are supplied by default:
230
- * each one runs on teammates' machines, so a project declares its own.
269
+ * Configures Worktrunk, when provided. Supplies a default `pre-start`
270
+ * pipeline, which a `pre-start` in `config`, in any form, replaces whole.
231
271
  */
232
272
  worktrunk?: WorktrunkOptions
233
273
  }
@@ -252,7 +292,9 @@ export class Project extends BaseProject {
252
292
  projenrc?: ProjenrcFile
253
293
  readme?: ReadmeFile
254
294
  renovate?: Renovate
295
+ ruff?: Ruff
255
296
  swcrc?: SWCConfig
297
+ ty?: Ty
256
298
  typeScriptConfig?: TypeScriptConfig
257
299
  uv?: UvPackage | UvWorkspace
258
300
  worktrunk?: Worktrunk
@@ -319,6 +361,8 @@ export class Project extends BaseProject {
319
361
  this.#configureNpmIgnore(options)
320
362
  this.#configurePnpmWorkspace(options)
321
363
  this.#configureReadme(options)
364
+ this.#configureRuff(options)
365
+ this.#configureTy(options)
322
366
  this.#configureUv(options)
323
367
  this.#configureWorktrunk(options)
324
368
  this.#configureRenovate(options)
@@ -599,6 +643,7 @@ export class Project extends BaseProject {
599
643
 
600
644
  #configureEditorConfig({
601
645
  editorConfig: editorConfigOptions,
646
+ uv,
602
647
  }: ProjectOptions) {
603
648
  if (!editorConfigOptions || this.parent) {
604
649
  return
@@ -616,11 +661,19 @@ export class Project extends BaseProject {
616
661
  Dockerfile: {
617
662
  indent_style: 'tab',
618
663
  },
664
+ // What ruff formats Python to. It ignores EditorConfig, so editors
665
+ // indenting at anything else are corrected on every format.
666
+ ...(uv && {
667
+ '*.{py,pyi}': {
668
+ indent_style: 'space',
669
+ indent_size: 4,
670
+ },
671
+ }),
619
672
  }
620
673
 
621
674
  this.editorConfig = new EditorConfig(
622
675
  this,
623
- deepMerge(editorConfigOptions ?? {}, defaults),
676
+ deepMerge(defaults, editorConfigOptions),
624
677
  )
625
678
 
626
679
  this.prettier?.ignore.addPatterns('!.editorconfig')
@@ -704,7 +757,7 @@ export class Project extends BaseProject {
704
757
  })
705
758
  }
706
759
 
707
- #configureLintStaged({ lintStaged, package: pkg }: ProjectOptions) {
760
+ #configureLintStaged({ lintStaged, package: pkg, ruff, uv }: ProjectOptions) {
708
761
  if (!lintStaged) {
709
762
  return
710
763
  }
@@ -721,7 +774,34 @@ export class Project extends BaseProject {
721
774
  this.#addDefaultDevDeps('lint-staged@17.6.0')
722
775
  }
723
776
 
724
- this.lintStaged = new LintStaged(this, deepMerge(defaults, lintStaged))
777
+ // ruff is configured at the root alone, but lint-staged runs the nearest
778
+ // configuration to each staged file, so a subproject's runs ruff as well.
779
+ const root = this.root instanceof Project ? this.root : undefined
780
+ const withRuff = this.parent ? root?.ruff : ruff
781
+ const withUv = uv ?? (this.parent ? root?.uv : undefined)
782
+
783
+ // ruff fixes before it formats, so that the formatter has the last word.
784
+ // lint-staged names the files, which ruff checks even where the project
785
+ // excludes them unless forced to exclude. Since 17.6 it also stages every
786
+ // file a task changes, so `uv run` must not re-lock the workspace.
787
+ const ruffCommand = withUv ? 'uv run --frozen ruff' : 'ruff'
788
+ const python = withRuff
789
+ ? {
790
+ '*.{py,pyi}': [
791
+ `${ruffCommand} check --fix --force-exclude`,
792
+ `${ruffCommand} format --force-exclude`,
793
+ ],
794
+ }
795
+ : {}
796
+
797
+ const { config, ...options } = deepMerge(defaults, lintStaged)
798
+
799
+ // Spread rather than merged, so that a project's own glob replaces these
800
+ // the way it replaces those of the configuration it extends.
801
+ this.lintStaged = new LintStaged(this, {
802
+ ...options,
803
+ config: { ...python, ...config },
804
+ })
725
805
 
726
806
  this.typeScriptConfig?.addFile(this.lintStaged!.path)
727
807
  }
@@ -1085,6 +1165,26 @@ export class Project extends BaseProject {
1085
1165
  },
1086
1166
  ]
1087
1167
  : []),
1168
+ // `.python-version` is synthesized from the projenrc, so pyenv's update
1169
+ // to the file alone would be put back by the next synthesis. Read with
1170
+ // pyenv's depName, datasource and versioning, the declaration moves on
1171
+ // the same branch as the file. Held to the projenrc for the reason
1172
+ // spelled out under the `packageManager` manager, and because
1173
+ // `pythonVersion:` appears in this preset's tests and projen-uv's.
1174
+ ...(uv?.pythonVersion
1175
+ ? [
1176
+ {
1177
+ customType: 'regex' as const,
1178
+ datasourceTemplate: 'docker',
1179
+ depNameTemplate: 'python',
1180
+ versioningTemplate: 'docker',
1181
+ managerFilePatterns: [
1182
+ '/(^|/)\\.?projenrc\\.(js|cjs|mjs|ts|mts|cts)$/',
1183
+ ],
1184
+ matchStrings: ["pythonVersion:\\s*'(?<currentValue>[^']+)'"],
1185
+ },
1186
+ ]
1187
+ : []),
1088
1188
  // The `packageManager` field is declared in a projenrc and only
1089
1189
  // reaches the manifest through synthesis, so the npm manager reading
1090
1190
  // the manifest cannot propose it. Both patterns are held to that one
@@ -1111,9 +1211,9 @@ export class Project extends BaseProject {
1111
1211
  // `pnpm` offered pnpm's version for every other tool — and the version
1112
1212
  // ends at the first space or quote.
1113
1213
  //
1114
- // Anchored to a projenrc for the reason the two managers above are: a
1115
- // pattern that merely contains `projen` reaches the sources of every
1116
- // package here, and this preset's own tests exercise this regex
1214
+ // Anchored to a projenrc for the reason the anchored managers above
1215
+ // are: a pattern that merely contains `projen` reaches the sources of
1216
+ // every package here, and this preset's own tests exercise this regex
1117
1217
  // against a literal `pnpx name@version`. Renovate rewrote the fixture
1118
1218
  // and left the assertion beside it, failing the suite over a package
1119
1219
  // nothing depends on.
@@ -1140,6 +1240,14 @@ export class Project extends BaseProject {
1140
1240
  this.renovate = new Renovate(this, deepMerge(defaults, renovateOptions))
1141
1241
  }
1142
1242
 
1243
+ #configureRuff({ ruff }: ProjectOptions) {
1244
+ if (!ruff || this.parent) {
1245
+ return
1246
+ }
1247
+
1248
+ this.ruff = new Ruff(this, ruff)
1249
+ }
1250
+
1143
1251
  #configureSWC({ swcrc, typeScriptConfig }: ProjectOptions) {
1144
1252
  if (!swcrc) {
1145
1253
  return
@@ -1170,6 +1278,29 @@ export class Project extends BaseProject {
1170
1278
  this.swcrc = new SWCConfig(this, deepMerge(defaults, swcrc))
1171
1279
  }
1172
1280
 
1281
+ #configureTy({ ty, uv }: ProjectOptions) {
1282
+ if (!ty || this.parent) {
1283
+ return
1284
+ }
1285
+
1286
+ // ty takes `major.minor` alone, so a pinned patch release is cut back to
1287
+ // it and a pinned implementation is left to ty. The schema's type lists
1288
+ // only the versions ty knew when it was published; a newer one is ty's
1289
+ // to refuse.
1290
+ const pythonVersion = uv?.pythonVersion?.match(/^\d+\.\d+/)?.[0] as
1291
+ SupportedPythonVersion | undefined
1292
+
1293
+ const defaults: TyOptions = {
1294
+ ...(pythonVersion && {
1295
+ environment: {
1296
+ 'python-version': pythonVersion,
1297
+ },
1298
+ }),
1299
+ }
1300
+
1301
+ this.ty = new Ty(this, deepMerge(defaults, ty))
1302
+ }
1303
+
1173
1304
  #configureTypeScript({ parent, typeScriptConfig }: ProjectOptions) {
1174
1305
  if (!typeScriptConfig) {
1175
1306
  return
@@ -1252,7 +1383,33 @@ export class Project extends BaseProject {
1252
1383
  return
1253
1384
  }
1254
1385
 
1255
- this.worktrunk = new Worktrunk(this, worktrunk)
1386
+ // Replaced whole per event, not deep-merged like the other defaults: the
1387
+ // three hook forms cannot be merged into one another.
1388
+ this.worktrunk = new Worktrunk(this, {
1389
+ ...worktrunk,
1390
+ config: {
1391
+ ...worktrunk.config,
1392
+ 'pre-start': worktrunk.config?.['pre-start'] ?? this.#defaultPreStart(),
1393
+ },
1394
+ })
1395
+ }
1396
+
1397
+ /**
1398
+ * The install commands are the components' own text, so an upgrade that
1399
+ * changes one asks every teammate to approve the hook again.
1400
+ */
1401
+ #defaultPreStart() {
1402
+ const install = this.package && {
1403
+ [PACKAGE_MANAGER_BINARIES[this.package.packageManager]]:
1404
+ this.package.installCommand,
1405
+ }
1406
+ const cargo = this.cargo && { cargo: this.cargo.installCommand }
1407
+ const uv = this.uv && { uv: this.uv.installCommand }
1408
+
1409
+ return pipeline(
1410
+ { sync: WORKTRUNK_SYNC_COMMAND },
1411
+ { env: WORKTRUNK_ENV_COMMAND, ...install, ...cargo, ...uv },
1412
+ )
1256
1413
  }
1257
1414
 
1258
1415
  #populateTypeScriptProjectReferencesFromDependencies() {
@@ -1297,6 +1454,7 @@ const getGitIgnoreOptions = ({
1297
1454
  parent,
1298
1455
  typeScriptConfig: typeScriptConfigOptions,
1299
1456
  withTerraform,
1457
+ worktrunk,
1300
1458
  }: ProjectOptions): ProjectOptions['gitIgnoreOptions'] =>
1301
1459
  parent
1302
1460
  ? gitIgnoreOptions
@@ -1307,6 +1465,7 @@ const getGitIgnoreOptions = ({
1307
1465
  !.babelrc
1308
1466
  !.dockerignore
1309
1467
  !.editorconfig
1468
+ ${worktrunk ? '!.env.example' : ''}
1310
1469
  !.gitattributes
1311
1470
  !.gitignore
1312
1471
  !.gitkeep
@@ -1341,6 +1500,8 @@ const deepMerge = R.mergeDeepWith(
1341
1500
  [R.allPass([R.is(String), R.is(String)]), R.nthArg(1)],
1342
1501
  // Same for booleans, which cannot be concatenated at all.
1343
1502
  [R.allPass([R.is(Boolean), R.is(Boolean)]), R.nthArg(1)],
1503
+ // And for numbers.
1504
+ [R.allPass([R.is(Number), R.is(Number)]), R.nthArg(1)],
1344
1505
  // Otherwise, concatenate.
1345
1506
  [R.T, R.concat],
1346
1507
  ]),