leerness 1.9.9 → 1.9.11
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/CHANGELOG.md +63 -0
- package/bin/harness.js +468 -14
- package/package.json +1 -1
- package/scripts/e2e.js +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.11 — 2026-05-12
|
|
4
|
+
|
|
5
|
+
**`leerness roadmap` 명령 통합 + `project-roadmap-generator` 스킬 기본 추천 + 화이트보드/토큰/상하 중앙정렬**.
|
|
6
|
+
|
|
7
|
+
### Added — `leerness roadmap [path] [--out file.html]`
|
|
8
|
+
|
|
9
|
+
`project-roadmap-generator` 로직을 leerness 본 패키지에 통합. 외부 의존성 없이 즉시 사용 가능.
|
|
10
|
+
|
|
11
|
+
- 좌→우 수평 트리 (project → milestones → tasks → skills/rules)
|
|
12
|
+
- **상하 중앙정렬**: 각 column의 노드들이 캔버스 세로 중앙 기준으로 균등 분포
|
|
13
|
+
- **디자인 토큰 자동 주입**: `.harness/design-system.md`의 Tokens 표 + 프로젝트 `styles/tokens.css`의 CSS 변수를 HTML `:root`에 `--lr-*`로 주입 (h1·card·border·dot 색상이 사용자 토큰을 따름)
|
|
14
|
+
- **화이트보드**: 드래그 panning, 휠 zoom (마우스 포인터 중심), 더블클릭 reset, +/-/⟳ 컨트롤 버튼
|
|
15
|
+
- 7개 상태 (완료/진행/보류/검토/예정/미완료/오류) + 스킬/룰 색상
|
|
16
|
+
- Milestones, 예정 작업, 보유 스킬, 활성 룰, 최근 결정, 디자인 토큰 6개 섹션 통합
|
|
17
|
+
|
|
18
|
+
### Changed — `recommended` 스킬에 자동 포함
|
|
19
|
+
|
|
20
|
+
`leerness init . --skills recommended` 호출 시 `project-roadmap-generator` 스킬이 기본으로 설치됩니다 (기존 4종 + 1). 별도 설치 불필요.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
recommended = ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator']
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Migration
|
|
27
|
+
|
|
28
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. `leerness roadmap`이 바로 사용 가능합니다.
|
|
29
|
+
|
|
30
|
+
## 1.9.10 — 2026-05-12
|
|
31
|
+
|
|
32
|
+
**leerness-skillpack 분리 + release publish 강화 (git remote 자동 감지 + GitHub Release + gh-pages 배포)**.
|
|
33
|
+
|
|
34
|
+
### Changed — 스킬 카탈로그 동적 로드
|
|
35
|
+
|
|
36
|
+
- `leerness-skillpack`이 npm에 별도 패키지로 분리됨. leerness 본 패키지는 `_tryLoadSkillpack()`으로 다음 순서로 동적 로드:
|
|
37
|
+
1. `require('leerness-skillpack/catalog.json')` 시도
|
|
38
|
+
2. `<cwd>/node_modules/leerness-skillpack/catalog.json` 탐색
|
|
39
|
+
3. `npm root -g`의 `leerness-skillpack/catalog.json` 탐색
|
|
40
|
+
4. `LEERNESS_SKILLPACK_PATH` 환경변수 경로
|
|
41
|
+
5. 모두 실패 시 leerness 본 패키지의 내장 fallback (1.9.x 호환 유지)
|
|
42
|
+
- `leerness init` 출력에 `Skill catalog source: skillpack v1.0.0 | builtin (fallback)` 안내.
|
|
43
|
+
- `leerness skill list` 헤더에 카탈로그 출처 + 출처 컬럼에 `skillpack` / `builtin` / `user` 표시.
|
|
44
|
+
|
|
45
|
+
### Added — release publish 강화
|
|
46
|
+
|
|
47
|
+
- `detectGitRemote(root)`: 현재 디렉토리의 `git remote -v origin` 자동 감지 + GitHub owner/repo 추출.
|
|
48
|
+
- `leerness release publish` 신규 플래그:
|
|
49
|
+
- `--auto` — remote 있으면 자동 `git push` (편의)
|
|
50
|
+
- `--gh-release` — gh CLI로 GitHub Release 자동 생성 (`v<version>` 태그 + 자동 노트 + tarball 첨부)
|
|
51
|
+
- `--gh-pages` — `gh-pages` branch에 정적 파일 자동 배포 (orphan 또는 기존 branch). 기본 소스는 `roadmap.html`, `--gh-pages-src <file>` 또는 `--roadmap <file>`로 지정.
|
|
52
|
+
- `--pack` — npm pack만 명시적 실행
|
|
53
|
+
- `gh-pages` 배포는 임시 git worktree로 처리해 현재 작업 트리에 영향 없음. 배포 후 `https://<owner>.github.io/<repo>/` URL 안내.
|
|
54
|
+
|
|
55
|
+
### Migration
|
|
56
|
+
|
|
57
|
+
기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. leerness-skillpack은 선택 설치:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install leerness-skillpack # 본 카탈로그 사용
|
|
61
|
+
# 또는 그대로 두면 leerness 내장 fallback이 동작 (기존과 동일)
|
|
62
|
+
```
|
|
63
|
+
|
|
3
64
|
## 1.9.9 — 2026-05-12
|
|
4
65
|
|
|
66
|
+
- 1.9.9 빌드 + GitHub 배포
|
|
67
|
+
|
|
5
68
|
**1.9.8 시연 중 자체 도그푸드(dogfood)로 빌드된 패치 — 룰 시스템이 정확히 작동한 증거**.
|
|
6
69
|
|
|
7
70
|
### Fixed
|
package/bin/harness.js
CHANGED
|
@@ -6,12 +6,64 @@ const path = require('path');
|
|
|
6
6
|
const cp = require('child_process');
|
|
7
7
|
const readline = require('readline');
|
|
8
8
|
|
|
9
|
-
const VERSION = '1.9.
|
|
9
|
+
const VERSION = '1.9.11';
|
|
10
10
|
const MARK = '<!-- leerness:managed -->';
|
|
11
11
|
const README_START = '<!-- leerness:project-readme:start -->';
|
|
12
12
|
const README_END = '<!-- leerness:project-readme:end -->';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// 1.9.10: leerness-skillpack 동적 로드 (선택). 없으면 BUILTIN 사용.
|
|
15
|
+
function _tryLoadSkillpack() {
|
|
16
|
+
// 1) 정상 require resolution
|
|
17
|
+
try { return { src: 'require', data: require('leerness-skillpack/catalog.json') }; } catch {}
|
|
18
|
+
// 2) cwd/node_modules
|
|
19
|
+
try {
|
|
20
|
+
const f = path.join(process.cwd(), 'node_modules/leerness-skillpack/catalog.json');
|
|
21
|
+
if (fs.existsSync(f)) return { src: 'cwd', data: JSON.parse(fs.readFileSync(f, 'utf8')) };
|
|
22
|
+
} catch {}
|
|
23
|
+
// 3) npm global root
|
|
24
|
+
try {
|
|
25
|
+
const root = cp.execSync('npm root -g', { encoding: 'utf8', timeout: 4000 }).trim();
|
|
26
|
+
const f = path.join(root, 'leerness-skillpack/catalog.json');
|
|
27
|
+
if (fs.existsSync(f)) return { src: 'global', data: JSON.parse(fs.readFileSync(f, 'utf8')) };
|
|
28
|
+
} catch {}
|
|
29
|
+
// 4) 환경변수 명시 경로
|
|
30
|
+
if (process.env.LEERNESS_SKILLPACK_PATH) {
|
|
31
|
+
try {
|
|
32
|
+
const f = path.resolve(process.env.LEERNESS_SKILLPACK_PATH);
|
|
33
|
+
const target = f.endsWith('.json') ? f : path.join(f, 'catalog.json');
|
|
34
|
+
if (fs.existsSync(target)) return { src: 'env', data: JSON.parse(fs.readFileSync(target, 'utf8')) };
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let SKILLPACK_SOURCE = 'builtin';
|
|
41
|
+
let SKILLPACK_META = null;
|
|
42
|
+
function _loadSkillCatalog() {
|
|
43
|
+
const sp = _tryLoadSkillpack();
|
|
44
|
+
if (sp && sp.data && Array.isArray(sp.data.skills)) {
|
|
45
|
+
SKILLPACK_SOURCE = sp.src;
|
|
46
|
+
SKILLPACK_META = { name: sp.data.name, version: sp.data.version };
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const s of sp.data.skills) {
|
|
49
|
+
out[s.id] = {
|
|
50
|
+
displayNameKo: s.displayNameKo,
|
|
51
|
+
version: s.version,
|
|
52
|
+
lastUpdated: s.lastUpdated,
|
|
53
|
+
verification: s.verification,
|
|
54
|
+
capabilities: s.capabilities,
|
|
55
|
+
_source: 'skillpack'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
SKILLPACK_SOURCE = 'builtin';
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const [k, v] of Object.entries(BUILTIN_CATALOG)) out[k] = { ...v, _source: 'builtin' };
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BUILTIN_CATALOG = {
|
|
15
67
|
'office': { displayNameKo: '마이크로소프트 오피스 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Word/Excel/PowerPoint 문서 자동화', '템플릿 기반 문서 생성', '표/차트/요약 문서화', '민감정보 제외 규칙 적용'] },
|
|
16
68
|
'commerce-api': { displayNameKo: '커머스 API 연동 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['쿠팡·롯데온·스마트스토어 API 연동 설계', '주문/상품/매출 동기화', '환경변수 기반 인증 분리', '레이트리밋/재시도/오류 처리'] },
|
|
17
69
|
'crawling': { displayNameKo: '크롤링·브라우저 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Playwright 기반 자동화', '다운로드/로그인 세션 처리', '스크린샷 기반 실패 진단', '약관/권한/차단 위험 점검'] },
|
|
@@ -19,9 +71,14 @@ const skillCatalog = {
|
|
|
19
71
|
'ads-analytics': { displayNameKo: '광고·GA4 분석 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화'] },
|
|
20
72
|
'appstore-review': { displayNameKo: '앱스토어 심사 대응 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분'] },
|
|
21
73
|
'ai-verified-skill-publisher': { displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트'] },
|
|
22
|
-
'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] }
|
|
74
|
+
'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] },
|
|
75
|
+
// 1.9.11: 기본 내장 — 로드맵 자동 생성 스킬
|
|
76
|
+
'project-roadmap-generator': { displayNameKo: '프로젝트 로드맵 자동 생성 스킬', version: '0.2.0', lastUpdated: '2026-05-12', verification: 'passed', capabilities: ['leerness .harness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state)', '좌→우 수평 트리 + 상하 중앙정렬 SVG', '7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류)', 'design-system + CSS variables 자동 주입', '화이트보드 panning/zoom + 더블클릭 reset', '단일 HTML 출력 (외부 의존성 0)'] }
|
|
23
77
|
};
|
|
24
78
|
|
|
79
|
+
// 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
|
|
80
|
+
const skillCatalog = _loadSkillCatalog();
|
|
81
|
+
|
|
25
82
|
const routes = {
|
|
26
83
|
planning: { read: ['.harness/plan.md','.harness/progress-tracker.md','.harness/project-brief.md','.harness/current-state.md','.harness/guideline.md'], update: ['.harness/plan.md','.harness/progress-tracker.md','.harness/current-state.md','.harness/session-handoff.md'] },
|
|
27
84
|
feature: { read: ['.harness/plan.md','.harness/current-state.md','.harness/architecture.md','.harness/context-map.md','.harness/feature-contracts.md','.harness/skills/feature-implementation/README.md','.harness/reuse-map.md'], update: ['.harness/progress-tracker.md','.harness/feature-contracts.md','.harness/current-state.md','.harness/task-log.md','.harness/session-handoff.md'] },
|
|
@@ -295,7 +352,8 @@ function syncReadme(root) {
|
|
|
295
352
|
function parseSkillsValue(v) {
|
|
296
353
|
if (!v || v === true) return [];
|
|
297
354
|
if (v === 'all') return Object.keys(skillCatalog);
|
|
298
|
-
|
|
355
|
+
// 1.9.11: recommended에 project-roadmap-generator 자동 포함
|
|
356
|
+
if (v === 'recommended') return ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator'];
|
|
299
357
|
return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
|
|
300
358
|
}
|
|
301
359
|
|
|
@@ -335,6 +393,9 @@ async function install(root, opts = {}) {
|
|
|
335
393
|
log(`Target: ${root}`);
|
|
336
394
|
log(`Language: ${lang}`);
|
|
337
395
|
log(`Skills: ${skills.length ? skills.join(', ') : 'none'}`);
|
|
396
|
+
// 1.9.10: 스킬 카탈로그 출처 안내
|
|
397
|
+
if (SKILLPACK_SOURCE === 'builtin') log(`Skill catalog source: builtin (leerness-skillpack 미설치 — \`npm i leerness-skillpack\`로 확장 가능)`);
|
|
398
|
+
else log(`Skill catalog source: ${SKILLPACK_SOURCE} (leerness-skillpack${SKILLPACK_META ? ` v${SKILLPACK_META.version}` : ''})`);
|
|
338
399
|
const files = coreFiles(root, lang, skills);
|
|
339
400
|
const backup = createBackup(root, opts.force ? 'force' : (opts.migration ? 'migration' : 'init'), files, opts.dry);
|
|
340
401
|
if (opts.dry) {
|
|
@@ -430,7 +491,8 @@ function saveUserSkill(root, id, data) {
|
|
|
430
491
|
|
|
431
492
|
function listAllSkills(root) {
|
|
432
493
|
const out = {};
|
|
433
|
-
|
|
494
|
+
// 1.9.10: skillCatalog의 _source('skillpack' 또는 'builtin')를 보존
|
|
495
|
+
for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: v._source || 'builtin' };
|
|
434
496
|
if (root) {
|
|
435
497
|
const dir = userSkillsDir(root);
|
|
436
498
|
if (exists(dir)) {
|
|
@@ -448,6 +510,8 @@ function listAllSkills(root) {
|
|
|
448
510
|
|
|
449
511
|
function skillList(root) {
|
|
450
512
|
const all = listAllSkills(root);
|
|
513
|
+
if (SKILLPACK_SOURCE !== 'builtin') log(`# skillpack 출처: ${SKILLPACK_SOURCE}${SKILLPACK_META ? ` (${SKILLPACK_META.name} v${SKILLPACK_META.version})` : ''}`);
|
|
514
|
+
else log('# skillpack 미설치 — builtin fallback 사용 (leerness 본 패키지 내장 카탈로그)');
|
|
451
515
|
log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
|
|
452
516
|
log('|---|---|---|---|---|---|');
|
|
453
517
|
for (const [id, v] of Object.entries(all)) {
|
|
@@ -1253,6 +1317,294 @@ function gate(root) {
|
|
|
1253
1317
|
else ok('all gates passed');
|
|
1254
1318
|
}
|
|
1255
1319
|
|
|
1320
|
+
// ===== 1.9.11: Roadmap (project-roadmap-generator 통합) =====
|
|
1321
|
+
const ROADMAP_STATUS_LABEL = { done: '완료', 'in-progress': '진행', 'on-hold': '보류', waiting: '검토', incomplete: '미완료', planned: '예정', blocked: '오류', dropped: '취소', skill: '스킬', rule: '룰', meta: '프로젝트' };
|
|
1322
|
+
const ROADMAP_STATUS_COLOR = { done: '#16a34a', 'in-progress': '#2563eb', 'on-hold': '#6b7280', waiting: '#eab308', incomplete: '#f97316', planned: '#94a3b8', blocked: '#dc2626', dropped: '#9ca3af', skill: '#8b5cf6', rule: '#06b6d4', meta: '#0f172a' };
|
|
1323
|
+
const ROADMAP_NODE_W = 220, ROADMAP_NODE_H = 72, ROADMAP_COL_GAP = 70, ROADMAP_ROW_GAP = 14;
|
|
1324
|
+
|
|
1325
|
+
function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
|
1326
|
+
function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
1327
|
+
|
|
1328
|
+
function _roadmapMapStatus(s) {
|
|
1329
|
+
s = String(s || '').toLowerCase();
|
|
1330
|
+
if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
|
|
1331
|
+
if (s === 'planned' || s === 'requested') return 'planned';
|
|
1332
|
+
return 'planned';
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function _roadmapParseMilestones(text) {
|
|
1336
|
+
const out = [];
|
|
1337
|
+
for (const m of String(text || '').matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)) {
|
|
1338
|
+
const after = text.slice(m.index);
|
|
1339
|
+
const sm = after.match(/^Status:\s*(\S+)/m);
|
|
1340
|
+
const pm = after.match(/^Progress:\s*(\d+)%/m);
|
|
1341
|
+
out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
|
|
1342
|
+
}
|
|
1343
|
+
return out;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function _roadmapParseTokens(text) {
|
|
1347
|
+
const tokens = {};
|
|
1348
|
+
for (const line of String(text || '').split('\n')) {
|
|
1349
|
+
const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
|
|
1350
|
+
if (!m) continue;
|
|
1351
|
+
const key = m[1].trim(), val = m[2].trim();
|
|
1352
|
+
if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
|
|
1353
|
+
if (val.length > 80) continue;
|
|
1354
|
+
tokens[key] = val;
|
|
1355
|
+
}
|
|
1356
|
+
return tokens;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function _roadmapParseCssVars(root) {
|
|
1360
|
+
const out = {};
|
|
1361
|
+
const cands = ['src/styles/tokens.css', 'styles/tokens.css', 'src/styles.css', 'styles.css', 'src/styles/main.css', 'public/styles.css'];
|
|
1362
|
+
for (const c of cands) {
|
|
1363
|
+
const f = path.join(root, c);
|
|
1364
|
+
if (!exists(f)) continue;
|
|
1365
|
+
const text = read(f);
|
|
1366
|
+
const m = text.match(/:root\s*\{([\s\S]*?)\}/);
|
|
1367
|
+
if (!m) continue;
|
|
1368
|
+
for (const line of m[1].split('\n')) {
|
|
1369
|
+
const v = line.match(/--([\w-]+)\s*:\s*([^;]+);/);
|
|
1370
|
+
if (v) out[v[1].trim()] = v[2].trim();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return out;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function _roadmapData(root) {
|
|
1377
|
+
root = absRoot(root);
|
|
1378
|
+
const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
|
|
1379
|
+
const tasks = readProgressRows(root).map(t => ({
|
|
1380
|
+
...t,
|
|
1381
|
+
milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4}/g)).map(m => m[0])
|
|
1382
|
+
}));
|
|
1383
|
+
// skills
|
|
1384
|
+
const skills = [];
|
|
1385
|
+
const skillsDir = path.join(root, '.harness/skills');
|
|
1386
|
+
if (exists(skillsDir)) {
|
|
1387
|
+
for (const id of fs.readdirSync(skillsDir)) {
|
|
1388
|
+
const f = path.join(skillsDir, id, 'skill.json');
|
|
1389
|
+
if (!exists(f)) continue;
|
|
1390
|
+
try { skills.push(JSON.parse(read(f))); } catch {}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// rules
|
|
1394
|
+
const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
|
|
1395
|
+
const rules = [];
|
|
1396
|
+
for (const line of rulesT.split('\n')) {
|
|
1397
|
+
if (!/^\| R-\d{4} \|/.test(line)) continue;
|
|
1398
|
+
const cells = line.split('|').slice(1, -1).map(s => s.trim());
|
|
1399
|
+
if (cells.length < 6) continue;
|
|
1400
|
+
rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
|
|
1401
|
+
}
|
|
1402
|
+
// currentState
|
|
1403
|
+
const csT = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
|
|
1404
|
+
const now = (csT.match(/## Now\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
|
|
1405
|
+
const next = (csT.match(/## Next\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
|
|
1406
|
+
const blockers = (csT.match(/## Blockers\n([\s\S]*?)$/) || [, ''])[1].trim();
|
|
1407
|
+
// decisions (top 6)
|
|
1408
|
+
const decT = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
|
|
1409
|
+
const decisions = [];
|
|
1410
|
+
for (const block of decT.split(/\n(?=### )/)) {
|
|
1411
|
+
if (!block.startsWith('### ')) continue;
|
|
1412
|
+
const tm = block.match(/^### (.+)$/m);
|
|
1413
|
+
if (tm) decisions.push({ title: tm[1].trim() });
|
|
1414
|
+
}
|
|
1415
|
+
return {
|
|
1416
|
+
project: path.basename(root),
|
|
1417
|
+
version: exists(path.join(root, '.harness/HARNESS_VERSION')) ? read(path.join(root, '.harness/HARNESS_VERSION')).trim() : 'unknown',
|
|
1418
|
+
milestones, tasks, skills, rules,
|
|
1419
|
+
currentState: { now, next, blockers },
|
|
1420
|
+
decisions,
|
|
1421
|
+
designTokens: _roadmapParseTokens(exists(path.join(root, '.harness/design-system.md')) ? read(path.join(root, '.harness/design-system.md')) : ''),
|
|
1422
|
+
cssVariables: _roadmapParseCssVars(root)
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function _roadmapLayout(data) {
|
|
1427
|
+
const nodes = []; const edges = [];
|
|
1428
|
+
nodes.push({ id: 'project', kind: 'project', title: data.project, subtitle: `leerness ${data.version}`, meta: `M ${data.milestones.length} · T ${data.tasks.length} · S ${data.skills.length}`, status: 'meta', col: 0 });
|
|
1429
|
+
for (const m of data.milestones) {
|
|
1430
|
+
nodes.push({ id: m.id, kind: 'milestone', title: m.id, subtitle: m.title, meta: `${m.progress}% · ${m.status}`, status: _roadmapMapStatus(m.status), col: 1 });
|
|
1431
|
+
edges.push({ from: 'project', to: m.id });
|
|
1432
|
+
}
|
|
1433
|
+
for (const t of data.tasks) {
|
|
1434
|
+
nodes.push({ id: t.id, kind: 'task', title: t.id, subtitle: t.request, meta: t.evidence ? `evidence: ${t.evidence.slice(0, 40)}` : '', status: _roadmapMapStatus(t.status), col: 2 });
|
|
1435
|
+
if (t.milestones.length) for (const mid of t.milestones) edges.push({ from: mid, to: t.id });
|
|
1436
|
+
else edges.push({ from: 'project', to: t.id });
|
|
1437
|
+
}
|
|
1438
|
+
for (const s of data.skills) {
|
|
1439
|
+
nodes.push({ id: 'skill:' + s.name, kind: 'skill', title: s.name, subtitle: s.displayNameKo || s.name, meta: `사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`, status: 'skill', col: 3 });
|
|
1440
|
+
edges.push({ from: 'project', to: 'skill:' + s.name });
|
|
1441
|
+
}
|
|
1442
|
+
for (const r of data.rules.filter(r => r.status === 'active')) {
|
|
1443
|
+
nodes.push({ id: 'rule:' + r.id, kind: 'rule', title: r.id, subtitle: r.rule, meta: r.trigger, status: 'rule', col: 3 });
|
|
1444
|
+
edges.push({ from: 'project', to: 'rule:' + r.id });
|
|
1445
|
+
}
|
|
1446
|
+
// 상하 중앙정렬 (1.9.11 v0.2)
|
|
1447
|
+
const byCol = {};
|
|
1448
|
+
for (const n of nodes) (byCol[n.col] = byCol[n.col] || []).push(n);
|
|
1449
|
+
const colH = {}; let maxColH = 0; let maxCol = 0;
|
|
1450
|
+
for (const c of Object.keys(byCol)) {
|
|
1451
|
+
const r = byCol[c]; const h = r.length * ROADMAP_NODE_H + Math.max(0, r.length - 1) * ROADMAP_ROW_GAP;
|
|
1452
|
+
colH[c] = h; maxColH = Math.max(maxColH, h); maxCol = Math.max(maxCol, parseInt(c, 10));
|
|
1453
|
+
}
|
|
1454
|
+
const padding = 40; const minHeight = 360;
|
|
1455
|
+
const canvasHeight = Math.max(maxColH, minHeight) + padding * 2;
|
|
1456
|
+
for (const c of Object.keys(byCol)) {
|
|
1457
|
+
const r = byCol[c]; const h = colH[c]; const startY = (canvasHeight - h) / 2;
|
|
1458
|
+
r.forEach((n, i) => {
|
|
1459
|
+
n.x = parseInt(c, 10) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding;
|
|
1460
|
+
n.y = startY + i * (ROADMAP_NODE_H + ROADMAP_ROW_GAP);
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
return { nodes, edges, width: (maxCol + 1) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding * 2, height: canvasHeight };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function _roadmapTokenStyles(designTokens, cssVariables) {
|
|
1467
|
+
const vars = {};
|
|
1468
|
+
const map = [
|
|
1469
|
+
['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
|
|
1470
|
+
['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
|
|
1471
|
+
['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
|
|
1472
|
+
['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
|
|
1473
|
+
['radius', 'radius', 'lr-radius']
|
|
1474
|
+
];
|
|
1475
|
+
for (const [ds, css, vn] of map) { const v = cssVariables[css] || designTokens[ds]; if (v) vars[vn] = v; }
|
|
1476
|
+
for (const [k, v] of Object.entries(cssVariables)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
|
|
1477
|
+
if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
|
|
1478
|
+
if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
|
|
1479
|
+
if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
|
|
1480
|
+
return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${v};`).join('\n') + '\n }';
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function _roadmapHTML(data) {
|
|
1484
|
+
const g = _roadmapLayout(data);
|
|
1485
|
+
const edges = g.edges.map(e => {
|
|
1486
|
+
const f = g.nodes.find(n => n.id === e.from), t = g.nodes.find(n => n.id === e.to);
|
|
1487
|
+
if (!f || !t) return '';
|
|
1488
|
+
const x1 = f.x + ROADMAP_NODE_W, y1 = f.y + ROADMAP_NODE_H / 2, x2 = t.x, y2 = t.y + ROADMAP_NODE_H / 2, mid = (x1 + x2) / 2;
|
|
1489
|
+
return `<path d="M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}" stroke="var(--lr-edge, #cbd5e1)" stroke-width="1.5" fill="none"/>`;
|
|
1490
|
+
}).join('\n');
|
|
1491
|
+
const nodes = g.nodes.map(n => {
|
|
1492
|
+
const c = ROADMAP_STATUS_COLOR[n.status] || 'var(--lr-text, #0f172a)';
|
|
1493
|
+
const lbl = ROADMAP_STATUS_LABEL[n.status] || n.status;
|
|
1494
|
+
return `<g class="node node-${n.kind} status-${n.status}" data-id="${_esc(n.id)}" transform="translate(${n.x},${n.y})">
|
|
1495
|
+
<rect width="${ROADMAP_NODE_W}" height="${ROADMAP_NODE_H}" rx="8" ry="8" fill="var(--lr-card-bg, #ffffff)" stroke="${c}" stroke-width="2"/>
|
|
1496
|
+
<rect width="5" height="${ROADMAP_NODE_H}" fill="${c}"/>
|
|
1497
|
+
<text x="14" y="22" font-size="12" fill="${c}" font-weight="600">${_esc(n.title)} · ${_esc(lbl)}</text>
|
|
1498
|
+
<text x="14" y="42" font-size="11" fill="var(--lr-text, #1f2937)" font-weight="500">${_esc(_truncate(n.subtitle, 30))}</text>
|
|
1499
|
+
<text x="14" y="60" font-size="10" fill="var(--lr-muted, #64748b)">${_esc(_truncate(n.meta, 36))}</text>
|
|
1500
|
+
<title>${_esc(n.id)} — ${_esc(n.subtitle)}${n.meta ? '\n' + _esc(n.meta) : ''}</title>
|
|
1501
|
+
</g>`;
|
|
1502
|
+
}).join('\n');
|
|
1503
|
+
const counts = {};
|
|
1504
|
+
for (const t of data.tasks) counts[t.status] = (counts[t.status] || 0) + 1;
|
|
1505
|
+
const legend = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked', 'skill', 'rule']
|
|
1506
|
+
.map(s => `<span class="badge" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]}</span>`).join(' ');
|
|
1507
|
+
const chips = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked']
|
|
1508
|
+
.map(s => `<span class="chip" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]} ${counts[s] || 0}</span>`).join(' ');
|
|
1509
|
+
const upcoming = data.tasks.filter(t => ['planned', 'requested', 'in-progress'].includes(t.status)).slice(0, 10);
|
|
1510
|
+
const upcomingBlock = upcoming.length ? upcoming.map(t => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[t.status] || '#000'}"></span><strong>${_esc(t.id)}</strong> <span class="meta">[${_esc(ROADMAP_STATUS_LABEL[t.status] || t.status)}]</span> ${_esc(t.request)} <span class="meta">→ ${_esc(t.nextAction)}</span></div>`).join('') : '<div class="empty">예정 작업 없음</div>';
|
|
1511
|
+
const milestoneBlock = data.milestones.length ? data.milestones.map(m => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[_roadmapMapStatus(m.status)] || ROADMAP_STATUS_COLOR.planned}"></span><strong>${_esc(m.id)}</strong> <span class="meta">[${_esc(m.status)} · ${m.progress}%]</span> ${_esc(m.title)}</div>`).join('') : '<div class="empty">마일스톤 없음</div>';
|
|
1512
|
+
const skillsBlock = data.skills.length ? data.skills.map(s => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.skill}"></span><strong>${_esc(s.name)}</strong> · ${_esc(s.displayNameKo || s.name)} <span class="meta">사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}</span></div>`).join('') : '<div class="empty">스킬 없음</div>';
|
|
1513
|
+
const activeRules = data.rules.filter(r => r.status === 'active');
|
|
1514
|
+
const rulesBlock = activeRules.length ? activeRules.map(r => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.rule}"></span><strong>${_esc(r.id)}</strong> <span class="meta">[${_esc(r.trigger)}]</span> ${_esc(r.rule)}</div>`).join('') : '<div class="empty">활성 룰 없음</div>';
|
|
1515
|
+
const decisionsBlock = data.decisions.length ? data.decisions.slice(0, 6).map(d => `<div class="row"><span class="dot" style="background:var(--lr-text, #0f172a)"></span>${_esc(d.title)}</div>`).join('') : '<div class="empty">결정 없음</div>';
|
|
1516
|
+
const tokensSection = (Object.keys(data.designTokens).length || Object.keys(data.cssVariables).length)
|
|
1517
|
+
? [...Object.entries(data.designTokens).slice(0, 8), ...Object.entries(data.cssVariables).slice(0, 8)]
|
|
1518
|
+
.map(([k, v]) => `<div class="row"><span class="dot" style="background:${/#[0-9a-f]{3,8}/i.test(v) ? v : 'var(--lr-muted, #94a3b8)'}"></span><strong>${_esc(k)}</strong> <span class="meta">${_esc(v)}</span></div>`).join('')
|
|
1519
|
+
: '<div class="empty">디자인 토큰 없음</div>';
|
|
1520
|
+
|
|
1521
|
+
return `<!doctype html>
|
|
1522
|
+
<html lang="ko"><head><meta charset="utf-8"><title>${_esc(data.project)} — leerness 로드맵</title>
|
|
1523
|
+
<style>
|
|
1524
|
+
${_roadmapTokenStyles(data.designTokens, data.cssVariables)}
|
|
1525
|
+
body { font-family: var(--lr-font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', sans-serif); margin: 0; padding: 20px; background: var(--lr-page-bg); color: var(--lr-text); }
|
|
1526
|
+
h1 { margin: 0 0 4px; font-size: 22px; color: var(--lr-primary, var(--lr-text, #0f172a)); }
|
|
1527
|
+
h2 { margin: 24px 0 8px; font-size: 16px; color: var(--lr-muted, #334155); }
|
|
1528
|
+
.meta { font-size: 11px; color: var(--lr-muted, #64748b); margin-left: 4px; }
|
|
1529
|
+
.summary { display: flex; gap: 16px; flex-wrap: wrap; background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); font-size: 13px; }
|
|
1530
|
+
.legend { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
|
|
1531
|
+
.badge, .chip { display: inline-block; padding: 2px 10px; border: 1.5px solid var(--lr-muted, #94a3b8); border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--lr-card-bg); }
|
|
1532
|
+
.chip { padding: 3px 10px; }
|
|
1533
|
+
.block { background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); margin: 8px 0; }
|
|
1534
|
+
.row { font-size: 13px; padding: 4px 0; border-bottom: 1px dashed var(--lr-muted, #f1f5f9); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
1535
|
+
.row:last-child { border-bottom: none; }
|
|
1536
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
1537
|
+
.empty { font-size: 12px; color: var(--lr-muted, #94a3b8); font-style: italic; padding: 4px 0; }
|
|
1538
|
+
.roadmap-wrap { position: relative; background: var(--lr-card-bg); border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); height: 640px; overflow: hidden; cursor: grab; }
|
|
1539
|
+
.roadmap-wrap.grabbing { cursor: grabbing; }
|
|
1540
|
+
.roadmap-wrap svg { display: block; width: 100%; height: 100%; }
|
|
1541
|
+
.node:hover rect:first-of-type { fill: var(--lr-page-bg, #f1f5f9); cursor: pointer; }
|
|
1542
|
+
.node text { user-select: none; pointer-events: none; }
|
|
1543
|
+
.controls { position: absolute; top: 12px; right: 12px; display: flex; gap: 6px; background: var(--lr-card-bg); padding: 6px; border-radius: 8px; border: 1px solid var(--lr-muted, #e2e8f0); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
|
1544
|
+
.controls button { width: 32px; height: 32px; border: 1px solid var(--lr-muted, #cbd5e1); background: var(--lr-card-bg); color: var(--lr-text); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; }
|
|
1545
|
+
.controls button:hover { background: var(--lr-page-bg); }
|
|
1546
|
+
.footer { color: var(--lr-muted, #94a3b8); font-size: 11px; text-align: right; margin-top: 16px; }
|
|
1547
|
+
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
1548
|
+
@media (max-width: 900px) { .columns { grid-template-columns: 1fr; } }
|
|
1549
|
+
</style></head>
|
|
1550
|
+
<body>
|
|
1551
|
+
<h1>${_esc(data.project)} — leerness 로드맵</h1>
|
|
1552
|
+
<div class="meta">자동 생성 · ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · leerness v${_esc(data.version)}</div>
|
|
1553
|
+
<div class="summary">
|
|
1554
|
+
<div><strong>milestones:</strong> ${data.milestones.length}</div>
|
|
1555
|
+
<div><strong>tasks:</strong> ${data.tasks.length}</div>
|
|
1556
|
+
<div><strong>skills:</strong> ${data.skills.length}</div>
|
|
1557
|
+
<div><strong>active rules:</strong> ${activeRules.length}</div>
|
|
1558
|
+
<div><strong>decisions:</strong> ${data.decisions.length}</div>
|
|
1559
|
+
<div><strong>design tokens:</strong> ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}</div>
|
|
1560
|
+
</div>
|
|
1561
|
+
<div class="legend">${legend}</div>
|
|
1562
|
+
<div class="legend">${chips}</div>
|
|
1563
|
+
<h2>📍 Current State</h2>
|
|
1564
|
+
<div class="block">
|
|
1565
|
+
<div class="row"><strong>Now:</strong> ${_esc(data.currentState.now || '-')}</div>
|
|
1566
|
+
<div class="row"><strong>Next:</strong> ${_esc(data.currentState.next || '-')}</div>
|
|
1567
|
+
<div class="row"><strong>Blockers:</strong> ${_esc(data.currentState.blockers || '-')}</div>
|
|
1568
|
+
</div>
|
|
1569
|
+
<h2>🗺️ Roadmap — 화이트보드 (드래그 panning · 휠 zoom · 더블클릭 reset)</h2>
|
|
1570
|
+
<div class="roadmap-wrap" id="roadmap-board">
|
|
1571
|
+
<svg id="roadmap-svg" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
|
|
1572
|
+
<g class="viewport">
|
|
1573
|
+
<g class="edges">${edges}</g>
|
|
1574
|
+
<g class="nodes">${nodes}</g>
|
|
1575
|
+
</g>
|
|
1576
|
+
</svg>
|
|
1577
|
+
<div class="controls"><button onclick="lrZoom(0.9)">−</button><button onclick="lrZoom(1.1)">+</button><button onclick="lrReset()">⟳</button></div>
|
|
1578
|
+
</div>
|
|
1579
|
+
<div class="columns">
|
|
1580
|
+
<div>
|
|
1581
|
+
<h2>🎯 Milestones (${data.milestones.length})</h2><div class="block">${milestoneBlock}</div>
|
|
1582
|
+
<h2>📌 다음 예정 작업</h2><div class="block">${upcomingBlock}</div>
|
|
1583
|
+
<h2>📚 보유 스킬 (${data.skills.length})</h2><div class="block">${skillsBlock}</div>
|
|
1584
|
+
</div>
|
|
1585
|
+
<div>
|
|
1586
|
+
<h2>⚡ Active Rules (${activeRules.length})</h2><div class="block">${rulesBlock}</div>
|
|
1587
|
+
<h2>🧠 최근 결정</h2><div class="block">${decisionsBlock}</div>
|
|
1588
|
+
<h2>🎨 디자인 토큰</h2><div class="block">${tokensSection}</div>
|
|
1589
|
+
</div>
|
|
1590
|
+
</div>
|
|
1591
|
+
<div class="footer">leerness roadmap · v${_esc(data.version)} · 화이트보드 + 토큰 주입 + 상하 중앙정렬</div>
|
|
1592
|
+
<script>
|
|
1593
|
+
(function(){var svg=document.getElementById('roadmap-svg');var board=document.getElementById('roadmap-board');var vp=svg.querySelector('.viewport');var tx=0,ty=0,scale=1;var dragging=false,sx=0,sy=0;function apply(){vp.setAttribute('transform','translate('+tx+','+ty+') scale('+scale+')');}board.addEventListener('mousedown',function(e){if(e.target.closest&&(e.target.closest('.node')||e.target.closest('.controls')))return;dragging=true;sx=e.clientX-tx;sy=e.clientY-ty;board.classList.add('grabbing');e.preventDefault();});window.addEventListener('mousemove',function(e){if(!dragging)return;tx=e.clientX-sx;ty=e.clientY-sy;apply();});window.addEventListener('mouseup',function(){dragging=false;board.classList.remove('grabbing');});board.addEventListener('wheel',function(e){e.preventDefault();var d=e.deltaY>0?0.9:1.1;var rect=board.getBoundingClientRect();var cx=e.clientX-rect.left;var cy=e.clientY-rect.top;var ns=Math.max(0.3,Math.min(3.0,scale*d));var r=ns/scale;tx=cx-(cx-tx)*r;ty=cy-(cy-ty)*r;scale=ns;apply();},{passive:false});board.addEventListener('dblclick',function(){tx=0;ty=0;scale=1;apply();});window.lrZoom=function(d){scale=Math.max(0.3,Math.min(3.0,scale*d));apply();};window.lrReset=function(){tx=0;ty=0;scale=1;apply();};})();
|
|
1594
|
+
</script>
|
|
1595
|
+
</body></html>`;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function roadmapCmd(root) {
|
|
1599
|
+
root = absRoot(root);
|
|
1600
|
+
if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음 — 먼저 \`leerness init .\``);
|
|
1601
|
+
const outFile = path.resolve(arg('--out', null) || path.join(root, 'roadmap.html'));
|
|
1602
|
+
const data = _roadmapData(root);
|
|
1603
|
+
writeUtf8(outFile, _roadmapHTML(data));
|
|
1604
|
+
ok(`로드맵 생성: ${rel(root, outFile)}`);
|
|
1605
|
+
log(` milestones: ${data.milestones.length} · tasks: ${data.tasks.length} (done ${data.tasks.filter(t => t.status === 'done').length}) · skills: ${data.skills.length} · active rules: ${data.rules.filter(r => r.status === 'active').length} · tokens: ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}`);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1256
1608
|
// ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
|
|
1257
1609
|
function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
|
|
1258
1610
|
function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
|
|
@@ -1528,21 +1880,121 @@ function releaseNote(root, text) {
|
|
|
1528
1880
|
ok(`CHANGELOG.md 갱신: [${version}] ${text}`);
|
|
1529
1881
|
}
|
|
1530
1882
|
|
|
1883
|
+
// 1.9.10: git remote 자동 감지 + gh-release + gh-pages 배포
|
|
1884
|
+
function detectGitRemote(root) {
|
|
1885
|
+
const r = cp.spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', shell: true });
|
|
1886
|
+
if (r.status !== 0) return null;
|
|
1887
|
+
const url = (r.stdout || '').trim();
|
|
1888
|
+
if (!url) return null;
|
|
1889
|
+
// owner/repo 추출
|
|
1890
|
+
const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
|
|
1891
|
+
return { url, host: m ? 'github' : 'unknown', owner: m ? m[1] : null, repo: m ? m[2] : null };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function getCurrentVersion(root) {
|
|
1895
|
+
const pkgF = path.join(root, 'package.json');
|
|
1896
|
+
if (!exists(pkgF)) return null;
|
|
1897
|
+
try { return JSON.parse(read(pkgF)).version || null; } catch { return null; }
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function deployGhPages(root, sourceFile) {
|
|
1901
|
+
const remote = detectGitRemote(root);
|
|
1902
|
+
if (!remote || remote.host !== 'github') { fail('GitHub remote가 없습니다 — gh-pages 배포 불가'); process.exitCode = 1; return; }
|
|
1903
|
+
const src = path.resolve(root, sourceFile);
|
|
1904
|
+
if (!exists(src)) { fail(`소스 파일 없음: ${src}`); process.exitCode = 1; return; }
|
|
1905
|
+
log(`# gh-pages deploy`);
|
|
1906
|
+
log(`Source: ${rel(root, src)}`);
|
|
1907
|
+
log(`Target: gh-pages branch of ${remote.owner}/${remote.repo}`);
|
|
1908
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1909
|
+
const wt = path.join(root, '.harness/cache', `ghpages-${stamp}`);
|
|
1910
|
+
mkdirp(path.dirname(wt));
|
|
1911
|
+
// worktree (기존 gh-pages 있으면 fetch, 없으면 orphan)
|
|
1912
|
+
const fetchR = cp.spawnSync('git', ['fetch', 'origin', 'gh-pages'], { cwd: root, encoding: 'utf8', shell: true });
|
|
1913
|
+
const hasBranch = fetchR.status === 0;
|
|
1914
|
+
let wtArgs;
|
|
1915
|
+
if (hasBranch) wtArgs = ['worktree', 'add', wt, 'origin/gh-pages'];
|
|
1916
|
+
else wtArgs = ['worktree', 'add', '--orphan', '-b', 'gh-pages', wt];
|
|
1917
|
+
const wtR = cp.spawnSync('git', wtArgs, { cwd: root, encoding: 'utf8', shell: true });
|
|
1918
|
+
if (wtR.status !== 0) { fail('worktree 생성 실패: ' + (wtR.stderr || '').slice(0, 200)); process.exitCode = 1; return; }
|
|
1919
|
+
try {
|
|
1920
|
+
// orphan인 경우 초기화
|
|
1921
|
+
if (!hasBranch) {
|
|
1922
|
+
cp.spawnSync('git', ['rm', '-rf', '.'], { cwd: wt, encoding: 'utf8', shell: true });
|
|
1923
|
+
}
|
|
1924
|
+
// 소스 복사 (index.html로 이름 변경)
|
|
1925
|
+
const destName = path.basename(src) === 'index.html' ? 'index.html' : 'index.html';
|
|
1926
|
+
fs.copyFileSync(src, path.join(wt, destName));
|
|
1927
|
+
// 원본 파일명도 보존
|
|
1928
|
+
if (path.basename(src) !== 'index.html') fs.copyFileSync(src, path.join(wt, path.basename(src)));
|
|
1929
|
+
cp.spawnSync('git', ['add', '-A'], { cwd: wt, encoding: 'utf8' });
|
|
1930
|
+
const commit = cp.spawnSync('git', ['commit', '-m', `deploy: ${path.basename(src)} ${stamp}`], { cwd: wt, encoding: 'utf8' });
|
|
1931
|
+
if (commit.status !== 0 && !/nothing to commit/.test(commit.stdout || '')) {
|
|
1932
|
+
fail('commit 실패: ' + (commit.stdout || commit.stderr || '').slice(0, 200));
|
|
1933
|
+
process.exitCode = 1;
|
|
1934
|
+
} else {
|
|
1935
|
+
const pushR = cp.spawnSync('git', ['push', 'origin', 'gh-pages'], { cwd: wt, encoding: 'utf8' });
|
|
1936
|
+
if (pushR.status !== 0) { fail('push 실패: ' + (pushR.stderr || '').slice(0, 200)); process.exitCode = 1; }
|
|
1937
|
+
else ok(`gh-pages push 완료 → https://${remote.owner}.github.io/${remote.repo}/`);
|
|
1938
|
+
}
|
|
1939
|
+
} finally {
|
|
1940
|
+
cp.spawnSync('git', ['worktree', 'remove', '--force', wt], { cwd: root, encoding: 'utf8', shell: true });
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1531
1944
|
function releasePublish(root) {
|
|
1532
1945
|
root = absRoot(root);
|
|
1533
1946
|
const dryRun = has('--dry-run');
|
|
1534
1947
|
log('# release publish');
|
|
1535
1948
|
log(`Mode: ${dryRun ? 'dry-run' : 'live'}`);
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
if (
|
|
1949
|
+
|
|
1950
|
+
// 1. git remote 자동 감지 (1.9.10)
|
|
1951
|
+
const remote = detectGitRemote(root);
|
|
1952
|
+
if (remote) log(`Git remote (origin): ${remote.host === 'github' ? `${remote.owner}/${remote.repo}` : remote.url}`);
|
|
1953
|
+
else log('Git remote: 없음');
|
|
1954
|
+
|
|
1955
|
+
// 2. npm pack (필요한 경우 — pack-only도 의미 있음)
|
|
1956
|
+
if (has('--pack') || has('--npm-publish') || (!has('--git-push') && !has('--gh-release') && !has('--gh-pages'))) {
|
|
1957
|
+
const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
|
|
1958
|
+
if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
|
|
1959
|
+
ok('npm pack 완료');
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// 3. git push (--git-push 또는 --auto + remote 있을 때)
|
|
1963
|
+
if (has('--git-push') || (has('--auto') && remote)) {
|
|
1540
1964
|
log('git push:');
|
|
1541
1965
|
const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
|
|
1542
|
-
log(r1.stdout || r1.stderr || '(no output)');
|
|
1966
|
+
log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
|
|
1543
1967
|
const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
|
|
1544
|
-
log(r2.stdout || r2.stderr || '(no output)');
|
|
1968
|
+
log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// 4. GitHub Release (--gh-release, gh CLI 사용)
|
|
1972
|
+
if (has('--gh-release')) {
|
|
1973
|
+
if (!remote || remote.host !== 'github') { warn('--gh-release: GitHub remote 없음 — 스킵'); }
|
|
1974
|
+
else {
|
|
1975
|
+
const v = getCurrentVersion(root);
|
|
1976
|
+
if (!v) { warn('--gh-release: package.json#version 없음 — 스킵'); }
|
|
1977
|
+
else {
|
|
1978
|
+
const tag = `v${v}`;
|
|
1979
|
+
const ghArgs = ['release', 'create', tag, '--generate-notes', '--title', `${remote.repo} ${tag}`];
|
|
1980
|
+
const tarball = path.join(root, `${JSON.parse(read(path.join(root, 'package.json'))).name}-${v}.tgz`);
|
|
1981
|
+
if (exists(tarball)) ghArgs.push(tarball);
|
|
1982
|
+
log(`gh ${ghArgs.join(' ')}`);
|
|
1983
|
+
const ghR = cp.spawnSync('gh', ghArgs, { cwd: root, encoding: 'utf8', shell: true });
|
|
1984
|
+
log((ghR.stdout || ghR.stderr || '').slice(-300) || '(no output)');
|
|
1985
|
+
if (ghR.status !== 0) warn('gh release 생성 실패 (이미 존재할 수 있음)');
|
|
1986
|
+
else ok(`GitHub Release 생성: ${tag}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// 5. gh-pages 배포 (--gh-pages)
|
|
1992
|
+
if (has('--gh-pages')) {
|
|
1993
|
+
const src = arg('--gh-pages-src', null) || arg('--roadmap', null) || 'roadmap.html';
|
|
1994
|
+
deployGhPages(root, src);
|
|
1545
1995
|
}
|
|
1996
|
+
|
|
1997
|
+
// 6. npm publish (--npm-publish)
|
|
1546
1998
|
if (has('--npm-publish')) {
|
|
1547
1999
|
const args = dryRun ? ['publish', '--dry-run'] : ['publish', '--access', 'public'];
|
|
1548
2000
|
log('npm ' + args.join(' '));
|
|
@@ -2071,6 +2523,7 @@ function viewworkInstall(root) {
|
|
|
2071
2523
|
|
|
2072
2524
|
function help() {
|
|
2073
2525
|
log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
|
|
2526
|
+
leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
|
|
2074
2527
|
leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
|
|
2075
2528
|
leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
|
|
2076
2529
|
leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
|
|
@@ -2078,7 +2531,7 @@ function help() {
|
|
|
2078
2531
|
leerness rule list|verify|pause <id>|resume <id>|remove <id>|stop|resume-all
|
|
2079
2532
|
leerness release bump [--patch|--minor|--major] # package.json 자동 bump (1.9.8)
|
|
2080
2533
|
leerness release note "<내용>" # CHANGELOG.md 자동 추가 (1.9.8)
|
|
2081
|
-
leerness release publish [--dry-run] [--git-push] [--npm-publish] # 통합 배포 (1.9.8)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
|
|
2534
|
+
leerness release publish [--dry-run] [--pack] [--git-push] [--gh-release] [--gh-pages] [--gh-pages-src file] [--npm-publish] [--auto] # 통합 배포 (1.9.8 + 1.9.10)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
|
|
2082
2535
|
}
|
|
2083
2536
|
|
|
2084
2537
|
async function main() {
|
|
@@ -2119,6 +2572,7 @@ async function main() {
|
|
|
2119
2572
|
if (cmd === 'gate') return gate(args[1] || process.cwd());
|
|
2120
2573
|
if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
|
|
2121
2574
|
if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
|
|
2575
|
+
if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
|
|
2122
2576
|
if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
2123
2577
|
if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
|
|
2124
2578
|
if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
|
|
@@ -2127,9 +2581,9 @@ async function main() {
|
|
|
2127
2581
|
if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
|
|
2128
2582
|
if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
|
|
2129
2583
|
if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
|
|
2130
|
-
if (cmd === 'release' && args[1] === 'bump') return releaseBump(arg('--path', process.cwd()));
|
|
2584
|
+
if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
|
|
2131
2585
|
if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
2132
|
-
if (cmd === 'release' && args[1] === 'publish') return releasePublish(arg('--path', process.cwd()));
|
|
2586
|
+
if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
|
|
2133
2587
|
if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
|
|
2134
2588
|
if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
|
|
2135
2589
|
if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
|
package/package.json
CHANGED
package/scripts/e2e.js
CHANGED
|
@@ -235,6 +235,93 @@ total++;
|
|
|
235
235
|
if (!(strongOK && weakHint)) failed++;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// 1.9.11: roadmap 명령 통합 + 화이트보드/토큰/중앙정렬 회귀
|
|
239
|
+
total++;
|
|
240
|
+
{
|
|
241
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm-'));
|
|
242
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
243
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { encoding: 'utf8' });
|
|
244
|
+
const outFile = path.join(tmpR, 'roadmap.html');
|
|
245
|
+
const ok = r.status === 0 && fs.existsSync(outFile);
|
|
246
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: 명령 + 파일 생성' : `✗ roadmap 실패\n${r.stdout}\n${r.stderr}`);
|
|
247
|
+
if (!ok) failed++;
|
|
248
|
+
}
|
|
249
|
+
total++;
|
|
250
|
+
{
|
|
251
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm2-'));
|
|
252
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
253
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
|
|
254
|
+
const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
|
|
255
|
+
const ok = /화이트보드/.test(html) && /id="roadmap-svg"/.test(html) && /viewBox="0 0/.test(html) && /window\.lrZoom/.test(html) && /window\.lrReset/.test(html);
|
|
256
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: 화이트보드 (panning/zoom JS)' : '✗ 화이트보드 부재');
|
|
257
|
+
if (!ok) failed++;
|
|
258
|
+
}
|
|
259
|
+
total++;
|
|
260
|
+
{
|
|
261
|
+
// 사용자 design-system 토큰 주입
|
|
262
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm3-'));
|
|
263
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
264
|
+
let ds = fs.readFileSync(path.join(tmpR, '.harness/design-system.md'), 'utf8');
|
|
265
|
+
ds = ds.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | |');
|
|
266
|
+
fs.writeFileSync(path.join(tmpR, '.harness/design-system.md'), ds);
|
|
267
|
+
cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
|
|
268
|
+
const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
|
|
269
|
+
const ok = /--lr-primary: #ff5722/.test(html);
|
|
270
|
+
console.log(ok ? '✓ B(1.9.11) roadmap: design-system 토큰 자동 주입' : '✗ 토큰 주입 실패');
|
|
271
|
+
if (!ok) failed++;
|
|
272
|
+
}
|
|
273
|
+
total++;
|
|
274
|
+
{
|
|
275
|
+
// recommended에 project-roadmap-generator 자동 포함
|
|
276
|
+
const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm4-'));
|
|
277
|
+
cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
|
|
278
|
+
const skillsDir = path.join(tmpR, '.harness/skills/project-roadmap-generator');
|
|
279
|
+
const ok = fs.existsSync(skillsDir) && fs.existsSync(path.join(skillsDir, 'skill.json'));
|
|
280
|
+
console.log(ok ? '✓ B(1.9.11) recommended에 project-roadmap-generator 자동 설치' : '✗ 자동 설치 실패');
|
|
281
|
+
if (!ok) failed++;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
|
|
285
|
+
total++;
|
|
286
|
+
{
|
|
287
|
+
const skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
|
|
288
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: skillpackDir })
|
|
291
|
+
});
|
|
292
|
+
const ok = r.status === 0 && /skillpack 출처: env/.test(r.stdout) && /\| skillpack \|/.test(r.stdout);
|
|
293
|
+
console.log(ok ? '✓ B(1.9.10) skillpack 동적 로드 (env path)' : '✗ skillpack 로드 실패');
|
|
294
|
+
if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
|
|
295
|
+
}
|
|
296
|
+
// 1.9.10 A: skillpack 없을 때 builtin fallback
|
|
297
|
+
total++;
|
|
298
|
+
{
|
|
299
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
|
|
300
|
+
encoding: 'utf8',
|
|
301
|
+
env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: '' })
|
|
302
|
+
});
|
|
303
|
+
const ok = r.status === 0 && /builtin fallback/.test(r.stdout) && /\| builtin \|/.test(r.stdout);
|
|
304
|
+
console.log(ok ? '✓ B(1.9.10) builtin fallback (skillpack 없을 때)' : '✗ builtin fallback 실패');
|
|
305
|
+
if (!ok) failed++;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 1.9.10 B: detectGitRemote (가짜 git remote 시뮬은 어려움 — 실제 git 명령으로 확인)
|
|
309
|
+
total++;
|
|
310
|
+
{
|
|
311
|
+
// tmp는 git init이 없음 → detectGitRemote는 null → publish 호출 시 'Git remote: 없음' 출력
|
|
312
|
+
// 시뮬: tmp에 git init + remote add
|
|
313
|
+
cp.spawnSync('git', ['init'], { cwd: tmp, encoding: 'utf8', shell: true });
|
|
314
|
+
cp.spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/test/repo.git'], { cwd: tmp, encoding: 'utf8', shell: true });
|
|
315
|
+
// package.json도 필요
|
|
316
|
+
if (!fs.existsSync(path.join(tmp, 'package.json'))) {
|
|
317
|
+
fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ name: 'e2e-test', version: '0.1.0' }));
|
|
318
|
+
}
|
|
319
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'release', 'publish', tmp, '--dry-run'], { encoding: 'utf8' });
|
|
320
|
+
const ok = /Git remote \(origin\): test\/repo/.test(r.stdout);
|
|
321
|
+
console.log(ok ? '✓ B(1.9.10) detectGitRemote: github owner/repo 추출' : `✗ remote 감지 실패\n${r.stdout.slice(0, 500)}`);
|
|
322
|
+
if (!ok) failed++;
|
|
323
|
+
}
|
|
324
|
+
|
|
238
325
|
// 1.9.8: rule add/list/pause/resume/remove
|
|
239
326
|
total++;
|
|
240
327
|
{
|