[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
@@ -6,8 +6,12 @@
|
||||
* - 문서·보고서·샘플·PDF 처럼 실행에 필요 없는 파일은 가져오지 않는다(EXCLUDE).
|
||||
* - 이미 가져온 파일을 다시 실행해도 Ariweb Suite 쪽 수정본을 덮어쓰지 않는다(수정된 파일은 'modified' 로 표시하고 건너뜀). --force 로 원본으로 되돌릴 수 있다.
|
||||
*
|
||||
* 사용: node scripts/import-sources.mjs [--force] [--only webmaker|arimail]
|
||||
* 사용: node scripts/import-sources.mjs [--force] [--only webmaker|arimail] [--allow-dirty]
|
||||
* 원본 위치는 환경 변수 WEBMAKER_SRC / ARIMAIL_SRC (기본: ~/work/WebHoting_AI_Agent, ~/work/mail-gateway 의 git 체크아웃).
|
||||
* --allow-dirty (0.2.0): 원본 체크아웃이 고정 커밋 위에 아직 커밋되지 않은 변경(작업 트리)을 가진 상태도 받아들인다 — 참조 저장소와 Suite 를 같은 작업자가
|
||||
* 나란히 개발할 때(예: Webmaker 1.8.0 직접 편집) 쓴다. 추적되지 않은 새 파일도 가져오고, upstream{version·branch·headCommit·workingTreeDirty} 를 함께 기록한다.
|
||||
* commit 칸은 변하지 않는 기준(불변 baseline) 커밋이며, 작업 트리 변경이 원본 저장소에 커밋되면 pin 을 그 커밋으로 올리고 다시 실행한다.
|
||||
* --only 로 한 소스만 다시 만들 때 다른 소스의 기존 기록은 그대로 유지한다.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, copyFileSync } from 'node:fs';
|
||||
@@ -17,21 +21,30 @@ import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; const allowDirty = args.includes('--allow-dirty');
|
||||
const SOURCES = [
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '3b10859884ef2fd0f2af11934ab6c16370fd64b8',
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '4b8c4d0de0f69025001a9e0a28a58a94e1a1f088',
|
||||
exclude: [/^reports\//, /^sample\//, /\.pdf$/, /^Dev_Order/, /^WebHosting_AI_agent_/, /^CHANGELOG\.md$/, /^ERRORS\.md$/, /^\.gitignore$/], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.' },
|
||||
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924',
|
||||
exclude: [/^CHANGELOG\.md$/, /^\.gitignore$/, /^deploy\/sites\//], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
||||
];
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 16);
|
||||
const prevManifest = existsSync(join(ROOT, 'IMPORTED_SOURCES.json')) ? JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')) : { sources: [] };
|
||||
const manifest = { generatedAt: new Date().toISOString(), sources: [] };
|
||||
for (const s of SOURCES) {
|
||||
if (only && only !== s.name) continue;
|
||||
if (only && only !== s.name) { const keep = prevManifest.sources.find((x) => x.name === s.name); if (keep) manifest.sources.push(keep); continue; } // --only: 다른 소스의 기존 기록 유지
|
||||
const head = execSync('git rev-parse HEAD', { cwd: s.src }).toString().trim();
|
||||
if (head !== s.pin) throw new Error(`${s.name}: 원본 체크아웃 커밋 ${head} 이 고정 커밋 ${s.pin} 과 다릅니다. git -C ${s.src} checkout ${s.pin}`);
|
||||
const files = execSync('git ls-files -z', { cwd: s.src }).toString().split('\0').filter(Boolean).filter((f) => !s.exclude.some((re) => re.test(f)));
|
||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license, files: [] };
|
||||
const dirty = execSync('git status --porcelain', { cwd: s.src }).toString().trim().length > 0;
|
||||
if (dirty && !allowDirty) throw new Error(`${s.name}: 원본 체크아웃에 커밋되지 않은 변경이 있습니다. 커밋한 뒤 pin 을 올리거나, 나란히 개발 중이면 --allow-dirty 로 실행하세요`);
|
||||
// 추적 파일 + (--allow-dirty 일 때) 아직 추적되지 않은 새 파일. .gitignore 대상(data/·node_modules/ 등)은 제외
|
||||
const lsArgs = allowDirty ? 'git ls-files -z --cached --others --exclude-standard' : 'git ls-files -z';
|
||||
const files = [...new Set(execSync(lsArgs, { cwd: s.src }).toString().split('\0').filter(Boolean))].sort().filter((f) => !s.exclude.some((re) => re.test(f)));
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: s.src }).toString().trim();
|
||||
let version = null; try { version = JSON.parse(readFileSync(join(s.src, 'package.json'), 'utf8')).version || null; } catch (_) {}
|
||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license,
|
||||
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
||||
files: [] };
|
||||
for (const f of files) {
|
||||
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
||||
if (!statSync(from).isFile()) continue;
|
||||
@@ -50,7 +63,8 @@ writeFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), JSON.stringify(manifest, null
|
||||
let md = `# 도입 소스 기록(IMPORTED_SOURCES)\n\n생성: ${manifest.generatedAt} — \`node scripts/import-sources.mjs\` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 \`[Ariweb Suite 변경]\` 표시로 설명한다.\n\n`;
|
||||
for (const e of manifest.sources) {
|
||||
const mod = e.files.filter((f) => f.status === 'modified').length;
|
||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋: \`${e.commit}\`\n- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||
const up = e.upstream ? `- 원본 버전(package.json): ${e.upstream.version || '-'} · 브랜치: \`${e.upstream.branch || '-'}\` · 작업 트리: ${e.upstream.workingTreeDirty ? '**커밋되지 않은 변경 포함(--allow-dirty)** — ' + e.upstream.note : '깨끗함'}\n` : '';
|
||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋(불변 기준): \`${e.commit}\`\n${up}- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : '그대로'} | ${f.origHash} |\n`;
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
+21
-8
@@ -7,12 +7,16 @@
|
||||
* 버전은 package.json, main 커밋은 git rev-parse HEAD(작업 트리가 깨끗하지 않으면 dirty 표시 — 승인 불가 검사 기록).
|
||||
* 모듈별 포함 경로: hal(hal, lib, deploy, lang, package.json) portal(portal, lib, lang, package.json) agent(agent, lib, deploy, package.json)
|
||||
* webmaker(modules/webmaker 전체 - data) arimail(modules/arimail 전체 - data). .git·node_modules·data·dist·test 제외.
|
||||
* 도입 소스 출처(0.2.0 검토 수정): build 는 IMPORTED_SOURCES.json 을 lib/provenance.checkProvenance 로 검사해 깨끗하지 않으면(workingTreeDirty=true·불완전·불일치)
|
||||
* artifact 를 만들지 않고 종료 1 — fail-closed. manifest.json 의 checks.provenanceClean/provenanceProblems 와 imported[].upstream 에 결과를 남기고,
|
||||
* register --approve 와 HAL approveRelease 는 provenanceClean 이 true 가 아닌 릴리스를 승인하지 않는다. (테스트용: ARIWEB_IMPORTED_SOURCES 로 manifest 경로를 바꿀 수 있다.)
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname, basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkProvenance } from '../lib/provenance.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const cmd = args[0] || 'build'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||
@@ -20,11 +24,16 @@ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'catalog', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const git = (a) => { try { return execFileSync('git', a, { cwd: ROOT, encoding: 'utf8' }).trim(); } catch (_) { return ''; } };
|
||||
const IMPORTED_FILE = process.env.ARIWEB_IMPORTED_SOURCES || join(ROOT, 'IMPORTED_SOURCES.json');
|
||||
|
||||
if (cmd === 'build') {
|
||||
// 도입 소스 출처 검사 — 깨끗하지 않으면 아무것도 만들지 않는다(fail-closed). 테스트보다 먼저 보아 시간을 아낀다
|
||||
const importedManifest = existsSync(IMPORTED_FILE) ? JSON.parse(readFileSync(IMPORTED_FILE, 'utf8')) : null;
|
||||
const prov = checkProvenance(importedManifest);
|
||||
if (!prov.ok) { console.error(`도입 소스 출처(provenance)가 깨끗하지 않아 릴리스 artifact 를 만들지 않습니다 (${IMPORTED_FILE}):`); for (const p of prov.problems) console.error(` - ${p}`); process.exit(1); }
|
||||
const out = join(resolve(ROOT, opt('out', 'dist')), VERSION); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true });
|
||||
const commit = git(['rev-parse', 'HEAD']); const dirty = git(['status', '--porcelain']).length > 0; const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
let checks = { testsPassed: null, dirty, branch };
|
||||
let checks = { testsPassed: null, dirty, branch, provenanceClean: prov.ok, provenanceProblems: prov.problems };
|
||||
if (!opt('skip-tests')) { const r = spawnSync(process.execPath, ['test/run.mjs'], { cwd: ROOT, stdio: 'inherit' }); checks.testsPassed = r.status === 0; if (r.status !== 0) { console.error('테스트 실패 — artifact 를 만들지 않습니다'); process.exit(1); } }
|
||||
const artifacts = {};
|
||||
for (const [m, paths] of Object.entries(MODULES)) {
|
||||
@@ -34,7 +43,7 @@ if (cmd === 'build') {
|
||||
if (m === 'webmaker' || m === 'arimail') { tarArgs.push('--transform', `s,^modules/${m},.,`); } tarArgs.push(...present);
|
||||
execFileSync('tar', tarArgs); artifacts[m] = { file, sha256: sha(abs), size: statSync(abs).size, paths: present };
|
||||
}
|
||||
const manifest = { version: VERSION, mainCommit: commit, branch, builtAt: new Date().toISOString(), artifacts, checks, imported: existsSync(join(ROOT, 'IMPORTED_SOURCES.json')) ? JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.map((s) => ({ name: s.name, repo: s.repo, commit: s.commit })) : [] };
|
||||
const manifest = { version: VERSION, mainCommit: commit, branch, builtAt: new Date().toISOString(), artifacts, checks, imported: importedManifest.sources.map((s) => ({ name: s.name, repo: s.repo, commit: s.commit, upstream: s.upstream ? { version: s.upstream.version ?? null, branch: s.upstream.branch ?? null, headCommit: s.upstream.headCommit ?? null, workingTreeDirty: s.upstream.workingTreeDirty } : null, legacy: !s.upstream })) };
|
||||
writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||
console.log(`릴리스 ${VERSION} (${commit.slice(0, 7)}${dirty ? ', dirty' : ''}) → ${out}`); for (const [m, a] of Object.entries(artifacts)) console.log(` ${m.padEnd(9)} ${a.file} ${(a.size / 1024).toFixed(0)}KB sha256 ${a.sha256.slice(0, 16)}…`);
|
||||
} else if (cmd === 'verify') {
|
||||
@@ -43,11 +52,15 @@ if (cmd === 'build') {
|
||||
process.exit(bad ? 1 : 0);
|
||||
} else if (cmd === 'register') {
|
||||
const halData = opt('hal-data'); if (!halData) { console.error('--hal-data 가 필요합니다'); process.exit(1); } const dist = resolve(ROOT, opt('dist', join('dist', VERSION))); const mf = JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8'));
|
||||
if (mf.checks?.dirty && opt('approve')) { console.error('작업 트리가 깨끗하지 않은(dirty) 빌드는 승인할 수 없습니다 — main 에 병합된 커밋에서 빌드하세요'); process.exit(1); }
|
||||
const { openDb } = await import('../lib/db.mjs'); const { openVault } = await import('../lib/vault.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs'); const { loadConfig } = await import('../hal/config.mjs');
|
||||
// 0.2.0 검토 수정(2차): 승인은 HAL 과 같은 한 규칙(core.releaseApprovalProblem) — testsPassed === true(--skip-tests 빌드는 등록만, 승인 불가) · dirty === false · provenanceClean === true 가 모두 기록된 manifest 만. DB 를 열기 전에 멈춘다(fail-closed)
|
||||
const core = await import('../hal/core.mjs');
|
||||
if (opt('approve')) { const why = core.releaseApprovalProblem(mf.checks); if (why) { console.error(`${why}${(mf.checks?.provenanceProblems || []).map((p) => `\n - ${p}`).join('')}\n테스트를 건너뛰지 않고(--skip-tests 없이) main 에 병합된 깨끗한 커밋에서, 깨끗한 IMPORTED_SOURCES.json(원본 저장소에 커밋된 고정 커밋, workingTreeDirty=false)으로 다시 build 하세요`); process.exit(1); } }
|
||||
const { openDb } = await import('../lib/db.mjs'); const { openVault } = await import('../lib/vault.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { loadConfig } = await import('../hal/config.mjs');
|
||||
process.env.ARIWEB_HAL_DATA = halData; const config = loadConfig(); const d = { store: openDb(join(halData, 'hal.sqlite'), MIGRATIONS), vault: openVault(halData), config, log: () => {} };
|
||||
const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true });
|
||||
const artifacts = {}; for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dist, a.file); if (sha(src) !== a.sha256) { console.error(`${m} artifact 해시 불일치`); process.exit(1); } const { copyFileSync } = await import('node:fs'); copyFileSync(src, join(dir, basename(a.file))); artifacts[m] = { file: basename(a.file), sha256: a.sha256, size: a.size }; }
|
||||
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks, approve: !!opt('approve'), by: opt('by', 'release-script') });
|
||||
console.log(`HAL 릴리스 등록: ${r.version} (${r.status}) main ${r.main_commit.slice(0, 7)} — artifact ${Object.keys(artifacts).join(', ')}`); d.store.close();
|
||||
// 0.2.0 검토 수정 4차: artifact 설치는 HAL 과 같은 불변·fail-closed 규칙(core.installArtifacts) — 바이트 해시·크기 검증 → 승인된 버전은 같을 때만 멱등, 다르면 파일·DB 불변 거부 → 새 설치는 임시 파일 → rename
|
||||
let r; try {
|
||||
const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dist, a.file) }])) });
|
||||
r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks, approve: !!opt('approve'), by: opt('by', 'release-script') });
|
||||
} catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); d.store.close(); process.exit(1); }
|
||||
console.log(`HAL 릴리스 등록: ${r.version} (${r.status}) main ${r.main_commit.slice(0, 7)} — artifact ${Object.keys(r.artifacts).join(', ')}`); d.store.close();
|
||||
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
||||
|
||||
Reference in New Issue
Block a user