@eightstate/escli 0.7.1 → 0.8.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.
@@ -6,7 +6,7 @@ import { BaseCommand } from '../../../base-command.js';
6
6
  import { writeStderr, writeStdout } from '../../../io/io.js';
7
7
  import { renderTrailer } from '../../../io/render-trailer.js';
8
8
  import { EscliError } from '../../../lib/escli-error.js';
9
- import { NOTION_VERSION } from '../../../services/notion.js';
9
+ import { NOTION_VERSION, notionUsersList } from '../../../services/notion.js';
10
10
  import { hasManifestFlag } from '../manifest-pass.js';
11
11
  export const notionCommentErrors = [
12
12
  ErrorCode.UsageInvalid,
@@ -26,6 +26,7 @@ export const notionCommentErrors = [
26
26
  ErrorCode.ServiceUnavailable,
27
27
  ErrorCode.ApiError,
28
28
  ];
29
+ const MAX_USER_PAGES = 5;
29
30
  export class NotionCommentsCommand extends BaseCommand {
30
31
  rawMode = false;
31
32
  nextToken = null;
@@ -170,13 +171,44 @@ export function shapeReceipt(result, fallback) {
170
171
  created_time: formatCreated(stringValue(data?.created_time)),
171
172
  body,
172
173
  warning: partial ? 'partial_comment_response' : undefined,
174
+ warnings: fallback.warnings,
173
175
  };
174
176
  }
175
177
  export function createBodyFromMarkdown(target, markdown) {
176
- return { parent: target.kind === 'block' ? { block_id: target.id } : { page_id: target.id }, markdown };
178
+ return { parent: target.kind === 'block' ? { block_id: target.id } : { page_id: target.id }, markdown: escapeUnsupportedCustomEmojiMarkdown(markdown) };
179
+ }
180
+ export function createBodyWithRichTextMentions(target, markdown, mentions) {
181
+ return { parent: target.kind === 'block' ? { block_id: target.id } : { page_id: target.id }, rich_text: richTextFromMentions(markdown, mentions) };
177
182
  }
178
183
  export function replyBodyFromMarkdown(discussionId, markdown) {
179
- return { discussion_id: discussionId, markdown };
184
+ return { discussion_id: discussionId, markdown: escapeUnsupportedCustomEmojiMarkdown(markdown) };
185
+ }
186
+ export function replyBodyWithRichTextMentions(discussionId, markdown, mentions) {
187
+ return { discussion_id: discussionId, rich_text: richTextFromMentions(markdown, mentions) };
188
+ }
189
+ export async function resolveCommentMentions(markdown, specs) {
190
+ const tokens = Array.from(new Set(specs.flatMap((spec) => splitMentionSpec(spec))));
191
+ if (tokens.length === 0)
192
+ return { mentions: [], warnings: [] };
193
+ const users = await listNotionUsers();
194
+ const mentions = [];
195
+ const warnings = [];
196
+ for (const token of tokens) {
197
+ const user = resolveUserToken(token, users);
198
+ if (!user) {
199
+ warnings.push(`mention not resolved: ${token}`);
200
+ continue;
201
+ }
202
+ const display = user.name ?? user.email ?? user.id;
203
+ mentions.push({ token, userId: user.id, display });
204
+ const atToken = token.startsWith('@') ? token : `@${token}`;
205
+ if (!markdown.includes(atToken) && !markdown.includes(user.id))
206
+ warnings.push(`mention resolved but no ${atToken} placeholder found; prepending mention`);
207
+ }
208
+ return { mentions, warnings };
209
+ }
210
+ export function escapeUnsupportedCustomEmojiMarkdown(markdown) {
211
+ return markdown.replace(/(?<!\\):([\p{Letter}\p{Number}_+-]+):/gu, '\\:$1\\:');
180
212
  }
181
213
  export function joinMarkdown(value) {
182
214
  const markdown = Array.isArray(value) ? value.join(' ') : value ?? '';
@@ -185,9 +217,101 @@ export function joinMarkdown(value) {
185
217
  }
186
218
  return markdown;
187
219
  }
220
+ export function richTextFromMentions(markdown, mentions) {
221
+ if (mentions.length === 0)
222
+ return [{ type: 'text', text: { content: markdown } }];
223
+ const richText = [];
224
+ let remaining = markdown;
225
+ const sorted = [...mentions].sort((a, b) => b.token.length - a.token.length);
226
+ while (remaining.length > 0) {
227
+ const next = nextMentionMatch(remaining, sorted);
228
+ if (!next) {
229
+ appendText(richText, remaining);
230
+ remaining = '';
231
+ break;
232
+ }
233
+ if (next.index > 0)
234
+ appendText(richText, remaining.slice(0, next.index));
235
+ appendMention(richText, next.mention);
236
+ remaining = remaining.slice(next.index + next.match.length);
237
+ }
238
+ const missing = mentions.filter((mention) => !markdown.includes(mention.token.startsWith('@') ? mention.token : `@${mention.token}`) && !markdown.includes(mention.userId));
239
+ if (missing.length > 0) {
240
+ const prefix = [];
241
+ for (const mention of missing) {
242
+ appendMention(prefix, mention);
243
+ appendText(prefix, ' ');
244
+ }
245
+ return [...prefix, ...richText];
246
+ }
247
+ return richText;
248
+ }
249
+ export function splitMentionSpec(spec) {
250
+ return spec.split(',').map((item) => item.trim()).filter(Boolean);
251
+ }
188
252
  export function kvEntries(input) {
189
253
  return Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== '');
190
254
  }
255
+ function nextMentionMatch(markdown, mentions) {
256
+ let best;
257
+ for (const mention of mentions) {
258
+ const candidates = [mention.token.startsWith('@') ? mention.token : `@${mention.token}`, mention.userId];
259
+ for (const candidate of candidates) {
260
+ const index = markdown.indexOf(candidate);
261
+ if (index === -1)
262
+ continue;
263
+ if (!best || index < best.index || (index === best.index && candidate.length > best.match.length))
264
+ best = { index, match: candidate, mention };
265
+ }
266
+ }
267
+ return best;
268
+ }
269
+ function appendText(richText, content) {
270
+ if (content.length === 0)
271
+ return;
272
+ richText.push({ type: 'text', text: { content } });
273
+ }
274
+ function appendMention(richText, mention) {
275
+ richText.push({ type: 'mention', mention: { type: 'user', user: { id: mention.userId } } });
276
+ }
277
+ async function listNotionUsers() {
278
+ const users = [];
279
+ let cursor;
280
+ for (let page = 0; page < MAX_USER_PAGES; page += 1) {
281
+ const result = await notionUsersList(cursor);
282
+ const data = recordValue(result.data);
283
+ users.push(...arrayValue(data?.results).map(shapeUser).filter((user) => Boolean(user)));
284
+ const next = stringValue(data?.next_cursor) ?? stringValue(result.meta.next);
285
+ if (!next || data?.has_more === false)
286
+ break;
287
+ cursor = next;
288
+ }
289
+ return users;
290
+ }
291
+ function shapeUser(value) {
292
+ const data = recordValue(value);
293
+ const id = stringValue(data?.id);
294
+ if (!id)
295
+ return undefined;
296
+ const person = recordValue(data?.person);
297
+ const type = data?.type === 'person' || data?.type === 'bot' ? data.type : undefined;
298
+ return { id, name: stringValue(data?.name), email: stringValue(person?.email), type };
299
+ }
300
+ function resolveUserToken(token, users) {
301
+ const normalized = token.replace(/^@/u, '').trim().toLowerCase();
302
+ if (!normalized)
303
+ return undefined;
304
+ if (isUuidLike(normalized))
305
+ return users.find((user) => user.id.toLowerCase() === normalizeId(normalized)) ?? { id: normalizeId(normalized) };
306
+ const exact = users.filter((user) => [user.name, user.email].some((value) => value?.toLowerCase() === normalized));
307
+ if (exact.length === 1)
308
+ return exact[0];
309
+ const loose = users.filter((user) => [user.name, user.email].some((value) => value?.toLowerCase().includes(normalized)));
310
+ return loose.length === 1 ? loose[0] : undefined;
311
+ }
312
+ function isUuidLike(value) {
313
+ return /^[0-9a-f]{32}$/iu.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
314
+ }
191
315
  function shapeComment(value) {
192
316
  const data = recordValue(value);
193
317
  const parent = recordValue(data?.parent);
@@ -328,7 +328,7 @@ export function buildEditBody(ops, allowDeletingContent) {
328
328
  export function buildReplaceBody(markdown, allowDeletingContent) {
329
329
  return {
330
330
  type: 'replace_content',
331
- replace_content: { markdown },
331
+ replace_content: { new_str: markdown },
332
332
  ...(allowDeletingContent ? { allow_deleting_content: true } : {}),
333
333
  };
334
334
  }
@@ -51,6 +51,11 @@ import NotionUpload from '../commands/notion/upload/index.js';
51
51
  import NotionUploadAttach from '../commands/notion/upload/attach.js';
52
52
  import NotionUploadStatus from '../commands/notion/upload/status.js';
53
53
  import NotionUploadList from '../commands/notion/upload/list.js';
54
+ import McpTopic from '../commands/mcp/index.js';
55
+ import McpRegister, { McpRegisterDataSchema } from '../commands/mcp/register.js';
56
+ import McpServe, { McpServeDataSchema } from '../commands/mcp/serve.js';
57
+ import McpStatus, { McpStatusDataSchema } from '../commands/mcp/status.js';
58
+ import McpRevoke, { McpRevokeDataSchema } from '../commands/mcp/revoke.js';
54
59
  import { registerCommandMetadata } from './command-metadata.js';
55
60
  import { renderAuthLogin, renderAuthLogout, renderAuthProfiles, renderAuthStatus, renderAuthSwitch, renderDocsContent, renderDocsSearch, renderFetch, renderModelList, renderResearch, renderSearch, renderSocial, renderUsage, renderVersion, } from '../io/io.js';
56
61
  const asCommandClass = (c) => c;
@@ -107,6 +112,11 @@ const commandClasses = [
107
112
  { id: 'notion upload attach', command: asCommandClass(NotionUploadAttach) },
108
113
  { id: 'notion upload status', command: asCommandClass(NotionUploadStatus) },
109
114
  { id: 'notion upload list', command: asCommandClass(NotionUploadList) },
115
+ { id: 'mcp', command: asCommandClass(McpTopic) },
116
+ { id: 'mcp register', command: asCommandClass(McpRegister) },
117
+ { id: 'mcp serve', command: asCommandClass(McpServe) },
118
+ { id: 'mcp status', command: asCommandClass(McpStatus) },
119
+ { id: 'mcp revoke', command: asCommandClass(McpRevoke) },
110
120
  ];
111
121
  const aliasOverrides = new Map([
112
122
  ['image', ['img', 'i']],
@@ -143,6 +153,7 @@ const aliasOverrides = new Map([
143
153
  ['notion upload attach', ['notion file-upload attach']],
144
154
  ['notion upload status', ['notion file-upload status']],
145
155
  ['notion upload list', ['notion file-upload list', 'notion file-upload ls', 'notion upload ls']],
156
+ ['mcp', []],
146
157
  ]);
147
158
  const metadataEntries = [
148
159
  ['image', { inputSchema: z.object({}), dataSchema: z.object({}) }],
@@ -284,6 +295,31 @@ const metadataEntries = [
284
295
  ['notion upload attach', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
285
296
  ['notion upload status', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
286
297
  ['notion upload list', { inputSchema: z.object({}).passthrough(), dataSchema: z.unknown() }],
298
+ ['mcp', { inputSchema: z.object({}), dataSchema: z.object({}) }],
299
+ ['mcp register', { inputSchema: z.object({
300
+ root: z.string(),
301
+ slug: z.string().optional(),
302
+ port: z.number().int().optional(),
303
+ label: z.string().optional(),
304
+ 'cf-account-id': z.string().optional(),
305
+ 'cf-zone-id': z.string().optional(),
306
+ 'cf-api-token': z.string().optional(),
307
+ }), dataSchema: McpRegisterDataSchema }],
308
+ ['mcp serve', { inputSchema: z.object({
309
+ root: z.string(),
310
+ slug: z.string().optional(),
311
+ port: z.number().int().optional(),
312
+ verbose: z.boolean().optional(),
313
+ }), dataSchema: McpServeDataSchema }],
314
+ ['mcp status', { inputSchema: z.object({ slug: z.string().optional() }), dataSchema: McpStatusDataSchema }],
315
+ ['mcp revoke', { inputSchema: z.object({
316
+ slug: z.string().optional(),
317
+ cloudflare: z.boolean().optional(),
318
+ yes: z.boolean().optional(),
319
+ 'cf-account-id': z.string().optional(),
320
+ 'cf-zone-id': z.string().optional(),
321
+ 'cf-api-token': z.string().optional(),
322
+ }), dataSchema: McpRevokeDataSchema }],
287
323
  ];
288
324
  const metadata = new Map(metadataEntries);
289
325
  export const commandRegistry = commandClasses.map(({ id, command }) => buildRegistration(id, command));
@@ -0,0 +1,18 @@
1
+ import { appendFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { ensureMcpDir, mcpLogsDir } from './config.js';
4
+ export async function writeAuditRecord(slug, record) {
5
+ const dir = mcpLogsDir();
6
+ await ensureMcpDir(dir);
7
+ const safe = {
8
+ timestamp: record.timestamp ?? new Date().toISOString(),
9
+ tool: record.tool,
10
+ target: record.target,
11
+ status: record.status,
12
+ duration_ms: record.duration_ms,
13
+ bytes: record.bytes,
14
+ truncated: record.truncated,
15
+ };
16
+ await appendFile(join(dir, `${slug}.jsonl`), `${JSON.stringify(safe)}\n`, { mode: 0o600 });
17
+ }
18
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1,91 @@
1
+ import { ErrorCode } from '@eightstate/contracts/errors';
2
+ import { ExitCodes } from '@eightstate/contracts/exit-codes';
3
+ import { EscliError } from '../../lib/escli-error.js';
4
+ import { parseJsonResponse, recordValue, stringValue, usageError } from './common.js';
5
+ const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
6
+ export class CloudflareClient {
7
+ credentials;
8
+ constructor(input) {
9
+ this.credentials = resolveCloudflareCredentials(input);
10
+ }
11
+ async createTunnel(slug) {
12
+ const data = await this.request(`/accounts/${encodeURIComponent(this.credentials.accountId)}/cfd_tunnel`, 'POST', {
13
+ name: `eightstate-mcp-${slug}`,
14
+ config_src: 'cloudflare',
15
+ });
16
+ const result = recordValue(recordValue(data)?.result);
17
+ const id = stringValue(result?.id);
18
+ const token = stringValue(result?.token);
19
+ if (!id || !token)
20
+ throw cfInvalid('invalid Cloudflare tunnel response', data);
21
+ return { id, token };
22
+ }
23
+ async configureIngress(tunnelId, hostname, port) {
24
+ await this.request(`/accounts/${encodeURIComponent(this.credentials.accountId)}/cfd_tunnel/${encodeURIComponent(tunnelId)}/configurations`, 'PUT', {
25
+ config: {
26
+ ingress: [
27
+ { hostname, service: `http://localhost:${port}`, originRequest: {} },
28
+ { service: 'http_status:404' },
29
+ ],
30
+ },
31
+ });
32
+ }
33
+ async createDnsRecord(hostname, tunnelId) {
34
+ const data = await this.request(`/zones/${encodeURIComponent(this.credentials.zoneId)}/dns_records`, 'POST', {
35
+ type: 'CNAME',
36
+ proxied: true,
37
+ name: hostname,
38
+ content: `${tunnelId}.cfargotunnel.com`,
39
+ });
40
+ const result = recordValue(recordValue(data)?.result);
41
+ const id = stringValue(result?.id);
42
+ const name = stringValue(result?.name) ?? hostname;
43
+ if (!id)
44
+ throw cfInvalid('invalid Cloudflare DNS response', data);
45
+ return { id, name };
46
+ }
47
+ async deleteDnsRecord(dnsRecordId) {
48
+ await this.request(`/zones/${encodeURIComponent(this.credentials.zoneId)}/dns_records/${encodeURIComponent(dnsRecordId)}`, 'DELETE');
49
+ }
50
+ async deleteTunnel(tunnelId) {
51
+ await this.request(`/accounts/${encodeURIComponent(this.credentials.accountId)}/cfd_tunnel/${encodeURIComponent(tunnelId)}`, 'DELETE');
52
+ }
53
+ async request(path, method, body) {
54
+ let response;
55
+ try {
56
+ response = await fetch(`${CF_API_BASE}${path}`, {
57
+ method,
58
+ headers: {
59
+ authorization: `Bearer ${this.credentials.apiToken}`,
60
+ accept: 'application/json',
61
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
62
+ },
63
+ body: body === undefined ? undefined : JSON.stringify(body),
64
+ });
65
+ }
66
+ catch (error) {
67
+ throw new EscliError('Cloudflare API unavailable', { code: ErrorCode.NetworkError, exitCode: ExitCodes.Transient, details: error instanceof Error ? error.message : error });
68
+ }
69
+ const data = await parseJsonResponse(response);
70
+ if (!response.ok)
71
+ throw new EscliError(`Cloudflare API request failed (${response.status})`, { code: ErrorCode.ApiError, exitCode: ExitCodes.Transient, details: data });
72
+ return data;
73
+ }
74
+ }
75
+ export function resolveCloudflareCredentials(input) {
76
+ const accountId = input.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
77
+ const zoneId = input.zoneId ?? process.env.CLOUDFLARE_ZONE_ID;
78
+ const apiToken = input.apiToken ?? process.env.CLOUDFLARE_API_TOKEN;
79
+ const missing = [
80
+ accountId ? undefined : 'CLOUDFLARE_ACCOUNT_ID',
81
+ zoneId ? undefined : 'CLOUDFLARE_ZONE_ID',
82
+ apiToken ? undefined : 'CLOUDFLARE_API_TOKEN',
83
+ ].filter(Boolean);
84
+ if (!accountId || !zoneId || !apiToken)
85
+ throw usageError(`missing Cloudflare credentials: ${missing.join(', ')}`);
86
+ return { accountId, zoneId, apiToken };
87
+ }
88
+ function cfInvalid(message, details) {
89
+ return new EscliError(message, { code: ErrorCode.GateInvalidResponse, exitCode: ExitCodes.Transient, details });
90
+ }
91
+ //# sourceMappingURL=cloudflare.js.map
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { access } from 'node:fs/promises';
3
+ import { delimiter, join } from 'node:path';
4
+ import { ErrorCode } from '@eightstate/contracts/errors';
5
+ import { ExitCodes } from '@eightstate/contracts/exit-codes';
6
+ import { EscliError } from '../../lib/escli-error.js';
7
+ import { redactSecrets } from './common.js';
8
+ export async function findCloudflared() {
9
+ const path = process.env.PATH ?? '';
10
+ for (const dir of path.split(delimiter)) {
11
+ if (!dir)
12
+ continue;
13
+ const candidate = join(dir, 'cloudflared');
14
+ try {
15
+ await access(candidate);
16
+ return candidate;
17
+ }
18
+ catch {
19
+ // continue searching PATH
20
+ }
21
+ }
22
+ throw new EscliError('cloudflared not found in PATH', {
23
+ code: ErrorCode.FileNotFound,
24
+ exitCode: ExitCodes.NotFound,
25
+ remediation: { command: 'brew install cloudflared' },
26
+ });
27
+ }
28
+ export async function startCloudflared(token, options) {
29
+ const binary = await findCloudflared();
30
+ const child = spawn(binary, ['tunnel', '--no-autoupdate', 'run', '--token', token], { stdio: ['ignore', 'pipe', 'pipe'] });
31
+ let stopping = false;
32
+ const logChunk = (chunk) => options.log?.(redactSecrets(chunk.toString('utf8'), [token]));
33
+ child.stdout.on('data', logChunk);
34
+ child.stderr.on('data', logChunk);
35
+ child.on('exit', (code, signal) => {
36
+ if (!stopping)
37
+ options.onExit(code, signal);
38
+ });
39
+ return {
40
+ child,
41
+ stop: () => new Promise((resolve) => {
42
+ if (child.exitCode !== null || child.signalCode !== null) {
43
+ resolve();
44
+ return;
45
+ }
46
+ stopping = true;
47
+ const timer = setTimeout(() => {
48
+ child.kill('SIGKILL');
49
+ resolve();
50
+ }, 3000);
51
+ child.once('exit', () => {
52
+ clearTimeout(timer);
53
+ resolve();
54
+ });
55
+ child.kill('SIGTERM');
56
+ }),
57
+ };
58
+ }
59
+ //# sourceMappingURL=cloudflared.js.map
@@ -0,0 +1,65 @@
1
+ import { ErrorCode } from '@eightstate/contracts/errors';
2
+ import { ExitCodes } from '@eightstate/contracts/exit-codes';
3
+ import { EscliError } from '../../lib/escli-error.js';
4
+ export const DEFAULT_GATE_URL = 'https://internal.eightstate.co';
5
+ export const DEFAULT_MCP_DOMAIN = 'eightstate.co';
6
+ export const MCP_AUTH_SERVER = 'https://internal.eightstate.co';
7
+ export function configError(message, details) {
8
+ return new EscliError(message, { code: ErrorCode.ConfigInvalid, exitCode: ExitCodes.Error, details });
9
+ }
10
+ export function usageError(message, details) {
11
+ return new EscliError(message, { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage, details });
12
+ }
13
+ export function authRequired(message = 'not authenticated') {
14
+ return new EscliError(message, {
15
+ code: ErrorCode.AuthRequired,
16
+ exitCode: ExitCodes.Auth,
17
+ remediation: { command: 'escli auth login' },
18
+ });
19
+ }
20
+ export function gateUrl() {
21
+ return stripTrailingSlash(process.env.ESCLI_GATE_URL ?? DEFAULT_GATE_URL);
22
+ }
23
+ export function stripTrailingSlash(value) {
24
+ return value.replace(/\/+$/u, '');
25
+ }
26
+ export async function parseJsonResponse(response) {
27
+ const text = await response.text();
28
+ if (!text)
29
+ return {};
30
+ try {
31
+ return JSON.parse(text);
32
+ }
33
+ catch {
34
+ return text;
35
+ }
36
+ }
37
+ export function recordValue(value) {
38
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
39
+ }
40
+ export function stringValue(value) {
41
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
42
+ }
43
+ export function numberValue(value) {
44
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
45
+ }
46
+ export function maskSecret(value) {
47
+ if (!value)
48
+ return '';
49
+ return value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : '***';
50
+ }
51
+ export function redactSecrets(text, secrets = []) {
52
+ let redacted = text;
53
+ for (const secret of secrets) {
54
+ if (secret)
55
+ redacted = redacted.split(secret).join('[redacted]');
56
+ }
57
+ return redacted
58
+ .replace(/mcp_at_[A-Za-z0-9_-]+/gu, 'mcp_at_[redacted]')
59
+ .replace(/mcp_ms_[A-Za-z0-9_-]+/gu, 'mcp_ms_[redacted]')
60
+ .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/gu, 'jwt_[redacted]');
61
+ }
62
+ export function isNodeError(error) {
63
+ return error instanceof Error && 'code' in error;
64
+ }
65
+ //# sourceMappingURL=common.js.map
@@ -0,0 +1,131 @@
1
+ import { chmod, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { McpLocalConfigSchema } from '@eightstate/contracts';
5
+ import { configError, isNodeError } from './common.js';
6
+ const MACHINE_DIR_MODE = 0o700;
7
+ const MACHINE_FILE_MODE = 0o600;
8
+ export function escliConfigDir() {
9
+ return process.env.ESCLI_CONFIG_DIR ?? join(homedir(), '.escli');
10
+ }
11
+ export function mcpConfigDir() {
12
+ return join(escliConfigDir(), 'mcp');
13
+ }
14
+ export function mcpMachinesDir() {
15
+ return join(mcpConfigDir(), 'machines');
16
+ }
17
+ export function mcpLogsDir() {
18
+ return join(mcpConfigDir(), 'logs');
19
+ }
20
+ export function machineConfigPath(slug) {
21
+ assertSafeSlug(slug);
22
+ return join(mcpMachinesDir(), `${slug}.json`);
23
+ }
24
+ export async function ensureMcpDir(path) {
25
+ await mkdir(path, { recursive: true, mode: MACHINE_DIR_MODE });
26
+ for (const dir of mcpParentDirs(path))
27
+ await chmod(dir, MACHINE_DIR_MODE).catch(() => { });
28
+ }
29
+ export async function writeMachineConfig(config) {
30
+ const parsed = McpLocalConfigSchema.parse(config);
31
+ const path = machineConfigPath(parsed.slug);
32
+ await ensureMcpDir(dirname(path));
33
+ const tempPath = join(dirname(path), `.${parsed.slug}.${process.pid}.${Date.now()}.tmp`);
34
+ const body = `${JSON.stringify(parsed, null, 2)}\n`;
35
+ try {
36
+ await writeFile(tempPath, body, { mode: MACHINE_FILE_MODE, flag: 'wx' });
37
+ await chmod(tempPath, MACHINE_FILE_MODE);
38
+ await rename(tempPath, path);
39
+ await chmod(path, MACHINE_FILE_MODE);
40
+ return path;
41
+ }
42
+ catch (error) {
43
+ await rm(tempPath, { force: true }).catch(() => { });
44
+ throw error;
45
+ }
46
+ }
47
+ export async function readMachineConfig(slug) {
48
+ const path = machineConfigPath(slug);
49
+ let raw;
50
+ try {
51
+ raw = await readFile(path, 'utf8');
52
+ }
53
+ catch (error) {
54
+ if (isNodeError(error) && error.code === 'ENOENT')
55
+ throw configError(`MCP machine '${slug}' not found`, path);
56
+ throw configError('failed to read MCP config', path);
57
+ }
58
+ try {
59
+ return McpLocalConfigSchema.parse(JSON.parse(raw));
60
+ }
61
+ catch (error) {
62
+ throw configError('invalid MCP machine config', { path, error });
63
+ }
64
+ }
65
+ export async function listMachineConfigs() {
66
+ let entries;
67
+ try {
68
+ entries = await readdir(mcpMachinesDir());
69
+ }
70
+ catch (error) {
71
+ if (isNodeError(error) && error.code === 'ENOENT')
72
+ return [];
73
+ throw configError('failed to list MCP machine configs', mcpMachinesDir());
74
+ }
75
+ const configs = [];
76
+ for (const entry of entries.filter((name) => name.endsWith('.json')).sort()) {
77
+ const slug = entry.slice(0, -'.json'.length);
78
+ configs.push(await readMachineConfig(slug));
79
+ }
80
+ return configs;
81
+ }
82
+ export async function deleteMachineConfig(slug) {
83
+ const path = machineConfigPath(slug);
84
+ try {
85
+ await rm(path);
86
+ return true;
87
+ }
88
+ catch (error) {
89
+ if (isNodeError(error) && error.code === 'ENOENT')
90
+ return false;
91
+ throw configError('failed to delete MCP machine config', path);
92
+ }
93
+ }
94
+ export async function resolveMachineConfig(slug) {
95
+ if (slug)
96
+ return readMachineConfig(slug);
97
+ const configs = await listMachineConfigs();
98
+ if (configs.length === 1)
99
+ return configs[0];
100
+ if (configs.length === 0)
101
+ throw configError('no MCP machines registered', { remediation: 'escli mcp register --root <dir>' });
102
+ throw configError('multiple MCP machines registered; pass --slug', { slugs: configs.map((config) => config.slug) });
103
+ }
104
+ export async function validateRootDirectory(root) {
105
+ const resolved = resolve(root);
106
+ const real = await realpath(resolved);
107
+ const info = await stat(real);
108
+ if (!info.isDirectory())
109
+ throw configError('--root must be a directory', root);
110
+ return real;
111
+ }
112
+ function assertSafeSlug(slug) {
113
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/u.test(slug))
114
+ throw configError('invalid MCP machine slug', slug);
115
+ }
116
+ function mcpParentDirs(path) {
117
+ const base = resolve(escliConfigDir());
118
+ const target = resolve(path);
119
+ const rel = relative(base, target);
120
+ if (rel.startsWith('..'))
121
+ return [target];
122
+ const dirs = [base];
123
+ const parts = rel.split(/[\\/]+/u).filter(Boolean);
124
+ let current = base;
125
+ for (const part of parts) {
126
+ current = join(current, part);
127
+ dirs.push(current);
128
+ }
129
+ return dirs;
130
+ }
131
+ //# sourceMappingURL=config.js.map