pluriply 0.1.0 → 0.3.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/README.md +28 -10
- package/bin/pluriply.js +122 -34
- package/package.json +1 -1
- package/src/hub/index.js +10 -7
- package/src/setup/clients.js +400 -32
- package/src/setup/hook-stop.js +157 -0
- package/src/setup/hooks.js +233 -0
- package/src/setup/run-setup.js +309 -41
- package/src/setup/toml-lite.js +87 -0
- package/src/shared/config.js +102 -0
- package/src/shared/identity.js +13 -2
- package/src/shared/lock.js +20 -0
- package/src/shared/mcp-register.js +25 -67
package/src/setup/run-setup.js
CHANGED
|
@@ -1,49 +1,152 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { existsSync, rmSync, realpathSync, lstatSync } from "node:fs";
|
|
2
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { CLIENTS, makeEnv, isSkipped } from "./clients.js";
|
|
5
|
+
import { HOOK_CLIENTS, hookStatus, installHook, removeHook } from "./hooks.js";
|
|
6
|
+
import { setWorkerEnabled, TEMPLATE_AGENTS } from "../shared/config.js";
|
|
7
|
+
import { readLock } from "../shared/lock.js";
|
|
8
|
+
// 허브(stopHub)와 커넥터(ensureHub → ws)는 쓰는 순간에만 불러온다. 공개 미러에는 src/hub 가
|
|
9
|
+
// 없고 node_modules 도 없을 수 있으므로 이 모듈은 둘 없이 로드돼야 한다(Plan 4e §5).
|
|
10
|
+
const hub = () => import("../hub/index.js");
|
|
11
|
+
const hubClient = () => import("../connector/hub-client.js");
|
|
12
|
+
|
|
13
|
+
/** 전체 제거 뒤 항상 출력한다: 열린 세션의 커넥터는 등록 해제와 무관하게 살아 있고 허브를 다시 띄울 수 있다(스펙 §4). */
|
|
14
|
+
export const REMOVE_NOTE =
|
|
15
|
+
"note: close or restart open client sessions; their connectors may restart the hub";
|
|
16
|
+
|
|
17
|
+
/** @param {string[]|undefined} only */
|
|
18
|
+
function resolveTargets(only) {
|
|
19
|
+
if (!only) return CLIENTS;
|
|
20
|
+
return only.map((id) => {
|
|
21
|
+
const c = CLIENTS.find((x) => x.id === id);
|
|
22
|
+
if (!c)
|
|
23
|
+
throw new Error(
|
|
24
|
+
`unknown client: ${id} (known: ${CLIENTS.map((x) => x.id).join(", ")})`,
|
|
25
|
+
);
|
|
26
|
+
return c;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
6
29
|
|
|
7
30
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
31
|
+
* 클라이언트를 돌며 표 행을 만든다. register/remove 루프가 공유한다.
|
|
32
|
+
* `act` 는 실제 등록/해제 호출, `dryRunResult` 는 (status 문자열) → 결과 문자열,
|
|
33
|
+
* `collect` 는 실패하지 않은 결과에서 워커 활성/비활성 후보를 모은다.
|
|
34
|
+
* `{error}` → `failed: <err>` 매핑과 `"failed"` → `"failed: see hint above"` 매핑은 여기 산다.
|
|
35
|
+
* @param {{targets: object[], e: object, dryRun: boolean, act: (c: object) => string, dryRunResult: (st: string) => string, collect: (c: object, result: string) => void}} opts
|
|
36
|
+
* @returns {{rows: Array<{id: string, label: string, installed: boolean, result: string}>, failed: number}}
|
|
11
37
|
*/
|
|
12
|
-
|
|
13
|
-
const e = env ?? makeEnv();
|
|
14
|
-
const targets = only
|
|
15
|
-
? only.map((id) => {
|
|
16
|
-
const c = CLIENTS.find((x) => x.id === id);
|
|
17
|
-
if (!c) throw new Error(`unknown client: ${id} (known: ${CLIENTS.map((x) => x.id).join(", ")})`);
|
|
18
|
-
return c;
|
|
19
|
-
})
|
|
20
|
-
: CLIENTS;
|
|
38
|
+
function walkClients({ targets, e, dryRun, act, dryRunResult, collect }) {
|
|
21
39
|
const rows = [];
|
|
22
40
|
let failed = 0;
|
|
23
|
-
const enabledAgents = [];
|
|
24
41
|
for (const c of targets) {
|
|
25
42
|
const det = c.detect(e);
|
|
26
43
|
if (!det.installed) {
|
|
27
|
-
rows.push({
|
|
44
|
+
rows.push({
|
|
45
|
+
id: c.id,
|
|
46
|
+
label: c.label,
|
|
47
|
+
installed: false,
|
|
48
|
+
result: "not installed",
|
|
49
|
+
});
|
|
28
50
|
continue;
|
|
29
51
|
}
|
|
30
52
|
let result;
|
|
31
53
|
if (dryRun) {
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
// PLURIPLY_SKIP_MCP_REGISTER 가 켜져 있으면 register/unregister 와 같은 순서(감지 → 스킵 →
|
|
55
|
+
// 상태조회)로 status() 호출 자체를 건너뛴다 — 안 그러면 dry-run만 진짜로 mcp list 를 쳐서
|
|
56
|
+
// 실행 결과와 어긋난다.
|
|
57
|
+
if (isSkipped(e)) {
|
|
58
|
+
result = "skipped";
|
|
59
|
+
} else {
|
|
60
|
+
const st = c.status(e);
|
|
61
|
+
result =
|
|
62
|
+
typeof st === "object" ? `failed: ${st.error}` : dryRunResult(st);
|
|
63
|
+
}
|
|
34
64
|
} else {
|
|
35
|
-
result = c
|
|
65
|
+
result = act(c);
|
|
36
66
|
if (result === "failed") result = "failed: see hint above";
|
|
37
67
|
}
|
|
38
68
|
if (result.startsWith("failed")) failed++;
|
|
39
|
-
else
|
|
69
|
+
else collect(c, result);
|
|
40
70
|
rows.push({ id: c.id, label: c.label, installed: true, result });
|
|
41
71
|
}
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
return { rows, failed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* MCP 등록 표(`rows`)를 바탕으로 훅 행을 만든다(스펙 §6). 도구가 감지되지 않았으면 not installed,
|
|
77
|
+
* `hooks:false` 면 skipped, dry-run 은 상태만 본다. 실패는 failed 로 세고 진행한다.
|
|
78
|
+
* @returns {{hookRows: object[], failed: number}}
|
|
79
|
+
*/
|
|
80
|
+
function walkHooks({ targets, rows, e, dryRun, hooks, remove }) {
|
|
81
|
+
const hookRows = [];
|
|
82
|
+
let failed = 0;
|
|
83
|
+
for (const hc of HOOK_CLIENTS) {
|
|
84
|
+
if (!targets.some((c) => c.id === hc.id)) continue;
|
|
85
|
+
const row = rows.find((x) => x.id === hc.id);
|
|
86
|
+
const installed = Boolean(row?.installed);
|
|
87
|
+
let result;
|
|
88
|
+
if (!installed) result = "not installed";
|
|
89
|
+
else if (!hooks) result = "skipped";
|
|
90
|
+
else if (dryRun) {
|
|
91
|
+
const st = hookStatus(e, hc);
|
|
92
|
+
if (typeof st === "object") result = `failed: ${st.error}`;
|
|
93
|
+
else if (remove) result = st === "missing" ? "absent" : "planned";
|
|
94
|
+
else result = st === "present" ? "present" : "planned";
|
|
95
|
+
} else result = remove ? removeHook(e, hc) : installHook(e, hc);
|
|
96
|
+
if (result === "failed") result = "failed: see hint above";
|
|
97
|
+
if (result.startsWith("failed")) failed++;
|
|
98
|
+
hookRows.push({ id: hc.id, label: hc.label, installed, result });
|
|
99
|
+
}
|
|
100
|
+
return { hookRows, failed };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `pluriply setup`: 설치된 클라이언트를 감지해 pluriply 커넥터를 멱등 등록한다.
|
|
105
|
+
* `remove` 면 반대로 등록을 풀고 워커 설정·허브·(purge 시) 데이터까지 정리한다.
|
|
106
|
+
* `--purge` 는 pluriply 홈이 심볼릭 링크면 **링크만 끊고** 링크가 가리키는 디렉터리는 남긴다
|
|
107
|
+
* (그 안의 내용까지 지우려면 실제 경로를 직접 지워야 한다).
|
|
108
|
+
* env.stopHub / env.rm / env.lstat 은 테스트가 주입한다.
|
|
109
|
+
* `hooks`(기본 true)는 Claude Code·Codex 의 Stop 훅 등록 여부다(스펙 §6). `--remove` 는 이 값과
|
|
110
|
+
* 무관하게 항상 훅을 제거한다.
|
|
111
|
+
* @param {{only?: string[], workers?: boolean, dryRun?: boolean, remove?: boolean, purge?: boolean, hooks?: boolean, env?: object, home: string}} opts
|
|
112
|
+
*/
|
|
113
|
+
export async function runSetup({
|
|
114
|
+
only,
|
|
115
|
+
workers = false,
|
|
116
|
+
dryRun = false,
|
|
117
|
+
remove = false,
|
|
118
|
+
purge = false,
|
|
119
|
+
hooks = true,
|
|
120
|
+
env,
|
|
121
|
+
home,
|
|
122
|
+
}) {
|
|
123
|
+
const e = env ?? makeEnv();
|
|
124
|
+
const targets = resolveTargets(only);
|
|
125
|
+
if (remove) return runRemove({ targets, only, dryRun, purge, e, home });
|
|
126
|
+
const enabledAgents = [];
|
|
127
|
+
const { rows, failed } = walkClients({
|
|
128
|
+
targets,
|
|
129
|
+
e,
|
|
130
|
+
dryRun,
|
|
131
|
+
act: (c) => c.register(e),
|
|
132
|
+
dryRunResult: (st) => (st === "present" ? "present" : "planned"),
|
|
133
|
+
collect: (c) => {
|
|
134
|
+
if (c.kind === "cli" && TEMPLATE_AGENTS.includes(c.agent))
|
|
135
|
+
enabledAgents.push(c.agent);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const hk = walkHooks({ targets, rows, e, dryRun, hooks, remove: false });
|
|
139
|
+
if (workers && !dryRun && enabledAgents.length > 0)
|
|
140
|
+
setWorkerEnabled(home, enabledAgents, true);
|
|
141
|
+
const out = {
|
|
142
|
+
rows,
|
|
143
|
+
failed: failed + hk.failed,
|
|
144
|
+
hookRows: hk.hookRows,
|
|
145
|
+
workers: workers && !dryRun ? enabledAgents : [],
|
|
146
|
+
};
|
|
44
147
|
if (!dryRun) {
|
|
45
148
|
try {
|
|
46
|
-
out.hub = { port: (await ensureHub({ home })).port };
|
|
149
|
+
out.hub = { port: (await (await hubClient()).ensureHub({ home })).port };
|
|
47
150
|
} catch (err) {
|
|
48
151
|
out.hubError = err.message;
|
|
49
152
|
}
|
|
@@ -51,32 +154,197 @@ export async function runSetup({ only, workers = false, dryRun = false, env, hom
|
|
|
51
154
|
return out;
|
|
52
155
|
}
|
|
53
156
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
157
|
+
/**
|
|
158
|
+
* `--purge` 가드가 **판정에 쓰는** 경로. 문자열 비교만으로는 심볼릭 링크(`/tmp/users-alias/alice` →
|
|
159
|
+
* `/Users/alice`)나 플랫폼 별칭(`/var` → `/private/var`)을 잡을 수 없으므로 realpath 로 정규화한다.
|
|
160
|
+
* 아직 없는 홈은 realpath 가 던지니 resolve 로 물러선다. e.realpath 는 테스트 주입용.
|
|
161
|
+
*
|
|
162
|
+
* 주의: 실제로 `rm` 을 거는 경로는 이게 아니라 `resolve(home)` 이다 — runRemove 주석 참고.
|
|
163
|
+
* @param {string} home @param {object} [e] @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function purgeTarget(home, e = {}) {
|
|
166
|
+
const abs = resolve(home);
|
|
167
|
+
try {
|
|
168
|
+
return (e.realpath ?? realpathSync.native)(abs);
|
|
169
|
+
} catch {
|
|
170
|
+
return abs;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 대소문자를 구분하지 않는 파일시스템(macOS·Windows)에서는 비교도 대소문자를 무시한다. */
|
|
175
|
+
function foldCase(p, platform) {
|
|
176
|
+
return platform === "darwin" || platform === "win32" ? p.toLowerCase() : p;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 루트를 뺀 경로 세그먼트 수. Windows 드라이브 문자(`C:`)는 세지 않는다 — `C:\Users` 는 1. */
|
|
180
|
+
function depth(p) {
|
|
181
|
+
return p.split(/[\\/]+/).filter((x) => x !== "" && !/^[A-Za-z]:$/.test(x))
|
|
182
|
+
.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* rm -rf 전 정신 확인: 지워선 안 되는 경로면 사람이 읽는 이유를, 괜찮으면 null 을 돌려준다.
|
|
187
|
+
* 거부 대상 — (a) 파일시스템 루트, (b) 사용자 홈 그 자체, (c) 사용자 홈의 상위 디렉터리,
|
|
188
|
+
* (d) 세그먼트가 두 개 미만인 최상위 디렉터리(`/Users`, `/home`, `/etc`, `C:\Users`).
|
|
189
|
+
* e.userHome / e.platform / e.realpath 는 테스트 주입용(어댑터의 env.homeDir 과는 다른 값)이며
|
|
190
|
+
* 기본은 os.homedir() / process.platform 이다.
|
|
191
|
+
* @param {string} home @param {object} [e] @returns {string|null}
|
|
192
|
+
*/
|
|
193
|
+
export function purgeRefusal(home, e = {}) {
|
|
194
|
+
const platform = e.platform ?? process.platform;
|
|
195
|
+
const target = purgeTarget(home, e);
|
|
196
|
+
const userHome = purgeTarget(e.userHome ?? homedir(), e);
|
|
197
|
+
const t = foldCase(target, platform);
|
|
198
|
+
const u = foldCase(userHome, platform);
|
|
199
|
+
if (dirname(target) === target) return `${target} is a filesystem root`;
|
|
200
|
+
if (t === u) return `${target} resolves to your home directory`;
|
|
201
|
+
if (u.startsWith(t.endsWith(sep) ? t : t + sep))
|
|
202
|
+
return `${target} contains your home directory`;
|
|
203
|
+
// 정규화 전후 둘 다 본다: `/etc` 는 macOS 에서 `/private/etc`(두 칸) 로 풀려 정규화 경로만
|
|
204
|
+
// 보면 통과해버린다.
|
|
205
|
+
if (depth(target) < 2 || depth(resolve(home)) < 2)
|
|
206
|
+
return `${target} is a top-level directory`;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runRemove({ targets, only, dryRun, purge, e, home }) {
|
|
211
|
+
const { rows, failed: rowsFailed } = walkClients({
|
|
212
|
+
targets,
|
|
213
|
+
e,
|
|
214
|
+
dryRun,
|
|
215
|
+
act: (c) => c.unregister(e),
|
|
216
|
+
dryRunResult: (st) => (st === "missing" ? "absent" : "planned"),
|
|
217
|
+
// remove 는 워커 비활성화를 행 결과가 아니라 대상 목록 자체로 정하므로(아래 toDisable)
|
|
218
|
+
// 여기서 따로 모을 게 없다.
|
|
219
|
+
collect: () => {},
|
|
220
|
+
});
|
|
221
|
+
// --remove 는 --no-hooks 와 무관하게 항상 훅을 제거한다.
|
|
222
|
+
const hk = walkHooks({ targets, rows, e, dryRun, hooks: true, remove: true });
|
|
223
|
+
const out = {
|
|
224
|
+
mode: "remove",
|
|
225
|
+
rows,
|
|
226
|
+
failed: rowsFailed + hk.failed,
|
|
227
|
+
hookRows: hk.hookRows,
|
|
228
|
+
workers: [],
|
|
229
|
+
workersDisabled: [],
|
|
230
|
+
};
|
|
231
|
+
if (dryRun) {
|
|
232
|
+
// --purge 는 --only 와 함께 실행되지 않는다(허브를 건드리지 않으므로 지울 것도 없다) — CLI 도 이 조합을 거부한다.
|
|
233
|
+
// 계획 단계에서도 실제 실행과 같은 가드를 태운다 — dry-run 이 "지우겠다"고 해놓고
|
|
234
|
+
// 실행이 거부하면(또는 그 반대면) 사용자가 확인할 방법이 없다.
|
|
235
|
+
if (purge && !only) {
|
|
236
|
+
const refusal = purgeRefusal(home, e);
|
|
237
|
+
if (refusal) {
|
|
238
|
+
out.purgeError = refusal;
|
|
239
|
+
out.failed++;
|
|
240
|
+
} else out.purge = `planned ${home}`;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// --only 면 그 실행에서 겨냥한 CLI 템플릿 에이전트 전부(행 결과가 removed/absent/skipped/failed
|
|
245
|
+
// 무엇이든) 비활성, 전체 제거면 템플릿 에이전트 전부 비활성. config.json 이 없으면 만들지 않는다.
|
|
246
|
+
// 행 결과로만 판단하면(예: removed 만) MCP 등록이 이미 없는(absent) 워커가 config.json 에는
|
|
247
|
+
// enabled:true 로 남아 허브가 계속 받아준다 — worker enable 로 등록 없이 활성화될 수 있어서다.
|
|
248
|
+
const toDisable = only
|
|
249
|
+
? targets
|
|
250
|
+
.filter((c) => c.kind === "cli" && TEMPLATE_AGENTS.includes(c.agent))
|
|
251
|
+
.map((c) => c.agent)
|
|
252
|
+
: [...TEMPLATE_AGENTS];
|
|
253
|
+
if (toDisable.length > 0 && existsSync(join(home, "config.json"))) {
|
|
59
254
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
255
|
+
setWorkerEnabled(home, toDisable, false);
|
|
256
|
+
out.workersDisabled = toDisable;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
out.workersError = err.message;
|
|
259
|
+
out.failed++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (only) return out;
|
|
263
|
+
try {
|
|
264
|
+
const stop = e.stopHub ?? (await hub()).stopHub;
|
|
265
|
+
out.hub = await stop({ home });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
out.hubError = err.message;
|
|
268
|
+
out.failed++;
|
|
269
|
+
}
|
|
270
|
+
if (out.hub === "timeout") {
|
|
271
|
+
out.hubPid = readLock(home)?.pid;
|
|
272
|
+
out.failed++;
|
|
273
|
+
} else if (out.hub && purge) {
|
|
274
|
+
const refusal = purgeRefusal(home, e);
|
|
275
|
+
if (refusal) {
|
|
276
|
+
out.purgeError = refusal;
|
|
277
|
+
out.failed++;
|
|
278
|
+
} else {
|
|
279
|
+
// 판정은 정규화된 경로로(위 purgeRefusal), 삭제는 **정규화하지 않은** 경로로 한다.
|
|
280
|
+
// realpath 를 지우면 `~/.pluriply` 가 다른 디렉터리로의 심볼릭 링크일 때 그 바깥 디렉터리가
|
|
281
|
+
// 통째로 재귀 삭제되고 끊어진 링크만 남는다 — pluriply 소유가 아닌 데이터가 사라진다.
|
|
282
|
+
// rmSync 는 링크를 따라가지 않으므로 링크 경로를 그대로 주면 링크만 끊긴다(대상은 그대로).
|
|
283
|
+
const raw = resolve(home);
|
|
284
|
+
let symlinked = false;
|
|
285
|
+
try {
|
|
286
|
+
symlinked = (e.lstat ?? lstatSync)(raw).isSymbolicLink();
|
|
287
|
+
} catch {
|
|
288
|
+
// 경합으로 사라졌거나 못 읽으면 평범한 디렉터리로 보고 문구만 단순하게 간다
|
|
289
|
+
}
|
|
290
|
+
(e.rm ?? rmSync)(raw, { recursive: true, force: true });
|
|
291
|
+
out.purge = symlinked
|
|
292
|
+
? `removed ${home} (symlink unlinked; target kept)`
|
|
293
|
+
: `removed ${home}`;
|
|
63
294
|
}
|
|
64
295
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
for (const a of agents) workers[a] = { ...(workers[a] ?? {}), enabled: true };
|
|
68
|
-
saveConfig(home, { ...rawDoc, workers });
|
|
296
|
+
out.note = REMOVE_NOTE;
|
|
297
|
+
return out;
|
|
69
298
|
}
|
|
70
299
|
|
|
71
300
|
/** @param {Awaited<ReturnType<typeof runSetup>>} r @returns {string[]} 사람이 읽는 표 */
|
|
72
301
|
export function formatSetup(r, { workers = false } = {}) {
|
|
73
|
-
const lines = r.rows.map(
|
|
302
|
+
const lines = r.rows.map(
|
|
303
|
+
(row) =>
|
|
304
|
+
`${row.id.padEnd(16)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
305
|
+
);
|
|
306
|
+
for (const row of r.hookRows ?? [])
|
|
307
|
+
lines.push(
|
|
308
|
+
`hooks ${row.id.padEnd(12)} ${row.installed ? "installed " : "not installed"} ${row.result}`,
|
|
309
|
+
);
|
|
310
|
+
if (r.mode === "remove") {
|
|
311
|
+
if (r.workersDisabled.length)
|
|
312
|
+
lines.push(`workers disabled: ${r.workersDisabled.join(", ")}`);
|
|
313
|
+
if (r.workersError)
|
|
314
|
+
lines.push(`workers: could not update config (${r.workersError})`);
|
|
315
|
+
if (r.hub === "stopped") lines.push("hub: stopped");
|
|
316
|
+
else if (r.hub === "not-running") lines.push("hub: not running");
|
|
317
|
+
else if (r.hub === "timeout")
|
|
318
|
+
lines.push(
|
|
319
|
+
`hub: failed to stop within 5s (pid ${r.hubPid ?? "unknown"})`,
|
|
320
|
+
);
|
|
321
|
+
else if (r.hubError) lines.push(`hub: could not stop (${r.hubError})`);
|
|
322
|
+
if (r.purge) lines.push(`purge: ${r.purge}`);
|
|
323
|
+
if (r.purgeError) lines.push(`purge: refused (${r.purgeError})`);
|
|
324
|
+
if (r.note) lines.push(r.note);
|
|
325
|
+
return lines;
|
|
326
|
+
}
|
|
74
327
|
if (r.hub) lines.push(`hub: running on port ${r.hub.port}`);
|
|
75
328
|
if (r.hubError) lines.push(`hub: could not start (${r.hubError})`);
|
|
76
329
|
if (r.workers.length) lines.push(`workers enabled: ${r.workers.join(", ")}`);
|
|
77
330
|
else if (!workers) {
|
|
78
|
-
const cli = r.rows
|
|
79
|
-
|
|
331
|
+
const cli = r.rows
|
|
332
|
+
.filter((x) => x.installed && TEMPLATE_AGENTS.includes(x.id))
|
|
333
|
+
.map((x) => x.id);
|
|
334
|
+
if (cli.length)
|
|
335
|
+
lines.push(
|
|
336
|
+
`hint: run \`pluriply worker enable <${cli.join("|")}>\` to let the hub run that tool headlessly (or re-run setup --workers)`,
|
|
337
|
+
);
|
|
80
338
|
}
|
|
339
|
+
if (
|
|
340
|
+
(r.hookRows ?? []).some(
|
|
341
|
+
(x) =>
|
|
342
|
+
x.id === "codex" &&
|
|
343
|
+
(x.result === "registered" || x.result === "updated"),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
lines.push(
|
|
347
|
+
"hint: Codex asks to trust the new hook in its next session — approve it.",
|
|
348
|
+
);
|
|
81
349
|
return lines;
|
|
82
350
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex `config.toml` 을 파서 없이 다루는 최소 편집기. 섹션 헤더 줄(`[a.b]`, `[[a]]`)과 그 본문
|
|
3
|
+
* (다음 헤더 전까지) 단위로만 동작한다. 최상위 점 표기(`a.b.key = …`) 와 인라인 테이블은
|
|
4
|
+
* 다루지 않는다(스펙 §11). 줄 끝 주석은 허용한다.
|
|
5
|
+
*
|
|
6
|
+
* 따옴표 헤더(`[projects."/Users/alice/My Project"]`)는 **경계로는** 인식한다. codex 가 바로 그런
|
|
7
|
+
* 헤더를 쓰므로, 못 알아보면 `[mcp_servers.pluriply.*]` 하위 테이블을 지우던 중 그 뒤의 무관한
|
|
8
|
+
* `[projects.…]` 섹션까지 함께 지워진다. 이름 비교는 문자열 그대로 하므로 따옴표 헤더는 그냥
|
|
9
|
+
* "다른 섹션"으로 남는다(따옴표를 풀어 정규화하지는 않는다).
|
|
10
|
+
*
|
|
11
|
+
* 트리플쿼트 문자열(`"""…"""`/`'''…'''`)은 다루지 않는다(스펙 §11). `#` 주석 안에 나온
|
|
12
|
+
* `"""` 한 조각만으로도 진짜 문자열 상태와 무관하게 상태가 뒤집힐 수 있어, 줄 개수만 세는
|
|
13
|
+
* 방식으로는 주석과 문자열을 구분할 수 없다 — 파서 없이 안전하게 가르는 방법이 없으므로,
|
|
14
|
+
* 문서 어디에든 `"""`·`'''` 가 있으면 편집 자체를 거부한다(`hasTripleQuotes`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @param {string} line @returns {string|null} 헤더 줄이면 대괄호 안 이름, 아니면 null */
|
|
18
|
+
function headerName(line) {
|
|
19
|
+
// 대괄호 안은 무엇이든(공백·점·슬래시·따옴표) 받는다 — 경계 판정이 목적이다.
|
|
20
|
+
const m = line.match(/^\s*\[\[?\s*(.+?)\s*\]\]?\s*(#.*)?$/);
|
|
21
|
+
return m ? m[1] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 문서 어디에든(주석·문자열·그 밖 어디든) `"""` 또는 `'''` 가 있는지만 본다. 있으면
|
|
26
|
+
* `insertTomlKey`/`removeTomlSections` 는 편집을 거부한다(스펙 §11 — 트리플쿼트 문자열은
|
|
27
|
+
* 지원 범위 밖).
|
|
28
|
+
* @param {string} text @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function hasTripleQuotes(text) {
|
|
31
|
+
return text.includes('"""') || text.includes("'''");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `[header]` 섹션 본문에 `key = value` 한 줄을 헤더 바로 아래에 넣는다.
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @param {string} header 대괄호 없는 이름(예: "mcp_servers.pluriply")
|
|
38
|
+
* @param {string} key
|
|
39
|
+
* @param {string} value TOML 리터럴 그대로(예: "600")
|
|
40
|
+
* @returns {{text: string, changed: boolean, reason?: "no-header"|"present"|"unsupported"}}
|
|
41
|
+
*/
|
|
42
|
+
export function insertTomlKey(text, header, key, value) {
|
|
43
|
+
if (hasTripleQuotes(text))
|
|
44
|
+
return { text, changed: false, reason: "unsupported" };
|
|
45
|
+
const lines = text.split("\n");
|
|
46
|
+
const start = lines.findIndex((l) => headerName(l) === header);
|
|
47
|
+
if (start === -1) return { text, changed: false, reason: "no-header" };
|
|
48
|
+
let end = lines.length;
|
|
49
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
50
|
+
if (headerName(lines[i]) !== null) {
|
|
51
|
+
end = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const keyRe = new RegExp(
|
|
56
|
+
`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`,
|
|
57
|
+
);
|
|
58
|
+
const hasKey = lines.slice(start + 1, end).some((l) => keyRe.test(l));
|
|
59
|
+
if (hasKey) return { text, changed: false, reason: "present" };
|
|
60
|
+
lines.splice(start + 1, 0, `${key} = ${value}`);
|
|
61
|
+
return { text: lines.join("\n"), changed: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 이름이 `prefix` 이거나 `prefix.` 로 시작하는 모든 섹션(헤더 + 본문)을 지운다.
|
|
66
|
+
* @param {string} text @param {string} prefix 예: "mcp_servers.pluriply"
|
|
67
|
+
* @returns {{text: string, removed: number, reason?: "unsupported"}}
|
|
68
|
+
*/
|
|
69
|
+
export function removeTomlSections(text, prefix) {
|
|
70
|
+
if (hasTripleQuotes(text)) return { text, removed: 0, reason: "unsupported" };
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const out = [];
|
|
73
|
+
let removed = 0;
|
|
74
|
+
let skipping = false;
|
|
75
|
+
lines.forEach((line) => {
|
|
76
|
+
const name = headerName(line);
|
|
77
|
+
if (name !== null) {
|
|
78
|
+
skipping = name === prefix || name.startsWith(`${prefix}.`);
|
|
79
|
+
if (skipping) {
|
|
80
|
+
removed++;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!skipping) out.push(line);
|
|
85
|
+
});
|
|
86
|
+
return { text: out.join("\n"), removed };
|
|
87
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join, isAbsolute } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** 내장 워커 템플릿이 있는 에이전트. 허브(worker-templates.js)와 setup 이 같이 쓴다. */
|
|
11
|
+
export const TEMPLATE_AGENTS = ["codex", "claude-code", "antigravity"];
|
|
12
|
+
|
|
13
|
+
/** 워커 상한 기본값 */
|
|
14
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
15
|
+
maxDepth: 2,
|
|
16
|
+
timeoutMs: 20 * 60 * 1000,
|
|
17
|
+
maxConcurrentPerAgent: 1,
|
|
18
|
+
maxQueuedPerAgent: 10,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `<home>/config.json`. 없거나 손상되면 기본값.
|
|
23
|
+
* @param {string} home
|
|
24
|
+
* @returns {{workers: object, limits: {maxDepth: number, timeoutMs: number, maxConcurrentPerAgent: number, maxQueuedPerAgent: number}, allowedRoots: string[]}}
|
|
25
|
+
*/
|
|
26
|
+
export function loadConfig(home) {
|
|
27
|
+
const file = join(home, "config.json");
|
|
28
|
+
let doc = {};
|
|
29
|
+
if (existsSync(file)) {
|
|
30
|
+
try {
|
|
31
|
+
doc = JSON.parse(readFileSync(file, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
doc = {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) doc = {};
|
|
37
|
+
const workers =
|
|
38
|
+
doc.workers &&
|
|
39
|
+
typeof doc.workers === "object" &&
|
|
40
|
+
!Array.isArray(doc.workers)
|
|
41
|
+
? doc.workers
|
|
42
|
+
: {};
|
|
43
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
44
|
+
for (const key of Object.keys(DEFAULT_LIMITS)) {
|
|
45
|
+
const v = doc.limits?.[key];
|
|
46
|
+
if (Number.isInteger(v) && v > 0) limits[key] = v;
|
|
47
|
+
}
|
|
48
|
+
const allowedRoots = Array.isArray(doc.allowedRoots)
|
|
49
|
+
? doc.allowedRoots.filter((r) => typeof r === "string" && isAbsolute(r))
|
|
50
|
+
: [];
|
|
51
|
+
return { workers, limits, allowedRoots };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} home @param {object} doc tmp+rename으로 원자 저장.
|
|
56
|
+
* home 이 아직 없는 새 설치(허브를 한 번도 안 띄운 상태에서 setup --workers, worker enable 이
|
|
57
|
+
* 먼저 실행되는 경우)에서도 ENOENT 없이 만들어지도록 폴더를 먼저 보장한다.
|
|
58
|
+
*/
|
|
59
|
+
export function saveConfig(home, doc) {
|
|
60
|
+
mkdirSync(home, { recursive: true });
|
|
61
|
+
const file = join(home, "config.json");
|
|
62
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
63
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2));
|
|
64
|
+
renameSync(tmp, file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {boolean} */
|
|
68
|
+
export function workerEnabled(config, agent) {
|
|
69
|
+
return config.workers[agent]?.enabled === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 원본 config.json 문서를 다시 읽어 workers 만 병합한다. loadConfig 가 돌려주는 값은
|
|
74
|
+
* limits·allowedRoots 에 기본값이 채워진 파생값이라 그대로 되쓰면 사용자가 넣은 다른 키가
|
|
75
|
+
* 사라지므로 파일을 직접 읽는다(없거나 손상되면 {}). enable 은 {...기존, enabled: true} 병합,
|
|
76
|
+
* disable 은 키 삭제. bin `worker enable|disable`, `setup --workers`, `setup --remove` 가 쓴다.
|
|
77
|
+
* @param {string} home @param {string[]} agents @param {boolean} enabled
|
|
78
|
+
*/
|
|
79
|
+
export function setWorkerEnabled(home, agents, enabled) {
|
|
80
|
+
const file = join(home, "config.json");
|
|
81
|
+
let rawDoc = {};
|
|
82
|
+
if (existsSync(file)) {
|
|
83
|
+
try {
|
|
84
|
+
rawDoc = JSON.parse(readFileSync(file, "utf8"));
|
|
85
|
+
} catch {
|
|
86
|
+
rawDoc = {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!rawDoc || typeof rawDoc !== "object" || Array.isArray(rawDoc))
|
|
90
|
+
rawDoc = {};
|
|
91
|
+
const workers =
|
|
92
|
+
rawDoc.workers &&
|
|
93
|
+
typeof rawDoc.workers === "object" &&
|
|
94
|
+
!Array.isArray(rawDoc.workers)
|
|
95
|
+
? { ...rawDoc.workers }
|
|
96
|
+
: {};
|
|
97
|
+
for (const a of agents) {
|
|
98
|
+
if (enabled) workers[a] = { ...(workers[a] ?? {}), enabled: true };
|
|
99
|
+
else delete workers[a];
|
|
100
|
+
}
|
|
101
|
+
saveConfig(home, { ...rawDoc, workers });
|
|
102
|
+
}
|
package/src/shared/identity.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
|
|
4
5
|
/** ids.js의 shortId 알파벳(i, l, o, 0, 1 제외)과 같은 문자 집합 */
|
|
@@ -16,7 +17,8 @@ export function isValidAgentName(name) {
|
|
|
16
17
|
* @param {string} agent @param {NodeJS.ProcessEnv} env @returns {string}
|
|
17
18
|
*/
|
|
18
19
|
export function resolveAgentName(agent, env) {
|
|
19
|
-
if (agent === "antigravity" && env.ANTIGRAVITY_EDITOR_APP_ROOT)
|
|
20
|
+
if (agent === "antigravity" && env.ANTIGRAVITY_EDITOR_APP_ROOT)
|
|
21
|
+
return "antigravity-ide";
|
|
20
22
|
return agent;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -50,6 +52,15 @@ export function toolOf(id) {
|
|
|
50
52
|
* @param {string} tool @param {string} cwd @returns {string} `<tool>@<sha256 앞 8자>`
|
|
51
53
|
*/
|
|
52
54
|
export function cwdKey(tool, cwd) {
|
|
53
|
-
|
|
55
|
+
// 심볼릭 링크로 인한 별칭(예: macOS /var → /private/var, cwd 별칭 디렉터리)을
|
|
56
|
+
// 같은 키로 묶는다. 존재하지 않는 경로는 realpath 가 실패하므로 resolve 결과를 쓴다
|
|
57
|
+
// (커넥터가 아직 만들어지지 않은 폴더로 뜨는 드문 경우까지 키를 안정적으로 유지).
|
|
58
|
+
let p = resolve(cwd);
|
|
59
|
+
try {
|
|
60
|
+
p = realpathSync.native(p);
|
|
61
|
+
} catch {
|
|
62
|
+
// 그대로 둔다
|
|
63
|
+
}
|
|
64
|
+
const hash = createHash("sha256").update(p).digest("hex");
|
|
54
65
|
return `${tool}@${hash.slice(0, 8)}`;
|
|
55
66
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `<home>/hub.json` 락파일. 없거나 손상이면(pid·port 가 정수가 아니면 포함) null.
|
|
6
|
+
* 허브(lifecycle.js)와 setup 이 같이 읽는다 — setup 은 허브 코드 없이도 로드돼야 한다(Plan 4e).
|
|
7
|
+
* @param {string} home @returns {object|null}
|
|
8
|
+
*/
|
|
9
|
+
export function readLock(home) {
|
|
10
|
+
const lockPath = join(home, "hub.json");
|
|
11
|
+
if (!existsSync(lockPath)) return null;
|
|
12
|
+
try {
|
|
13
|
+
const doc = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
14
|
+
return Number.isInteger(doc?.pid) && Number.isInteger(doc?.port)
|
|
15
|
+
? doc
|
|
16
|
+
: null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|