axmap-cli 1.3.0 → 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/.claude/commands/ax-setup.md +9 -12
- package/README.ko.md +13 -7
- package/README.md +11 -7
- package/bin/axmap.mjs +56 -21
- package/mcp/README.md +6 -18
- package/mcp/SETUP-FOR-AI.md +2 -3
- package/mcp/install.ps1 +2 -1
- package/mcp/install.sh +2 -2
- 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/mcp-register.mjs +44 -14
- package/tools/setup.mjs +67 -74
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/mcp-register.mjs
CHANGED
|
@@ -105,6 +105,7 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
|
105
105
|
*/
|
|
106
106
|
const sameEntry = (cur, want) =>
|
|
107
107
|
!!cur && cur.command === want.command &&
|
|
108
|
+
!cur.cwd && !Object.hasOwn(cur.env ?? {}, 'AXMAP_REPO') &&
|
|
108
109
|
JSON.stringify(cur.args || []) === JSON.stringify(want.args) &&
|
|
109
110
|
Object.entries(want.env).every(([k, v]) => ((cur.env || {})[k] === v))
|
|
110
111
|
|
|
@@ -118,6 +119,8 @@ function doClaude(bin, repo) {
|
|
|
118
119
|
snippetClaude()
|
|
119
120
|
return
|
|
120
121
|
}
|
|
122
|
+
want.env = { ...(cur?.env ?? {}), ...want.env }
|
|
123
|
+
delete want.env.AXMAP_REPO
|
|
121
124
|
if (sameEntry(cur, want)) { ok('claude', '이미 등록돼 있습니다 (user 범위)'); return }
|
|
122
125
|
|
|
123
126
|
// 문서에 있다고 믿고 부르지 않는다 — 먼저 물어본다. doCodex 와 같다.
|
|
@@ -133,7 +136,7 @@ function doClaude(bin, repo) {
|
|
|
133
136
|
'--', want.command, ...want.args]
|
|
134
137
|
|
|
135
138
|
if (DRY) {
|
|
136
|
-
hm('claude',
|
|
139
|
+
hm('claude', '(dry-run) Claude 사용자 설정의 axmap 서버 경로·환경을 갱신합니다')
|
|
137
140
|
return
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -220,6 +223,8 @@ function mergeJson(label, file) {
|
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
const servers = cfg.mcpServers ?? {}
|
|
226
|
+
want.env = { ...(servers.axmap?.env ?? {}), ...want.env }
|
|
227
|
+
delete want.env.AXMAP_REPO
|
|
223
228
|
if (same(servers.axmap, want)) { ok(label, `이미 등록돼 있습니다: ${file}`); return }
|
|
224
229
|
|
|
225
230
|
const had = servers.axmap !== undefined
|
|
@@ -261,31 +266,56 @@ function snippetJson() {
|
|
|
261
266
|
// 🔴 TOML 을 직접 파싱하지 않는다. 파서를 새로 짜는 것은 이 저장소의 "의존성 없음" 과
|
|
262
267
|
// "판정을 복잡하게 만들지 않는다" 에 둘 다 어긋나고, 어설픈 파서가 남의 config.toml 을
|
|
263
268
|
// 깨뜨리는 것이 이 작업에서 나올 수 있는 최악의 결과다. 쓰는 일은 codex 자신에게 맡긴다.
|
|
264
|
-
function doCodex(bin) {
|
|
265
|
-
const run = (args) => spawnSync(bin, args, { encoding: 'utf8', windowsHide: true })
|
|
269
|
+
export function doCodex(bin, { run: injectedRun, dry = DRY } = {}) {
|
|
270
|
+
const run = injectedRun ?? ((args) => spawnSync(bin, args, { cwd: os.homedir(), encoding: 'utf8', windowsHide: true }))
|
|
266
271
|
|
|
267
272
|
const help = run(['mcp', '--help'])
|
|
268
273
|
if (help.status !== 0 || !/\badd\b/.test(`${help.stdout}${help.stderr}`)) {
|
|
269
274
|
hm('codex', "'codex mcp add' 를 확인하지 못했습니다 — 손으로 넣으세요")
|
|
270
275
|
snippetToml()
|
|
271
|
-
return
|
|
276
|
+
return false
|
|
272
277
|
}
|
|
273
278
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
// Check the actual command and environment, not just the server name. An old
|
|
280
|
+
// registration may still point at a temporary install or pin AXMAP_REPO.
|
|
281
|
+
const list = run(['mcp', 'list', '--json'])
|
|
282
|
+
let servers
|
|
283
|
+
try { servers = JSON.parse(list.stdout) } catch { /* reported below */ }
|
|
284
|
+
if (list.status !== 0 || !Array.isArray(servers)) {
|
|
285
|
+
no('codex', '기존 MCP 설정을 확인하지 못했습니다 — 설정을 덮어쓰지 않았습니다')
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
const current = servers.find((s) => s.name === 'axmap')
|
|
289
|
+
const transport = current?.transport
|
|
290
|
+
const want = entry()
|
|
291
|
+
const wantedEnv = { ...(transport?.env ?? {}), ...want.env }
|
|
292
|
+
delete wantedEnv.AXMAP_REPO
|
|
293
|
+
if (transport?.type === 'stdio' && sameEntry(transport, { ...want, env: wantedEnv }) &&
|
|
294
|
+
!transport.cwd && !Object.hasOwn(transport.env ?? {}, 'AXMAP_REPO') && current.enabled !== false) {
|
|
295
|
+
ok('codex', '전역 등록이 최신입니다 — 실행 위치의 repo를 사용합니다')
|
|
296
|
+
return true
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
const add = ['mcp', 'add', 'axmap',
|
|
282
|
-
if (
|
|
299
|
+
const add = ['mcp', 'add', 'axmap', ...Object.entries(wantedEnv).flatMap(([k, v]) => ['--env', `${k}=${v}`]), '--', want.command, ...want.args]
|
|
300
|
+
if (dry) { hm('codex', '(dry-run) codex mcp add로 전역 axmap 서버 경로·환경을 갱신합니다'); return true }
|
|
283
301
|
|
|
284
302
|
const r = run(add)
|
|
285
|
-
if (r.status === 0)
|
|
303
|
+
if (r.status === 0) {
|
|
304
|
+
const check = run(['mcp', 'list', '--json'])
|
|
305
|
+
let saved
|
|
306
|
+
try { saved = JSON.parse(check.stdout)?.find((s) => s.name === 'axmap') } catch { /* below */ }
|
|
307
|
+
if (check.status === 0 && sameEntry(saved?.transport, { ...want, env: wantedEnv }) &&
|
|
308
|
+
!saved.transport.cwd && !Object.hasOwn(saved.transport.env ?? {}, 'AXMAP_REPO')) {
|
|
309
|
+
ok('codex', '전역 등록을 갱신했습니다 — 실행 위치의 repo를 사용합니다')
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
no('codex', '등록 후 서버 경로·작업 폴더 설정이 일치하지 않습니다')
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
286
315
|
else {
|
|
287
316
|
no('codex', `codex mcp add 가 실패했습니다 (${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`})`)
|
|
288
317
|
snippetToml()
|
|
318
|
+
return false
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
|
|
@@ -339,7 +369,7 @@ function main() {
|
|
|
339
369
|
any = true
|
|
340
370
|
if (a.id === 'claude') doClaude(bin, root)
|
|
341
371
|
else if (a.id === 'agy') doAgy()
|
|
342
|
-
else if (a.id === 'codex'
|
|
372
|
+
else if (a.id === 'codex' && !doCodex(bin)) return 1
|
|
343
373
|
}
|
|
344
374
|
|
|
345
375
|
if (!any) hm('CLI', '이 PC 에서 AI CLI 를 찾지 못했습니다 — 나중에 다시 실행하세요')
|
|
@@ -350,4 +380,4 @@ function main() {
|
|
|
350
380
|
return 0
|
|
351
381
|
}
|
|
352
382
|
|
|
353
|
-
process.exit(main())
|
|
383
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exit(main())
|