sanjang 0.3.4 → 0.3.6

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.
@@ -7,6 +7,18 @@
7
7
 
8
8
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap');
9
9
 
10
+ .sr-only {
11
+ position: absolute;
12
+ width: 1px;
13
+ height: 1px;
14
+ padding: 0;
15
+ margin: -1px;
16
+ overflow: hidden;
17
+ clip: rect(0,0,0,0);
18
+ white-space: nowrap;
19
+ border: 0;
20
+ }
21
+
10
22
  *, *::before, *::after {
11
23
  box-sizing: border-box;
12
24
  margin: 0;
@@ -684,6 +696,35 @@ header h1::before {
684
696
  letter-spacing: -0.01em;
685
697
  }
686
698
 
699
+ .new-camp-tabs {
700
+ display: flex;
701
+ gap: 0;
702
+ border-bottom: 1px solid var(--border);
703
+ margin: -8px 0 0;
704
+ }
705
+
706
+ .new-camp-tab {
707
+ flex: 1;
708
+ padding: 8px 12px;
709
+ background: none;
710
+ border: none;
711
+ border-bottom: 2px solid transparent;
712
+ color: var(--text-muted);
713
+ font-size: 13px;
714
+ cursor: pointer;
715
+ transition: color 0.15s, border-color 0.15s;
716
+ }
717
+
718
+ .new-camp-tab:hover {
719
+ color: var(--text-primary);
720
+ }
721
+
722
+ .new-camp-tab.active {
723
+ color: var(--text-primary);
724
+ border-bottom-color: var(--accent);
725
+ font-weight: 500;
726
+ }
727
+
687
728
  /* Form elements */
688
729
  .form-group {
689
730
  display: flex;
@@ -968,6 +1009,49 @@ header h1::before {
968
1009
  font-weight: 600;
969
1010
  }
970
1011
 
1012
+ /* Camp switcher dropdown */
1013
+ .ws-camp-switcher { position: relative; }
1014
+ .ws-camp-switch-btn {
1015
+ background: none;
1016
+ border: 1px solid transparent;
1017
+ border-radius: 6px;
1018
+ padding: 4px 8px;
1019
+ font-size: 14px;
1020
+ font-weight: 600;
1021
+ color: var(--text);
1022
+ cursor: pointer;
1023
+ }
1024
+ .ws-camp-switch-btn:hover { border-color: var(--border-subtle); background: var(--surface); }
1025
+ .ws-camp-switch-btn::after { content: ' ▾'; font-size: 10px; color: var(--text-muted); }
1026
+ .ws-camp-dropdown {
1027
+ display: none;
1028
+ position: absolute;
1029
+ top: 100%;
1030
+ left: 0;
1031
+ z-index: 100;
1032
+ min-width: 220px;
1033
+ background: var(--bg);
1034
+ border: 1px solid var(--border-subtle);
1035
+ border-radius: 8px;
1036
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15);
1037
+ padding: 4px;
1038
+ margin-top: 4px;
1039
+ }
1040
+ .ws-camp-dropdown.open { display: block; }
1041
+ .ws-camp-dd-item {
1042
+ display: flex;
1043
+ align-items: center;
1044
+ gap: 8px;
1045
+ padding: 8px 10px;
1046
+ border-radius: 6px;
1047
+ cursor: pointer;
1048
+ font-size: 13px;
1049
+ }
1050
+ .ws-camp-dd-item:hover { background: var(--surface); }
1051
+ .ws-camp-dd-name { font-weight: 500; color: var(--text); }
1052
+ .ws-camp-dd-branch { color: var(--text-muted); font-size: 11px; margin-left: auto; }
1053
+ .ws-camp-dd-empty { padding: 12px; color: var(--text-muted); font-size: 12px; text-align: center; }
1054
+
971
1055
  /* Preview — full screen behind topbar */
972
1056
  .ws-preview-full {
973
1057
  position: absolute;
@@ -1074,6 +1158,7 @@ header h1::before {
1074
1158
  padding: 3px 0;
1075
1159
  border-bottom: 1px solid var(--border-subtle);
1076
1160
  display: flex;
1161
+ flex-wrap: wrap;
1077
1162
  gap: 6px;
1078
1163
  align-items: baseline;
1079
1164
  }
@@ -1090,13 +1175,113 @@ header h1::before {
1090
1175
  word-break: break-all;
1091
1176
  }
1092
1177
 
1093
- .ws-error-badge {
1178
+ .ws-error-stack {
1179
+ width: 100%;
1180
+ margin-top: 2px;
1181
+ }
1182
+
1183
+ .ws-error-stack summary {
1184
+ cursor: pointer;
1185
+ color: var(--text-muted);
1186
+ font-size: 10px;
1187
+ user-select: none;
1188
+ }
1189
+
1190
+ .ws-error-stack pre {
1191
+ margin: 4px 0 0;
1192
+ padding: 6px 8px;
1193
+ background: rgba(0,0,0,0.3);
1194
+ border-radius: 4px;
1195
+ font-size: 10px;
1196
+ line-height: 1.5;
1197
+ color: var(--text-secondary);
1198
+ white-space: pre-wrap;
1199
+ word-break: break-all;
1200
+ max-height: 150px;
1201
+ overflow-y: auto;
1202
+ }
1203
+
1204
+ .btn-fix {
1205
+ display: block;
1206
+ width: 100%;
1207
+ margin-top: 8px;
1208
+ padding: 8px 12px;
1209
+ background: linear-gradient(135deg, #ef4444 0%, #f97316 100%);
1210
+ color: #fff;
1211
+ border: none;
1212
+ border-radius: 6px;
1213
+ font-size: 13px;
1214
+ font-weight: 600;
1215
+ cursor: pointer;
1216
+ transition: opacity 0.15s, transform 0.1s;
1217
+ }
1218
+ .btn-fix:hover { opacity: 0.9; transform: translateY(-1px); }
1219
+ .btn-fix:active { transform: translateY(0); }
1220
+
1221
+ .ws-error-badge, .ws-devtab-badge {
1094
1222
  background: #ef4444;
1095
1223
  color: white;
1096
1224
  font-size: 10px;
1097
1225
  padding: 1px 6px;
1098
1226
  border-radius: 10px;
1099
- margin-left: 6px;
1227
+ margin-left: 4px;
1228
+ }
1229
+
1230
+ /* Devtools tabs */
1231
+ .ws-devtabs {
1232
+ display: flex;
1233
+ gap: 0;
1234
+ border-bottom: 1px solid var(--border-subtle);
1235
+ margin-bottom: 8px;
1236
+ }
1237
+ .ws-devtab-btn {
1238
+ background: none;
1239
+ border: none;
1240
+ border-bottom: 2px solid transparent;
1241
+ padding: 6px 10px;
1242
+ font-size: 12px;
1243
+ color: var(--text-muted);
1244
+ cursor: pointer;
1245
+ white-space: nowrap;
1246
+ }
1247
+ .ws-devtab-btn:hover { color: var(--text); }
1248
+ .ws-devtab-btn.ws-devtab-active { color: var(--text); border-bottom-color: var(--accent); }
1249
+ .ws-devtab-panel { max-height: 300px; overflow-y: auto; }
1250
+
1251
+ /* Console panel */
1252
+ .ws-console-panel { padding: 4px 8px; font-size: 11px; font-family: var(--font-mono); }
1253
+ .ws-console-item { padding: 2px 0; border-bottom: 1px solid var(--border-subtle); word-break: break-all; }
1254
+ .ws-console-log { color: var(--text); }
1255
+ .ws-console-warn { color: #f59e0b; }
1256
+ .ws-console-info { color: #3b82f6; }
1257
+
1258
+ /* Network panel */
1259
+ .ws-network-panel { padding: 4px 8px; font-size: 11px; font-family: var(--font-mono); }
1260
+ .ws-net-item { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px solid var(--border-subtle); }
1261
+ .ws-net-method { font-weight: 600; font-size: 10px; padding: 1px 4px; border-radius: 3px; flex-shrink: 0; }
1262
+ .ws-net-method-get { color: #22c55e; }
1263
+ .ws-net-method-post { color: #3b82f6; }
1264
+ .ws-net-method-put { color: #f59e0b; }
1265
+ .ws-net-method-delete { color: #ef4444; }
1266
+ .ws-net-method-patch { color: #a855f7; }
1267
+ .ws-net-url { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
1268
+ .ws-net-status { font-weight: 600; flex-shrink: 0; }
1269
+ .ws-net-ok { color: #22c55e; }
1270
+ .ws-net-warn { color: #f59e0b; }
1271
+ .ws-net-err { color: #ef4444; }
1272
+ .ws-net-dur { color: var(--text-muted); flex-shrink: 0; }
1273
+
1274
+ /* Test runner panel */
1275
+ .ws-test-panel { padding: 4px 8px; font-size: 12px; }
1276
+ .ws-test-status { padding: 8px; font-weight: 600; border-radius: 6px; margin-bottom: 8px; }
1277
+ .ws-test-running { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
1278
+ .ws-test-pass { background: #dcfce7; color: #166534; }
1279
+ .ws-test-fail { background: #fef2f2; color: #991b1b; }
1280
+ .ws-test-output { max-height: 250px; overflow: auto; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; padding: 8px; background: var(--surface); border-radius: 6px; }
1281
+ .ws-test-badge { font-size: 10px; }
1282
+ @media (prefers-color-scheme: dark) {
1283
+ .ws-test-pass { background: #052e16; color: #86efac; }
1284
+ .ws-test-fail { background: #450a0a; color: #fca5a5; }
1100
1285
  }
1101
1286
 
1102
1287
  .ws-file-item {
@@ -1106,6 +1291,62 @@ header h1::before {
1106
1291
  padding: 3px 0;
1107
1292
  font-size: 13px;
1108
1293
  }
1294
+ /* Stale camp banner + cleanup */
1295
+ .portal-stale-banner {
1296
+ display: flex;
1297
+ align-items: center;
1298
+ justify-content: space-between;
1299
+ padding: 10px 16px;
1300
+ margin: 0 16px;
1301
+ background: color-mix(in srgb, #f59e0b 12%, transparent);
1302
+ border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
1303
+ border-radius: 8px;
1304
+ font-size: 13px;
1305
+ color: var(--text);
1306
+ }
1307
+ .ws-stale-item {
1308
+ display: flex;
1309
+ align-items: center;
1310
+ gap: 8px;
1311
+ padding: 8px 0;
1312
+ border-bottom: 1px solid var(--border-subtle);
1313
+ font-size: 13px;
1314
+ cursor: pointer;
1315
+ }
1316
+ .ws-stale-item:last-child { border-bottom: none; }
1317
+ .ws-stale-item input { flex-shrink: 0; }
1318
+
1319
+ /* Conflict resolution */
1320
+ .conflict-file { border: 1px solid var(--border-subtle); border-radius: 8px; margin-bottom: 8px; overflow: hidden; transition: opacity 0.3s; }
1321
+ .conflict-file-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: var(--surface); }
1322
+ .conflict-file-header code { font-size: 12px; }
1323
+ .conflict-file-actions { display: flex; gap: 4px; }
1324
+ .conflict-section { display: flex; gap: 0; }
1325
+ .conflict-side { flex: 1; padding: 8px; font-size: 11px; overflow: auto; }
1326
+ .conflict-side pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono); font-size: 11px; }
1327
+ .conflict-side-label { font-size: 10px; font-weight: 600; margin-bottom: 4px; }
1328
+ .conflict-ours { background: color-mix(in srgb, #3b82f6 10%, transparent); border-right: 1px solid var(--border-subtle); }
1329
+ .conflict-ours .conflict-side-label { color: #3b82f6; }
1330
+ .conflict-theirs { background: color-mix(in srgb, #f59e0b 10%, transparent); }
1331
+ .conflict-theirs .conflict-side-label { color: #f59e0b; }
1332
+
1333
+ .ws-file-clickable { cursor: pointer; border-radius: 4px; padding: 3px 4px; }
1334
+ .ws-file-clickable:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1335
+
1336
+ /* Diff modal */
1337
+ .modal-wide { max-width: 720px; width: 90vw; }
1338
+ .diff-content { max-height: 60vh; overflow: auto; }
1339
+ .diff-header { font-size: 13px; font-weight: 600; color: var(--text-muted); padding: 8px 0 4px; }
1340
+ .diff-pre { margin: 0; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
1341
+ .diff-line { padding: 0 8px; }
1342
+ .diff-line-add { background: #dcfce7; color: #166534; }
1343
+ .diff-line-del { background: #fef2f2; color: #991b1b; }
1344
+ .diff-line-hunk { background: var(--surface); color: var(--accent); font-weight: 600; margin-top: 8px; }
1345
+ .diff-line-meta { color: var(--text-muted); font-weight: 600; }
1346
+ @media (prefers-color-scheme: dark) {
1347
+ .diff-line-add { background: #052e16; color: #86efac; }
1348
+ .diff-line-del { background: #450a0a; color: #fca5a5; }
1349
+ }
1109
1350
 
1110
1351
  .ws-action-item {
1111
1352
  font-size: 13px;
@@ -1707,11 +1948,8 @@ header.hidden {
1707
1948
  margin-top: 8px;
1708
1949
  }
1709
1950
  .ws-commit-item {
1710
- display: flex;
1711
- justify-content: space-between;
1712
- align-items: baseline;
1713
- gap: 8px;
1714
- padding: 4px 0;
1951
+ display: block;
1952
+ padding: 0;
1715
1953
  font-size: 13px;
1716
1954
  border-bottom: 1px solid var(--border);
1717
1955
  }
@@ -1720,6 +1958,10 @@ header.hidden {
1720
1958
  }
1721
1959
  .ws-commit-msg {
1722
1960
  color: var(--text-primary);
1961
+ white-space: nowrap;
1962
+ overflow: hidden;
1963
+ text-overflow: ellipsis;
1964
+ min-width: 0;
1723
1965
  }
1724
1966
  .ws-commit-date {
1725
1967
  color: var(--text-muted);
@@ -2096,6 +2338,9 @@ header.hidden {
2096
2338
  font-size: 11px;
2097
2339
  color: #e4e8f0;
2098
2340
  white-space: nowrap;
2341
+ max-width: min(320px, calc(100vw - 120px));
2342
+ overflow: hidden;
2343
+ text-overflow: ellipsis;
2099
2344
  animation: bubble-float 3s ease-in-out infinite;
2100
2345
  }
2101
2346
 
@@ -2169,3 +2414,125 @@ header.hidden {
2169
2414
  font-size: 11px;
2170
2415
  color: var(--text-muted);
2171
2416
  }
2417
+
2418
+ /* Change Report */
2419
+ .ws-report-section { border-top: 1px solid var(--border); padding-top: 12px; }
2420
+ .ws-report-desc { font-size: 14px; line-height: 1.6; color: var(--text); margin-bottom: 8px; white-space: pre-line; }
2421
+ .ws-report-warning { display: flex; align-items: center; gap: 6px; padding: 6px 8px; margin-bottom: 4px; background: rgba(255, 180, 0, 0.08); border-radius: 6px; font-size: 13px; color: var(--text-muted); }
2422
+ .ws-report-warning-icon { font-size: 14px; flex-shrink: 0; }
2423
+ .ws-report-categories { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
2424
+ .ws-report-cat-group { }
2425
+ .ws-report-cat-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
2426
+ .ws-report-cat-label { font-size: 13px; font-weight: 600; color: var(--text); }
2427
+ .ws-report-cat-count { font-size: 11px; color: var(--text-muted); background: var(--surface); padding: 1px 6px; border-radius: 8px; }
2428
+ .ws-report-cat-items { list-style: none; margin: 0; padding: 0; }
2429
+ .ws-report-cat-items li { font-size: 13px; color: var(--text-muted); padding: 2px 0 2px 16px; position: relative; line-height: 1.5; }
2430
+ .ws-report-cat-items li::before { content: '•'; position: absolute; left: 4px; color: var(--text-muted); }
2431
+ .ws-report-nav-item { cursor: pointer; transition: color 0.15s, background 0.15s; border-radius: 4px; }
2432
+ .ws-report-nav-item:hover { color: var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, transparent); }
2433
+ .ws-report-nav-hint { font-size: 11px; color: var(--accent); opacity: 0; transition: opacity 0.15s; margin-left: 4px; }
2434
+ .ws-report-nav-item:hover .ws-report-nav-hint { opacity: 1; }
2435
+ .ws-report-saved { opacity: 0.7; }
2436
+ .ws-report-saved-desc { font-size: 13px; color: var(--text-muted); }
2437
+
2438
+ /* Commit report (details/summary dropdown) */
2439
+ details.ws-commit-item { border-radius: 6px; margin-bottom: 4px; }
2440
+ details.ws-commit-item > summary.ws-commit-summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; padding: 6px 8px; border-radius: 6px; flex-wrap: nowrap; }
2441
+ details.ws-commit-item > summary.ws-commit-summary::-webkit-details-marker { display: none; }
2442
+ details.ws-commit-item > summary.ws-commit-summary:hover { background: var(--surface); }
2443
+ .ws-commit-arrow { font-size: 10px; color: var(--text-muted); flex-shrink: 0; transition: transform 0.15s; display: inline-block; }
2444
+ details.ws-commit-item[open] .ws-commit-arrow { transform: rotate(90deg); }
2445
+ .ws-commit-report { margin: 4px 8px 8px 8px; padding: 10px 12px; background: var(--surface); border-radius: 6px; font-size: 12px; width: auto; }
2446
+ .ws-commit-report-loading, .ws-commit-report-empty { color: var(--text-muted); }
2447
+ .ws-commit-cat { margin-bottom: 6px; }
2448
+ .ws-commit-cat:last-child { margin-bottom: 0; }
2449
+ .ws-commit-cat-label { font-size: 11px; font-weight: 600; color: var(--text-muted); display: block; margin-bottom: 2px; }
2450
+ .ws-commit-cat-item { color: var(--text-muted); padding-left: 12px; position: relative; line-height: 1.5; }
2451
+ .ws-commit-cat-item::before { content: '•'; position: absolute; left: 3px; color: var(--text-muted); }
2452
+
2453
+ /* Preview toolbar — URL bar + viewport switcher */
2454
+ .ws-preview-toolbar {
2455
+ display: flex;
2456
+ align-items: center;
2457
+ gap: 8px;
2458
+ padding: 4px 8px;
2459
+ background: var(--bg);
2460
+ border-bottom: 1px solid var(--border-subtle);
2461
+ flex-shrink: 0;
2462
+ }
2463
+ .ws-url-bar {
2464
+ display: flex;
2465
+ align-items: center;
2466
+ gap: 4px;
2467
+ flex: 1;
2468
+ min-width: 0;
2469
+ }
2470
+ .ws-url-back, .ws-url-refresh {
2471
+ background: none;
2472
+ border: 1px solid var(--border-subtle);
2473
+ border-radius: 4px;
2474
+ width: 28px;
2475
+ height: 28px;
2476
+ cursor: pointer;
2477
+ font-size: 14px;
2478
+ color: var(--text-muted);
2479
+ display: flex;
2480
+ align-items: center;
2481
+ justify-content: center;
2482
+ flex-shrink: 0;
2483
+ }
2484
+ .ws-url-back:hover, .ws-url-refresh:hover { background: var(--surface); color: var(--text); }
2485
+ .ws-url-input {
2486
+ flex: 1;
2487
+ min-width: 0;
2488
+ height: 28px;
2489
+ padding: 0 8px;
2490
+ border: 1px solid var(--border-subtle);
2491
+ border-radius: 4px;
2492
+ background: var(--surface);
2493
+ color: var(--text);
2494
+ font-family: var(--font-mono);
2495
+ font-size: 12px;
2496
+ }
2497
+ .ws-url-input:focus { outline: none; border-color: var(--accent); }
2498
+ .ws-viewport-switcher {
2499
+ display: flex;
2500
+ gap: 2px;
2501
+ flex-shrink: 0;
2502
+ }
2503
+ .ws-vp-btn {
2504
+ background: none;
2505
+ border: 1px solid transparent;
2506
+ border-radius: 4px;
2507
+ width: 28px;
2508
+ height: 28px;
2509
+ cursor: pointer;
2510
+ font-size: 14px;
2511
+ display: flex;
2512
+ align-items: center;
2513
+ justify-content: center;
2514
+ }
2515
+ .ws-vp-btn:hover { background: var(--surface); }
2516
+ .ws-vp-btn.ws-vp-active { background: var(--surface); border-color: var(--accent); }
2517
+
2518
+ /* Split-view compare */
2519
+ .ws-preview-container { position: relative; flex: 1; display: flex; }
2520
+ .ws-preview-container .ws-preview-full { flex: 1; }
2521
+ .ws-preview-main { display: none; flex: 1; flex-direction: column; border-left: 2px solid var(--accent); position: relative; }
2522
+ .ws-preview-main.hidden { display: none !important; }
2523
+ .ws-split-view .ws-preview-full,
2524
+ .ws-split-view .ws-preview-main { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative !important; top: auto !important; left: auto !important; right: auto !important; bottom: auto !important; }
2525
+ .ws-split-view .ws-preview-iframe { width: 100%; height: 100%; border: none; }
2526
+ .ws-preview-label { position: absolute; top: 8px; left: 8px; z-index: 10; background: var(--surface); padding: 2px 8px; border-radius: 4px; font-size: 11px; color: var(--text-muted); pointer-events: none; }
2527
+ .ws-preview-loading { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-size: 14px; }
2528
+ .btn-active { background: var(--accent) !important; color: white !important; }
2529
+ .ws-split-view .ws-preview-full::before {
2530
+ content: '⛺ 내 캠프';
2531
+ position: absolute; top: 8px; left: 8px; z-index: 10;
2532
+ background: var(--surface); padding: 2px 8px; border-radius: 4px;
2533
+ font-size: 11px; color: var(--text-muted); pointer-events: none;
2534
+ }
2535
+
2536
+ /* Ship report preview */
2537
+ .ship-report-preview { margin-bottom: 12px; padding: 10px; background: var(--surface); border-radius: 6px; font-size: 13px; line-height: 1.5; }
2538
+ .ship-report-desc { margin-bottom: 8px; }
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ // Node 22+ required for --experimental-transform-types
3
+ const nodeVersion = parseInt(process.versions.node.split(".")[0], 10);
4
+ if (nodeVersion < 22) {
5
+ console.error(`⛰ 산장: Node 22 이상이 필요합니다. (현재: v${process.versions.node})`);
6
+ console.error(" 해결: nvm install 22 && nvm use 22");
7
+ console.error(" 또는: https://nodejs.org 에서 최신 LTS를 설치하세요.");
8
+ process.exit(1);
9
+ }
2
10
  import { execSync } from "node:child_process";
3
11
  import { existsSync } from "node:fs";
4
12
  import { resolve } from "node:path";
@@ -14,7 +22,7 @@ for (let i = 0; i < args.length; i++) {
14
22
  i++;
15
23
  }
16
24
  if (args[i] === "--port" && args[i + 1]) {
17
- port = parseInt(args[i + 1]);
25
+ port = parseInt(args[i + 1], 10);
18
26
  i++;
19
27
  }
20
28
  if (args[i] === "--force") {
@@ -53,8 +61,8 @@ if (command === "init") {
53
61
  rl.question(" 어떤 앱을 띄울까요? [번호]: ", resolve);
54
62
  });
55
63
  rl.close();
56
- const idx = parseInt(answer) - 1;
57
- if (idx < 0 || idx >= apps.length || isNaN(idx)) {
64
+ const idx = parseInt(answer, 10) - 1;
65
+ if (idx < 0 || idx >= apps.length || Number.isNaN(idx)) {
58
66
  console.error("⛰ 잘못된 선택입니다.");
59
67
  process.exit(1);
60
68
  }
@@ -116,21 +124,156 @@ if (command === "init") {
116
124
  }
117
125
  else if (command === "help" || command === "--help" || command === "-h") {
118
126
  console.log(`
119
- ⛰ 산장 (Sanjang) — 바이브코더를 위한 로컬 개발 환경 매니저
127
+ ⛰ 산장 (Sanjang) — 비개발자가 AI로 코드를 고치고 PR을 보낼 수 있는 로컬 개발 환경
120
128
 
121
129
  사용법:
122
130
  sanjang 서버 시작 (대시보드: http://localhost:4000)
123
131
  sanjang init 프로젝트 분석 → sanjang.config.js 생성
132
+ sanjang list 캠프 목록 보기
133
+ sanjang status 서버 + 캠프 상태 확인
134
+ sanjang start <name> 캠프 시작
135
+ sanjang stop <name> 캠프 중지
136
+ sanjang open <name> 캠프를 브라우저에서 열기
124
137
  sanjang help 이 도움말
125
138
 
126
139
  옵션:
127
140
  --port <N> 대시보드 포트 (기본: 4000)
128
141
  --project <path> 프로젝트 경로 (기본: 현재 디렉토리)
142
+ --json JSON으로 출력 (Claude Code 등 자동화용)
129
143
  --force 기존 설정을 덮어쓰고 다시 생성
130
144
 
131
145
  자세히: https://github.com/paul-sherpas/sanjang
132
146
  `);
133
147
  }
148
+ else if (command === "list" || command === "status" || command === "start" || command === "stop" || command === "open") {
149
+ // CLI commands that talk to the running sanjang server
150
+ const jsonMode = args.includes("--json");
151
+ const campName = args[1] && !args[1].startsWith("-") ? args[1] : undefined;
152
+ const baseUrl = `http://127.0.0.1:${port}`;
153
+ async function apiFetch(path, method = "GET", body) {
154
+ const opts = { method, headers: { "content-type": "application/json" } };
155
+ if (body)
156
+ opts.body = JSON.stringify(body);
157
+ const res = await fetch(`${baseUrl}${path}`, opts);
158
+ if (!res.ok) {
159
+ const err = await res.json().catch(() => ({ error: res.statusText }));
160
+ throw new Error(err.error || `HTTP ${res.status}`);
161
+ }
162
+ return res.json();
163
+ }
164
+ async function tryApi(fn) {
165
+ try {
166
+ return await fn();
167
+ }
168
+ catch (err) {
169
+ if (err.cause && String(err.cause).includes("ECONNREFUSED")) {
170
+ console.error("⛰ 산장 서버가 실행되지 않고 있습니다.");
171
+ console.error(` sanjang 또는 npx sanjang 으로 먼저 시작하세요.`);
172
+ process.exit(1);
173
+ }
174
+ throw err;
175
+ }
176
+ }
177
+ try {
178
+ if (command === "list") {
179
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
180
+ if (jsonMode) {
181
+ process.stdout.write(JSON.stringify(camps, null, 2) + "\n");
182
+ }
183
+ else if (camps.length === 0) {
184
+ console.log("⛰ 캠프가 없습니다. 대시보드에서 만들어보세요.");
185
+ }
186
+ else {
187
+ console.log("⛰ 캠프 목록:\n");
188
+ for (const c of camps) {
189
+ const status = c.status === "running" ? "🟢" : c.status === "error" ? "🔴" : "⚪";
190
+ const url = c.status === "running" && c.fePort ? `http://localhost:${c.fePort}` : "";
191
+ console.log(` ${status} ${c.name}\t${c.branch}\t${url}`);
192
+ }
193
+ }
194
+ }
195
+ else if (command === "status") {
196
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
197
+ const running = camps.filter(c => c.status === "running").length;
198
+ const total = camps.length;
199
+ if (jsonMode) {
200
+ process.stdout.write(JSON.stringify({ server: { url: baseUrl, status: "running" }, camps }, null, 2) + "\n");
201
+ }
202
+ else {
203
+ console.log(`⛰ 산장 서버: ${baseUrl}`);
204
+ console.log(` 캠프: ${total}개 (실행 중 ${running}개)`);
205
+ for (const c of camps) {
206
+ const status = c.status === "running" ? "🟢" : c.status === "error" ? "🔴" : "⚪";
207
+ console.log(` ${status} ${c.name} (${c.status})`);
208
+ }
209
+ }
210
+ }
211
+ else if (command === "start") {
212
+ if (!campName) {
213
+ console.error("⛰ 사용법: sanjang start <캠프이름>");
214
+ process.exit(1);
215
+ }
216
+ const result = await tryApi(() => apiFetch(`/api/playgrounds/${campName}/start`, "POST"));
217
+ if (jsonMode) {
218
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
219
+ }
220
+ else {
221
+ console.log(`⛰ ${campName} 캠프를 시작합니다.`);
222
+ }
223
+ }
224
+ else if (command === "stop") {
225
+ if (!campName) {
226
+ console.error("⛰ 사용법: sanjang stop <캠프이름>");
227
+ process.exit(1);
228
+ }
229
+ const result = await tryApi(() => apiFetch(`/api/playgrounds/${campName}/stop`, "POST"));
230
+ if (jsonMode) {
231
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
232
+ }
233
+ else {
234
+ console.log(`⛰ ${campName} 캠프를 중지합니다.`);
235
+ }
236
+ }
237
+ else if (command === "open") {
238
+ if (!campName) {
239
+ console.error("⛰ 사용법: sanjang open <캠프이름>");
240
+ process.exit(1);
241
+ }
242
+ const camps = await tryApi(() => apiFetch("/api/playgrounds"));
243
+ const camp = camps.find(c => c.name === campName);
244
+ if (!camp) {
245
+ console.error(`⛰ "${campName}" 캠프를 찾을 수 없습니다.`);
246
+ process.exit(1);
247
+ }
248
+ if (camp.status !== "running" || !camp.fePort) {
249
+ console.error(`⛰ "${campName}" 캠프가 실행 중이 아닙니다. sanjang start ${campName} 을 먼저 실행하세요.`);
250
+ process.exit(1);
251
+ }
252
+ const campUrl = `http://localhost:${camp.fePort}`;
253
+ if (jsonMode) {
254
+ process.stdout.write(JSON.stringify({ name: campName, url: campUrl, status: camp.status }, null, 2) + "\n");
255
+ }
256
+ else {
257
+ console.log(`⛰ ${campName} 캠프를 브라우저에서 엽니다. → ${campUrl}`);
258
+ }
259
+ const { spawn: spawnOpen } = await import("node:child_process");
260
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
261
+ try {
262
+ spawnOpen(openCmd, [campUrl], { stdio: "ignore", detached: true }).unref();
263
+ }
264
+ catch { /* */ }
265
+ }
266
+ }
267
+ catch (err) {
268
+ if (jsonMode) {
269
+ process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + "\n");
270
+ }
271
+ else {
272
+ console.error(`⛰ 오류: ${err.message}`);
273
+ }
274
+ process.exit(1);
275
+ }
276
+ }
134
277
  else {
135
278
  // Default: start server — auto-init if no config exists
136
279
  const configPath = resolve(projectRoot, "sanjang.config.js");
@@ -147,10 +290,12 @@ else {
147
290
  console.log("");
148
291
  const { createInterface } = await import("node:readline");
149
292
  const rl = createInterface({ input: process.stdin, output: process.stdout });
150
- const answer = await new Promise((r) => { rl.question(" 어떤 앱을 띄울까요? [번호]: ", r); });
293
+ const answer = await new Promise((r) => {
294
+ rl.question(" 어떤 앱을 띄울까요? [번호]: ", r);
295
+ });
151
296
  rl.close();
152
- const idx = parseInt(answer) - 1;
153
- if (idx < 0 || idx >= apps.length || isNaN(idx)) {
297
+ const idx = parseInt(answer, 10) - 1;
298
+ if (idx < 0 || idx >= apps.length || Number.isNaN(idx)) {
154
299
  console.error("⛰ 잘못된 선택입니다.");
155
300
  process.exit(1);
156
301
  }
@@ -4,6 +4,11 @@ import type { DetectedApp, DetectedProject, GenerateConfigResult, SanjangConfig
4
4
  * Returns merged config with defaults.
5
5
  */
6
6
  export declare function loadConfig(projectRoot: string): Promise<SanjangConfig>;
7
+ /**
8
+ * Auto-detect a test command from package.json.
9
+ * Returns null if no test script found or it's the npm default placeholder.
10
+ */
11
+ export declare function detectTestCommand(projectRoot: string, cwd?: string): string | null;
7
12
  /**
8
13
  * Auto-detect project type and generate config.
9
14
  */