@simplysm/sd-cli 14.2.14 → 14.2.18
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.
- package/dist/commands/publish/deployment-phase.d.ts +1 -1
- package/dist/commands/publish/deployment-phase.d.ts.map +1 -1
- package/dist/commands/publish/deployment-phase.js +63 -51
- package/dist/commands/publish/deployment-phase.js.map +1 -1
- package/dist/commands/publish/npm-publisher.d.ts +13 -2
- package/dist/commands/publish/npm-publisher.d.ts.map +1 -1
- package/dist/commands/publish/npm-publisher.js +113 -11
- package/dist/commands/publish/npm-publisher.js.map +1 -1
- package/dist/commands/publish/publish-command.d.ts +2 -0
- package/dist/commands/publish/publish-command.d.ts.map +1 -1
- package/dist/commands/publish/publish-command.js +55 -9
- package/dist/commands/publish/publish-command.js.map +1 -1
- package/dist/commands/publish/version-upgrade.d.ts.map +1 -1
- package/dist/commands/publish/version-upgrade.js +0 -8
- package/dist/commands/publish/version-upgrade.js.map +1 -1
- package/dist/sd-cli-entry.d.ts.map +1 -1
- package/dist/sd-cli-entry.js +5 -0
- package/dist/sd-cli-entry.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/publish/deployment-phase.ts +89 -48
- package/src/commands/publish/npm-publisher.ts +131 -13
- package/src/commands/publish/publish-command.ts +63 -9
- package/src/commands/publish/version-upgrade.ts +0 -14
- package/src/sd-cli-entry.ts +5 -0
- package/tests/commands/deployment-phase.acc.spec.ts +148 -11
- package/tests/commands/npm-publisher.acc.spec.ts +360 -0
- package/tests/commands/version-upgrade.acc.spec.ts +0 -46
- package/tests/sd-cli-entry.spec.ts +20 -0
|
@@ -8,6 +8,9 @@ const mocks = {
|
|
|
8
8
|
fsx: {
|
|
9
9
|
readJson: vi.spyOn(fsx, "readJson"),
|
|
10
10
|
copy: vi.spyOn(fsx, "copy"),
|
|
11
|
+
mkdir: vi.spyOn(fsx, "mkdir"),
|
|
12
|
+
readdir: vi.spyOn(fsx, "readdir"),
|
|
13
|
+
rm: vi.spyOn(fsx, "rm"),
|
|
11
14
|
},
|
|
12
15
|
storageConnect: vi.spyOn(StorageFactory, "connect"),
|
|
13
16
|
};
|
|
@@ -32,9 +35,30 @@ function createMockLogger() {
|
|
|
32
35
|
} as unknown as ReturnType<typeof import("@simplysm/core-common").createLogger>;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/** npm 호출 중 실제 배포(publish)만 골라낸다. dist-tag 조회(`npm view`)와 구분하기 위함 */
|
|
39
|
+
function isNpmPublish(cmd: string, args?: string[]): boolean {
|
|
40
|
+
return cmd === "npm" && args?.[0] === "publish";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 독립 패키지들이 한 레벨에 오도록 의존성 없는 package.json 을 돌려준다 */
|
|
44
|
+
function mockIndependentPackages(): void {
|
|
45
|
+
mocks.fsx.readJson.mockImplementation(((p: string) => {
|
|
46
|
+
const name = path.basename(path.dirname(p));
|
|
47
|
+
return { name: `@simplysm/${name}`, version: "14.0.1", dependencies: {} };
|
|
48
|
+
}) as never);
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
describe("runDeployment", () => {
|
|
36
52
|
beforeEach(() => {
|
|
37
53
|
vi.clearAllMocks();
|
|
54
|
+
process.exitCode = undefined;
|
|
55
|
+
// npm 배포는 pnpm pack 이 만든 tarball 을 npm publish 로 올린다
|
|
56
|
+
mocks.fsx.mkdir.mockResolvedValue(undefined);
|
|
57
|
+
mocks.fsx.rm.mockResolvedValue(undefined);
|
|
58
|
+
mocks.fsx.readdir.mockResolvedValue(["pkg-14.0.1.tgz"]);
|
|
59
|
+
// clearAllMocks 는 호출 기록만 지우므로, 구현이 테스트 간에 새지 않도록 매번 초기화한다
|
|
60
|
+
mocks.execa.mockImplementation((() => ({ stdout: "", stderr: "", exitCode: 0 })) as never);
|
|
61
|
+
mocks.storageConnect.mockResolvedValue(undefined);
|
|
38
62
|
});
|
|
39
63
|
|
|
40
64
|
it("deploys packages in dependency level order", async () => {
|
|
@@ -47,7 +71,11 @@ describe("runDeployment", () => {
|
|
|
47
71
|
);
|
|
48
72
|
mocks.fsx.readJson.mockImplementation(((p: string) => {
|
|
49
73
|
if (p.includes("pkg-b")) {
|
|
50
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
name: "@simplysm/pkg-b",
|
|
76
|
+
version: "14.0.1",
|
|
77
|
+
dependencies: { "@simplysm/pkg-a": "~14.0.0" },
|
|
78
|
+
};
|
|
51
79
|
}
|
|
52
80
|
return { name: "@simplysm/pkg-a", version: "14.0.1", dependencies: {} };
|
|
53
81
|
}) as never);
|
|
@@ -62,6 +90,7 @@ describe("runDeployment", () => {
|
|
|
62
90
|
CWD,
|
|
63
91
|
logger,
|
|
64
92
|
false,
|
|
93
|
+
undefined,
|
|
65
94
|
);
|
|
66
95
|
|
|
67
96
|
const aIdx = publishOrder.indexOf("pkg-a");
|
|
@@ -69,12 +98,77 @@ describe("runDeployment", () => {
|
|
|
69
98
|
expect(aIdx).toBeLessThan(bIdx);
|
|
70
99
|
});
|
|
71
100
|
|
|
72
|
-
it("
|
|
101
|
+
it("runs npm publishes one at a time so their auth prompts cannot collide", async () => {
|
|
102
|
+
mockIndependentPackages();
|
|
103
|
+
|
|
104
|
+
let active = 0;
|
|
105
|
+
let maxActive = 0;
|
|
106
|
+
mocks.execa.mockImplementation(((cmd: string, args?: string[]) => {
|
|
107
|
+
if (!isNpmPublish(cmd, args)) return { stdout: "", stderr: "", exitCode: 0 };
|
|
108
|
+
active++;
|
|
109
|
+
maxActive = Math.max(maxActive, active);
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
active--;
|
|
113
|
+
resolve({ stdout: "", stderr: "", exitCode: 0 });
|
|
114
|
+
}, 10);
|
|
115
|
+
});
|
|
116
|
+
}) as never);
|
|
117
|
+
|
|
118
|
+
const logger = createMockLogger();
|
|
119
|
+
await runDeployment(
|
|
120
|
+
[
|
|
121
|
+
{ name: "pkg-a", path: pkgPath("pkg-a"), config: { type: "npm" } },
|
|
122
|
+
{ name: "pkg-b", path: pkgPath("pkg-b"), config: { type: "npm" } },
|
|
123
|
+
{ name: "pkg-c", path: pkgPath("pkg-c"), config: { type: "npm" } },
|
|
124
|
+
],
|
|
125
|
+
"14.0.1",
|
|
126
|
+
CWD,
|
|
127
|
+
logger,
|
|
128
|
+
false,
|
|
129
|
+
undefined,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(maxActive).toBe(1);
|
|
133
|
+
expect(process.exitCode).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("still runs non-npm publishes in parallel", async () => {
|
|
137
|
+
mockIndependentPackages();
|
|
138
|
+
|
|
139
|
+
let active = 0;
|
|
140
|
+
let maxActive = 0;
|
|
141
|
+
mocks.storageConnect.mockImplementation(async () => {
|
|
142
|
+
active++;
|
|
143
|
+
maxActive = Math.max(maxActive, active);
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
145
|
+
active--;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const logger = createMockLogger();
|
|
149
|
+
await runDeployment(
|
|
150
|
+
[
|
|
151
|
+
{ name: "pkg-a", path: pkgPath("pkg-a"), config: { type: "ftp", host: "h" } },
|
|
152
|
+
{ name: "pkg-b", path: pkgPath("pkg-b"), config: { type: "ftp", host: "h" } },
|
|
153
|
+
],
|
|
154
|
+
"14.0.1",
|
|
155
|
+
CWD,
|
|
156
|
+
logger,
|
|
157
|
+
false,
|
|
158
|
+
undefined,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(maxActive).toBe(2);
|
|
162
|
+
expect(process.exitCode).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not retry a failed npm publish", async () => {
|
|
73
166
|
let publishAttempts = 0;
|
|
74
|
-
mocks.execa.mockImplementation(() => {
|
|
167
|
+
mocks.execa.mockImplementation(((cmd: string, args?: string[]) => {
|
|
168
|
+
if (!isNpmPublish(cmd, args)) return { stdout: "", stderr: "", exitCode: 0 };
|
|
75
169
|
publishAttempts++;
|
|
76
170
|
throw new Error("publish failed");
|
|
77
|
-
});
|
|
171
|
+
}) as never);
|
|
78
172
|
mocks.fsx.readJson.mockResolvedValue({
|
|
79
173
|
name: "@simplysm/pkg-a",
|
|
80
174
|
version: "14.0.1",
|
|
@@ -88,22 +182,43 @@ describe("runDeployment", () => {
|
|
|
88
182
|
CWD,
|
|
89
183
|
logger,
|
|
90
184
|
false,
|
|
185
|
+
undefined,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// 재시도하면 npm 인증 UI 가 다시 뜨고, 인증·권한·버전 충돌은 재시도해도 같은 결과다
|
|
189
|
+
expect(publishAttempts).toBe(1);
|
|
190
|
+
expect(process.exitCode).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("still retries a failed storage publish up to 3 times", async () => {
|
|
194
|
+
mockIndependentPackages();
|
|
195
|
+
let attempts = 0;
|
|
196
|
+
mocks.storageConnect.mockImplementation(() => {
|
|
197
|
+
attempts++;
|
|
198
|
+
throw new Error("connection reset");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const logger = createMockLogger();
|
|
202
|
+
await runDeployment(
|
|
203
|
+
[{ name: "pkg-a", path: pkgPath("pkg-a"), config: { type: "ftp", host: "h" } }],
|
|
204
|
+
"14.0.1",
|
|
205
|
+
CWD,
|
|
206
|
+
logger,
|
|
207
|
+
false,
|
|
208
|
+
undefined,
|
|
91
209
|
);
|
|
92
210
|
|
|
93
|
-
expect(
|
|
211
|
+
expect(attempts).toBe(3);
|
|
94
212
|
expect(process.exitCode).toBe(1);
|
|
95
213
|
});
|
|
96
214
|
|
|
97
215
|
it("reports partially deployed packages on failure", async () => {
|
|
98
|
-
|
|
99
|
-
const name = path.basename(path.dirname(p));
|
|
100
|
-
return { name: `@simplysm/${name}`, version: "14.0.1", dependencies: {} };
|
|
101
|
-
}) as never);
|
|
216
|
+
mockIndependentPackages();
|
|
102
217
|
|
|
103
218
|
// pkg-a succeeds, pkg-b fails
|
|
104
219
|
mocks.execa.mockImplementation(
|
|
105
|
-
((
|
|
106
|
-
if (opts?.cwd?.includes("pkg-b")) {
|
|
220
|
+
((cmd: string, args?: string[], opts?: { cwd?: string }) => {
|
|
221
|
+
if (isNpmPublish(cmd, args) && opts?.cwd?.includes("pkg-b")) {
|
|
107
222
|
throw new Error("publish failed");
|
|
108
223
|
}
|
|
109
224
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
@@ -120,6 +235,7 @@ describe("runDeployment", () => {
|
|
|
120
235
|
CWD,
|
|
121
236
|
logger,
|
|
122
237
|
false,
|
|
238
|
+
undefined,
|
|
123
239
|
);
|
|
124
240
|
|
|
125
241
|
expect(process.exitCode).toBe(1);
|
|
@@ -130,4 +246,25 @@ describe("runDeployment", () => {
|
|
|
130
246
|
);
|
|
131
247
|
expect(hasPartialMsg).toBe(true);
|
|
132
248
|
});
|
|
249
|
+
|
|
250
|
+
it("passes the OTP through to npm publish", async () => {
|
|
251
|
+
mockIndependentPackages();
|
|
252
|
+
const npmArgs: string[][] = [];
|
|
253
|
+
mocks.execa.mockImplementation(((cmd: string, args?: string[]) => {
|
|
254
|
+
if (isNpmPublish(cmd, args)) npmArgs.push(args ?? []);
|
|
255
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
256
|
+
}) as never);
|
|
257
|
+
|
|
258
|
+
const logger = createMockLogger();
|
|
259
|
+
await runDeployment(
|
|
260
|
+
[{ name: "pkg-a", path: pkgPath("pkg-a"), config: { type: "npm" } }],
|
|
261
|
+
"14.0.1",
|
|
262
|
+
CWD,
|
|
263
|
+
logger,
|
|
264
|
+
false,
|
|
265
|
+
"123456",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(npmArgs[0][npmArgs[0].indexOf("--otp") + 1]).toBe("123456");
|
|
269
|
+
});
|
|
133
270
|
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
import { cpx, fsx } from "@simplysm/core-node";
|
|
5
|
+
|
|
6
|
+
const mocks = {
|
|
7
|
+
spawn: vi.spyOn(cpx, "spawn"),
|
|
8
|
+
mkdir: vi.spyOn(fsx, "mkdir"),
|
|
9
|
+
readdir: vi.spyOn(fsx, "readdir"),
|
|
10
|
+
rm: vi.spyOn(fsx, "rm"),
|
|
11
|
+
readJson: vi.spyOn(fsx, "readJson"),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
import { publishNpm, validateOtp } from "../../src/commands/publish/npm-publisher";
|
|
15
|
+
|
|
16
|
+
const OTP = "123456";
|
|
17
|
+
const TARBALL = "simplysm-pkg-a-14.0.1.tgz";
|
|
18
|
+
|
|
19
|
+
function createMockLogger() {
|
|
20
|
+
return {
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
debug: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
start: vi.fn(),
|
|
26
|
+
success: vi.fn(),
|
|
27
|
+
fail: vi.fn(),
|
|
28
|
+
} as unknown as ReturnType<typeof import("@simplysm/core-common").createLogger>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loggedMessages(logger: ReturnType<typeof createMockLogger>): string {
|
|
32
|
+
const calls = [
|
|
33
|
+
...(logger.debug as unknown as ReturnType<typeof vi.fn>).mock.calls,
|
|
34
|
+
...(logger.info as unknown as ReturnType<typeof vi.fn>).mock.calls,
|
|
35
|
+
];
|
|
36
|
+
return calls.map((c: unknown[]) => String(c[0])).join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SpawnCall {
|
|
40
|
+
cmd: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
opts: { cwd?: string; stdio?: unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* cpx.spawn 호출을 수집한다.
|
|
47
|
+
*
|
|
48
|
+
* `npm view <pkg> dist-tags.latest` 응답만 `registryLatest` 로 흉내내고, 나머지는 성공 처리한다.
|
|
49
|
+
* 반환 배열에는 publish 파이프라인 호출(pack, publish)만 담긴다.
|
|
50
|
+
*/
|
|
51
|
+
function captureSpawns(registryLatest?: string): SpawnCall[] {
|
|
52
|
+
const calls: SpawnCall[] = [];
|
|
53
|
+
mocks.spawn.mockImplementation(((
|
|
54
|
+
cmd: string,
|
|
55
|
+
args?: string[],
|
|
56
|
+
opts?: { cwd?: string; stdio?: unknown },
|
|
57
|
+
) => {
|
|
58
|
+
const argList = args ?? [];
|
|
59
|
+
if (cmd === "npm" && argList[0] === "view") {
|
|
60
|
+
if (registryLatest == null) throw new Error("E404 not found");
|
|
61
|
+
return { stdout: `${registryLatest}\n`, stderr: "", exitCode: 0 };
|
|
62
|
+
}
|
|
63
|
+
calls.push({ cmd, args: argList, opts: opts ?? {} });
|
|
64
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
65
|
+
}) as never);
|
|
66
|
+
return calls;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("validateOtp", () => {
|
|
70
|
+
it("accepts 6 digits and trims", () => {
|
|
71
|
+
expect(validateOtp(" 012345 ")).toBe("012345");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects anything else", () => {
|
|
75
|
+
for (const bad of ["12345", "1234567", "abcdef", "123456; rm -rf /", ""]) {
|
|
76
|
+
expect(() => validateOtp(bad)).toThrow("OTP 는 6자리 숫자여야 합니다.");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("publishNpm", () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
mocks.mkdir.mockResolvedValue(undefined);
|
|
85
|
+
mocks.rm.mockResolvedValue(undefined);
|
|
86
|
+
mocks.readdir.mockResolvedValue([TARBALL]);
|
|
87
|
+
mocks.readJson.mockResolvedValue({ name: "@simplysm/pkg-a" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("packs with pnpm and publishes the tarball with npm", async () => {
|
|
91
|
+
const calls = captureSpawns();
|
|
92
|
+
|
|
93
|
+
const logger = createMockLogger();
|
|
94
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined);
|
|
95
|
+
|
|
96
|
+
expect(calls).toHaveLength(2);
|
|
97
|
+
// workspace: 치환과 publishConfig 머지는 pnpm pack 이 해준다
|
|
98
|
+
expect(calls[0].cmd).toBe("pnpm");
|
|
99
|
+
expect(calls[0].args[0]).toBe("pack");
|
|
100
|
+
// 2FA 인증은 npm 이 처리하므로 tarball 을 npm 으로 올린다
|
|
101
|
+
expect(calls[1].cmd).toBe("npm");
|
|
102
|
+
expect(calls[1].args[0]).toBe("publish");
|
|
103
|
+
expect(calls[1].args.join(" ")).toContain(TARBALL);
|
|
104
|
+
expect(calls[1].args[calls[1].args.indexOf("--access") + 1]).toBe("public");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("looks up the registry latest by the npm package name, not the directory name", async () => {
|
|
108
|
+
const viewed: string[] = [];
|
|
109
|
+
mocks.spawn.mockImplementation(((cmd: string, args?: string[]) => {
|
|
110
|
+
const argList = args ?? [];
|
|
111
|
+
if (cmd === "npm" && argList[0] === "view") {
|
|
112
|
+
viewed.push(argList[1]);
|
|
113
|
+
return { stdout: "14.2.14\n", stderr: "", exitCode: 0 };
|
|
114
|
+
}
|
|
115
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
116
|
+
}) as never);
|
|
117
|
+
mocks.readJson.mockResolvedValue({ name: "@simplysm/core-common" });
|
|
118
|
+
|
|
119
|
+
const logger = createMockLogger();
|
|
120
|
+
await publishNpm("/tmp/core-common", "core-common", "14.2.15", logger, false, undefined);
|
|
121
|
+
|
|
122
|
+
expect(viewed).toEqual(['"@simplysm/core-common"']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("hands the terminal over to npm so it can run its own auth flow", async () => {
|
|
126
|
+
const calls = captureSpawns();
|
|
127
|
+
|
|
128
|
+
const logger = createMockLogger();
|
|
129
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined);
|
|
130
|
+
|
|
131
|
+
// pack 은 로그를 캡처하고, publish 는 TTY 를 그대로 물려준다
|
|
132
|
+
expect(calls[0].opts.stdio).toBeUndefined();
|
|
133
|
+
expect(calls[1].opts.stdio).toBe("inherit");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("cleans up the temporary tarball directory when pack fails", async () => {
|
|
137
|
+
mocks.spawn.mockImplementation(() => {
|
|
138
|
+
throw new Error("pack failed");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const logger = createMockLogger();
|
|
142
|
+
await expect(
|
|
143
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined),
|
|
144
|
+
).rejects.toThrow("pack failed");
|
|
145
|
+
|
|
146
|
+
expect(mocks.rm).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(String(mocks.rm.mock.calls[0][0])).toContain("sd-cli-pack-pkg-a");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("cleans up the temporary tarball directory when publish fails", async () => {
|
|
151
|
+
mocks.spawn.mockImplementation(((cmd: string, args?: string[]) => {
|
|
152
|
+
if (cmd === "npm" && args?.[0] === "publish") throw new Error("publish failed");
|
|
153
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
154
|
+
}) as never);
|
|
155
|
+
|
|
156
|
+
const logger = createMockLogger();
|
|
157
|
+
await expect(
|
|
158
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined),
|
|
159
|
+
).rejects.toThrow("publish failed");
|
|
160
|
+
|
|
161
|
+
expect(mocks.rm).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("points to the npm output because stdio was handed over", async () => {
|
|
165
|
+
mocks.spawn.mockImplementation(((cmd: string, args?: string[]) => {
|
|
166
|
+
if (cmd === "npm" && args?.[0] === "publish") throw new Error("Command failed (exit 1)");
|
|
167
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
168
|
+
}) as never);
|
|
169
|
+
|
|
170
|
+
const logger = createMockLogger();
|
|
171
|
+
await expect(
|
|
172
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined),
|
|
173
|
+
).rejects.toThrow("위 npm 출력을 확인하세요");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not let a cleanup failure hide the publish failure", async () => {
|
|
177
|
+
mocks.spawn.mockImplementation(((cmd: string, args?: string[]) => {
|
|
178
|
+
if (cmd === "npm" && args?.[0] === "publish") throw new Error("publish failed");
|
|
179
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
180
|
+
}) as never);
|
|
181
|
+
mocks.rm.mockRejectedValue(new Error("EBUSY"));
|
|
182
|
+
|
|
183
|
+
const logger = createMockLogger();
|
|
184
|
+
await expect(
|
|
185
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined),
|
|
186
|
+
).rejects.toThrow("publish failed");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("fails when pack produced no tarball", async () => {
|
|
190
|
+
captureSpawns();
|
|
191
|
+
mocks.readdir.mockResolvedValue([]);
|
|
192
|
+
|
|
193
|
+
const logger = createMockLogger();
|
|
194
|
+
await expect(
|
|
195
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined),
|
|
196
|
+
).rejects.toThrow("tarball 을 찾을 수 없습니다");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("adds a prerelease tag from the version", async () => {
|
|
200
|
+
const calls = captureSpawns();
|
|
201
|
+
|
|
202
|
+
const logger = createMockLogger();
|
|
203
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1-beta.3", logger, false, undefined);
|
|
204
|
+
|
|
205
|
+
const args = calls[1].args;
|
|
206
|
+
expect(args[args.indexOf("--tag") + 1]).toBe("beta");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("never lets a prerelease become latest, even with a numeric identifier", async () => {
|
|
210
|
+
const calls = captureSpawns("14.0.0");
|
|
211
|
+
|
|
212
|
+
const logger = createMockLogger();
|
|
213
|
+
// semver.inc(v, "prerelease") 는 `14.3.0-1` 같은 숫자 식별자를 만든다
|
|
214
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.3.0-1", logger, false, undefined);
|
|
215
|
+
|
|
216
|
+
const args = calls[1].args;
|
|
217
|
+
const tag = args[args.indexOf("--tag") + 1];
|
|
218
|
+
expect(args).toContain("--tag");
|
|
219
|
+
expect(semver.validRange(tag)).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("adds a major tag when the registry latest is higher", async () => {
|
|
223
|
+
const calls = captureSpawns("15.1.15");
|
|
224
|
+
|
|
225
|
+
const logger = createMockLogger();
|
|
226
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.2.15", logger, false, undefined);
|
|
227
|
+
|
|
228
|
+
// latest 를 15.1.15 에서 끌어내리지 않으려면 별도 태그가 필요하다
|
|
229
|
+
const args = calls[1].args;
|
|
230
|
+
expect(args[args.indexOf("--tag") + 1]).toBe("latest-14");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("never uses a tag name npm would read as a semver range", async () => {
|
|
234
|
+
const calls = captureSpawns("15.1.15");
|
|
235
|
+
|
|
236
|
+
const logger = createMockLogger();
|
|
237
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.2.15", logger, false, undefined);
|
|
238
|
+
|
|
239
|
+
// npm 은 `v14`, `14.x` 처럼 범위로 해석되는 이름을 dist-tag 로 거부한다
|
|
240
|
+
const args = calls[1].args;
|
|
241
|
+
expect(semver.validRange(args[args.indexOf("--tag") + 1])).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not tag when the version becomes the new latest", async () => {
|
|
245
|
+
const calls = captureSpawns("14.2.14");
|
|
246
|
+
|
|
247
|
+
const logger = createMockLogger();
|
|
248
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.2.15", logger, false, undefined);
|
|
249
|
+
|
|
250
|
+
expect(calls[1].args).not.toContain("--tag");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does not tag a package that was never published", async () => {
|
|
254
|
+
const calls = captureSpawns();
|
|
255
|
+
|
|
256
|
+
const logger = createMockLogger();
|
|
257
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.2.15", logger, false, undefined);
|
|
258
|
+
|
|
259
|
+
expect(calls[1].args).not.toContain("--tag");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("passes the OTP to npm but masks it in logs", async () => {
|
|
263
|
+
const calls = captureSpawns();
|
|
264
|
+
|
|
265
|
+
const logger = createMockLogger();
|
|
266
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, OTP);
|
|
267
|
+
|
|
268
|
+
const args = calls[1].args;
|
|
269
|
+
expect(args[args.indexOf("--otp") + 1]).toBe(OTP);
|
|
270
|
+
|
|
271
|
+
const logged = loggedMessages(logger);
|
|
272
|
+
expect(logged).not.toContain(OTP);
|
|
273
|
+
expect(logged).toContain("******");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not add --otp when no code is given", async () => {
|
|
277
|
+
const calls = captureSpawns();
|
|
278
|
+
|
|
279
|
+
const logger = createMockLogger();
|
|
280
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined);
|
|
281
|
+
|
|
282
|
+
expect(calls[1].args).not.toContain("--otp");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("masks the OTP in the failure message and keeps the original stack", async () => {
|
|
286
|
+
mocks.spawn.mockImplementation(((cmd: string) => {
|
|
287
|
+
if (cmd === "pnpm") return { stdout: "", stderr: "", exitCode: 0 };
|
|
288
|
+
// 실제 실패 메시지에는 실행된 명령줄이 그대로 담긴다
|
|
289
|
+
const err = new Error(`Command failed (exit 1): npm publish --otp ${OTP}\nEOTP required`);
|
|
290
|
+
err.stack = `Error: publish failed with --otp ${OTP}\n at spawnedByNpmPublish (cp.ts:1:1)`;
|
|
291
|
+
throw err;
|
|
292
|
+
}) as never);
|
|
293
|
+
|
|
294
|
+
const logger = createMockLogger();
|
|
295
|
+
const err = await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, OTP).then(
|
|
296
|
+
() => null,
|
|
297
|
+
(e: unknown) => e,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const asError = err as Error;
|
|
301
|
+
expect(asError.message).toContain("EOTP");
|
|
302
|
+
expect(asError.message).not.toContain(OTP);
|
|
303
|
+
expect(asError.message).toContain("******");
|
|
304
|
+
expect(asError.stack ?? "").toContain("spawnedByNpmPublish");
|
|
305
|
+
expect(asError.stack ?? "").not.toContain(OTP);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("masks a padded OTP consistently", async () => {
|
|
309
|
+
let publishArgs: string[] = [];
|
|
310
|
+
mocks.spawn.mockImplementation(((cmd: string, args?: string[]) => {
|
|
311
|
+
const argList = args ?? [];
|
|
312
|
+
if (!(cmd === "npm" && argList[0] === "publish")) {
|
|
313
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
314
|
+
}
|
|
315
|
+
publishArgs = argList;
|
|
316
|
+
throw new Error(`Command failed: npm ${argList.join(" ")}`);
|
|
317
|
+
}) as never);
|
|
318
|
+
|
|
319
|
+
const logger = createMockLogger();
|
|
320
|
+
const err = await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, ` ${OTP} `).then(
|
|
321
|
+
() => null,
|
|
322
|
+
(e: unknown) => e,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// 명령줄에 trim 된 값이 들어가고, 마스킹 대상도 같은 값이어야 한다
|
|
326
|
+
expect(publishArgs[publishArgs.indexOf("--otp") + 1]).toBe(OTP);
|
|
327
|
+
expect((err as Error).message).not.toContain(OTP);
|
|
328
|
+
expect((err as Error).message).toContain("******");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("rejects a malformed OTP", async () => {
|
|
332
|
+
captureSpawns();
|
|
333
|
+
|
|
334
|
+
const logger = createMockLogger();
|
|
335
|
+
await expect(
|
|
336
|
+
publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, "12345; rm -rf /"),
|
|
337
|
+
).rejects.toThrow("OTP 는 6자리 숫자여야 합니다.");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("adds --dry-run instead of publishing for real", async () => {
|
|
341
|
+
const calls = captureSpawns();
|
|
342
|
+
|
|
343
|
+
const logger = createMockLogger();
|
|
344
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, true, undefined);
|
|
345
|
+
|
|
346
|
+
expect(calls[1].args).toContain("--dry-run");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("quotes paths so spaces cannot split the shell command", async () => {
|
|
350
|
+
const calls = captureSpawns();
|
|
351
|
+
|
|
352
|
+
const logger = createMockLogger();
|
|
353
|
+
await publishNpm("/tmp/pkg-a", "pkg-a", "14.0.1", logger, false, undefined);
|
|
354
|
+
|
|
355
|
+
const packDest = calls[0].args[calls[0].args.indexOf("--pack-destination") + 1];
|
|
356
|
+
expect(packDest.startsWith('"')).toBe(true);
|
|
357
|
+
expect(packDest.endsWith('"')).toBe(true);
|
|
358
|
+
expect(calls[1].args[1]).toBe(`"${path.join(packDest.slice(1, -1), TARBALL)}"`);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -141,52 +141,6 @@ describe("upgradeVersion", () => {
|
|
|
141
141
|
expect(templateChanges).toHaveLength(0);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
it("plugins 하위의 *-plugin manifest 버전을 함께 업데이트한다", async () => {
|
|
145
|
-
writeJson(path.join(tmpDir, "package.json"), {
|
|
146
|
-
name: "@simplysm/root",
|
|
147
|
-
version: "14.0.0",
|
|
148
|
-
});
|
|
149
|
-
fs.mkdirSync(path.join(tmpDir, "packages", "sd-cli", "templates"), {
|
|
150
|
-
recursive: true,
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const codexPluginPath = path.join(tmpDir, "plugins", "sd", ".codex-plugin", "plugin.json");
|
|
154
|
-
const claudePluginPath = path.join(tmpDir, "plugins", "sd", ".claude-plugin", "plugin.json");
|
|
155
|
-
const anotherPluginPath = path.join(tmpDir, "plugins", "extra", "custom-plugin", "plugin.json");
|
|
156
|
-
const nonMatchingPluginPath = path.join(tmpDir, "plugins", "extra", "plugin.json");
|
|
157
|
-
writeJson(codexPluginPath, {
|
|
158
|
-
name: "sd",
|
|
159
|
-
version: "14.0.0",
|
|
160
|
-
description: "Codex plugin",
|
|
161
|
-
});
|
|
162
|
-
writeJson(claudePluginPath, {
|
|
163
|
-
name: "sd",
|
|
164
|
-
version: "14.0.0",
|
|
165
|
-
description: "Claude plugin",
|
|
166
|
-
});
|
|
167
|
-
writeJson(anotherPluginPath, {
|
|
168
|
-
name: "custom",
|
|
169
|
-
version: "14.0.0",
|
|
170
|
-
description: "Custom plugin",
|
|
171
|
-
});
|
|
172
|
-
writeJson(nonMatchingPluginPath, {
|
|
173
|
-
name: "ignored",
|
|
174
|
-
version: "14.0.0",
|
|
175
|
-
description: "Ignored plugin",
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const result = await upgradeVersion(tmpDir, [], false);
|
|
179
|
-
|
|
180
|
-
expect(readJson<{ version: string }>(codexPluginPath).version).toBe("14.0.1");
|
|
181
|
-
expect(readJson<{ version: string }>(claudePluginPath).version).toBe("14.0.1");
|
|
182
|
-
expect(readJson<{ version: string }>(anotherPluginPath).version).toBe("14.0.1");
|
|
183
|
-
expect(readJson<{ version: string }>(nonMatchingPluginPath).version).toBe("14.0.0");
|
|
184
|
-
expect(result.changedFiles).toContain(path.resolve(codexPluginPath));
|
|
185
|
-
expect(result.changedFiles).toContain(path.resolve(claudePluginPath));
|
|
186
|
-
expect(result.changedFiles).toContain(path.resolve(anotherPluginPath));
|
|
187
|
-
expect(result.changedFiles).not.toContain(path.resolve(nonMatchingPluginPath));
|
|
188
|
-
});
|
|
189
|
-
|
|
190
144
|
it("changedFiles[0]이 프로젝트 루트 package.json 경로이다", async () => {
|
|
191
145
|
// Given: allPkgPaths에 3개 패키지가 있다
|
|
192
146
|
writeJson(path.join(tmpDir, "package.json"), {
|
|
@@ -61,6 +61,26 @@ describe("sd-cli-entry createCliParser", () => {
|
|
|
61
61
|
).resolves.toBeDefined();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("passes publish --otp through to runPublish", async () => {
|
|
65
|
+
const { createCliParser } = await import("../src/sd-cli-entry");
|
|
66
|
+
|
|
67
|
+
await createCliParser(["publish", "--otp", "012345"]).exitProcess(false).parse();
|
|
68
|
+
|
|
69
|
+
expect(publishCmd.runPublish).toHaveBeenCalledWith(
|
|
70
|
+
expect.objectContaining({ otp: "012345" }),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("leaves publish otp undefined when not given", async () => {
|
|
75
|
+
const { createCliParser } = await import("../src/sd-cli-entry");
|
|
76
|
+
|
|
77
|
+
await createCliParser(["publish"]).exitProcess(false).parse();
|
|
78
|
+
|
|
79
|
+
expect(publishCmd.runPublish).toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({ otp: undefined }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
64
84
|
it("throws on --type test (removed type)", async () => {
|
|
65
85
|
const { createCliParser } = await import("../src/sd-cli-entry");
|
|
66
86
|
|