naro-cli 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,91 +1,1427 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ NaroClient,
4
+ NaroError,
5
+ clearSession,
6
+ controlPlaneUrl,
7
+ createAuthClient,
3
8
  deployDirectory,
4
- readProjectEnv,
5
- runLogin,
6
- runLogout,
7
- runWhoami
8
- } from "./chunk-IC4R5EYE.js";
9
+ detectGitInfo,
10
+ ensureGitignoreHasNaro,
11
+ isExpired,
12
+ isNaroError,
13
+ isTerminalStatus,
14
+ loadStoredSession,
15
+ login,
16
+ openBrowser,
17
+ parseGitHubRemote,
18
+ promptForMissingEnv,
19
+ readProjectLink,
20
+ refreshStoredSession,
21
+ removeProjectLink,
22
+ resolveAuth,
23
+ saveSession,
24
+ serializeDotenv,
25
+ slugify,
26
+ toNaroError,
27
+ writeProjectLink
28
+ } from "./chunk-YLFLDW2H.js";
9
29
 
10
- // src/index.ts
11
- function parseFlags(argv) {
12
- const flags = { production: false };
30
+ // src/args.ts
31
+ var BOOLEAN_FLAGS = {
32
+ "--json": "json",
33
+ "--yes": "yes",
34
+ "-y": "yes",
35
+ "--debug": "debug",
36
+ "--help": "help",
37
+ "-h": "help",
38
+ "--version": "version",
39
+ "-v": "version",
40
+ "--prod": "prod",
41
+ "--production": "prod",
42
+ "--follow": "follow",
43
+ "-f": "follow",
44
+ "--reveal": "reveal"
45
+ };
46
+ var STRING_FLAGS = {
47
+ "--token": "token",
48
+ "--project": "project",
49
+ "--team": "team",
50
+ "--cwd": "cwd",
51
+ "--name": "name",
52
+ "--root": "root",
53
+ "--branch": "branch",
54
+ "--type": "type",
55
+ "--env": "env",
56
+ "--level": "level"
57
+ };
58
+ function parseArgs(argv) {
59
+ const flags = {
60
+ json: false,
61
+ yes: false,
62
+ debug: false,
63
+ help: false,
64
+ version: false,
65
+ prod: false,
66
+ follow: false,
67
+ reveal: false
68
+ };
69
+ const positionals = [];
70
+ const unknown = [];
13
71
  for (let i = 0; i < argv.length; i++) {
14
72
  const arg = argv[i];
15
- if (arg === "--prod" || arg === "--production") flags.production = true;
16
- else if (arg === "--name") flags.name = argv[++i];
17
- else if (arg === "--root") flags.root = argv[++i];
18
- else if (arg === "--branch") flags.branch = argv[++i];
73
+ if (arg === void 0) continue;
74
+ const booleanKey = BOOLEAN_FLAGS[arg];
75
+ if (booleanKey) {
76
+ flags[booleanKey] = true;
77
+ continue;
78
+ }
79
+ const eq = arg.indexOf("=");
80
+ const stringKey = STRING_FLAGS[arg] ?? (eq > 0 ? STRING_FLAGS[arg.slice(0, eq)] : void 0);
81
+ if (stringKey) {
82
+ if (eq > 0 && STRING_FLAGS[arg.slice(0, eq)]) {
83
+ flags[stringKey] = arg.slice(
84
+ eq + 1
85
+ );
86
+ continue;
87
+ }
88
+ const value = argv[i + 1];
89
+ if (value === void 0 || value.startsWith("-") && value !== "-") {
90
+ unknown.push(`${arg} (missing value)`);
91
+ continue;
92
+ }
93
+ i += 1;
94
+ flags[stringKey] = value;
95
+ continue;
96
+ }
97
+ if (arg.startsWith("-") && arg !== "-") {
98
+ unknown.push(arg);
99
+ continue;
100
+ }
101
+ positionals.push(arg);
19
102
  }
20
- return flags;
103
+ const [command, ...rest] = positionals;
104
+ return { command, positionals: rest, flags, unknown };
21
105
  }
22
- var HELP = `naro \u2014 deploy the current app to naro
23
106
 
24
- Usage:
25
- naro login
26
- naro whoami
27
- naro logout
28
- naro deploy [--name <name>] [--root <dir>] [--branch <name>] [--prod]
29
-
30
- Options:
31
- --name Override the deployment name (defaults to package.json name)
32
- --root Sub-directory that contains the Next.js app (where package.json is)
33
- --branch Override the detected git branch
34
- --prod Publish to production ({name}.naro.sh) instead of preview
35
-
36
- Credentials:
37
- Run \`naro login\` to authenticate via GitHub.
38
- \`naro deploy\` signs you in automatically (opens your browser) when needed.
39
- Set NARO_CONTROL_PLANE_URL to use a custom control plane (defaults to https://naro.sh).
40
- `;
41
- async function main() {
42
- const [command, ...rest] = process.argv.slice(2);
43
- if (!command || command === "help" || command === "--help") {
44
- process.stdout.write(HELP);
45
- return;
107
+ // src/output.ts
108
+ var processStreams = {
109
+ stdout: (text) => process.stdout.write(text),
110
+ stderr: (text) => process.stderr.write(text)
111
+ };
112
+ var Output = class {
113
+ constructor(json, debug, streams = processStreams) {
114
+ this.json = json;
115
+ this.debug = debug;
116
+ this.streams = streams;
46
117
  }
47
- if (command === "login") return runLogin();
48
- if (command === "whoami") return runWhoami();
49
- if (command === "logout") return runLogout();
50
- if (command !== "deploy") {
51
- process.stderr.write(`Unknown command: ${command}
52
-
53
- ${HELP}`);
118
+ resultPrinted = false;
119
+ /** Progress/log line for humans. */
120
+ log(message) {
121
+ if (this.json) this.streams.stderr(`${message}
122
+ `);
123
+ else this.streams.stdout(`${message}
124
+ `);
125
+ }
126
+ /** Extra diagnostics, only with --debug. */
127
+ debugLog(message) {
128
+ if (this.debug) this.streams.stderr(`[debug] ${message}
129
+ `);
130
+ }
131
+ /**
132
+ * Final command result. JSON mode prints {success:true, ...data}; human
133
+ * mode prints the lines instead.
134
+ */
135
+ result(data, humanLines = []) {
136
+ this.resultPrinted = true;
137
+ if (this.json) {
138
+ this.streams.stdout(
139
+ `${JSON.stringify({ success: true, ...data }, null, 2)}
140
+ `
141
+ );
142
+ return;
143
+ }
144
+ for (const line of humanLines) this.streams.stdout(`${line}
145
+ `);
146
+ }
147
+ /** Command failure: stable {success:false, code, message} in JSON mode. */
148
+ fail(error) {
149
+ const naroError = isNaroError(error) ? error : toNaroError(error);
150
+ this.resultPrinted = true;
54
151
  process.exitCode = 1;
55
- return;
152
+ if (this.json) {
153
+ this.streams.stdout(
154
+ `${JSON.stringify(
155
+ {
156
+ success: false,
157
+ code: naroError.code,
158
+ message: naroError.message,
159
+ ...naroError.details
160
+ },
161
+ null,
162
+ 2
163
+ )}
164
+ `
165
+ );
166
+ return;
167
+ }
168
+ this.streams.stderr(`\u2717 ${naroError.message}
169
+ `);
170
+ if (this.debug && naroError.cause instanceof Error) {
171
+ this.streams.stderr(`[debug] cause: ${naroError.cause.stack ?? ""}
172
+ `);
173
+ }
56
174
  }
57
- const flags = parseFlags(rest);
58
- const cwd = process.cwd();
59
- const env = readProjectEnv(cwd);
60
- process.stdout.write(`
61
- \u{1F680} Deploying ${cwd}
175
+ get hasResult() {
176
+ return this.resultPrinted;
177
+ }
178
+ };
62
179
 
180
+ // src/prompt.ts
181
+ import { createInterface } from "readline";
182
+ import { Writable } from "stream";
183
+ function isInteractive() {
184
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
185
+ }
186
+ async function readStdin() {
187
+ let data = "";
188
+ for await (const chunk of process.stdin) data += String(chunk);
189
+ return data.replace(/\r?\n$/, "");
190
+ }
191
+ var TtyPrompter = class {
192
+ async ask(question) {
193
+ const rl = createInterface({
194
+ input: process.stdin,
195
+ output: process.stdout
196
+ });
197
+ try {
198
+ return await new Promise(
199
+ (resolve) => rl.question(question, resolve)
200
+ );
201
+ } finally {
202
+ rl.close();
203
+ }
204
+ }
205
+ async askHidden(question) {
206
+ let muted = false;
207
+ const output = new Writable({
208
+ write(chunk, _enc, done) {
209
+ if (!muted) process.stdout.write(chunk);
210
+ done();
211
+ }
212
+ });
213
+ const rl = createInterface({
214
+ input: process.stdin,
215
+ output,
216
+ terminal: true
217
+ });
218
+ try {
219
+ const answer = new Promise(
220
+ (resolve) => rl.question(question, resolve)
221
+ );
222
+ muted = true;
223
+ const value = await answer;
224
+ process.stdout.write("\n");
225
+ return value;
226
+ } finally {
227
+ rl.close();
228
+ }
229
+ }
230
+ async confirm(question) {
231
+ const answer = (await this.ask(`${question} (y/N) `)).trim().toLowerCase();
232
+ return answer === "y" || answer === "yes";
233
+ }
234
+ async select(question, options) {
235
+ process.stdout.write(`${question}
236
+ `);
237
+ options.forEach((option, index) => {
238
+ process.stdout.write(` ${String(index + 1)}. ${option}
63
239
  `);
64
- const result = await deployDirectory(
240
+ });
241
+ for (; ; ) {
242
+ const answer = Number.parseInt(
243
+ (await this.ask(`Choose 1-${String(options.length)}: `)).trim(),
244
+ 10
245
+ );
246
+ if (Number.isInteger(answer) && answer >= 1 && answer <= options.length)
247
+ return answer - 1;
248
+ }
249
+ }
250
+ };
251
+ var NonInteractivePrompter = class {
252
+ constructor(assumeYes) {
253
+ this.assumeYes = assumeYes;
254
+ }
255
+ refuse(question) {
256
+ throw new NaroError(
257
+ "VALIDATION_FAILED",
258
+ `Interactive input required (${question.trim()}). Pass the value via flags/stdin or run without --json in a terminal.`
259
+ );
260
+ }
261
+ async ask(question) {
262
+ this.refuse(question);
263
+ }
264
+ async askHidden(question) {
265
+ this.refuse(question);
266
+ }
267
+ async confirm() {
268
+ return this.assumeYes;
269
+ }
270
+ async select(question) {
271
+ this.refuse(question);
272
+ }
273
+ };
274
+ function createPrompter(options) {
275
+ if (options.yes || options.json || !isInteractive())
276
+ return new NonInteractivePrompter(options.yes);
277
+ return new TtyPrompter();
278
+ }
279
+
280
+ // src/context.ts
281
+ function createContext(flags, positionals = []) {
282
+ const cwd = flags.cwd ?? process.cwd();
283
+ const output = new Output(flags.json, flags.debug);
284
+ const prompter = createPrompter({ json: flags.json, yes: flags.yes });
285
+ let resolvedAuth = null;
286
+ let resolvedClient = null;
287
+ const auth = async () => {
288
+ if (resolvedAuth) return resolvedAuth;
289
+ const interactive = !flags.json && !flags.yes && isInteractive() ? { openBrowser, log: (message) => output.log(message) } : false;
290
+ resolvedAuth = await resolveAuth({
291
+ explicitToken: flags.token,
292
+ interactiveLogin: interactive
293
+ });
294
+ return resolvedAuth;
295
+ };
296
+ return {
65
297
  cwd,
298
+ flags,
299
+ positionals,
300
+ output,
301
+ prompter,
302
+ positionalOrThrow: (count, usage) => {
303
+ if (positionals.length < count)
304
+ throw new NaroError("VALIDATION_FAILED", usage);
305
+ return positionals.slice(0, count);
306
+ },
307
+ auth,
308
+ client: async () => {
309
+ if (resolvedClient) return resolvedClient;
310
+ const resolved = await auth();
311
+ resolvedClient = new NaroClient({
312
+ baseUrl: controlPlaneUrl(),
313
+ getAccessToken: () => resolved.getAccessToken()
314
+ });
315
+ return resolvedClient;
316
+ },
317
+ projectLink: () => readProjectLink(cwd),
318
+ resolveProjectId: async () => {
319
+ if (flags.project) return flags.project;
320
+ const link = readProjectLink(cwd);
321
+ if (link) return link.projectId;
322
+ throw new NaroError(
323
+ "PROJECT_NOT_LINKED",
324
+ "No project is linked here. Run `naro link` first, or pass --project <id>."
325
+ );
326
+ }
327
+ };
328
+ }
329
+
330
+ // src/router.ts
331
+ import { readFileSync } from "fs";
332
+ import { dirname, join as join2 } from "path";
333
+ import { fileURLToPath } from "url";
334
+
335
+ // src/commands/auth.ts
336
+ async function runLogin(context) {
337
+ const { output } = context;
338
+ output.log("Opening your browser to sign in with GitHub...");
339
+ const session = await login({
340
+ openBrowser: (url) => {
341
+ output.log(`If your browser did not open, visit:
342
+ ${url}`);
343
+ openBrowser(url);
344
+ }
345
+ });
346
+ saveSession(session);
347
+ output.result(
348
+ {
349
+ user: {
350
+ id: session.user.id,
351
+ email: session.user.email,
352
+ github: session.user.github
353
+ }
354
+ },
355
+ [`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
356
+ );
357
+ }
358
+ async function runLogout(context) {
359
+ const session = loadStoredSession();
360
+ if (session) {
361
+ try {
362
+ const client = createAuthClient();
363
+ await client.auth.setSession({
364
+ access_token: session.access_token,
365
+ refresh_token: session.refresh_token
366
+ });
367
+ await client.auth.signOut();
368
+ } catch {
369
+ }
370
+ }
371
+ clearSession();
372
+ context.output.result({ loggedOut: true }, ["Logged out."]);
373
+ }
374
+ async function currentSession() {
375
+ let session = loadStoredSession();
376
+ if (!session)
377
+ throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
378
+ if (isExpired(session)) {
379
+ try {
380
+ session = await refreshStoredSession(session.refresh_token);
381
+ saveSession(session);
382
+ } catch {
383
+ throw new NaroError(
384
+ "NOT_LOGGED_IN",
385
+ "Session expired. Run `naro login`."
386
+ );
387
+ }
388
+ }
389
+ return session;
390
+ }
391
+ async function runWhoami(context) {
392
+ const session = await currentSession();
393
+ const handle = session.user.github ? ` (${session.user.github})` : "";
394
+ context.output.result(
395
+ {
396
+ user: {
397
+ id: session.user.id,
398
+ email: session.user.email,
399
+ github: session.user.github
400
+ }
401
+ },
402
+ [`Logged in as ${session.user.email ?? session.user.id}${handle}`]
403
+ );
404
+ }
405
+ async function runToken(context) {
406
+ const session = await currentSession();
407
+ process.stderr.write(
408
+ "\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
409
+ );
410
+ context.output.result(
411
+ { token: session.access_token, expiresAt: session.expires_at },
412
+ [session.access_token]
413
+ );
414
+ }
415
+
416
+ // src/commands/git.ts
417
+ var defaults = {
418
+ detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
419
+ openBrowser,
420
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
421
+ };
422
+ function connected(context, project) {
423
+ const branch = project.defaultBranch;
424
+ context.output.result(
425
+ {
426
+ projectId: project.id,
427
+ repositoryFullName: project.repositoryFullName,
428
+ autoDeploy: true
429
+ },
430
+ [
431
+ `\u2705 Connected to ${project.repositoryFullName ?? "the repository"}.`,
432
+ ` Pushes to ${branch} now deploy automatically.`,
433
+ "",
434
+ " To turn this off: naro git disconnect"
435
+ ]
436
+ );
437
+ }
438
+ async function runGitConnect(context, overrides = {}) {
439
+ const deps = { ...defaults, ...overrides };
440
+ const { output } = context;
441
+ const explicit = context.positionals[0];
442
+ let fullName;
443
+ if (explicit) {
444
+ const parsed = parseGitHubRemote(explicit) ?? parseGitHubRemote(`https://github.com/${explicit}`);
445
+ if (!parsed)
446
+ throw new NaroError(
447
+ "VALIDATION_FAILED",
448
+ `"${explicit}" is not an owner/repo pair.`
449
+ );
450
+ fullName = parsed.fullName;
451
+ } else {
452
+ const remote = await deps.detectRemote(context.cwd);
453
+ const parsed = remote ? parseGitHubRemote(remote) : null;
454
+ if (!parsed) {
455
+ throw new NaroError(
456
+ "NO_GIT_REMOTE",
457
+ remote ? `Could not read a GitHub owner/repo from "${remote}". Pass it directly: naro git connect <owner/repo>` : "This directory has no GitHub origin remote. Pass one directly: naro git connect <owner/repo>"
458
+ );
459
+ }
460
+ fullName = parsed.fullName;
461
+ }
462
+ const client = await context.client();
463
+ const projectId = await context.resolveProjectId();
464
+ const first = await client.projects.connectGit(projectId, fullName);
465
+ if (first.ok) {
466
+ connected(context, first.project);
467
+ return;
468
+ }
469
+ if (!first.installUrl) {
470
+ throw new NaroError(
471
+ "REPOSITORY_NOT_ACCESSIBLE",
472
+ `Naro cannot reach ${fullName}, and no install URL was offered. Connect it from the dashboard.`
473
+ );
474
+ }
475
+ output.log(`
476
+ \u25B8 Naro needs access to ${fullName}.`);
477
+ output.log(` Opening ${first.installUrl}`);
478
+ deps.openBrowser(first.installUrl);
479
+ output.log(" Waiting for the installation to finish...\n");
480
+ await waitForInstall(context, projectId, fullName, deps);
481
+ }
482
+ async function waitForInstall(context, projectId, fullName, deps) {
483
+ const client = await context.client();
484
+ const project = await client.projects.get(projectId);
485
+ const teams = await client.teams.list();
486
+ const teamSlug = teams.find((team) => team.id === project.teamId)?.slug;
487
+ if (teamSlug) {
488
+ const attempts = deps.pollAttempts ?? 60;
489
+ const interval = deps.pollIntervalMs ?? 3e3;
490
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
491
+ await deps.sleep(interval);
492
+ let reachable = false;
493
+ try {
494
+ const repositories = await client.github.listRepositories(teamSlug);
495
+ reachable = repositories.some(
496
+ (repository) => repository.fullName.toLowerCase() === fullName.toLowerCase()
497
+ );
498
+ } catch {
499
+ }
500
+ if (!reachable) continue;
501
+ const retry = await client.projects.connectGit(projectId, fullName);
502
+ if (retry.ok) {
503
+ connected(context, retry.project);
504
+ return;
505
+ }
506
+ }
507
+ }
508
+ context.output.result(
509
+ { projectId, repositoryFullName: null, autoDeploy: false },
510
+ [
511
+ `\u25B8 Still waiting on access to ${fullName}.`,
512
+ " Finish the installation, then run: naro git connect"
513
+ ]
514
+ );
515
+ }
516
+ async function runGitDisconnect(context) {
517
+ const client = await context.client();
518
+ const projectId = await context.resolveProjectId();
519
+ const project = await client.projects.disconnectGit(projectId);
520
+ context.output.result({ projectId: project.id, autoDeploy: false }, [
521
+ `\u2705 ${project.name} no longer deploys on push.`,
522
+ " Deploy from here with: naro deploy"
523
+ ]);
524
+ }
525
+ async function offerAutoDeploy(context, projectId, overrides = {}) {
526
+ const deps = { ...defaults, ...overrides };
527
+ if (overrides.canPrompt === false) return;
528
+ try {
529
+ if (readProjectLink(context.cwd)?.autoDeployDeclined === true) return;
530
+ const remote = await deps.detectRemote(context.cwd);
531
+ const parsed = remote ? parseGitHubRemote(remote) : null;
532
+ if (!parsed) return;
533
+ const client = await context.client();
534
+ const project = await client.projects.get(projectId);
535
+ if (project.repositoryFullName) return;
536
+ const wanted = await context.prompter.confirm(
537
+ "Should your changes deploy automatically from now on?"
538
+ );
539
+ if (!wanted) {
540
+ const link = readProjectLink(context.cwd);
541
+ writeProjectLink(context.cwd, {
542
+ ...link ?? { projectId },
543
+ autoDeployDeclined: true
544
+ });
545
+ context.output.log(
546
+ "\u25B8 Not now. Later, if you change your mind: naro git connect"
547
+ );
548
+ return;
549
+ }
550
+ const result = await client.projects.connectGit(projectId, parsed.fullName);
551
+ if (result.ok) {
552
+ connected(context, result.project);
553
+ return;
554
+ }
555
+ if (!result.installUrl) return;
556
+ context.output.log(`
557
+ \u25B8 Naro needs access to ${parsed.fullName}.`);
558
+ context.output.log(` Opening ${result.installUrl}`);
559
+ deps.openBrowser(result.installUrl);
560
+ context.output.log(" Waiting for the installation to finish...\n");
561
+ await waitForInstall(context, projectId, parsed.fullName, deps);
562
+ } catch (error) {
563
+ context.output.log(
564
+ `\u25B8 Could not set up automatic deploys (${error instanceof Error ? error.message : String(error)}).`
565
+ );
566
+ context.output.log(" Try later with: naro git connect");
567
+ }
568
+ }
569
+
570
+ // src/commands/deploy.ts
571
+ async function runDeploy(context, deps = { deploy: deployDirectory, offerAutoDeploy }) {
572
+ const { flags, output } = context;
573
+ const link = flags.project ? { projectId: flags.project } : readProjectLink(context.cwd);
574
+ if (flags.prod && !flags.yes && !flags.json && isInteractive()) {
575
+ const confirmed = await context.prompter.confirm("Deploy to production?");
576
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
577
+ }
578
+ output.log(`
579
+ \u{1F680} Deploying ${context.cwd}
580
+ `);
581
+ const canPrompt = !flags.json && !flags.yes && isInteractive();
582
+ const result = await deps.deploy(
583
+ context.cwd,
66
584
  {
67
585
  name: flags.name,
586
+ projectId: link?.projectId,
68
587
  rootDirectory: flags.root ?? null,
69
588
  branch: flags.branch,
70
- production: flags.production,
71
- env
589
+ production: flags.prod,
590
+ ...canPrompt && {
591
+ onMissingEnv: (keys) => promptForMissingEnv(
592
+ { cwd: context.cwd, prompter: context.prompter, output },
593
+ keys
594
+ )
595
+ },
596
+ token: flags.token,
597
+ allowLogin: !flags.json && !flags.yes
72
598
  },
73
- (message) => process.stdout.write(`${message}
74
- `)
599
+ (message) => output.log(message)
75
600
  );
76
- process.stdout.write(`
77
- \u2705 Live at ${result.url}
601
+ if (!link) {
602
+ writeProjectLink(context.cwd, { projectId: result.projectId });
603
+ if (ensureGitignoreHasNaro(context.cwd))
604
+ output.log("\u25B8 added .naro to .gitignore");
605
+ output.log("\u25B8 linked (.naro/project.json)");
606
+ }
607
+ await deps.offerAutoDeploy?.(context, result.projectId, { canPrompt });
608
+ context.output.result(
609
+ {
610
+ deploymentId: result.deploymentId,
611
+ projectId: result.projectId,
612
+ status: result.status,
613
+ url: result.url,
614
+ target: result.production ? "production" : "preview",
615
+ missingEnvKeys: result.missingEnvKeys
616
+ },
617
+ [
618
+ "",
619
+ `\u2705 Deployment ready (${result.production ? "production" : "preview"})`,
620
+ "",
621
+ result.url
622
+ ]
623
+ );
624
+ }
78
625
 
79
- `);
626
+ // src/commands/deployments.ts
627
+ function shortSha(sha) {
628
+ return sha ? sha.slice(0, 7) : "-";
80
629
  }
81
- void main().catch((error) => {
82
- process.stderr.write(
83
- `
84
- \u2717 ${error instanceof Error ? error.message : String(error)}
85
- `
630
+ function formatDeploymentLine(deployment) {
631
+ return [
632
+ deployment.id.padEnd(38),
633
+ deployment.status.padEnd(10),
634
+ deployment.branch.padEnd(16),
635
+ shortSha(deployment.gitCommitSha).padEnd(9),
636
+ deployment.url ?? ""
637
+ ].join(" ");
638
+ }
639
+ async function runDeployments(context) {
640
+ const client = await context.client();
641
+ const projectId = await context.resolveProjectId();
642
+ const deployments = await client.deployments.list(projectId);
643
+ context.output.result({ projectId, deployments }, [
644
+ `Deployments for ${projectId}:`,
645
+ "",
646
+ [
647
+ "ID".padEnd(38),
648
+ "STATUS".padEnd(10),
649
+ "BRANCH".padEnd(16),
650
+ "COMMIT".padEnd(9),
651
+ "URL"
652
+ ].join(" "),
653
+ ...deployments.map(formatDeploymentLine)
654
+ ]);
655
+ }
656
+ async function runInspect(context) {
657
+ const [deploymentId] = context.positionalOrThrow(
658
+ 1,
659
+ "Usage: naro inspect <deployment-id>"
660
+ );
661
+ const client = await context.client();
662
+ const detail = await client.deployments.get(deploymentId);
663
+ const { deployment } = detail;
664
+ const lastLogs = detail.logs.slice(-15);
665
+ context.output.result({ deployment, logs: detail.logs }, [
666
+ `Deployment ${deployment.id}`,
667
+ ` status: ${deployment.status}${deployment.errorCode ? ` (${deployment.errorCode})` : ""}`,
668
+ ` branch: ${deployment.branch}`,
669
+ ` commit: ${deployment.gitCommitSha ?? "-"}`,
670
+ ` url: ${deployment.url ?? "-"}`,
671
+ ` created: ${deployment.createdAt}`,
672
+ ` finished: ${deployment.finishedAt ?? "-"}`,
673
+ ...deployment.errorMessage ? [` error: ${deployment.errorMessage}`] : [],
674
+ ...lastLogs.length > 0 ? [
675
+ "",
676
+ "Recent build logs:",
677
+ ...lastLogs.map((log) => ` [${log.stage}] ${log.message}`)
678
+ ] : []
679
+ ]);
680
+ }
681
+
682
+ // src/commands/confirm.ts
683
+ async function confirmOrAbort(context, question) {
684
+ if (context.flags.yes) return;
685
+ if (context.flags.json || !isInteractive()) {
686
+ throw new NaroError(
687
+ "CONFIRMATION_REQUIRED",
688
+ `${question} Re-run with --yes to confirm.`
689
+ );
690
+ }
691
+ const confirmed = await context.prompter.confirm(question);
692
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
693
+ }
694
+
695
+ // src/commands/domains.ts
696
+ function domainLines(domain) {
697
+ const lines = [
698
+ ` ${domain.hostname.padEnd(36)} ${domain.status.padEnd(12)} ssl: ${domain.sslStatus}`
699
+ ];
700
+ if (domain.status !== "active" && domain.verification.length > 0) {
701
+ lines.push(" Required DNS records:");
702
+ for (const record of domain.verification) {
703
+ lines.push(
704
+ ` Type: ${record.type.toUpperCase().padEnd(6)} Name: ${record.name}`,
705
+ ` Value: ${record.value}`
706
+ );
707
+ }
708
+ }
709
+ return lines;
710
+ }
711
+ async function runDomainsList(context) {
712
+ const client = await context.client();
713
+ const projectId = await context.resolveProjectId();
714
+ const domains = await client.domains.list(projectId);
715
+ context.output.result(
716
+ { projectId, domains },
717
+ domains.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
718
+ `Domains (${String(domains.length)}):`,
719
+ ...domains.flatMap(domainLines)
720
+ ]
86
721
  );
87
- process.exitCode = 1;
88
- });
722
+ }
723
+ async function runDomainsAdd(context) {
724
+ const [hostname] = context.positionalOrThrow(
725
+ 1,
726
+ "Usage: naro domains add <hostname>"
727
+ );
728
+ const client = await context.client();
729
+ const projectId = await context.resolveProjectId();
730
+ const domain = await client.domains.add(projectId, hostname);
731
+ context.output.result({ projectId, domain }, [
732
+ `\u2705 Domain added: ${domain.hostname}`,
733
+ "",
734
+ ...domainLines(domain),
735
+ "",
736
+ domain.status === "active" ? "The domain is active." : "Status: Pending verification \u2014 add the DNS records above, then run `naro domains list` to refresh."
737
+ ]);
738
+ }
739
+ async function runDomainsRemove(context) {
740
+ const [hostname] = context.positionalOrThrow(
741
+ 1,
742
+ "Usage: naro domains remove <hostname>"
743
+ );
744
+ await confirmOrAbort(
745
+ context,
746
+ `Remove ${String(hostname)}? Traffic to it will stop routing here.`
747
+ );
748
+ const client = await context.client();
749
+ const projectId = await context.resolveProjectId();
750
+ await client.domains.remove(projectId, hostname);
751
+ context.output.result({ projectId, hostname, removed: true }, [
752
+ `\u2705 Removed ${String(hostname)}`
753
+ ]);
754
+ }
755
+
756
+ // src/commands/env.ts
757
+ import { existsSync, writeFileSync } from "fs";
758
+ import { join } from "path";
759
+ var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
760
+ var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
761
+ function parseTarget(value) {
762
+ if (value === void 0) return "all";
763
+ if (!TARGETS.has(value))
764
+ throw new NaroError(
765
+ "VALIDATION_FAILED",
766
+ "Environment must be production, preview or all."
767
+ );
768
+ return value;
769
+ }
770
+ function maskedValue(row) {
771
+ if (!row.isSecret) return row.value ?? "";
772
+ return "\u2022\u2022\u2022\u2022\u2022\u2022 (secret)";
773
+ }
774
+ async function runEnvList(context) {
775
+ const client = await context.client();
776
+ const projectId = await context.resolveProjectId();
777
+ const reveal = context.flags.reveal;
778
+ const rows = await client.env.list(projectId, { reveal });
779
+ context.output.result(
780
+ {
781
+ projectId,
782
+ envVars: rows.map((row) => ({
783
+ id: row.id,
784
+ key: row.key,
785
+ target: row.target,
786
+ isSecret: row.isSecret,
787
+ value: row.isSecret && !reveal ? null : row.value
788
+ }))
789
+ },
790
+ [
791
+ `Environment variables (${String(rows.length)}):`,
792
+ ...rows.map(
793
+ (row) => ` ${row.key.padEnd(32)} ${row.target.padEnd(12)} ${reveal ? row.value ?? "" : maskedValue(row)}`
794
+ )
795
+ ]
796
+ );
797
+ }
798
+ async function runEnvAdd(context) {
799
+ context.positionalOrThrow(
800
+ 1,
801
+ "Usage: naro env add KEY [production|preview|all]"
802
+ );
803
+ const [key, targetArg] = context.positionals;
804
+ if (!key || !KEY_RE.test(key) || key.length > 256)
805
+ throw new NaroError(
806
+ "VALIDATION_FAILED",
807
+ "Keys must look like MY_VARIABLE (letters, digits, underscore)."
808
+ );
809
+ const target = parseTarget(targetArg);
810
+ let value;
811
+ if (!process.stdin.isTTY) {
812
+ value = await readStdin();
813
+ } else {
814
+ value = await context.prompter.askHidden(`? What's the value of ${key}? `);
815
+ }
816
+ if (value.length === 0)
817
+ throw new NaroError("VALIDATION_FAILED", `No value provided for ${key}.`);
818
+ const isSecret = !key.startsWith("NEXT_PUBLIC_");
819
+ const client = await context.client();
820
+ const projectId = await context.resolveProjectId();
821
+ await client.env.set(projectId, [{ key, value, target, isSecret }]);
822
+ context.output.result({ projectId, key, target, isSecret }, [
823
+ `\u2705 Added ${key} (${target}${isSecret ? ", secret" : ""})`
824
+ ]);
825
+ }
826
+ async function runEnvRemove(context) {
827
+ context.positionalOrThrow(
828
+ 1,
829
+ "Usage: naro env remove KEY [production|preview|all]"
830
+ );
831
+ const [key, targetArg] = context.positionals;
832
+ const target = targetArg === void 0 ? void 0 : parseTarget(targetArg);
833
+ await confirmOrAbort(
834
+ context,
835
+ `Remove ${String(key)}${target ? ` (${target})` : ""} from this project?`
836
+ );
837
+ const client = await context.client();
838
+ const projectId = await context.resolveProjectId();
839
+ const removed = await client.env.remove(projectId, key, target);
840
+ context.output.result({ projectId, key, removed }, [
841
+ `\u2705 Removed ${String(key)} (${String(removed)} entr${removed === 1 ? "y" : "ies"})`
842
+ ]);
843
+ }
844
+ async function runEnvPull(context) {
845
+ const file = context.positionals[0] ?? ".env.local";
846
+ const surface = context.flags.env === "production" ? "production" : "preview";
847
+ if (context.flags.env && !["production", "preview"].includes(context.flags.env))
848
+ throw new NaroError(
849
+ "VALIDATION_FAILED",
850
+ "--env must be production or preview."
851
+ );
852
+ const client = await context.client();
853
+ const projectId = await context.resolveProjectId();
854
+ const rows = await client.env.list(projectId, { reveal: true });
855
+ const selected = rows.filter(
856
+ (row) => row.target === "all" || row.target === surface
857
+ );
858
+ const vars = Object.fromEntries(
859
+ selected.map((row) => [row.key, row.value ?? ""])
860
+ );
861
+ const path = join(context.cwd, file);
862
+ if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
863
+ const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
864
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
865
+ }
866
+ writeFileSync(
867
+ path,
868
+ `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
869
+ ${serializeDotenv(vars)}`,
870
+ { mode: 384 }
871
+ );
872
+ context.output.result(
873
+ { projectId, file, environment: surface, count: selected.length },
874
+ [`\u2705 Pulled ${String(selected.length)} variable(s) into ${file}`]
875
+ );
876
+ }
877
+
878
+ // src/commands/link.ts
879
+ async function resolveTeam(context, teams) {
880
+ if (context.flags.team) {
881
+ const match = teams.find((team) => team.slug === context.flags.team);
882
+ if (!match)
883
+ throw new NaroError(
884
+ "NOT_FOUND",
885
+ `You are not a member of team "${context.flags.team}".`
886
+ );
887
+ return match;
888
+ }
889
+ const linked = context.projectLink();
890
+ if (linked?.teamSlug) {
891
+ const match = teams.find((team) => team.slug === linked.teamSlug);
892
+ if (match) return match;
893
+ }
894
+ const personal = teams.find((team) => team.isPersonal);
895
+ const fallback = personal ?? teams[0];
896
+ if (!fallback)
897
+ throw new NaroError("NOT_FOUND", "Your account has no teams yet.");
898
+ if (teams.length > 1 && !personal) {
899
+ const index = await context.prompter.select(
900
+ "Which team?",
901
+ teams.map((team) => `${team.name} (${team.slug})`)
902
+ );
903
+ return teams[index] ?? fallback;
904
+ }
905
+ return fallback;
906
+ }
907
+ function saveLink(context, project, teamSlug) {
908
+ writeProjectLink(context.cwd, {
909
+ projectId: project.id,
910
+ teamSlug,
911
+ projectName: project.name
912
+ });
913
+ if (ensureGitignoreHasNaro(context.cwd))
914
+ context.output.log("\u25B8 added .naro to .gitignore");
915
+ }
916
+ async function runLink(context) {
917
+ const client = await context.client();
918
+ const teams = await client.teams.list();
919
+ const team = await resolveTeam(context, teams);
920
+ const projects = await client.projects.list(team.slug);
921
+ let project;
922
+ if (context.flags.project) {
923
+ project = projects.find(
924
+ (candidate) => candidate.id === context.flags.project
925
+ );
926
+ if (!project) {
927
+ project = await client.projects.get(context.flags.project);
928
+ }
929
+ } else if (context.flags.name) {
930
+ project = projects.find(
931
+ (candidate) => candidate.name === context.flags.name || candidate.slug === context.flags.name
932
+ );
933
+ if (!project)
934
+ throw new NaroError(
935
+ "PROJECT_NOT_FOUND",
936
+ `No project named "${context.flags.name}" in team ${team.slug}. Run \`naro deploy\` to create it.`
937
+ );
938
+ } else {
939
+ const guess = slugify(context.cwd.split("/").at(-1) ?? "app");
940
+ const exact = projects.find(
941
+ (candidate) => candidate.name === guess || candidate.slug === guess
942
+ );
943
+ if (exact && (context.flags.yes || context.flags.json)) {
944
+ project = exact;
945
+ } else if (projects.length === 0) {
946
+ throw new NaroError(
947
+ "PROJECT_NOT_FOUND",
948
+ `Team ${team.slug} has no projects yet. Run \`naro deploy\` to create one.`
949
+ );
950
+ } else {
951
+ const options = projects.map(
952
+ (candidate) => `${candidate.name} (${candidate.subdomain})`
953
+ );
954
+ const index = await context.prompter.select(
955
+ `Link ${context.cwd} to which project?`,
956
+ options
957
+ );
958
+ project = projects[index];
959
+ }
960
+ }
961
+ if (!project)
962
+ throw new NaroError("PROJECT_NOT_FOUND", "No project selected.");
963
+ const owningTeam = teams.find((candidate) => candidate.id === project.teamId) ?? team;
964
+ saveLink(context, project, owningTeam.slug);
965
+ context.output.result(
966
+ {
967
+ projectId: project.id,
968
+ projectName: project.name,
969
+ teamSlug: owningTeam.slug,
970
+ subdomain: project.subdomain
971
+ },
972
+ [`\u2705 Linked to ${owningTeam.slug}/${project.name} (.naro/project.json)`]
973
+ );
974
+ }
975
+ async function runUnlink(context) {
976
+ const existing = readProjectLink(context.cwd);
977
+ const removed = removeProjectLink(context.cwd);
978
+ context.output.result(
979
+ { removed, ...existing ? { projectId: existing.projectId } : {} },
980
+ [removed ? "\u2705 Unlinked (.naro removed)" : "Nothing to unlink here."]
981
+ );
982
+ }
983
+
984
+ // src/commands/logs.ts
985
+ var FOLLOW_INTERVAL_MS = 3e3;
986
+ function sleep(ms) {
987
+ return new Promise((resolve) => setTimeout(resolve, ms));
988
+ }
989
+ function formatBuildLog(log) {
990
+ return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
991
+ }
992
+ function formatRuntimeLog(entry) {
993
+ const time = new Date(entry.ts).toISOString();
994
+ const request = entry.method && entry.path ? ` ${entry.method} ${entry.path}${entry.status ? ` ${String(entry.status)}` : ""}` : "";
995
+ return `${time} [${entry.level}]${request} ${entry.message}`.trimEnd();
996
+ }
997
+ async function latestProductionDeploymentId(client, projectId) {
998
+ const deployments = await client.deployments.list(projectId);
999
+ const latest = deployments.find(
1000
+ (deployment) => deployment.status === "READY"
1001
+ );
1002
+ const fallback = deployments[0];
1003
+ const chosen = latest ?? fallback;
1004
+ if (!chosen)
1005
+ throw new NaroError(
1006
+ "DEPLOYMENT_NOT_FOUND",
1007
+ "This project has no deployments yet."
1008
+ );
1009
+ return chosen.id;
1010
+ }
1011
+ async function buildLogs(context, client, deploymentId) {
1012
+ if (!context.flags.follow) {
1013
+ const detail = await client.deployments.get(deploymentId);
1014
+ context.output.result(
1015
+ { deploymentId, type: "build", logs: detail.logs },
1016
+ detail.logs.map(formatBuildLog)
1017
+ );
1018
+ return;
1019
+ }
1020
+ let lastSequence = -1;
1021
+ for (; ; ) {
1022
+ const detail = await client.deployments.get(deploymentId);
1023
+ for (const log of detail.logs) {
1024
+ if (log.sequence > lastSequence) {
1025
+ lastSequence = log.sequence;
1026
+ context.output.log(formatBuildLog(log));
1027
+ }
1028
+ }
1029
+ if (isTerminalStatus(detail.deployment.status)) {
1030
+ context.output.result(
1031
+ {
1032
+ deploymentId,
1033
+ type: "build",
1034
+ status: detail.deployment.status,
1035
+ logs: detail.logs
1036
+ },
1037
+ [`\u2014 deployment ${detail.deployment.status} \u2014`]
1038
+ );
1039
+ return;
1040
+ }
1041
+ await sleep(FOLLOW_INTERVAL_MS);
1042
+ }
1043
+ }
1044
+ async function runtimeLogs(context, client, projectId) {
1045
+ const environment = context.flags.env === "preview" ? "preview" : "production";
1046
+ if (context.flags.env && !["production", "preview"].includes(context.flags.env))
1047
+ throw new NaroError(
1048
+ "VALIDATION_FAILED",
1049
+ "--env must be production or preview."
1050
+ );
1051
+ const options = {
1052
+ environment,
1053
+ ...context.flags.level ? { level: context.flags.level } : {}
1054
+ };
1055
+ if (!context.flags.follow) {
1056
+ const entries = await client.logs.runtime(projectId, options);
1057
+ context.output.result(
1058
+ { projectId, type: "runtime", environment, entries },
1059
+ entries.length > 0 ? entries.map(formatRuntimeLog) : ["(no runtime logs yet \u2014 the worker logs on each request)"]
1060
+ );
1061
+ return;
1062
+ }
1063
+ context.output.log(
1064
+ `Following ${environment} runtime logs (Ctrl-C to stop)...`
1065
+ );
1066
+ let lastTs = -1;
1067
+ let boundary = /* @__PURE__ */ new Set();
1068
+ for (; ; ) {
1069
+ const entries = await client.logs.runtime(projectId, options);
1070
+ for (const entry of entries) {
1071
+ if (entry.ts < lastTs) continue;
1072
+ const key = JSON.stringify(entry);
1073
+ if (entry.ts === lastTs) {
1074
+ if (boundary.has(key)) continue;
1075
+ } else {
1076
+ lastTs = entry.ts;
1077
+ boundary = /* @__PURE__ */ new Set();
1078
+ }
1079
+ boundary.add(key);
1080
+ context.output.log(formatRuntimeLog(entry));
1081
+ }
1082
+ await sleep(FOLLOW_INTERVAL_MS);
1083
+ }
1084
+ }
1085
+ async function runLogs(context) {
1086
+ const client = await context.client();
1087
+ const [deploymentId] = context.positionals;
1088
+ const type = context.flags.type ?? (deploymentId !== void 0 ? "build" : "runtime");
1089
+ if (type !== "build" && type !== "runtime")
1090
+ throw new NaroError(
1091
+ "VALIDATION_FAILED",
1092
+ "--type must be build or runtime."
1093
+ );
1094
+ if (type === "runtime") {
1095
+ if (deploymentId)
1096
+ throw new NaroError(
1097
+ "VALIDATION_FAILED",
1098
+ "Runtime logs are per project, not per deployment. Drop the deployment id or use --type build."
1099
+ );
1100
+ const projectId = await context.resolveProjectId();
1101
+ await runtimeLogs(context, client, projectId);
1102
+ return;
1103
+ }
1104
+ const target = deploymentId ?? await latestProductionDeploymentId(
1105
+ client,
1106
+ await context.resolveProjectId()
1107
+ );
1108
+ await buildLogs(context, client, target);
1109
+ }
1110
+
1111
+ // src/commands/projects.ts
1112
+ function productionUrl(subdomain) {
1113
+ return `https://${subdomain}.${new URL(controlPlaneUrl()).hostname}`;
1114
+ }
1115
+ async function runProjects(context) {
1116
+ const client = await context.client();
1117
+ const teams = await client.teams.list();
1118
+ const team = await resolveTeam(context, teams);
1119
+ const projects = await client.projects.list(team.slug);
1120
+ context.output.result(
1121
+ {
1122
+ team: team.slug,
1123
+ projects: projects.map((project) => ({
1124
+ id: project.id,
1125
+ name: project.name,
1126
+ subdomain: project.subdomain,
1127
+ url: productionUrl(project.subdomain),
1128
+ defaultBranch: project.defaultBranch,
1129
+ updatedAt: project.updatedAt
1130
+ }))
1131
+ },
1132
+ [
1133
+ `Projects in ${team.slug} (${String(projects.length)}):`,
1134
+ ...projects.map(
1135
+ (project) => ` ${project.name.padEnd(24)} ${productionUrl(project.subdomain).padEnd(40)} ${project.id}`
1136
+ )
1137
+ ]
1138
+ );
1139
+ }
1140
+ async function runProjectInspect(context) {
1141
+ const client = await context.client();
1142
+ const projectId = await context.resolveProjectId();
1143
+ const project = await client.projects.get(projectId);
1144
+ const url = productionUrl(project.subdomain);
1145
+ context.output.result({ project: { ...project, url } }, [
1146
+ `${project.name}`,
1147
+ ` id: ${project.id}`,
1148
+ ` team: ${project.teamId}`,
1149
+ ` production URL: ${url}`,
1150
+ ` preview URL: https://${project.subdomain}-preview.${new URL(controlPlaneUrl()).hostname}`,
1151
+ ` default branch: ${project.defaultBranch}`,
1152
+ ` root directory: ${project.rootDirectory ?? "(repo root)"}`,
1153
+ ` repository: ${project.repositoryFullName ?? project.repositoryUrl}`
1154
+ ]);
1155
+ }
1156
+
1157
+ // src/commands/promote.ts
1158
+ async function promoteAndWait(context, client, project, sourceDeploymentId, action) {
1159
+ const promoted = await client.deployments.promote(
1160
+ project.id,
1161
+ sourceDeploymentId
1162
+ );
1163
+ context.output.log(
1164
+ `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
1165
+ );
1166
+ const detail = await client.deployments.wait(promoted.id, {
1167
+ timeoutMs: 5 * 6e4
1168
+ });
1169
+ if (detail.deployment.status !== "READY") {
1170
+ throw new NaroError(
1171
+ detail.deployment.errorCode ?? "DEPLOY_FAILED",
1172
+ detail.deployment.errorMessage ?? `The ${action} failed.`,
1173
+ { details: { deploymentId: promoted.id } }
1174
+ );
1175
+ }
1176
+ context.output.result(
1177
+ {
1178
+ deploymentId: promoted.id,
1179
+ sourceDeploymentId,
1180
+ projectId: project.id,
1181
+ status: detail.deployment.status,
1182
+ url: detail.deployment.url
1183
+ },
1184
+ [
1185
+ `\u2705 Production now serves ${sourceDeploymentId}`,
1186
+ "",
1187
+ detail.deployment.url ?? ""
1188
+ ]
1189
+ );
1190
+ }
1191
+ async function runPromote(context) {
1192
+ const [deploymentId] = context.positionalOrThrow(
1193
+ 1,
1194
+ "Usage: naro promote <deployment-id>"
1195
+ );
1196
+ const client = await context.client();
1197
+ const projectId = await context.resolveProjectId();
1198
+ const project = await client.projects.get(projectId);
1199
+ await confirmOrAbort(
1200
+ context,
1201
+ `Promote ${String(deploymentId)} to production (${project.subdomain})?`
1202
+ );
1203
+ await promoteAndWait(
1204
+ context,
1205
+ client,
1206
+ project,
1207
+ deploymentId,
1208
+ "promote"
1209
+ );
1210
+ }
1211
+ async function runRollback(context) {
1212
+ const client = await context.client();
1213
+ const projectId = await context.resolveProjectId();
1214
+ const project = await client.projects.get(projectId);
1215
+ let target = context.positionals[0];
1216
+ if (!target) {
1217
+ const { current, target: candidate } = await client.deployments.rollbackTarget(projectId, project.defaultBranch);
1218
+ if (!candidate)
1219
+ throw new NaroError(
1220
+ "DEPLOYMENT_NOT_FOUND",
1221
+ "No earlier production build with a different artifact to roll back to. Pass a deployment id explicitly."
1222
+ );
1223
+ context.output.log(
1224
+ `\u25B8 current production: ${current.id}${current.gitCommitSha ? ` (${current.gitCommitSha.slice(0, 7)})` : ""}`
1225
+ );
1226
+ target = candidate.id;
1227
+ }
1228
+ await confirmOrAbort(
1229
+ context,
1230
+ `Are you sure you want to rollback production to ${target}?`
1231
+ );
1232
+ await promoteAndWait(context, client, project, target, "rollback");
1233
+ }
1234
+
1235
+ // src/router.ts
1236
+ var HELP = `naro \u2014 deploy Next.js apps to Naro (https://naro.sh)
1237
+
1238
+ Usage:
1239
+ naro login | logout | whoami
1240
+ naro token Print an access token for CI (NARO_TOKEN)
1241
+
1242
+ naro link | init Link this directory to a Naro project
1243
+ naro unlink Remove the link (.naro/)
1244
+ naro projects [--team <slug>] List projects
1245
+ naro project inspect Show the linked project
1246
+
1247
+ naro deploy [--prod] Build locally and deploy (preview by default)
1248
+ naro deployments List deployments of the linked project
1249
+ naro inspect <deployment-id> Show one deployment + build logs
1250
+
1251
+ naro logs [deployment-id] Runtime logs (or build logs of a deployment)
1252
+ [--type build|runtime] [--env production|preview] [--level error] [--follow]
1253
+
1254
+ naro env list List env vars (secrets masked)
1255
+ naro env add KEY [environment] Add/update (value from prompt or stdin)
1256
+ naro env remove KEY [environment] Remove
1257
+ naro env pull [file] Write env vars to .env.local
1258
+
1259
+ naro domains list | add <host> | remove <host>
1260
+
1261
+ naro promote <deployment-id> Publish a build to production
1262
+ naro rollback [deployment-id] Roll production back to the previous build
1263
+
1264
+ Global options:
1265
+ --json Machine-readable output (single JSON object on stdout)
1266
+ --yes, -y Never prompt; assume yes (CI / agents)
1267
+ --token <t> Access token (or set NARO_TOKEN)
1268
+ --project <id> Target project (overrides .naro/project.json)
1269
+ --team <slug> Team scope for project listing/linking
1270
+ --cwd <dir> Run as if started in <dir>
1271
+ --prod Deploy/target production
1272
+ --debug Verbose diagnostics
1273
+
1274
+ Credentials: \`naro login\` opens a browser (GitHub). CI: NARO_TOKEN=$(naro token).
1275
+ Set NARO_CONTROL_PLANE_URL to use a custom control plane (default https://naro.sh).
1276
+ `;
1277
+ function route(parsed) {
1278
+ const { command, positionals } = parsed;
1279
+ const sub = positionals[0];
1280
+ switch (command) {
1281
+ case "login":
1282
+ return { handler: runLogin, positionals };
1283
+ case "logout":
1284
+ return { handler: runLogout, positionals };
1285
+ case "whoami":
1286
+ return { handler: runWhoami, positionals };
1287
+ case "token":
1288
+ return { handler: runToken, positionals };
1289
+ case "link":
1290
+ case "init":
1291
+ return { handler: runLink, positionals };
1292
+ case "unlink":
1293
+ return { handler: runUnlink, positionals };
1294
+ case "projects":
1295
+ return { handler: runProjects, positionals };
1296
+ case "project":
1297
+ if (sub === void 0 || sub === "inspect")
1298
+ return {
1299
+ handler: runProjectInspect,
1300
+ positionals: positionals.slice(1)
1301
+ };
1302
+ return null;
1303
+ case "deploy":
1304
+ return { handler: runDeploy, positionals };
1305
+ case "deployments":
1306
+ case "ls":
1307
+ return { handler: runDeployments, positionals };
1308
+ case "inspect":
1309
+ return { handler: runInspect, positionals };
1310
+ case "logs":
1311
+ return { handler: runLogs, positionals };
1312
+ case "env":
1313
+ switch (sub) {
1314
+ case void 0:
1315
+ case "list":
1316
+ case "ls":
1317
+ return { handler: runEnvList, positionals: positionals.slice(1) };
1318
+ case "add":
1319
+ return { handler: runEnvAdd, positionals: positionals.slice(1) };
1320
+ case "remove":
1321
+ case "rm":
1322
+ return { handler: runEnvRemove, positionals: positionals.slice(1) };
1323
+ case "pull":
1324
+ return { handler: runEnvPull, positionals: positionals.slice(1) };
1325
+ default:
1326
+ return null;
1327
+ }
1328
+ case "domains":
1329
+ switch (sub) {
1330
+ case void 0:
1331
+ case "list":
1332
+ case "ls":
1333
+ return { handler: runDomainsList, positionals: positionals.slice(1) };
1334
+ case "add":
1335
+ return { handler: runDomainsAdd, positionals: positionals.slice(1) };
1336
+ case "remove":
1337
+ case "rm":
1338
+ return {
1339
+ handler: runDomainsRemove,
1340
+ positionals: positionals.slice(1)
1341
+ };
1342
+ default:
1343
+ return null;
1344
+ }
1345
+ case "promote":
1346
+ return { handler: runPromote, positionals };
1347
+ case "rollback":
1348
+ return { handler: runRollback, positionals };
1349
+ case "git":
1350
+ switch (positionals[0]) {
1351
+ case "connect":
1352
+ return { handler: runGitConnect, positionals: positionals.slice(1) };
1353
+ case "disconnect":
1354
+ return { handler: runGitDisconnect, positionals: [] };
1355
+ default:
1356
+ return null;
1357
+ }
1358
+ default:
1359
+ return null;
1360
+ }
1361
+ }
1362
+ function version() {
1363
+ try {
1364
+ const pkg = JSON.parse(
1365
+ readFileSync(
1366
+ join2(dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
1367
+ "utf8"
1368
+ )
1369
+ );
1370
+ return pkg.version ?? "unknown";
1371
+ } catch {
1372
+ return "unknown";
1373
+ }
1374
+ }
1375
+
1376
+ // src/index.ts
1377
+ async function main(argv = process.argv.slice(2)) {
1378
+ const parsed = parseArgs(argv);
1379
+ if (parsed.flags.version) {
1380
+ process.stdout.write(`${version()}
1381
+ `);
1382
+ return;
1383
+ }
1384
+ if (!parsed.command || parsed.command === "help" || parsed.flags.help) {
1385
+ if (parsed.flags.json) {
1386
+ process.stdout.write(
1387
+ `${JSON.stringify({ success: true, help: HELP }, null, 2)}
1388
+ `
1389
+ );
1390
+ return;
1391
+ }
1392
+ process.stdout.write(HELP);
1393
+ if (parsed.command === void 0 && !parsed.flags.help && argv.length > 0)
1394
+ process.exitCode = 1;
1395
+ return;
1396
+ }
1397
+ const output = new Output(parsed.flags.json, parsed.flags.debug);
1398
+ if (parsed.unknown.length > 0) {
1399
+ output.fail(
1400
+ new NaroError(
1401
+ "VALIDATION_FAILED",
1402
+ `Unknown option: ${parsed.unknown.join(", ")}. See \`naro help\`.`
1403
+ )
1404
+ );
1405
+ return;
1406
+ }
1407
+ const routed = route(parsed);
1408
+ if (!routed) {
1409
+ output.fail(
1410
+ new NaroError(
1411
+ "VALIDATION_FAILED",
1412
+ `Unknown command: ${[parsed.command, ...parsed.positionals].join(" ")}. See \`naro help\`.`
1413
+ )
1414
+ );
1415
+ return;
1416
+ }
1417
+ const context = createContext(parsed.flags, routed.positionals);
1418
+ try {
1419
+ await routed.handler(context);
1420
+ } catch (error) {
1421
+ context.output.fail(error);
1422
+ }
1423
+ }
1424
+ void main();
89
1425
  export {
90
- parseFlags
1426
+ main
91
1427
  };