@simplysm/sd-cli 14.2.6 → 14.2.7
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/esbuild/esbuild-config.d.ts.map +1 -1
- package/dist/esbuild/esbuild-config.js +2 -0
- package/dist/esbuild/esbuild-config.js.map +1 -1
- package/dist/utils/license-extractor.d.ts +53 -0
- package/dist/utils/license-extractor.d.ts.map +1 -0
- package/dist/utils/license-extractor.js +163 -0
- package/dist/utils/license-extractor.js.map +1 -0
- package/dist/workers/client.worker.d.ts.map +1 -1
- package/dist/workers/client.worker.js +6 -11
- package/dist/workers/client.worker.js.map +1 -1
- package/dist/workers/server-build.worker.d.ts.map +1 -1
- package/dist/workers/server-build.worker.js +14 -4
- package/dist/workers/server-build.worker.js.map +1 -1
- package/package.json +4 -4
- package/src/esbuild/esbuild-config.ts +27 -32
- package/src/utils/license-extractor.ts +224 -0
- package/src/workers/client.worker.ts +16 -23
- package/src/workers/server-build.worker.ts +32 -12
- package/tests/utils/license-extractor.spec.ts +273 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fsx } from "@simplysm/core-node";
|
|
3
|
+
import type esbuild from "esbuild";
|
|
4
|
+
|
|
5
|
+
/** 빌드 산출물에 기록하는 제3자 라이선스 고지 파일명 */
|
|
6
|
+
export const LICENSE_NOTICE_FILE_NAME = "3rdpartylicenses.txt";
|
|
7
|
+
|
|
8
|
+
/** 패키지 경계를 나타내는 경로 세그먼트 */
|
|
9
|
+
const NODE_MODULES_SEGMENT = "node_modules";
|
|
10
|
+
|
|
11
|
+
/** 패키지 루트에서 라이선스 본문을 찾을 때 시도하는 파일명 */
|
|
12
|
+
const LICENSE_FILE_NAMES = ["LICENSE", "LICENSE.txt", "LICENSE.md"];
|
|
13
|
+
|
|
14
|
+
/** npm 이 정한 커스텀 라이선스 표기 접두사 (`SEE LICENSE IN <파일명>`) */
|
|
15
|
+
const CUSTOM_LICENSE_PREFIX = "SEE LICENSE IN ";
|
|
16
|
+
|
|
17
|
+
/** 고지 파일의 패키지 항목 구분선 */
|
|
18
|
+
const ENTRY_SEPARATOR = "-".repeat(80);
|
|
19
|
+
|
|
20
|
+
/** 고지 파일 머리말 */
|
|
21
|
+
const FILE_HEADER = "이 파일은 함께 배포되는 산출물에 포함된 제3자 패키지의 라이선스 고지입니다.";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 라이선스 판정에 사용하는 package.json 필드.
|
|
25
|
+
*
|
|
26
|
+
* `license` 오브젝트와 `licenses` 배열은 npm 이 deprecated 로 지정한 표기지만
|
|
27
|
+
* 실제 배포된 구 패키지에 남아 있어, 라이선스 미상 오판을 막기 위해 함께 인식한다.
|
|
28
|
+
* (https://docs.npmjs.com/cli/v11/configuring-npm/package-json)
|
|
29
|
+
*/
|
|
30
|
+
interface LicenseManifest {
|
|
31
|
+
name?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
license?: string | { type?: string };
|
|
34
|
+
licenses?: { type?: string }[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** metafile 입력 경로에서 역추적한 패키지 위치 */
|
|
38
|
+
export interface PackageRef {
|
|
39
|
+
packageName: string;
|
|
40
|
+
packageDir: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `node_modules` 아래 파일의 절대 경로에서 그 파일이 속한 패키지를 역추적한다.
|
|
45
|
+
*
|
|
46
|
+
* 경로를 상위로 거슬러 올라가며 `node_modules` 세그먼트를 찾고, 직전까지 지나온
|
|
47
|
+
* 세그먼트로 패키지명을 조립한다. `@scope/name` 형태는 두 세그먼트를 한 이름으로 합친다.
|
|
48
|
+
*
|
|
49
|
+
* @returns `node_modules` 를 거치지 않는 경로(자체 소스, 워크스페이스 패키지)면 undefined
|
|
50
|
+
*/
|
|
51
|
+
export function resolvePackageRef(absInputPath: string): PackageRef | undefined {
|
|
52
|
+
let dir = absInputPath;
|
|
53
|
+
let nameOrScope: string | undefined;
|
|
54
|
+
let nameOrFile: string | undefined;
|
|
55
|
+
let found = false;
|
|
56
|
+
|
|
57
|
+
while (dir !== path.dirname(dir)) {
|
|
58
|
+
const segment = path.basename(dir);
|
|
59
|
+
if (segment === NODE_MODULES_SEGMENT) {
|
|
60
|
+
found = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
nameOrFile = nameOrScope;
|
|
64
|
+
nameOrScope = segment;
|
|
65
|
+
dir = path.dirname(dir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!found || nameOrScope == null) return undefined;
|
|
69
|
+
|
|
70
|
+
const packageName =
|
|
71
|
+
nameOrScope.startsWith("@") && nameOrFile != null
|
|
72
|
+
? `${nameOrScope}/${nameOrFile}`
|
|
73
|
+
: nameOrScope;
|
|
74
|
+
|
|
75
|
+
return { packageName, packageDir: path.join(dir, packageName) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* package.json 에서 라이선스 식별자를 뽑는다.
|
|
80
|
+
*
|
|
81
|
+
* @returns 어떤 표기로도 라이선스를 알 수 없으면 undefined
|
|
82
|
+
*/
|
|
83
|
+
export function resolveLicenseId(manifest: LicenseManifest): string | undefined {
|
|
84
|
+
if (typeof manifest.license === "string") {
|
|
85
|
+
const trimmed = manifest.license.trim();
|
|
86
|
+
if (trimmed !== "") return trimmed;
|
|
87
|
+
} else if (manifest.license != null) {
|
|
88
|
+
const type = manifest.license.type?.trim();
|
|
89
|
+
if (type != null && type !== "") return type;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const legacyTypes = (manifest.licenses ?? [])
|
|
93
|
+
.map((item) => item.type?.trim())
|
|
94
|
+
.filter((type): type is string => type != null && type !== "");
|
|
95
|
+
if (legacyTypes.length > 0) return legacyTypes.join(" OR ");
|
|
96
|
+
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 패키지 루트에서 라이선스 본문을 읽는다.
|
|
102
|
+
*
|
|
103
|
+
* `SEE LICENSE IN <파일명>` 표기면 그 파일을 읽고, 아니면 관용적인 LICENSE 파일명을 차례로 시도한다.
|
|
104
|
+
*
|
|
105
|
+
* @returns 라이선스 파일을 동봉하지 않은 패키지면 undefined (식별자만으로 고지가 성립한다)
|
|
106
|
+
* @throws `SEE LICENSE IN` 이 가리키는 파일을 읽을 수 없을 때
|
|
107
|
+
*/
|
|
108
|
+
async function readLicenseText(
|
|
109
|
+
packageRef: PackageRef,
|
|
110
|
+
licenseId: string,
|
|
111
|
+
): Promise<string | undefined> {
|
|
112
|
+
if (licenseId.toUpperCase().startsWith(CUSTOM_LICENSE_PREFIX)) {
|
|
113
|
+
const fileName = licenseId.slice(CUSTOM_LICENSE_PREFIX.length).trim();
|
|
114
|
+
const normalized = path.normalize(fileName);
|
|
115
|
+
if (path.isAbsolute(normalized) || normalized.startsWith("..")) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`라이선스 파일 경로가 패키지 바깥을 가리킵니다: ${packageRef.packageName} (${licenseId})`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const customPath = path.join(packageRef.packageDir, normalized);
|
|
122
|
+
if (!(await fsx.exists(customPath))) {
|
|
123
|
+
throw new Error(`라이선스 파일을 찾을 수 없습니다: ${packageRef.packageName} (${licenseId})`);
|
|
124
|
+
}
|
|
125
|
+
return fsx.read(customPath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const candidate of LICENSE_FILE_NAMES) {
|
|
129
|
+
const licensePath = path.join(packageRef.packageDir, candidate);
|
|
130
|
+
if (await fsx.exists(licensePath)) {
|
|
131
|
+
return fsx.read(licensePath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 고지 파일에 기록할 패키지 1건 */
|
|
139
|
+
interface LicenseEntry {
|
|
140
|
+
name: string;
|
|
141
|
+
version: string | undefined;
|
|
142
|
+
licenseId: string;
|
|
143
|
+
licenseText: string | undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* esbuild metafile 을 근거로 번들에 실제 포함된 제3자 패키지의 라이선스 고지문을 만든다.
|
|
148
|
+
*
|
|
149
|
+
* 트리셰이킹으로 잘려나가 산출물에 남지 않은 입력(`bytesInOutput` 이 0 이하)은 제외한다.
|
|
150
|
+
* 같은 패키지의 같은 버전은 한 번만 기록한다.
|
|
151
|
+
*
|
|
152
|
+
* @param metafile esbuild 빌드 결과 metafile
|
|
153
|
+
* @param rootDir metafile 의 상대 입력 경로를 절대 경로로 바꿀 기준 디렉터리 (esbuild 실행 작업 디렉터리)
|
|
154
|
+
* @throws 라이선스를 알 수 없는 패키지가 하나라도 있을 때
|
|
155
|
+
*/
|
|
156
|
+
export async function extractLicenses(
|
|
157
|
+
metafile: esbuild.Metafile,
|
|
158
|
+
rootDir: string,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const seenInputPaths = new Set<string>();
|
|
161
|
+
const seenPackageIds = new Set<string>();
|
|
162
|
+
const entries: LicenseEntry[] = [];
|
|
163
|
+
const unknownPackages: string[] = [];
|
|
164
|
+
|
|
165
|
+
for (const output of Object.values(metafile.outputs)) {
|
|
166
|
+
for (const [inputPath, { bytesInOutput }] of Object.entries(output.inputs)) {
|
|
167
|
+
if (bytesInOutput <= 0) continue;
|
|
168
|
+
if (seenInputPaths.has(inputPath)) continue;
|
|
169
|
+
seenInputPaths.add(inputPath);
|
|
170
|
+
|
|
171
|
+
const packageRef = resolvePackageRef(path.resolve(rootDir, inputPath));
|
|
172
|
+
if (packageRef == null) continue;
|
|
173
|
+
|
|
174
|
+
const manifestPath = path.join(packageRef.packageDir, "package.json");
|
|
175
|
+
if (!(await fsx.exists(manifestPath))) {
|
|
176
|
+
throw new Error(`패키지 정보를 찾을 수 없습니다: ${manifestPath} (입력: ${inputPath})`);
|
|
177
|
+
}
|
|
178
|
+
const manifest = await fsx.readJson<LicenseManifest>(manifestPath);
|
|
179
|
+
|
|
180
|
+
const packageId = `${packageRef.packageName}@${manifest.version ?? ""}`;
|
|
181
|
+
if (seenPackageIds.has(packageId)) continue;
|
|
182
|
+
seenPackageIds.add(packageId);
|
|
183
|
+
|
|
184
|
+
const licenseId = resolveLicenseId(manifest);
|
|
185
|
+
if (licenseId == null) {
|
|
186
|
+
unknownPackages.push(packageId);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
entries.push({
|
|
191
|
+
name: manifest.name ?? packageRef.packageName,
|
|
192
|
+
version: manifest.version,
|
|
193
|
+
licenseId,
|
|
194
|
+
licenseText: await readLicenseText(packageRef, licenseId),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (unknownPackages.length > 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`라이선스를 알 수 없는 패키지가 있어 고지 파일을 만들 수 없습니다.\n` +
|
|
202
|
+
unknownPackages
|
|
203
|
+
.sort()
|
|
204
|
+
.map((id) => ` - ${id}`)
|
|
205
|
+
.join("\n"),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
entries.sort(
|
|
210
|
+
(a, b) => a.name.localeCompare(b.name) || (a.version ?? "").localeCompare(b.version ?? ""),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
let content = `${FILE_HEADER}\n\n${ENTRY_SEPARATOR}\n`;
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
content += `Package: ${entry.name}${entry.version != null ? `@${entry.version}` : ""}\n`;
|
|
216
|
+
content += `License: ${entry.licenseId}\n`;
|
|
217
|
+
if (entry.licenseText != null) {
|
|
218
|
+
content += `\n${entry.licenseText.trimEnd()}\n`;
|
|
219
|
+
}
|
|
220
|
+
content += `${ENTRY_SEPARATOR}\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return content;
|
|
224
|
+
}
|
|
@@ -13,6 +13,7 @@ import { createDevHttpServer, type DevHttpServer } from "../dev-server/dev-http-
|
|
|
13
13
|
import { createHmrService, type HmrService } from "../dev-server/hmr-service";
|
|
14
14
|
import { createHmrPostTransform } from "../dev-server/hmr-client-script";
|
|
15
15
|
import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
|
|
16
|
+
import { extractLicenses, LICENSE_NOTICE_FILE_NAME } from "../utils/license-extractor";
|
|
16
17
|
import { buildSsrBundle } from "../esbuild/esbuild-ssr-config";
|
|
17
18
|
import { prerenderRoutes } from "../ssg/prerender";
|
|
18
19
|
import type { SdBrowserSupportConfig, SdPwaConfig } from "../sd-config.types";
|
|
@@ -99,8 +100,7 @@ function resolvePackageInfo(info: ClientBuildInfo): {
|
|
|
99
100
|
async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
|
|
100
101
|
logger.debug(`[${info.name}] client worker build 시작`);
|
|
101
102
|
try {
|
|
102
|
-
const { pkgName, legacyModule, browserslist, postcssPlugins } =
|
|
103
|
-
resolvePackageInfo(info);
|
|
103
|
+
const { pkgName, legacyModule, browserslist, postcssPlugins } = resolvePackageInfo(info);
|
|
104
104
|
|
|
105
105
|
const outdir = info.outDir ?? path.join(info.pkgDir, "dist");
|
|
106
106
|
|
|
@@ -136,8 +136,7 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
|
|
|
136
136
|
const basePath = info.base ?? `/${name}/`;
|
|
137
137
|
const indexPath = path.join(info.pkgDir, "src", "index.html");
|
|
138
138
|
|
|
139
|
-
const pwaHtmlTransform =
|
|
140
|
-
info.pwa !== false ? createPwaHtmlTransform() : undefined;
|
|
139
|
+
const pwaHtmlTransform = info.pwa !== false ? createPwaHtmlTransform() : undefined;
|
|
141
140
|
|
|
142
141
|
const indexResult = await generateIndexHtml({
|
|
143
142
|
indexPath,
|
|
@@ -188,8 +187,12 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
|
|
|
188
187
|
await ctx.context.dispose();
|
|
189
188
|
// SourceFileCache는 LMDB 기반. context.dispose()에 의해 정리됨.
|
|
190
189
|
|
|
191
|
-
// 8. .config.json 기록
|
|
190
|
+
// 8. .config.json + 제3자 라이선스 고지 기록
|
|
192
191
|
writeConfigJson(outdir, info.configs);
|
|
192
|
+
fsx.writeSync(
|
|
193
|
+
path.join(outdir, LICENSE_NOTICE_FILE_NAME),
|
|
194
|
+
await extractLicenses(result.metafile!, process.cwd()),
|
|
195
|
+
);
|
|
193
196
|
|
|
194
197
|
logger.debug(`[${info.name}] client worker build 완료`);
|
|
195
198
|
return {
|
|
@@ -224,13 +227,9 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
|
|
|
224
227
|
pluginBuild.onStart(() => {
|
|
225
228
|
// sourceFileCache 무효화: 변경된 파일의 loadResultCache + TypeScript 소스 캐시 모두 제거
|
|
226
229
|
if (esbuildResult != null) {
|
|
227
|
-
const { loadResultCache, typeScriptFileCache } =
|
|
228
|
-
esbuildResult.sourceFileCache;
|
|
230
|
+
const { loadResultCache, typeScriptFileCache } = esbuildResult.sourceFileCache;
|
|
229
231
|
// JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 감시
|
|
230
|
-
const watchTargets = [
|
|
231
|
-
...loadResultCache.watchFiles,
|
|
232
|
-
...typeScriptFileCache.keys(),
|
|
233
|
-
];
|
|
232
|
+
const watchTargets = [...loadResultCache.watchFiles, ...typeScriptFileCache.keys()];
|
|
234
233
|
const changedFiles = mtimeTracker.detectChanges(watchTargets);
|
|
235
234
|
const normalizedChangedFiles = new Set<string>();
|
|
236
235
|
for (const file of changedFiles) {
|
|
@@ -296,12 +295,10 @@ function createDevBuildEndHandler(
|
|
|
296
295
|
|
|
297
296
|
// build 이벤트 전송
|
|
298
297
|
const success = result.errors.length === 0;
|
|
299
|
-
const errors =
|
|
300
|
-
? formatEsbuildMessages(result.errors, "error")
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
? formatEsbuildMessages(result.warnings, "warning")
|
|
304
|
-
: undefined;
|
|
298
|
+
const errors =
|
|
299
|
+
result.errors.length > 0 ? formatEsbuildMessages(result.errors, "error") : undefined;
|
|
300
|
+
const warnings =
|
|
301
|
+
result.warnings.length > 0 ? formatEsbuildMessages(result.warnings, "warning") : undefined;
|
|
305
302
|
|
|
306
303
|
if (!isInitialBuild) {
|
|
307
304
|
sender.send("build", { success, errors, warnings });
|
|
@@ -338,8 +335,7 @@ function createDevBuildEndHandler(
|
|
|
338
335
|
*/
|
|
339
336
|
async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
|
|
340
337
|
guardStartWatch();
|
|
341
|
-
const { pkgName, legacyModule, browserslist, postcssPlugins } =
|
|
342
|
-
resolvePackageInfo(info);
|
|
338
|
+
const { pkgName, legacyModule, browserslist, postcssPlugins } = resolvePackageInfo(info);
|
|
343
339
|
|
|
344
340
|
logger.debug(
|
|
345
341
|
`[${info.name}] client worker startWatch 시작 (port: ${info.port ?? "auto"}, legacy: ${legacyModule})`,
|
|
@@ -492,10 +488,7 @@ async function stopWatch(): Promise<void> {
|
|
|
492
488
|
}
|
|
493
489
|
|
|
494
490
|
/** .config.json 생성 */
|
|
495
|
-
function writeConfigJson(
|
|
496
|
-
distDir: string,
|
|
497
|
-
configs?: Record<string, unknown>,
|
|
498
|
-
): void {
|
|
491
|
+
function writeConfigJson(distDir: string, configs?: Record<string, unknown>): void {
|
|
499
492
|
fsx.writeJsonSync(path.join(distDir, ".config.json"), configs ?? {}, { space: 2 });
|
|
500
493
|
}
|
|
501
494
|
|
|
@@ -7,21 +7,19 @@ import { formatEsbuildMessages } from "../utils/output-utils";
|
|
|
7
7
|
import type { BuildOutput } from "../engines/types";
|
|
8
8
|
import type { SerializedDiagnostic } from "../typecheck/typecheck-serialization";
|
|
9
9
|
import type { LintWithProgramResult } from "../lint/lint-with-program";
|
|
10
|
+
import { parseTsconfig, getPackageSourceFiles } from "../utils/tsconfig";
|
|
11
|
+
import { createServerEsbuildOptions, writeChangedOutputFiles } from "../esbuild/esbuild-config";
|
|
10
12
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "../
|
|
14
|
-
import {
|
|
15
|
-
createServerEsbuildOptions,
|
|
16
|
-
writeChangedOutputFiles,
|
|
17
|
-
} from "../esbuild/esbuild-config";
|
|
18
|
-
import { collectAllExternals, generateProductionFiles } from "../deps/server-externals/server-production-files";
|
|
13
|
+
collectAllExternals,
|
|
14
|
+
generateProductionFiles,
|
|
15
|
+
} from "../deps/server-externals/server-production-files";
|
|
19
16
|
import { SdTsCompiler } from "../ts-compiler/SdTsCompiler";
|
|
20
17
|
import { createTscPlugin } from "../esbuild/esbuild-tsc-plugin";
|
|
21
18
|
import { createWorkerBundlePlugin } from "../esbuild/esbuild-worker-plugin";
|
|
22
19
|
import { setupWorkerLifecycle } from "./shared-worker-lifecycle";
|
|
23
20
|
import { buildWatchPaths } from "./build-watch-paths";
|
|
24
21
|
import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
|
|
22
|
+
import { extractLicenses, LICENSE_NOTICE_FILE_NAME } from "../utils/license-extractor";
|
|
25
23
|
import * as esbuildCtx from "./server-esbuild-context";
|
|
26
24
|
import { startServerWatchLoop } from "./server-watch-manager";
|
|
27
25
|
|
|
@@ -72,7 +70,12 @@ export interface ServerWatchInfo {
|
|
|
72
70
|
* 서버 빌드 결과 (LibraryBuildResult + mainJsPath 형태)
|
|
73
71
|
*/
|
|
74
72
|
export interface ServerBuildResult {
|
|
75
|
-
build: {
|
|
73
|
+
build: {
|
|
74
|
+
success: boolean;
|
|
75
|
+
errors?: string[];
|
|
76
|
+
warnings?: string[];
|
|
77
|
+
diagnostics: SerializedDiagnostic[];
|
|
78
|
+
};
|
|
76
79
|
lint?: LintWithProgramResult;
|
|
77
80
|
mainJsPath: string;
|
|
78
81
|
}
|
|
@@ -133,7 +136,9 @@ const { logger, guardStartWatch } = setupWorkerLifecycle("server-build", cleanup
|
|
|
133
136
|
*/
|
|
134
137
|
async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
|
|
135
138
|
const mainJsPath = pathx.posixResolve(info.pkgDir, "dist", "main.js");
|
|
136
|
-
logger.debug(
|
|
139
|
+
logger.debug(
|
|
140
|
+
`[${info.name}] server worker build 시작 (js: ${info.output.js}, dts: ${info.output.dts})`,
|
|
141
|
+
);
|
|
137
142
|
|
|
138
143
|
try {
|
|
139
144
|
// tsconfig 파싱
|
|
@@ -147,6 +152,7 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
|
|
|
147
152
|
let tscErrors: string[];
|
|
148
153
|
let tscDiagnostics: SerializedDiagnostic[];
|
|
149
154
|
let lint: LintWithProgramResult | undefined;
|
|
155
|
+
let bundleMetafile: esbuild.Metafile | undefined;
|
|
150
156
|
|
|
151
157
|
if (info.output.js) {
|
|
152
158
|
// js=true: tsc 플러그인 통합 — 단일 esbuild.build() 호출
|
|
@@ -166,8 +172,10 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
|
|
|
166
172
|
external: bundleExternals,
|
|
167
173
|
});
|
|
168
174
|
|
|
169
|
-
jsResult = await esbuild
|
|
175
|
+
jsResult = await esbuild
|
|
176
|
+
.build({ ...esbuildOptions, plugins: [createWorkerBundlePlugin(), tscPlugin.plugin] })
|
|
170
177
|
.then(async (result) => {
|
|
178
|
+
bundleMetafile = result.metafile;
|
|
171
179
|
if (result.outputFiles) {
|
|
172
180
|
await writeChangedOutputFiles(result.outputFiles, { rewriteJsExtensions: false });
|
|
173
181
|
}
|
|
@@ -217,11 +225,23 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
|
|
|
217
225
|
await copyPublicFiles(info.pkgDir, false);
|
|
218
226
|
|
|
219
227
|
generateProductionFiles(info, prodDependencies);
|
|
228
|
+
|
|
229
|
+
if (jsResult.success) {
|
|
230
|
+
if (bundleMetafile == null) {
|
|
231
|
+
throw new Error("번들 metafile이 없어 제3자 라이선스 고지를 생성할 수 없습니다.");
|
|
232
|
+
}
|
|
233
|
+
await fsx.write(
|
|
234
|
+
path.join(info.pkgDir, "dist", LICENSE_NOTICE_FILE_NAME),
|
|
235
|
+
await extractLicenses(bundleMetafile, process.cwd()),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
220
238
|
}
|
|
221
239
|
|
|
222
240
|
const allErrors = [...(jsResult.errors ?? []), ...tscErrors];
|
|
223
241
|
const tscSuccess = tscErrors.length === 0;
|
|
224
|
-
logger.debug(
|
|
242
|
+
logger.debug(
|
|
243
|
+
`[${info.name}] server worker build 완료 (js: ${jsResult.success}, tsc: ${tscSuccess})`,
|
|
244
|
+
);
|
|
225
245
|
return {
|
|
226
246
|
build: {
|
|
227
247
|
success: jsResult.success && tscSuccess,
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import type esbuild from "esbuild";
|
|
6
|
+
|
|
7
|
+
const { extractLicenses, resolvePackageRef, resolveLicenseId } =
|
|
8
|
+
await import("../../src/utils/license-extractor");
|
|
9
|
+
|
|
10
|
+
let rootDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "sd-cli-license-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** rootDir 하위에 패키지 디렉터리를 만든다 */
|
|
21
|
+
function writePackage(
|
|
22
|
+
relDir: string,
|
|
23
|
+
manifest: object | undefined,
|
|
24
|
+
files: Record<string, string> = {},
|
|
25
|
+
): void {
|
|
26
|
+
const dir = path.join(rootDir, relDir);
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
if (manifest != null) {
|
|
29
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(manifest));
|
|
30
|
+
}
|
|
31
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
32
|
+
fs.writeFileSync(path.join(dir, fileName), content);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 입력 경로별 bytesInOutput 을 담은 단일 출력 metafile 을 만든다 */
|
|
37
|
+
function metafileOf(inputs: Record<string, number>): esbuild.Metafile {
|
|
38
|
+
return {
|
|
39
|
+
inputs: {},
|
|
40
|
+
outputs: {
|
|
41
|
+
"dist/main.js": {
|
|
42
|
+
bytes: 0,
|
|
43
|
+
inputs: Object.fromEntries(
|
|
44
|
+
Object.entries(inputs).map(([inputPath, bytesInOutput]) => [
|
|
45
|
+
inputPath,
|
|
46
|
+
{ bytesInOutput },
|
|
47
|
+
]),
|
|
48
|
+
),
|
|
49
|
+
imports: [],
|
|
50
|
+
exports: [],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("resolvePackageRef", () => {
|
|
57
|
+
it("node_modules 아래 파일에서 패키지명을 역추적한다", () => {
|
|
58
|
+
const ref = resolvePackageRef(path.join(rootDir, "node_modules", "foo", "dist", "index.js"));
|
|
59
|
+
expect(ref?.packageName).toBe("foo");
|
|
60
|
+
expect(ref?.packageDir).toBe(path.join(rootDir, "node_modules", "foo"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("scope 가 있는 패키지는 scope 를 포함한 이름으로 역추적한다", () => {
|
|
64
|
+
const ref = resolvePackageRef(
|
|
65
|
+
path.join(rootDir, "node_modules", "@scope", "bar", "src", "index.js"),
|
|
66
|
+
);
|
|
67
|
+
expect(ref?.packageName).toBe("@scope/bar");
|
|
68
|
+
expect(ref?.packageDir).toBe(path.join(rootDir, "node_modules", "@scope", "bar"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("중첩된 node_modules 에서는 가장 가까운 패키지를 찾는다", () => {
|
|
72
|
+
const ref = resolvePackageRef(
|
|
73
|
+
path.join(rootDir, "node_modules", "outer", "node_modules", "inner", "index.js"),
|
|
74
|
+
);
|
|
75
|
+
expect(ref?.packageName).toBe("inner");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("node_modules 를 거치지 않는 경로는 패키지가 아니다", () => {
|
|
79
|
+
expect(
|
|
80
|
+
resolvePackageRef(path.join(rootDir, "packages", "app", "src", "main.ts")),
|
|
81
|
+
).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("resolveLicenseId", () => {
|
|
86
|
+
it("license 문자열을 그대로 사용한다", () => {
|
|
87
|
+
expect(resolveLicenseId({ license: "MIT" })).toBe("MIT");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("공백뿐인 license 는 미상으로 본다", () => {
|
|
91
|
+
expect(resolveLicenseId({ license: " " })).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("deprecated license 오브젝트의 type 을 인식한다", () => {
|
|
95
|
+
expect(resolveLicenseId({ license: { type: "BSD-3-Clause" } })).toBe("BSD-3-Clause");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("deprecated licenses 배열의 type 들을 OR 로 잇는다", () => {
|
|
99
|
+
expect(resolveLicenseId({ licenses: [{ type: "MIT" }, { type: "Apache-2.0" }] })).toBe(
|
|
100
|
+
"MIT OR Apache-2.0",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("어떤 표기도 없으면 미상이다", () => {
|
|
105
|
+
expect(resolveLicenseId({ name: "foo", version: "1.0.0" })).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("extractLicenses", () => {
|
|
110
|
+
it("LICENSE 파일이 있으면 본문까지 기록한다", async () => {
|
|
111
|
+
writePackage(
|
|
112
|
+
"node_modules/foo",
|
|
113
|
+
{ name: "foo", version: "1.2.3", license: "MIT" },
|
|
114
|
+
{ LICENSE: "Copyright (c) 2020 Foo Author" },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const content = await extractLicenses(metafileOf({ "node_modules/foo/index.js": 10 }), rootDir);
|
|
118
|
+
|
|
119
|
+
expect(content).toContain("Package: foo@1.2.3");
|
|
120
|
+
expect(content).toContain("License: MIT");
|
|
121
|
+
expect(content).toContain("Copyright (c) 2020 Foo Author");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("LICENSE 파일이 없으면 라이선스 식별자만 기록한다", async () => {
|
|
125
|
+
writePackage("node_modules/bar", { name: "bar", version: "0.1.0", license: "ISC" });
|
|
126
|
+
|
|
127
|
+
const content = await extractLicenses(metafileOf({ "node_modules/bar/index.js": 10 }), rootDir);
|
|
128
|
+
|
|
129
|
+
expect(content).toContain("Package: bar@0.1.0");
|
|
130
|
+
expect(content).toContain("License: ISC");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("LICENSE.md 등 다른 파일명도 찾는다", async () => {
|
|
134
|
+
writePackage(
|
|
135
|
+
"node_modules/baz",
|
|
136
|
+
{ name: "baz", version: "1.0.0", license: "MIT" },
|
|
137
|
+
{ "LICENSE.md": "# License\nMIT text here" },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const content = await extractLicenses(metafileOf({ "node_modules/baz/index.js": 10 }), rootDir);
|
|
141
|
+
|
|
142
|
+
expect(content).toContain("MIT text here");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("scope 가 있는 패키지도 기록한다", async () => {
|
|
146
|
+
writePackage("node_modules/@scope/pkg", {
|
|
147
|
+
name: "@scope/pkg",
|
|
148
|
+
version: "2.0.0",
|
|
149
|
+
license: "Apache-2.0",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const content = await extractLicenses(
|
|
153
|
+
metafileOf({ "node_modules/@scope/pkg/index.js": 10 }),
|
|
154
|
+
rootDir,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(content).toContain("Package: @scope/pkg@2.0.0");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("같은 패키지의 여러 파일이 포함돼도 한 번만 기록한다", async () => {
|
|
161
|
+
writePackage("node_modules/foo", { name: "foo", version: "1.0.0", license: "MIT" });
|
|
162
|
+
|
|
163
|
+
const content = await extractLicenses(
|
|
164
|
+
metafileOf({
|
|
165
|
+
"node_modules/foo/index.js": 10,
|
|
166
|
+
"node_modules/foo/util.js": 20,
|
|
167
|
+
"node_modules/foo/deep/inner.js": 30,
|
|
168
|
+
}),
|
|
169
|
+
rootDir,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(content.match(/Package: foo@1\.0\.0/g)).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("산출물에 남지 않은 입력(bytesInOutput 0)은 제외한다", async () => {
|
|
176
|
+
writePackage("node_modules/used", { name: "used", version: "1.0.0", license: "MIT" });
|
|
177
|
+
writePackage("node_modules/shaken", { name: "shaken", version: "1.0.0", license: "MIT" });
|
|
178
|
+
|
|
179
|
+
const content = await extractLicenses(
|
|
180
|
+
metafileOf({
|
|
181
|
+
"node_modules/used/index.js": 10,
|
|
182
|
+
"node_modules/shaken/index.js": 0,
|
|
183
|
+
}),
|
|
184
|
+
rootDir,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(content).toContain("Package: used@1.0.0");
|
|
188
|
+
expect(content).not.toContain("Package: shaken@1.0.0");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("node_modules 밖의 자체 소스는 제외한다", async () => {
|
|
192
|
+
const content = await extractLicenses(metafileOf({ "packages/app/src/main.ts": 100 }), rootDir);
|
|
193
|
+
|
|
194
|
+
expect(content).not.toContain("Package:");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("SEE LICENSE IN 표기는 지정한 파일을 읽는다", async () => {
|
|
198
|
+
writePackage(
|
|
199
|
+
"node_modules/custom",
|
|
200
|
+
{ name: "custom", version: "1.0.0", license: "SEE LICENSE IN TERMS.txt" },
|
|
201
|
+
{ "TERMS.txt": "사내 전용 라이선스 조건" },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const content = await extractLicenses(
|
|
205
|
+
metafileOf({ "node_modules/custom/index.js": 10 }),
|
|
206
|
+
rootDir,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(content).toContain("사내 전용 라이선스 조건");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("SEE LICENSE IN 이 가리키는 파일이 없으면 실패한다", async () => {
|
|
213
|
+
writePackage("node_modules/custom", {
|
|
214
|
+
name: "custom",
|
|
215
|
+
version: "1.0.0",
|
|
216
|
+
license: "SEE LICENSE IN MISSING.txt",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
extractLicenses(metafileOf({ "node_modules/custom/index.js": 10 }), rootDir),
|
|
221
|
+
).rejects.toThrow("custom");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("SEE LICENSE IN 이 패키지 바깥을 가리키면 실패한다", async () => {
|
|
225
|
+
writePackage("node_modules/escape", {
|
|
226
|
+
name: "escape",
|
|
227
|
+
version: "1.0.0",
|
|
228
|
+
license: "SEE LICENSE IN ../../secret.txt",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await expect(
|
|
232
|
+
extractLicenses(metafileOf({ "node_modules/escape/index.js": 10 }), rootDir),
|
|
233
|
+
).rejects.toThrow("패키지 바깥");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("라이선스를 알 수 없는 패키지가 있으면 실패하고 그 패키지를 알린다", async () => {
|
|
237
|
+
writePackage("node_modules/ok", { name: "ok", version: "1.0.0", license: "MIT" });
|
|
238
|
+
writePackage("node_modules/nolicense", { name: "nolicense", version: "3.1.4" });
|
|
239
|
+
|
|
240
|
+
await expect(
|
|
241
|
+
extractLicenses(
|
|
242
|
+
metafileOf({
|
|
243
|
+
"node_modules/ok/index.js": 10,
|
|
244
|
+
"node_modules/nolicense/index.js": 10,
|
|
245
|
+
}),
|
|
246
|
+
rootDir,
|
|
247
|
+
),
|
|
248
|
+
).rejects.toThrow("nolicense@3.1.4");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("package.json 이 없는 node_modules 경로는 실패한다", async () => {
|
|
252
|
+
writePackage("node_modules/broken", undefined, { "index.js": "" });
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
extractLicenses(metafileOf({ "node_modules/broken/index.js": 10 }), rootDir),
|
|
256
|
+
).rejects.toThrow("패키지 정보를 찾을 수 없습니다");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("패키지를 이름순으로 정렬해 기록한다", async () => {
|
|
260
|
+
writePackage("node_modules/zeta", { name: "zeta", version: "1.0.0", license: "MIT" });
|
|
261
|
+
writePackage("node_modules/alpha", { name: "alpha", version: "1.0.0", license: "MIT" });
|
|
262
|
+
|
|
263
|
+
const content = await extractLicenses(
|
|
264
|
+
metafileOf({
|
|
265
|
+
"node_modules/zeta/index.js": 10,
|
|
266
|
+
"node_modules/alpha/index.js": 10,
|
|
267
|
+
}),
|
|
268
|
+
rootDir,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(content.indexOf("Package: alpha")).toBeLessThan(content.indexOf("Package: zeta"));
|
|
272
|
+
});
|
|
273
|
+
});
|