@smoothbricks/cli 0.1.0 → 0.2.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.
Files changed (148) hide show
  1. package/README.md +338 -88
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +136 -22
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/lib/cli-package.d.ts +4 -0
  8. package/dist/lib/cli-package.d.ts.map +1 -0
  9. package/dist/lib/cli-package.js +15 -0
  10. package/dist/lib/devenv.d.ts +6 -0
  11. package/dist/lib/devenv.d.ts.map +1 -0
  12. package/dist/lib/devenv.js +82 -0
  13. package/dist/lib/run.d.ts +5 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +13 -0
  16. package/dist/lib/workspace.d.ts +20 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +87 -7
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +24 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +136 -15
  25. package/dist/monorepo/lockfile.d.ts +5 -1
  26. package/dist/monorepo/lockfile.d.ts.map +1 -1
  27. package/dist/monorepo/lockfile.js +45 -5
  28. package/dist/monorepo/managed-files.d.ts +1 -1
  29. package/dist/monorepo/managed-files.d.ts.map +1 -1
  30. package/dist/monorepo/managed-files.js +28 -11
  31. package/dist/monorepo/nx-sync.d.ts +2 -1
  32. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  33. package/dist/monorepo/nx-sync.js +8 -2
  34. package/dist/monorepo/package-hygiene.d.ts +6 -2
  35. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  36. package/dist/monorepo/package-hygiene.js +12 -4
  37. package/dist/monorepo/package-policy.d.ts +25 -2
  38. package/dist/monorepo/package-policy.d.ts.map +1 -1
  39. package/dist/monorepo/package-policy.js +1289 -27
  40. package/dist/monorepo/packed-manifest.d.ts +4 -0
  41. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  42. package/dist/monorepo/packed-manifest.js +51 -0
  43. package/dist/monorepo/packed-package.d.ts +3 -0
  44. package/dist/monorepo/packed-package.d.ts.map +1 -1
  45. package/dist/monorepo/packed-package.js +69 -31
  46. package/dist/monorepo/packs/index.d.ts +20 -1
  47. package/dist/monorepo/packs/index.d.ts.map +1 -1
  48. package/dist/monorepo/packs/index.js +240 -18
  49. package/dist/monorepo/publish-workflow.d.ts +82 -0
  50. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  51. package/dist/monorepo/publish-workflow.js +336 -0
  52. package/dist/monorepo/tool-validation.d.ts +22 -0
  53. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  54. package/dist/monorepo/tool-validation.js +399 -0
  55. package/dist/nx/index.d.ts +24 -0
  56. package/dist/nx/index.d.ts.map +1 -0
  57. package/dist/nx/index.js +94 -0
  58. package/dist/nx-version-actions.cjs +25 -0
  59. package/dist/nx-version-actions.d.cts +6 -0
  60. package/dist/nx-version-actions.d.cts.map +1 -0
  61. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  62. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  63. package/dist/release/bootstrap-npm-packages.js +58 -0
  64. package/dist/release/candidates.d.ts +11 -0
  65. package/dist/release/candidates.d.ts.map +1 -0
  66. package/dist/release/candidates.js +181 -0
  67. package/dist/release/core.d.ts +51 -0
  68. package/dist/release/core.d.ts.map +1 -0
  69. package/dist/release/core.js +97 -0
  70. package/dist/release/github-release.d.ts +46 -0
  71. package/dist/release/github-release.d.ts.map +1 -0
  72. package/dist/release/github-release.js +97 -0
  73. package/dist/release/index.d.ts +39 -5
  74. package/dist/release/index.d.ts.map +1 -1
  75. package/dist/release/index.js +763 -130
  76. package/dist/release/npm-auth.d.ts +16 -0
  77. package/dist/release/npm-auth.d.ts.map +1 -0
  78. package/dist/release/npm-auth.js +39 -0
  79. package/dist/release/orchestration.d.ts +49 -0
  80. package/dist/release/orchestration.d.ts.map +1 -0
  81. package/dist/release/orchestration.js +113 -0
  82. package/dist/release/publish-plan.d.ts +17 -0
  83. package/dist/release/publish-plan.d.ts.map +1 -0
  84. package/dist/release/publish-plan.js +15 -0
  85. package/dist/release/retag-unpublished.d.ts +34 -0
  86. package/dist/release/retag-unpublished.d.ts.map +1 -0
  87. package/dist/release/retag-unpublished.js +77 -0
  88. package/managed/raw/git-format-staged.yml +54 -0
  89. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  90. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  91. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  92. package/managed/templates/github/workflows/ci.yml +3 -0
  93. package/package.json +37 -4
  94. package/src/cli.ts +193 -40
  95. package/src/devenv/index.test.ts +49 -0
  96. package/src/devenv/index.ts +38 -0
  97. package/src/lib/cli-package.ts +18 -0
  98. package/src/lib/devenv.test.ts +28 -0
  99. package/src/lib/devenv.ts +89 -0
  100. package/src/lib/run.ts +19 -0
  101. package/src/lib/workspace.ts +117 -7
  102. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  103. package/src/monorepo/__tests__/publish-workflow.test.ts +322 -0
  104. package/src/monorepo/commit-msg.test.ts +45 -0
  105. package/src/monorepo/commit-msg.ts +107 -7
  106. package/src/monorepo/index.ts +181 -15
  107. package/src/monorepo/lockfile.ts +52 -7
  108. package/src/monorepo/managed-files.ts +43 -13
  109. package/src/monorepo/nx-sync.ts +9 -2
  110. package/src/monorepo/package-hygiene.test.ts +34 -0
  111. package/src/monorepo/package-hygiene.ts +26 -4
  112. package/src/monorepo/package-policy.test.ts +902 -0
  113. package/src/monorepo/package-policy.ts +1527 -25
  114. package/src/monorepo/packed-manifest.ts +67 -0
  115. package/src/monorepo/packed-package.ts +90 -31
  116. package/src/monorepo/packs/index.test.ts +189 -0
  117. package/src/monorepo/packs/index.ts +314 -19
  118. package/src/monorepo/publish-workflow.ts +422 -0
  119. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  120. package/src/monorepo/tool-validation.test.ts +261 -0
  121. package/src/monorepo/tool-validation.ts +462 -0
  122. package/src/nx/index.test.ts +64 -0
  123. package/src/nx/index.ts +119 -0
  124. package/src/nx-version-actions.cts +36 -0
  125. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  126. package/src/release/__tests__/candidates.test.ts +217 -0
  127. package/src/release/__tests__/core-properties.test.ts +149 -0
  128. package/src/release/__tests__/core-scenarios.test.ts +187 -0
  129. package/src/release/__tests__/core.test.ts +73 -0
  130. package/src/release/__tests__/fixture-repo.test.ts +314 -0
  131. package/src/release/__tests__/github-release.test.ts +149 -0
  132. package/src/release/__tests__/helpers/fixture-repo.ts +114 -0
  133. package/src/release/__tests__/npm-auth.test.ts +120 -0
  134. package/src/release/__tests__/orchestration.test.ts +246 -0
  135. package/src/release/__tests__/publish-plan.test.ts +67 -0
  136. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  137. package/src/release/__tests__/trust-publisher.test.ts +133 -0
  138. package/src/release/bootstrap-npm-packages.ts +88 -0
  139. package/src/release/candidates.ts +225 -0
  140. package/src/release/core.ts +171 -0
  141. package/src/release/github-release.ts +134 -0
  142. package/src/release/index.ts +974 -138
  143. package/src/release/npm-auth.ts +93 -0
  144. package/src/release/orchestration.ts +196 -0
  145. package/src/release/publish-plan.ts +37 -0
  146. package/src/release/retag-unpublished.ts +122 -0
  147. package/managed/raw/.git-format-staged.yml +0 -47
  148. package/managed/templates/github/workflows/publish.yml +0 -136
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Command, CommanderError } from 'commander';
2
+ import { cliPackageVersion } from './lib/cli-package.js';
2
3
  import { findRepoRoot } from './lib/run.js';
3
4
 
4
5
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
@@ -20,7 +21,12 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
20
21
 
21
22
  function buildProgram(): Command {
22
23
  const program = new Command();
23
- program.name('smoo').description('SmoothBricks monorepo tooling').exitOverride().showHelpAfterError();
24
+ program
25
+ .name('smoo')
26
+ .description('SmoothBricks monorepo tooling')
27
+ .version(cliPackageVersion, '-v, --version', 'print smoo version')
28
+ .exitOverride()
29
+ .showHelpAfterError();
24
30
 
25
31
  const monorepo = program.command('monorepo').description('Manage SmoothBricks-style monorepos');
26
32
  monorepo
@@ -33,11 +39,21 @@ function buildProgram(): Command {
33
39
  });
34
40
  monorepo
35
41
  .command('validate')
42
+ .option('--fix', 'apply safe monorepo policy fixes before validation')
36
43
  .option('--fail-fast', 'stop after the first failing validation pack')
37
- .action(async (options: { failFast?: boolean }) => {
38
- const { validateMonorepo } = await import('./monorepo/index.js');
39
- await validateMonorepo(await findRepoRoot(), options);
40
- });
44
+ .option('--only-if-new-workspace-package', 'skip validation unless a new workspace package manifest is staged')
45
+ .option('--verbose', 'print validation progress and successful checks')
46
+ .action(
47
+ async (options: {
48
+ fix?: boolean;
49
+ failFast?: boolean;
50
+ onlyIfNewWorkspacePackage?: boolean;
51
+ verbose?: boolean;
52
+ }) => {
53
+ const { validateMonorepo } = await import('./monorepo/index.js');
54
+ await validateMonorepo(await findRepoRoot(), options);
55
+ },
56
+ );
41
57
  monorepo.command('update').action(async () => {
42
58
  const { updateManagedFiles } = await import('./monorepo/index.js');
43
59
  updateManagedFiles(await findRepoRoot());
@@ -50,72 +66,209 @@ function buildProgram(): Command {
50
66
  const { diffManagedFiles } = await import('./monorepo/index.js');
51
67
  diffManagedFiles(await findRepoRoot());
52
68
  });
53
- monorepo.command('validate-commit-msg <commitMsgFile>').action(async (commitMsgFile: string) => {
54
- const { validateCommitMessageFile } = await import('./monorepo/index.js');
55
- validateCommitMessageFile(commitMsgFile);
56
- });
69
+ monorepo
70
+ .command('validate-commit-msg <commitMsgFile>')
71
+ .option('--fix', 'format the commit message before validation')
72
+ .action(async (commitMsgFile: string, options: { fix?: boolean }) => {
73
+ const { validateCommitMessageFile } = await import('./monorepo/index.js');
74
+ validateCommitMessageFile(commitMsgFile, options, await findRepoRoot());
75
+ });
57
76
  monorepo.command('sync-bun-lockfile-versions').action(async () => {
58
77
  const { syncBunLockfileVersions } = await import('./monorepo/index.js');
59
78
  syncBunLockfileVersions(await findRepoRoot());
60
79
  });
61
- monorepo.command('list-public-projects').action(async () => {
62
- const { listPublicProjects } = await import('./monorepo/index.js');
63
- console.log(listPublicProjects(await findRepoRoot()));
64
- });
80
+ monorepo
81
+ .command('list-release-packages')
82
+ .option('--fail-empty', 'fail when no owned release packages are found')
83
+ .option('--github-output <path>', 'append projects=<nx-projects> to a GitHub Actions output file')
84
+ .action(async (options: { failEmpty?: boolean; githubOutput?: string }) => {
85
+ const { listReleaseProjectNamesForNx } = await import('./monorepo/index.js');
86
+ const packages = listReleaseProjectNamesForNx(await findRepoRoot(), options);
87
+ if (!options.githubOutput) {
88
+ console.log(packages);
89
+ }
90
+ });
65
91
  monorepo.command('validate-public-tags').action(async () => {
66
92
  const { validatePublicPackageTags } = await import('./monorepo/index.js');
67
93
  validatePublicPackageTags(await findRepoRoot());
68
94
  });
69
- monorepo.command('release-state').action(async () => {
95
+ monorepo
96
+ .command('setup-test-tracing')
97
+ .description('Configure LMAO Bun test tracing for workspace packages')
98
+ .option('--all', 'configure every workspace package')
99
+ .option('--projects <projects>', 'comma-separated Nx project names, package names, or package roots')
100
+ .option('--op-context-export <exportName>', 'named op context export imported by test-suite-tracer', 'opContext')
101
+ .option(
102
+ '--tracer-module <module>',
103
+ 'module specifier that exports defineTestTracer',
104
+ '@smoothbricks/lmao/testing/bun',
105
+ )
106
+ .option('--dry-run', 'print generator invocations without writing files')
107
+ .action(
108
+ async (options: {
109
+ all?: boolean;
110
+ projects?: string;
111
+ opContextExport?: string;
112
+ tracerModule?: string;
113
+ dryRun?: boolean;
114
+ }) => {
115
+ const { setupTestTracing } = await import('./monorepo/index.js');
116
+ await setupTestTracing(await findRepoRoot(), options);
117
+ },
118
+ );
119
+ const release = program.command('release').description('Version, publish, and create GitHub Releases');
120
+ release.command('npm-status').action(async () => {
70
121
  const { printReleaseState } = await import('./release/index.js');
71
122
  await printReleaseState(await findRepoRoot());
72
123
  });
73
-
74
- const release = program.command('release').description('Version, publish, and create GitHub Releases');
124
+ release
125
+ .command('repair-pending')
126
+ .description('Repair incomplete older release commits before releasing the current HEAD')
127
+ .option('--dry-run [dryRun]', 'run without pushing, publishing, or writing GitHub Releases')
128
+ .action(async (options: { dryRun?: string | boolean }) => {
129
+ const { releaseRepairPending } = await import('./release/index.js');
130
+ await releaseRepairPending(await findRepoRoot(), { ...options, dryRun: booleanOption(options.dryRun) });
131
+ });
75
132
  release
76
133
  .command('version')
77
134
  .option('--bump <bump>', 'auto, patch, minor, major, or prerelease', 'auto')
78
- .option('--dry-run [dryRun]', 'run without writing versions, tags, or pushes')
79
- .action(async (options: { bump: string; dryRun?: string | boolean }) => {
135
+ .option('--dry-run [dryRun]', 'run without writing versions or tags')
136
+ .option('--github-output <path>', 'append mode=<mode> and projects=<nx-projects> to a GitHub Actions output file')
137
+ .action(async (options: { bump: string; dryRun?: string | boolean; githubOutput?: string }) => {
80
138
  const { releaseVersion } = await import('./release/index.js');
81
- await releaseVersion(await findRepoRoot(), { bump: options.bump, dryRun: booleanOption(options.dryRun) });
139
+ await releaseVersion(await findRepoRoot(), {
140
+ bump: options.bump,
141
+ dryRun: booleanOption(options.dryRun),
142
+ githubOutput: options.githubOutput,
143
+ });
82
144
  });
83
145
  release
84
146
  .command('publish')
85
147
  .option('--bump <bump>', 'auto, patch, minor, major, or prerelease', 'auto')
86
- .option('--tag <tag>', 'explicit npm dist-tag; must match bump-derived tag')
87
- .option('--npm-tag <npmTag>', 'explicit npm dist-tag; must match bump-derived tag')
88
- .option('--dry-run [dryRun]', 'run without publishing')
89
- .action(async (options: { bump: string; tag?: string; npmTag?: string; dryRun?: string | boolean }) => {
148
+ .option('--dry-run [dryRun]', 'run without pushing, publishing, or writing GitHub Releases')
149
+ .action(async (options: { bump: string; dryRun?: string | boolean }) => {
90
150
  const { releasePublish } = await import('./release/index.js');
91
151
  await releasePublish(await findRepoRoot(), { ...options, dryRun: booleanOption(options.dryRun) });
92
152
  });
93
153
  release
94
- .command('github-release')
95
- .option('--tags <tags>', 'space-separated release tags')
96
- .option('--bump <bump>', 'auto, patch, minor, major, or prerelease', 'auto')
97
- .option('--tag <tag>', 'explicit npm dist-tag; must match bump-derived tag')
98
- .option('--npm-tag <npmTag>', 'explicit npm dist-tag; must match bump-derived tag')
99
- .option('--dry-run [dryRun]', 'skip GitHub Release writes')
154
+ .command('retag-unpublished')
155
+ .description('Move unpublished owned release tags to a later commit without bumping versions')
156
+ .argument('<tag...>', 'owned release tags to move, for example @scope/pkg@1.2.3')
157
+ .option('--to <ref>', 'commit or ref to move tags to', 'HEAD')
158
+ .option('--push', 'push moved tags with force-with-lease')
159
+ .option('--dispatch', 'push moved tags and start publish.yml with bump=auto')
160
+ .option('--remote <remote>', 'git remote used for pushed tags')
161
+ .option('--branch <branch>', 'branch used for publish workflow dispatch')
162
+ .option('--dry-run [dryRun]', 'validate and print the retag operation without mutating refs')
100
163
  .action(
101
- async (options: { tags?: string; bump: string; tag?: string; npmTag?: string; dryRun?: string | boolean }) => {
102
- const { releaseGithubRelease } = await import('./release/index.js');
103
- await releaseGithubRelease(await findRepoRoot(), { ...options, dryRun: booleanOption(options.dryRun) });
164
+ async (
165
+ tags: string[],
166
+ options: {
167
+ to?: string;
168
+ push?: boolean;
169
+ dispatch?: boolean;
170
+ remote?: string;
171
+ branch?: string;
172
+ dryRun?: string | boolean;
173
+ },
174
+ ) => {
175
+ const { releaseRetagUnpublished } = await import('./release/index.js');
176
+ await releaseRetagUnpublished(await findRepoRoot(), {
177
+ tags,
178
+ to: options.to,
179
+ push: options.push === true,
180
+ dispatch: options.dispatch === true,
181
+ remote: options.remote,
182
+ branch: options.branch,
183
+ dryRun: booleanOption(options.dryRun),
184
+ });
104
185
  },
105
186
  );
187
+ release
188
+ .command('bootstrap-npm-packages')
189
+ .alias('bootstrap')
190
+ .description('Publish minimal npm placeholder packages so trusted publishing can be configured')
191
+ .option('--dry-run [dryRun]', 'show placeholder publishes without logging in or publishing')
192
+ .option('--skip-login', 'skip npm browser login before publishing placeholders')
193
+ .option('--otp <otp>', 'npm one-time password for placeholder publish operations')
194
+ .option('--package <name...>', 'only bootstrap the selected owned release package names')
195
+ .action(async (options: { dryRun?: string | boolean; skipLogin?: boolean; otp?: string; package?: string[] }) => {
196
+ const { releaseBootstrapNpmPackages } = await import('./release/index.js');
197
+ await releaseBootstrapNpmPackages(await findRepoRoot(), {
198
+ dryRun: booleanOption(options.dryRun),
199
+ skipLogin: options.skipLogin === true,
200
+ otp: options.otp,
201
+ packages: options.package ?? [],
202
+ });
203
+ });
106
204
  release
107
205
  .command('trust-publisher')
108
- .description('Configure npm trusted publishing for public packages')
206
+ .description('Configure npm trusted publishing for owned release packages')
109
207
  .option('--dry-run [dryRun]', 'show npm trust changes without saving them')
208
+ .option('--bootstrap', 'publish missing npm placeholder packages before configuring trust')
110
209
  .option('--otp <otp>', 'npm one-time password for trust operations')
210
+ .option('--bootstrap-otp <otp>', 'npm one-time password for placeholder publishes during --bootstrap')
111
211
  .option('--skip-login', 'skip npm browser login before configuring trust')
112
- .action(async (options: { dryRun?: string | boolean; otp?: string; skipLogin?: boolean }) => {
113
- const { releaseTrustPublisher } = await import('./release/index.js');
114
- await releaseTrustPublisher(await findRepoRoot(), {
115
- dryRun: booleanOption(options.dryRun),
116
- otp: options.otp,
117
- skipLogin: options.skipLogin === true,
118
- });
212
+ .action(
213
+ async (options: {
214
+ dryRun?: string | boolean;
215
+ bootstrap?: boolean;
216
+ otp?: string;
217
+ bootstrapOtp?: string;
218
+ skipLogin?: boolean;
219
+ }) => {
220
+ const { releaseTrustPublisher } = await import('./release/index.js');
221
+ await releaseTrustPublisher(await findRepoRoot(), {
222
+ dryRun: booleanOption(options.dryRun),
223
+ bootstrap: options.bootstrap === true,
224
+ otp: options.otp,
225
+ bootstrapOtp: options.bootstrapOtp,
226
+ skipLogin: options.skipLogin === true,
227
+ });
228
+ },
229
+ );
230
+
231
+ const devenv = program.command('devenv').description('Manage the repository devenv shell');
232
+ devenv.command('update').action(async () => {
233
+ const { updateDevenv } = await import('./devenv/index.js');
234
+ await updateDevenv(await findRepoRoot());
235
+ });
236
+ devenv.command('reload').action(async () => {
237
+ const { reloadDevenv } = await import('./devenv/index.js');
238
+ await reloadDevenv(await findRepoRoot());
239
+ });
240
+
241
+ const nixpkgsOverlay = program.command('nixpkgs-overlay').description('Manage the repository nixpkgs overlay');
242
+ nixpkgsOverlay.command('update').action(async () => {
243
+ const { updateNixpkgsOverlay } = await import('./devenv/index.js');
244
+ await updateNixpkgsOverlay(await findRepoRoot());
245
+ });
246
+
247
+ const nx = program.command('nx').description('Nx workspace helpers');
248
+ nx.command('list-targets')
249
+ .description('List project:target entries for every Nx project')
250
+ .action(async () => {
251
+ const { listTargets } = await import('./nx/index.js');
252
+ await listTargets(await findRepoRoot());
253
+ });
254
+ nx.command('list-projects')
255
+ .description('List Nx projects matching filters')
256
+ .requiredOption('--with-target <target>', 'only include projects defining this target')
257
+ .action(async (options: { withTarget?: string }) => {
258
+ const { listProjects } = await import('./nx/index.js');
259
+ await listProjects(await findRepoRoot(), options);
260
+ });
261
+ nx.command('reset-cache')
262
+ .description('Run nx reset to clear Nx daemon and cache state')
263
+ .action(async () => {
264
+ const { resetCache } = await import('./nx/index.js');
265
+ await resetCache(await findRepoRoot());
266
+ });
267
+ nx.command('clean-cache')
268
+ .description('Remove local Nx cache directories when present')
269
+ .action(async () => {
270
+ const { cleanCache } = await import('./nx/index.js');
271
+ await cleanCache(await findRepoRoot());
119
272
  });
120
273
 
121
274
  const githubCi = program.command('github-ci').description('GitHub Actions helpers');
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { DevenvCommandShell } from './index.js';
3
+ import { reloadDevenv, updateDevenv, updateNixpkgsOverlay } from './index.js';
4
+
5
+ describe('devenv commands', () => {
6
+ it('runs devenv update in tooling/direnv', async () => {
7
+ const shell = new RecordingShell();
8
+
9
+ await updateDevenv('/repo', shell);
10
+
11
+ expect(shell.commands).toEqual([{ command: 'devenv', args: ['update'], cwd: '/repo/tooling/direnv' }]);
12
+ });
13
+
14
+ it('clears cached devenv directories before reloading direnv', async () => {
15
+ const shell = new RecordingShell();
16
+
17
+ await reloadDevenv('/repo', shell);
18
+
19
+ expect(shell.removals).toEqual(['/repo/tooling/direnv/.direnv', '/repo/tooling/direnv/.devenv']);
20
+ expect(shell.commands).toEqual([{ command: 'direnv', args: ['reload'], cwd: '/repo/tooling/direnv' }]);
21
+ });
22
+
23
+ it('runs nvfetcher through nix shell in the overlay directory', async () => {
24
+ const shell = new RecordingShell();
25
+
26
+ await updateNixpkgsOverlay('/repo', shell);
27
+
28
+ expect(shell.commands).toEqual([
29
+ {
30
+ command: 'nix',
31
+ args: ['shell', 'nixpkgs#nvfetcher', '-c', 'nvfetcher', '-o', '_sources'],
32
+ cwd: '/repo/tooling/direnv/nixpkgs-overlay',
33
+ },
34
+ ]);
35
+ });
36
+ });
37
+
38
+ class RecordingShell implements DevenvCommandShell {
39
+ readonly commands: { command: string; args: string[]; cwd: string }[] = [];
40
+ readonly removals: string[] = [];
41
+
42
+ async run(command: string, args: string[], cwd: string): Promise<void> {
43
+ this.commands.push({ command, args, cwd });
44
+ }
45
+
46
+ async remove(path: string): Promise<void> {
47
+ this.removals.push(path);
48
+ }
49
+ }
@@ -0,0 +1,38 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { run } from '../lib/run.js';
4
+
5
+ export interface DevenvCommandShell {
6
+ run(command: string, args: string[], cwd: string): Promise<void>;
7
+ remove(path: string): Promise<void>;
8
+ }
9
+
10
+ const defaultShell: DevenvCommandShell = {
11
+ run,
12
+ async remove(path) {
13
+ await rm(path, { recursive: true, force: true });
14
+ },
15
+ };
16
+
17
+ export async function updateDevenv(root: string, shell: DevenvCommandShell = defaultShell): Promise<void> {
18
+ await shell.run('devenv', ['update'], direnvRoot(root));
19
+ }
20
+
21
+ export async function reloadDevenv(root: string, shell: DevenvCommandShell = defaultShell): Promise<void> {
22
+ const cwd = direnvRoot(root);
23
+ await shell.remove(join(cwd, '.direnv'));
24
+ await shell.remove(join(cwd, '.devenv'));
25
+ await shell.run('direnv', ['reload'], cwd);
26
+ }
27
+
28
+ export async function updateNixpkgsOverlay(root: string, shell: DevenvCommandShell = defaultShell): Promise<void> {
29
+ await shell.run('nix', ['shell', 'nixpkgs#nvfetcher', '-c', 'nvfetcher', '-o', '_sources'], nixpkgsOverlayRoot(root));
30
+ }
31
+
32
+ export function direnvRoot(root: string): string {
33
+ return join(root, 'tooling', 'direnv');
34
+ }
35
+
36
+ export function nixpkgsOverlayRoot(root: string): string {
37
+ return join(direnvRoot(root), 'nixpkgs-overlay');
38
+ }
@@ -0,0 +1,18 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { readJsonObject, stringProperty } from './json.js';
3
+
4
+ export const smoothBricksCodebasePackageName = '@smoothbricks/codebase';
5
+ export const cliPackageVersion = readCliPackageVersion();
6
+
7
+ function readCliPackageVersion(): string {
8
+ const pkg = readJsonObject(fileURLToPath(new URL('../../package.json', import.meta.url)));
9
+ const version = pkg ? stringProperty(pkg, 'version') : null;
10
+ if (!version) {
11
+ throw new Error('Unable to read @smoothbricks/cli package version.');
12
+ }
13
+ return version;
14
+ }
15
+
16
+ export function isSmoothBricksCodebasePackageName(name: string | undefined): boolean {
17
+ return name === smoothBricksCodebasePackageName;
18
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mergeDevenvEnv, parseNulEnv } from './devenv.js';
3
+
4
+ describe('devenv environment loader', () => {
5
+ it('parses NUL-separated env output without splitting values on newlines or equals signs', () => {
6
+ const bytes = new TextEncoder().encode('SIMPLE=value\0MULTILINE=line 1\nline 2\0TOKEN=a=b=c\0');
7
+
8
+ expect(parseNulEnv(bytes)).toEqual({
9
+ SIMPLE: 'value',
10
+ MULTILINE: 'line 1\nline 2',
11
+ TOKEN: 'a=b=c',
12
+ });
13
+ });
14
+
15
+ it('merges devenv output over the existing environment instead of replacing it', () => {
16
+ expect(
17
+ mergeDevenvEnv(
18
+ { GH_TOKEN: 'existing-token', NODE_AUTH_TOKEN: 'npm-token', PATH: '/usr/bin' },
19
+ { DEVENV_ROOT: '/repo/tooling/direnv', PATH: '/nix/bin' },
20
+ ),
21
+ ).toEqual({
22
+ DEVENV_ROOT: '/repo/tooling/direnv',
23
+ GH_TOKEN: 'existing-token',
24
+ NODE_AUTH_TOKEN: 'npm-token',
25
+ PATH: '/nix/bin',
26
+ });
27
+ });
28
+ });
@@ -0,0 +1,89 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import { decode } from './run.js';
6
+
7
+ type EnvSnapshot = Record<string, string>;
8
+
9
+ export async function withDevenvEnv<T>(root: string, runWithEnv: () => Promise<T>): Promise<T> {
10
+ const snapshot = snapshotProcessEnv();
11
+ const env = await loadDevenvEnv(root);
12
+ replaceProcessEnv(mergeDevenvEnv(snapshot, env));
13
+ try {
14
+ return await runWithEnv();
15
+ } finally {
16
+ restoreProcessEnv(snapshot);
17
+ }
18
+ }
19
+
20
+ async function loadDevenvEnv(root: string): Promise<EnvSnapshot> {
21
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-devenv-env-'));
22
+ const envPath = join(tempDir, 'env');
23
+ try {
24
+ const result = await $`devenv shell -- bash -lc ${'env -0 > "$1"'} bash ${envPath}`
25
+ .cwd(join(root, 'tooling', 'direnv'))
26
+ .quiet()
27
+ .nothrow();
28
+ if (result.exitCode !== 0) {
29
+ throw new Error('devenv shell failed. Ensure devenv is installed and the tooling/direnv shell is valid.');
30
+ }
31
+ return parseNulEnv(await readFile(envPath));
32
+ } finally {
33
+ await rm(tempDir, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ export function parseNulEnv(bytes: Uint8Array): EnvSnapshot {
38
+ const env: EnvSnapshot = {};
39
+ let entryStart = 0;
40
+ for (let index = 0; index <= bytes.length; index += 1) {
41
+ if (index !== bytes.length && bytes[index] !== 0) {
42
+ continue;
43
+ }
44
+ if (index === entryStart) {
45
+ entryStart = index + 1;
46
+ continue;
47
+ }
48
+ const entry = decode(bytes.subarray(entryStart, index));
49
+ const separator = entry.indexOf('=');
50
+ if (separator <= 0) {
51
+ throw new Error('devenv shell produced an invalid environment entry.');
52
+ }
53
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
54
+ entryStart = index + 1;
55
+ }
56
+ return env;
57
+ }
58
+
59
+ export function mergeDevenvEnv(baseEnv: EnvSnapshot, devenvEnv: EnvSnapshot): EnvSnapshot {
60
+ return { ...baseEnv, ...devenvEnv };
61
+ }
62
+
63
+ function snapshotProcessEnv(): EnvSnapshot {
64
+ const snapshot: EnvSnapshot = {};
65
+ for (const [key, value] of Object.entries(process.env)) {
66
+ if (value !== undefined) {
67
+ snapshot[key] = value;
68
+ }
69
+ }
70
+ return snapshot;
71
+ }
72
+
73
+ function replaceProcessEnv(env: EnvSnapshot): void {
74
+ for (const key of Object.keys(process.env)) {
75
+ delete process.env[key];
76
+ }
77
+ for (const [key, value] of Object.entries(env)) {
78
+ process.env[key] = value;
79
+ }
80
+ }
81
+
82
+ function restoreProcessEnv(snapshot: EnvSnapshot): void {
83
+ for (const key of Object.keys(process.env)) {
84
+ delete process.env[key];
85
+ }
86
+ for (const [key, value] of Object.entries(snapshot)) {
87
+ process.env[key] = value;
88
+ }
89
+ }
package/src/lib/run.ts CHANGED
@@ -26,6 +26,25 @@ export async function runStatus(
26
26
  return result.exitCode;
27
27
  }
28
28
 
29
+ export async function runResult(
30
+ command: string,
31
+ args: string[],
32
+ cwd: string,
33
+ env?: Record<string, string>,
34
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
35
+ const invocation = resolveCommandInvocation(cwd, command, args);
36
+ let shell = $`${invocation.command} ${invocation.args}`.cwd(cwd).nothrow().quiet();
37
+ if (env) {
38
+ shell = shell.env(mergeEnv(env));
39
+ }
40
+ const result = await shell;
41
+ return {
42
+ exitCode: result.exitCode,
43
+ stdout: decode(result.stdout),
44
+ stderr: decode(result.stderr),
45
+ };
46
+ }
47
+
29
48
  function mergeEnv(env: Record<string, string>): Record<string, string> {
30
49
  const merged: Record<string, string> = {};
31
50
  for (const [key, value] of Object.entries(process.env)) {