@smoothbricks/cli 0.1.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 (86) hide show
  1. package/README.md +289 -0
  2. package/bin/smoo +4 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +141 -0
  6. package/dist/github-ci/index.d.ts +11 -0
  7. package/dist/github-ci/index.d.ts.map +1 -0
  8. package/dist/github-ci/index.js +174 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/lib/json.d.ts +13 -0
  13. package/dist/lib/json.d.ts.map +1 -0
  14. package/dist/lib/json.js +57 -0
  15. package/dist/lib/run.d.ts +5 -0
  16. package/dist/lib/run.d.ts.map +1 -0
  17. package/dist/lib/run.js +64 -0
  18. package/dist/lib/workspace.d.ts +23 -0
  19. package/dist/lib/workspace.d.ts.map +1 -0
  20. package/dist/lib/workspace.js +101 -0
  21. package/dist/monorepo/commit-msg.d.ts +2 -0
  22. package/dist/monorepo/commit-msg.d.ts.map +1 -0
  23. package/dist/monorepo/commit-msg.js +34 -0
  24. package/dist/monorepo/git-config.d.ts +2 -0
  25. package/dist/monorepo/git-config.d.ts.map +1 -0
  26. package/dist/monorepo/git-config.js +37 -0
  27. package/dist/monorepo/index.d.ts +19 -0
  28. package/dist/monorepo/index.d.ts.map +1 -0
  29. package/dist/monorepo/index.js +59 -0
  30. package/dist/monorepo/lockfile.d.ts +2 -0
  31. package/dist/monorepo/lockfile.d.ts.map +1 -0
  32. package/dist/monorepo/lockfile.js +34 -0
  33. package/dist/monorepo/managed-files.d.ts +8 -0
  34. package/dist/monorepo/managed-files.d.ts.map +1 -0
  35. package/dist/monorepo/managed-files.js +130 -0
  36. package/dist/monorepo/nx-sync.d.ts +2 -0
  37. package/dist/monorepo/nx-sync.d.ts.map +1 -0
  38. package/dist/monorepo/nx-sync.js +5 -0
  39. package/dist/monorepo/package-hygiene.d.ts +3 -0
  40. package/dist/monorepo/package-hygiene.d.ts.map +1 -0
  41. package/dist/monorepo/package-hygiene.js +12 -0
  42. package/dist/monorepo/package-policy.d.ts +8 -0
  43. package/dist/monorepo/package-policy.d.ts.map +1 -0
  44. package/dist/monorepo/package-policy.js +279 -0
  45. package/dist/monorepo/packed-package.d.ts +2 -0
  46. package/dist/monorepo/packed-package.d.ts.map +1 -0
  47. package/dist/monorepo/packed-package.js +92 -0
  48. package/dist/monorepo/packs/index.d.ts +10 -0
  49. package/dist/monorepo/packs/index.d.ts.map +1 -0
  50. package/dist/monorepo/packs/index.js +87 -0
  51. package/dist/monorepo/runtime.d.ts +2 -0
  52. package/dist/monorepo/runtime.d.ts.map +1 -0
  53. package/dist/monorepo/runtime.js +37 -0
  54. package/dist/release/index.d.ts +24 -0
  55. package/dist/release/index.d.ts.map +1 -0
  56. package/dist/release/index.js +325 -0
  57. package/managed/raw/.git-format-staged.yml +47 -0
  58. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +66 -0
  59. package/managed/raw/tooling/git-hooks/commit-msg.sh +9 -0
  60. package/managed/raw/tooling/git-hooks/pre-commit.sh +19 -0
  61. package/managed/templates/github/actions/cache-nix-devenv/action.yml +79 -0
  62. package/managed/templates/github/actions/cache-node-modules/action.yml +13 -0
  63. package/managed/templates/github/actions/cache-nx/action.yml +21 -0
  64. package/managed/templates/github/actions/save-nix-devenv/action.yml +39 -0
  65. package/managed/templates/github/actions/setup-devenv/action.yml +63 -0
  66. package/managed/templates/github/workflows/ci.yml +98 -0
  67. package/managed/templates/github/workflows/publish.yml +136 -0
  68. package/package.json +68 -0
  69. package/src/cli.ts +149 -0
  70. package/src/github-ci/index.ts +196 -0
  71. package/src/index.ts +1 -0
  72. package/src/lib/json.ts +68 -0
  73. package/src/lib/run.ts +76 -0
  74. package/src/lib/workspace.ts +125 -0
  75. package/src/monorepo/commit-msg.ts +35 -0
  76. package/src/monorepo/git-config.ts +43 -0
  77. package/src/monorepo/index.ts +78 -0
  78. package/src/monorepo/lockfile.ts +37 -0
  79. package/src/monorepo/managed-files.ts +153 -0
  80. package/src/monorepo/nx-sync.ts +6 -0
  81. package/src/monorepo/package-hygiene.ts +14 -0
  82. package/src/monorepo/package-policy.ts +307 -0
  83. package/src/monorepo/packed-package.ts +101 -0
  84. package/src/monorepo/packs/index.ts +115 -0
  85. package/src/monorepo/runtime.ts +41 -0
  86. package/src/release/index.ts +394 -0
@@ -0,0 +1,153 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readPackageJson } from '../lib/workspace.js';
5
+
6
+ type ManagedKind = 'raw' | 'template';
7
+
8
+ interface ManagedFile {
9
+ kind: ManagedKind;
10
+ source: string;
11
+ target: string;
12
+ executable?: boolean;
13
+ }
14
+
15
+ export interface FileResult {
16
+ target: string;
17
+ action: 'created' | 'updated' | 'unchanged' | 'skipped-symlink' | 'drifted' | 'ok-symlink';
18
+ }
19
+
20
+ const managedFiles: ManagedFile[] = [
21
+ {
22
+ kind: 'raw',
23
+ source: 'tooling/direnv/github-actions-bootstrap.sh',
24
+ target: 'tooling/direnv/github-actions-bootstrap.sh',
25
+ executable: true,
26
+ },
27
+ {
28
+ kind: 'raw',
29
+ source: 'tooling/git-hooks/pre-commit.sh',
30
+ target: 'tooling/git-hooks/pre-commit.sh',
31
+ executable: true,
32
+ },
33
+ {
34
+ kind: 'raw',
35
+ source: 'tooling/git-hooks/commit-msg.sh',
36
+ target: 'tooling/git-hooks/commit-msg.sh',
37
+ executable: true,
38
+ },
39
+ {
40
+ kind: 'raw',
41
+ source: '.git-format-staged.yml',
42
+ target: '.git-format-staged.yml',
43
+ },
44
+ {
45
+ kind: 'template',
46
+ source: 'github/workflows/ci.yml',
47
+ target: '.github/workflows/ci.yml',
48
+ },
49
+ {
50
+ kind: 'template',
51
+ source: 'github/workflows/publish.yml',
52
+ target: '.github/workflows/publish.yml',
53
+ },
54
+ {
55
+ kind: 'template',
56
+ source: 'github/actions/cache-nix-devenv/action.yml',
57
+ target: '.github/actions/cache-nix-devenv/action.yml',
58
+ },
59
+ {
60
+ kind: 'template',
61
+ source: 'github/actions/setup-devenv/action.yml',
62
+ target: '.github/actions/setup-devenv/action.yml',
63
+ },
64
+ {
65
+ kind: 'template',
66
+ source: 'github/actions/save-nix-devenv/action.yml',
67
+ target: '.github/actions/save-nix-devenv/action.yml',
68
+ },
69
+ {
70
+ kind: 'template',
71
+ source: 'github/actions/cache-node-modules/action.yml',
72
+ target: '.github/actions/cache-node-modules/action.yml',
73
+ },
74
+ {
75
+ kind: 'template',
76
+ source: 'github/actions/cache-nx/action.yml',
77
+ target: '.github/actions/cache-nx/action.yml',
78
+ },
79
+ ];
80
+
81
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
82
+
83
+ export function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): FileResult[] {
84
+ return managedFiles.map((file) => applyManagedFile(root, file, mode));
85
+ }
86
+
87
+ function applyManagedFile(root: string, file: ManagedFile, mode: 'update' | 'check' | 'diff'): FileResult {
88
+ const target = resolve(root, file.target);
89
+ const content = getManagedContent(root, file);
90
+ if (existsSync(target)) {
91
+ const info = lstatSync(target);
92
+ if (info.isSymbolicLink()) {
93
+ return { target: file.target, action: mode === 'check' ? 'ok-symlink' : 'skipped-symlink' };
94
+ }
95
+ if (!info.isFile()) {
96
+ throw new Error(`${file.target} exists but is not a regular file or symlink`);
97
+ }
98
+ const current = readFileSync(target, 'utf8');
99
+ if (current === content) {
100
+ return { target: file.target, action: 'unchanged' };
101
+ }
102
+ if (mode === 'check' || mode === 'diff') {
103
+ return { target: file.target, action: 'drifted' };
104
+ }
105
+ writeManagedFile(target, content, file.executable === true);
106
+ return { target: file.target, action: 'updated' };
107
+ }
108
+ if (mode === 'check' || mode === 'diff') {
109
+ return { target: file.target, action: 'drifted' };
110
+ }
111
+ writeManagedFile(target, content, file.executable === true);
112
+ return { target: file.target, action: 'created' };
113
+ }
114
+
115
+ function getManagedContent(root: string, file: ManagedFile): string {
116
+ const sourceRoot = file.kind === 'raw' ? 'managed/raw' : 'managed/templates';
117
+ const sourcePath = join(packageRoot, sourceRoot, file.source);
118
+ const content = readFileSync(sourcePath, 'utf8');
119
+ if (file.kind === 'raw') {
120
+ return content;
121
+ }
122
+ return renderTemplate(root, content);
123
+ }
124
+
125
+ function renderTemplate(root: string, template: string): string {
126
+ const packageJson = readPackageJson(join(root, 'package.json'));
127
+ const repoName = packageJson?.name ?? 'monorepo';
128
+ const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
129
+ ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
130
+ : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
131
+ return template.replaceAll('{{REPO_NAME}}', repoName).replaceAll('{{NODE_MODULES_CACHE_KEY}}', nodeModulesCacheKey);
132
+ }
133
+
134
+ function writeManagedFile(path: string, content: string, executable: boolean): void {
135
+ mkdirSync(dirname(path), { recursive: true });
136
+ writeFileSync(path, content, { mode: executable ? 0o755 : 0o644 });
137
+ }
138
+
139
+ export function printResults(results: FileResult[]): void {
140
+ for (const result of results) {
141
+ console.log(`${result.action.padEnd(15)} ${result.target}`);
142
+ }
143
+ }
144
+
145
+ export function validateManagedFiles(root: string): number {
146
+ const results = applyManagedFiles(root, 'check');
147
+ printResults(results);
148
+ const failures = results.filter((result) => result.action === 'drifted').length;
149
+ if (failures > 0) {
150
+ console.error('Managed monorepo files are out of date. Run: smoo monorepo update');
151
+ }
152
+ return failures;
153
+ }
@@ -0,0 +1,6 @@
1
+ import { runStatus } from '../lib/run.js';
2
+
3
+ export async function validateNxSync(root: string): Promise<number> {
4
+ const status = await runStatus('nx', ['sync:check'], root);
5
+ return status === 0 ? 0 : 1;
6
+ }
@@ -0,0 +1,14 @@
1
+ import { run, runStatus } from '../lib/run.js';
2
+
3
+ export async function fixPackageHygiene(root: string): Promise<void> {
4
+ await run('sherif', ['--fix', '--select', 'highest'], root);
5
+ }
6
+
7
+ export async function validatePackageHygiene(root: string): Promise<number> {
8
+ const status = await runStatus('sherif', [], root);
9
+ if (status !== 0) {
10
+ console.error('sherif package hygiene validation failed');
11
+ return 1;
12
+ }
13
+ return 0;
14
+ }
@@ -0,0 +1,307 @@
1
+ import { join } from 'node:path';
2
+ import {
3
+ getOrCreateRecord,
4
+ hasOwnString,
5
+ isRecord,
6
+ readJsonObject,
7
+ recordProperty,
8
+ requiredJsonObject,
9
+ setMissingStringProperty,
10
+ setStringProperty,
11
+ stringProperty,
12
+ writeJsonObject,
13
+ } from '../lib/json.js';
14
+ import {
15
+ getWorkspacePackages,
16
+ listPackageJsonRecords,
17
+ listPublicPackages,
18
+ repositoryInfo,
19
+ workspaceDependencyFields,
20
+ } from '../lib/workspace.js';
21
+
22
+ export function applyPublicPackageDefaults(root: string): void {
23
+ const rootPackage = requiredJsonObject(join(root, 'package.json'));
24
+ const rootLicense = stringProperty(rootPackage, 'license');
25
+ const rootRepository = repositoryInfo(rootPackage);
26
+ if (!rootLicense) {
27
+ throw new Error('Root package.json must define license before public package defaults can be applied.');
28
+ }
29
+ if (!rootRepository) {
30
+ throw new Error('Root package.json must define repository.url before public package defaults can be applied.');
31
+ }
32
+
33
+ for (const pkg of listPublicPackages(root)) {
34
+ let changed = false;
35
+ changed = setMissingStringProperty(pkg.json, 'license', rootLicense) || changed;
36
+ const publishConfig = getOrCreateRecord(pkg.json, 'publishConfig');
37
+ changed = setStringProperty(publishConfig, 'access', 'public') || changed;
38
+
39
+ const repository = getOrCreateRecord(pkg.json, 'repository');
40
+ changed = setStringProperty(repository, 'type', rootRepository.type) || changed;
41
+ changed = setStringProperty(repository, 'url', rootRepository.url) || changed;
42
+ changed = setStringProperty(repository, 'directory', pkg.path.replaceAll('\\', '/')) || changed;
43
+ changed = normalizeExportConditionOrder(pkg.json.exports) || changed;
44
+ if (hasDevelopmentSourceExport(pkg.json.exports)) {
45
+ changed = addFileEntry(pkg.json, 'src') || changed;
46
+ }
47
+
48
+ if (changed) {
49
+ writeJsonObject(pkg.packageJsonPath, pkg.json);
50
+ console.log(`updated ${pkg.path}/package.json public metadata`);
51
+ } else {
52
+ console.log(`unchanged ${pkg.path}/package.json public metadata`);
53
+ }
54
+ }
55
+ }
56
+
57
+ export function applyWorkspaceDependencyDefaults(root: string): void {
58
+ const workspaceNames = new Set(getWorkspacePackages(root).map((pkg) => pkg.name));
59
+ for (const pkg of listPackageJsonRecords(root)) {
60
+ const changed = fixWorkspaceDependencyRanges(pkg.json, workspaceNames);
61
+ if (changed) {
62
+ writeJsonObject(pkg.packageJsonPath, pkg.json);
63
+ console.log(`updated ${pkg.path}/package.json workspace dependency ranges`);
64
+ } else {
65
+ console.log(`unchanged ${pkg.path}/package.json workspace dependency ranges`);
66
+ }
67
+ }
68
+ }
69
+
70
+ export function validateRootPackagePolicy(root: string): number {
71
+ const rootPackage = readJsonObject(join(root, 'package.json'));
72
+ if (!rootPackage) {
73
+ console.error('package.json not found or invalid');
74
+ return 1;
75
+ }
76
+ let failures = 0;
77
+ if (!stringProperty(rootPackage, 'name')) {
78
+ console.error('package.json must define name');
79
+ failures++;
80
+ }
81
+ if (!stringProperty(rootPackage, 'license')) {
82
+ console.error('package.json must define repo-wide license');
83
+ failures++;
84
+ }
85
+ if (!repositoryInfo(rootPackage)) {
86
+ console.error('package.json must define repository.url');
87
+ failures++;
88
+ }
89
+ const packageManager = stringProperty(rootPackage, 'packageManager');
90
+ if (!packageManager?.startsWith('bun@')) {
91
+ console.error('package.json packageManager must use bun@<version>');
92
+ failures++;
93
+ }
94
+ const bunVersion = packageManager?.startsWith('bun@') ? packageManager.slice('bun@'.length) : null;
95
+ const devDependencies = recordProperty(rootPackage, 'devDependencies');
96
+ if (!bunVersion || !devDependencies || devDependencies['@types/bun'] !== bunVersion) {
97
+ console.error('package.json devDependencies.@types/bun must match packageManager bun version');
98
+ failures++;
99
+ }
100
+ const engines = recordProperty(rootPackage, 'engines');
101
+ if (!engines || !stringProperty(engines, 'node')) {
102
+ console.error('package.json engines.node must be defined');
103
+ failures++;
104
+ }
105
+ return failures;
106
+ }
107
+
108
+ export function validateNxReleaseConfig(root: string): number {
109
+ const nxJson = readJsonObject(join(root, 'nx.json'));
110
+ if (!nxJson) {
111
+ console.error('nx.json not found or invalid');
112
+ return 1;
113
+ }
114
+ const release = recordProperty(nxJson, 'release');
115
+ const version = release ? recordProperty(release, 'version') : null;
116
+ let failures = 0;
117
+ if (!release) {
118
+ console.error('nx.json release config is missing');
119
+ failures++;
120
+ }
121
+ if (release && stringProperty(release, 'projectsRelationship') !== 'independent') {
122
+ console.error('nx.json release.projectsRelationship must be independent');
123
+ failures++;
124
+ }
125
+ if (!version) {
126
+ console.error('nx.json release.version config is missing');
127
+ failures++;
128
+ }
129
+ if (version && stringProperty(version, 'specifierSource') !== 'conventional-commits') {
130
+ console.error('nx.json release.version.specifierSource must be conventional-commits');
131
+ failures++;
132
+ }
133
+ // Nx requires git-tag as the primary resolver when deriving versions from
134
+ // conventional commits. Disk is only a fallback for initial untagged packages.
135
+ if (version && stringProperty(version, 'currentVersionResolver') !== 'git-tag') {
136
+ console.error('nx.json release.version.currentVersionResolver must be git-tag');
137
+ failures++;
138
+ }
139
+ if (version && stringProperty(version, 'fallbackCurrentVersionResolver') !== 'disk') {
140
+ console.error('nx.json release.version.fallbackCurrentVersionResolver must be disk');
141
+ failures++;
142
+ }
143
+ if (version && !stringProperty(version, 'preVersionCommand')) {
144
+ console.error('nx.json release.version.preVersionCommand must be defined');
145
+ failures++;
146
+ }
147
+ return failures;
148
+ }
149
+
150
+ export function validatePublicTags(root: string): number {
151
+ let failures = 0;
152
+ for (const pkg of getWorkspacePackages(root)) {
153
+ const hasPublicTag = pkg.tags.includes('npm:public');
154
+ if (pkg.private && hasPublicTag) {
155
+ console.error(`${pkg.path}: private package must not have nx tag npm:public`);
156
+ failures++;
157
+ }
158
+ if (!pkg.private && !hasPublicTag) {
159
+ console.error(`${pkg.path}: public package must have nx tag npm:public`);
160
+ failures++;
161
+ }
162
+ }
163
+ if (failures > 0) {
164
+ return failures;
165
+ }
166
+ console.log('npm:public tags are valid.');
167
+ return 0;
168
+ }
169
+
170
+ export function validatePublicPackageMetadata(root: string): number {
171
+ const rootPackage = readJsonObject(join(root, 'package.json'));
172
+ const rootRepository = rootPackage ? repositoryInfo(rootPackage) : null;
173
+ let failures = 0;
174
+ for (const pkg of listPublicPackages(root)) {
175
+ if (!stringProperty(pkg.json, 'license')) {
176
+ console.error(`${pkg.path}: public package must define license`);
177
+ failures++;
178
+ }
179
+ const publishConfig = recordProperty(pkg.json, 'publishConfig');
180
+ if (!publishConfig || stringProperty(publishConfig, 'access') !== 'public') {
181
+ console.error(`${pkg.path}: public package must define publishConfig.access = public`);
182
+ failures++;
183
+ }
184
+ const repository = recordProperty(pkg.json, 'repository');
185
+ const packageRepository = repository ? repositoryInfo(pkg.json) : null;
186
+ if (!rootRepository || !packageRepository || packageRepository.url !== rootRepository.url) {
187
+ console.error(`${pkg.path}: public package repository.url must match root package.json repository.url`);
188
+ failures++;
189
+ }
190
+ if (!repository || stringProperty(repository, 'directory') !== pkg.path.replaceAll('\\', '/')) {
191
+ console.error(`${pkg.path}: public package repository.directory must be ${pkg.path.replaceAll('\\', '/')}`);
192
+ failures++;
193
+ }
194
+ if (!Array.isArray(pkg.json.files)) {
195
+ console.error(`${pkg.path}: public package must define files`);
196
+ failures++;
197
+ }
198
+ if (!isRecord(pkg.json.exports) && !isRecord(pkg.json.bin)) {
199
+ console.error(`${pkg.path}: public package must define exports or bin`);
200
+ failures++;
201
+ }
202
+ if (!hasOwnString(pkg.json, 'types') && !isRecord(pkg.json.bin)) {
203
+ console.error(`${pkg.path}: public library package must define types`);
204
+ failures++;
205
+ }
206
+ }
207
+ return failures;
208
+ }
209
+
210
+ export function validateWorkspaceDependencies(root: string): number {
211
+ const workspaceNames = new Set(getWorkspacePackages(root).map((pkg) => pkg.name));
212
+ let failures = 0;
213
+ for (const pkg of listPackageJsonRecords(root)) {
214
+ for (const field of workspaceDependencyFields) {
215
+ const dependencies = recordProperty(pkg.json, field);
216
+ if (!dependencies) {
217
+ continue;
218
+ }
219
+ for (const [name, range] of Object.entries(dependencies)) {
220
+ if (workspaceNames.has(name) && range !== 'workspace:*') {
221
+ console.error(`${pkg.path}: ${field}.${name} must use workspace:*`);
222
+ failures++;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ if (failures === 0) {
228
+ console.log('Workspace dependency ranges are valid.');
229
+ }
230
+ return failures;
231
+ }
232
+
233
+ function fixWorkspaceDependencyRanges(pkg: Record<string, unknown>, workspaceNames: Set<string>): boolean {
234
+ let changed = false;
235
+ for (const field of workspaceDependencyFields) {
236
+ const dependencies = recordProperty(pkg, field);
237
+ if (!dependencies) {
238
+ continue;
239
+ }
240
+ for (const name of Object.keys(dependencies)) {
241
+ if (workspaceNames.has(name) && dependencies[name] !== 'workspace:*') {
242
+ dependencies[name] = 'workspace:*';
243
+ changed = true;
244
+ }
245
+ }
246
+ }
247
+ return changed;
248
+ }
249
+
250
+ function normalizeExportConditionOrder(value: unknown): boolean {
251
+ if (!isRecord(value)) {
252
+ return false;
253
+ }
254
+ let changed = false;
255
+ for (const child of Object.values(value)) {
256
+ changed = normalizeExportConditionOrder(child) || changed;
257
+ }
258
+ const keys = Object.keys(value);
259
+ if (!keys.includes('types') && !keys.includes('default')) {
260
+ return changed;
261
+ }
262
+ const ordered = [
263
+ ...(keys.includes('types') ? ['types'] : []),
264
+ ...keys.filter((key) => key !== 'types' && key !== 'default'),
265
+ ...(keys.includes('default') ? ['default'] : []),
266
+ ];
267
+ if (keys.join('\n') === ordered.join('\n')) {
268
+ return changed;
269
+ }
270
+ const entries = new Map(keys.map((key) => [key, value[key]]));
271
+ for (const key of keys) {
272
+ delete value[key];
273
+ }
274
+ for (const key of ordered) {
275
+ value[key] = entries.get(key);
276
+ }
277
+ return true;
278
+ }
279
+
280
+ function hasDevelopmentSourceExport(value: unknown): boolean {
281
+ if (!isRecord(value)) {
282
+ return false;
283
+ }
284
+ for (const [key, child] of Object.entries(value)) {
285
+ if ((key === 'development' || key === 'bun') && typeof child === 'string' && child.startsWith('./src/')) {
286
+ return true;
287
+ }
288
+ if (hasDevelopmentSourceExport(child)) {
289
+ return true;
290
+ }
291
+ }
292
+ return false;
293
+ }
294
+
295
+ function addFileEntry(pkg: Record<string, unknown>, entry: string): boolean {
296
+ const files = pkg.files;
297
+ if (!Array.isArray(files) || files.includes(entry)) {
298
+ return false;
299
+ }
300
+ const firstNegated = files.findIndex((file) => typeof file === 'string' && file.startsWith('!'));
301
+ if (firstNegated === -1) {
302
+ files.push(entry);
303
+ } else {
304
+ files.splice(firstNegated, 0, entry);
305
+ }
306
+ return true;
307
+ }
@@ -0,0 +1,101 @@
1
+ import { readFileSync, rmSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { publint } from 'publint';
4
+ import { formatMessage } from 'publint/utils';
5
+ import { isRecord } from '../lib/json.js';
6
+ import { runStatus } from '../lib/run.js';
7
+ import type { PackageInfo } from '../lib/workspace.js';
8
+ import { listPublicPackages } from '../lib/workspace.js';
9
+
10
+ export async function validatePackedPublicPackages(root: string): Promise<number> {
11
+ let failures = 0;
12
+ for (const pkg of listPublicPackages(root)) {
13
+ failures += await validatePackedPublicPackage(root, pkg);
14
+ }
15
+ return failures;
16
+ }
17
+
18
+ async function validatePackedPublicPackage(root: string, pkg: PackageInfo): Promise<number> {
19
+ let failures = 0;
20
+ const packed = await packPackage(root, pkg);
21
+ try {
22
+ const lint = await publint({ pack: { tarball: packed.arrayBuffer }, level: 'warning', strict: true });
23
+ for (const message of lint.messages) {
24
+ console.error(`${pkg.path}: publint ${message.type} ${message.code}: ${formatMessage(message, lint.pkg)}`);
25
+ failures++;
26
+ }
27
+
28
+ const attwArgs = [
29
+ packed.path,
30
+ '--format',
31
+ 'ascii',
32
+ '--no-color',
33
+ '--profile',
34
+ 'node16',
35
+ '--ignore-rules',
36
+ 'cjs-resolves-to-esm',
37
+ ...attwExcludedEntrypointArgs(pkg),
38
+ ];
39
+ const attwStatus = await runStatus('attw', ['--quiet', ...attwArgs], root, true);
40
+ if (attwStatus !== 0) {
41
+ await runStatus('attw', attwArgs, root);
42
+ console.error(`${pkg.path}: are-the-types-wrong validation failed`);
43
+ failures++;
44
+ } else {
45
+ console.log(`${pkg.path}: are-the-types-wrong valid.`);
46
+ }
47
+ } catch (error) {
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ console.error(`${pkg.path}: packed package validation failed: ${message}`);
50
+ failures++;
51
+ } finally {
52
+ unlinkSync(packed.path);
53
+ }
54
+ if (failures === 0) {
55
+ console.log(`${pkg.path}: packed package is valid.`);
56
+ }
57
+ return failures;
58
+ }
59
+
60
+ function attwExcludedEntrypointArgs(pkg: PackageInfo): string[] {
61
+ const excluded = wasmExportEntrypoints(pkg.json.exports);
62
+ return excluded.length === 0 ? [] : ['--exclude-entrypoints', ...excluded];
63
+ }
64
+
65
+ function wasmExportEntrypoints(exports: unknown): string[] {
66
+ if (!isRecord(exports)) {
67
+ return [];
68
+ }
69
+ return Object.entries(exports)
70
+ .filter(([key, value]) => key.startsWith('.') && exportPointsToWasm(value))
71
+ .map(([key]) => key);
72
+ }
73
+
74
+ function exportPointsToWasm(value: unknown): boolean {
75
+ if (typeof value === 'string') {
76
+ return value.endsWith('.wasm');
77
+ }
78
+ return isRecord(value) && Object.values(value).some(exportPointsToWasm);
79
+ }
80
+
81
+ async function packPackage(root: string, pkg: PackageInfo): Promise<{ path: string; arrayBuffer: ArrayBuffer }> {
82
+ const packageDir = join(root, pkg.path);
83
+ const tarballName = `.smoo-${process.pid}-${Date.now()}.tgz`;
84
+ const tarballPath = join(root, tarballName);
85
+ try {
86
+ const status = await runStatus(
87
+ 'bun',
88
+ ['pm', 'pack', '--filename', tarballName, '--ignore-scripts', '--quiet'],
89
+ packageDir,
90
+ true,
91
+ );
92
+ if (status !== 0) {
93
+ throw new Error(`bun pm pack failed with exit code ${status}`);
94
+ }
95
+ const bytes = new Uint8Array(readFileSync(tarballPath));
96
+ return { path: tarballPath, arrayBuffer: bytes.slice().buffer };
97
+ } catch (error) {
98
+ rmSync(tarballPath, { force: true });
99
+ throw error;
100
+ }
101
+ }
@@ -0,0 +1,115 @@
1
+ import { chmodSync, existsSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { validateManagedFiles } from '../managed-files.js';
4
+ import { validateNxSync } from '../nx-sync.js';
5
+ import { fixPackageHygiene, validatePackageHygiene } from '../package-hygiene.js';
6
+ import {
7
+ applyPublicPackageDefaults,
8
+ applyWorkspaceDependencyDefaults,
9
+ validateNxReleaseConfig,
10
+ validatePublicPackageMetadata,
11
+ validatePublicTags,
12
+ validateRootPackagePolicy,
13
+ validateWorkspaceDependencies,
14
+ } from '../package-policy.js';
15
+ import { validatePackedPublicPackages } from '../packed-package.js';
16
+ import { syncRootRuntimeVersions } from '../runtime.js';
17
+
18
+ export interface MonorepoContext {
19
+ root: string;
20
+ syncRuntime: boolean;
21
+ }
22
+
23
+ export interface ValidatePackOptions {
24
+ failFast?: boolean;
25
+ }
26
+
27
+ interface MonorepoPack {
28
+ name: string;
29
+ init?(ctx: MonorepoContext): Promise<void> | void;
30
+ validate?(ctx: MonorepoContext): Promise<number> | number;
31
+ }
32
+
33
+ const packs: MonorepoPack[] = [
34
+ {
35
+ name: 'core',
36
+ async init(ctx) {
37
+ ensureLocalSmooShim(ctx.root);
38
+ if (ctx.syncRuntime) {
39
+ await syncRootRuntimeVersions(ctx.root);
40
+ } else {
41
+ console.log('skip root runtime versions (outside devenv; pass --sync-runtime to force)');
42
+ }
43
+ },
44
+ async validate(ctx) {
45
+ return (
46
+ validateManagedFiles(ctx.root) +
47
+ validateRootPackagePolicy(ctx.root) +
48
+ validateNxReleaseConfig(ctx.root) +
49
+ (await validateNxSync(ctx.root))
50
+ );
51
+ },
52
+ },
53
+ {
54
+ name: 'publishing',
55
+ init(ctx) {
56
+ applyPublicPackageDefaults(ctx.root);
57
+ },
58
+ validate(ctx) {
59
+ return validatePublicTags(ctx.root) + validatePublicPackageMetadata(ctx.root);
60
+ },
61
+ },
62
+ {
63
+ name: 'workspace-dependencies',
64
+ async init(ctx) {
65
+ applyWorkspaceDependencyDefaults(ctx.root);
66
+ await fixPackageHygiene(ctx.root);
67
+ },
68
+ async validate(ctx) {
69
+ return validateWorkspaceDependencies(ctx.root) + (await validatePackageHygiene(ctx.root));
70
+ },
71
+ },
72
+ {
73
+ name: 'packed-packages',
74
+ validate(ctx) {
75
+ return validatePackedPublicPackages(ctx.root);
76
+ },
77
+ },
78
+ ];
79
+
80
+ export async function runInitPacks(ctx: MonorepoContext): Promise<void> {
81
+ for (const pack of packs) {
82
+ await pack.init?.(ctx);
83
+ }
84
+ }
85
+
86
+ export async function runValidatePacks(ctx: MonorepoContext, options: ValidatePackOptions = {}): Promise<number> {
87
+ let failures = 0;
88
+ let checkedPacks = 0;
89
+ for (const pack of packs) {
90
+ if (!pack.validate) {
91
+ continue;
92
+ }
93
+ console.log(`${checkedPacks === 0 ? '' : '\n'}== ${pack.name} ==`);
94
+ checkedPacks++;
95
+ const packFailures = await pack.validate(ctx);
96
+ failures += packFailures;
97
+ if (packFailures > 0 && options.failFast) {
98
+ break;
99
+ }
100
+ }
101
+ return failures;
102
+ }
103
+
104
+ function ensureLocalSmooShim(root: string): void {
105
+ const shim = join(root, 'tooling', 'smoo');
106
+ if (!existsSync(shim)) {
107
+ return;
108
+ }
109
+ if ((statSync(shim).mode & 0o755) === 0o755) {
110
+ console.log('unchanged tooling/smoo executable bit');
111
+ return;
112
+ }
113
+ chmodSync(shim, 0o755);
114
+ console.log('updated tooling/smoo executable bit');
115
+ }