@simplysm/sd-cli 14.2.14 → 14.2.17

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.
@@ -24,11 +24,12 @@ async function publishPackage(
24
24
  projectPath: string,
25
25
  logger: ConsolaInstance,
26
26
  dryRun: boolean,
27
+ otp: string | undefined,
27
28
  ): Promise<void> {
28
29
  const pkgName = path.basename(pkgPath);
29
30
 
30
31
  if (publishConfig.type === "npm") {
31
- await publishNpm(pkgPath, pkgName, version, logger, dryRun);
32
+ await publishNpm(pkgPath, pkgName, version, logger, dryRun, otp);
32
33
  } else if (publishConfig.type === "local-directory") {
33
34
  const targetPath = replaceEnvVariables(publishConfig.path, version, projectPath);
34
35
  await publishToLocal(pkgPath, pkgName, targetPath, logger, dryRun);
@@ -37,6 +38,80 @@ async function publishPackage(
37
38
  }
38
39
  }
39
40
 
41
+ /** 개별 패키지 배포 결과. `error` 가 있으면 실패다. */
42
+ interface PublishResult {
43
+ name: string;
44
+ error?: unknown;
45
+ }
46
+
47
+ /**
48
+ * 패키지 하나를 배포한다 (백오프 재시도 포함)
49
+ *
50
+ * npm 배포는 재시도하지 않는다. 실패 사유가 대개 인증, 권한, 버전 충돌이라 다시 걸어도 같은 결과이고,
51
+ * 재시도할 때마다 npm 인증 UI 가 다시 떠서 사용자를 붙잡는다. 네트워크가 관건인 나머지 배포만 재시도한다.
52
+ */
53
+ async function publishWithRetry(
54
+ pkg: DeploymentPackage,
55
+ version: string,
56
+ projectPath: string,
57
+ logger: ConsolaInstance,
58
+ dryRun: boolean,
59
+ otp: string | undefined,
60
+ ): Promise<PublishResult> {
61
+ const maxRetries = pkg.config.type === "npm" ? 1 : 3;
62
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
63
+ try {
64
+ await publishPackage(pkg.path, pkg.config, version, projectPath, logger, dryRun, otp);
65
+ if (dryRun) {
66
+ logger.info(`[DRY-RUN] ${pkg.name}`);
67
+ } else {
68
+ logger.debug(pkg.name);
69
+ }
70
+ return { name: pkg.name };
71
+ } catch (err) {
72
+ if (attempt === maxRetries) {
73
+ return { name: pkg.name, error: err };
74
+ }
75
+ const delay = attempt * 5_000;
76
+ logger.warn(`${pkg.name} 배포 실패. ${delay / 1000}초 후 재시도 (${attempt + 1}/${maxRetries})`);
77
+ await wait.time(delay);
78
+ }
79
+ }
80
+ // TypeScript 타입 체커를 위한 폴백 (실제로 도달 불가)
81
+ return { name: pkg.name, error: new Error("알 수 없는 에러") };
82
+ }
83
+
84
+ /**
85
+ * 한 레벨의 패키지를 배포한다.
86
+ *
87
+ * npm 배포는 순차로 돈다. npm 이 2FA 인증을 직접 처리하며 터미널을 점유하므로(브라우저 안내,
88
+ * OTP 프롬프트), 동시에 띄우면 서로 입력을 뺏는다. 나머지 배포는 그대로 병렬로 돈다.
89
+ */
90
+ async function publishLevel(
91
+ pkgs: DeploymentPackage[],
92
+ version: string,
93
+ projectPath: string,
94
+ logger: ConsolaInstance,
95
+ dryRun: boolean,
96
+ otp: string | undefined,
97
+ ): Promise<PublishResult[]> {
98
+ const run = async (pkg: DeploymentPackage): Promise<PublishResult> =>
99
+ publishWithRetry(pkg, version, projectPath, logger, dryRun, otp);
100
+
101
+ const [npmResults, otherResults] = await Promise.all([
102
+ (async () => {
103
+ const results: PublishResult[] = [];
104
+ for (const pkg of pkgs.filter((p) => p.config.type === "npm")) {
105
+ results.push(await run(pkg));
106
+ }
107
+ return results;
108
+ })(),
109
+ Promise.all(pkgs.filter((p) => p.config.type !== "npm").map(run)),
110
+ ]);
111
+
112
+ return [...npmResults, ...otherResults];
113
+ }
114
+
40
115
  /**
41
116
  * 의존성 레벨별 순차 배포 (레벨 내 병렬, 재시도 포함)
42
117
  */
@@ -46,65 +121,31 @@ export async function runDeployment(
46
121
  projectPath: string,
47
122
  logger: ConsolaInstance,
48
123
  dryRun: boolean,
124
+ otp: string | undefined,
49
125
  ): Promise<void> {
50
126
  const levels = await computePublishLevels(publishPackages);
51
127
  const publishedPackages: string[] = [];
52
- let publishFailed = false;
53
128
 
54
129
  // 레벨별 순차 실행
55
130
  for (let levelIdx = 0; levelIdx < levels.length; levelIdx++) {
56
- if (publishFailed) break;
57
-
58
131
  const levelPkgs = levels[levelIdx];
59
132
  logger.start(`Level ${levelIdx + 1}/${levels.length}`);
60
133
 
61
- // 레벨 병렬 실행 (Promise.allSettled)
62
- const publishPromises = levelPkgs.map(async (pkg) => {
63
- const maxRetries = 3;
64
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
65
- try {
66
- await publishPackage(pkg.path, pkg.config, version, projectPath, logger, dryRun);
67
- if (dryRun) {
68
- logger.info(`[DRY-RUN] ${pkg.name}`);
69
- } else {
70
- logger.debug(pkg.name);
71
- }
72
- publishedPackages.push(pkg.name);
73
- return { status: "success" as const, name: pkg.name };
74
- } catch (err) {
75
- if (attempt < maxRetries) {
76
- const delay = attempt * 5_000;
77
- if (dryRun) {
78
- logger.info(`[DRY-RUN] ${pkg.name} (retry ${attempt + 1}/${maxRetries})`);
79
- } else {
80
- logger.debug(`${pkg.name} (retry ${attempt + 1}/${maxRetries})`);
81
- }
82
- await wait.time(delay);
83
- } else {
84
- throw err;
85
- }
86
- }
87
- }
88
- // TypeScript 타입 체커를 위한 폴백 (실제로 도달 불가)
89
- return { status: "error" as const, name: pkg.name, error: new Error("알 수 없는 에러") };
90
- });
91
-
92
- const results = await Promise.allSettled(publishPromises);
93
-
94
- // 레벨 내 실패 확인
95
- const rejectedResults = results.filter(
96
- (r): r is PromiseRejectedResult => r.status === "rejected",
97
- );
98
- if (rejectedResults.length > 0) {
99
- publishFailed = true;
100
- for (const r of rejectedResults) {
101
- logger.error(errNs.message(r.reason));
102
- logger.debug(`배포 실패 스택:\n${errNs.stack(r.reason)}`);
134
+ const results = await publishLevel(levelPkgs, version, projectPath, logger, dryRun, otp);
135
+
136
+ publishedPackages.push(...results.filter((r) => r.error == null).map((r) => r.name));
137
+ const failures = results.filter((r) => r.error != null);
138
+
139
+ if (failures.length > 0) {
140
+ for (const f of failures) {
141
+ logger.error(errNs.message(f.error));
142
+ logger.debug(`배포 실패 스택:\n${errNs.stack(f.error)}`);
103
143
  }
104
144
  logger.error(`Level ${levelIdx + 1}/${levels.length}`);
105
- } else {
106
- logger.success(`Level ${levelIdx + 1}/${levels.length}`);
145
+ break;
107
146
  }
147
+
148
+ logger.success(`Level ${levelIdx + 1}/${levels.length}`);
108
149
  }
109
150
 
110
151
  // 실패한 패키지 확인
@@ -1,9 +1,78 @@
1
+ import path from "path";
2
+ import os from "os";
1
3
  import semver from "semver";
2
4
  import type { ConsolaInstance } from "consola";
5
+ import { err as errNs } from "@simplysm/core-common";
6
+ import { fsx } from "@simplysm/core-node";
3
7
  import { shellSpawn } from "../../utils/shell-spawn";
4
8
 
9
+ /** npm classic OTP 형식 (6자리 숫자) */
10
+ const OTP_PATTERN = /^\d{6}$/;
11
+
12
+ /** 로그, 에러 메시지에 OTP 코드 대신 남길 문자열 */
13
+ const OTP_MASK = "******";
14
+
15
+ /**
16
+ * OTP 코드 형식을 검증한다.
17
+ *
18
+ * 셸을 거쳐 `--otp` 인자로 전달되므로, 형식을 벗어난 값은 주입 위험이 있어 거부한다.
19
+ */
20
+ export function validateOtp(otp: string): string {
21
+ const trimmed = otp.trim();
22
+ if (!OTP_PATTERN.test(trimmed)) {
23
+ throw new Error("OTP 는 6자리 숫자여야 합니다.");
24
+ }
25
+ return trimmed;
26
+ }
27
+
5
28
  /**
6
- * npm 레지스트리에 패키지를 배포한다
29
+ * 배포에 dist-tag 를 정한다. `undefined` 면 태그 없이 올려 `latest` 를 갱신한다.
30
+ *
31
+ * 레지스트리의 `latest` 보다 낮은 버전을 태그 없이 올리면 npm 이 거부한다. `--tag latest` 로
32
+ * 밀어붙이면 `latest` 가 끌어내려져 상위 라인 사용자가 깨지므로, `latest-14` 같은 별도 태그를 붙인다.
33
+ *
34
+ * 태그 이름에 `v14`, `14.x` 는 쓸 수 없다. npm 은 semver 범위로 해석되는 이름을 거부한다.
35
+ */
36
+ async function resolveDistTag(
37
+ npmName: string,
38
+ version: string,
39
+ logger: ConsolaInstance,
40
+ ): Promise<string | undefined> {
41
+ // prerelease 는 절대 latest 가 되면 안 되므로 반드시 태그를 붙인다.
42
+ const prereleaseInfo = semver.prerelease(version);
43
+ if (prereleaseInfo != null) {
44
+ // 식별자가 숫자(`14.3.0-1`)이거나 범위로 읽히면 태그 이름으로 쓸 수 없다.
45
+ const identifier = String(prereleaseInfo[0]);
46
+ return semver.validRange(identifier) == null
47
+ ? identifier
48
+ : `pre-${semver.major(version)}`;
49
+ }
50
+
51
+ let latest: string;
52
+ try {
53
+ const { stdout } = await shellSpawn("npm", ["view", `"${npmName}"`, "dist-tags.latest"]);
54
+ latest = stdout.trim();
55
+ } catch {
56
+ // 아직 배포된 적 없거나 레지스트리 조회가 실패한 경우. 태그 없이 올린다.
57
+ // 조회 실패로 태그를 놓쳐도 npm 이 "latest 보다 낮다"며 거부하므로 조용히 잘못되지 않는다.
58
+ logger.debug(`[${npmName}] latest 조회 실패. 태그 없이 배포합니다.`);
59
+ return undefined;
60
+ }
61
+
62
+ if (semver.valid(latest) == null || !semver.lt(version, latest)) return undefined;
63
+
64
+ const tag = `latest-${semver.major(version)}`;
65
+ logger.info(`[${npmName}] latest(${latest})가 더 높아 '${tag}' 태그로 배포합니다.`);
66
+ return tag;
67
+ }
68
+
69
+ /**
70
+ * npm 레지스트리에 패키지를 배포한다.
71
+ *
72
+ * `pnpm pack` 으로 tarball 을 만든 뒤 `npm publish` 로 올린다. 두 단계로 나누는 이유:
73
+ * - `workspace:*` 치환과 `publishConfig` 머지는 pnpm 만 해준다. → pack 을 pnpm 으로 한다.
74
+ * - 2FA 인증(브라우저 로그인 창, OTP 프롬프트)은 npm 이 처리해준다. → publish 를 npm 으로 하고
75
+ * TTY 를 그대로 물려줘(`stdio: "inherit"`) npm 이 사용자와 직접 대화하게 한다.
7
76
  */
8
77
  export async function publishNpm(
9
78
  pkgPath: string,
@@ -11,20 +80,69 @@ export async function publishNpm(
11
80
  version: string,
12
81
  logger: ConsolaInstance,
13
82
  dryRun: boolean,
83
+ otp: string | undefined,
14
84
  ): Promise<void> {
15
- const prereleaseInfo = semver.prerelease(version);
16
- const args = ["publish", "--access", "public", "--no-git-checks"];
85
+ const tmpDir = path.join(os.tmpdir(), `sd-cli-pack-${pkgName}-${Date.now().toString(36)}`);
86
+ await fsx.mkdir(tmpDir);
17
87
 
18
- if (prereleaseInfo != null && typeof prereleaseInfo[0] === "string") {
19
- args.push("--tag", prereleaseInfo[0]);
20
- }
88
+ try {
89
+ logger.debug(`[${pkgName}] pnpm pack`);
90
+ await shellSpawn("pnpm", ["pack", "--pack-destination", `"${tmpDir}"`], { cwd: pkgPath });
21
91
 
22
- if (dryRun) {
23
- args.push("--dry-run");
24
- logger.info(`[DRY-RUN] [${pkgName}] pnpm ${args.join(" ")}`);
25
- } else {
26
- logger.debug(`[${pkgName}] pnpm ${args.join(" ")}`);
27
- }
92
+ const tarball = (await fsx.readdir(tmpDir)).find((f) => f.endsWith(".tgz"));
93
+ if (tarball == null) {
94
+ throw new Error(`[${pkgName}] pack 결과 tarball 을 찾을 수 없습니다.`);
95
+ }
28
96
 
29
- await shellSpawn("pnpm", args, { cwd: pkgPath });
97
+ const args = ["publish", `"${path.join(tmpDir, tarball)}"`, "--access", "public"];
98
+
99
+ const { name: npmName } = await fsx.readJson<{ name: string }>(
100
+ path.resolve(pkgPath, "package.json"),
101
+ );
102
+ const tag = await resolveDistTag(npmName, version, logger);
103
+ if (tag != null) {
104
+ args.push("--tag", tag);
105
+ }
106
+
107
+ // 명령줄에 들어가는 값과 마스킹 대상이 어긋나지 않도록 검증본 하나만 쓴다.
108
+ const otpCode = otp == null ? undefined : validateOtp(otp);
109
+ if (otpCode != null) {
110
+ args.push("--otp", otpCode);
111
+ }
112
+
113
+ if (dryRun) {
114
+ args.push("--dry-run");
115
+ }
116
+
117
+ // OTP 코드가 로그에 남지 않도록 마스킹한다.
118
+ const maskedArgs = args.map((arg, i) => (args[i - 1] === "--otp" ? OTP_MASK : arg));
119
+ if (dryRun) {
120
+ logger.info(`[DRY-RUN] [${pkgName}] npm ${maskedArgs.join(" ")}`);
121
+ } else {
122
+ logger.debug(`[${pkgName}] npm ${maskedArgs.join(" ")}`);
123
+ }
124
+
125
+ try {
126
+ // stdio 를 물려주므로 npm 출력이 화면에 그대로 나온다. 인증이 필요하면 npm 이 직접 안내한다.
127
+ await shellSpawn("npm", args, { cwd: pkgPath, stdio: "inherit" });
128
+ } catch (err) {
129
+ // stdio 를 넘겼으므로 실패 사유는 캡처되지 않고 화면에만 남는다. 어디를 봐야 하는지 알린다.
130
+ const hint = `\n실패 사유는 위 npm 출력을 확인하세요.`;
131
+ // 실패 메시지에는 실행된 명령줄이 그대로 담기므로 OTP 코드를 마스킹한다.
132
+ const mask = (s: string): string =>
133
+ otpCode == null ? s : s.replaceAll(otpCode, OTP_MASK);
134
+
135
+ const wrapped = new Error(mask(errNs.message(err)) + hint);
136
+ // 원본 스택을 잃지 않도록 마스킹한 스택을 그대로 옮긴다.
137
+ wrapped.stack = mask(errNs.stack(err));
138
+ throw wrapped;
139
+ }
140
+ } finally {
141
+ // 정리 실패가 원래 배포 실패를 덮지 않도록 삼킨다.
142
+ try {
143
+ await fsx.rm(tmpDir);
144
+ } catch (err) {
145
+ logger.debug(`[${pkgName}] 임시 디렉터리 정리 실패: ${errNs.message(err)}`);
146
+ }
147
+ }
30
148
  }
@@ -13,6 +13,7 @@ import { type PackageJson, upgradeVersion } from "./version-upgrade";
13
13
  import { waitWithCountdown } from "./env-utils";
14
14
  import { ensureCleanWorkingTree, commitTagAndPush } from "./git-phase";
15
15
  import { runDeployment } from "./deployment-phase";
16
+ import { validateOtp } from "./npm-publisher";
16
17
  import { runPostPublish } from "./post-publish-phase";
17
18
 
18
19
  //#region Types
@@ -27,6 +28,8 @@ export interface PublishOptions {
27
28
  noBuild: boolean;
28
29
  /** 실제 배포 없이 시뮬레이션 */
29
30
  dryRun: boolean;
31
+ /** npm 2FA OTP 코드 (미지정 시 npm 이 배포 중 직접 인증을 처리한다) */
32
+ otp?: string;
30
33
  /** sd.config.ts에 전달할 추가 옵션 */
31
34
  options: string[];
32
35
  }
@@ -123,7 +126,9 @@ export async function runPublish(options: PublishOptions): Promise<void> {
123
126
  //#region Phase 1: Pre-validation
124
127
 
125
128
  // npm 인증 검증 (npm publish 설정이 있는 경우)
126
- if (publishPackages.some((p) => p.config.type === "npm")) {
129
+ const hasNpmPublish = publishPackages.some((p) => p.config.type === "npm");
130
+
131
+ if (hasNpmPublish) {
127
132
  logger.debug("npm 인증 검증 중...");
128
133
  try {
129
134
  const { stdout: whoami } = await shellSpawn("npm", ["whoami"]);
@@ -143,6 +148,19 @@ export async function runPublish(options: PublishOptions): Promise<void> {
143
148
  }
144
149
  }
145
150
 
151
+ // --otp 값 형식 검증 (--dry-run 이라도 사전 점검 용도로 쓸 수 있게 항상 검증한다)
152
+ // 값을 주지 않으면 2FA 인증은 npm 이 배포 단계에서 직접 처리한다(브라우저 로그인 창 등).
153
+ let otpOption: string | undefined;
154
+ if (options.otp != null) {
155
+ try {
156
+ otpOption = validateOtp(options.otp);
157
+ } catch (err) {
158
+ logger.error(errNs.message(err));
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ }
163
+
146
164
  // SSH 키 인증 검증 (비밀번호 없는 SFTP publish 설정이 있는 경우)
147
165
  try {
148
166
  await ensureSshAuth(publishPackages, logger);
@@ -236,8 +254,25 @@ export async function runPublish(options: PublishOptions): Promise<void> {
236
254
 
237
255
  //#region Phase 4: Deployment
238
256
 
239
- await runDeployment(publishPackages, version, cwd, logger, dryRun);
240
- if (process.exitCode === 1) return;
257
+ // 배포 단계에서 멈추면 버전 상향과 git commit/tag/push 는 이미 끝난 상태다.
258
+ // 다시 `pnpm pub` 을 돌리면 버전이 또 올라가므로, 같은 버전으로 이어서 배포하는 방법을 알린다.
259
+ const reportResumeHint = (): void => {
260
+ if (dryRun || noBuild) return;
261
+ logger.error(
262
+ `v${version} 커밋, 태그는 이미 생성되었습니다. 같은 버전으로 이어서 배포하려면:\n` +
263
+ " pnpm pub:no-build # 버전 상향, 빌드 없이 배포만 재실행",
264
+ );
265
+ };
266
+
267
+ if (hasNpmPublish && !dryRun && otpOption == null) {
268
+ logger.info("npm 이 2FA 인증을 요구하면 화면 안내(브라우저 로그인 등)에 따라 진행하세요.");
269
+ }
270
+
271
+ await runDeployment(publishPackages, version, cwd, logger, dryRun, otpOption);
272
+ if (process.exitCode === 1) {
273
+ reportResumeHint();
274
+ return;
275
+ }
241
276
 
242
277
  //#endregion
243
278
 
@@ -245,6 +245,10 @@ export function createCliParser(argv: string[]): Argv {
245
245
  describe: "Simulate without actual deployment",
246
246
  default: false,
247
247
  },
248
+ "otp": {
249
+ type: "string",
250
+ describe: "npm 2FA one-time password (omit to let npm handle auth interactively)",
251
+ },
248
252
  "opt": {
249
253
  type: "string",
250
254
  array: true,
@@ -259,6 +263,7 @@ export function createCliParser(argv: string[]): Argv {
259
263
  targets: args.target,
260
264
  noBuild: !args.build,
261
265
  dryRun: args.dryRun,
266
+ otp: args.otp,
262
267
  options: args.opt,
263
268
  });
264
269
  },
@@ -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 { name: "@simplysm/pkg-b", version: "14.0.1", dependencies: { "@simplysm/pkg-a": "~14.0.0" } };
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("retries failed publish up to 3 times then sets exitCode", async () => {
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(publishAttempts).toBe(3);
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
- mocks.fsx.readJson.mockImplementation(((p: string) => {
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
- ((_cmd: string, _args?: string[], opts?: { cwd?: string }) => {
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
  });