axmap-cli 1.3.1 → 1.3.2
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/README.ko.md +8 -0
- package/README.md +6 -0
- package/bin/axmap.mjs +55 -20
- package/mcp/server.mjs +9 -7
- package/package.json +1 -1
- package/src/identity.mjs +62 -0
- package/tools/bus.mjs +222 -89
- package/tools/setup.mjs +41 -11
package/README.ko.md
CHANGED
|
@@ -24,6 +24,14 @@ axmap setup
|
|
|
24
24
|
MCP를 사용하려면 설정 후 AI 도구를 다시 시작한다.
|
|
25
25
|
함께 작업하는 사람과 에이전트는 같은 원격 장부를 사용해야 서로의 선점을 볼 수 있다.
|
|
26
26
|
|
|
27
|
+
## 1.3.2: 쪽지 표시 이름과 사용자 자동 공유
|
|
28
|
+
|
|
29
|
+
대화형 `setup`에서 `장효준`, `John Smith`처럼 쪽지에 표시할 이름을 입력한다. 입력 없이 설정하거나 오타를 고치려면 `axmap setup --name "장효준"`을 실행한다. 재설치할 때는 저장된 이름을 유지하며 Git 이름·이메일 설정은 바꾸지 않는다.
|
|
30
|
+
|
|
31
|
+
각 repo에서 axMap을 처음 사용하면 해당 repo의 Git 이메일을 읽고 기존 `axmap/users` 고아 브랜치에 표시 이름을 자동 공유한다. 코드 커밋이나 repo별 수동 등록은 필요 없다. 다른 팀원은 사용자 정보 동기화 후 수신자로 발견한다. 원격 push 권한이 필요하며, 공유 실패는 경고하고 다음 사용 때 재시도한다. `setup`만 실행하고 사용한 적 없는 repo에는 등록하지 않는다.
|
|
32
|
+
|
|
33
|
+
`axmap bus contacts`로 수신자 이름·이메일을 확인한다. 아는 이름으로 보낸 쪽지는 이메일 주소로 저장하므로 이름을 고쳐도 수신·읽음 기록이 이어진다. 동명이인은 이메일로 지정한다. 쪽지 자동 등록은 투표권이나 MR 승인 권한을 부여하지 않는다.
|
|
34
|
+
|
|
27
35
|
자세한 설치·설정은 [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md)를 참고한다.
|
|
28
36
|
|
|
29
37
|
## 1.3.1: 어느 폴더에서든 setup 한 번, 이후 repo 자동 연결
|
package/README.md
CHANGED
|
@@ -24,6 +24,12 @@ axmap setup
|
|
|
24
24
|
Restart your AI tool after setup to use MCP.
|
|
25
25
|
People and agents working together must share the same remote ledger to see each other's claims.
|
|
26
26
|
|
|
27
|
+
During interactive setup, enter the name people should use for messages, such as `John Smith` or `장효준`. To set or correct it without a prompt, run `axmap setup --name "John Smith"`. Reinstallation keeps the saved name. Git's name and email settings are unchanged.
|
|
28
|
+
|
|
29
|
+
On first use in a repository, axMap reads that repository's Git email and shares your display name through the existing `axmap/users` branch. No code commit or per-repository manual registration is needed. Teammates discover it when their user information synchronizes. This requires push access; an offline or rejected update is reported and retried. Setup alone does not announce you to repositories you have never used.
|
|
30
|
+
|
|
31
|
+
Use `axmap bus contacts` to list known names and email addresses. Messages sent to a known name are addressed to its email, so correcting the name preserves delivery and read history. If multiple people use the same name, specify the recipient's email. Messaging registration does not grant voting or MR approval rights.
|
|
32
|
+
|
|
27
33
|
See [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md) for installation and configuration details (Korean).
|
|
28
34
|
|
|
29
35
|
## Global setup and automatic repositories in 1.3.1
|
package/bin/axmap.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { stateDir } from '../src/repotarget.mjs'
|
|
|
47
47
|
import { homeRegistrations } from '../src/mcpstate.mjs'
|
|
48
48
|
import { compareBranchFor, formatOverlap } from '../src/overlap.mjs'
|
|
49
49
|
import { withRepoLock } from '../src/repolock.mjs'
|
|
50
|
+
import { readProfile, syncConfiguredProfile } from '../src/identity.mjs'
|
|
50
51
|
|
|
51
52
|
/** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
|
|
52
53
|
const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -453,9 +454,17 @@ function unreadNotes(root, me) {
|
|
|
453
454
|
}
|
|
454
455
|
if (!found.length) return []
|
|
455
456
|
let seen = ''
|
|
457
|
+
const email = (process.env.AXMAP_AGENT_EMAIL || git(['config', 'user.email'], { cwd: root }).out).trim().toLowerCase()
|
|
458
|
+
const mine = new Set([me.toLowerCase(), email].filter(Boolean))
|
|
459
|
+
try { const p = readProfile(); if (p) mine.add(p.name.toLowerCase()) } catch { /* profile sync reports it */ }
|
|
460
|
+
try {
|
|
461
|
+
const state = JSON.parse(fs.readFileSync(path.join(usersFilesDir(root), `${email}.json`), 'utf8'))
|
|
462
|
+
for (const n of [state.name, ...(state.aliases ?? [])]) if (n) mine.add(n.toLowerCase())
|
|
463
|
+
seen = state.seen ?? ''
|
|
464
|
+
} catch { /* no shared state */ }
|
|
456
465
|
try {
|
|
457
466
|
const raw = JSON.parse(fs.readFileSync(path.join(root, '.axmap-bus-seen.json'), 'utf8'))
|
|
458
|
-
|
|
467
|
+
for (const [key, id] of Object.entries(raw)) if (mine.has(key.toLowerCase()) && id > seen) seen = id
|
|
459
468
|
} catch { /* 처음이면 아무것도 안 읽은 것 */ }
|
|
460
469
|
|
|
461
470
|
const out = []
|
|
@@ -471,8 +480,8 @@ function unreadNotes(root, me) {
|
|
|
471
480
|
const from = g('from')
|
|
472
481
|
const to = g('to')
|
|
473
482
|
// 내가 보낸 것은 뺀다 — 자기 쪽지에 자기가 놀라면 안 된다.
|
|
474
|
-
if (
|
|
475
|
-
if (to
|
|
483
|
+
if (g('fromEmail') ? mine.has(g('fromEmail').toLowerCase()) : mine.has(from.toLowerCase())) continue
|
|
484
|
+
if (!to.split(',').some((v) => v.trim().toLowerCase() === 'all' || mine.has(v.trim().toLowerCase()))) continue
|
|
476
485
|
out.push({ id, from, subject: g('subject') })
|
|
477
486
|
}
|
|
478
487
|
return out
|
|
@@ -504,6 +513,11 @@ function markBusSeen(root, me, id) {
|
|
|
504
513
|
all[me] = id
|
|
505
514
|
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n')
|
|
506
515
|
} catch { /* 조용히 */ }
|
|
516
|
+
// Use the same email-keyed receipt as MCP, while retaining the legacy file
|
|
517
|
+
// for older installations that still share this checkout.
|
|
518
|
+
spawnSync(process.execPath, [path.join(SELF_ROOT, 'tools', 'bus.mjs'), 'seen', '--mine', id], {
|
|
519
|
+
cwd: root, env: { ...process.env, AXMAP_AGENT: me }, encoding: 'utf8', windowsHide: true,
|
|
520
|
+
})
|
|
507
521
|
}
|
|
508
522
|
|
|
509
523
|
/**
|
|
@@ -722,18 +736,10 @@ function claimsDir(root) {
|
|
|
722
736
|
return path.join(ledgerDir(root), 'claims')
|
|
723
737
|
}
|
|
724
738
|
|
|
725
|
-
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* 장부와 같은 `.axmap/` 아래라 같은 문제가 있지만(링크된 worktree 에는 없다),
|
|
729
|
-
* 고치려면 `tools/bus.mjs` 의 `SEEN_FILE`·쪽지함 경로까지 같이 옮겨야 한다.
|
|
730
|
-
* 한쪽만 옮기면 **읽는 곳과 쓰는 곳이 갈려** 읽음 표시가 어긋난다 —
|
|
731
|
-
* 쪽지가 안 보이는 것보다 나쁘다. 장부와 달리 여기서는 못 찾아도 죽지 않고
|
|
732
|
-
* "새 쪽지 없음" 으로 조용히 지나가므로, 링크된 worktree 에서 쪽지가 안 보이는
|
|
733
|
-
* 것은 남는 구멍으로 적어 둔다.
|
|
734
|
-
*/
|
|
739
|
+
/** Message and user worktrees follow the same common checkout as the ledger.
|
|
740
|
+
* Git identity is still read from the caller's working repository. */
|
|
735
741
|
function busDir(root) {
|
|
736
|
-
return path.join(root, BUS_REL)
|
|
742
|
+
return path.join(axmapHome(root), BUS_REL)
|
|
737
743
|
}
|
|
738
744
|
|
|
739
745
|
/** 쪽지 파일이 실제로 쌓이는 곳. 브랜치 루트에 흩뿌리지 않고 한 폴더 아래 모은다. */
|
|
@@ -742,7 +748,7 @@ function busMessagesDir(root) {
|
|
|
742
748
|
}
|
|
743
749
|
|
|
744
750
|
function usersDir(root) {
|
|
745
|
-
return path.join(root, USERS_REL)
|
|
751
|
+
return path.join(axmapHome(root), USERS_REL)
|
|
746
752
|
}
|
|
747
753
|
|
|
748
754
|
/** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
|
|
@@ -1343,9 +1349,17 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1343
1349
|
const remote = r.source === 'ambiguous' ? null : r.name
|
|
1344
1350
|
|
|
1345
1351
|
let base = null
|
|
1346
|
-
if (
|
|
1347
|
-
|
|
1348
|
-
|
|
1352
|
+
if (r.source === 'ambiguous') return fail({ err: '원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.' })
|
|
1353
|
+
if (remote) {
|
|
1354
|
+
const exists = git(['ls-remote', '--exit-code', remote, `refs/heads/${branch}`], { cwd: root })
|
|
1355
|
+
if (exists.code !== 0 && exists.code !== 2) return fail(exists)
|
|
1356
|
+
if (exists.code === 0) {
|
|
1357
|
+
const fetched = git(['fetch', '--quiet', remote, branch], { cwd: root })
|
|
1358
|
+
if (fetched.code !== 0) return fail(fetched)
|
|
1359
|
+
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (!base) {
|
|
1349
1363
|
// 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
|
|
1350
1364
|
const tree = git(['mktree'], { cwd: root, input: '' })
|
|
1351
1365
|
if (tree.code !== 0) return fail(tree)
|
|
@@ -1361,6 +1375,8 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1361
1375
|
const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
|
|
1362
1376
|
if (w.code !== 0) return fail(w)
|
|
1363
1377
|
|
|
1378
|
+
excludeAxmap(root)
|
|
1379
|
+
|
|
1364
1380
|
seed(dir)
|
|
1365
1381
|
|
|
1366
1382
|
// 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
|
|
@@ -1386,6 +1402,16 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
|
1386
1402
|
return 'created'
|
|
1387
1403
|
}
|
|
1388
1404
|
|
|
1405
|
+
function excludeAxmap(root) {
|
|
1406
|
+
const file = path.resolve(root, gitOrDie(['rev-parse', '--git-path', 'info/exclude'], { cwd: root }).out)
|
|
1407
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1408
|
+
let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
1409
|
+
for (const entry of ['.axmap/', '.axmap-bus-seen.json']) {
|
|
1410
|
+
if (!text.split(/\r?\n/).includes(entry)) text += (text && !text.endsWith('\n') ? '\n' : '') + entry + '\n'
|
|
1411
|
+
}
|
|
1412
|
+
fs.writeFileSync(file, text)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1389
1415
|
function ensureBus(root) {
|
|
1390
1416
|
return ensureOrphan(root, {
|
|
1391
1417
|
branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
|
|
@@ -1424,7 +1450,7 @@ function cmdBusRepair() {
|
|
|
1424
1450
|
const root = repoRoot()
|
|
1425
1451
|
// .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
|
|
1426
1452
|
git(['worktree', 'prune'], { cwd: root })
|
|
1427
|
-
const r = ensureBus(root)
|
|
1453
|
+
const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureBus(root))
|
|
1428
1454
|
if (r === 'failed') process.exit(1)
|
|
1429
1455
|
console.log(
|
|
1430
1456
|
r === 'ready'
|
|
@@ -1437,7 +1463,7 @@ function cmdBusRepair() {
|
|
|
1437
1463
|
function cmdUsersRepair() {
|
|
1438
1464
|
const root = repoRoot()
|
|
1439
1465
|
git(['worktree', 'prune'], { cwd: root })
|
|
1440
|
-
const r = ensureUsers(root)
|
|
1466
|
+
const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureUsers(root))
|
|
1441
1467
|
if (r === 'failed') process.exit(1)
|
|
1442
1468
|
console.log(
|
|
1443
1469
|
r === 'ready'
|
|
@@ -2898,3 +2924,12 @@ switch (cmd) {
|
|
|
2898
2924
|
console.log(HELP)
|
|
2899
2925
|
process.exit(cmd ? 1 : 0)
|
|
2900
2926
|
}
|
|
2927
|
+
|
|
2928
|
+
// Setup stores a label, never a repository pin. Publish it only after an
|
|
2929
|
+
// actual successful collaboration command in the selected working repository.
|
|
2930
|
+
if (['init', 'claim', 'release', 'renew', 'status'].includes(cmd)) {
|
|
2931
|
+
const warning = syncConfiguredProfile(process.cwd(), path.join(SELF_ROOT, 'tools', 'bus.mjs'), {
|
|
2932
|
+
...process.env, ...(flags.agent ? { AXMAP_AGENT: flags.agent } : {}),
|
|
2933
|
+
})
|
|
2934
|
+
if (warning) console.error(warning)
|
|
2935
|
+
}
|
package/mcp/server.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import fs from 'node:fs'
|
|
|
45
45
|
import path from 'node:path'
|
|
46
46
|
import readline from 'node:readline'
|
|
47
47
|
import { fileURLToPath } from 'node:url'
|
|
48
|
+
import { syncConfiguredProfile } from '../src/identity.mjs'
|
|
48
49
|
|
|
49
50
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
50
51
|
|
|
@@ -311,7 +312,7 @@ function unreadBanner(tool, text) {
|
|
|
311
312
|
// 떼는 순간 여기가 조용히 낡는다 — 목록은 언제나 코드보다 먼저 낡는다.
|
|
312
313
|
if (text.includes('안 읽은 쪽지')) return ''
|
|
313
314
|
|
|
314
|
-
const r = bus(['list', '--
|
|
315
|
+
const r = bus(['list', '--mine', '--unread', '--no-mark',
|
|
315
316
|
'--throttle', String(BUS_POLL), '--quiet-if-empty'])
|
|
316
317
|
if (r.code !== 0) return ''
|
|
317
318
|
const rows = busRows(r.out)
|
|
@@ -331,7 +332,7 @@ function unreadBanner(tool, text) {
|
|
|
331
332
|
|
|
332
333
|
// 🔴 배너를 다 만든 뒤에 찍는다. 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
|
|
333
334
|
const ids = shown.map((s) => s.id).filter(Boolean)
|
|
334
|
-
if (ids.length) bus(['seen', '--
|
|
335
|
+
if (ids.length) bus(['seen', '--mine', ...ids])
|
|
335
336
|
return banner
|
|
336
337
|
}
|
|
337
338
|
|
|
@@ -358,7 +359,7 @@ function cli(args) {
|
|
|
358
359
|
cwd: REPO,
|
|
359
360
|
encoding: 'utf8',
|
|
360
361
|
windowsHide: true,
|
|
361
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
362
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
|
|
362
363
|
})
|
|
363
364
|
const stdout = (r.stdout ?? '').trim()
|
|
364
365
|
const stderr = (r.stderr ?? '').trim()
|
|
@@ -737,7 +738,7 @@ function releaseAllIn(dir) {
|
|
|
737
738
|
cwd: dir,
|
|
738
739
|
encoding: 'utf8',
|
|
739
740
|
windowsHide: true,
|
|
740
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
741
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
|
|
741
742
|
})
|
|
742
743
|
return {
|
|
743
744
|
code: r.status ?? 1,
|
|
@@ -859,7 +860,7 @@ function dispatch(name, args = {}) {
|
|
|
859
860
|
// 겹치지만 `markSeen` 은 뒤로 가지 않으므로 결과가 같다.
|
|
860
861
|
//
|
|
861
862
|
// 전부 보려면 `all: true` 가 이미 있다 — 그쪽은 거르지도 찍지도 않는다.
|
|
862
|
-
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--
|
|
863
|
+
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--mine', '--unread'])]
|
|
863
864
|
const r = bus(a)
|
|
864
865
|
|
|
865
866
|
// 🔴 여기도 **낸 것만 찍는다.** 목록을 자르지 않으므로 낸 것 전부다.
|
|
@@ -870,7 +871,7 @@ function dispatch(name, args = {}) {
|
|
|
870
871
|
// id 로 찍으면 **나에게 온 안 읽은 쪽지가 화면에 뜬 적 없이 묻힌다.**
|
|
871
872
|
if (r.code === 0 && !args.id && !args.all) {
|
|
872
873
|
const ids = busRows(r.out).map((s) => s.id).filter(Boolean)
|
|
873
|
-
if (ids.length) bus(['seen', '--
|
|
874
|
+
if (ids.length) bus(['seen', '--mine', ...ids])
|
|
874
875
|
}
|
|
875
876
|
return {
|
|
876
877
|
ok: r.code === 0,
|
|
@@ -1007,7 +1008,8 @@ async function handle(req) {
|
|
|
1007
1008
|
do { task = rootsTask; await task } while (task !== rootsTask)
|
|
1008
1009
|
}
|
|
1009
1010
|
const r = callTool(params?.name, params?.arguments ?? {})
|
|
1010
|
-
|
|
1011
|
+
const warning = r.ok ? syncConfiguredProfile(REPO, BUS, { ...process.env, AXMAP_AGENT: AGENT }) : ''
|
|
1012
|
+
return { content: [{ type: 'text', text: r.text + (warning ? '\n' + warning : '') + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
|
|
1011
1013
|
}
|
|
1012
1014
|
if (method === 'ping') return {}
|
|
1013
1015
|
return null // 알림이거나 지원하지 않는 메서드
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
|
|
3
3
|
"name": "axmap-cli",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.2",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { agentNameError } from './protocol.mjs'
|
|
6
|
+
|
|
7
|
+
// Only the display name is global. Email is resolved from the working repo so
|
|
8
|
+
// a work identity never silently becomes the address used in a personal repo.
|
|
9
|
+
export const profilePath = (home = os.homedir()) => path.join(home, '.axmap', 'profile.json')
|
|
10
|
+
|
|
11
|
+
export function displayNameError(name) {
|
|
12
|
+
if (typeof name !== 'string') return '표시 이름은 문자열이어야 합니다.'
|
|
13
|
+
return agentNameError(name) || (/[,;@\u007f]/.test(name)
|
|
14
|
+
? '표시 이름에는 쉼표·세미콜론·@ 문자를 쓸 수 없습니다.'
|
|
15
|
+
: name.toLowerCase() === 'all' ? 'all은 전체 수신자를 뜻하므로 표시 이름으로 쓸 수 없습니다.' : null)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readProfile(home) {
|
|
19
|
+
const file = profilePath(home)
|
|
20
|
+
let data
|
|
21
|
+
try { data = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
|
|
22
|
+
if (e.code === 'ENOENT') return null
|
|
23
|
+
throw new Error(`표시 이름 설정을 읽지 못했습니다: ${file} (${e.message})`)
|
|
24
|
+
}
|
|
25
|
+
const error = displayNameError(data?.name)
|
|
26
|
+
if (data?.version !== 1 || error || (data.updatedAt && !Number.isFinite(Date.parse(data.updatedAt)))) throw new Error(`표시 이름 설정이 올바르지 않습니다: ${file}${error ? ` (${error})` : ''}`)
|
|
27
|
+
return data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeProfile(name, home) {
|
|
31
|
+
const error = displayNameError(name)
|
|
32
|
+
if (error) throw new Error(error)
|
|
33
|
+
const previous = readProfile(home)
|
|
34
|
+
const file = profilePath(home)
|
|
35
|
+
const data = { ...previous, version: 1, name,
|
|
36
|
+
updatedAt: previous?.name === name && previous.updatedAt ? previous.updatedAt : new Date().toISOString() }
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
38
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
39
|
+
try {
|
|
40
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
41
|
+
fs.renameSync(tmp, file)
|
|
42
|
+
} finally { fs.rmSync(tmp, { force: true }) }
|
|
43
|
+
return data
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function emailError(email) {
|
|
47
|
+
// This address also becomes a filename on Windows; reject unsafe Git config
|
|
48
|
+
// values rather than rewriting two different addresses to the same file.
|
|
49
|
+
return typeof email !== 'string' || !/^[^\s@\\/:*?"<>|,;\u0000-\u001f]+@[^\s@\\/:*?"<>|,;\u0000-\u001f]+$/.test(email)
|
|
50
|
+
|| /[.\s]$/.test(email) ? 'Git 이메일이 없거나 사용자 주소로 쓸 수 없습니다. git config user.email을 확인하세요.' : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function syncConfiguredProfile(repo, bus, env = process.env) {
|
|
54
|
+
if (env.AXMAP_SKIP_PROFILE_SYNC === '1') return ''
|
|
55
|
+
try {
|
|
56
|
+
if (!readProfile()) return ''
|
|
57
|
+
const r = spawnSync(process.execPath, [bus, 'profile'], {
|
|
58
|
+
cwd: repo, env, encoding: 'utf8', windowsHide: true,
|
|
59
|
+
})
|
|
60
|
+
return (r.stderr || (r.status !== 0 ? '경고: 쪽지 프로필 공유에 실패했습니다. 다음 사용 때 재시도합니다.' : '')).trim()
|
|
61
|
+
} catch (e) { return `경고: ${e.message}` }
|
|
62
|
+
}
|
package/tools/bus.mjs
CHANGED
|
@@ -34,6 +34,10 @@ import { execFileSync, spawnSync } from 'node:child_process'
|
|
|
34
34
|
import fs from 'node:fs'
|
|
35
35
|
import path from 'node:path'
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { agentNameError } from '../src/protocol.mjs'
|
|
38
|
+
import { readProfile, profilePath, emailError } from '../src/identity.mjs'
|
|
39
|
+
import { withRepoLock } from '../src/repolock.mjs'
|
|
40
|
+
import { randomUUID } from 'node:crypto'
|
|
37
41
|
|
|
38
42
|
/** **이 프로그램**의 뿌리. 데이터가 아니라 코드를 찾을 때만 쓴다 (`who` 의 axmap.mjs). */
|
|
39
43
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -85,7 +89,11 @@ const REPO = (() => {
|
|
|
85
89
|
})()
|
|
86
90
|
|
|
87
91
|
/** 쪽지 worktree 의 루트. 커밋·push 는 여기서 돈다. */
|
|
88
|
-
const
|
|
92
|
+
const COMMON = REPO ? path.resolve(REPO, execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
93
|
+
cwd: REPO, encoding: 'utf8', windowsHide: true,
|
|
94
|
+
}).trim()) : null
|
|
95
|
+
const DATA_HOME = COMMON ? (path.basename(COMMON) === '.git' ? path.dirname(COMMON) : REPO) : null
|
|
96
|
+
const BUS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'bus') : null
|
|
89
97
|
const BUS_BRANCH = 'axmap/bus'
|
|
90
98
|
|
|
91
99
|
/** **쓰는 곳은 하나뿐이다.** */
|
|
@@ -273,14 +281,14 @@ const recipientsOf = (m) => String(m.to ?? 'all').split(',').map(norm).filter(Bo
|
|
|
273
281
|
*/
|
|
274
282
|
function me() {
|
|
275
283
|
const v = process.env.AXMAP_AGENT
|
|
276
|
-
if (v &&
|
|
284
|
+
if (v && !agentNameError(v)) return v
|
|
277
285
|
try {
|
|
278
286
|
const n = execFileSync('git', ['config', 'user.name'], {
|
|
279
287
|
cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
280
288
|
}).trim()
|
|
281
289
|
// 서버와 달리 여기서는 형식을 본다. 통과 못 하면 아래에서 죽는다 —
|
|
282
290
|
// 못 읽는 이름을 그냥 쓰면 파일 이름과 필터가 조용히 어긋난다.
|
|
283
|
-
if (n &&
|
|
291
|
+
if (n && !agentNameError(n)) return n
|
|
284
292
|
} catch { /* 아래에서 죽는다 */ }
|
|
285
293
|
console.error(
|
|
286
294
|
'AXMAP_AGENT 를 먼저 정하세요 (선점과 같은 값).\n' +
|
|
@@ -319,30 +327,14 @@ function roster() {
|
|
|
319
327
|
if (rosterCache) return rosterCache
|
|
320
328
|
const byEmail = new Map()
|
|
321
329
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
*
|
|
325
|
-
* 이 줄이 없으면 어긋난 쪽지 한 통이 두 사람을 영구히 합친다. 실제로 그랬다 —
|
|
326
|
-
* 도구 이름은 `alice` 인데 git 이메일이 `bob@x.com` 인 쪽지가 하나 있었고,
|
|
327
|
-
* 그 뒤로 `alice` 가 bob 의 이름이 됐다. 그러면 bob 은 alice 가 보낸 쪽지를
|
|
328
|
-
* **자기가 보낸 것으로 보고 안 읽음에서 지운다.**
|
|
329
|
-
*
|
|
330
|
-
* 이름이 어긋나 쪽지가 묻히는 것이 이 판에서 고치려던 바로 그 증상인데,
|
|
331
|
-
* 신원을 합치는 방식으로 고치면 **자리만 바꿔서 되살아난다.**
|
|
332
|
-
*
|
|
333
|
-
* 순서가 곧 우선순위다: git 이력(1) → 쪽지 머리말(2) → 적어 둔 별칭(3).
|
|
334
|
-
* git 이력이 가장 믿을 만하다 — 사람이 자기 PC 에 직접 설정한 값이다.
|
|
335
|
-
*/
|
|
336
|
-
const nameOwner = new Map()
|
|
330
|
+
// Keep homonyms as separate email identities. Recipient resolution rejects
|
|
331
|
+
// an ambiguous label instead of silently assigning it to the first author.
|
|
337
332
|
const add = (email, name) => {
|
|
338
333
|
const e = norm(email)
|
|
339
334
|
if (!e || !e.includes('@')) return
|
|
340
335
|
if (!byEmail.has(e)) byEmail.set(e, { email: e, names: new Set() })
|
|
341
336
|
const n = norm(name)
|
|
342
337
|
if (!n) return
|
|
343
|
-
const owner = nameOwner.get(n)
|
|
344
|
-
if (owner && owner !== e) return // 이미 남의 이름이다. 합치지 않는다
|
|
345
|
-
nameOwner.set(n, e)
|
|
346
338
|
byEmail.get(e).names.add(n)
|
|
347
339
|
}
|
|
348
340
|
|
|
@@ -383,6 +375,7 @@ function roster() {
|
|
|
383
375
|
const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
|
|
384
376
|
const email = f.replace(/\.json$/, '')
|
|
385
377
|
add(email, st.name)
|
|
378
|
+
if (byEmail.has(norm(email))) byEmail.get(norm(email)).displayName = st.name
|
|
386
379
|
for (const a of (st.aliases ?? [])) add(email, a)
|
|
387
380
|
}
|
|
388
381
|
} catch { /* 사람별 상태가 아직 없다. 1)·2)만으로도 명단은 선다 */ }
|
|
@@ -399,8 +392,13 @@ function roster() {
|
|
|
399
392
|
*/
|
|
400
393
|
function addressesOf(who) {
|
|
401
394
|
const w = norm(who)
|
|
402
|
-
const
|
|
403
|
-
|
|
395
|
+
const people = roster()
|
|
396
|
+
const matches = people.filter((p) => p.email === w || p.names.has(w))
|
|
397
|
+
if (matches.length !== 1) return new Set([w])
|
|
398
|
+
const p = matches[0]
|
|
399
|
+
// A shared label must never make two mailboxes the same person. Known
|
|
400
|
+
// homonyms can still receive mail through their unambiguous email addresses.
|
|
401
|
+
return new Set([p.email, ...[...p.names].filter((n) => people.filter((x) => x.names.has(n)).length === 1)])
|
|
404
402
|
}
|
|
405
403
|
|
|
406
404
|
/**
|
|
@@ -466,8 +464,12 @@ function myAddresses() {
|
|
|
466
464
|
const s = new Set()
|
|
467
465
|
const e = myEmail()
|
|
468
466
|
if (e) for (const a of addressesOf(e)) s.add(a)
|
|
469
|
-
|
|
470
|
-
|
|
467
|
+
const ownName = (name) => {
|
|
468
|
+
if (roster().filter((p) => p.names.has(norm(name))).length < 2) s.add(norm(name))
|
|
469
|
+
}
|
|
470
|
+
ownName(me())
|
|
471
|
+
if (process.env.AXMAP_AGENT) ownName(process.env.AXMAP_AGENT)
|
|
472
|
+
try { const p = readProfile(); if (p) ownName(p.name) } catch { /* reported during profile sync */ }
|
|
471
473
|
return s
|
|
472
474
|
}
|
|
473
475
|
|
|
@@ -489,7 +491,7 @@ function myAddresses() {
|
|
|
489
491
|
const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
|
|
490
492
|
|
|
491
493
|
/** 새 자리 — 고아 브랜치 `axmap/users` 의 worktree. 사람마다 파일 하나. */
|
|
492
|
-
const USERS_WT = REPO ? path.join(
|
|
494
|
+
const USERS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'users') : null
|
|
493
495
|
const USERS_BRANCH = 'axmap/users'
|
|
494
496
|
const usersReady = () => USERS_WT !== null && fs.existsSync(path.join(USERS_WT, '.git'))
|
|
495
497
|
|
|
@@ -528,8 +530,9 @@ function openUsers() {
|
|
|
528
530
|
/** 이 사람의 파일 자리. **이메일이 정본**이고, 없는 사람만 이름으로 떨어진다. */
|
|
529
531
|
function userKey(who) {
|
|
530
532
|
const w = norm(who)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
+
if (w.includes('@')) return w
|
|
534
|
+
const matches = roster().filter((x) => x.names.has(w))
|
|
535
|
+
return matches.length === 1 ? matches[0].email : w
|
|
533
536
|
}
|
|
534
537
|
|
|
535
538
|
const userFile = (who) => path.join(USERS_WT, 'users', `${userKey(who)}.json`)
|
|
@@ -539,19 +542,148 @@ function readUserState(who) {
|
|
|
539
542
|
}
|
|
540
543
|
|
|
541
544
|
/** 원격의 사람별 상태를 받아온다. 자주 물을 이유가 없어 느슨하게 맞춘다. */
|
|
545
|
+
function usersRemote() {
|
|
546
|
+
const configured = process.env.AXMAP_REMOTE || gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out
|
|
547
|
+
if (configured) return configured
|
|
548
|
+
const names = gitUsers(['remote'], { cwd: REPO }).out.split('\n').filter(Boolean)
|
|
549
|
+
if (names.length === 1) return names[0]
|
|
550
|
+
if (names.includes('origin')) return 'origin'
|
|
551
|
+
if (names.length) throw new Error('사용자 정보를 공유할 원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.')
|
|
552
|
+
return null
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function mustUsers(args) {
|
|
556
|
+
const r = gitUsers(args)
|
|
557
|
+
if (r.code !== 0) throw new Error(r.err || r.out || `git ${args[0]} 실패`)
|
|
558
|
+
return r.out
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function mergeState(local, incoming) {
|
|
562
|
+
const newest = (incoming.profileUpdatedAt ?? '') > (local.profileUpdatedAt ?? '') ? incoming : local
|
|
563
|
+
const aliases = [...new Set([...(local.aliases ?? []), ...(incoming.aliases ?? []), local.name, incoming.name].filter(Boolean))]
|
|
564
|
+
const seen = [local.seen ?? '', incoming.seen ?? ''].sort().at(-1)
|
|
565
|
+
return { ...incoming, ...local, ...newest, aliases: aliases.filter((a) => a !== newest.name), ...(seen ? { seen } : {}) }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Never reset away unpublished profiles or read receipts. A rejected push is
|
|
569
|
+
// retried on the latest branch, merging only the dedicated JSON user records.
|
|
570
|
+
function syncUsers(remote) {
|
|
571
|
+
if (!remote) return
|
|
572
|
+
const exists = gitUsers(['ls-remote', '--exit-code', remote, `refs/heads/${USERS_BRANCH}`])
|
|
573
|
+
if (exists.code === 2) return
|
|
574
|
+
if (exists.code !== 0) throw new Error(exists.err || '사용자 정보 원격을 확인하지 못했습니다.')
|
|
575
|
+
mustUsers(['fetch', '--quiet', remote, USERS_BRANCH])
|
|
576
|
+
if (gitUsers(['merge', '--ff-only', '--quiet', 'FETCH_HEAD']).code === 0) return
|
|
577
|
+
const merged = gitUsers(['merge', '--no-commit', '--no-ff', '--allow-unrelated-histories', 'FETCH_HEAD'])
|
|
578
|
+
const conflicts = mustUsers(['diff', '--name-only', '--diff-filter=U']).split('\n').filter(Boolean)
|
|
579
|
+
try {
|
|
580
|
+
for (const f of conflicts) {
|
|
581
|
+
if (!/^users\/[^/]+\.json$/.test(f)) throw new Error(`사용자 정보 충돌을 자동으로 풀 수 없습니다: ${f}`)
|
|
582
|
+
const local = JSON.parse(mustUsers(['show', `:2:${f}`]))
|
|
583
|
+
const incoming = JSON.parse(mustUsers(['show', `:3:${f}`]))
|
|
584
|
+
fs.writeFileSync(path.join(USERS_WT, f), JSON.stringify(mergeState(local, incoming), null, 2) + '\n')
|
|
585
|
+
mustUsers(['add', '--', f])
|
|
586
|
+
}
|
|
587
|
+
if (merged.code !== 0 && !conflicts.length) throw new Error(merged.err || merged.out)
|
|
588
|
+
mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: merge user profiles'])
|
|
589
|
+
} catch (e) {
|
|
590
|
+
gitUsers(['merge', '--abort'])
|
|
591
|
+
throw e
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function withUsers(action) {
|
|
596
|
+
if (!COMMON) return false
|
|
597
|
+
return withRepoLock(path.join(COMMON, 'axmap-users.lock'), () => {
|
|
598
|
+
if (!openUsers()) throw new Error('사용자 정보 브랜치를 준비하지 못했습니다.')
|
|
599
|
+
return action()
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
542
603
|
function pullUsers() {
|
|
543
|
-
if (!
|
|
544
|
-
const stampFile = path.join(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
604
|
+
if (!REPO) return
|
|
605
|
+
const stampFile = path.join(DATA_HOME, '.axmap', '.users-lastfetch')
|
|
606
|
+
try { if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return } catch { /* first sync */ }
|
|
607
|
+
try {
|
|
608
|
+
withUsers(() => {
|
|
609
|
+
// A fresh local-only orphan still has its seed files unstaged.
|
|
610
|
+
if (gitUsers(['status', '--porcelain']).out) {
|
|
611
|
+
mustUsers(['add', '-A'])
|
|
612
|
+
mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
|
|
613
|
+
}
|
|
614
|
+
syncUsers(usersRemote())
|
|
615
|
+
fs.writeFileSync(stampFile, '')
|
|
616
|
+
})
|
|
617
|
+
} catch { /* Read receipts remain conservative when synchronization fails. */ }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function updateUser(email, change) {
|
|
621
|
+
if (emailError(email)) throw new Error(emailError(email))
|
|
622
|
+
return withUsers(() => {
|
|
623
|
+
const remote = usersRemote()
|
|
624
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
625
|
+
if (gitUsers(['status', '--porcelain']).out) {
|
|
626
|
+
mustUsers(['add', '-A'])
|
|
627
|
+
mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
|
|
628
|
+
}
|
|
629
|
+
syncUsers(remote)
|
|
630
|
+
const f = path.join(USERS_WT, 'users', `${email}.json`)
|
|
631
|
+
let cur = {}
|
|
632
|
+
try { cur = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { if (e.code !== 'ENOENT') throw e }
|
|
633
|
+
const next = change(cur)
|
|
634
|
+
if (JSON.stringify(cur) !== JSON.stringify(next)) {
|
|
635
|
+
fs.mkdirSync(path.dirname(f), { recursive: true })
|
|
636
|
+
fs.writeFileSync(f, JSON.stringify(next, null, 2) + '\n')
|
|
637
|
+
mustUsers(['add', '--', `users/${email}.json`])
|
|
638
|
+
mustUsers(['commit', '--quiet', '--no-verify', '-m', `user: ${email}`])
|
|
639
|
+
}
|
|
640
|
+
if (!remote) return 'local'
|
|
641
|
+
const pushed = gitUsers(['push', '--quiet', remote, `HEAD:refs/heads/${USERS_BRANCH}`])
|
|
642
|
+
if (pushed.code === 0) {
|
|
643
|
+
fs.writeFileSync(path.join(DATA_HOME, '.axmap', '.users-lastfetch'), '')
|
|
644
|
+
return 'shared'
|
|
645
|
+
}
|
|
646
|
+
if (attempt === 4) throw new Error(pushed.err || '사용자 정보 push 실패')
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function publishProfile() {
|
|
652
|
+
const profile = readProfile()
|
|
653
|
+
if (!profile || !REPO) return null
|
|
654
|
+
const email = myEmail()
|
|
655
|
+
if (emailError(email)) throw new Error(emailError(email))
|
|
656
|
+
// Successful unchanged profiles need no network more than once a minute.
|
|
657
|
+
const stamp = path.join(DATA_HOME, '.axmap', '.profile-shared.json')
|
|
658
|
+
const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
|
|
659
|
+
const key = JSON.stringify([profile.name, revision, email, me(), usersRemote()])
|
|
548
660
|
try {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
661
|
+
const last = JSON.parse(fs.readFileSync(stamp, 'utf8'))
|
|
662
|
+
if (last.key === key && Date.now() - last.at < 60_000) return last.result
|
|
663
|
+
} catch { /* first use / changed identity */ }
|
|
664
|
+
const result = updateUser(email, (cur) => {
|
|
665
|
+
// An older PC must not undo a name correction made on another PC just
|
|
666
|
+
// because it became active later. Revision belongs to setup, not to use.
|
|
667
|
+
const name = (cur.profileUpdatedAt ?? '') > revision ? cur.name : profile.name
|
|
668
|
+
const aliases = [...new Set([...(cur.aliases ?? []), cur.name, profile.name, me()].filter((n) => n && n !== name))]
|
|
669
|
+
return { ...cur, name, aliases,
|
|
670
|
+
profileUpdatedAt: (cur.profileUpdatedAt ?? '') > revision ? cur.profileUpdatedAt : revision }
|
|
671
|
+
})
|
|
672
|
+
rosterCache = null
|
|
673
|
+
fs.writeFileSync(stamp, JSON.stringify({ key, at: Date.now(), result }) + '\n')
|
|
674
|
+
if (result === 'local') console.error('※ 쪽지 프로필을 로컬에만 저장했습니다 — 원격이 없어 다른 팀원에게는 아직 보이지 않습니다.')
|
|
675
|
+
return result
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function messageName() {
|
|
679
|
+
const profile = readProfile()
|
|
680
|
+
if (!profile) return me()
|
|
681
|
+
try {
|
|
682
|
+
const state = JSON.parse(fs.readFileSync(path.join(USERS_WT, 'users', `${myEmail()}.json`), 'utf8'))
|
|
683
|
+
const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
|
|
684
|
+
if ((state.profileUpdatedAt ?? '') > revision && state.name) return state.name
|
|
685
|
+
} catch { /* unpublished profile / custom standalone mailbox */ }
|
|
686
|
+
return profile.name
|
|
555
687
|
}
|
|
556
688
|
|
|
557
689
|
function readSeen(who) {
|
|
@@ -564,7 +696,12 @@ function readSeen(who) {
|
|
|
564
696
|
// 그건 이 도구가 고치려던 바로 그 상태다.
|
|
565
697
|
let legacy = ''
|
|
566
698
|
if (SEEN_FILE) {
|
|
567
|
-
try {
|
|
699
|
+
try {
|
|
700
|
+
const known = addressesOf(who)
|
|
701
|
+
for (const [name, id] of Object.entries(JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')))) {
|
|
702
|
+
if (known.has(norm(name)) && id > legacy) legacy = id
|
|
703
|
+
}
|
|
704
|
+
} catch { /* no legacy receipts */ }
|
|
568
705
|
}
|
|
569
706
|
return fromBranch > legacy ? fromBranch : legacy
|
|
570
707
|
}
|
|
@@ -592,42 +729,15 @@ function markSeen(who, id) {
|
|
|
592
729
|
} catch { /* 조용히 */ }
|
|
593
730
|
}
|
|
594
731
|
|
|
595
|
-
|
|
732
|
+
const email = myEmail() || userKey(who)
|
|
596
733
|
try {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
// 지금 쓰고 있는 이름이 이메일도 아니고 git 이 아는 이름도 아니면, 그것은
|
|
605
|
-
// 이 사람이 띄운 도구의 이름이다 (codex-jinmiri · jaehyeon-2 처럼). 적어
|
|
606
|
-
// 두면 다음부터 그 이름으로 온 쪽지도 이 사람 것이 된다.
|
|
607
|
-
const known = addressesOf(who)
|
|
608
|
-
const now = norm(me())
|
|
609
|
-
const aliases = [...new Set([...(cur.aliases ?? []), ...(known.has(now) ? [] : [now])])]
|
|
610
|
-
|
|
611
|
-
// 이름도 같이 남긴다 — 파일 이름이 이메일이라 사람이 열었을 때 누구인지 보이게.
|
|
612
|
-
fs.writeFileSync(f, JSON.stringify({ ...cur, name: me(), aliases, seen: id }, null, 2) + '\n')
|
|
613
|
-
|
|
614
|
-
gitUsers(['add', '--', path.relative(USERS_WT, f).replace(/\\/g, '/')])
|
|
615
|
-
// --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
|
|
616
|
-
gitUsers(['commit', '--quiet', '--no-verify', '-m', `seen: ${userKey(who)}`])
|
|
617
|
-
|
|
618
|
-
// 🔴 push 는 **60초에 한 번**만 시도한다. 커밋은 이 PC 안이라 싸지만 push 는
|
|
619
|
-
// 통신이고, 쪽지를 볼 때마다 붙으면 모든 도구 호출이 그만큼 느려진다.
|
|
620
|
-
// 늦게 가도 손해가 없다 — 다른 PC 에서 그 쪽지가 한 번 더 뜰 뿐이다.
|
|
621
|
-
const stamp = path.join(REPO, '.axmap', '.users-lastpush')
|
|
622
|
-
let due = true
|
|
623
|
-
try { due = Date.now() - fs.statSync(stamp).mtimeMs >= 60_000 } catch { /* 처음이다 */ }
|
|
624
|
-
if (due) {
|
|
625
|
-
const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
|
|
626
|
-
if (gitUsers(['push', '--quiet', remote, `HEAD:${USERS_BRANCH}`]).code === 0) {
|
|
627
|
-
try { fs.writeFileSync(stamp, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
} catch { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
|
|
734
|
+
updateUser(email, (cur) => {
|
|
735
|
+
const profile = readProfile()
|
|
736
|
+
const name = profile ? messageName() : (cur.name || me())
|
|
737
|
+
const aliases = [...new Set([...(cur.aliases ?? []), me()].filter((n) => n !== name))]
|
|
738
|
+
return { ...cur, name, aliases, seen: (cur.seen ?? '') > id ? cur.seen : id }
|
|
739
|
+
})
|
|
740
|
+
} catch { /* Failed read receipts cause a repeat, never a hidden message. */ }
|
|
631
741
|
}
|
|
632
742
|
|
|
633
743
|
const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
@@ -734,7 +844,7 @@ function stdin() {
|
|
|
734
844
|
}
|
|
735
845
|
|
|
736
846
|
function post({ to, subject, body, replyTo = null }) {
|
|
737
|
-
const from =
|
|
847
|
+
const from = messageName()
|
|
738
848
|
// 부르는 쪽이 하나를 주든 여럿을 주든 여기서는 목록 하나로 본다.
|
|
739
849
|
let tos = (Array.isArray(to) ? to : (to == null ? [] : [to])).map((s) => String(s).trim()).filter(Boolean)
|
|
740
850
|
|
|
@@ -761,15 +871,17 @@ function post({ to, subject, body, replyTo = null }) {
|
|
|
761
871
|
// 나중에 `ensureBus` 가 "정상적인 쪽지함이 아닙니다 — 지운 뒤 다시 하세요"
|
|
762
872
|
// 라고 안내한다. **안 간 쪽지를 지우라고 시키는 것**이다. 여기서 멈추면
|
|
763
873
|
// 사람은 init 한 번 하고 다시 보내면 된다 — 잃는 것이 없다.
|
|
764
|
-
if (!
|
|
765
|
-
|
|
766
|
-
'쪽지함이 아직 없습니다. 한 번만 준비하면 됩니다:\n' +
|
|
767
|
-
' axmap init (MCP 에서는 ax_init)\n\n' +
|
|
768
|
-
'준비 전에 쪽지를 쓰지 않습니다 — 여기 남으면 아무에게도 안 가고,\n' +
|
|
769
|
-
'나중에 쪽지함을 만들 때 지워야 할 것으로 보입니다.',
|
|
770
|
-
)
|
|
874
|
+
if (!openBus()) {
|
|
875
|
+
reportBusProblem()
|
|
771
876
|
process.exit(1)
|
|
772
877
|
}
|
|
878
|
+
for (const one of tos) {
|
|
879
|
+
const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
|
|
880
|
+
if (norm(one) !== 'all' && !norm(one).includes('@') && matches.length > 1) {
|
|
881
|
+
console.error(`같은 이름의 수신자가 여러 명입니다: ${one}\n이메일로 지정하세요: ${matches.map((p) => p.email).join(', ')}`)
|
|
882
|
+
process.exit(1)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
773
885
|
// 🔴 **모르는 주소로는 안 보낸다.**
|
|
774
886
|
//
|
|
775
887
|
// 지금까지는 받는 사람 이름을 아무도 검사하지 않았다. 오타 하나면 그 쪽지는
|
|
@@ -785,7 +897,7 @@ function post({ to, subject, body, replyTo = null }) {
|
|
|
785
897
|
if (norm(one) === 'all') continue
|
|
786
898
|
const known = roster().some((p) => p.email === norm(one) || p.names.has(norm(one)))
|
|
787
899
|
if (!known) {
|
|
788
|
-
const near = nearMatches(one)
|
|
900
|
+
const near = norm(one).includes('@') ? [] : nearMatches(one)
|
|
789
901
|
if (near.length) {
|
|
790
902
|
// 🔴 **거부하는 것은 오타뿐이다.** 아는 이름과 한 글자 차이거나 그 이름의
|
|
791
903
|
// 앞부분이면 새 사람이 아니라 손이 미끄러진 것이다. 실측에서 실제로
|
|
@@ -806,9 +918,15 @@ function post({ to, subject, body, replyTo = null }) {
|
|
|
806
918
|
}
|
|
807
919
|
}
|
|
808
920
|
|
|
921
|
+
// Resolve labels now: later profile corrections cannot redirect an old note.
|
|
922
|
+
tos = [...new Set(tos.map((one) => {
|
|
923
|
+
if (norm(one) === 'all') return 'all'
|
|
924
|
+
const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
|
|
925
|
+
return matches.length === 1 ? matches[0].email : one
|
|
926
|
+
}))]
|
|
809
927
|
fs.mkdirSync(BOX, { recursive: true })
|
|
810
928
|
const now = new Date()
|
|
811
|
-
const id = `${stamp(now)}-${from}-${slug(subject)}`
|
|
929
|
+
const id = `${stamp(now)}-${from}-${slug(subject)}-${randomUUID().slice(0, 8)}`
|
|
812
930
|
// 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
|
|
813
931
|
// 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
|
|
814
932
|
// 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
|
|
@@ -918,7 +1036,7 @@ function selfCmd() {
|
|
|
918
1036
|
function list() {
|
|
919
1037
|
pull()
|
|
920
1038
|
const all = readAll()
|
|
921
|
-
const to = has('--mine') ? me() : flag('--to')
|
|
1039
|
+
const to = has('--mine') ? (myEmail() || me()) : flag('--to')
|
|
922
1040
|
const from = flag('--from')
|
|
923
1041
|
|
|
924
1042
|
// 🔴 **한 사람에게는 주소가 여럿이다.** `--to yeaseung-lee` 로 물어도
|
|
@@ -958,7 +1076,7 @@ function list() {
|
|
|
958
1076
|
// **내 안 읽은 쪽지로 다시 잡힌다.** 주소 전부와 견준다.
|
|
959
1077
|
const mine = has('--mine') ? myAddresses() : addressesOf(who)
|
|
960
1078
|
rows = rows.filter((m) => m.id > seen
|
|
961
|
-
&& !mine.has(norm(m.
|
|
1079
|
+
&& !(m.fromEmail ? mine.has(norm(m.fromEmail)) : mine.has(norm(m.from))))
|
|
962
1080
|
}
|
|
963
1081
|
|
|
964
1082
|
// 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
|
|
@@ -1054,11 +1172,25 @@ function seen() {
|
|
|
1054
1172
|
console.error(`찍을 쪽지 id 를 주세요. 예: ${selfCmd()} seen <아이디> [<아이디>…]`)
|
|
1055
1173
|
process.exit(1)
|
|
1056
1174
|
}
|
|
1057
|
-
markSeen(has('--mine') ? me() : (flag('--to') || me()),
|
|
1175
|
+
markSeen(has('--mine') ? (myEmail() || me()) : (flag('--to') || me()),
|
|
1058
1176
|
ids.reduce((hi, id) => (id > hi ? id : hi), ''))
|
|
1059
1177
|
}
|
|
1060
1178
|
|
|
1179
|
+
let profileResult = null
|
|
1180
|
+
if (['profile', 'contacts', 'post', 'reply', 'list', 'read'].includes(cmd)) {
|
|
1181
|
+
try { profileResult = publishProfile() } catch (e) {
|
|
1182
|
+
console.error(`경고: 쪽지 프로필을 공유하지 못했습니다 — ${e.message}`)
|
|
1183
|
+
if (cmd === 'profile') process.exit(1)
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1061
1187
|
switch (cmd) {
|
|
1188
|
+
case 'profile':
|
|
1189
|
+
if (profileResult) console.log(`쪽지 프로필: ${profileResult}`)
|
|
1190
|
+
break
|
|
1191
|
+
case 'contacts':
|
|
1192
|
+
for (const p of roster()) console.log(`${p.displayName || [...p.names][0] || p.email}\t${p.email}`)
|
|
1193
|
+
break
|
|
1062
1194
|
case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
|
|
1063
1195
|
case 'reply': {
|
|
1064
1196
|
const target = args[1]
|
|
@@ -1068,7 +1200,7 @@ switch (cmd) {
|
|
|
1068
1200
|
const src = readAll().find((x) => x.id === target || x.id.includes(target))
|
|
1069
1201
|
if (!src && busProblem) { reportBusProblem(); process.exit(1) }
|
|
1070
1202
|
if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
|
|
1071
|
-
post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
|
|
1203
|
+
post({ to: src.fromEmail || src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
|
|
1072
1204
|
break
|
|
1073
1205
|
}
|
|
1074
1206
|
case 'list': list(); break
|
|
@@ -1087,6 +1219,7 @@ switch (cmd) {
|
|
|
1087
1219
|
${selfCmd()} seen <아이디> [<아이디>…] 보여준 쪽지만 읽음으로 찍는다
|
|
1088
1220
|
${selfCmd()} reply <아이디> < 본문.md
|
|
1089
1221
|
${selfCmd()} who 지금 누가 무엇을 잡고 있나
|
|
1222
|
+
${selfCmd()} contacts 이 저장소의 쪽지 수신자 이름·이메일
|
|
1090
1223
|
|
|
1091
1224
|
목록을 잘라서 보여주는 쪽은 --no-mark 로 읽기만 하고, 자기가 그린 아이디만
|
|
1092
1225
|
seen 에 넘기세요. 안 그러면 화면에 뜬 적 없는 쪽지가 읽음이 되어 안 보입니다.
|
package/tools/setup.mjs
CHANGED
|
@@ -11,7 +11,9 @@ import fs from 'node:fs'
|
|
|
11
11
|
import os from 'node:os'
|
|
12
12
|
import path from 'node:path'
|
|
13
13
|
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { createInterface } from 'node:readline/promises'
|
|
14
15
|
|
|
16
|
+
import { displayNameError, readProfile, writeProfile, profilePath } from '../src/identity.mjs'
|
|
15
17
|
import { agentNameError } from '../src/protocol.mjs'
|
|
16
18
|
|
|
17
19
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -58,7 +60,7 @@ axmap setup — 어느 폴더에서든 이 PC에 axMap을 전역 설치·등록
|
|
|
58
60
|
--repo <경로> 선택 사항: 이 저장소의 장부·커밋 훅도 명시적으로 준비한다
|
|
59
61
|
--remote <이름|URL> 장부를 둘 원격. git 주소를 주면, 그 주소를 가리키는 원격이
|
|
60
62
|
없을 때 'axmap' 이라는 이름으로 하나 더한다
|
|
61
|
-
--name <이름>
|
|
63
|
+
--name <이름> 쪽지 표시 이름을 저장한다 (대화형 실행에서는 직접 입력)
|
|
62
64
|
--dest <경로> 프로그램을 둘 자리 (기본: ~/.axmap/app)
|
|
63
65
|
--commands <곳> 슬래시 명령을 둘 자리: global(기본) · project · none
|
|
64
66
|
--hook 프롬프트마다 쪽지를 받는 하네스 훅을 settings.json 에 더한다
|
|
@@ -113,12 +115,14 @@ if (projectRequested) {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
// ── 3. 이름 ─────────────────────────────────────────────────────────────────
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// 판정은 자기 claim 을 겹침으로 보지 않으므로 팀 전원이 서로의 영역을 아무
|
|
119
|
-
// 경고 없이 덮어쓴다. 그래서 이 PC 에서 이미 사람마다 다른 값만 후보로 쓴다.
|
|
118
|
+
// Display labels are independent of claim ownership. The saved label is the
|
|
119
|
+
// default on upgrades; a first setup starts from this person's Git identity.
|
|
120
120
|
let name = flag('--name')
|
|
121
121
|
let nameFrom = '--name'
|
|
122
|
+
if (has('--name') && !name) die('--name 뒤에 표시 이름을 지정하세요.')
|
|
123
|
+
let saved
|
|
124
|
+
try { saved = readProfile() } catch (e) { die(e.message) }
|
|
125
|
+
if (!name && saved) { name = saved.name; nameFrom = '저장된 표시 이름' }
|
|
122
126
|
if (!name) {
|
|
123
127
|
const g = git(REPO ? ['config', 'user.name'] : ['config', '--global', 'user.name'], REPO ?? os.homedir())
|
|
124
128
|
if (g.code === 0 && g.out) { name = g.out; nameFrom = REPO ? 'git config user.name' : 'git config --global user.name' }
|
|
@@ -129,10 +133,36 @@ if (!name) {
|
|
|
129
133
|
if (!name) { name = os.hostname(); nameFrom = '호스트 이름' }
|
|
130
134
|
if (!name) die('이 PC 에서 쓸 이름을 정하지 못했습니다. --name "홍길동" 으로 직접 주세요.')
|
|
131
135
|
|
|
136
|
+
if (!has('--name') && !DRY && process.stdin.isTTY && process.stdout.isTTY) {
|
|
137
|
+
const input = createInterface({ input: process.stdin, output: process.stdout })
|
|
138
|
+
try {
|
|
139
|
+
for (;;) {
|
|
140
|
+
const answer = (await input.question(`쪽지에 표시할 이름 [${name}]: `)).trim().normalize('NFC')
|
|
141
|
+
const candidate = answer || name
|
|
142
|
+
const error = displayNameError(candidate)
|
|
143
|
+
if (error) { say(error); continue }
|
|
144
|
+
name = candidate
|
|
145
|
+
nameFrom = '표시 이름 입력'
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
} finally { input.close() }
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
// 검사기를 다시 짜지 않는다. 여기서 통과시킨 이름을 서버가 거부하면 설치가 거짓말이 된다.
|
|
133
|
-
const nameErr =
|
|
134
|
-
if (nameErr) die(`이 이름은
|
|
135
|
-
|
|
152
|
+
const nameErr = displayNameError(name)
|
|
153
|
+
if (nameErr) die(`이 표시 이름은 쓸 수 없습니다: ${name}\n\n${nameErr}`)
|
|
154
|
+
if (!DRY) {
|
|
155
|
+
try { writeProfile(name) } catch (e) { die(e.message) }
|
|
156
|
+
}
|
|
157
|
+
ok('쪽지 이름', `${name} (${nameFrom}${DRY ? ', 저장 예정' : ''})`)
|
|
158
|
+
const email = git(REPO ? ['config', 'user.email'] : ['config', '--global', 'user.email'], REPO ?? os.homedir()).out
|
|
159
|
+
if (email) ok('Git 이메일', `${email} — 실제 쪽지 주소는 작업 repo의 Git 설정을 사용합니다`)
|
|
160
|
+
else hm('Git 이메일', '작업 repo의 git config user.email을 사용합니다. 이메일은 별도로 저장하지 않습니다.')
|
|
161
|
+
say(` 이름 변경: axmap setup --name "새 이름" (${profilePath()})`)
|
|
162
|
+
// Changing a message label must not change ownership of existing claims.
|
|
163
|
+
const agentIdentity = git(REPO ? ['config', 'user.name'] : ['config', '--global', 'user.name'], REPO ?? os.homedir()).out
|
|
164
|
+
|| (() => { try { return os.userInfo().username } catch { return os.hostname() } })()
|
|
165
|
+
if (agentNameError(agentIdentity)) die('Git 사용자 이름을 선점 이름으로 쓸 수 없습니다. git config user.name을 확인하세요.')
|
|
136
166
|
|
|
137
167
|
// ── 4. 장부를 둘 원격 ───────────────────────────────────────────────────────
|
|
138
168
|
//
|
|
@@ -211,8 +241,8 @@ function installAndRegister() {
|
|
|
211
241
|
|
|
212
242
|
say('')
|
|
213
243
|
const r = win
|
|
214
|
-
? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Source', SOURCE_ROOT, '-Dest', DEST, '-Name',
|
|
215
|
-
: spawnSync('bash', [script, '--source', SOURCE_ROOT, '--dest', DEST, '--name',
|
|
244
|
+
? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Source', SOURCE_ROOT, '-Dest', DEST, '-Name', agentIdentity], { cwd: os.homedir(), stdio: 'inherit', windowsHide: true })
|
|
245
|
+
: spawnSync('bash', [script, '--source', SOURCE_ROOT, '--dest', DEST, '--name', agentIdentity], { cwd: os.homedir(), stdio: 'inherit' })
|
|
216
246
|
say('')
|
|
217
247
|
|
|
218
248
|
if (r.status === 0) ok('설치·등록', DEST)
|
|
@@ -241,7 +271,7 @@ function ledgerAndHook() {
|
|
|
241
271
|
stdio: 'inherit',
|
|
242
272
|
// 이름을 넘겨서 판정을 한 번으로 못 박는다. 여기서 통과한 이름과 나중에 CLI 가
|
|
243
273
|
// 스스로 고르는 이름이 다르면, 자기가 잡은 것을 자기가 반납하지 못한다.
|
|
244
|
-
env: { ...process.env, AXMAP_AGENT:
|
|
274
|
+
env: { ...process.env, AXMAP_AGENT: agentIdentity },
|
|
245
275
|
windowsHide: true,
|
|
246
276
|
})
|
|
247
277
|
|