@smoothbricks/cli 0.4.2 → 0.5.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.
@@ -1,5 +1,7 @@
1
- import { readFileSync, rmSync, unlinkSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
5
  import { publint } from 'publint';
4
6
  import { formatMessage } from 'publint/utils';
5
7
  import { isRecord } from '../lib/json.js';
@@ -86,39 +88,158 @@ async function validatePackedManifest(
86
88
  }
87
89
 
88
90
  async function validateAttw(root: string, pkg: PackageInfo, packed: { path: string }): Promise<number> {
89
- const attwArgs = [
90
- packed.path,
91
- '--format',
92
- 'ascii',
93
- '--no-color',
94
- '--profile',
95
- 'node16',
96
- '--ignore-rules',
97
- 'cjs-resolves-to-esm',
98
- ...attwExcludedEntrypointArgs(pkg),
99
- ];
100
- const attw = await runResult('attw', attwArgs, root);
101
- if (attw.exitCode === 0) {
91
+ const attw = await loadAttwCore();
92
+ const analysis = await attw.checkPackage(await createAttwPackageFromTarball(attw, root, packed.path), {
93
+ excludeEntrypoints: wasmExportEntrypoints(pkg.json.exports),
94
+ });
95
+ if (!isRecord(analysis) || analysis.types === false) {
102
96
  return 0;
103
97
  }
104
- printAttwOutput(attw.stdout);
105
- printAttwOutput(attw.stderr);
98
+ const rawProblems = Array.isArray(analysis.problems) ? analysis.problems : [];
99
+ const problems = rawProblems.filter(isReportedAttwProblem);
100
+ if (problems.length === 0) {
101
+ return 0;
102
+ }
103
+ for (const problem of problems) {
104
+ console.error(`${pkg.path}: are-the-types-wrong ${problem.kind}${formatProblemLocation(problem)}`);
105
+ }
106
106
  console.error(`${pkg.path}: are-the-types-wrong validation failed`);
107
107
  return 1;
108
108
  }
109
109
 
110
- function printAttwOutput(output: string): void {
111
- for (const line of output.split('\n')) {
112
- if (!line || line.includes('(ignored)')) {
110
+ interface AttwCore {
111
+ createPackage: (files: Record<string, Uint8Array>, packageName: string, packageVersion: string) => unknown;
112
+ checkPackage: (pkg: unknown, options: { excludeEntrypoints: string[] }) => Promise<unknown>;
113
+ }
114
+
115
+ async function loadAttwCore(): Promise<AttwCore> {
116
+ const packageJson = fileURLToPath(import.meta.resolve('@arethetypeswrong/core/package.json'));
117
+ const core = await import(pathToFileURL(join(dirname(packageJson), 'dist', 'index.js')).href);
118
+ if (!isRecord(core)) {
119
+ throw new Error('@arethetypeswrong/core does not expose the expected API');
120
+ }
121
+ const PackageConstructor = core.Package;
122
+ const checkPackage = core.checkPackage;
123
+ if (typeof PackageConstructor !== 'function' || typeof checkPackage !== 'function') {
124
+ throw new Error('@arethetypeswrong/core does not expose the expected API');
125
+ }
126
+ return {
127
+ createPackage: (files, packageName, packageVersion) =>
128
+ Reflect.construct(PackageConstructor, [files, packageName, packageVersion]),
129
+ checkPackage: (pkg, options) => Promise.resolve(checkPackage(pkg, options)),
130
+ };
131
+ }
132
+
133
+ async function createAttwPackageFromTarball(attw: AttwCore, root: string, tarballPath: string): Promise<unknown> {
134
+ const temp = mkdtempSync(join(tmpdir(), 'smoo-attw-'));
135
+ try {
136
+ const extract = await runResult('tar', ['-xzf', tarballPath, '-C', temp], root);
137
+ if (extract.exitCode !== 0) {
138
+ printCommandOutput(extract.stdout, extract.stderr);
139
+ throw new Error('unable to extract packed package for are-the-types-wrong');
140
+ }
141
+ return createAttwPackageFromDirectory(attw, join(temp, 'package'));
142
+ } finally {
143
+ rmSync(temp, { recursive: true, force: true });
144
+ }
145
+ }
146
+
147
+ function createAttwPackageFromDirectory(attw: AttwCore, packageDir: string): unknown {
148
+ const packageJson = readJsonFile(join(packageDir, 'package.json'));
149
+ const packageName = typeof packageJson.name === 'string' ? packageJson.name : null;
150
+ const packageVersion = typeof packageJson.version === 'string' ? packageJson.version : null;
151
+ if (!packageName || !packageVersion) {
152
+ throw new Error('packed package.json must contain name and version');
153
+ }
154
+ const files: Record<string, Uint8Array> = {};
155
+ collectAttwFiles(packageDir, packageDir, packageName, files);
156
+ return attw.createPackage(files, packageName, packageVersion);
157
+ }
158
+
159
+ function collectAttwFiles(root: string, current: string, packageName: string, files: Record<string, Uint8Array>): void {
160
+ for (const entry of readdirSync(current)) {
161
+ const path = join(current, entry);
162
+ const stat = statSync(path);
163
+ if (stat.isDirectory()) {
164
+ collectAttwFiles(root, path, packageName, files);
113
165
  continue;
114
166
  }
115
- console.error(line);
167
+ if (!stat.isFile()) {
168
+ continue;
169
+ }
170
+ const packageRelativePath = relative(root, path).split('\\').join('/');
171
+ files[`/node_modules/${packageName}/${packageRelativePath}`] = new Uint8Array(readFileSync(path));
172
+ }
173
+ }
174
+
175
+ function readJsonFile(path: string): Record<string, unknown> {
176
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
177
+ if (!isRecord(parsed)) {
178
+ throw new Error(`${path} is not a JSON object`);
179
+ }
180
+ return parsed;
181
+ }
182
+
183
+ function isReportedAttwProblem(problem: unknown): boolean {
184
+ if (!isRecord(problem) || typeof problem.kind !== 'string') {
185
+ return false;
186
+ }
187
+ const flag = attwProblemFlag(problem.kind);
188
+ if (flag === 'cjs-resolves-to-esm') {
189
+ return false;
116
190
  }
191
+ return problem.resolutionKind !== 'node10';
117
192
  }
118
193
 
119
- function attwExcludedEntrypointArgs(pkg: PackageInfo): string[] {
120
- const excluded = wasmExportEntrypoints(pkg.json.exports);
121
- return excluded.length === 0 ? [] : ['--exclude-entrypoints', ...excluded];
194
+ function attwProblemFlag(kind: string): string | null {
195
+ switch (kind) {
196
+ case 'NoResolution':
197
+ return 'no-resolution';
198
+ case 'UntypedResolution':
199
+ return 'untyped-resolution';
200
+ case 'FalseCJS':
201
+ return 'false-cjs';
202
+ case 'FalseESM':
203
+ return 'false-esm';
204
+ case 'CJSResolvesToESM':
205
+ return 'cjs-resolves-to-esm';
206
+ case 'FallbackCondition':
207
+ return 'fallback-condition';
208
+ case 'CJSOnlyExportsDefault':
209
+ return 'cjs-only-exports-default';
210
+ case 'NamedExports':
211
+ return 'named-exports';
212
+ case 'FalseExportDefault':
213
+ return 'false-export-default';
214
+ case 'MissingExportEquals':
215
+ return 'missing-export-equals';
216
+ case 'UnexpectedModuleSyntax':
217
+ return 'unexpected-module-syntax';
218
+ case 'InternalResolutionError':
219
+ return 'internal-resolution-error';
220
+ default:
221
+ return null;
222
+ }
223
+ }
224
+
225
+ function formatProblemLocation(problem: unknown): string {
226
+ if (!isRecord(problem)) {
227
+ return '';
228
+ }
229
+ const entrypoint = typeof problem.entrypoint === 'string' ? ` ${problem.entrypoint}` : '';
230
+ const resolution =
231
+ typeof problem.resolutionKind === 'string' ? ` ${formatResolutionKind(problem.resolutionKind)}` : '';
232
+ return `${entrypoint}${resolution}`;
233
+ }
234
+
235
+ function formatResolutionKind(kind: string): string {
236
+ if (kind === 'node16-cjs') {
237
+ return 'node16 (from CJS)';
238
+ }
239
+ if (kind === 'node16-esm') {
240
+ return 'node16 (from ESM)';
241
+ }
242
+ return kind;
122
243
  }
123
244
 
124
245
  function wasmExportEntrypoints(exports: unknown): string[] {
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { type PrResolveShell, resolvePrConflicts } from './index.js';
6
+
7
+ let gitDir: string;
8
+
9
+ beforeEach(() => {
10
+ gitDir = mkdtempSync(join(tmpdir(), 'smoo-pr-'));
11
+ spyOn(console, 'log').mockImplementation(() => {});
12
+ spyOn(console, 'error').mockImplementation(() => {});
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(gitDir, { recursive: true, force: true });
17
+ });
18
+
19
+ const PR_JSON = JSON.stringify({
20
+ number: 40,
21
+ url: 'https://github.com/conloca/private/pull/40',
22
+ headRefName: 'gar-sync/private-to-public',
23
+ baseRefName: 'public-mirror',
24
+ isCrossRepository: false,
25
+ });
26
+ const MARKERED = '{\n<<<<<<< HEAD\n "a": 1\n=======\n "a": 2\n>>>>>>> feat\n}\n';
27
+ const CLEAN = '{\n "a": 1\n}\n';
28
+
29
+ const statePath = () => join(gitDir, 'smoo', 'pr-resolve.json');
30
+
31
+ describe('smoo pr resolve — phase A (start)', () => {
32
+ it('reports clean and writes no state when the PR has no markers', async () => {
33
+ const shell = scriptedShell([
34
+ ok(gitDir), // rev-parse --absolute-git-dir
35
+ ok(''), // status --porcelain (clean tree)
36
+ ok(PR_JSON), // gh pr view
37
+ ok('a.json\n'), // diff --name-only base...head
38
+ ok(CLEAN), // show head:a.json
39
+ ]);
40
+ const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
41
+ expect(exit).toBe(0);
42
+ expect(existsSync(statePath())).toBe(false);
43
+ expect(shell.runCommands).not.toContainEqual(expect.stringContaining('checkout'));
44
+ });
45
+
46
+ it('checks out the branch, saves state, and returns 2 when markers exist', async () => {
47
+ const shell = scriptedShell([
48
+ ok(gitDir),
49
+ ok(''),
50
+ ok(PR_JSON),
51
+ ok('a.json\n'),
52
+ ok(MARKERED),
53
+ ok('main\n'), // symbolic-ref (current branch)
54
+ ok('deadbeefcafe\n'), // rev-parse HEAD
55
+ ]);
56
+ const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
57
+ expect(exit).toBe(2);
58
+ const state = JSON.parse(readFileSync(statePath(), 'utf8'));
59
+ expect(state.pr).toBe(40);
60
+ expect(state.headBranch).toBe('gar-sync/private-to-public');
61
+ expect(state.originalBranch).toBe('main');
62
+ expect(state.remote).toBe('origin');
63
+ expect(shell.runCommands).toContainEqual(
64
+ 'git checkout -B gar-sync/private-to-public origin/gar-sync/private-to-public',
65
+ );
66
+ });
67
+
68
+ it('refuses to start on a dirty working tree', async () => {
69
+ const shell = scriptedShell([ok(gitDir), ok(' M src/x.ts\n')]);
70
+ const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
71
+ expect(exit).toBe(1);
72
+ expect(existsSync(statePath())).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('smoo pr resolve — phase B (finish)', () => {
77
+ it('pushes and restores the original branch when markers are resolved', async () => {
78
+ writeState({ originalBranch: 'main' });
79
+ const shell = scriptedShell([
80
+ ok(gitDir),
81
+ ok('gar-sync/private-to-public\n'), // symbolic-ref (on the PR branch)
82
+ ok(''), // status --porcelain (committed, clean)
83
+ ok('a.json\n'), // diff --name-only base...HEAD
84
+ ok(CLEAN), // show HEAD:a.json (resolved)
85
+ ok(''), // git push (fast-forward)
86
+ ]);
87
+ const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
88
+ expect(exit).toBe(0);
89
+ expect(existsSync(statePath())).toBe(false);
90
+ expect(shell.runResultCommands).toContainEqual('git push origin HEAD:refs/heads/gar-sync/private-to-public');
91
+ expect(shell.runCommands).toContainEqual('git checkout main');
92
+ });
93
+
94
+ it('returns 2 and keeps state when markers still remain', async () => {
95
+ writeState({ originalBranch: 'main' });
96
+ const shell = scriptedShell([
97
+ ok(gitDir),
98
+ ok('gar-sync/private-to-public\n'),
99
+ ok(''),
100
+ ok('a.json\n'),
101
+ ok(MARKERED), // still markered
102
+ ]);
103
+ const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
104
+ expect(exit).toBe(2);
105
+ expect(existsSync(statePath())).toBe(true);
106
+ expect(shell.runResultCommands).not.toContainEqual(expect.stringContaining('push'));
107
+ });
108
+
109
+ it('returns 2 when the resolution is not committed', async () => {
110
+ writeState({ originalBranch: 'main' });
111
+ const shell = scriptedShell([ok(gitDir), ok('gar-sync/private-to-public\n'), ok(' M a.json\n')]);
112
+ const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
113
+ expect(exit).toBe(2);
114
+ expect(existsSync(statePath())).toBe(true);
115
+ });
116
+
117
+ it('errors when HEAD is not on the PR branch', async () => {
118
+ writeState({ originalBranch: 'main' });
119
+ const shell = scriptedShell([ok(gitDir), ok('some-other-branch\n')]);
120
+ const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
121
+ expect(exit).toBe(1);
122
+ expect(existsSync(statePath())).toBe(true);
123
+ });
124
+
125
+ it('errors when pointed at a different PR mid-resolution', async () => {
126
+ writeState({ originalBranch: 'main' });
127
+ const shell = scriptedShell([ok(gitDir)]);
128
+ const exit = await resolvePrConflicts('/repo', '99', {}, shell.shell);
129
+ expect(exit).toBe(1);
130
+ });
131
+ });
132
+
133
+ describe('smoo pr resolve --abort', () => {
134
+ it('restores the original branch and clears state', async () => {
135
+ writeState({ originalBranch: 'main' });
136
+ const shell = scriptedShell([ok(gitDir)]);
137
+ const exit = await resolvePrConflicts('/repo', undefined, { abort: true }, shell.shell);
138
+ expect(exit).toBe(0);
139
+ expect(existsSync(statePath())).toBe(false);
140
+ expect(shell.runCommands).toContainEqual('git checkout main');
141
+ });
142
+ });
143
+
144
+ function writeState(overrides: Record<string, unknown>): void {
145
+ mkdirSync(join(gitDir, 'smoo'), { recursive: true });
146
+ const state = {
147
+ pr: 40,
148
+ url: 'https://github.com/conloca/private/pull/40',
149
+ headBranch: 'gar-sync/private-to-public',
150
+ baseBranch: 'public-mirror',
151
+ remote: 'origin',
152
+ crossRepo: false,
153
+ originalBranch: 'main',
154
+ originalSha: 'deadbeefcafe',
155
+ startedAt: '2026-07-01T00:00:00Z',
156
+ ...overrides,
157
+ };
158
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
159
+ }
160
+
161
+ function ok(stdout: string): { exitCode: number; stdout: string; stderr: string } {
162
+ return { exitCode: 0, stdout, stderr: '' };
163
+ }
164
+
165
+ function scriptedShell(responses: { exitCode: number; stdout: string; stderr: string }[]): {
166
+ shell: PrResolveShell;
167
+ runCommands: string[];
168
+ runResultCommands: string[];
169
+ } {
170
+ const runCommands: string[] = [];
171
+ const runResultCommands: string[] = [];
172
+ let index = 0;
173
+ const shell: PrResolveShell = {
174
+ async runResult(command, args, _cwd) {
175
+ runResultCommands.push(`${command} ${args.join(' ')}`);
176
+ const response = responses[index++];
177
+ if (!response) {
178
+ throw new Error(`unexpected runResult call: ${command} ${args.join(' ')}`);
179
+ }
180
+ return response;
181
+ },
182
+ async run(command, args, _cwd) {
183
+ runCommands.push(`${command} ${args.join(' ')}`);
184
+ },
185
+ };
186
+ return { shell, runCommands, runResultCommands };
187
+ }