@simplysm/sd-cli 14.0.63 → 14.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/capacitor/capacitor-android.d.ts +2 -0
  2. package/dist/capacitor/capacitor-android.d.ts.map +1 -1
  3. package/dist/capacitor/capacitor-android.js +13 -0
  4. package/dist/capacitor/capacitor-android.js.map +1 -1
  5. package/dist/capacitor/capacitor-npm-config.d.ts.map +1 -1
  6. package/dist/capacitor/capacitor-npm-config.js +2 -6
  7. package/dist/capacitor/capacitor-npm-config.js.map +1 -1
  8. package/dist/electron/electron.d.ts.map +1 -1
  9. package/dist/electron/electron.js +1 -2
  10. package/dist/electron/electron.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/capacitor/capacitor-android.ts +14 -0
  13. package/src/capacitor/capacitor-npm-config.ts +2 -6
  14. package/src/electron/electron.ts +1 -2
  15. package/tests/angular/ngtsc-build-core.acc.spec.ts +36 -94
  16. package/tests/capacitor/capacitor-android.spec.ts +65 -28
  17. package/tests/capacitor/capacitor-build.spec.ts +40 -385
  18. package/tests/capacitor/capacitor-config-writer.acc.spec.ts +3 -17
  19. package/tests/capacitor/capacitor-config-writer.spec.ts +3 -17
  20. package/tests/capacitor/capacitor-init.spec.ts +40 -636
  21. package/tests/capacitor/capacitor-npm-config.acc.spec.ts +38 -168
  22. package/tests/capacitor/capacitor-npm-config.spec.ts +33 -71
  23. package/tests/commands/check.spec.ts +25 -36
  24. package/tests/commands/deployment-phase.acc.spec.ts +17 -26
  25. package/tests/commands/git-phase.acc.spec.ts +13 -112
  26. package/tests/commands/lint.spec.ts +7 -24
  27. package/tests/commands/post-publish-phase.acc.spec.ts +5 -10
  28. package/tests/commands/typecheck.spec.ts +43 -65
  29. package/tests/electron/electron.spec.ts +22 -46
  30. package/tests/engines/base-engine.spec.ts +4 -13
  31. package/tests/engines/engine-selection.spec.ts +14 -17
  32. package/tests/engines/engine-typecheck-selection.acc.spec.ts +13 -16
  33. package/tests/engines/esbuild-client-engine.acc.spec.ts +36 -40
  34. package/tests/engines/esbuild-client-engine.spec.ts +4 -23
  35. package/tests/engines/ngtsc-engine.spec.ts +3 -10
  36. package/tests/engines/server-esbuild-engine.spec.ts +3 -10
  37. package/tests/engines/tsc-engine.spec.ts +3 -10
  38. package/tests/esbuild/esbuild-tsc-plugin.acc.spec.ts +3 -8
  39. package/tests/esbuild/esbuild-tsc-plugin.spec.ts +3 -8
  40. package/tests/orchestrators/build-orchestrator.spec.ts +57 -102
  41. package/tests/orchestrators/dev-orchestrator.spec.ts +68 -109
  42. package/tests/orchestrators/typecheck-orchestrator.spec.ts +25 -57
  43. package/tests/orchestrators/watch-orchestrator.spec.ts +73 -99
  44. package/tests/sd-cli-entry.spec.ts +17 -20
  45. package/tests/utils/angular-source-file-cache.spec.ts +4 -8
  46. package/tests/utils/copy-src.spec.ts +9 -20
  47. package/tests/utils/esbuild-client-config.acc.spec.ts +9 -15
  48. package/tests/utils/esbuild-client-config.spec.ts +12 -24
  49. package/tests/utils/esbuild-config.spec.ts +51 -42
  50. package/tests/utils/lint-core.spec.ts +13 -19
  51. package/tests/utils/lint-utils.spec.ts +8 -15
  52. package/tests/utils/lint-with-program.spec.ts +3 -7
  53. package/tests/utils/ngtsc-build-core.spec.ts +2 -99
  54. package/tests/utils/orchestrator-utils.spec.ts +7 -20
  55. package/tests/utils/output-utils.spec.ts +5 -11
  56. package/tests/utils/sd-config.spec.ts +4 -12
  57. package/tests/utils/typecheck-env.spec.ts +49 -77
  58. package/tests/utils/typecheck-non-package.spec.ts +23 -16
  59. package/tests/workers/build-watch-paths.acc.spec.ts +4 -10
  60. package/tests/workers/build-watch-paths.spec.ts +4 -9
  61. package/tests/workers/client-worker.acc.spec.ts +64 -137
  62. package/tests/workers/client-worker.spec.ts +63 -89
  63. package/tests/workers/library-build-lint.spec.ts +19 -30
  64. package/tests/workers/library-build-worker.spec.ts +28 -55
  65. package/tests/workers/server-esbuild-context.acc.spec.ts +6 -15
  66. package/tests/workers/server-esbuild-context.spec.ts +7 -16
  67. package/tests/workers/server-runtime-worker.spec.ts +8 -10
  68. package/tests/workers/shared-worker-lifecycle.acc.spec.ts +3 -5
  69. package/tests/workers/shared-worker-lifecycle.spec.ts +4 -5
  70. package/tests/capacitor/capacitor-icon.spec.ts +0 -285
  71. package/tests/capacitor/capacitor-run.spec.ts +0 -256
  72. package/tests/capacitor/capacitor-workspace.spec.ts +0 -203
  73. package/tests/commands/device.spec.ts +0 -237
  74. package/tests/commands/publish.spec.ts +0 -1183
  75. package/tests/utils/external-modules.spec.ts +0 -217
  76. package/tests/workers/server-build-lint.spec.ts +0 -201
  77. package/tests/workers/server-build-worker.spec.ts +0 -765
  78. package/tests/workers/server-watch-manager.acc.spec.ts +0 -162
  79. package/tests/workers/server-watch-manager.spec.ts +0 -199
@@ -1,1183 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import path from "path";
3
-
4
- const mocks = vi.hoisted(() => ({
5
- loadSdConfig: vi.fn(),
6
- runBuild: vi.fn(),
7
- parseWorkspaceGlobs: vi.fn(),
8
- execa: vi.fn(),
9
- fsx: {
10
- readJson: vi.fn(),
11
- write: vi.fn(),
12
- read: vi.fn(),
13
- exists: vi.fn(),
14
- glob: vi.fn(),
15
- copy: vi.fn(),
16
- },
17
- storageConnect: vi.fn(),
18
- SshClientInstance: {
19
- on: vi.fn(),
20
- connect: vi.fn(),
21
- end: vi.fn(),
22
- exec: vi.fn(),
23
- },
24
- SshClient: vi.fn(),
25
- sshUtils: {
26
- generateKeyPairSync: vi.fn(),
27
- parseKey: vi.fn(),
28
- },
29
- passwordPrompt: vi.fn(),
30
- fsExistsSync: vi.fn(),
31
- fsReadFileSync: vi.fn(),
32
- fsWriteFileSync: vi.fn(),
33
- fsMkdirSync: vi.fn(),
34
- homedir: vi.fn(),
35
- }));
36
-
37
- vi.mock("../../src/utils/sd-config", () => ({
38
- loadSdConfig: mocks.loadSdConfig,
39
- }));
40
-
41
- vi.mock("../../src/commands/build", () => ({
42
- runBuild: mocks.runBuild,
43
- }));
44
-
45
- vi.mock("../../src/deps/replace-deps/replace-deps", () => ({
46
- parseWorkspaceGlobs: mocks.parseWorkspaceGlobs,
47
- }));
48
-
49
- vi.mock("@simplysm/core-node", () => ({
50
- fsx: mocks.fsx,
51
- cpx: {
52
- spawn: mocks.execa,
53
- spawnSync: vi.fn().mockReturnValue({ stdout: "", stderr: "", exitCode: 0 }),
54
- },
55
- }));
56
-
57
- vi.mock("@simplysm/storage", () => ({
58
- StorageFactory: { connect: mocks.storageConnect },
59
- }));
60
-
61
- vi.mock("ssh2", () => {
62
- const ClientCtor = mocks.SshClient;
63
- return {
64
- default: {
65
- Client: ClientCtor,
66
- utils: mocks.sshUtils,
67
- },
68
- };
69
- });
70
-
71
- vi.mock("@inquirer/prompts", () => ({
72
- password: mocks.passwordPrompt,
73
- }));
74
-
75
- vi.mock("fs", async (importOriginal: () => Promise<Record<string, unknown>>) => {
76
- const actual = await importOriginal();
77
- return {
78
- ...actual,
79
- default: {
80
- ...(actual["default"] as Record<string, unknown>),
81
- existsSync: mocks.fsExistsSync,
82
- readFileSync: mocks.fsReadFileSync,
83
- writeFileSync: mocks.fsWriteFileSync,
84
- mkdirSync: mocks.fsMkdirSync,
85
- },
86
- };
87
- });
88
-
89
- vi.mock("os", async (importOriginal: () => Promise<Record<string, unknown>>) => {
90
- const actual = await importOriginal();
91
- return {
92
- ...actual,
93
- default: {
94
- ...(actual["default"] as Record<string, unknown>),
95
- homedir: mocks.homedir,
96
- },
97
- };
98
- });
99
-
100
- const { runPublish } = await import("../../src/commands/publish/publish-command");
101
-
102
- const CWD = process.cwd();
103
-
104
- function pkgPath(name: string): string {
105
- return path.resolve(CWD, `packages/${name}`);
106
- }
107
-
108
- function createPkgJson(
109
- name: string,
110
- version: string,
111
- deps: Record<string, string> = {},
112
- ): { name: string; version: string; dependencies: Record<string, string> } {
113
- return { name: `@simplysm/${name}`, version, dependencies: deps };
114
- }
115
-
116
- /**
117
- * Set up a default happy-path mock environment for npm publish
118
- */
119
- function setupHappyPath(opts: {
120
- version?: string;
121
- packages?: Record<string, { publish?: { type: string; [k: string]: unknown }; target?: string }>;
122
- packageDeps?: Record<string, Record<string, string>>;
123
- templateFiles?: string[];
124
- hasGit?: boolean;
125
- } = {}) {
126
- const version = opts.version ?? "14.0.0";
127
- const packages = opts.packages ?? {
128
- "pkg-a": { target: "node", publish: { type: "npm" } },
129
- "pkg-b": { target: "node", publish: { type: "npm" } },
130
- };
131
- const packageDeps = opts.packageDeps ?? {};
132
- const templateFiles = opts.templateFiles ?? [];
133
- const hasGit = opts.hasGit ?? true;
134
- const pkgNames = Object.keys(packages);
135
-
136
- // loadSdConfig
137
- mocks.loadSdConfig.mockResolvedValue({ packages });
138
-
139
- // parseWorkspaceGlobs
140
- mocks.parseWorkspaceGlobs.mockReturnValue(["packages/*"]);
141
-
142
- // fsx.readJson — route by path
143
- mocks.fsx.readJson.mockImplementation((p: string) => {
144
- const basename = path.basename(path.dirname(p));
145
- // Root package.json
146
- if (p === path.resolve(CWD, "package.json")) {
147
- return createPkgJson("simplysm", version);
148
- }
149
- // Package package.json
150
- if (pkgNames.includes(basename)) {
151
- return createPkgJson(basename, version, packageDeps[basename] ?? {});
152
- }
153
- throw new Error(`Unexpected readJson path: ${p}`);
154
- });
155
-
156
- // fsx.write
157
- mocks.fsx.write.mockResolvedValue(undefined);
158
-
159
- // fsx.read
160
- mocks.fsx.read.mockImplementation((p: string) => {
161
- if (p.includes("pnpm-workspace.yaml")) {
162
- return "packages:\n - packages/*";
163
- }
164
- if (p.endsWith(".hbs")) {
165
- return `"@simplysm/core-common": "~${version}"`;
166
- }
167
- return "";
168
- });
169
-
170
- // fsx.exists
171
- mocks.fsx.exists.mockImplementation((p: string) => {
172
- if (p.endsWith("pnpm-workspace.yaml")) return true;
173
- if (p.endsWith(".git")) return hasGit;
174
- return false;
175
- });
176
-
177
- // fsx.glob
178
- mocks.fsx.glob.mockImplementation((pattern: string) => {
179
- if (pattern.includes("templates")) {
180
- return templateFiles.map((f) => path.resolve(CWD, f));
181
- }
182
- // packages/*
183
- return pkgNames.map((n) => pkgPath(n));
184
- });
185
-
186
- // fsx.copy
187
- mocks.fsx.copy.mockResolvedValue(undefined);
188
-
189
- // runBuild
190
- mocks.runBuild.mockResolvedValue(undefined);
191
-
192
- // execa — default: all succeed
193
- mocks.execa.mockImplementation(
194
- (cmd: string, _args?: string[], _opts?: unknown) => {
195
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
196
- return { stdout: "", stderr: "", exitCode: 0 };
197
- },
198
- );
199
-
200
- // SSH-related: not needed for npm-only, but set defaults
201
- mocks.homedir.mockReturnValue("/mock/home");
202
- mocks.fsExistsSync.mockImplementation((p: string) => {
203
- // package.json existence check for workspace package filtering
204
- if (p.endsWith("package.json")) {
205
- return pkgNames.some((n) => p.includes(n));
206
- }
207
- return true;
208
- });
209
- mocks.fsReadFileSync.mockReturnValue(new TextEncoder().encode("fake-key"));
210
- mocks.sshUtils.parseKey.mockReturnValue({});
211
- }
212
-
213
- /**
214
- * Get all execa calls matching a command prefix
215
- */
216
- function getExecaCalls(
217
- cmd: string,
218
- firstArg?: string,
219
- ): Array<{ cmd: string; args: string[] }> {
220
- return mocks.execa.mock.calls
221
- .filter(
222
- (c: unknown[]) =>
223
- c[0] === cmd && (firstArg == null || (c[1] as string[] | undefined)?.[0] === firstArg),
224
- )
225
- .map((c: unknown[]) => ({ cmd: c[0] as string, args: (c[1] as string[] | undefined) ?? [] }));
226
- }
227
-
228
- describe("runPublish", () => {
229
- let savedExitCode: typeof process.exitCode;
230
-
231
- beforeEach(() => {
232
- vi.clearAllMocks();
233
- savedExitCode = process.exitCode;
234
- process.exitCode = undefined;
235
- });
236
-
237
- afterEach(() => {
238
- process.exitCode = savedExitCode;
239
- });
240
-
241
- describe("package selection", () => {
242
- it("publishes only targeted packages when targets specified", async () => {
243
- setupHappyPath();
244
-
245
- await runPublish({
246
- targets: ["pkg-a"],
247
- noBuild: false,
248
- dryRun: false,
249
- options: [],
250
- });
251
-
252
- const publishCalls = getExecaCalls("pnpm", "publish");
253
- expect(publishCalls).toHaveLength(1);
254
- // The publish call should be for pkg-a's directory
255
- const call = mocks.execa.mock.calls.find(
256
- (c: unknown[]) => c[0] === "pnpm" && (c[1] as string[] | undefined)?.[0] === "publish",
257
- );
258
- expect(call?.[2]).toHaveProperty("cwd", pkgPath("pkg-a"));
259
- expect(call?.[2]).toHaveProperty("shell", true);
260
- });
261
-
262
- it("publishes all packages with publish config when targets empty", async () => {
263
- setupHappyPath();
264
-
265
- await runPublish({
266
- targets: [],
267
- noBuild: false,
268
- dryRun: false,
269
- options: [],
270
- });
271
-
272
- const publishCalls = getExecaCalls("pnpm", "publish");
273
- expect(publishCalls).toHaveLength(2);
274
- });
275
-
276
- it("ignores packages without publish config", async () => {
277
- setupHappyPath({
278
- packages: {
279
- "pkg-a": { target: "node", publish: { type: "npm" } },
280
- "pkg-b": { target: "node" }, // no publish
281
- },
282
- });
283
-
284
- await runPublish({
285
- targets: [],
286
- noBuild: false,
287
- dryRun: false,
288
- options: [],
289
- });
290
-
291
- const publishCalls = getExecaCalls("pnpm", "publish");
292
- expect(publishCalls).toHaveLength(1);
293
- });
294
-
295
- it("outputs no-op message when no packages to deploy", async () => {
296
- setupHappyPath({
297
- packages: {
298
- "pkg-a": { target: "node" }, // no publish
299
- },
300
- });
301
-
302
- const { consola } = await import("consola");
303
- const infoSpy = vi.fn();
304
- const origWithTag = consola.withTag.bind(consola);
305
- const withTagSpy = vi.spyOn(consola, "withTag").mockImplementation((tag: string) => {
306
- const logger = origWithTag(tag);
307
- logger.info = infoSpy as any;
308
- return logger;
309
- });
310
-
311
- // Re-import to pick up the spy (module already loaded, but logger is created at call time)
312
- await runPublish({
313
- targets: [],
314
- noBuild: false,
315
- dryRun: false,
316
- options: [],
317
- });
318
-
319
- const infoArgs = infoSpy.mock.calls.map((c: unknown[]) => String(c[0]));
320
- expect(infoArgs.some((a: string) => a.includes("배포할 패키지가 없습니다"))).toBe(true);
321
- withTagSpy.mockRestore();
322
- });
323
- });
324
-
325
- describe("pre-validation", () => {
326
- it("passes when npm whoami returns valid username", async () => {
327
- setupHappyPath();
328
-
329
- await runPublish({
330
- targets: [],
331
- noBuild: false,
332
- dryRun: false,
333
- options: [],
334
- });
335
-
336
- expect(process.exitCode).toBeUndefined();
337
- expect(getExecaCalls("npm", "whoami")).toHaveLength(1);
338
- expect(mocks.execa).toHaveBeenCalledWith(
339
- "npm",
340
- ["whoami"],
341
- expect.objectContaining({ shell: true }),
342
- );
343
- });
344
-
345
- it("aborts when npm whoami fails", async () => {
346
- setupHappyPath();
347
- mocks.execa.mockImplementation((cmd: string) => {
348
- if (cmd === "npm") throw new Error("npm not authenticated");
349
- return { stdout: "", stderr: "", exitCode: 0 };
350
- });
351
-
352
- await runPublish({
353
- targets: [],
354
- noBuild: false,
355
- dryRun: false,
356
- options: [],
357
- });
358
-
359
- expect(process.exitCode).toBe(1);
360
- // Should not reach build phase
361
- expect(mocks.runBuild).not.toHaveBeenCalled();
362
- });
363
-
364
- it("skips npm auth check when no npm publish packages", async () => {
365
- setupHappyPath({
366
- packages: {
367
- "pkg-a": {
368
- target: "node",
369
- publish: {
370
- type: "local-directory",
371
- path: "/deploy/%VER%",
372
- },
373
- },
374
- },
375
- hasGit: false,
376
- });
377
-
378
- await runPublish({
379
- targets: [],
380
- noBuild: false,
381
- dryRun: false,
382
- options: [],
383
- });
384
-
385
- expect(getExecaCalls("npm", "whoami")).toHaveLength(0);
386
- });
387
-
388
- it("auto-commits with codex when uncommitted changes detected", async () => {
389
- setupHappyPath();
390
- mocks.execa.mockImplementation(
391
- (cmd: string, args?: string[], _opts?: unknown) => {
392
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
393
- if (cmd === "git" && args?.[0] === "diff") {
394
- return { stdout: "file.txt", stderr: "", exitCode: 0 };
395
- }
396
- return { stdout: "", stderr: "", exitCode: 0 };
397
- },
398
- );
399
-
400
- await runPublish({
401
- targets: [],
402
- noBuild: false,
403
- dryRun: false,
404
- options: [],
405
- });
406
-
407
- // codex CLI should have been called for auto-commit
408
- const codexCalls = mocks.execa.mock.calls.filter(
409
- (c: unknown[]) => c[0] === "codex",
410
- );
411
- expect(codexCalls).toHaveLength(1);
412
- expect((codexCalls[0][1] as string[])).toContain("exec");
413
- expect((codexCalls[0][1] as string[])).toContain("gpt-5.3-codex-spark");
414
- expect((codexCalls[0][1] as string[])).toContain('model_reasoning_effort="low"');
415
- expect((codexCalls[0][1] as string[]).some((arg) => arg.includes("$sd-commit"))).toBe(true);
416
- });
417
-
418
- it("aborts when auto-commit codex command fails", async () => {
419
- setupHappyPath();
420
- mocks.execa.mockImplementation((cmd: string, args?: string[]) => {
421
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
422
- if (cmd === "git" && args?.[0] === "diff") {
423
- return { stdout: "file.txt", stderr: "", exitCode: 0 };
424
- }
425
- if (cmd === "codex") {
426
- throw new Error("codex commit failed");
427
- }
428
- return { stdout: "", stderr: "", exitCode: 0 };
429
- });
430
-
431
- await runPublish({
432
- targets: [],
433
- noBuild: false,
434
- dryRun: false,
435
- options: [],
436
- });
437
-
438
- expect(process.exitCode).toBe(1);
439
- });
440
-
441
- it("skips auto-commit when no uncommitted changes", async () => {
442
- setupHappyPath();
443
-
444
- await runPublish({
445
- targets: [],
446
- noBuild: false,
447
- dryRun: false,
448
- options: [],
449
- });
450
-
451
- // No codex CLI calls
452
- const codexCalls = mocks.execa.mock.calls.filter(
453
- (c: unknown[]) => c[0] === "codex",
454
- );
455
- expect(codexCalls).toHaveLength(0);
456
- });
457
- });
458
-
459
- describe("version upgrade and git", () => {
460
- it("increments patch for stable version", async () => {
461
- setupHappyPath({ version: "14.0.0" });
462
-
463
- await runPublish({
464
- targets: [],
465
- noBuild: false,
466
- dryRun: false,
467
- options: [],
468
- });
469
-
470
- // Check that fsx.write was called with new version
471
- const writeCalls = mocks.fsx.write.mock.calls;
472
- const rootPkgWrite = writeCalls.find((c: unknown[]) =>
473
- (c[0] as string).endsWith("package.json") &&
474
- !(c[0] as string).includes("packages/"),
475
- );
476
- expect(rootPkgWrite).toBeDefined();
477
- expect(rootPkgWrite![1]).toContain('"14.0.1"');
478
- });
479
-
480
- it("increments prerelease for prerelease version", async () => {
481
- setupHappyPath({ version: "14.0.0-beta.1" });
482
-
483
- await runPublish({
484
- targets: [],
485
- noBuild: false,
486
- dryRun: false,
487
- options: [],
488
- });
489
-
490
- const writeCalls = mocks.fsx.write.mock.calls;
491
- const rootPkgWrite = writeCalls.find((c: unknown[]) =>
492
- (c[0] as string).endsWith("package.json") &&
493
- !(c[0] as string).includes("packages/"),
494
- );
495
- expect(rootPkgWrite).toBeDefined();
496
- expect(rootPkgWrite![1]).toContain('"14.0.0-beta.2"');
497
- });
498
-
499
- it("syncs version across all workspace packages", async () => {
500
- setupHappyPath({ version: "14.0.0" });
501
-
502
- await runPublish({
503
- targets: [],
504
- noBuild: false,
505
- dryRun: false,
506
- options: [],
507
- });
508
-
509
- // Each package's package.json should be written with new version
510
- const writeCalls = mocks.fsx.write.mock.calls.filter((c: unknown[]) => {
511
- const p = (c[0] as string).replace(/\\/g, "/");
512
- return p.includes("packages/") && p.endsWith("package.json");
513
- });
514
- expect(writeCalls.length).toBeGreaterThanOrEqual(2);
515
- for (const call of writeCalls) {
516
- expect(call[1]).toContain('"14.0.1"');
517
- }
518
- });
519
-
520
- it("syncs @simplysm version in template files", async () => {
521
- setupHappyPath({
522
- version: "14.0.0",
523
- templateFiles: ["packages/sd-cli/templates/test.hbs"],
524
- });
525
-
526
- await runPublish({
527
- targets: [],
528
- noBuild: false,
529
- dryRun: false,
530
- options: [],
531
- });
532
-
533
- // Template file should be written with updated version
534
- const templateWrite = mocks.fsx.write.mock.calls.find((c: unknown[]) =>
535
- (c[0] as string).endsWith(".hbs"),
536
- );
537
- expect(templateWrite).toBeDefined();
538
- expect(templateWrite![1]).toContain("~14.0.1");
539
- });
540
-
541
- it("commits version files and creates annotated tag", async () => {
542
- setupHappyPath({ version: "14.0.0" });
543
-
544
- await runPublish({
545
- targets: [],
546
- noBuild: false,
547
- dryRun: false,
548
- options: [],
549
- });
550
-
551
- const commitCalls = getExecaCalls("git", "commit");
552
- expect(commitCalls).toHaveLength(1);
553
- expect(commitCalls[0].args).toContain("v14.0.1");
554
-
555
- const tagCalls = getExecaCalls("git", "tag");
556
- expect(tagCalls).toHaveLength(1);
557
- expect(tagCalls[0].args).toContain("v14.0.1");
558
- expect(tagCalls[0].args).toContain("-a");
559
-
560
- expect(getExecaCalls("git", "push")).toHaveLength(2); // push + push --tags
561
- });
562
-
563
- it("skips git operations when .git directory absent", async () => {
564
- setupHappyPath({ hasGit: false });
565
-
566
- await runPublish({
567
- targets: [],
568
- noBuild: false,
569
- dryRun: false,
570
- options: [],
571
- });
572
-
573
- expect(getExecaCalls("git", "commit")).toHaveLength(0);
574
- expect(getExecaCalls("git", "tag")).toHaveLength(0);
575
- // Should still publish
576
- expect(getExecaCalls("pnpm", "publish").length).toBeGreaterThan(0);
577
- });
578
-
579
- it("aborts with recovery message when git operations fail", async () => {
580
- setupHappyPath();
581
- mocks.execa.mockImplementation((cmd: string, args?: string[]) => {
582
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
583
- if (cmd === "git" && args?.[0] === "commit") {
584
- throw new Error("git commit failed");
585
- }
586
- return { stdout: "", stderr: "", exitCode: 0 };
587
- });
588
-
589
- await runPublish({
590
- targets: [],
591
- noBuild: false,
592
- dryRun: false,
593
- options: [],
594
- });
595
-
596
- expect(process.exitCode).toBe(1);
597
- expect(getExecaCalls("pnpm", "publish")).toHaveLength(0);
598
- });
599
- });
600
-
601
- describe("deployment", () => {
602
- it("publishes npm stable version without --tag", async () => {
603
- setupHappyPath({ version: "14.0.0" });
604
-
605
- await runPublish({
606
- targets: [],
607
- noBuild: false,
608
- dryRun: false,
609
- options: [],
610
- });
611
-
612
- const publishCalls = getExecaCalls("pnpm", "publish");
613
- expect(publishCalls.length).toBeGreaterThan(0);
614
- for (const call of publishCalls) {
615
- expect(call.args).toContain("--access");
616
- expect(call.args).toContain("public");
617
- expect(call.args).toContain("--no-git-checks");
618
- expect(call.args).not.toContain("--tag");
619
- }
620
- });
621
-
622
- it("publishes npm prerelease version with --tag", async () => {
623
- setupHappyPath({ version: "14.0.0-beta.1" });
624
-
625
- await runPublish({
626
- targets: [],
627
- noBuild: false,
628
- dryRun: false,
629
- options: [],
630
- });
631
-
632
- const publishCalls = getExecaCalls("pnpm", "publish");
633
- expect(publishCalls.length).toBeGreaterThan(0);
634
- for (const call of publishCalls) {
635
- expect(call.args).toContain("--tag");
636
- expect(call.args).toContain("beta");
637
- }
638
- });
639
-
640
- it("copies dist to local directory with env var substitution", async () => {
641
- setupHappyPath({
642
- version: "14.0.0",
643
- packages: {
644
- "pkg-a": {
645
- target: "node",
646
- publish: { type: "local-directory", path: "/deploy/%VER%" },
647
- },
648
- },
649
- hasGit: false,
650
- });
651
-
652
- await runPublish({
653
- targets: [],
654
- noBuild: false,
655
- dryRun: false,
656
- options: [],
657
- });
658
-
659
- expect(mocks.fsx.copy).toHaveBeenCalledWith(
660
- path.resolve(pkgPath("pkg-a"), "dist"),
661
- "/deploy/14.0.1",
662
- );
663
- });
664
-
665
- it("uploads dist to SFTP server", async () => {
666
- setupHappyPath({
667
- version: "14.0.0",
668
- packages: {
669
- "pkg-a": {
670
- target: "node",
671
- publish: {
672
- type: "sftp",
673
- host: "example.com",
674
- port: 22,
675
- user: "deploy",
676
- password: "secret",
677
- path: "/app",
678
- },
679
- },
680
- },
681
- hasGit: false,
682
- });
683
-
684
- mocks.storageConnect.mockImplementation(
685
- async (
686
- _type: string,
687
- _opts: unknown,
688
- cb: (storage: { uploadDir: (...args: unknown[]) => Promise<void> }) => Promise<void>,
689
- ) => {
690
- await cb({ uploadDir: vi.fn() });
691
- },
692
- );
693
-
694
- await runPublish({
695
- targets: [],
696
- noBuild: false,
697
- dryRun: false,
698
- options: [],
699
- });
700
-
701
- expect(mocks.storageConnect).toHaveBeenCalledWith(
702
- "sftp",
703
- expect.objectContaining({ host: "example.com" }),
704
- expect.any(Function),
705
- );
706
- });
707
-
708
- it("uploads to root path when remote path is not specified", async () => {
709
- setupHappyPath({
710
- version: "14.0.0",
711
- packages: {
712
- "pkg-a": {
713
- target: "node",
714
- publish: {
715
- type: "sftp",
716
- host: "example.com",
717
- user: "deploy",
718
- password: "secret",
719
- },
720
- },
721
- },
722
- hasGit: false,
723
- });
724
-
725
- let uploadedPath: string | undefined;
726
- mocks.storageConnect.mockImplementation(
727
- async (
728
- _type: string,
729
- _opts: unknown,
730
- cb: (storage: { uploadDir: (src: string, dest: string) => Promise<void> }) => Promise<void>,
731
- ) => {
732
- await cb({
733
- uploadDir: vi.fn((_src: string, dest: string) => {
734
- uploadedPath = dest;
735
- return Promise.resolve();
736
- }),
737
- });
738
- },
739
- );
740
-
741
- await runPublish({
742
- targets: [],
743
- noBuild: false,
744
- dryRun: false,
745
- options: [],
746
- });
747
-
748
- expect(uploadedPath).toBe("/");
749
- });
750
-
751
- it("deploys packages in dependency order (Level 0 before Level 1)", async () => {
752
- setupHappyPath({
753
- packages: {
754
- "pkg-a": { target: "node", publish: { type: "npm" } },
755
- "pkg-b": { target: "node", publish: { type: "npm" } },
756
- },
757
- packageDeps: {
758
- "pkg-a": {},
759
- "pkg-b": { "@simplysm/pkg-a": "~14.0.0" },
760
- },
761
- });
762
-
763
- const publishOrder: string[] = [];
764
- mocks.execa.mockImplementation(
765
- (cmd: string, args?: string[], opts?: { cwd?: string }) => {
766
- if (cmd === "pnpm" && args?.[0] === "publish") {
767
- publishOrder.push(path.basename(opts?.cwd ?? ""));
768
- }
769
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
770
- return { stdout: "", stderr: "", exitCode: 0 };
771
- },
772
- );
773
-
774
- await runPublish({
775
- targets: [],
776
- noBuild: false,
777
- dryRun: false,
778
- options: [],
779
- });
780
-
781
- const aIdx = publishOrder.indexOf("pkg-a");
782
- const bIdx = publishOrder.indexOf("pkg-b");
783
- expect(aIdx).toBeLessThan(bIdx);
784
- });
785
-
786
- it("only tracks @simplysm scoped dependencies for level computation", async () => {
787
- setupHappyPath({
788
- packages: {
789
- "pkg-a": { target: "node", publish: { type: "npm" } },
790
- },
791
- packageDeps: {
792
- // External dep should not affect level
793
- "pkg-a": { "lodash": "^4.0.0" },
794
- },
795
- });
796
-
797
- await runPublish({
798
- targets: [],
799
- noBuild: false,
800
- dryRun: false,
801
- options: [],
802
- });
803
-
804
- // Should succeed without issues (lodash doesn't create level dependency)
805
- expect(process.exitCode).toBeUndefined();
806
- expect(getExecaCalls("pnpm", "publish")).toHaveLength(1);
807
- });
808
-
809
- it("retries failed publish up to 3 times then reports error", async () => {
810
- setupHappyPath({
811
- packages: {
812
- "pkg-a": { target: "node", publish: { type: "npm" } },
813
- },
814
- });
815
-
816
- let publishAttempts = 0;
817
- mocks.execa.mockImplementation(
818
- (cmd: string, args?: string[]) => {
819
- if (cmd === "pnpm" && args?.[0] === "publish") {
820
- publishAttempts++;
821
- throw new Error("publish failed");
822
- }
823
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
824
- return { stdout: "", stderr: "", exitCode: 0 };
825
- },
826
- );
827
-
828
- await runPublish({
829
- targets: [],
830
- noBuild: false,
831
- dryRun: false,
832
- options: [],
833
- });
834
-
835
- expect(publishAttempts).toBe(3);
836
- expect(process.exitCode).toBe(1);
837
- });
838
-
839
- it("succeeds on retry after initial failure", async () => {
840
- setupHappyPath({
841
- packages: {
842
- "pkg-a": { target: "node", publish: { type: "npm" } },
843
- },
844
- });
845
-
846
- let publishAttempts = 0;
847
- mocks.execa.mockImplementation(
848
- (cmd: string, args?: string[]) => {
849
- if (cmd === "pnpm" && args?.[0] === "publish") {
850
- publishAttempts++;
851
- if (publishAttempts === 1) throw new Error("temporary failure");
852
- return { stdout: "", stderr: "", exitCode: 0 };
853
- }
854
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
855
- return { stdout: "", stderr: "", exitCode: 0 };
856
- },
857
- );
858
-
859
- await runPublish({
860
- targets: [],
861
- noBuild: false,
862
- dryRun: false,
863
- options: [],
864
- });
865
-
866
- expect(publishAttempts).toBe(2);
867
- expect(process.exitCode).toBeUndefined();
868
- });
869
- });
870
-
871
- describe("noBuild, dry-run, postPublish", () => {
872
- it("skips version upgrade, build, and git when noBuild is true", async () => {
873
- setupHappyPath();
874
-
875
- await runPublish({
876
- targets: [],
877
- noBuild: true,
878
- dryRun: false,
879
- options: [],
880
- });
881
-
882
- // No version upgrade (fsx.write for package.json should not be called)
883
- const versionWrites = mocks.fsx.write.mock.calls.filter((c: unknown[]) =>
884
- (c[0] as string).endsWith("package.json"),
885
- );
886
- expect(versionWrites).toHaveLength(0);
887
-
888
- // No build
889
- expect(mocks.runBuild).not.toHaveBeenCalled();
890
-
891
- // No git commit/tag
892
- expect(getExecaCalls("git", "commit")).toHaveLength(0);
893
- expect(getExecaCalls("git", "tag")).toHaveLength(0);
894
-
895
- // But still publishes
896
- expect(getExecaCalls("pnpm", "publish").length).toBeGreaterThan(0);
897
- });
898
-
899
- it("skips git uncommitted check when noBuild is true", async () => {
900
- setupHappyPath();
901
- mocks.execa.mockImplementation((cmd: string, args?: string[]) => {
902
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
903
- if (cmd === "git" && args?.[0] === "diff") {
904
- return { stdout: "changed-file.txt", stderr: "", exitCode: 0 };
905
- }
906
- return { stdout: "", stderr: "", exitCode: 0 };
907
- });
908
-
909
- await runPublish({
910
- targets: [],
911
- noBuild: true,
912
- dryRun: false,
913
- options: [],
914
- });
915
-
916
- // Should not auto-commit since noBuild skips the check
917
- const autoCommitCalls = mocks.execa.mock.calls.filter(
918
- (c: unknown[]) => c[0] === "git" && (c[1] as string[])[0] === "add",
919
- );
920
- expect(autoCommitCalls).toHaveLength(0);
921
- // Should succeed
922
- expect(process.exitCode).toBeUndefined();
923
- });
924
-
925
- it("builds only publish-configured packages", async () => {
926
- setupHappyPath({
927
- packages: {
928
- "pkg-a": { target: "node", publish: { type: "npm" } },
929
- "pkg-b": { target: "node", publish: { type: "npm" } },
930
- "pkg-c": { target: "node" }, // no publish
931
- },
932
- });
933
-
934
- await runPublish({
935
- targets: [],
936
- noBuild: false,
937
- dryRun: false,
938
- options: [],
939
- });
940
-
941
- // publish 설정이 없는 pkg-c는 배포되지 않아야 한다
942
- const publishCalls = getExecaCalls("pnpm", "publish");
943
- expect(publishCalls).toHaveLength(2);
944
- const publishCwds = mocks.execa.mock.calls
945
- .filter((c: unknown[]) => {
946
- const args = c[1] as string[] | undefined;
947
- return c[0] === "pnpm" && args != null && args[0] === "publish";
948
- })
949
- .map((c: unknown[]) => {
950
- const opts = c[2] as { cwd?: string } | undefined;
951
- return opts != null ? (opts.cwd ?? "") : "";
952
- });
953
- for (const cwd of publishCwds) {
954
- expect(cwd).not.toContain("pkg-c");
955
- }
956
- });
957
-
958
- it("aborts with recovery message when build fails", async () => {
959
- setupHappyPath();
960
- mocks.runBuild.mockRejectedValue(new Error("build failed"));
961
-
962
- await runPublish({
963
- targets: [],
964
- noBuild: false,
965
- dryRun: false,
966
- options: [],
967
- });
968
-
969
- expect(process.exitCode).toBe(1);
970
- expect(getExecaCalls("pnpm", "publish")).toHaveLength(0);
971
- });
972
-
973
- it("does not modify files in dry-run version upgrade", async () => {
974
- setupHappyPath({ version: "14.0.0" });
975
-
976
- await runPublish({
977
- targets: [],
978
- noBuild: false,
979
- dryRun: true,
980
- options: [],
981
- });
982
-
983
- // fsx.write should not be called for version changes
984
- const pkgJsonWrites = mocks.fsx.write.mock.calls.filter((c: unknown[]) =>
985
- (c[0] as string).endsWith("package.json"),
986
- );
987
- expect(pkgJsonWrites).toHaveLength(0);
988
- });
989
-
990
- it("adds --dry-run to pnpm publish in dry-run mode", async () => {
991
- setupHappyPath();
992
-
993
- await runPublish({
994
- targets: [],
995
- noBuild: false,
996
- dryRun: true,
997
- options: [],
998
- });
999
-
1000
- const publishCalls = getExecaCalls("pnpm", "publish");
1001
- expect(publishCalls.length).toBeGreaterThan(0);
1002
- for (const call of publishCalls) {
1003
- expect(call.args).toContain("--dry-run");
1004
- }
1005
- });
1006
-
1007
- it("executes postPublish scripts with env var substitution", async () => {
1008
- setupHappyPath({ version: "14.0.0" });
1009
- mocks.loadSdConfig.mockResolvedValue({
1010
- packages: {
1011
- "pkg-a": { target: "node", publish: { type: "npm" } },
1012
- },
1013
- postPublish: [
1014
- { type: "script", cmd: "echo", args: ["v%VER%"] },
1015
- ],
1016
- });
1017
-
1018
- await runPublish({
1019
- targets: [],
1020
- noBuild: false,
1021
- dryRun: false,
1022
- options: [],
1023
- });
1024
-
1025
- const echoCalls = mocks.execa.mock.calls.filter(
1026
- (c: unknown[]) => c[0] === "echo",
1027
- );
1028
- expect(echoCalls).toHaveLength(1);
1029
- expect(echoCalls[0][1]).toEqual(["v14.0.1"]);
1030
- });
1031
-
1032
- it("warns but does not fail when postPublish script fails", async () => {
1033
- setupHappyPath({ version: "14.0.0" });
1034
- mocks.loadSdConfig.mockResolvedValue({
1035
- packages: {
1036
- "pkg-a": { target: "node", publish: { type: "npm" } },
1037
- },
1038
- postPublish: [
1039
- { type: "script", cmd: "failing-cmd", args: [] },
1040
- ],
1041
- });
1042
- mocks.execa.mockImplementation((cmd: string) => {
1043
- if (cmd === "failing-cmd") throw new Error("script failed");
1044
- if (cmd === "npm") return { stdout: "testuser", stderr: "", exitCode: 0 };
1045
- return { stdout: "", stderr: "", exitCode: 0 };
1046
- });
1047
-
1048
- await runPublish({
1049
- targets: [],
1050
- noBuild: false,
1051
- dryRun: false,
1052
- options: [],
1053
- });
1054
-
1055
- // Should NOT set exit code to 1
1056
- expect(process.exitCode).toBeUndefined();
1057
- });
1058
-
1059
- it("passes options to loadSdConfig", async () => {
1060
- setupHappyPath();
1061
-
1062
- await runPublish({
1063
- targets: [],
1064
- noBuild: true,
1065
- dryRun: false,
1066
- options: ["production"],
1067
- });
1068
-
1069
- expect(mocks.loadSdConfig).toHaveBeenCalledWith(
1070
- expect.objectContaining({ opt: ["production"] }),
1071
- );
1072
- });
1073
-
1074
- it("throws error for unresolved environment variables", async () => {
1075
- setupHappyPath({
1076
- version: "14.0.0",
1077
- packages: {
1078
- "pkg-a": {
1079
- target: "node",
1080
- publish: {
1081
- type: "local-directory",
1082
- path: "/deploy/%UNDEFINED_VAR%",
1083
- },
1084
- },
1085
- },
1086
- hasGit: false,
1087
- });
1088
-
1089
- await runPublish({
1090
- targets: [],
1091
- noBuild: false,
1092
- dryRun: false,
1093
- options: [],
1094
- });
1095
-
1096
- // Should fail due to unresolved env var
1097
- expect(process.exitCode).toBe(1);
1098
- });
1099
- });
1100
-
1101
- describe("publish security + stability", () => {
1102
- it("dry-run does not execute git push (no network)", async () => {
1103
- setupHappyPath({ version: "14.0.0" });
1104
-
1105
- await runPublish({
1106
- targets: [],
1107
- noBuild: false,
1108
- dryRun: true,
1109
- options: [],
1110
- });
1111
-
1112
- // git push should NOT be called in dry-run mode
1113
- const pushCalls = mocks.execa.mock.calls.filter(
1114
- (c: unknown[]) =>
1115
- c[0] === "git" && (c[1] as string[])[0] === "push",
1116
- );
1117
- expect(pushCalls).toHaveLength(0);
1118
- });
1119
-
1120
- it("includes workspace packages whose directory name contains a dot", async () => {
1121
- setupHappyPath({
1122
- packages: {
1123
- "pkg.v2": { target: "node", publish: { type: "npm" } },
1124
- },
1125
- });
1126
-
1127
- // fsx.glob returns a directory with a dot in its name
1128
- mocks.fsx.glob.mockImplementation((pattern: string) => {
1129
- if (pattern.includes("templates")) return [];
1130
- return [pkgPath("pkg.v2")];
1131
- });
1132
-
1133
- // fs.existsSync should report package.json exists in pkg.v2
1134
- mocks.fsExistsSync.mockImplementation((p: string) => {
1135
- if (typeof p === "string" && p.endsWith("package.json")) {
1136
- return p.includes("pkg.v2");
1137
- }
1138
- return true;
1139
- });
1140
-
1141
- // fsx.readJson for pkg.v2
1142
- mocks.fsx.readJson.mockImplementation((p: string) => {
1143
- if (p === path.resolve(CWD, "package.json")) {
1144
- return createPkgJson("simplysm", "14.0.0");
1145
- }
1146
- return createPkgJson("pkg.v2", "14.0.0");
1147
- });
1148
-
1149
- await runPublish({
1150
- targets: [],
1151
- noBuild: false,
1152
- dryRun: false,
1153
- options: [],
1154
- });
1155
-
1156
- // pkg.v2 should have been published (not filtered out by the dot)
1157
- const publishCalls = getExecaCalls("pnpm", "publish");
1158
- expect(publishCalls).toHaveLength(1);
1159
- });
1160
- });
1161
-
1162
- describe("target validation", () => {
1163
- it("throws error for unknown target", async () => {
1164
- mocks.loadSdConfig.mockResolvedValue({
1165
- packages: {
1166
- "pkg-a": { target: "node", publish: { type: "npm" } },
1167
- },
1168
- });
1169
-
1170
- await expect(
1171
- runPublish({ targets: ["nonexistent"], noBuild: false, dryRun: false, options: [] }),
1172
- ).rejects.toThrow("Unknown target: nonexistent");
1173
- });
1174
-
1175
- it("does not throw for valid target", async () => {
1176
- setupHappyPath();
1177
-
1178
- // Should not throw for valid target
1179
- await runPublish({ targets: ["pkg-a"], noBuild: false, dryRun: false, options: [] });
1180
- // If it didn't throw, validation passed
1181
- });
1182
- });
1183
- });