naro-mcp 0.2.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.
Files changed (3) hide show
  1. package/README.md +70 -3
  2. package/dist/index.js +1280 -76
  3. package/package.json +7 -3
package/dist/index.js CHANGED
@@ -1,95 +1,1299 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // ../../packages/sdk/src/auth.ts
7
+ import { homedir as homedir2 } from "os";
8
+
9
+ // ../../packages/sdk/src/errors.ts
10
+ var NaroError = class extends Error {
11
+ code;
12
+ /** HTTP status of the control plane response, when one was received. */
13
+ status;
14
+ /** Extra structured context (validation fields, deployment id...). */
15
+ details;
16
+ constructor(code, message, options) {
17
+ super(
18
+ message,
19
+ options?.cause !== void 0 ? { cause: options.cause } : {}
20
+ );
21
+ this.name = "NaroError";
22
+ this.code = code;
23
+ if (options?.status !== void 0) this.status = options.status;
24
+ if (options?.details !== void 0) this.details = options.details;
25
+ }
26
+ };
27
+ function isNaroError(error) {
28
+ return error instanceof NaroError || error instanceof Error && error.name === "NaroError" && typeof error.code === "string";
29
+ }
30
+ function toNaroError(error, fallbackCode = "INTERNAL_ERROR") {
31
+ if (error instanceof NaroError) return error;
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return new NaroError(fallbackCode, message, { cause: error });
34
+ }
35
+ async function errorFromResponse(response, context) {
36
+ let body = null;
37
+ try {
38
+ body = await response.json();
39
+ } catch {
40
+ body = null;
41
+ }
42
+ const serverCode = typeof body?.error?.code === "string" ? body.error.code : null;
43
+ const serverMessage = typeof body?.error?.message === "string" ? body.error.message : null;
44
+ const code = serverCode ?? (response.status === 401 ? "UNAUTHORIZED" : response.status === 404 ? "NOT_FOUND" : response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR");
45
+ const details = {};
46
+ if (body?.error?.fields !== void 0) details.fields = body.error.fields;
47
+ return new NaroError(
48
+ code,
49
+ serverMessage ?? `${context} failed: the control plane returned ${String(response.status)}.`,
50
+ {
51
+ status: response.status,
52
+ ...Object.keys(details).length > 0 ? { details } : {}
53
+ }
54
+ );
55
+ }
56
+
57
+ // ../../packages/sdk/src/session.ts
58
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
59
+ import { homedir } from "os";
60
+ import { join } from "path";
61
+ function credentialsDir(home = homedir()) {
62
+ return join(home, ".naro");
63
+ }
64
+ function credentialsFile(home = homedir()) {
65
+ return join(credentialsDir(home), "credentials.json");
66
+ }
67
+ function saveSession(session, home = homedir()) {
68
+ mkdirSync(credentialsDir(home), { recursive: true, mode: 448 });
69
+ writeFileSync(credentialsFile(home), JSON.stringify(session, null, 2), {
70
+ mode: 384
71
+ });
72
+ }
73
+ function loadStoredSession(home = homedir()) {
74
+ try {
75
+ return JSON.parse(
76
+ readFileSync(credentialsFile(home), "utf8")
77
+ );
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+ function isExpired(session, skewSeconds = 60) {
83
+ return session.expires_at * 1e3 <= Date.now() + skewSeconds * 1e3;
84
+ }
85
+
86
+ // ../../packages/sdk/src/supabase-auth.ts
87
+ import {
88
+ createClient
89
+ } from "@supabase/supabase-js";
90
+
91
+ // ../../packages/sdk/src/config.ts
92
+ var DEFAULT_CONTROL_PLANE_URL = "https://naro.sh";
93
+ var DEFAULT_SUPABASE_URL = "https://bymwzjpqfmwsdhtcpgjg.supabase.co";
94
+ var DEFAULT_SUPABASE_KEY = "sb_publishable_qjM6NZsHnGZ1t6iwuTDwcQ_LQbq4aCX";
95
+ function controlPlaneUrl(env = process.env) {
96
+ return (env.NARO_CONTROL_PLANE_URL ?? DEFAULT_CONTROL_PLANE_URL).replace(
97
+ /\/$/,
98
+ ""
99
+ );
100
+ }
101
+
102
+ // ../../packages/sdk/src/callback-server.ts
103
+ import { createServer } from "http";
104
+ var DONE_HTML = "<html><body><p>\u2705 Logged in. You can close this tab and return to the terminal.</p></body></html>";
105
+ var FAIL_HTML = "<html><body><p>Login failed. You can close this tab and return to the terminal.</p></body></html>";
106
+ async function startCallbackServer() {
107
+ let resolveCode;
108
+ let rejectCode;
109
+ const codePromise = new Promise((resolve, reject) => {
110
+ resolveCode = resolve;
111
+ rejectCode = reject;
112
+ });
113
+ const server = createServer((req, res) => {
114
+ const url = new URL(req.url ?? "/", "http://localhost");
115
+ if (url.pathname !== "/callback") {
116
+ res.statusCode = 404;
117
+ res.end("Not found");
118
+ return;
119
+ }
120
+ const error = url.searchParams.get("error");
121
+ const code = url.searchParams.get("code");
122
+ if (error) {
123
+ res.setHeader("content-type", "text/html");
124
+ res.end(FAIL_HTML);
125
+ rejectCode(new Error(`GitHub authorization failed: ${error}`));
126
+ return;
127
+ }
128
+ if (!code) {
129
+ res.statusCode = 400;
130
+ res.end("Missing code");
131
+ return;
132
+ }
133
+ res.setHeader("content-type", "text/html");
134
+ res.end(DONE_HTML);
135
+ resolveCode(code);
136
+ });
137
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
138
+ const port = server.address().port;
139
+ return {
140
+ port,
141
+ waitForCode(timeoutMs) {
142
+ let timer;
143
+ const timeout = new Promise((_, reject) => {
144
+ timer = setTimeout(
145
+ () => reject(new Error("Timed out waiting for GitHub callback.")),
146
+ timeoutMs
147
+ );
148
+ timer.unref?.();
149
+ });
150
+ return Promise.race([codePromise, timeout]).finally(
151
+ () => clearTimeout(timer)
152
+ );
153
+ },
154
+ close() {
155
+ server.close();
156
+ }
157
+ };
158
+ }
159
+
160
+ // ../../packages/sdk/src/supabase-auth.ts
161
+ function createAuthClient(env = process.env) {
162
+ const url = env.NARO_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
163
+ const key = env.NARO_SUPABASE_KEY ?? DEFAULT_SUPABASE_KEY;
164
+ const store = /* @__PURE__ */ new Map();
165
+ return createClient(url, key, {
166
+ auth: {
167
+ flowType: "pkce",
168
+ persistSession: false,
169
+ autoRefreshToken: false,
170
+ detectSessionInUrl: false,
171
+ storage: {
172
+ getItem: (k) => store.get(k) ?? null,
173
+ setItem: (k, v) => {
174
+ store.set(k, v);
175
+ },
176
+ removeItem: (k) => {
177
+ store.delete(k);
178
+ }
179
+ }
180
+ }
181
+ });
182
+ }
183
+ function toStoredSession(session) {
184
+ const meta = session.user.user_metadata ?? {};
185
+ const github = meta.user_name ?? meta.preferred_username ?? null;
186
+ return {
187
+ access_token: session.access_token,
188
+ refresh_token: session.refresh_token,
189
+ expires_at: session.expires_at ?? 0,
190
+ user: {
191
+ id: session.user.id,
192
+ email: session.user.email ?? null,
193
+ github
194
+ }
195
+ };
196
+ }
197
+ async function refreshStoredSession(refreshToken, client = createAuthClient()) {
198
+ const { data, error } = await client.auth.refreshSession({
199
+ refresh_token: refreshToken
200
+ });
201
+ if (error || !data.session) {
202
+ throw new Error(
203
+ `Session refresh failed: ${error?.message ?? "no session"}`
204
+ );
205
+ }
206
+ return toStoredSession(data.session);
207
+ }
208
+ async function login(deps) {
209
+ const client = deps.client ?? createAuthClient();
210
+ const server = await startCallbackServer();
211
+ try {
212
+ const redirectTo = `http://localhost:${server.port}/callback`;
213
+ const { data, error } = await client.auth.signInWithOAuth({
214
+ provider: "github",
215
+ options: { redirectTo, skipBrowserRedirect: true }
216
+ });
217
+ if (error || !data?.url) {
218
+ throw new Error(
219
+ `Could not start GitHub login: ${error?.message ?? "no authorize URL"}`
220
+ );
221
+ }
222
+ deps.openBrowser(data.url);
223
+ const code = await server.waitForCode(deps.timeoutMs ?? 12e4);
224
+ const { data: exchanged, error: exchangeError } = await client.auth.exchangeCodeForSession(code);
225
+ if (exchangeError || !exchanged?.session) {
226
+ throw new Error(
227
+ `Could not complete login: ${exchangeError?.message ?? "no session"}`
228
+ );
229
+ }
230
+ return toStoredSession(exchanged.session);
231
+ } finally {
232
+ server.close();
233
+ }
234
+ }
235
+
236
+ // ../../packages/sdk/src/auth.ts
237
+ async function resolveAuth(options = {}) {
238
+ const env = options.env ?? process.env;
239
+ const home = options.home ?? homedir2();
240
+ if (options.explicitToken) {
241
+ const token = options.explicitToken;
242
+ return { getAccessToken: async () => token, source: "flag" };
243
+ }
244
+ if (env.NARO_TOKEN) {
245
+ const token = env.NARO_TOKEN;
246
+ return { getAccessToken: async () => token, source: "env" };
247
+ }
248
+ const refresh = options.refresh ?? refreshStoredSession;
249
+ let session = loadStoredSession(home);
250
+ if (!session || isExpired(session)) {
251
+ if (session) {
252
+ try {
253
+ session = await refresh(session.refresh_token);
254
+ saveSession(session, home);
255
+ } catch {
256
+ session = null;
257
+ }
258
+ }
259
+ if (!session) {
260
+ const interactive = options.interactiveLogin;
261
+ if (!interactive) {
262
+ throw new NaroError(
263
+ "NOT_LOGGED_IN",
264
+ "Not logged in. Run `naro login`, or set NARO_TOKEN."
265
+ );
266
+ }
267
+ interactive.log(
268
+ "Not logged in \u2014 opening your browser to sign in with GitHub\u2026"
269
+ );
270
+ const loginFlow = options.loginFlow ?? ((openBrowser) => login({ openBrowser }));
271
+ session = await loginFlow((url) => {
272
+ interactive.log(`If your browser did not open, visit:
273
+ ${url}`);
274
+ interactive.openBrowser(url);
275
+ });
276
+ saveSession(session, home);
277
+ }
278
+ }
279
+ const auth = {
280
+ source: "session",
281
+ user: session.user,
282
+ getAccessToken: async () => {
283
+ const current = loadStoredSession(home);
284
+ if (!current) {
285
+ throw new NaroError(
286
+ "NOT_LOGGED_IN",
287
+ "The session was logged out. Run `naro login` again."
288
+ );
289
+ }
290
+ if (!isExpired(current)) return current.access_token;
291
+ const refreshed = await refresh(current.refresh_token);
292
+ saveSession(refreshed, home);
293
+ return refreshed.access_token;
294
+ }
295
+ };
296
+ return auth;
297
+ }
298
+
299
+ // ../../packages/sdk/src/browser.ts
300
+ import { spawn } from "child_process";
301
+
302
+ // ../../packages/sdk/src/types.ts
303
+ var TERMINAL_DEPLOYMENT_STATUSES = [
304
+ "READY",
305
+ "FAILED"
306
+ ];
307
+ function isTerminalStatus(status) {
308
+ return TERMINAL_DEPLOYMENT_STATUSES.includes(status);
309
+ }
310
+ function projectInfo(row) {
311
+ return {
312
+ id: row.id,
313
+ name: row.name,
314
+ slug: row.slug,
315
+ subdomain: row.subdomain,
316
+ teamId: row.team_id,
317
+ defaultBranch: row.default_branch,
318
+ rootDirectory: row.root_directory,
319
+ repositoryUrl: row.repository_url,
320
+ repositoryFullName: row.repository_full_name ?? null,
321
+ createdAt: row.created_at,
322
+ updatedAt: row.updated_at
323
+ };
324
+ }
325
+ function deploymentInfo(row) {
326
+ return {
327
+ id: row.id,
328
+ projectId: row.project_id,
329
+ status: row.status,
330
+ branch: row.branch,
331
+ url: row.preview_url,
332
+ artifactKey: row.artifact_key ?? null,
333
+ errorCode: row.error_code,
334
+ errorMessage: row.error_message,
335
+ gitCommitSha: row.git_commit_sha,
336
+ gitCommitMessage: row.git_commit_message,
337
+ createdAt: row.created_at,
338
+ startedAt: row.started_at,
339
+ finishedAt: row.finished_at
340
+ };
341
+ }
342
+ function buildLogEntry(row) {
343
+ return {
344
+ sequence: row.sequence,
345
+ level: row.level,
346
+ stage: row.stage,
347
+ message: row.message,
348
+ createdAt: row.created_at
349
+ };
350
+ }
351
+
352
+ // ../../packages/sdk/src/client.ts
353
+ var NaroClient = class {
354
+ baseUrl;
355
+ getAccessToken;
356
+ doFetch;
357
+ teams;
358
+ projects;
359
+ deployments;
360
+ env;
361
+ domains;
362
+ logs;
363
+ constructor(options) {
364
+ this.baseUrl = (options.baseUrl ?? controlPlaneUrl()).replace(/\/$/, "");
365
+ this.getAccessToken = options.getAccessToken;
366
+ this.doFetch = options.fetch ?? fetch;
367
+ this.teams = new TeamsApi(this);
368
+ this.projects = new ProjectsApi(this);
369
+ this.deployments = new DeploymentsApi(this);
370
+ this.env = new EnvApi(this);
371
+ this.domains = new DomainsApi(this);
372
+ this.logs = new LogsApi(this);
373
+ }
374
+ async request(path, options) {
375
+ const token = await this.getAccessToken();
376
+ let response;
377
+ try {
378
+ response = await this.doFetch(`${this.baseUrl}${path}`, {
379
+ method: options.method ?? "GET",
380
+ headers: {
381
+ Authorization: `Bearer ${token}`,
382
+ ...options.body !== void 0 ? { "Content-Type": "application/json" } : {}
383
+ },
384
+ ...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {}
385
+ });
386
+ } catch (error) {
387
+ throw new NaroError(
388
+ "NETWORK_ERROR",
389
+ `${options.context} failed: could not reach ${this.baseUrl}.`,
390
+ { cause: error }
391
+ );
392
+ }
393
+ if (!response.ok) throw await errorFromResponse(response, options.context);
394
+ return await response.json();
395
+ }
396
+ };
397
+ var TeamsApi = class {
398
+ constructor(client) {
399
+ this.client = client;
400
+ }
401
+ async list() {
402
+ const body = await this.client.request(
403
+ "/api/teams",
404
+ { context: "List teams" }
405
+ );
406
+ return body.teams;
407
+ }
408
+ };
409
+ var ProjectsApi = class {
410
+ constructor(client) {
411
+ this.client = client;
412
+ }
413
+ async list(teamSlug) {
414
+ const body = await this.client.request(
415
+ `/api/projects?team=${encodeURIComponent(teamSlug)}`,
416
+ { context: "List projects" }
417
+ );
418
+ return body.projects.map(projectInfo);
419
+ }
420
+ async get(projectId) {
421
+ const body = await this.client.request(
422
+ `/api/projects/${encodeURIComponent(projectId)}`,
423
+ { context: "Read project" }
424
+ );
425
+ return projectInfo(body.project);
426
+ }
427
+ };
428
+ var DeploymentsApi = class {
429
+ constructor(client) {
430
+ this.client = client;
431
+ }
432
+ async list(projectId) {
433
+ const body = await this.client.request(`/api/projects/${encodeURIComponent(projectId)}/deployments`, {
434
+ context: "List deployments"
435
+ });
436
+ return body.deployments.map(deploymentInfo);
437
+ }
438
+ async get(deploymentId) {
439
+ const body = await this.client.request(`/api/deployments/${encodeURIComponent(deploymentId)}`, {
440
+ context: "Read deployment"
441
+ });
442
+ return {
443
+ deployment: deploymentInfo(body.deployment),
444
+ logs: body.logs.map(buildLogEntry)
445
+ };
446
+ }
447
+ /**
448
+ * Starts a CLI deployment: the control plane creates/loads the project,
449
+ * records the deployment and returns a callback token; the caller builds
450
+ * locally and uploads the artifact.
451
+ */
452
+ async createCli(input) {
453
+ return this.client.request("/api/cli/deployments", {
454
+ method: "POST",
455
+ body: input,
456
+ context: "Create deployment"
457
+ });
458
+ }
459
+ /**
460
+ * Queues a server-side git build (GitHub-connected projects): clone,
461
+ * build and deploy run on the platform.
462
+ */
463
+ async createRemote(projectId, options = {}) {
464
+ const body = await this.client.request(
465
+ `/api/projects/${encodeURIComponent(projectId)}/deployments`,
466
+ {
467
+ method: "POST",
468
+ // createDeploymentSchema accepts string | undefined — never null.
469
+ body: options.branch ? { branch: options.branch } : {},
470
+ context: "Create deployment"
471
+ }
472
+ );
473
+ return deploymentInfo(body.deployment);
474
+ }
475
+ /**
476
+ * Picks the deployment `naro rollback` restores: the newest READY
477
+ * production deployment serving a DIFFERENT artifact than the current one.
478
+ * Comparing artifacts (not row order) keeps repeated rollbacks from
479
+ * ping-ponging between the same two rows — promoted rows share their
480
+ * source's artifact.
481
+ */
482
+ async rollbackTarget(projectId, defaultBranch) {
483
+ const deployments = await this.list(projectId);
484
+ const production = deployments.filter(
485
+ (deployment) => deployment.status === "READY" && deployment.branch === defaultBranch
486
+ );
487
+ const current = production[0];
488
+ if (!current) {
489
+ throw new NaroError(
490
+ "DEPLOYMENT_NOT_FOUND",
491
+ "No production deployment to roll back from."
492
+ );
493
+ }
494
+ const differs = (candidate) => current.artifactKey && candidate.artifactKey ? candidate.artifactKey !== current.artifactKey : candidate.id !== current.id;
495
+ return { current, target: production.slice(1).find(differs) ?? null };
496
+ }
497
+ /**
498
+ * Redeploys an existing READY deployment's artifact to production.
499
+ * Used by both `naro promote` (preview → production) and `naro rollback`
500
+ * (older production build → production).
501
+ */
502
+ async promote(projectId, deploymentId) {
503
+ const body = await this.client.request(
504
+ `/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/promote`,
505
+ { method: "POST", context: "Promote deployment" }
506
+ );
507
+ return deploymentInfo(body.deployment);
508
+ }
509
+ /** Polls a deployment until READY/FAILED, streaming new build logs. */
510
+ async wait(deploymentId, options = {}) {
511
+ const intervalMs = options.intervalMs ?? 2500;
512
+ const timeoutMs = options.timeoutMs ?? 15 * 6e4;
513
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
514
+ const startedAt = Date.now();
515
+ let lastStatus = null;
516
+ let lastSequence = -1;
517
+ for (; ; ) {
518
+ const detail = await this.get(deploymentId);
519
+ for (const log of detail.logs) {
520
+ if (log.sequence > lastSequence) {
521
+ lastSequence = log.sequence;
522
+ options.onLog?.(log);
523
+ }
524
+ }
525
+ if (detail.deployment.status !== lastStatus) {
526
+ lastStatus = detail.deployment.status;
527
+ options.onStatus?.(lastStatus);
528
+ }
529
+ if (isTerminalStatus(detail.deployment.status)) return detail;
530
+ if (Date.now() - startedAt > timeoutMs) {
531
+ throw new NaroError(
532
+ "TIMEOUT",
533
+ `Deployment ${deploymentId} did not finish within ${String(Math.round(timeoutMs / 1e3))}s.`,
534
+ { details: { deploymentId } }
535
+ );
536
+ }
537
+ await sleep(intervalMs);
538
+ }
539
+ }
540
+ };
541
+ var EnvApi = class {
542
+ constructor(client) {
543
+ this.client = client;
544
+ }
545
+ async list(projectId, options = {}) {
546
+ const query = options.reveal ? "?reveal=true" : "";
547
+ const body = await this.client.request(
548
+ `/api/projects/${encodeURIComponent(projectId)}/env${query}`,
549
+ { context: "List environment variables" }
550
+ );
551
+ return body.envVars;
552
+ }
553
+ async set(projectId, entries) {
554
+ await this.client.request(
555
+ `/api/projects/${encodeURIComponent(projectId)}/env`,
556
+ {
557
+ method: "POST",
558
+ body: {
559
+ entries: entries.map((entry) => ({
560
+ key: entry.key,
561
+ value: entry.value,
562
+ target: entry.target ?? "all",
563
+ isSecret: entry.isSecret ?? true
564
+ }))
565
+ },
566
+ context: "Set environment variable"
567
+ }
568
+ );
569
+ }
570
+ async removeById(projectId, envVarId) {
571
+ await this.client.request(
572
+ `/api/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(envVarId)}`,
573
+ { method: "DELETE", context: "Remove environment variable" }
574
+ );
575
+ }
576
+ /** Removes every row for a key (optionally narrowed to one target). */
577
+ async remove(projectId, key, target) {
578
+ const rows = await this.list(projectId);
579
+ const matches = rows.filter(
580
+ (row) => row.key === key && (target === void 0 || row.target === target)
581
+ );
582
+ if (matches.length === 0) {
583
+ throw new NaroError(
584
+ "ENV_NOT_FOUND",
585
+ target ? `No ${key} variable found for the ${target} environment.` : `No ${key} variable found.`,
586
+ { details: { key, ...target ? { target } : {} } }
587
+ );
588
+ }
589
+ for (const row of matches) await this.removeById(projectId, row.id);
590
+ return matches.length;
591
+ }
592
+ };
593
+ var DomainsApi = class {
594
+ constructor(client) {
595
+ this.client = client;
596
+ }
597
+ async list(projectId) {
598
+ const body = await this.client.request(
599
+ `/api/projects/${encodeURIComponent(projectId)}/domains`,
600
+ { context: "List domains" }
601
+ );
602
+ return body.domains;
603
+ }
604
+ async add(projectId, hostname) {
605
+ const body = await this.client.request(
606
+ `/api/projects/${encodeURIComponent(projectId)}/domains`,
607
+ { method: "POST", body: { hostname }, context: "Add domain" }
608
+ );
609
+ return body.domain;
610
+ }
611
+ async refresh(projectId, domainId) {
612
+ const body = await this.client.request(
613
+ `/api/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domainId)}`,
614
+ { method: "PATCH", context: "Refresh domain" }
615
+ );
616
+ return body.domain;
617
+ }
618
+ async removeById(projectId, domainId) {
619
+ await this.client.request(
620
+ `/api/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domainId)}`,
621
+ { method: "DELETE", context: "Remove domain" }
622
+ );
623
+ }
624
+ /** Removes a domain by hostname. */
625
+ async remove(projectId, hostname) {
626
+ const domains = await this.list(projectId);
627
+ const match = domains.find((domain) => domain.hostname === hostname);
628
+ if (!match) {
629
+ throw new NaroError(
630
+ "DOMAIN_NOT_FOUND",
631
+ `${hostname} is not attached to this project.`,
632
+ { details: { hostname } }
633
+ );
634
+ }
635
+ await this.removeById(projectId, match.id);
636
+ }
637
+ };
638
+ var LogsApi = class {
639
+ constructor(client) {
640
+ this.client = client;
641
+ }
642
+ async runtime(projectId, options = {}) {
643
+ const params = new URLSearchParams();
644
+ params.set("env", options.environment ?? "production");
645
+ if (options.level) params.set("level", options.level);
646
+ const body = await this.client.request(
647
+ `/api/projects/${encodeURIComponent(projectId)}/logs?${params.toString()}`,
648
+ { context: "Read runtime logs" }
649
+ );
650
+ return body.entries;
651
+ }
652
+ };
653
+
654
+ // ../../packages/sdk/src/dotenv.ts
655
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
656
+ import { join as join2 } from "path";
657
+
658
+ // ../../packages/sdk/src/framework.ts
659
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
660
+ import { join as join3 } from "path";
661
+
662
+ // ../../packages/sdk/src/link.ts
663
+ import {
664
+ existsSync as existsSync3,
665
+ mkdirSync as mkdirSync2,
666
+ readFileSync as readFileSync4,
667
+ rmSync as rmSync2,
668
+ writeFileSync as writeFileSync2
669
+ } from "fs";
670
+ import { join as join4 } from "path";
671
+ function linkDir(dir) {
672
+ return join4(dir, ".naro");
673
+ }
674
+ function linkFile(dir) {
675
+ return join4(linkDir(dir), "project.json");
676
+ }
677
+ function readProjectLink(dir) {
678
+ try {
679
+ const parsed = JSON.parse(readFileSync4(linkFile(dir), "utf8"));
680
+ if (typeof parsed.projectId !== "string" || parsed.projectId.length === 0)
681
+ return null;
682
+ return {
683
+ projectId: parsed.projectId,
684
+ ...typeof parsed.teamSlug === "string" ? { teamSlug: parsed.teamSlug } : {},
685
+ ...typeof parsed.projectName === "string" ? { projectName: parsed.projectName } : {}
686
+ };
687
+ } catch {
688
+ return null;
689
+ }
690
+ }
691
+ function writeProjectLink(dir, link) {
692
+ mkdirSync2(linkDir(dir), { recursive: true });
693
+ writeFileSync2(linkFile(dir), `${JSON.stringify(link, null, 2)}
694
+ `);
695
+ writeFileSync2(
696
+ join4(linkDir(dir), "README.txt"),
697
+ "This directory links the project to Naro and should not be committed.\n"
698
+ );
699
+ }
700
+ function ensureGitignoreHasNaro(dir) {
701
+ const gitignorePath = join4(dir, ".gitignore");
702
+ const hasGitignore = existsSync3(gitignorePath);
703
+ if (!hasGitignore && !existsSync3(join4(dir, ".git"))) return false;
704
+ const current = hasGitignore ? readFileSync4(gitignorePath, "utf8") : "";
705
+ const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => line === ".naro" || line === ".naro/" || line === "/.naro");
706
+ if (covered) return false;
707
+ const suffix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
708
+ writeFileSync2(gitignorePath, `${current}${suffix}.naro
709
+ `);
710
+ return true;
711
+ }
712
+
713
+ // src/naro.ts
714
+ function createNaroDeps() {
715
+ let cachedAuth = null;
716
+ let cached = null;
717
+ const auth = async () => {
718
+ cachedAuth ??= await resolveAuth({ interactiveLogin: false });
719
+ return cachedAuth;
720
+ };
721
+ const client = async () => {
722
+ if (cached) return cached;
723
+ const resolved = await auth();
724
+ cached = new NaroClient({
725
+ baseUrl: controlPlaneUrl(),
726
+ getAccessToken: () => resolved.getAccessToken()
727
+ });
728
+ return cached;
729
+ };
730
+ return {
731
+ client,
732
+ auth,
733
+ cwd: () => process.cwd(),
734
+ resolveProjectId: async (args) => {
735
+ if (args.projectId) return args.projectId;
736
+ const directory = args.directory ?? process.cwd();
737
+ const link = readProjectLink(directory);
738
+ if (link) return link.projectId;
739
+ throw new NaroError(
740
+ "PROJECT_NOT_LINKED",
741
+ `No Naro project is linked in ${directory}. Pass projectId, or link the directory first (link_project / \`naro link\`).`
742
+ );
743
+ }
744
+ };
745
+ }
746
+
747
+ // src/server.ts
748
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
749
  import {
7
750
  CallToolRequestSchema,
8
751
  ListToolsRequestSchema
9
752
  } from "@modelcontextprotocol/sdk/types.js";
753
+
754
+ // src/tools.ts
10
755
  import { deployDirectory, readProjectEnv } from "naro-cli";
11
- console.log = (...args) => {
12
- process.stderr.write(`${args.map(String).join(" ")}
13
- `);
756
+ var PROJECT_ARGS = {
757
+ projectId: {
758
+ type: "string",
759
+ description: "Naro project id (UUID). Omit to use the .naro/project.json link in `directory`."
760
+ },
761
+ directory: {
762
+ type: "string",
763
+ description: "Absolute path of the project directory used to resolve the linked project. Defaults to the current working directory."
764
+ }
14
765
  };
15
- var DEPLOY_TOOL = {
16
- name: "deploy",
17
- description: "Deploy the current Next.js project to naro using the naro login session. If the user is not logged in, a browser window opens for GitHub sign-in \u2014 tell the user to approve it. Local git remote/branch and .env variables are detected and registered to the project automatically. Builds locally and publishes to Cloudflare, returning a live {name}.naro.sh URL.",
18
- inputSchema: {
19
- type: "object",
20
- properties: {
21
- directory: {
22
- type: "string",
23
- description: "Absolute path to the project directory. Defaults to the current working directory."
24
- },
25
- name: {
26
- type: "string",
27
- description: "Deployment name / subdomain label. Defaults to the package.json name."
766
+ var DESTRUCTIVE = "\u26A0 DESTRUCTIVE: ";
767
+ function str(value) {
768
+ return typeof value === "string" && value.length > 0 ? value : void 0;
769
+ }
770
+ var TOOLS = [
771
+ {
772
+ name: "whoami",
773
+ description: "Returns the authenticated Naro user (from the `naro login` session or NARO_TOKEN).",
774
+ inputSchema: { type: "object", properties: {} },
775
+ handler: async (_args, deps) => {
776
+ const auth = await deps.auth();
777
+ const teams = await (await deps.client()).teams.list();
778
+ return {
779
+ user: auth.user ?? null,
780
+ tokenSource: auth.source,
781
+ teams
782
+ };
783
+ }
784
+ },
785
+ {
786
+ name: "list_teams",
787
+ description: "Lists the teams the authenticated user belongs to.",
788
+ inputSchema: { type: "object", properties: {} },
789
+ handler: async (_args, deps) => ({
790
+ teams: await (await deps.client()).teams.list()
791
+ })
792
+ },
793
+ {
794
+ name: "list_projects",
795
+ description: "Lists the Naro projects of a team (defaults to the personal team).",
796
+ inputSchema: {
797
+ type: "object",
798
+ properties: {
799
+ team: {
800
+ type: "string",
801
+ description: "Team slug. Omit for the personal team."
802
+ }
803
+ }
804
+ },
805
+ handler: async (args, deps) => {
806
+ const client = await deps.client();
807
+ let slug = str(args.team);
808
+ if (!slug) {
809
+ const teams = await client.teams.list();
810
+ slug = (teams.find((team) => team.isPersonal) ?? teams[0])?.slug;
811
+ if (!slug) return { projects: [] };
812
+ }
813
+ return { team: slug, projects: await client.projects.list(slug) };
814
+ }
815
+ },
816
+ {
817
+ name: "get_project",
818
+ description: "Returns one project (id, subdomain/URL, default branch, root directory).",
819
+ inputSchema: { type: "object", properties: { ...PROJECT_ARGS } },
820
+ handler: async (args, deps) => {
821
+ const projectId = await deps.resolveProjectId({
822
+ projectId: str(args.projectId),
823
+ directory: str(args.directory)
824
+ });
825
+ return { project: await (await deps.client()).projects.get(projectId) };
826
+ }
827
+ },
828
+ {
829
+ name: "link_project",
830
+ description: "Links a local directory to a Naro project by writing .naro/project.json (and gitignoring it). Later tools can then omit projectId.",
831
+ inputSchema: {
832
+ type: "object",
833
+ properties: {
834
+ projectId: { type: "string", description: "Project id to link to." },
835
+ directory: {
836
+ type: "string",
837
+ description: "Directory to link. Defaults to the current directory."
838
+ }
28
839
  },
29
- production: {
30
- type: "boolean",
31
- description: "Publish to production ({name}.naro.sh). Defaults to false, which deploys a preview at {name}-preview.naro.sh."
840
+ required: ["projectId"]
841
+ },
842
+ handler: async (args, deps) => {
843
+ const projectId = str(args.projectId);
844
+ if (!projectId) throw new Error("projectId is required.");
845
+ const directory = str(args.directory) ?? deps.cwd();
846
+ const project = await (await deps.client()).projects.get(projectId);
847
+ writeProjectLink(directory, {
848
+ projectId: project.id,
849
+ projectName: project.name
850
+ });
851
+ ensureGitignoreHasNaro(directory);
852
+ return { linked: true, directory, project };
853
+ }
854
+ },
855
+ {
856
+ name: "deploy",
857
+ description: "Deploys a local project to Naro (create_deployment): registers the deployment, builds the Next.js app locally, uploads the artifact and waits for the live URL. Preview by default; production:true publishes to the main domain. If the user is not logged in and no NARO_TOKEN is set, a browser window opens for GitHub sign-in \u2014 tell the user to approve it. On the FIRST deploy of a directory, local .env variables seed the project; afterwards env is managed on the platform (set_environment_variable).",
858
+ inputSchema: {
859
+ type: "object",
860
+ properties: {
861
+ directory: {
862
+ type: "string",
863
+ description: "Absolute path to the project directory. Defaults to the current working directory."
864
+ },
865
+ production: {
866
+ type: "boolean",
867
+ description: `${DESTRUCTIVE}when true, publishes to the production domain. Defaults to false (preview).`
868
+ },
869
+ name: {
870
+ type: "string",
871
+ description: "Project name / subdomain label for first deploys. Defaults to the package.json name."
872
+ },
873
+ rootDirectory: {
874
+ type: "string",
875
+ description: 'Sub-directory containing the Next.js app (where its package.json lives), e.g. "client". Inspect the project structure and pass this whenever the app is not at the repository root.'
876
+ },
877
+ branch: {
878
+ type: "string",
879
+ description: "Override the recorded git branch. Usually omit \u2014 the current branch is detected."
880
+ }
881
+ }
882
+ },
883
+ handler: async (args, deps) => {
884
+ const directory = str(args.directory) ?? deps.cwd();
885
+ const link = readProjectLink(directory);
886
+ const logs = [];
887
+ const result = await deployDirectory(
888
+ directory,
889
+ {
890
+ name: str(args.name),
891
+ projectId: link?.projectId,
892
+ production: args.production === true,
893
+ rootDirectory: str(args.rootDirectory) ?? null,
894
+ branch: str(args.branch),
895
+ // Local dotenv files seed the project once (first, unlinked
896
+ // deploy); afterwards the platform owns env vars.
897
+ env: link ? {} : readProjectEnv(directory)
898
+ },
899
+ (message) => logs.push(message)
900
+ );
901
+ if (!link) {
902
+ writeProjectLink(directory, { projectId: result.projectId });
903
+ ensureGitignoreHasNaro(directory);
904
+ }
905
+ return {
906
+ deploymentId: result.deploymentId,
907
+ projectId: result.projectId,
908
+ status: result.status,
909
+ url: result.url,
910
+ target: result.production ? "production" : "preview",
911
+ logs
912
+ };
913
+ }
914
+ },
915
+ {
916
+ name: "list_deployments",
917
+ description: "Lists recent deployments of a project (id, status, branch, URL).",
918
+ inputSchema: { type: "object", properties: { ...PROJECT_ARGS } },
919
+ handler: async (args, deps) => {
920
+ const projectId = await deps.resolveProjectId({
921
+ projectId: str(args.projectId),
922
+ directory: str(args.directory)
923
+ });
924
+ return {
925
+ projectId,
926
+ deployments: await (await deps.client()).deployments.list(projectId)
927
+ };
928
+ }
929
+ },
930
+ {
931
+ name: "get_deployment",
932
+ description: "Returns one deployment's status, URL and error details. Poll this after deploy/promote to track progress.",
933
+ inputSchema: {
934
+ type: "object",
935
+ properties: {
936
+ deploymentId: { type: "string", description: "Deployment id." }
32
937
  },
33
- rootDirectory: {
34
- type: "string",
35
- description: 'Sub-directory containing the Next.js app (where its package.json lives), e.g. "client". Inspect the project structure and pass this whenever the app is not at the repository root.'
938
+ required: ["deploymentId"]
939
+ },
940
+ handler: async (args, deps) => {
941
+ const deploymentId = str(args.deploymentId);
942
+ if (!deploymentId) throw new Error("deploymentId is required.");
943
+ const detail = await (await deps.client()).deployments.get(deploymentId);
944
+ return { deployment: detail.deployment, logCount: detail.logs.length };
945
+ }
946
+ },
947
+ {
948
+ name: "get_build_logs",
949
+ description: "Returns the build logs of a deployment \u2014 use this to diagnose why a build FAILED, then fix the code and redeploy.",
950
+ inputSchema: {
951
+ type: "object",
952
+ properties: {
953
+ deploymentId: { type: "string", description: "Deployment id." }
36
954
  },
37
- branch: {
38
- type: "string",
39
- description: "Override the git branch recorded for this deployment. Usually omit \u2014 the CLI detects the current branch automatically."
955
+ required: ["deploymentId"]
956
+ },
957
+ handler: async (args, deps) => {
958
+ const deploymentId = str(args.deploymentId);
959
+ if (!deploymentId) throw new Error("deploymentId is required.");
960
+ const detail = await (await deps.client()).deployments.get(deploymentId);
961
+ return {
962
+ deploymentId,
963
+ status: detail.deployment.status,
964
+ errorCode: detail.deployment.errorCode,
965
+ errorMessage: detail.deployment.errorMessage,
966
+ logs: detail.logs
967
+ };
968
+ }
969
+ },
970
+ {
971
+ name: "get_runtime_logs",
972
+ description: "Returns recent runtime (request/console) logs of the deployed worker.",
973
+ inputSchema: {
974
+ type: "object",
975
+ properties: {
976
+ ...PROJECT_ARGS,
977
+ environment: {
978
+ type: "string",
979
+ enum: ["production", "preview"],
980
+ description: "Which surface to read. Defaults to production."
981
+ },
982
+ level: {
983
+ type: "string",
984
+ enum: ["log", "info", "warn", "error", "debug"],
985
+ description: "Only entries at this level."
986
+ }
40
987
  }
988
+ },
989
+ handler: async (args, deps) => {
990
+ const projectId = await deps.resolveProjectId({
991
+ projectId: str(args.projectId),
992
+ directory: str(args.directory)
993
+ });
994
+ const environment = str(args.environment) === "preview" ? "preview" : "production";
995
+ const level = str(args.level);
996
+ const entries = await (await deps.client()).logs.runtime(projectId, {
997
+ environment,
998
+ ...level !== void 0 ? { level } : {}
999
+ });
1000
+ return { projectId, environment, entries };
41
1001
  }
42
- }
43
- };
44
- var server = new Server(
45
- { name: "naro", version: "0.1.0" },
46
- { capabilities: { tools: {} } }
47
- );
48
- server.setRequestHandler(ListToolsRequestSchema, () => ({
49
- tools: [DEPLOY_TOOL]
50
- }));
51
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
52
- if (request.params.name !== "deploy") {
53
- throw new Error(`Unknown tool: ${request.params.name}`);
54
- }
55
- const args = request.params.arguments ?? {};
56
- const directory = args.directory ?? process.cwd();
57
- const logs = [];
58
- try {
59
- const result = await deployDirectory(
60
- directory,
61
- {
62
- name: args.name,
63
- production: args.production ?? false,
64
- rootDirectory: args.rootDirectory ?? null,
65
- branch: args.branch,
66
- env: readProjectEnv(directory)
1002
+ },
1003
+ {
1004
+ name: "list_environment_variables",
1005
+ description: "Lists environment variables (secret values are never returned).",
1006
+ inputSchema: { type: "object", properties: { ...PROJECT_ARGS } },
1007
+ handler: async (args, deps) => {
1008
+ const projectId = await deps.resolveProjectId({
1009
+ projectId: str(args.projectId),
1010
+ directory: str(args.directory)
1011
+ });
1012
+ const rows = await (await deps.client()).env.list(projectId);
1013
+ return {
1014
+ projectId,
1015
+ envVars: rows.map((row) => ({
1016
+ key: row.key,
1017
+ target: row.target,
1018
+ isSecret: row.isSecret,
1019
+ value: row.isSecret ? null : row.value
1020
+ }))
1021
+ };
1022
+ }
1023
+ },
1024
+ {
1025
+ name: "set_environment_variable",
1026
+ description: "Adds or overwrites one environment variable. Takes effect on the NEXT deployment.",
1027
+ inputSchema: {
1028
+ type: "object",
1029
+ properties: {
1030
+ ...PROJECT_ARGS,
1031
+ key: {
1032
+ type: "string",
1033
+ description: "Variable name, e.g. DATABASE_URL."
1034
+ },
1035
+ value: { type: "string", description: "Variable value." },
1036
+ target: {
1037
+ type: "string",
1038
+ enum: ["production", "preview", "all"],
1039
+ description: "Which deployments receive it. Defaults to all."
1040
+ },
1041
+ isSecret: {
1042
+ type: "boolean",
1043
+ description: "Hide the value in listings. Defaults to true unless the key starts with NEXT_PUBLIC_."
1044
+ }
67
1045
  },
68
- (message) => logs.push(message)
69
- );
70
- return {
71
- content: [
72
- {
73
- type: "text",
74
- text: `Deployed ${directory} to ${result.url}
75
-
76
- ${logs.join("\n")}`
1046
+ required: ["key", "value"]
1047
+ },
1048
+ handler: async (args, deps) => {
1049
+ const key = str(args.key);
1050
+ const value = typeof args.value === "string" ? args.value : void 0;
1051
+ if (!key || value === void 0)
1052
+ throw new Error("key and value are required.");
1053
+ const projectId = await deps.resolveProjectId({
1054
+ projectId: str(args.projectId),
1055
+ directory: str(args.directory)
1056
+ });
1057
+ const rawTarget = str(args.target);
1058
+ if (rawTarget !== void 0 && rawTarget !== "production" && rawTarget !== "preview" && rawTarget !== "all")
1059
+ throw new Error(
1060
+ `Invalid target "${rawTarget}" \u2014 use production, preview or all.`
1061
+ );
1062
+ const target = rawTarget ?? "all";
1063
+ const isSecret = typeof args.isSecret === "boolean" ? args.isSecret : !key.startsWith("NEXT_PUBLIC_");
1064
+ await (await deps.client()).env.set(projectId, [{ key, value, target, isSecret }]);
1065
+ return { projectId, key, target, isSecret };
1066
+ }
1067
+ },
1068
+ {
1069
+ name: "remove_environment_variable",
1070
+ description: `${DESTRUCTIVE}deletes an environment variable from the project (all matching targets).`,
1071
+ inputSchema: {
1072
+ type: "object",
1073
+ properties: {
1074
+ ...PROJECT_ARGS,
1075
+ key: { type: "string", description: "Variable name to remove." },
1076
+ target: {
1077
+ type: "string",
1078
+ enum: ["production", "preview", "all"],
1079
+ description: "Only remove the row for this target."
77
1080
  }
78
- ]
79
- };
80
- } catch (error) {
81
- const message = error instanceof Error ? error.message : String(error);
82
- return {
83
- isError: true,
84
- content: [
85
- {
86
- type: "text",
87
- text: `Deploy failed: ${message}
88
-
89
- ${logs.join("\n")}`
1081
+ },
1082
+ required: ["key"]
1083
+ },
1084
+ handler: async (args, deps) => {
1085
+ const key = str(args.key);
1086
+ if (!key) throw new Error("key is required.");
1087
+ const projectId = await deps.resolveProjectId({
1088
+ projectId: str(args.projectId),
1089
+ directory: str(args.directory)
1090
+ });
1091
+ const target = str(args.target);
1092
+ const removed = await (await deps.client()).env.remove(projectId, key, target);
1093
+ return { projectId, key, removed };
1094
+ }
1095
+ },
1096
+ {
1097
+ name: "list_domains",
1098
+ description: "Lists the project's custom domains with verification status and required DNS records.",
1099
+ inputSchema: { type: "object", properties: { ...PROJECT_ARGS } },
1100
+ handler: async (args, deps) => {
1101
+ const projectId = await deps.resolveProjectId({
1102
+ projectId: str(args.projectId),
1103
+ directory: str(args.directory)
1104
+ });
1105
+ return {
1106
+ projectId,
1107
+ domains: await (await deps.client()).domains.list(projectId)
1108
+ };
1109
+ }
1110
+ },
1111
+ {
1112
+ name: "add_domain",
1113
+ description: "Attaches a custom domain to the project's production deployment and returns the DNS records the user must create.",
1114
+ inputSchema: {
1115
+ type: "object",
1116
+ properties: {
1117
+ ...PROJECT_ARGS,
1118
+ hostname: {
1119
+ type: "string",
1120
+ description: "Domain to attach, e.g. www.example.com."
90
1121
  }
91
- ]
92
- };
1122
+ },
1123
+ required: ["hostname"]
1124
+ },
1125
+ handler: async (args, deps) => {
1126
+ const hostname = str(args.hostname);
1127
+ if (!hostname) throw new Error("hostname is required.");
1128
+ const projectId = await deps.resolveProjectId({
1129
+ projectId: str(args.projectId),
1130
+ directory: str(args.directory)
1131
+ });
1132
+ const domain = await (await deps.client()).domains.add(projectId, hostname);
1133
+ return { projectId, domain };
1134
+ }
1135
+ },
1136
+ {
1137
+ name: "remove_domain",
1138
+ description: `${DESTRUCTIVE}detaches a custom domain \u2014 traffic to it stops routing to this project.`,
1139
+ inputSchema: {
1140
+ type: "object",
1141
+ properties: {
1142
+ ...PROJECT_ARGS,
1143
+ hostname: { type: "string", description: "Domain to detach." }
1144
+ },
1145
+ required: ["hostname"]
1146
+ },
1147
+ handler: async (args, deps) => {
1148
+ const hostname = str(args.hostname);
1149
+ if (!hostname) throw new Error("hostname is required.");
1150
+ const projectId = await deps.resolveProjectId({
1151
+ projectId: str(args.projectId),
1152
+ directory: str(args.directory)
1153
+ });
1154
+ await (await deps.client()).domains.remove(projectId, hostname);
1155
+ return { projectId, hostname, removed: true };
1156
+ }
1157
+ },
1158
+ {
1159
+ name: "promote_deployment",
1160
+ description: `${DESTRUCTIVE}publishes an existing READY deployment (e.g. a preview build) to production. Production traffic switches to it once READY.`,
1161
+ inputSchema: {
1162
+ type: "object",
1163
+ properties: {
1164
+ ...PROJECT_ARGS,
1165
+ deploymentId: {
1166
+ type: "string",
1167
+ description: "The READY deployment to promote."
1168
+ }
1169
+ },
1170
+ required: ["deploymentId"]
1171
+ },
1172
+ handler: async (args, deps) => {
1173
+ const deploymentId = str(args.deploymentId);
1174
+ if (!deploymentId) throw new Error("deploymentId is required.");
1175
+ const projectId = await deps.resolveProjectId({
1176
+ projectId: str(args.projectId),
1177
+ directory: str(args.directory)
1178
+ });
1179
+ const client = await deps.client();
1180
+ const promoted = await client.deployments.promote(
1181
+ projectId,
1182
+ deploymentId
1183
+ );
1184
+ const detail = await client.deployments.wait(promoted.id, {
1185
+ timeoutMs: 5 * 6e4
1186
+ });
1187
+ return {
1188
+ deploymentId: promoted.id,
1189
+ sourceDeploymentId: deploymentId,
1190
+ status: detail.deployment.status,
1191
+ url: detail.deployment.url,
1192
+ errorCode: detail.deployment.errorCode
1193
+ };
1194
+ }
1195
+ },
1196
+ {
1197
+ name: "rollback_deployment",
1198
+ description: `${DESTRUCTIVE}rolls production back. Without deploymentId, the previous READY production deployment is restored; with deploymentId, that specific build is restored.`,
1199
+ inputSchema: {
1200
+ type: "object",
1201
+ properties: {
1202
+ ...PROJECT_ARGS,
1203
+ deploymentId: {
1204
+ type: "string",
1205
+ description: "Specific deployment to restore. Omit for the previous production build."
1206
+ }
1207
+ }
1208
+ },
1209
+ handler: async (args, deps) => {
1210
+ const projectId = await deps.resolveProjectId({
1211
+ projectId: str(args.projectId),
1212
+ directory: str(args.directory)
1213
+ });
1214
+ const client = await deps.client();
1215
+ let target = str(args.deploymentId);
1216
+ if (!target) {
1217
+ const project = await client.projects.get(projectId);
1218
+ const { target: candidate } = await client.deployments.rollbackTarget(
1219
+ projectId,
1220
+ project.defaultBranch
1221
+ );
1222
+ if (!candidate)
1223
+ throw Object.assign(
1224
+ new Error(
1225
+ "No earlier production build with a different artifact to roll back to."
1226
+ ),
1227
+ { code: "DEPLOYMENT_NOT_FOUND" }
1228
+ );
1229
+ target = candidate.id;
1230
+ }
1231
+ const promoted = await client.deployments.promote(projectId, target);
1232
+ const detail = await client.deployments.wait(promoted.id, {
1233
+ timeoutMs: 5 * 6e4
1234
+ });
1235
+ return {
1236
+ deploymentId: promoted.id,
1237
+ rolledBackTo: target,
1238
+ status: detail.deployment.status,
1239
+ url: detail.deployment.url,
1240
+ errorCode: detail.deployment.errorCode
1241
+ };
1242
+ }
93
1243
  }
94
- });
95
- await server.connect(new StdioServerTransport());
1244
+ ];
1245
+ function toolResultJson(data) {
1246
+ return JSON.stringify({ success: true, ...data }, null, 2);
1247
+ }
1248
+ function toolErrorJson(error) {
1249
+ const naroError = isNaroError(error) ? error : toNaroError(error);
1250
+ const code = isNaroError(error) || !(error instanceof Error && "code" in error) ? naroError.code : String(error.code);
1251
+ return JSON.stringify(
1252
+ {
1253
+ success: false,
1254
+ code,
1255
+ message: naroError.message,
1256
+ ...naroError.details ?? {}
1257
+ },
1258
+ null,
1259
+ 2
1260
+ );
1261
+ }
1262
+
1263
+ // src/server.ts
1264
+ function createServer2(deps) {
1265
+ const server = new Server(
1266
+ { name: "naro", version: "0.3.0" },
1267
+ { capabilities: { tools: {} } }
1268
+ );
1269
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
1270
+ tools: TOOLS.map((tool) => ({
1271
+ name: tool.name,
1272
+ description: tool.description,
1273
+ inputSchema: tool.inputSchema
1274
+ }))
1275
+ }));
1276
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1277
+ const tool = TOOLS.find(
1278
+ (candidate) => candidate.name === request.params.name
1279
+ );
1280
+ if (!tool) throw new Error(`Unknown tool: ${request.params.name}`);
1281
+ try {
1282
+ const data = await tool.handler(request.params.arguments ?? {}, deps);
1283
+ return { content: [{ type: "text", text: toolResultJson(data) }] };
1284
+ } catch (error) {
1285
+ return {
1286
+ isError: true,
1287
+ content: [{ type: "text", text: toolErrorJson(error) }]
1288
+ };
1289
+ }
1290
+ });
1291
+ return server;
1292
+ }
1293
+
1294
+ // src/index.ts
1295
+ console.log = (...args) => {
1296
+ process.stderr.write(`${args.map(String).join(" ")}
1297
+ `);
1298
+ };
1299
+ await createServer2(createNaroDeps()).connect(new StdioServerTransport());