@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.
- 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 +31 -3
- package/dist/commands/publish/publish-command.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 +4 -4
- 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 +38 -3
- 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/sd-cli-entry.spec.ts +20 -0
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
16
|
-
|
|
85
|
+
const tmpDir = path.join(os.tmpdir(), `sd-cli-pack-${pkgName}-${Date.now().toString(36)}`);
|
|
86
|
+
await fsx.mkdir(tmpDir);
|
|
17
87
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
88
|
+
try {
|
|
89
|
+
logger.debug(`[${pkgName}] pnpm pack`);
|
|
90
|
+
await shellSpawn("pnpm", ["pack", "--pack-destination", `"${tmpDir}"`], { cwd: pkgPath });
|
|
21
91
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
package/src/sd-cli-entry.ts
CHANGED
|
@@ -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 {
|
|
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
|
});
|