ts-repo-utils 7.7.2 → 7.8.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 (62) hide show
  1. package/README.md +197 -144
  2. package/dist/cmd/assert-repo-is-clean.d.mts +1 -1
  3. package/dist/cmd/assert-repo-is-clean.mjs +2 -2
  4. package/dist/cmd/assert-repo-is-clean.mjs.map +1 -1
  5. package/dist/cmd/check-should-run-type-checks.d.mts +1 -1
  6. package/dist/cmd/check-should-run-type-checks.mjs +2 -2
  7. package/dist/cmd/check-should-run-type-checks.mjs.map +1 -1
  8. package/dist/cmd/format-diff-from.d.mts +1 -1
  9. package/dist/cmd/format-diff-from.mjs +2 -2
  10. package/dist/cmd/format-diff-from.mjs.map +1 -1
  11. package/dist/cmd/format-uncommitted.d.mts +1 -1
  12. package/dist/cmd/format-uncommitted.mjs +2 -2
  13. package/dist/cmd/format-uncommitted.mjs.map +1 -1
  14. package/dist/cmd/gen-index-ts.d.mts +1 -1
  15. package/dist/cmd/gen-index-ts.mjs +2 -2
  16. package/dist/cmd/gen-index-ts.mjs.map +1 -1
  17. package/dist/entry-point.mjs +2 -1
  18. package/dist/entry-point.mjs.map +1 -1
  19. package/dist/functions/assert-ext.d.mts +20 -1
  20. package/dist/functions/assert-ext.d.mts.map +1 -1
  21. package/dist/functions/assert-ext.mjs +52 -35
  22. package/dist/functions/assert-ext.mjs.map +1 -1
  23. package/dist/functions/create-result-assert.d.mts +18 -0
  24. package/dist/functions/create-result-assert.d.mts.map +1 -0
  25. package/dist/functions/create-result-assert.mjs +40 -0
  26. package/dist/functions/create-result-assert.mjs.map +1 -0
  27. package/dist/functions/exec-async.d.mts +5 -6
  28. package/dist/functions/exec-async.d.mts.map +1 -1
  29. package/dist/functions/exec-async.mjs +26 -7
  30. package/dist/functions/exec-async.mjs.map +1 -1
  31. package/dist/functions/index.d.mts +1 -0
  32. package/dist/functions/index.d.mts.map +1 -1
  33. package/dist/functions/index.mjs +2 -1
  34. package/dist/functions/index.mjs.map +1 -1
  35. package/dist/functions/should-run.d.mts +1 -1
  36. package/dist/functions/should-run.mjs +1 -1
  37. package/dist/functions/workspace-utils/execute-parallel.mjs.map +1 -1
  38. package/dist/functions/workspace-utils/get-workspace-packages.d.mts.map +1 -1
  39. package/dist/functions/workspace-utils/get-workspace-packages.mjs +2 -2
  40. package/dist/functions/workspace-utils/get-workspace-packages.mjs.map +1 -1
  41. package/dist/index.mjs +2 -1
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/node-global.d.mts +2 -0
  44. package/dist/node-global.d.mts.map +1 -1
  45. package/dist/node-global.mjs +3 -1
  46. package/dist/node-global.mjs.map +1 -1
  47. package/package.json +50 -38
  48. package/src/cmd/assert-repo-is-clean.mts +3 -3
  49. package/src/cmd/check-should-run-type-checks.mts +3 -3
  50. package/src/cmd/format-diff-from.mts +3 -3
  51. package/src/cmd/format-uncommitted.mts +3 -3
  52. package/src/cmd/gen-index-ts.mts +4 -4
  53. package/src/functions/assert-ext.mts +78 -52
  54. package/src/functions/create-result-assert.mts +59 -0
  55. package/src/functions/exec-async.mts +71 -14
  56. package/src/functions/exec-async.test.mts +5 -5
  57. package/src/functions/index.mts +1 -0
  58. package/src/functions/should-run.mts +1 -1
  59. package/src/functions/workspace-utils/execute-parallel.mts +1 -1
  60. package/src/functions/workspace-utils/get-workspace-packages.mts +12 -14
  61. package/src/functions/workspace-utils/run-cmd-in-stages.test.mts +5 -8
  62. package/src/node-global.mts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-repo-utils",
3
- "version": "7.7.2",
3
+ "version": "7.8.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript"
@@ -24,11 +24,11 @@
24
24
  "module": "./dist/entry-point.mjs",
25
25
  "types": "./dist/types.d.mts",
26
26
  "bin": {
27
- "assert-repo-is-clean": "./src/cmd/assert-repo-is-clean.mts",
28
- "check-should-run-type-checks": "./src/cmd/check-should-run-type-checks.mts",
29
- "format-diff-from": "./src/cmd/format-diff-from.mts",
30
- "format-uncommitted": "./src/cmd/format-uncommitted.mts",
31
- "gen-index-ts": "./src/cmd/gen-index-ts.mts"
27
+ "assert-repo-is-clean": "./dist/cmd/assert-repo-is-clean.mjs",
28
+ "check-should-run-type-checks": "./dist/cmd/check-should-run-type-checks.mjs",
29
+ "format-diff-from": "./dist/cmd/format-diff-from.mjs",
30
+ "format-uncommitted": "./dist/cmd/format-uncommitted.mjs",
31
+ "gen-index-ts": "./dist/cmd/gen-index-ts.mjs"
32
32
  },
33
33
  "files": [
34
34
  "src",
@@ -38,13 +38,14 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "build": "tsx ./scripts/cmd/build.mts",
41
+ "build:min": "tsx ./scripts/cmd/build.mts --skip-check",
41
42
  "check-all": "tsx ./scripts/cmd/check-all.mts",
42
43
  "check:ext": "tsx ./scripts/cmd/check-ext.mts",
43
44
  "cspell": "cspell \"**\" --gitignore --gitignore-root ./ --no-progress",
44
45
  "doc": "tsx ./scripts/cmd/gen-docs.mts",
45
46
  "doc:embed": "tsx ./scripts/cmd/embed-samples.mts",
46
- "fmt": "format-uncommitted",
47
- "fmt:diff": "format-diff-from origin/main",
47
+ "fmt": "pnpm run z:format-uncommitted",
48
+ "fmt:diff": "pnpm run z:format-diff-from origin/main",
48
49
  "fmt:full": "prettier --write .",
49
50
  "gh:apply-all": "run-s gh:apply-variables gh:apply-rulesets gh:apply-repository-settings",
50
51
  "gh:apply-repository-settings": "tsx --env-file=.env scripts/github/repository/apply.mts",
@@ -54,72 +55,83 @@
54
55
  "gh:backup-repository-settings": "tsx --env-file=.env scripts/github/repository/backup.mts",
55
56
  "gh:backup-rulesets": "tsx --env-file=.env scripts/github/ruleset/backup.mts",
56
57
  "gi": "run-s gi:scripts gi:src fmt",
57
- "gi:scripts": "gen-index-ts ./scripts/github --index-ext .mts --export-ext .mjs --target-ext .mts --exclude cmd --exclude apply.mts --exclude octokit.mts",
58
- "gi:src": "gen-index-ts ./src --index-ext .mts --export-ext .mjs --target-ext .mts --target-ext .tsx --exclude entry-point.mts --exclude cmd --exclude node-global.mts",
58
+ "gi:scripts": "pnpm run z:gen-index-ts ./scripts/github --index-ext .mts --export-ext .mjs --target-ext .mts --exclude cmd --exclude apply.mts --exclude octokit.mts",
59
+ "gi:src": "pnpm run z:gen-index-ts ./src --index-ext .mts --export-ext .mjs --target-ext .mts --target-ext .tsx --exclude entry-point.mts --exclude cmd --exclude node-global.mts",
59
60
  "lint": "eslint .",
60
61
  "lint:fix": "eslint . --fix",
61
62
  "md": "markdownlint-cli2",
62
63
  "prepare-release": "run-s sync-cli-versions fmt build doc",
63
64
  "sync-cli-versions": "tsx scripts/cmd/sync-cli-versions.mts",
64
- "test": "npm run z:vitest -- run",
65
- "test:cov": "npm run z:vitest -- run --coverage",
65
+ "test": "pnpm run z:vitest run",
66
+ "test:cov": "pnpm run z:vitest run --coverage",
66
67
  "test:cov:ui": "vite preview --outDir ./coverage",
67
- "test:ui": "npm run z:vitest -- --ui",
68
- "testw": "npm run z:vitest -- watch",
68
+ "test:ui": "pnpm run z:vitest --ui",
69
+ "testw": "pnpm run z:vitest watch",
69
70
  "tsc": "tsc --noEmit",
70
71
  "tscw": "tsc --noEmit --watch -p ./tsconfig.json",
71
72
  "type-check": "tsc --noEmit",
72
- "update-packages": "npx npm-check-updates -u --install always",
73
+ "update-packages": "pnpm update --latest",
74
+ "z:assert-repo-is-clean": "tsx ./src/cmd/assert-repo-is-clean.mjs",
75
+ "z:check-should-run-type-checks": "tsx ./src/cmd/check-should-run-type-checks.mjs",
76
+ "z:format-diff-from": "tsx ./src/cmd/format-diff-from.mjs",
77
+ "z:format-uncommitted": "tsx ./src/cmd/format-uncommitted.mjs",
78
+ "z:gen-index-ts": "tsx ./src/cmd/gen-index-ts.mjs",
73
79
  "z:vitest": "vitest --config ./configs/vitest.config.ts"
74
80
  },
75
81
  "dependencies": {
76
- "@types/micromatch": "^4.0.9",
77
- "cmd-ts": "^0.14.1",
82
+ "@types/micromatch": "^4.0.10",
83
+ "cmd-ts": "^0.14.3",
78
84
  "fast-glob": "^3.3.3",
79
85
  "micromatch": "^4.0.8",
80
86
  "prettier": "^3.6.2",
81
- "ts-data-forge": "^3.3.0",
87
+ "ts-data-forge": "^3.3.1",
82
88
  "tsx": "^4.20.6"
83
89
  },
84
90
  "devDependencies": {
85
- "@octokit/core": "^7.0.3",
86
- "@rollup/plugin-replace": "^6.0.2",
91
+ "@octokit/core": "7.0.6",
92
+ "@rollup/plugin-replace": "^6.0.3",
87
93
  "@rollup/plugin-strip": "^3.0.4",
88
- "@rollup/plugin-typescript": "^12.1.4",
94
+ "@rollup/plugin-typescript": "^12.3.0",
89
95
  "@semantic-release/changelog": "^6.0.3",
90
96
  "@semantic-release/commit-analyzer": "^13.0.1",
91
97
  "@semantic-release/exec": "^7.1.0",
92
98
  "@semantic-release/git": "^10.0.1",
93
- "@semantic-release/github": "^11.0.6",
94
- "@semantic-release/npm": "^12.0.2",
99
+ "@semantic-release/github": "^12.0.1",
100
+ "@semantic-release/npm": "^13.1.1",
95
101
  "@semantic-release/release-notes-generator": "^14.1.0",
96
- "@types/node": "^24.5.2",
97
- "@vitest/coverage-v8": "^3.2.4",
98
- "@vitest/ui": "^3.2.4",
102
+ "@types/node": "^24.9.2",
103
+ "@vitest/coverage-v8": "^4.0.6",
104
+ "@vitest/ui": "^4.0.6",
99
105
  "conventional-changelog-conventionalcommits": "^9.1.0",
100
- "cspell": "^9.2.1",
106
+ "cspell": "^9.2.2",
101
107
  "dedent": "^1.7.0",
102
- "eslint": "^9.37.0",
103
- "eslint-config-typed": "^1.2.8",
108
+ "eslint": "9.38.0",
109
+ "eslint-config-typed": "^3.1.1",
104
110
  "fast-glob": "^3.3.3",
111
+ "jiti": "^2.6.1",
112
+ "markdownlint": "^0.39.0",
105
113
  "markdownlint-cli2": "^0.18.1",
106
114
  "npm-run-all2": "^8.0.4",
107
- "octokit-safe-types": "^1.0.0",
115
+ "octokit-safe-types": "^1.1.2",
108
116
  "prettier": "^3.6.2",
109
117
  "prettier-plugin-organize-imports": "^4.3.0",
110
118
  "prettier-plugin-packagejson": "^2.5.19",
111
- "rollup": "^4.52.3",
112
- "semantic-release": "^24.2.9",
113
- "ts-fortress": "^4.3.0",
114
- "ts-repo-utils": "^7.5.0",
115
- "ts-type-forge": "^2.2.0",
119
+ "rollup": "^4.52.5",
120
+ "semantic-release": "^25.0.1",
121
+ "ts-fortress": "^5.2.0",
122
+ "ts-type-forge": "^2.3.0",
116
123
  "tslib": "^2.8.1",
117
- "typedoc": "^0.28.13",
124
+ "tsx": "^4.20.6",
125
+ "typedoc": "^0.28.14",
118
126
  "typedoc-plugin-markdown": "^4.9.0",
119
- "typescript": "^5.9.2",
120
- "vitest": "^3.2.4"
127
+ "typescript": "^5.9.3",
128
+ "vitest": "^4.0.6"
121
129
  },
122
130
  "peerDependencies": {
123
131
  "prettier": "^3.6.2"
132
+ },
133
+ "volta": {
134
+ "node": "25.0.0",
135
+ "npm": "11.6.2"
124
136
  }
125
137
  }
@@ -1,11 +1,11 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
4
  import { assertRepoIsClean } from '../functions/index.mjs';
5
5
 
6
6
  const cmdDef = cmd.command({
7
7
  name: 'assert-repo-is-clean-cli',
8
- version: '7.7.2',
8
+ version: '7.8.0',
9
9
  args: {
10
10
  silent: cmd.flag({
11
11
  long: 'silent',
@@ -14,7 +14,7 @@ const cmdDef = cmd.command({
14
14
  }),
15
15
  },
16
16
  handler: (args) => {
17
- main(args).catch((error) => {
17
+ main(args).catch((error: unknown) => {
18
18
  console.error('An error occurred:', error);
19
19
  process.exit(1);
20
20
  });
@@ -1,11 +1,11 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
4
  import { checkShouldRunTypeChecks } from '../functions/index.mjs';
5
5
 
6
6
  const cmdDef = cmd.command({
7
7
  name: 'check-should-run-type-checks-cli',
8
- version: '7.7.2',
8
+ version: '7.8.0',
9
9
  args: {
10
10
  pathsIgnore: cmd.multioption({
11
11
  long: 'paths-ignore',
@@ -21,7 +21,7 @@ const cmdDef = cmd.command({
21
21
  }),
22
22
  },
23
23
  handler: (args) => {
24
- main(args).catch((error) => {
24
+ main(args).catch((error: unknown) => {
25
25
  console.error('An error occurred:', error);
26
26
  process.exit(1);
27
27
  });
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
4
  import { Result } from 'ts-data-forge';
@@ -6,7 +6,7 @@ import { formatDiffFrom } from '../functions/index.mjs';
6
6
 
7
7
  const cmdDef = cmd.command({
8
8
  name: 'format-diff-from-cli',
9
- version: '7.7.2',
9
+ version: '7.8.0',
10
10
  args: {
11
11
  base: cmd.positional({
12
12
  type: cmd.string,
@@ -50,7 +50,7 @@ const cmdDef = cmd.command({
50
50
  excludeStaged: args.excludeStaged ?? false,
51
51
  ignoreUnknown: args.ignoreUnknown ?? true,
52
52
  silent: args.silent ?? false,
53
- }).catch((error) => {
53
+ }).catch((error: unknown) => {
54
54
  console.error('An error occurred:', error);
55
55
  process.exit(1);
56
56
  });
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
4
  import { Result } from 'ts-data-forge';
@@ -6,7 +6,7 @@ import { formatUncommittedFiles } from '../functions/index.mjs';
6
6
 
7
7
  const cmdDef = cmd.command({
8
8
  name: 'format-uncommitted-cli',
9
- version: '7.7.2',
9
+ version: '7.8.0',
10
10
  args: {
11
11
  excludeUntracked: cmd.flag({
12
12
  long: 'exclude-untracked',
@@ -44,7 +44,7 @@ const cmdDef = cmd.command({
44
44
  excludeStaged: args.excludeStaged ?? false,
45
45
  ignoreUnknown: args.ignoreUnknown ?? true,
46
46
  silent: args.silent ?? false,
47
- }).catch((error) => {
47
+ }).catch((error: unknown) => {
48
48
  console.error('An error occurred:', error);
49
49
  process.exit(1);
50
50
  });
@@ -1,8 +1,8 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
4
 
5
- // eslint-disable-next-line import/no-internal-modules
5
+ // eslint-disable-next-line import-x/no-internal-modules
6
6
  import { type InputOf, type OutputOf } from 'cmd-ts/dist/esm/from.js';
7
7
  import { expectType } from 'ts-data-forge';
8
8
  import { genIndex } from '../functions/index.mjs';
@@ -40,7 +40,7 @@ const nonEmptyArray = <T extends cmd.Type<any, any>>(
40
40
 
41
41
  const cmdDef = cmd.command({
42
42
  name: 'gen-index-ts-cli',
43
- version: '7.7.2',
43
+ version: '7.8.0',
44
44
  args: {
45
45
  // required args
46
46
  targetDirectory: cmd.positional({
@@ -109,7 +109,7 @@ const cmdDef = cmd.command({
109
109
  expectType<typeof args.formatCommand, string | undefined>('=');
110
110
  expectType<typeof args.silent, boolean | undefined>('=');
111
111
 
112
- main(args).catch((error) => {
112
+ main(args).catch((error: unknown) => {
113
113
  console.error('An error occurred:', error);
114
114
  process.exit(1);
115
115
  });
@@ -1,6 +1,7 @@
1
- import { Arr, type IMap, isString } from 'ts-data-forge';
1
+ import { Arr, type IMap, isString, Result } from 'ts-data-forge';
2
2
  import '../node-global.mjs';
3
3
  import { assertPathExists } from './assert-path-exists.mjs';
4
+ import { createResultAssert } from './create-result-assert.mjs';
4
5
 
5
6
  /** Configuration for directory extension checking. */
6
7
  export type CheckExtConfig = DeepReadonly<{
@@ -23,13 +24,21 @@ export type CheckExtConfig = DeepReadonly<{
23
24
  }[];
24
25
  }>;
25
26
 
27
+ export type CheckExtError = Readonly<{
28
+ message: string;
29
+ files: readonly string[];
30
+ }>;
31
+
26
32
  /**
27
33
  * Validates that all files in specified directories have the correct
28
- * extensions. Exits with code 1 if any files have incorrect extensions.
34
+ * extensions.
29
35
  *
30
36
  * @param config - Configuration specifying directories and expected extensions.
37
+ * @returns Result.ok when all files pass, otherwise Result.err with details.
31
38
  */
32
- export const assertExt = async (config: CheckExtConfig): Promise<void> => {
39
+ export const checkExt = async (
40
+ config: CheckExtConfig,
41
+ ): Promise<Result<undefined, CheckExtError>> => {
33
42
  // Check all directories in parallel
34
43
  const results = await Promise.all(
35
44
  config.directories.map(async ({ path: dir, extension, ignorePatterns }) => {
@@ -49,59 +58,43 @@ export const assertExt = async (config: CheckExtConfig): Promise<void> => {
49
58
  // Collect all incorrect files
50
59
  const allIncorrectFiles: readonly string[] = results.flat();
51
60
 
52
- if (allIncorrectFiles.length > 0) {
53
- const generateErrorMessage = (): string => {
54
- // Group directories by extension for a cleaner message
55
- const extensionGroups: IMap<
56
- string,
57
- readonly Readonly<{
58
- relativePath: string;
59
- extKey: string;
60
- }>[]
61
- > = Arr.groupBy(
62
- config.directories.map(({ path: dirPath, extension }) => {
63
- const relativePath = path.relative(process.cwd(), dirPath);
64
- const extKey = isString(extension)
65
- ? extension
66
- : extension.join(' or ');
67
-
68
- return {
69
- relativePath,
70
- extKey,
71
- };
72
- }),
73
- ({ extKey }) => extKey,
74
- );
75
-
76
- // Generate message parts for each extension
77
- const messageParts = Array.from(
78
- extensionGroups.entries(),
79
- ([ext, dirs]) => {
80
- const dirList =
81
- dirs.length === 1
82
- ? dirs[0]?.relativePath
83
- : dirs.map((d) => d.relativePath).join(', ');
84
- return `${dirList} should have ${ext} extension`;
85
- },
86
- );
87
-
88
- return `All files in ${messageParts.join(' and ')}.`;
89
- };
90
-
91
- const errorMessage = [
92
- 'Files with incorrect extensions found:',
93
- ...allIncorrectFiles.map((file) => ` - ${file}`),
94
- '',
95
- generateErrorMessage(),
96
- ].join('\n');
97
-
98
- echo(errorMessage);
99
- process.exit(1);
61
+ if (allIncorrectFiles.length === 0) {
62
+ return Result.ok(undefined);
100
63
  }
101
64
 
102
- echo('✓ All files have correct extensions');
65
+ const message = [
66
+ 'Files with incorrect extensions found:',
67
+ ...allIncorrectFiles.map((file) => ` - ${file}`),
68
+ '',
69
+ describeExpectedExtensions(config),
70
+ ].join('\n');
71
+
72
+ return Result.err({
73
+ message,
74
+ files: allIncorrectFiles,
75
+ });
103
76
  };
104
77
 
78
+ /**
79
+ * Validates that all files in specified directories have the correct
80
+ * extensions. Exits with code 1 if any files have incorrect extensions.
81
+ *
82
+ * @param config - Configuration specifying directories and expected extensions.
83
+ */
84
+ export const assertExt = createResultAssert<
85
+ CheckExtConfig,
86
+ undefined,
87
+ CheckExtError
88
+ >({
89
+ run: checkExt,
90
+ onError: (error) => {
91
+ echo(error.message);
92
+ },
93
+ onSuccess: () => {
94
+ echo('✓ All files have correct extensions');
95
+ },
96
+ });
97
+
105
98
  /**
106
99
  * Checks if all files in a directory have the expected extension.
107
100
  *
@@ -134,3 +127,36 @@ const getFilesWithIncorrectExtension = async (
134
127
  (file) => !expectedExtensions.some((ext) => file.endsWith(ext)),
135
128
  );
136
129
  };
130
+
131
+ const describeExpectedExtensions = (config: CheckExtConfig): string => {
132
+ // Group directories by extension for a cleaner message
133
+ const extensionGroups: IMap<
134
+ string,
135
+ readonly Readonly<{
136
+ relativePath: string;
137
+ extKey: string;
138
+ }>[]
139
+ > = Arr.groupBy(
140
+ config.directories.map(({ path: dirPath, extension }) => {
141
+ const relativePath = path.relative(process.cwd(), dirPath);
142
+ const extKey = isString(extension) ? extension : extension.join(' or ');
143
+
144
+ return {
145
+ relativePath,
146
+ extKey,
147
+ };
148
+ }),
149
+ ({ extKey }) => extKey,
150
+ );
151
+
152
+ // Generate message parts for each extension
153
+ const messageParts = Array.from(extensionGroups.entries(), ([ext, dirs]) => {
154
+ const dirList =
155
+ dirs.length === 1
156
+ ? dirs[0]?.relativePath
157
+ : dirs.map((d) => d.relativePath).join(', ');
158
+ return `${dirList} should have ${ext} extension`;
159
+ });
160
+
161
+ return `All files in ${messageParts.join(' and ')}.`;
162
+ };
@@ -0,0 +1,59 @@
1
+ import { hasKey, isRecord, isString, Result } from 'ts-data-forge';
2
+ import '../node-global.mjs';
3
+
4
+ type ResultProducer<TConfig, TOk, TErr> = (
5
+ config: TConfig,
6
+ ) => Promise<Result<TOk, TErr>>;
7
+
8
+ export type CreateResultAssertOptions<Config, Ok, Err> = Readonly<{
9
+ run: ResultProducer<Config, Ok, Err>;
10
+ onSuccess?: (value: Ok, config: Config) => void | Promise<void>;
11
+ onError?: (error: Err, config: Config) => void | Promise<void>;
12
+ exitCode?: number;
13
+ }>;
14
+
15
+ /**
16
+ * Converts a function that returns a Result into an assert-style variant that
17
+ * exits the process when the Result is Err. This is useful for building CLI
18
+ * commands that should stop execution on failure but remain composable when a
19
+ * Result is preferred.
20
+ */
21
+ export const createResultAssert = <Config, Ok, Err>({
22
+ run,
23
+ onSuccess,
24
+ onError,
25
+ exitCode = 1,
26
+ }: CreateResultAssertOptions<Config, Ok, Err>): ((
27
+ config: Config,
28
+ ) => Promise<Ok>) => {
29
+ const defaultOnError = (error: Err): void => {
30
+ if (
31
+ isRecord(error) &&
32
+ hasKey(error, 'message') &&
33
+ isString(error.message)
34
+ ) {
35
+ echo(error.message);
36
+ } else {
37
+ console.error(error);
38
+ }
39
+ };
40
+
41
+ return async (config: Config): Promise<Ok> => {
42
+ const result = await run(config);
43
+
44
+ if (Result.isErr(result)) {
45
+ if (onError !== undefined) {
46
+ await onError(result.value, config);
47
+ } else {
48
+ defaultOnError(result.value);
49
+ }
50
+ process.exit(exitCode);
51
+ }
52
+
53
+ if (onSuccess !== undefined) {
54
+ await onSuccess(result.value, config);
55
+ }
56
+
57
+ return result.value;
58
+ };
59
+ };
@@ -5,6 +5,18 @@ type ExecOptionsCustom = Readonly<{
5
5
  silent?: boolean;
6
6
  }>;
7
7
 
8
+ type ExecOptionsWithStringEncoding = Readonly<
9
+ childProcess.ExecOptionsWithStringEncoding & ExecOptionsCustom
10
+ >;
11
+
12
+ type ExecOptionsWithBufferEncoding = Readonly<
13
+ childProcess.ExecOptionsWithBufferEncoding & ExecOptionsCustom
14
+ >;
15
+
16
+ type NormalizedExecOptions = Readonly<
17
+ childProcess.ExecOptions & { encoding?: BufferEncoding | 'buffer' | null }
18
+ >;
19
+
8
20
  export type ExecOptions = childProcess.ExecOptions & ExecOptionsCustom;
9
21
 
10
22
  export type ExecResult<T extends string | Buffer> = Result<
@@ -23,47 +35,92 @@ export type ExecResult<T extends string | Buffer> = Result<
23
35
  export function $(
24
36
  command: string,
25
37
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
26
- options?:
27
- | ExecOptionsCustom
28
- | Readonly<{ encoding: BufferEncoding } & ExecOptions>,
38
+ options?: ExecOptionsWithStringEncoding,
29
39
  ): Promise<ExecResult<string>>;
30
40
 
31
41
  export function $(
32
42
  command: string,
33
43
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
34
- options?: Readonly<{ encoding: 'buffer' | null } & ExecOptions>,
44
+ options: ExecOptionsWithBufferEncoding,
35
45
  ): Promise<ExecResult<Buffer>>;
36
46
 
47
+ export function $<
48
+ TOptions extends
49
+ | ExecOptionsWithBufferEncoding
50
+ | ExecOptionsWithStringEncoding
51
+ | undefined = undefined,
52
+ >(
53
+ command: string,
54
+ options?: TOptions,
55
+ ): Promise<
56
+ ExecResult<TOptions extends ExecOptionsWithBufferEncoding ? Buffer : string>
57
+ >;
58
+
37
59
  export function $(
38
60
  command: string,
39
61
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
40
- options?: Readonly<
41
- { encoding?: BufferEncoding | 'buffer' | null } & ExecOptions
42
- >,
62
+ options?: ExecOptionsWithStringEncoding | ExecOptionsWithBufferEncoding,
43
63
  ): Promise<ExecResult<string | Buffer>> {
44
64
  const { silent = false, ...restOptions } = options ?? {};
65
+ const normalizedOptions: NormalizedExecOptions = restOptions;
45
66
 
46
67
  if (!silent) {
47
68
  echo(`$ ${command}`);
48
69
  }
49
70
 
50
71
  return new Promise((resolve) => {
51
- // eslint-disable-next-line security/detect-child-process
52
- childProcess.exec(command, restOptions, (error, stdout, stderr) => {
72
+ const handleResult = <T extends string | Buffer>(
73
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
74
+ error: childProcess.ExecException | null,
75
+ stdout: T,
76
+ stderr: T,
77
+ ): void => {
53
78
  if (!silent) {
54
- if (stdout !== '') {
79
+ if (!isEmpty(stdout)) {
55
80
  echo(stdout);
56
81
  }
57
- if (stderr !== '') {
82
+ if (!isEmpty(stderr)) {
58
83
  console.error(stderr);
59
84
  }
60
85
  }
61
86
 
62
87
  if (error !== null) {
63
88
  resolve(Result.err(error));
64
- } else {
65
- resolve(Result.ok({ stdout, stderr }));
89
+ return;
66
90
  }
67
- });
91
+
92
+ resolve(
93
+ Result.ok<Readonly<{ stdout: T; stderr: T }>>({ stdout, stderr }),
94
+ );
95
+ };
96
+
97
+ const encoding = normalizedOptions.encoding;
98
+
99
+ if (encoding === 'buffer' || encoding === null) {
100
+ // eslint-disable-next-line security/detect-child-process
101
+ childProcess.exec(
102
+ command,
103
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
104
+ normalizedOptions as childProcess.ExecOptionsWithBufferEncoding,
105
+ (error, stdout, stderr) => {
106
+ handleResult(error, stdout, stderr);
107
+ },
108
+ );
109
+ return;
110
+ }
111
+
112
+ // eslint-disable-next-line security/detect-child-process
113
+ childProcess.exec(
114
+ command,
115
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
116
+ normalizedOptions as childProcess.ExecOptionsWithStringEncoding,
117
+ (error, stdout, stderr) => {
118
+ handleResult(error, stdout, stderr);
119
+ },
120
+ );
68
121
  });
69
122
  }
123
+
124
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
125
+ const isEmpty = (value: string | Buffer): boolean =>
126
+ typeof value === 'string' ? value === '' : value.length === 0;
@@ -368,8 +368,8 @@ describe('exec-async', () => {
368
368
  // Type check for native exec with buffer encoding
369
369
  exec('echo "test"', { encoding: 'buffer' }, (error, stdout, stderr) => {
370
370
  expectType<ExecException | null, typeof error>('=');
371
- expectType<Buffer, typeof stdout>('=');
372
- expectType<Buffer, typeof stderr>('=');
371
+ expectType<Buffer, typeof stdout>('>=');
372
+ expectType<Buffer, typeof stderr>('>=');
373
373
  });
374
374
 
375
375
  // The $ function should produce the same types wrapped in Result (suppressed for clean output)
@@ -381,15 +381,15 @@ describe('exec-async', () => {
381
381
  Promise<
382
382
  Result<Readonly<{ stdout: Buffer; stderr: Buffer }>, ExecException>
383
383
  >
384
- >('=');
384
+ >('~=');
385
385
  });
386
386
 
387
387
  test('should match exec callback types for null encoding', () => {
388
388
  // Type check for native exec with null encoding
389
389
  exec('echo "test"', { encoding: null }, (error, stdout, stderr) => {
390
390
  expectType<ExecException | null, typeof error>('=');
391
- expectType<Buffer, typeof stdout>('=');
392
- expectType<Buffer, typeof stderr>('=');
391
+ expectType<Buffer, typeof stdout>('>=');
392
+ expectType<Buffer, typeof stderr>('>=');
393
393
  });
394
394
 
395
395
  // The $ function should produce the same types wrapped in Result (suppressed for clean output)
@@ -1,6 +1,7 @@
1
1
  export * from './assert-ext.mjs';
2
2
  export * from './assert-path-exists.mjs';
3
3
  export * from './assert-repo-is-clean.mjs';
4
+ export * from './create-result-assert.mjs';
4
5
  export * from './diff.mjs';
5
6
  export * from './exec-async.mjs';
6
7
  export * from './format.mjs';