@athsra/cli 1.0.4 → 1.1.1
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 +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +208 -61
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +77 -13
- package/src/lib/auth-proof.ts +26 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +112 -19
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- package/src/lib/service-tokens.ts +62 -0
package/src/lib/envelope.ts
CHANGED
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
type SecretEnvelopeV2,
|
|
23
23
|
} from '@athsra/crypto';
|
|
24
24
|
import {
|
|
25
|
+
addMemberRecipient,
|
|
26
|
+
createEnvelopeAsMember,
|
|
27
|
+
createEnvelopeWithSelf,
|
|
25
28
|
decryptEnvelopeAsMember,
|
|
26
29
|
reEncryptAsMember,
|
|
27
30
|
unwrapPrivateKey,
|
|
@@ -62,7 +65,54 @@ async function memberRecipientUserId(
|
|
|
62
65
|
return env.recipients.some((r) => r.id === `member:${me.userId}`) ? me.userId : null;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Phase 5 — master(owner) write 의 member:self 재료 (whoami + getKeys). best-effort: 키 미
|
|
70
|
+
* provisioning/오프라인이면 null → master-only 폴백(기존 동작 보존, migrate --self 가 다음 기회).
|
|
71
|
+
*/
|
|
72
|
+
async function selfMember(
|
|
73
|
+
ctx: UserAuthContext,
|
|
74
|
+
): Promise<{ publicKey: Uint8Array; userId: number } | null> {
|
|
75
|
+
try {
|
|
76
|
+
const me = await ctx.client.whoami();
|
|
77
|
+
if (me.userId === undefined) return null;
|
|
78
|
+
const keyRow = await ctx.client.getKeys();
|
|
79
|
+
if (!keyRow) return null;
|
|
80
|
+
return { publicKey: fromBase64(keyRow.public_key), userId: me.userId };
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** routine master write 시 member:self 부재면 1회 추가. 403/실패면 env 그대로(body-only 폴백). */
|
|
87
|
+
async function ensureSelfRecipient(
|
|
88
|
+
ctx: UserAuthContext,
|
|
89
|
+
env: SecretEnvelopeV2,
|
|
90
|
+
self: { publicKey: Uint8Array; userId: number } | null,
|
|
91
|
+
): Promise<SecretEnvelopeV2> {
|
|
92
|
+
if (!self) return env;
|
|
93
|
+
if (env.recipients.some((r) => r.id === `member:${self.userId}`)) return env;
|
|
94
|
+
try {
|
|
95
|
+
return await addMemberRecipient(env, ctx.masterPw, self.publicKey, self.userId);
|
|
96
|
+
} catch {
|
|
97
|
+
return env;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promise<string> {
|
|
102
|
+
// Phase 5 — identity 모드: master pw 없이 내 X25519 키로 멤버 경로 복호 (member:me recipient).
|
|
103
|
+
if (ctx.kind === 'identity') {
|
|
104
|
+
if (env.version !== 2) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'identity 로그인은 envelope v2 만 복호 가능 — master pw 머신에서 `athsra set` 1회로 v1→v2 마이그레이션 필요.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (!env.recipients.some((r) => r.id === `member:${ctx.userId}`)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'이 시크릿에 내 멤버 recipient 가 없습니다 — master pw 머신에서 `athsra migrate-envelopes --self` 1회 실행 후 재시도.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return decryptEnvelopeAsMember(env, ctx.identityPrivateKey, ctx.userId);
|
|
115
|
+
}
|
|
66
116
|
if (ctx.kind === 'user') {
|
|
67
117
|
if (env.version === 2) {
|
|
68
118
|
try {
|
|
@@ -100,8 +150,9 @@ async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promis
|
|
|
100
150
|
export async function readPlain(
|
|
101
151
|
ctx: AuthContext,
|
|
102
152
|
project: string,
|
|
153
|
+
config = 'default',
|
|
103
154
|
): Promise<Record<string, string> | null> {
|
|
104
|
-
const env = await ctx.client.getEnvelope(project);
|
|
155
|
+
const env = await ctx.client.getEnvelope(project, config);
|
|
105
156
|
if (!env) return null;
|
|
106
157
|
const text = await decryptEnvelope(env, ctx);
|
|
107
158
|
return parseEnv(text);
|
|
@@ -116,30 +167,67 @@ export async function writePlain(
|
|
|
116
167
|
ctx: AuthContext,
|
|
117
168
|
project: string,
|
|
118
169
|
plain: Record<string, string>,
|
|
119
|
-
opts?: { kdfParams?: KDFParams },
|
|
170
|
+
opts?: { kdfParams?: KDFParams; config?: string },
|
|
120
171
|
): Promise<SecretEnvelopeV2> {
|
|
121
|
-
if (ctx.kind
|
|
172
|
+
if (ctx.kind === 'service') {
|
|
122
173
|
throw new Error('service token cannot write envelopes — use a user token (master pw)');
|
|
123
174
|
}
|
|
175
|
+
const config = opts?.config ?? 'default';
|
|
124
176
|
const serialized = serializeEnv(plain);
|
|
125
177
|
|
|
178
|
+
// Phase 5 — identity 모드: master pw 없이 멤버 경로로 본문 재암호(recipients 보존)/신규 생성.
|
|
179
|
+
if (ctx.kind === 'identity') {
|
|
180
|
+
if (opts?.kdfParams) {
|
|
181
|
+
throw new Error('rotate-master 는 master pw 가 필요합니다 (identity 로그인 머신에선 불가).');
|
|
182
|
+
}
|
|
183
|
+
const existing = await ctx.client.getEnvelope(project, config);
|
|
184
|
+
if (existing && existing.version === 2) {
|
|
185
|
+
if (!existing.recipients.some((r) => r.id === `member:${ctx.userId}`)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'이 시크릿에 내 멤버 recipient 가 없습니다 — `athsra migrate-envelopes --self` 1회 후 재시도.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const env = await reEncryptAsMember(existing, ctx.identityPrivateKey, ctx.userId, serialized);
|
|
191
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
192
|
+
return env;
|
|
193
|
+
}
|
|
194
|
+
// 신규 프로젝트 — member:self 단독 envelope 생성 (server 의 내 public key 로 wrap).
|
|
195
|
+
const keyRow = await ctx.client.getKeys();
|
|
196
|
+
if (!keyRow) {
|
|
197
|
+
throw new Error('identity key 없음 — `athsra login` 으로 재온보딩 필요.');
|
|
198
|
+
}
|
|
199
|
+
const env = await createEnvelopeAsMember(serialized, fromBase64(keyRow.public_key), ctx.userId);
|
|
200
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
201
|
+
return env;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Phase 5 — master write 는 가능하면 member:self 동반(identity 로그인 머신이 멤버 경로로 복호하는
|
|
205
|
+
// substrate). whoami/getKeys best-effort — 실패(키 미provisioning/오프라인)면 master-only 폴백.
|
|
206
|
+
const self = await selfMember(ctx);
|
|
207
|
+
|
|
126
208
|
// opts.kdfParams 명시 = rotate-master 의 의도적 reset (ENTERPRISE_KDF 마이그레이션):
|
|
127
|
-
// 새 envelope 를 만들어 모든 service recipient 를 의도적으로
|
|
128
|
-
// = scoped 신뢰 reset). routine write 와 구분되는 유일한 신호.
|
|
209
|
+
// 새 envelope 를 만들어 모든 service recipient 를 의도적으로 폐기. master(+self) recipient 만.
|
|
129
210
|
if (opts?.kdfParams) {
|
|
130
|
-
const env =
|
|
131
|
-
|
|
211
|
+
const env = self
|
|
212
|
+
? await createEnvelopeWithSelf(serialized, ctx.masterPw, self.publicKey, self.userId, {
|
|
213
|
+
kdfParams: opts.kdfParams,
|
|
214
|
+
})
|
|
215
|
+
: await createEnvelopeV2(serialized, ctx.masterPw, { kdfParams: opts.kdfParams });
|
|
216
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
132
217
|
return env;
|
|
133
218
|
}
|
|
134
219
|
|
|
135
220
|
// routine write (set/unset/mcp/sync): 기존 v2 envelope 가 있으면 본문만 재암호화해
|
|
136
221
|
// kdf_params 와 모든 recipient (service token 포함) 를 보존한다. createEnvelopeV2 로
|
|
137
222
|
// 새로 만들면 service recipient 가 silent 삭제되고 kdf 가 DEFAULT(64MB) 로 downgrade 됨.
|
|
138
|
-
|
|
223
|
+
// config 전파 필수 — 생략 시 default envelope 를 읽어 다른 환경의 recipient 로 덮어쓰는 버그.
|
|
224
|
+
const existing = await ctx.client.getEnvelope(project, config);
|
|
139
225
|
if (existing && existing.version === 2) {
|
|
140
226
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
227
|
+
const reenc = await reEncryptEnvelopeBody(existing, ctx.masterPw, serialized);
|
|
228
|
+
// lazy self-grant: member:self 부재 시 1회 추가 (identity 복호 substrate). 실패=body-only.
|
|
229
|
+
const env = await ensureSelfRecipient(ctx, reenc, self);
|
|
230
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
143
231
|
return env;
|
|
144
232
|
} catch (masterErr) {
|
|
145
233
|
// Slice 6c — master(owner) 가 아니면 member 경로: 내 X25519 키로 DEK 얻어 본문만 재암호
|
|
@@ -149,14 +237,16 @@ export async function writePlain(
|
|
|
149
237
|
const priv = await memberPrivateKey(ctx);
|
|
150
238
|
if (!priv) throw masterErr;
|
|
151
239
|
const env = await reEncryptAsMember(existing, priv, memberUserId, serialized);
|
|
152
|
-
await ctx.client.putEnvelope(project, env);
|
|
240
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
153
241
|
return env;
|
|
154
242
|
}
|
|
155
243
|
}
|
|
156
244
|
|
|
157
|
-
// 신규 프로젝트 또는 v1 (recipients[] 없음) → 새 v2 (DEFAULT_KDF).
|
|
158
|
-
// 마이그레이션은 rotate-master / service-token create (migrateV1ToV2)
|
|
159
|
-
const env =
|
|
160
|
-
|
|
245
|
+
// 신규 프로젝트 또는 v1 (recipients[] 없음) → 새 v2 (DEFAULT_KDF). master(+self) recipient.
|
|
246
|
+
// v1 의 enterprise 마이그레이션은 rotate-master / service-token create (migrateV1ToV2) 가 담당.
|
|
247
|
+
const env = self
|
|
248
|
+
? await createEnvelopeWithSelf(serialized, ctx.masterPw, self.publicKey, self.userId)
|
|
249
|
+
: await createEnvelopeV2(serialized, ctx.masterPw);
|
|
250
|
+
await ctx.client.putEnvelope(project, env, config);
|
|
161
251
|
return env;
|
|
162
252
|
}
|
package/src/lib/identity-key.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* crypto 는 @athsra/crypto/member (subpath — worker 번들 격리).
|
|
7
7
|
*/
|
|
8
8
|
import { toBase64 } from '@athsra/crypto';
|
|
9
|
+
import { parseSealedBox, unseal } from '@athsra/crypto/device';
|
|
9
10
|
import { generateIdentityKeypair, unwrapPrivateKey, wrapPrivateKey } from '@athsra/crypto/member';
|
|
10
11
|
import type { AthsraClient, IdentityKeyRewrap } from './client.ts';
|
|
11
12
|
|
|
@@ -57,3 +58,23 @@ export async function rewrapForRotation(
|
|
|
57
58
|
kdf_params: rewrapped.kdfParams,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Phase 5 — device-login(identity) poll 의 sealed_identity_key(SealedBox JSON)를 디바이스 privkey
|
|
64
|
+
* 로 unseal → identity private key (base64). payload.user_id 를 poll 응답 user_id 와 대조해 키
|
|
65
|
+
* 혼선/스왑을 수령 시점에 차단한다. 변조·타 디바이스 키·user_id 불일치는 전부 throw.
|
|
66
|
+
*/
|
|
67
|
+
export async function unsealIdentityKey(
|
|
68
|
+
sealedJson: string,
|
|
69
|
+
devicePrivateKey: Uint8Array,
|
|
70
|
+
expectedUserId: number,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const box = parseSealedBox(sealedJson);
|
|
73
|
+
const payload = await unseal(box, devicePrivateKey);
|
|
74
|
+
if (payload.user_id !== expectedUserId) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`sealed identity user_id 불일치 (${payload.user_id} ≠ ${expectedUserId}) — 재시도 또는 \`athsra login\`.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return payload.identity_private_key;
|
|
80
|
+
}
|
package/src/lib/keyring.ts
CHANGED
|
@@ -72,6 +72,31 @@ export function clearDeviceToken(project: string): void {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Phase 5 (2026-06-12) — identity 디바이스 모드. `athsra login`(identity) 이 device flow 로
|
|
77
|
+
* 수령·unseal 한 identity X25519 private key (base64 32B) 를 머신 전역 slot 에 저장. master pw 가
|
|
78
|
+
* 이 머신에 없어도 멤버 경로로 envelope 복호. loadAuthContext 의 identity 모드가 조회.
|
|
79
|
+
*/
|
|
80
|
+
export function setIdentityKey(machineId: string, privateKeyB64: string): void {
|
|
81
|
+
entry(`identity-priv:${machineId}`).setPassword(privateKeyB64);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getIdentityKey(machineId: string): string | null {
|
|
85
|
+
try {
|
|
86
|
+
return entry(`identity-priv:${machineId}`).getPassword();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clearIdentityKey(machineId: string): void {
|
|
93
|
+
try {
|
|
94
|
+
entry(`identity-priv:${machineId}`).deletePassword();
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
export interface ProbeResult {
|
|
76
101
|
ok: boolean;
|
|
77
102
|
error?: string;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-register.ts — Phase 5 B6. MCP client 설정 파일에 athsra server entry 를 등록/갱신.
|
|
3
|
+
*
|
|
4
|
+
* `scripts/register-athsra-mcp.ts` 의 detectIndent/registerInJson 에서 이동·일반화 —
|
|
5
|
+
* `athsra mcp install` (제품 표면) 과 스크립트(thin wrapper)가 같은 코어를 소비.
|
|
6
|
+
*
|
|
7
|
+
* 원칙:
|
|
8
|
+
* - **최소 diff**: 기존 파일은 텍스트 수준 삽입/치환 (indent 감지·보존, 타 entry 재직렬화 X).
|
|
9
|
+
* - **upsert**: 미등록 → 추가, 등록 + 내용 상이(env 등) → athsra entry 만 치환, 동일 → no-op.
|
|
10
|
+
* - 클라이언트별 스키마: claude/cursor = `mcpServers`, vscode = `servers` (+type:stdio).
|
|
11
|
+
*/
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export type McpClient = 'claude' | 'cursor' | 'vscode';
|
|
16
|
+
export type McpScope = 'project' | 'user';
|
|
17
|
+
|
|
18
|
+
export interface AthsraServerEntry {
|
|
19
|
+
type?: 'stdio';
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
env?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 클라이언트별 athsra entry. cursor 는 type 필드 미사용 관례, claude/vscode 는 stdio 명시. */
|
|
26
|
+
export function buildEntry(
|
|
27
|
+
client: McpClient,
|
|
28
|
+
opts: { write?: boolean; admin?: boolean; readValues?: boolean } = {},
|
|
29
|
+
): AthsraServerEntry {
|
|
30
|
+
const entry: AthsraServerEntry = { command: 'athsra', args: ['mcp'] };
|
|
31
|
+
if (client !== 'cursor') entry.type = 'stdio';
|
|
32
|
+
const env: Record<string, string> = {};
|
|
33
|
+
if (opts.write) env.ATHSRA_MCP_WRITE = '1';
|
|
34
|
+
if (opts.admin) env.ATHSRA_MCP_ADMIN = '1';
|
|
35
|
+
if (opts.readValues) env.ATHSRA_MCP_READ_VALUES = '1';
|
|
36
|
+
if (Object.keys(env).length > 0) entry.env = env;
|
|
37
|
+
return entry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedTarget {
|
|
41
|
+
path: string;
|
|
42
|
+
rootKey: 'mcpServers' | 'servers';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 설정 파일 위치 + root key. vscode 는 project scope 만 (.vscode/mcp.json — user 위치 플랫폼 의존). */
|
|
46
|
+
export function resolveTarget(
|
|
47
|
+
client: McpClient,
|
|
48
|
+
scope: McpScope,
|
|
49
|
+
targetDir: string,
|
|
50
|
+
): ResolvedTarget | { error: string } {
|
|
51
|
+
if (scope === 'user') {
|
|
52
|
+
if (client === 'claude') {
|
|
53
|
+
return { path: join(homedir(), '.claude.json'), rootKey: 'mcpServers' };
|
|
54
|
+
}
|
|
55
|
+
if (client === 'cursor') {
|
|
56
|
+
return { path: join(homedir(), '.cursor', 'mcp.json'), rootKey: 'mcpServers' };
|
|
57
|
+
}
|
|
58
|
+
return { error: 'vscode 는 user scope 미지원 — project scope (.vscode/mcp.json) 사용' };
|
|
59
|
+
}
|
|
60
|
+
if (client === 'claude') return { path: join(targetDir, '.mcp.json'), rootKey: 'mcpServers' };
|
|
61
|
+
if (client === 'cursor') {
|
|
62
|
+
return { path: join(targetDir, '.cursor', 'mcp.json'), rootKey: 'mcpServers' };
|
|
63
|
+
}
|
|
64
|
+
return { path: join(targetDir, '.vscode', 'mcp.json'), rootKey: 'servers' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 기존 파일의 1-level indent 감지 (tab 또는 space). 기본 tab (.mcp.json universe 표준). */
|
|
68
|
+
export function detectIndent(raw: string): string {
|
|
69
|
+
const m = raw.match(/\n([\t ]+)"/);
|
|
70
|
+
if (!m?.[1]) return '\t';
|
|
71
|
+
return m[1][0] === '\t' ? '\t' : m[1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ConfigJson {
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UpsertResult {
|
|
79
|
+
changed: boolean;
|
|
80
|
+
reason: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** key("…") 다음에 오는 object 의 `{…}` 범위를 string-aware brace matching 으로 찾는다. */
|
|
84
|
+
function findKeyObjectBounds(
|
|
85
|
+
raw: string,
|
|
86
|
+
quotedKey: string,
|
|
87
|
+
from = 0,
|
|
88
|
+
): { keyStart: number; open: number; close: number } | null {
|
|
89
|
+
const ki = raw.indexOf(quotedKey, from);
|
|
90
|
+
if (ki < 0) return null;
|
|
91
|
+
const open = raw.indexOf('{', ki);
|
|
92
|
+
if (open < 0) return null;
|
|
93
|
+
let depth = 0;
|
|
94
|
+
let inStr = false;
|
|
95
|
+
let esc = false;
|
|
96
|
+
for (let i = open; i < raw.length; i++) {
|
|
97
|
+
const ch = raw[i];
|
|
98
|
+
if (esc) {
|
|
99
|
+
esc = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '\\') {
|
|
103
|
+
esc = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '"') {
|
|
107
|
+
inStr = !inStr;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (inStr) continue;
|
|
111
|
+
if (ch === '{') depth++;
|
|
112
|
+
else if (ch === '}') {
|
|
113
|
+
depth--;
|
|
114
|
+
if (depth === 0) return { keyStart: ki, open, close: i };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** athsra entry 수동 직렬화 — args 단일라인 등 기존 .mcp.json 관례 유지 (JSON.stringify 다행 배열 회피). */
|
|
121
|
+
function serializeEntry(entry: AthsraServerEntry, indent: string, level: number): string {
|
|
122
|
+
const pad = indent.repeat(level);
|
|
123
|
+
const inner = indent.repeat(level + 1);
|
|
124
|
+
const fields: string[] = [];
|
|
125
|
+
if (entry.type) fields.push(`${inner}"type": "stdio"`);
|
|
126
|
+
fields.push(`${inner}"command": ${JSON.stringify(entry.command)}`);
|
|
127
|
+
fields.push(`${inner}"args": [${entry.args.map((a) => JSON.stringify(a)).join(', ')}]`);
|
|
128
|
+
if (entry.env) {
|
|
129
|
+
const envInner = indent.repeat(level + 2);
|
|
130
|
+
const envLines = Object.entries(entry.env)
|
|
131
|
+
.map(([k, v]) => `${envInner}${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
132
|
+
.join(',\n');
|
|
133
|
+
fields.push(`${inner}"env": {\n${envLines}\n${inner}}`);
|
|
134
|
+
}
|
|
135
|
+
return `${pad}"athsra": {\n${fields.join(',\n')}\n${pad}}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** sorted-key stringify — entry 동일성 비교 (파일 내 key 순서 무관). */
|
|
139
|
+
function stable(v: unknown): string {
|
|
140
|
+
if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
|
|
141
|
+
if (v !== null && typeof v === 'object') {
|
|
142
|
+
const o = v as Record<string, unknown>;
|
|
143
|
+
return `{${Object.keys(o)
|
|
144
|
+
.sort()
|
|
145
|
+
.map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
|
|
146
|
+
.join(',')}}`;
|
|
147
|
+
}
|
|
148
|
+
return JSON.stringify(v);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 파일 부재 시의 신규 내용 (tab indent, universe 표준). */
|
|
152
|
+
export function freshFileContent(
|
|
153
|
+
rootKey: 'mcpServers' | 'servers',
|
|
154
|
+
entry: AthsraServerEntry,
|
|
155
|
+
): string {
|
|
156
|
+
return `{\n\t"${rootKey}": {\n${serializeEntry(entry, '\t', 2)}\n\t}\n}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 기존 설정 텍스트에 athsra entry upsert (최소 diff).
|
|
161
|
+
* - 미등록 → rootKey 블록 마지막 entry 뒤 삽입 (comma 처리)
|
|
162
|
+
* - 등록 + 동일 → { changed:false, reason:'already registered' }
|
|
163
|
+
* - 등록 + 상이 → athsra entry 범위만 치환 (reason:'updated')
|
|
164
|
+
* - rootKey 블록 부재 → parse 기반 전체 재직렬화 fallback
|
|
165
|
+
*/
|
|
166
|
+
export function upsertServerEntry(
|
|
167
|
+
raw: string,
|
|
168
|
+
rootKey: 'mcpServers' | 'servers',
|
|
169
|
+
entry: AthsraServerEntry,
|
|
170
|
+
): { result: UpsertResult; output?: string } {
|
|
171
|
+
const json = JSON.parse(raw) as ConfigJson;
|
|
172
|
+
const root = (json[rootKey] ?? {}) as Record<string, unknown>;
|
|
173
|
+
const indent = detectIndent(raw);
|
|
174
|
+
|
|
175
|
+
const existing = root.athsra;
|
|
176
|
+
if (existing !== undefined && stable(existing) === stable(entry)) {
|
|
177
|
+
return { result: { changed: false, reason: 'already registered' } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const rootBounds = findKeyObjectBounds(raw, `"${rootKey}"`);
|
|
181
|
+
if (!rootBounds) {
|
|
182
|
+
// rootKey 블록 미발견 — fallback: parse 기반 재직렬화.
|
|
183
|
+
json[rootKey] = { ...root, athsra: entry };
|
|
184
|
+
return {
|
|
185
|
+
result: {
|
|
186
|
+
changed: true,
|
|
187
|
+
reason: existing ? 'updated (reserialized)' : 'added (reserialized)',
|
|
188
|
+
},
|
|
189
|
+
output: `${JSON.stringify(json, null, indent)}\n`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (existing !== undefined) {
|
|
194
|
+
// 치환 — athsra entry 범위만 교체 (선행 indent 는 기존 텍스트 그대로).
|
|
195
|
+
const entryBounds = findKeyObjectBounds(raw, '"athsra"', rootBounds.open);
|
|
196
|
+
if (!entryBounds || entryBounds.keyStart > rootBounds.close) {
|
|
197
|
+
json[rootKey] = { ...root, athsra: entry };
|
|
198
|
+
return {
|
|
199
|
+
result: { changed: true, reason: 'updated (reserialized)' },
|
|
200
|
+
output: `${JSON.stringify(json, null, indent)}\n`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const pad = indent.repeat(2);
|
|
204
|
+
const replacement = serializeEntry(entry, indent, 2).slice(pad.length);
|
|
205
|
+
const output =
|
|
206
|
+
raw.slice(0, entryBounds.keyStart) + replacement + raw.slice(entryBounds.close + 1);
|
|
207
|
+
return { result: { changed: true, reason: 'updated' }, output };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 삽입 — rootKey 닫는 `}` 직전, 마지막 entry 뒤 (comma 처리).
|
|
211
|
+
const entryText = serializeEntry(entry, indent, 2);
|
|
212
|
+
const entryCount = Object.keys(root).length;
|
|
213
|
+
const head = raw.slice(0, rootBounds.close);
|
|
214
|
+
const tail = raw.slice(rootBounds.close);
|
|
215
|
+
const headTrimmed = head.replace(/\s*$/, '');
|
|
216
|
+
const trailingWs = head.slice(headTrimmed.length);
|
|
217
|
+
|
|
218
|
+
const output =
|
|
219
|
+
entryCount === 0
|
|
220
|
+
? `${headTrimmed}\n${entryText}${trailingWs}${tail}`
|
|
221
|
+
: `${headTrimmed},\n${entryText}${trailingWs}${tail}`;
|
|
222
|
+
return { result: { changed: true, reason: 'added' }, output };
|
|
223
|
+
}
|