axmap-cli 1.8.0 → 1.9.0
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-update.md +0 -3
- package/README.ko.md +15 -0
- package/README.md +15 -0
- package/bin/axmap.mjs +149 -88
- package/governance/vote.mjs +6 -69
- package/mcp/README.md +2 -0
- package/mcp/server.mjs +22 -7
- package/package.json +1 -1
- package/src/bridge.mjs +168 -56
- package/src/bridge.mts +104 -12
- package/src/managed-session.mjs +4 -3
- package/src/managed-session.mts +4 -3
- package/src/session-mailbox.mjs +21 -11
- package/src/session-mailbox.mts +13 -8
- package/src/session-types.mjs +3 -0
- package/src/session-types.mts +9 -1
- package/src/terminal-bridge.mjs +13 -7
- package/src/terminal-bridge.mts +12 -7
- package/src/update.mjs +3 -20
- package/src/workspace.mjs +247 -0
- package/src/workspace.mts +220 -0
- package/tools/build.mjs +1 -1
- package/tools/bus.mjs +1 -1
- package/tools/run.mjs +8 -1
- package/src/closure.mjs +0 -343
- package/tools/vendor.mjs +0 -382
package/src/session-types.mts
CHANGED
|
@@ -4,6 +4,11 @@ export type Transport = 'console' | 'stdio' | 'pty' | 'agentapi'
|
|
|
4
4
|
export type SessionState = 'starting' | 'running' | 'idle' | 'awaiting_user' | 'settling' | 'closed' | 'unknown'
|
|
5
5
|
export type RequestState = 'sending' | 'submitted' | 'unknown' | 'completed' | 'failed' | 'superseded'
|
|
6
6
|
export type ConnectionState = 'connected' | 'closed' | 'unreachable' | 'unverified'
|
|
7
|
+
/** 관리형 세션 호스트의 생존. unverified 는 "확인하지 못함"이며 살아 있음도 죽음도 아니다. */
|
|
8
|
+
export type HostLiveness = 'alive' | 'dead' | 'unverified'
|
|
9
|
+
/** `run.mjs rpc` 가 로컬 파이프에 연결조차 못 했을 때(ENOENT·ECONNREFUSED)의 종료 코드.
|
|
10
|
+
* 이 경우에만 요청이 확실히 전달되지 않았다고 판단한다. */
|
|
11
|
+
export const RPC_NOT_DELIVERED_EXIT = 3
|
|
7
12
|
export type HookEvent = 'SessionStart' | 'PreInvocation' | 'Stop'
|
|
8
13
|
|
|
9
14
|
export interface Usage {
|
|
@@ -38,9 +43,12 @@ export interface SessionRecord {
|
|
|
38
43
|
executable?: string
|
|
39
44
|
address?: string
|
|
40
45
|
token?: string
|
|
46
|
+
/** 로컬 파이프를 연 `bridge serve`·`run` 프로세스. 비 Windows 생존 확인에 쓴다. */
|
|
47
|
+
hostPid?: number
|
|
48
|
+
closedReason?: string
|
|
41
49
|
}
|
|
42
50
|
export type PublicSession = Pick<SessionRecord, 'id' | 'provider' | 'workspaces' | 'model' | 'transport' |
|
|
43
|
-
'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'>
|
|
51
|
+
'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'> & { liveness?: HostLiveness }
|
|
44
52
|
export interface SessionSnapshot extends PublicSession {
|
|
45
53
|
lastKnownState: SessionState | null
|
|
46
54
|
state: SessionState
|
package/src/terminal-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import net from 'node:net';
|
|
|
8
8
|
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
|
-
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
|
|
11
|
+
import { bridgeHome, hostPipe, repoKey, readJSON, writeJSON } from './bridge.mjs';
|
|
12
12
|
import { errorCode, errorMessage } from './session-types.mjs';
|
|
13
13
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
14
|
const validID = (id) => /^[0-9a-f-]{36}$/i.test(id || '');
|
|
@@ -17,8 +17,8 @@ const sessionFile = (id) => {
|
|
|
17
17
|
throw new Error('잘못된 터미널 세션 ID입니다.');
|
|
18
18
|
return path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
19
19
|
};
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
|
|
21
|
+
const pipeFor = hostPipe;
|
|
22
22
|
export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
|
|
23
23
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
24
24
|
throw new Error('양의 응답 시간 한도가 필요합니다.');
|
|
@@ -28,11 +28,17 @@ export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } =
|
|
|
28
28
|
return new Promise((resolve, reject) => {
|
|
29
29
|
const socket = net.createConnection(pipeFor(id));
|
|
30
30
|
socket.setEncoding('utf8');
|
|
31
|
-
let buffer = '';
|
|
31
|
+
let buffer = '', connected = false;
|
|
32
32
|
const fail = (e) => { socket.destroy(); reject(e); };
|
|
33
33
|
socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')));
|
|
34
|
-
socket.on('error',
|
|
35
|
-
|
|
34
|
+
socket.on('error', e => {
|
|
35
|
+
// 연결 전의 ENOENT(파이프·소켓 없음)·ECONNREFUSED(듣는 쪽 없음)는 아무것도 쓰지
|
|
36
|
+
// 않았다는 증거다. 연결 뒤의 오류는 요청이 이미 건너갔을 수 있어 구별해 둔다.
|
|
37
|
+
if (!connected && ['ENOENT', 'ECONNREFUSED'].includes(errorCode(e) || ''))
|
|
38
|
+
Object.assign(e, { notDelivered: true });
|
|
39
|
+
fail(e);
|
|
40
|
+
});
|
|
41
|
+
socket.on('connect', () => { connected = true; socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'); });
|
|
36
42
|
socket.on('data', chunk => {
|
|
37
43
|
buffer += chunk;
|
|
38
44
|
if (Buffer.byteLength(buffer) > 1024 * 1024)
|
|
@@ -60,7 +66,7 @@ export async function startTerminal({ executable, args = [], provider, cwd = pro
|
|
|
60
66
|
const { spawn } = await import('node-pty');
|
|
61
67
|
const id = randomUUID(), token = randomBytes(32).toString('hex');
|
|
62
68
|
const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
|
|
63
|
-
state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' };
|
|
69
|
+
state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch', hostPid: process.pid };
|
|
64
70
|
const jobs = new Map();
|
|
65
71
|
let pending = null, draft = false, term, closed = false, stopTimer;
|
|
66
72
|
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s); };
|
package/src/terminal-bridge.mts
CHANGED
|
@@ -7,7 +7,7 @@ import net from 'node:net'
|
|
|
7
7
|
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
|
-
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
10
|
+
import { bridgeHome, hostPipe, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
11
11
|
import { errorCode, errorMessage } from './session-types.mjs'
|
|
12
12
|
import type { IPty } from 'node-pty'
|
|
13
13
|
import type { Provider, SessionRecord, LocalMessage, JobRecord, RequestState, TranscriptEntry } from './session-types.mjs'
|
|
@@ -25,8 +25,8 @@ const sessionFile = (id: string) => {
|
|
|
25
25
|
if (!validID(id)) throw new Error('잘못된 터미널 세션 ID입니다.')
|
|
26
26
|
return path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
|
|
29
|
+
const pipeFor = hostPipe
|
|
30
30
|
|
|
31
31
|
export function sendTerminal(id: string, message: LocalMessage, token: string | null = null, { timeoutMs = 10000 } = {}): Promise<unknown> {
|
|
32
32
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 응답 시간 한도가 필요합니다.')
|
|
@@ -35,11 +35,16 @@ export function sendTerminal(id: string, message: LocalMessage, token: string |
|
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
36
|
const socket = net.createConnection(pipeFor(id))
|
|
37
37
|
socket.setEncoding('utf8')
|
|
38
|
-
let buffer = ''
|
|
38
|
+
let buffer = '', connected = false
|
|
39
39
|
const fail = (e: Error) => { socket.destroy(); reject(e) }
|
|
40
40
|
socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
|
|
41
|
-
socket.on('error',
|
|
42
|
-
|
|
41
|
+
socket.on('error', e => {
|
|
42
|
+
// 연결 전의 ENOENT(파이프·소켓 없음)·ECONNREFUSED(듣는 쪽 없음)는 아무것도 쓰지
|
|
43
|
+
// 않았다는 증거다. 연결 뒤의 오류는 요청이 이미 건너갔을 수 있어 구별해 둔다.
|
|
44
|
+
if (!connected && ['ENOENT', 'ECONNREFUSED'].includes(errorCode(e) || '')) Object.assign(e, { notDelivered: true })
|
|
45
|
+
fail(e)
|
|
46
|
+
})
|
|
47
|
+
socket.on('connect', () => { connected = true; socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n') })
|
|
43
48
|
socket.on('data', chunk => {
|
|
44
49
|
buffer += chunk
|
|
45
50
|
if (Buffer.byteLength(buffer) > 1024 * 1024) return fail(new Error('로컬 응답 크기 제한 초과'))
|
|
@@ -59,7 +64,7 @@ export async function startTerminal({ executable, args = [], provider, cwd = pro
|
|
|
59
64
|
const { spawn } = await import('node-pty')
|
|
60
65
|
const id = randomUUID(), token = randomBytes(32).toString('hex')
|
|
61
66
|
const s: SessionRecord = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
|
|
62
|
-
state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' }
|
|
67
|
+
state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch', hostPid: process.pid }
|
|
63
68
|
const jobs = new Map<string, JobRecord>()
|
|
64
69
|
let pending: JobRecord | null = null, draft = false, term: IPty, closed = false, stopTimer: NodeJS.Timeout | undefined
|
|
65
70
|
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s) }
|
package/src/update.mjs
CHANGED
|
@@ -11,19 +11,9 @@
|
|
|
11
11
|
* 코드가 아니기 때문이다. 적용은 사람이 `axmap update` 를 읽고 한 줄을
|
|
12
12
|
* 직접 실행하는 것으로만 일어난다.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 그 사본이 최신인지는 npm 이 아니라 `tools/vendor.mjs` 가 정한다. 사본이
|
|
18
|
-
* 레지스트리를 쳐다보면 두 가지가 한꺼번에 나빠진다.
|
|
19
|
-
*
|
|
20
|
-
* 1. 팀 CI 가 매 파이프라인마다 바깥 네트워크에 의존하게 된다 — 벤더링이
|
|
21
|
-
* 끊으려고 했던 바로 그 연결이다
|
|
22
|
-
* 2. "새 버전이 있다" 는 안내가 팀원에게 뜨는데, 그 팀원이 할 수 있는 일은
|
|
23
|
-
* 없다. 사본은 axMap 저장소에서 `vendor.mjs` 를 다시 돌려야만 바뀐다
|
|
24
|
-
*
|
|
25
|
-
* 사본인지 아닌지는 **옆에 `SOURCE.json` 이 있는가**로 가른다. 그 파일은
|
|
26
|
-
* `tools/vendor.mjs` 가 사본에만 써 넣는다(원본 저장소 루트에는 없다).
|
|
14
|
+
* 팀 저장소 안에 이 코드의 사본(`ci/axmap/`)을 두던 시절에는 사본이면 확인을
|
|
15
|
+
* 건너뛰는 갈래가 여기 있었다. 2026-10-29 에 사본을 걷어냈으므로(DECISIONS D31)
|
|
16
|
+
* 그 갈래도 없다 — 이 코드가 도는 자리는 npm 설치본과 axMap 저장소뿐이다.
|
|
27
17
|
*/
|
|
28
18
|
|
|
29
19
|
import fs from 'node:fs'
|
|
@@ -79,13 +69,6 @@ export function compareVersions(a, b) {
|
|
|
79
69
|
return x.pre > y.pre ? 1 : -1
|
|
80
70
|
}
|
|
81
71
|
|
|
82
|
-
/**
|
|
83
|
-
* 이 axMap 이 **벤더링된 사본**인가. 사본이면 갱신 확인을 통째로 건너뛴다.
|
|
84
|
-
*
|
|
85
|
-
* @param {string} axmapRoot `bin/` · `src/` 를 담고 있는 폴더
|
|
86
|
-
*/
|
|
87
|
-
export const isVendored = (axmapRoot) => fs.existsSync(path.join(axmapRoot, 'SOURCE.json'))
|
|
88
|
-
|
|
89
72
|
/**
|
|
90
73
|
* 확인을 하지 말아야 하는 자리인가.
|
|
91
74
|
*
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Generated from src/workspace.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** 작업 폴더 → 대상 Git 저장소. MCP 서버·로컬 세션 연결·CLI가 이 한 곳에서 정한다.
|
|
3
|
+
*
|
|
4
|
+
* 사람이 AI 도구를 여는 폴더가 Git 저장소가 아닐 수 있다. 예: 한 저장소의 worktree
|
|
5
|
+
* 여러 개를 나란히 담아 둔 `robotable_hj_workspace`. 위로만 찾으면 이 폴더는
|
|
6
|
+
* "Git 저장소 아님"이 되어 연결이 끊긴다. 그래서 위로 찾기에 실패한 폴더에 한해
|
|
7
|
+
* 아래 순서로 정한다 (D30).
|
|
8
|
+
*
|
|
9
|
+
* 0. 수동 설정 — 그 폴더나 조상에 있는 `.axmap.json` 의 `{ "repo": "<경로>" }`
|
|
10
|
+
* 1. 자동 탐색 — 바로 아래 자식 폴더(깊이 1)의 Git 저장소를 공통 디렉터리로 묶어
|
|
11
|
+
* 묶음이 정확히 하나면 그 저장소
|
|
12
|
+
* 2. 모호함 — 묶음이 둘 이상이면 고르지 않고 후보를 적은 오류를 낸다
|
|
13
|
+
*
|
|
14
|
+
* 매번 디스크와 git 에게 다시 묻는다. 캐시하면 사람이 설정 파일을 쓰거나 폴더를
|
|
15
|
+
* 추가한 뒤에도 서버를 다시 켜기 전까지 옛 판단이 남는다.
|
|
16
|
+
*
|
|
17
|
+
* 읽지 못한 것과 없는 것을 구별한다. `.git` 이 있는데 git 이 읽지 못한 자식 폴더를
|
|
18
|
+
* "저장소 없음"으로 세면, 실제로는 두 저장소인데 하나로 보고 엉뚱한 장부를 잡는다.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
// bin/axmap.mjs·mcp/server.mjs 가 CLI 를 띄울 때마다 import 하므로 Node 내장만 쓴다.
|
|
24
|
+
// 그래서 session-types 의 작은 도우미도 가져오지 않는다.
|
|
25
|
+
const errorMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
26
|
+
const errorCode = (e) => e instanceof Error && 'code' in e && typeof e.code === 'string' ? e.code : undefined;
|
|
27
|
+
export const WORKSPACE_CONFIG = '.axmap.json';
|
|
28
|
+
/** 오류 문구만으로 종류를 가르지 않도록 사유를 붙인다. */
|
|
29
|
+
export class WorkspaceError extends Error {
|
|
30
|
+
reason;
|
|
31
|
+
constructor(message, reason) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 커밋 훅 안에서는 GIT_DIR 등이 설정돼 있다. 그대로 두면 자식 폴더마다 같은 저장소로
|
|
37
|
+
// 답해서 서로 다른 저장소가 하나로 묶인다. bin/axmap.mjs 의 GIT_ENV_KEYS 와 같은 목록이다.
|
|
38
|
+
const GIT_ENV_KEYS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE',
|
|
39
|
+
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_PREFIX'];
|
|
40
|
+
function gitEnv() {
|
|
41
|
+
// "not a git repository" 문구로 판정하므로 번역된 메시지가 나오지 않게 고정한다.
|
|
42
|
+
const env = { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' };
|
|
43
|
+
for (const key of GIT_ENV_KEYS)
|
|
44
|
+
delete env[key];
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
function askGit(cwd, args) {
|
|
48
|
+
const r = spawnSync('git', args, { cwd, env: gitEnv(), encoding: 'utf8', windowsHide: true });
|
|
49
|
+
if (r.error)
|
|
50
|
+
return { ok: false, notRepo: false, detail: r.error.message };
|
|
51
|
+
if (r.status === 0)
|
|
52
|
+
return { ok: true, lines: (r.stdout ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean) };
|
|
53
|
+
const detail = (r.stderr ?? '').trim() || `git 종료 코드 ${r.status}`;
|
|
54
|
+
return { ok: false, notRepo: /not a git repository/i.test(detail), detail };
|
|
55
|
+
}
|
|
56
|
+
const real = (p) => fs.realpathSync(p);
|
|
57
|
+
/** 공통 디렉터리 비교 키. bridge 의 repoKey 와 같은 규칙이다 (Windows 는 대소문자 무시). */
|
|
58
|
+
export const commonKey = (common) => process.platform === 'win32' ? common.toLowerCase() : common;
|
|
59
|
+
function statOrNull(p) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.statSync(p);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (['ENOENT', 'ENOTDIR'].includes(errorCode(e) || ''))
|
|
65
|
+
return null;
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** 폴더가 속한 Git 저장소의 최상위. 저장소 밖이면 null, 판정하지 못하면 예외. */
|
|
70
|
+
export function gitTop(dir) {
|
|
71
|
+
const stat = statOrNull(dir);
|
|
72
|
+
if (!stat || !stat.isDirectory())
|
|
73
|
+
return null;
|
|
74
|
+
const r = askGit(dir, ['rev-parse', '--show-toplevel']);
|
|
75
|
+
if (r.ok) {
|
|
76
|
+
if (!r.lines[0])
|
|
77
|
+
throw new WorkspaceError(`Git 최상위 폴더를 읽지 못했습니다: ${dir}`, 'unreadable');
|
|
78
|
+
return real(r.lines[0]);
|
|
79
|
+
}
|
|
80
|
+
if (r.notRepo)
|
|
81
|
+
return null;
|
|
82
|
+
throw new WorkspaceError(`Git 저장소인지 확인하지 못했습니다: ${dir}\n ${r.detail}`, 'unreadable');
|
|
83
|
+
}
|
|
84
|
+
/** 그 폴더나 조상에 있는 `.axmap.json`. 없으면 null. 읽지 못하면 예외. */
|
|
85
|
+
export function findWorkspaceConfig(dir) {
|
|
86
|
+
for (let at = path.resolve(dir);; at = path.dirname(at)) {
|
|
87
|
+
const candidate = path.join(at, WORKSPACE_CONFIG);
|
|
88
|
+
const stat = statOrNull(candidate);
|
|
89
|
+
if (stat)
|
|
90
|
+
return candidate;
|
|
91
|
+
if (path.dirname(at) === at)
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function manualTarget(config) {
|
|
96
|
+
let data;
|
|
97
|
+
try {
|
|
98
|
+
data = JSON.parse(fs.readFileSync(config, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
throw new WorkspaceError(`작업 폴더 설정을 읽지 못했습니다: ${config}\n ${errorMessage(e)}`, 'manual');
|
|
102
|
+
}
|
|
103
|
+
const repo = data?.repo;
|
|
104
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || typeof repo !== 'string' || !repo.trim()) {
|
|
105
|
+
throw new WorkspaceError(`작업 폴더 설정에 "repo" 경로가 없습니다: ${config}\n 예: { "repo": "폴더이름" }`, 'manual');
|
|
106
|
+
}
|
|
107
|
+
const wanted = path.resolve(path.dirname(config), repo);
|
|
108
|
+
// 설정이 틀렸을 때 자동 탐색으로 넘어가지 않는다. 사람이 고른 것을 몰래 바꾸면
|
|
109
|
+
// 다른 저장소의 장부에 선점이 남는다.
|
|
110
|
+
const top = gitTop(wanted);
|
|
111
|
+
if (!top)
|
|
112
|
+
throw new WorkspaceError(`작업 폴더 설정의 저장소가 Git 저장소가 아닙니다: ${wanted}\n (설정 파일: ${config})`, 'manual');
|
|
113
|
+
return top;
|
|
114
|
+
}
|
|
115
|
+
function scanChildren(dir) {
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
throw new WorkspaceError(`작업 폴더의 하위 폴더를 읽지 못했습니다: ${dir}\n ${errorMessage(e)}`, 'unreadable');
|
|
122
|
+
}
|
|
123
|
+
const groups = new Map(), unreadable = [];
|
|
124
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
125
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
126
|
+
continue;
|
|
127
|
+
const child = path.join(dir, entry.name);
|
|
128
|
+
try {
|
|
129
|
+
// worktree 폴더가 심볼릭 링크·junction 일 수 있어 링크는 따라가서 본다.
|
|
130
|
+
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && !!statOrNull(child)?.isDirectory());
|
|
131
|
+
if (!isDir)
|
|
132
|
+
continue;
|
|
133
|
+
// `.git` 이 없는 폴더는 저장소의 최상위가 아니다. git 을 띄우지 않고 넘어간다.
|
|
134
|
+
if (fs.lstatSync(path.join(child, '.git'), { throwIfNoEntry: false }) === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
unreadable.push(`${entry.name} (${errorMessage(e)})`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const r = askGit(child, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir']);
|
|
142
|
+
if (!r.ok || r.lines.length < 2) {
|
|
143
|
+
unreadable.push(`${entry.name} (${r.ok ? 'git 응답이 비어 있음' : r.detail})`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let top, common;
|
|
147
|
+
try {
|
|
148
|
+
top = real(r.lines[0]);
|
|
149
|
+
common = real(r.lines[1]);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
unreadable.push(`${entry.name} (${errorMessage(e)})`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const key = commonKey(common);
|
|
156
|
+
const group = groups.get(key) ?? { key, common, target: '', members: [] };
|
|
157
|
+
group.members.push({ name: entry.name, top });
|
|
158
|
+
groups.set(key, group);
|
|
159
|
+
}
|
|
160
|
+
for (const group of groups.values()) {
|
|
161
|
+
// 메인 체크아웃(공통 디렉터리 `.git` 의 부모)을 겨눈다. 장부도 거기 있다.
|
|
162
|
+
// bare 저장소처럼 그 부모가 체크아웃이 아니면 첫 번째 worktree 를 쓴다.
|
|
163
|
+
const parent = path.dirname(group.common);
|
|
164
|
+
group.target = path.basename(group.common).toLowerCase() === '.git' && statOrNull(parent)?.isDirectory()
|
|
165
|
+
? real(parent) : group.members[0].top;
|
|
166
|
+
}
|
|
167
|
+
return { groups: [...groups.values()], unreadable };
|
|
168
|
+
}
|
|
169
|
+
/** 무엇을 왜 고르는지까지 돌려준다. 모호함은 예외가 아니라 결과다 (`workspace show` 용). */
|
|
170
|
+
export function inspectTarget(dir) {
|
|
171
|
+
const from = path.resolve(dir);
|
|
172
|
+
const top = gitTop(from);
|
|
173
|
+
if (top)
|
|
174
|
+
return { kind: 'git', from, repo: top };
|
|
175
|
+
if (!statOrNull(from)?.isDirectory())
|
|
176
|
+
return { kind: 'none', from };
|
|
177
|
+
const config = findWorkspaceConfig(from);
|
|
178
|
+
if (config)
|
|
179
|
+
return { kind: 'manual', from, repo: manualTarget(config), config };
|
|
180
|
+
const { groups, unreadable } = scanChildren(from);
|
|
181
|
+
if (unreadable.length) {
|
|
182
|
+
throw new WorkspaceError([
|
|
183
|
+
`작업 폴더 ${from} 아래에서 확인하지 못한 Git 폴더가 있어 대상 저장소를 정하지 않았습니다.`,
|
|
184
|
+
...unreadable.map(s => ` - ${s}`),
|
|
185
|
+
`저장소를 직접 고르려면 이 폴더에서 \`axmap workspace use <폴더>\` 를 실행하세요.`,
|
|
186
|
+
].join('\n'), 'unreadable');
|
|
187
|
+
}
|
|
188
|
+
if (groups.length === 1)
|
|
189
|
+
return { kind: 'auto', from, repo: groups[0].target, group: groups[0] };
|
|
190
|
+
if (groups.length > 1)
|
|
191
|
+
return { kind: 'ambiguous', from, groups };
|
|
192
|
+
return { kind: 'none', from };
|
|
193
|
+
}
|
|
194
|
+
export function ambiguityMessage(r) {
|
|
195
|
+
return [
|
|
196
|
+
`작업 폴더 ${r.from} 아래에 서로 다른 Git 저장소가 ${r.groups.length}개 있어 하나로 정하지 않았습니다.`,
|
|
197
|
+
...r.groups.map(g => ` - ${g.target} (폴더: ${g.members.map(m => m.name).join(', ')})`),
|
|
198
|
+
'고르는 방법 (둘 중 하나):',
|
|
199
|
+
' 1. 도구를 부를 때 cwd 에 작업할 저장소 폴더의 절대 경로를 넘긴다.',
|
|
200
|
+
` 2. 이 폴더에 ${WORKSPACE_CONFIG} 을 둔다: { "repo": "${r.groups[0].members[0].name}" }`,
|
|
201
|
+
` 터미널에서는: axmap workspace use ${r.groups[0].members[0].name}`,
|
|
202
|
+
].join('\n');
|
|
203
|
+
}
|
|
204
|
+
/** 대상 저장소 경로. 저장소가 없으면 null, 모호하거나 판정하지 못하면 예외. */
|
|
205
|
+
export function resolveTarget(dir) {
|
|
206
|
+
const r = inspectTarget(dir);
|
|
207
|
+
if (r.kind === 'ambiguous')
|
|
208
|
+
throw new WorkspaceError(ambiguityMessage(r), 'ambiguous');
|
|
209
|
+
return r.kind === 'none' ? null : r.repo;
|
|
210
|
+
}
|
|
211
|
+
export function describeTarget(r) {
|
|
212
|
+
switch (r.kind) {
|
|
213
|
+
case 'git': return `대상 저장소: ${r.repo}\n이유: 작업 폴더가 Git 저장소 안에 있습니다.`;
|
|
214
|
+
case 'manual': return `대상 저장소: ${r.repo}\n이유: 수동 설정 ${r.config}`;
|
|
215
|
+
case 'auto': return `대상 저장소: ${r.repo}\n이유: 자동 탐색 — 하위 폴더가 모두 같은 저장소입니다 (${r.group.members.map(m => m.name).join(', ')}).`;
|
|
216
|
+
case 'ambiguous': return ambiguityMessage(r);
|
|
217
|
+
case 'none': return `대상 저장소 없음: ${r.from}\n이유: 이 폴더와 바로 아래 폴더에서 Git 저장소를 찾지 못했습니다.`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** `axmap workspace use` — 사람이 고른 저장소를 작업 폴더의 `.axmap.json` 에 적는다. */
|
|
221
|
+
export function writeWorkspaceChoice(dir, choice) {
|
|
222
|
+
const from = path.resolve(dir);
|
|
223
|
+
if (gitTop(from))
|
|
224
|
+
throw new WorkspaceError(`이 폴더는 이미 Git 저장소 안이라 ${WORKSPACE_CONFIG} 를 쓰지 않습니다: ${from}`, 'manual');
|
|
225
|
+
const wanted = path.resolve(from, choice);
|
|
226
|
+
const repo = gitTop(wanted);
|
|
227
|
+
if (!repo)
|
|
228
|
+
throw new WorkspaceError(`Git 저장소가 아닙니다: ${wanted}`, 'manual');
|
|
229
|
+
const config = path.join(from, WORKSPACE_CONFIG);
|
|
230
|
+
let data = {};
|
|
231
|
+
if (statOrNull(config)) {
|
|
232
|
+
// 읽지 못한 설정은 덮어쓰지 않는다 (D23 과 같은 원칙).
|
|
233
|
+
try {
|
|
234
|
+
data = JSON.parse(fs.readFileSync(config, 'utf8'));
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
throw new WorkspaceError(`기존 ${config} 를 읽지 못해 바꾸지 않았습니다: ${errorMessage(e)}`, 'manual');
|
|
238
|
+
}
|
|
239
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
240
|
+
throw new WorkspaceError(`기존 ${config} 가 객체가 아니라 바꾸지 않았습니다.`, 'manual');
|
|
241
|
+
}
|
|
242
|
+
// 폴더 안의 저장소는 상대 경로로 적어 작업 폴더를 통째로 옮겨도 맞게 한다.
|
|
243
|
+
const relative = path.relative(from, wanted);
|
|
244
|
+
const value = !relative || relative.startsWith('..') || path.isAbsolute(relative) ? wanted : relative.replaceAll(path.sep, '/');
|
|
245
|
+
fs.writeFileSync(config, JSON.stringify({ ...data, repo: value }, null, 2) + '\n');
|
|
246
|
+
return { config, repo, value };
|
|
247
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** 작업 폴더 → 대상 Git 저장소. MCP 서버·로컬 세션 연결·CLI가 이 한 곳에서 정한다.
|
|
2
|
+
*
|
|
3
|
+
* 사람이 AI 도구를 여는 폴더가 Git 저장소가 아닐 수 있다. 예: 한 저장소의 worktree
|
|
4
|
+
* 여러 개를 나란히 담아 둔 `robotable_hj_workspace`. 위로만 찾으면 이 폴더는
|
|
5
|
+
* "Git 저장소 아님"이 되어 연결이 끊긴다. 그래서 위로 찾기에 실패한 폴더에 한해
|
|
6
|
+
* 아래 순서로 정한다 (D30).
|
|
7
|
+
*
|
|
8
|
+
* 0. 수동 설정 — 그 폴더나 조상에 있는 `.axmap.json` 의 `{ "repo": "<경로>" }`
|
|
9
|
+
* 1. 자동 탐색 — 바로 아래 자식 폴더(깊이 1)의 Git 저장소를 공통 디렉터리로 묶어
|
|
10
|
+
* 묶음이 정확히 하나면 그 저장소
|
|
11
|
+
* 2. 모호함 — 묶음이 둘 이상이면 고르지 않고 후보를 적은 오류를 낸다
|
|
12
|
+
*
|
|
13
|
+
* 매번 디스크와 git 에게 다시 묻는다. 캐시하면 사람이 설정 파일을 쓰거나 폴더를
|
|
14
|
+
* 추가한 뒤에도 서버를 다시 켜기 전까지 옛 판단이 남는다.
|
|
15
|
+
*
|
|
16
|
+
* 읽지 못한 것과 없는 것을 구별한다. `.git` 이 있는데 git 이 읽지 못한 자식 폴더를
|
|
17
|
+
* "저장소 없음"으로 세면, 실제로는 두 저장소인데 하나로 보고 엉뚱한 장부를 잡는다.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
|
|
23
|
+
// bin/axmap.mjs·mcp/server.mjs 가 CLI 를 띄울 때마다 import 하므로 Node 내장만 쓴다.
|
|
24
|
+
// 그래서 session-types 의 작은 도우미도 가져오지 않는다.
|
|
25
|
+
const errorMessage = (e: unknown) => e instanceof Error ? e.message : String(e)
|
|
26
|
+
const errorCode = (e: unknown) => e instanceof Error && 'code' in e && typeof e.code === 'string' ? e.code : undefined
|
|
27
|
+
|
|
28
|
+
export const WORKSPACE_CONFIG = '.axmap.json'
|
|
29
|
+
|
|
30
|
+
export interface WorkspaceMember { name: string; top: string }
|
|
31
|
+
export interface RepoGroup { key: string; common: string; target: string; members: WorkspaceMember[] }
|
|
32
|
+
export type TargetResolution =
|
|
33
|
+
| { kind: 'git'; from: string; repo: string }
|
|
34
|
+
| { kind: 'manual'; from: string; repo: string; config: string }
|
|
35
|
+
| { kind: 'auto'; from: string; repo: string; group: RepoGroup }
|
|
36
|
+
| { kind: 'ambiguous'; from: string; groups: RepoGroup[] }
|
|
37
|
+
| { kind: 'none'; from: string }
|
|
38
|
+
|
|
39
|
+
/** 오류 문구만으로 종류를 가르지 않도록 사유를 붙인다. */
|
|
40
|
+
export class WorkspaceError extends Error {
|
|
41
|
+
constructor(message: string, readonly reason: 'ambiguous' | 'manual' | 'unreadable') { super(message) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 커밋 훅 안에서는 GIT_DIR 등이 설정돼 있다. 그대로 두면 자식 폴더마다 같은 저장소로
|
|
45
|
+
// 답해서 서로 다른 저장소가 하나로 묶인다. bin/axmap.mjs 의 GIT_ENV_KEYS 와 같은 목록이다.
|
|
46
|
+
const GIT_ENV_KEYS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE',
|
|
47
|
+
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_PREFIX']
|
|
48
|
+
function gitEnv(): NodeJS.ProcessEnv {
|
|
49
|
+
// "not a git repository" 문구로 판정하므로 번역된 메시지가 나오지 않게 고정한다.
|
|
50
|
+
const env: NodeJS.ProcessEnv = { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' }
|
|
51
|
+
for (const key of GIT_ENV_KEYS) delete env[key]
|
|
52
|
+
return env
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type GitAnswer = { ok: true; lines: string[] } | { ok: false; notRepo: boolean; detail: string }
|
|
56
|
+
function askGit(cwd: string, args: string[]): GitAnswer {
|
|
57
|
+
const r = spawnSync('git', args, { cwd, env: gitEnv(), encoding: 'utf8', windowsHide: true })
|
|
58
|
+
if (r.error) return { ok: false, notRepo: false, detail: r.error.message }
|
|
59
|
+
if (r.status === 0) return { ok: true, lines: (r.stdout ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean) }
|
|
60
|
+
const detail = (r.stderr ?? '').trim() || `git 종료 코드 ${r.status}`
|
|
61
|
+
return { ok: false, notRepo: /not a git repository/i.test(detail), detail }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const real = (p: string) => fs.realpathSync(p)
|
|
65
|
+
/** 공통 디렉터리 비교 키. bridge 의 repoKey 와 같은 규칙이다 (Windows 는 대소문자 무시). */
|
|
66
|
+
export const commonKey = (common: string) => process.platform === 'win32' ? common.toLowerCase() : common
|
|
67
|
+
|
|
68
|
+
function statOrNull(p: string): fs.Stats | null {
|
|
69
|
+
try { return fs.statSync(p) } catch (e) {
|
|
70
|
+
if (['ENOENT', 'ENOTDIR'].includes(errorCode(e) || '')) return null
|
|
71
|
+
throw e
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 폴더가 속한 Git 저장소의 최상위. 저장소 밖이면 null, 판정하지 못하면 예외. */
|
|
76
|
+
export function gitTop(dir: string): string | null {
|
|
77
|
+
const stat = statOrNull(dir)
|
|
78
|
+
if (!stat || !stat.isDirectory()) return null
|
|
79
|
+
const r = askGit(dir, ['rev-parse', '--show-toplevel'])
|
|
80
|
+
if (r.ok) {
|
|
81
|
+
if (!r.lines[0]) throw new WorkspaceError(`Git 최상위 폴더를 읽지 못했습니다: ${dir}`, 'unreadable')
|
|
82
|
+
return real(r.lines[0])
|
|
83
|
+
}
|
|
84
|
+
if (r.notRepo) return null
|
|
85
|
+
throw new WorkspaceError(`Git 저장소인지 확인하지 못했습니다: ${dir}\n ${r.detail}`, 'unreadable')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 그 폴더나 조상에 있는 `.axmap.json`. 없으면 null. 읽지 못하면 예외. */
|
|
89
|
+
export function findWorkspaceConfig(dir: string): string | null {
|
|
90
|
+
for (let at = path.resolve(dir); ; at = path.dirname(at)) {
|
|
91
|
+
const candidate = path.join(at, WORKSPACE_CONFIG)
|
|
92
|
+
const stat = statOrNull(candidate)
|
|
93
|
+
if (stat) return candidate
|
|
94
|
+
if (path.dirname(at) === at) return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function manualTarget(config: string): string {
|
|
99
|
+
let data: unknown
|
|
100
|
+
try { data = JSON.parse(fs.readFileSync(config, 'utf8')) } catch (e) {
|
|
101
|
+
throw new WorkspaceError(`작업 폴더 설정을 읽지 못했습니다: ${config}\n ${errorMessage(e)}`, 'manual')
|
|
102
|
+
}
|
|
103
|
+
const repo = (data as { repo?: unknown } | null)?.repo
|
|
104
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || typeof repo !== 'string' || !repo.trim()) {
|
|
105
|
+
throw new WorkspaceError(`작업 폴더 설정에 "repo" 경로가 없습니다: ${config}\n 예: { "repo": "폴더이름" }`, 'manual')
|
|
106
|
+
}
|
|
107
|
+
const wanted = path.resolve(path.dirname(config), repo)
|
|
108
|
+
// 설정이 틀렸을 때 자동 탐색으로 넘어가지 않는다. 사람이 고른 것을 몰래 바꾸면
|
|
109
|
+
// 다른 저장소의 장부에 선점이 남는다.
|
|
110
|
+
const top = gitTop(wanted)
|
|
111
|
+
if (!top) throw new WorkspaceError(`작업 폴더 설정의 저장소가 Git 저장소가 아닙니다: ${wanted}\n (설정 파일: ${config})`, 'manual')
|
|
112
|
+
return top
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scanChildren(dir: string): { groups: RepoGroup[]; unreadable: string[] } {
|
|
116
|
+
let entries: fs.Dirent[]
|
|
117
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) {
|
|
118
|
+
throw new WorkspaceError(`작업 폴더의 하위 폴더를 읽지 못했습니다: ${dir}\n ${errorMessage(e)}`, 'unreadable')
|
|
119
|
+
}
|
|
120
|
+
const groups = new Map<string, RepoGroup>(), unreadable: string[] = []
|
|
121
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
122
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
123
|
+
const child = path.join(dir, entry.name)
|
|
124
|
+
try {
|
|
125
|
+
// worktree 폴더가 심볼릭 링크·junction 일 수 있어 링크는 따라가서 본다.
|
|
126
|
+
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && !!statOrNull(child)?.isDirectory())
|
|
127
|
+
if (!isDir) continue
|
|
128
|
+
// `.git` 이 없는 폴더는 저장소의 최상위가 아니다. git 을 띄우지 않고 넘어간다.
|
|
129
|
+
if (fs.lstatSync(path.join(child, '.git'), { throwIfNoEntry: false }) === undefined) continue
|
|
130
|
+
} catch (e) { unreadable.push(`${entry.name} (${errorMessage(e)})`); continue }
|
|
131
|
+
const r = askGit(child, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
|
|
132
|
+
if (!r.ok || r.lines.length < 2) { unreadable.push(`${entry.name} (${r.ok ? 'git 응답이 비어 있음' : r.detail})`); continue }
|
|
133
|
+
let top: string, common: string
|
|
134
|
+
try { top = real(r.lines[0]); common = real(r.lines[1]) } catch (e) { unreadable.push(`${entry.name} (${errorMessage(e)})`); continue }
|
|
135
|
+
const key = commonKey(common)
|
|
136
|
+
const group = groups.get(key) ?? { key, common, target: '', members: [] }
|
|
137
|
+
group.members.push({ name: entry.name, top })
|
|
138
|
+
groups.set(key, group)
|
|
139
|
+
}
|
|
140
|
+
for (const group of groups.values()) {
|
|
141
|
+
// 메인 체크아웃(공통 디렉터리 `.git` 의 부모)을 겨눈다. 장부도 거기 있다.
|
|
142
|
+
// bare 저장소처럼 그 부모가 체크아웃이 아니면 첫 번째 worktree 를 쓴다.
|
|
143
|
+
const parent = path.dirname(group.common)
|
|
144
|
+
group.target = path.basename(group.common).toLowerCase() === '.git' && statOrNull(parent)?.isDirectory()
|
|
145
|
+
? real(parent) : group.members[0].top
|
|
146
|
+
}
|
|
147
|
+
return { groups: [...groups.values()], unreadable }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 무엇을 왜 고르는지까지 돌려준다. 모호함은 예외가 아니라 결과다 (`workspace show` 용). */
|
|
151
|
+
export function inspectTarget(dir: string): TargetResolution {
|
|
152
|
+
const from = path.resolve(dir)
|
|
153
|
+
const top = gitTop(from)
|
|
154
|
+
if (top) return { kind: 'git', from, repo: top }
|
|
155
|
+
if (!statOrNull(from)?.isDirectory()) return { kind: 'none', from }
|
|
156
|
+
const config = findWorkspaceConfig(from)
|
|
157
|
+
if (config) return { kind: 'manual', from, repo: manualTarget(config), config }
|
|
158
|
+
const { groups, unreadable } = scanChildren(from)
|
|
159
|
+
if (unreadable.length) {
|
|
160
|
+
throw new WorkspaceError([
|
|
161
|
+
`작업 폴더 ${from} 아래에서 확인하지 못한 Git 폴더가 있어 대상 저장소를 정하지 않았습니다.`,
|
|
162
|
+
...unreadable.map(s => ` - ${s}`),
|
|
163
|
+
`저장소를 직접 고르려면 이 폴더에서 \`axmap workspace use <폴더>\` 를 실행하세요.`,
|
|
164
|
+
].join('\n'), 'unreadable')
|
|
165
|
+
}
|
|
166
|
+
if (groups.length === 1) return { kind: 'auto', from, repo: groups[0].target, group: groups[0] }
|
|
167
|
+
if (groups.length > 1) return { kind: 'ambiguous', from, groups }
|
|
168
|
+
return { kind: 'none', from }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ambiguityMessage(r: Extract<TargetResolution, { kind: 'ambiguous' }>): string {
|
|
172
|
+
return [
|
|
173
|
+
`작업 폴더 ${r.from} 아래에 서로 다른 Git 저장소가 ${r.groups.length}개 있어 하나로 정하지 않았습니다.`,
|
|
174
|
+
...r.groups.map(g => ` - ${g.target} (폴더: ${g.members.map(m => m.name).join(', ')})`),
|
|
175
|
+
'고르는 방법 (둘 중 하나):',
|
|
176
|
+
' 1. 도구를 부를 때 cwd 에 작업할 저장소 폴더의 절대 경로를 넘긴다.',
|
|
177
|
+
` 2. 이 폴더에 ${WORKSPACE_CONFIG} 을 둔다: { "repo": "${r.groups[0].members[0].name}" }`,
|
|
178
|
+
` 터미널에서는: axmap workspace use ${r.groups[0].members[0].name}`,
|
|
179
|
+
].join('\n')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 대상 저장소 경로. 저장소가 없으면 null, 모호하거나 판정하지 못하면 예외. */
|
|
183
|
+
export function resolveTarget(dir: string): string | null {
|
|
184
|
+
const r = inspectTarget(dir)
|
|
185
|
+
if (r.kind === 'ambiguous') throw new WorkspaceError(ambiguityMessage(r), 'ambiguous')
|
|
186
|
+
return r.kind === 'none' ? null : r.repo
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function describeTarget(r: TargetResolution): string {
|
|
190
|
+
switch (r.kind) {
|
|
191
|
+
case 'git': return `대상 저장소: ${r.repo}\n이유: 작업 폴더가 Git 저장소 안에 있습니다.`
|
|
192
|
+
case 'manual': return `대상 저장소: ${r.repo}\n이유: 수동 설정 ${r.config}`
|
|
193
|
+
case 'auto': return `대상 저장소: ${r.repo}\n이유: 자동 탐색 — 하위 폴더가 모두 같은 저장소입니다 (${r.group.members.map(m => m.name).join(', ')}).`
|
|
194
|
+
case 'ambiguous': return ambiguityMessage(r)
|
|
195
|
+
case 'none': return `대상 저장소 없음: ${r.from}\n이유: 이 폴더와 바로 아래 폴더에서 Git 저장소를 찾지 못했습니다.`
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `axmap workspace use` — 사람이 고른 저장소를 작업 폴더의 `.axmap.json` 에 적는다. */
|
|
200
|
+
export function writeWorkspaceChoice(dir: string, choice: string): { config: string; repo: string; value: string } {
|
|
201
|
+
const from = path.resolve(dir)
|
|
202
|
+
if (gitTop(from)) throw new WorkspaceError(`이 폴더는 이미 Git 저장소 안이라 ${WORKSPACE_CONFIG} 를 쓰지 않습니다: ${from}`, 'manual')
|
|
203
|
+
const wanted = path.resolve(from, choice)
|
|
204
|
+
const repo = gitTop(wanted)
|
|
205
|
+
if (!repo) throw new WorkspaceError(`Git 저장소가 아닙니다: ${wanted}`, 'manual')
|
|
206
|
+
const config = path.join(from, WORKSPACE_CONFIG)
|
|
207
|
+
let data: Record<string, unknown> = {}
|
|
208
|
+
if (statOrNull(config)) {
|
|
209
|
+
// 읽지 못한 설정은 덮어쓰지 않는다 (D23 과 같은 원칙).
|
|
210
|
+
try { data = JSON.parse(fs.readFileSync(config, 'utf8')) } catch (e) {
|
|
211
|
+
throw new WorkspaceError(`기존 ${config} 를 읽지 못해 바꾸지 않았습니다: ${errorMessage(e)}`, 'manual')
|
|
212
|
+
}
|
|
213
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new WorkspaceError(`기존 ${config} 가 객체가 아니라 바꾸지 않았습니다.`, 'manual')
|
|
214
|
+
}
|
|
215
|
+
// 폴더 안의 저장소는 상대 경로로 적어 작업 폴더를 통째로 옮겨도 맞게 한다.
|
|
216
|
+
const relative = path.relative(from, wanted)
|
|
217
|
+
const value = !relative || relative.startsWith('..') || path.isAbsolute(relative) ? wanted : relative.replaceAll(path.sep, '/')
|
|
218
|
+
fs.writeFileSync(config, JSON.stringify({ ...data, repo: value }, null, 2) + '\n')
|
|
219
|
+
return { config, repo, value }
|
|
220
|
+
}
|
package/tools/build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** TS owns migrated modules; checked-in JS keeps Node 20 and
|
|
2
|
+
/** TS owns migrated modules; checked-in JS keeps Node 20 source checkouts and npm installs usable without a build step. */
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
package/tools/bus.mjs
CHANGED
|
@@ -1002,7 +1002,7 @@ function publish(id) {
|
|
|
1002
1002
|
|
|
1003
1003
|
/**
|
|
1004
1004
|
* 이 파일을 지금 폴더에서 부르는 명령. **문자열로 적지 않고 계산한다.**
|
|
1005
|
-
*
|
|
1005
|
+
* 이 파일은 도구가 설치된 자리(npm 설치본·axMap 저장소)에 있고, 그 자리는 기계마다 다르다.
|
|
1006
1006
|
*/
|
|
1007
1007
|
function selfCmd() {
|
|
1008
1008
|
const self = fileURLToPath(import.meta.url)
|