naro-cli 0.3.0 → 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/README.md CHANGED
@@ -13,11 +13,19 @@ naro deploy --prod # production deployment
13
13
  ```
14
14
 
15
15
  The first `naro deploy` creates the project (named after `package.json`),
16
- seeds it with your local `.env` values, links the directory via
17
- `.naro/project.json` (gitignored automatically) and prints the live URL.
18
- From then on the platform owns env vars (`naro env add/remove/pull`) —
19
- later deploys do not re-upload local dotenv files. No Cloudflare
20
- credentials are ever needed.
16
+ links the directory via `.naro/project.json` (gitignored automatically) and
17
+ prints the live URL. No Cloudflare credentials are ever needed.
18
+
19
+ Local `.env` files are never uploaded on their own. Every deploy scans your
20
+ source for the env vars it references (`process.env.X`, `import.meta.env.X`,
21
+ plus anything listed in `.env.example`), compares that with the project, and
22
+ asks before opening a dotenv file to fill what is missing. Decline and it
23
+ prints the `naro env add` commands instead. With `--yes`, `--json` or outside
24
+ a TTY it never asks — it just lists what is missing and deploys.
25
+
26
+ Values you register are encrypted at rest, and `naro env pull` /
27
+ `naro env list --reveal` can read them back — your account credentials are
28
+ effectively your env credentials.
21
29
 
22
30
  ## Commands
23
31
 
@@ -6,10 +6,10 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/deploy.ts
9
- import { existsSync as existsSync4 } from "fs";
9
+ import { existsSync as existsSync6 } from "fs";
10
10
  import { cp, mkdtemp as mkdtemp2, readFile as readFile7, rm as rm2 } from "fs/promises";
11
11
  import { tmpdir as tmpdir2 } from "os";
12
- import { basename, dirname, join as join13 } from "path";
12
+ import { basename, dirname, join as join15 } from "path";
13
13
  import { fileURLToPath } from "url";
14
14
 
15
15
  // ../../packages/build/src/artifact-upload.ts
@@ -186,6 +186,27 @@ var DeploymentError = class extends Error {
186
186
  }
187
187
  };
188
188
 
189
+ // ../../packages/core/src/git-remote.ts
190
+ var SCP_LIKE = /^[^@\s/]+@([^:\s/]+):(.+)$/;
191
+ function parseGitHubRemote(url2) {
192
+ const trimmed = url2.trim();
193
+ if (trimmed === "") return null;
194
+ const scp = SCP_LIKE.exec(trimmed);
195
+ const path = scp ? scp[2] ?? null : pathnameOf(trimmed);
196
+ if (path === null) return null;
197
+ const segments = path.replace(/\.git$/, "").split("/").filter((segment) => segment !== "");
198
+ if (segments.length !== 2) return null;
199
+ const [owner, repo] = segments;
200
+ return { owner, repo, fullName: `${owner}/${repo}` };
201
+ }
202
+ function pathnameOf(url2) {
203
+ try {
204
+ return new URL(url2).pathname;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
189
210
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
190
211
  var external_exports = {};
191
212
  __export(external_exports, {
@@ -14950,7 +14971,10 @@ export default defineCloudflareConfig({});
14950
14971
  )}
14951
14972
  `
14952
14973
  );
14953
- const packageManagerConfigPath = await createPackageManagerConfig(appRoot, writer);
14974
+ const packageManagerConfigPath = await createPackageManagerConfig(
14975
+ appRoot,
14976
+ writer
14977
+ );
14954
14978
  return {
14955
14979
  openNextConfigPath,
14956
14980
  wranglerConfigPath,
@@ -15735,7 +15759,7 @@ var LocalBuildExecutor = class {
15735
15759
  args: ["run", resolved.buildScript],
15736
15760
  cwd: appRoot,
15737
15761
  env: {
15738
- ...input.env ?? {},
15762
+ ...input.env,
15739
15763
  ...isNext ? { NEXT_PRIVATE_STANDALONE: "true" } : {},
15740
15764
  NPM_CONFIG_USERCONFIG: packageManagerConfigPath
15741
15765
  },
@@ -15865,6 +15889,8 @@ async function errorFromResponse(response, context) {
15865
15889
  const code = serverCode ?? (response.status === 401 ? "UNAUTHORIZED" : response.status === 404 ? "NOT_FOUND" : response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR");
15866
15890
  const details = {};
15867
15891
  if (body?.error?.fields !== void 0) details.fields = body.error.fields;
15892
+ if (typeof body?.error?.installUrl === "string")
15893
+ details.installUrl = body.error.installUrl;
15868
15894
  return new NaroError(
15869
15895
  code,
15870
15896
  serverMessage ?? `${context} failed: the control plane returned ${String(response.status)}.`,
@@ -16189,6 +16215,7 @@ var NaroClient = class {
16189
16215
  doFetch;
16190
16216
  teams;
16191
16217
  projects;
16218
+ github;
16192
16219
  deployments;
16193
16220
  env;
16194
16221
  domains;
@@ -16199,6 +16226,7 @@ var NaroClient = class {
16199
16226
  this.doFetch = options.fetch ?? fetch;
16200
16227
  this.teams = new TeamsApi(this);
16201
16228
  this.projects = new ProjectsApi(this);
16229
+ this.github = new GithubApi(this);
16202
16230
  this.deployments = new DeploymentsApi(this);
16203
16231
  this.env = new EnvApi(this);
16204
16232
  this.domains = new DomainsApi(this);
@@ -16257,6 +16285,51 @@ var ProjectsApi = class {
16257
16285
  );
16258
16286
  return projectInfo(body.project);
16259
16287
  }
16288
+ /**
16289
+ * Connects a project to a GitHub repository, turning `git push` into a
16290
+ * deployment.
16291
+ *
16292
+ * "The App is not installed on that account" is a normal branch here, not a
16293
+ * failure: the CLI opens a browser and polls, while MCP hands the URL back
16294
+ * to the agent. So it is returned rather than thrown.
16295
+ */
16296
+ async connectGit(projectId, fullName) {
16297
+ try {
16298
+ const body = await this.client.request(
16299
+ `/api/projects/${encodeURIComponent(projectId)}/git`,
16300
+ { method: "POST", body: { fullName }, context: "Connect repository" }
16301
+ );
16302
+ return { ok: true, project: projectInfo(body.project) };
16303
+ } catch (error51) {
16304
+ if (isNaroError(error51) && error51.code === "REPOSITORY_NOT_ACCESSIBLE") {
16305
+ const installUrl = error51.details?.installUrl;
16306
+ return {
16307
+ ok: false,
16308
+ installUrl: typeof installUrl === "string" ? installUrl : null
16309
+ };
16310
+ }
16311
+ throw error51;
16312
+ }
16313
+ }
16314
+ async disconnectGit(projectId) {
16315
+ const body = await this.client.request(
16316
+ `/api/projects/${encodeURIComponent(projectId)}/git`,
16317
+ { method: "DELETE", context: "Disconnect repository" }
16318
+ );
16319
+ return projectInfo(body.project);
16320
+ }
16321
+ };
16322
+ var GithubApi = class {
16323
+ constructor(client) {
16324
+ this.client = client;
16325
+ }
16326
+ /** Repositories every installation attached to the team can reach. */
16327
+ async listRepositories(teamSlug) {
16328
+ const body = await this.client.request(`/api/github/repositories?team=${encodeURIComponent(teamSlug)}`, {
16329
+ context: "List repositories"
16330
+ });
16331
+ return body.repositories;
16332
+ }
16260
16333
  };
16261
16334
  var DeploymentsApi = class {
16262
16335
  constructor(client) {
@@ -16517,13 +16590,118 @@ function serializeDotenv(vars) {
16517
16590
  `;
16518
16591
  }
16519
16592
 
16520
- // ../../packages/sdk/src/framework.ts
16521
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
16593
+ // ../../packages/sdk/src/env-scan.ts
16594
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync } from "fs";
16522
16595
  import { join as join11 } from "path";
16596
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
16597
+ ".ts",
16598
+ ".tsx",
16599
+ ".js",
16600
+ ".jsx",
16601
+ ".mjs",
16602
+ ".cjs",
16603
+ ".mts",
16604
+ ".cts",
16605
+ ".vue",
16606
+ ".svelte",
16607
+ ".astro"
16608
+ ]);
16609
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
16610
+ "node_modules",
16611
+ "dist",
16612
+ "build",
16613
+ "out",
16614
+ "coverage",
16615
+ "tmp"
16616
+ ]);
16617
+ var EXAMPLE_FILES = [".env.example", ".env.sample", ".env.template"];
16618
+ var IGNORED_KEYS = /* @__PURE__ */ new Set([
16619
+ "NODE_ENV",
16620
+ "PORT",
16621
+ "CI",
16622
+ "HOME",
16623
+ "PATH",
16624
+ "PWD",
16625
+ "TZ",
16626
+ "NEXT_RUNTIME",
16627
+ "MODE",
16628
+ "BASE_URL",
16629
+ "DEV",
16630
+ "PROD",
16631
+ "SSR"
16632
+ ]);
16633
+ var MAX_FILES = 5e3;
16634
+ var MAX_FILE_BYTES2 = 1024 * 1024;
16635
+ var REFERENCE = /(?:process|import\.meta)\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
16636
+ function ignored(key) {
16637
+ return IGNORED_KEYS.has(key) || key.startsWith("npm_");
16638
+ }
16639
+ function collect(dir, out) {
16640
+ if (out.length >= MAX_FILES) return;
16641
+ let entries;
16642
+ try {
16643
+ entries = readdirSync(dir, { withFileTypes: true });
16644
+ } catch {
16645
+ return;
16646
+ }
16647
+ for (const entry of entries) {
16648
+ if (out.length >= MAX_FILES) return;
16649
+ if (entry.isSymbolicLink()) continue;
16650
+ const path = join11(dir, entry.name);
16651
+ if (entry.isDirectory()) {
16652
+ if (entry.name.startsWith(".") || SKIP_DIRECTORIES.has(entry.name))
16653
+ continue;
16654
+ collect(path, out);
16655
+ continue;
16656
+ }
16657
+ if (!entry.isFile()) continue;
16658
+ const dot = entry.name.lastIndexOf(".");
16659
+ if (dot < 0 || !SOURCE_EXTENSIONS.has(entry.name.slice(dot))) continue;
16660
+ out.push(path);
16661
+ }
16662
+ }
16663
+ function scanEnvReferences(dir) {
16664
+ const found = /* @__PURE__ */ new Map();
16665
+ const files = [];
16666
+ collect(dir, files);
16667
+ for (const file2 of files) {
16668
+ let text;
16669
+ try {
16670
+ if (statSync(file2).size > MAX_FILE_BYTES2) continue;
16671
+ text = readFileSync3(file2, "utf8");
16672
+ } catch {
16673
+ continue;
16674
+ }
16675
+ for (const match of text.matchAll(REFERENCE)) {
16676
+ const key = match[1] ?? match[2];
16677
+ if (!key || ignored(key)) continue;
16678
+ found.set(key, "code");
16679
+ }
16680
+ }
16681
+ for (const name of EXAMPLE_FILES) {
16682
+ const path = join11(dir, name);
16683
+ if (!existsSync2(path)) continue;
16684
+ let parsed;
16685
+ try {
16686
+ parsed = parseDotenv(readFileSync3(path, "utf8"));
16687
+ } catch {
16688
+ continue;
16689
+ }
16690
+ for (const key of Object.keys(parsed)) {
16691
+ if (ignored(key) || found.has(key)) continue;
16692
+ found.set(key, "example");
16693
+ }
16694
+ }
16695
+ return [...found].map(([key, source]) => ({ key, source })).sort((a, b) => a.key.localeCompare(b.key));
16696
+ }
16697
+
16698
+ // ../../packages/sdk/src/framework.ts
16699
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
16700
+ import { join as join12 } from "path";
16523
16701
  function readPackageJson2(dir) {
16524
16702
  try {
16525
16703
  return JSON.parse(
16526
- readFileSync3(join11(dir, "package.json"), "utf8")
16704
+ readFileSync4(join12(dir, "package.json"), "utf8")
16527
16705
  );
16528
16706
  } catch {
16529
16707
  return null;
@@ -16534,7 +16712,7 @@ function hasDependency(pkg, name) {
16534
16712
  }
16535
16713
  function hasConfig(dir, base2, evidence) {
16536
16714
  for (const ext of ["js", "mjs", "cjs", "ts", "mts"]) {
16537
- if (existsSync2(join11(dir, `${base2}.${ext}`))) {
16715
+ if (existsSync3(join12(dir, `${base2}.${ext}`))) {
16538
16716
  evidence.push(`${base2}.${ext}`);
16539
16717
  return true;
16540
16718
  }
@@ -16546,8 +16724,8 @@ function detectFramework2(dir) {
16546
16724
  const pkg = readPackageJson2(dir);
16547
16725
  if (hasDependency(pkg, "next") || hasConfig(dir, "next.config", evidence)) {
16548
16726
  if (hasDependency(pkg, "next")) evidence.push("package.json: next");
16549
- if (existsSync2(join11(dir, "app"))) evidence.push("app/");
16550
- if (existsSync2(join11(dir, "pages"))) evidence.push("pages/");
16727
+ if (existsSync3(join12(dir, "app"))) evidence.push("app/");
16728
+ if (existsSync3(join12(dir, "pages"))) evidence.push("pages/");
16551
16729
  return {
16552
16730
  framework: "nextjs",
16553
16731
  name: "Next.js",
@@ -16601,7 +16779,7 @@ function detectFramework2(dir) {
16601
16779
  deployable: true
16602
16780
  };
16603
16781
  }
16604
- if (!pkg && existsSync2(join11(dir, "index.html"))) {
16782
+ if (!pkg && existsSync3(join12(dir, "index.html"))) {
16605
16783
  evidence.push("index.html (no package.json)");
16606
16784
  return {
16607
16785
  framework: "static",
@@ -16624,28 +16802,29 @@ function detectFramework2(dir) {
16624
16802
 
16625
16803
  // ../../packages/sdk/src/link.ts
16626
16804
  import {
16627
- existsSync as existsSync3,
16805
+ existsSync as existsSync4,
16628
16806
  mkdirSync as mkdirSync2,
16629
- readFileSync as readFileSync4,
16807
+ readFileSync as readFileSync5,
16630
16808
  rmSync as rmSync2,
16631
16809
  writeFileSync as writeFileSync2
16632
16810
  } from "fs";
16633
- import { join as join12 } from "path";
16811
+ import { join as join13 } from "path";
16634
16812
  function linkDir(dir) {
16635
- return join12(dir, ".naro");
16813
+ return join13(dir, ".naro");
16636
16814
  }
16637
16815
  function linkFile(dir) {
16638
- return join12(linkDir(dir), "project.json");
16816
+ return join13(linkDir(dir), "project.json");
16639
16817
  }
16640
16818
  function readProjectLink(dir) {
16641
16819
  try {
16642
- const parsed = JSON.parse(readFileSync4(linkFile(dir), "utf8"));
16820
+ const parsed = JSON.parse(readFileSync5(linkFile(dir), "utf8"));
16643
16821
  if (typeof parsed.projectId !== "string" || parsed.projectId.length === 0)
16644
16822
  return null;
16645
16823
  return {
16646
16824
  projectId: parsed.projectId,
16647
16825
  ...typeof parsed.teamSlug === "string" ? { teamSlug: parsed.teamSlug } : {},
16648
- ...typeof parsed.projectName === "string" ? { projectName: parsed.projectName } : {}
16826
+ ...typeof parsed.projectName === "string" ? { projectName: parsed.projectName } : {},
16827
+ ...parsed.autoDeployDeclined === true ? { autoDeployDeclined: true } : {}
16649
16828
  };
16650
16829
  } catch {
16651
16830
  return null;
@@ -16656,20 +16835,20 @@ function writeProjectLink(dir, link) {
16656
16835
  writeFileSync2(linkFile(dir), `${JSON.stringify(link, null, 2)}
16657
16836
  `);
16658
16837
  writeFileSync2(
16659
- join12(linkDir(dir), "README.txt"),
16838
+ join13(linkDir(dir), "README.txt"),
16660
16839
  "This directory links the project to Naro and should not be committed.\n"
16661
16840
  );
16662
16841
  }
16663
16842
  function removeProjectLink(dir) {
16664
- if (!existsSync3(linkDir(dir))) return false;
16843
+ if (!existsSync4(linkDir(dir))) return false;
16665
16844
  rmSync2(linkDir(dir), { recursive: true, force: true });
16666
16845
  return true;
16667
16846
  }
16668
16847
  function ensureGitignoreHasNaro(dir) {
16669
- const gitignorePath = join12(dir, ".gitignore");
16670
- const hasGitignore = existsSync3(gitignorePath);
16671
- if (!hasGitignore && !existsSync3(join12(dir, ".git"))) return false;
16672
- const current = hasGitignore ? readFileSync4(gitignorePath, "utf8") : "";
16848
+ const gitignorePath = join13(dir, ".gitignore");
16849
+ const hasGitignore = existsSync4(gitignorePath);
16850
+ if (!hasGitignore && !existsSync4(join13(dir, ".git"))) return false;
16851
+ const current = hasGitignore ? readFileSync5(gitignorePath, "utf8") : "";
16673
16852
  const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => line === ".naro" || line === ".naro/" || line === "/.naro");
16674
16853
  if (covered) return false;
16675
16854
  const suffix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
@@ -16678,6 +16857,67 @@ function ensureGitignoreHasNaro(dir) {
16678
16857
  return true;
16679
16858
  }
16680
16859
 
16860
+ // src/env-consent.ts
16861
+ import { existsSync as existsSync5 } from "fs";
16862
+ import { join as join14 } from "path";
16863
+ var DOTENV_FILES = [".env.local", ".env.production", ".env"];
16864
+ var PUBLIC_PREFIXES = ["NEXT_PUBLIC_", "VITE_", "REACT_APP_", "PUBLIC_"];
16865
+ function maskEnvValue(key, value) {
16866
+ if (PUBLIC_PREFIXES.some((prefix) => key.startsWith(prefix)))
16867
+ return value.length > 60 ? `${value.slice(0, 60)}\u2026` : value;
16868
+ return value.length <= 8 ? "****" : `${value.slice(0, 4)}****`;
16869
+ }
16870
+ function findDotenvFile(dir) {
16871
+ for (const name of DOTENV_FILES) if (existsSync5(join14(dir, name))) return name;
16872
+ return null;
16873
+ }
16874
+ function envAddGuidance(keys) {
16875
+ return [
16876
+ " Set them yourself:",
16877
+ ...keys.map((key) => ` npx naro-cli env add ${key}`),
16878
+ " (values come from a hidden prompt or stdin, never from argv)"
16879
+ ];
16880
+ }
16881
+ async function promptForMissingEnv(ctx, keys) {
16882
+ const guide = (remaining2) => {
16883
+ for (const line of envAddGuidance(remaining2)) ctx.output.log(line);
16884
+ };
16885
+ const file2 = findDotenvFile(ctx.cwd);
16886
+ if (!file2) {
16887
+ guide(keys);
16888
+ return {};
16889
+ }
16890
+ const consented = await ctx.prompter.confirm(
16891
+ `Found ${file2}. Read it to fill these ${String(keys.length)} value(s)?`
16892
+ );
16893
+ if (!consented) {
16894
+ ctx.output.log(`\u25B8 skipped reading ${file2}`);
16895
+ guide(keys);
16896
+ return {};
16897
+ }
16898
+ const local = (ctx.readEnv ?? readProjectEnv)(ctx.cwd);
16899
+ const collected = {};
16900
+ for (const key of keys) {
16901
+ const value = local[key];
16902
+ if (value !== void 0 && value !== "") collected[key] = value;
16903
+ }
16904
+ const names = Object.keys(collected);
16905
+ if (names.length === 0) {
16906
+ ctx.output.log(`\u25B8 no matching values in ${file2}`);
16907
+ guide(keys);
16908
+ return {};
16909
+ }
16910
+ const width = Math.max(...names.map((name) => name.length));
16911
+ for (const key of names)
16912
+ ctx.output.log(
16913
+ ` ${key.padEnd(width)} ${maskEnvValue(key, collected[key] ?? "")}`
16914
+ );
16915
+ ctx.output.log(`\u25B8 adding ${String(names.length)} env var(s)`);
16916
+ const remaining = keys.filter((key) => !(key in collected));
16917
+ if (remaining.length > 0) guide(remaining);
16918
+ return collected;
16919
+ }
16920
+
16681
16921
  // src/git-info.ts
16682
16922
  import { execFile } from "child_process";
16683
16923
  var SUPPORTED_REMOTE = [
@@ -16857,7 +17097,7 @@ function slugify2(value) {
16857
17097
  async function projectName(sourceDir) {
16858
17098
  try {
16859
17099
  const pkg = JSON.parse(
16860
- await readFile7(join13(sourceDir, "package.json"), "utf8")
17100
+ await readFile7(join15(sourceDir, "package.json"), "utf8")
16861
17101
  );
16862
17102
  if (pkg.name) return pkg.name;
16863
17103
  } catch {
@@ -16868,8 +17108,8 @@ var COPY_SKIP = /(^|[\\/])(node_modules|\.next|\.open-next|\.git|\.wrangler|\.tu
16868
17108
  function resolveBin(name) {
16869
17109
  let dir = dirname(fileURLToPath(import.meta.url));
16870
17110
  for (; ; ) {
16871
- const candidate = join13(dir, "node_modules", ".bin", name);
16872
- if (existsSync4(candidate)) return candidate;
17111
+ const candidate = join15(dir, "node_modules", ".bin", name);
17112
+ if (existsSync6(candidate)) return candidate;
16873
17113
  const parent = dirname(dir);
16874
17114
  if (parent === dir) return name;
16875
17115
  dir = parent;
@@ -16893,7 +17133,7 @@ async function detectPackageManagerLenient(root) {
16893
17133
  }
16894
17134
  function localDependencies(sourceDir) {
16895
17135
  return {
16896
- createWorkspace: () => mkdtemp2(join13(tmpdir2(), "naro-build-")),
17136
+ createWorkspace: () => mkdtemp2(join15(tmpdir2(), "naro-build-")),
16897
17137
  removeWorkspace: (path) => rm2(path, { recursive: true, force: true }),
16898
17138
  async runCommand(spec, observer) {
16899
17139
  if (spec.command === "git" && spec.args[0] === "clone") {
@@ -16955,6 +17195,32 @@ async function deployDirectory(sourceDir, options, log2) {
16955
17195
  else if (git.repositoryUrl) log2(`\u25B8 linking ${git.repositoryUrl}`);
16956
17196
  if (options.env && Object.keys(options.env).length > 0)
16957
17197
  log2(`\u25B8 syncing ${String(Object.keys(options.env).length)} env var(s)`);
17198
+ const scanned = scanEnvReferences(sourceDir).map(
17199
+ (reference) => reference.key
17200
+ );
17201
+ let missing = [];
17202
+ if (scanned.length > 0) {
17203
+ let existing = /* @__PURE__ */ new Set();
17204
+ if (options.projectId) {
17205
+ try {
17206
+ const rows = await client.env.list(options.projectId);
17207
+ existing = new Set(rows.map((row) => row.key));
17208
+ } catch {
17209
+ }
17210
+ }
17211
+ const provided = options.env ?? {};
17212
+ missing = scanned.filter((key) => !existing.has(key) && !(key in provided));
17213
+ }
17214
+ let collected = {};
17215
+ if (missing.length > 0) {
17216
+ log2(
17217
+ `\u25B8 ${String(missing.length)} env var(s) needed, not set on this project: ${missing.join(", ")}`
17218
+ );
17219
+ if (options.onMissingEnv) collected = await options.onMissingEnv(missing);
17220
+ else for (const line of envAddGuidance(missing)) log2(line);
17221
+ }
17222
+ const missingEnvKeys = missing.filter((key) => !(key in collected));
17223
+ const envToSend = { ...options.env, ...collected };
16958
17224
  log2("\u25B8 starting deployment");
16959
17225
  const ctx = await client.deployments.createCli({
16960
17226
  ...options.projectId ? { projectId: options.projectId } : { name },
@@ -16964,7 +17230,7 @@ async function deployDirectory(sourceDir, options, log2) {
16964
17230
  ...options.rootDirectory != null && {
16965
17231
  rootDirectory: options.rootDirectory
16966
17232
  },
16967
- ...options.env !== void 0 && { env: options.env }
17233
+ ...Object.keys(envToSend).length > 0 && { env: envToSend }
16968
17234
  });
16969
17235
  if (ctx.envSkipped?.length)
16970
17236
  log2(`\u26A0 skipped env: ${ctx.envSkipped.join(", ")}`);
@@ -16984,7 +17250,7 @@ async function deployDirectory(sourceDir, options, log2) {
16984
17250
  repositoryUrl: sourceDir,
16985
17251
  branch: ctx.branch,
16986
17252
  rootDirectory: options.rootDirectory ?? ctx.rootDirectory,
16987
- env: { ...ctx.env, ...options.env ?? {} }
17253
+ env: { ...ctx.env, ...envToSend }
16988
17254
  },
16989
17255
  observer
16990
17256
  );
@@ -17017,7 +17283,8 @@ async function deployDirectory(sourceDir, options, log2) {
17017
17283
  projectId: ctx.projectId,
17018
17284
  status: detail.deployment.status,
17019
17285
  url: url2,
17020
- production
17286
+ production,
17287
+ missingEnvKeys
17021
17288
  };
17022
17289
  }
17023
17290
  async function waitForFinish(client, ctx, log2) {
@@ -17037,6 +17304,7 @@ function deploymentUrl(detail, ctx, production) {
17037
17304
  }
17038
17305
 
17039
17306
  export {
17307
+ parseGitHubRemote,
17040
17308
  NaroError,
17041
17309
  isNaroError,
17042
17310
  toNaroError,
@@ -17052,14 +17320,15 @@ export {
17052
17320
  openBrowser,
17053
17321
  isTerminalStatus,
17054
17322
  NaroClient,
17055
- readProjectEnv,
17056
17323
  serializeDotenv,
17057
17324
  readProjectLink,
17058
17325
  writeProjectLink,
17059
17326
  removeProjectLink,
17060
17327
  ensureGitignoreHasNaro,
17328
+ promptForMissingEnv,
17329
+ detectGitInfo,
17061
17330
  parseDotenv2 as parseDotenv,
17062
- readProjectEnv2,
17331
+ readProjectEnv2 as readProjectEnv,
17063
17332
  eventsObserver,
17064
17333
  slugify2 as slugify,
17065
17334
  deployDirectory
package/dist/deploy.d.ts CHANGED
@@ -35,6 +35,11 @@ export interface DeployOptions {
35
35
  fetch?: typeof fetch;
36
36
  /** Test seam: replaces the local OpenNext build. */
37
37
  executor?: DeployBuildExecutor;
38
+ /**
39
+ * 소스가 참조하지만 프로젝트에 없는 env 키를 받아, 등록할 값을 돌려준다.
40
+ * 대화형 CLI만 전달한다 — MCP·CI는 생략하고 안내만 받는다.
41
+ */
42
+ onMissingEnv?: (keys: string[]) => Promise<Record<string, string>>;
38
43
  }
39
44
  export interface DeployResult {
40
45
  deploymentId: string;
@@ -42,6 +47,8 @@ export interface DeployResult {
42
47
  status: DeploymentStatus;
43
48
  url: string;
44
49
  production: boolean;
50
+ /** 끝까지 채워지지 않은 키. */
51
+ missingEnvKeys: string[];
45
52
  }
46
53
  /**
47
54
  * The full CLI deploy: authenticate, register the deployment with the
package/dist/deploy.js CHANGED
@@ -3,9 +3,9 @@ import {
3
3
  deployDirectory,
4
4
  eventsObserver,
5
5
  parseDotenv,
6
- readProjectEnv2 as readProjectEnv,
6
+ readProjectEnv,
7
7
  slugify
8
- } from "./chunk-B6F7QIW7.js";
8
+ } from "./chunk-YLFLDW2H.js";
9
9
  export {
10
10
  deployDirectory,
11
11
  eventsObserver,
@@ -0,0 +1,18 @@
1
+ import type { Output } from "./output";
2
+ import type { Prompter } from "./prompt";
3
+ export interface EnvConsentContext {
4
+ cwd: string;
5
+ prompter: Prompter;
6
+ output: Output;
7
+ /** 테스트 주입용. 기본값은 SDK의 readProjectEnv. */
8
+ readEnv?: (dir: string) => Record<string, string>;
9
+ }
10
+ export declare function maskEnvValue(key: string, value: string): string;
11
+ /** 동의를 구하기 전에는 존재만 확인한다. 내용은 열지 않는다. */
12
+ export declare function findDotenvFile(dir: string): string | null;
13
+ export declare function envAddGuidance(keys: string[]): string[];
14
+ /**
15
+ * 누락된 env 키를 채운다. dotenv 파일은 사용자가 명시적으로 동의한 뒤에만
16
+ * 열리고, 그때도 요청된 키의 값만 꺼낸다 — 파일의 나머지 내용은 전송되지 않는다.
17
+ */
18
+ export declare function promptForMissingEnv(ctx: EnvConsentContext, keys: string[]): Promise<Record<string, string>>;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  controlPlaneUrl,
7
7
  createAuthClient,
8
8
  deployDirectory,
9
+ detectGitInfo,
9
10
  ensureGitignoreHasNaro,
10
11
  isExpired,
11
12
  isNaroError,
@@ -13,7 +14,8 @@ import {
13
14
  loadStoredSession,
14
15
  login,
15
16
  openBrowser,
16
- readProjectEnv,
17
+ parseGitHubRemote,
18
+ promptForMissingEnv,
17
19
  readProjectLink,
18
20
  refreshStoredSession,
19
21
  removeProjectLink,
@@ -23,7 +25,7 @@ import {
23
25
  slugify,
24
26
  toNaroError,
25
27
  writeProjectLink
26
- } from "./chunk-B6F7QIW7.js";
28
+ } from "./chunk-YLFLDW2H.js";
27
29
 
28
30
  // src/args.ts
29
31
  var BOOLEAN_FLAGS = {
@@ -154,7 +156,7 @@ var Output = class {
154
156
  success: false,
155
157
  code: naroError.code,
156
158
  message: naroError.message,
157
- ...naroError.details ?? {}
159
+ ...naroError.details
158
160
  },
159
161
  null,
160
162
  2
@@ -411,8 +413,162 @@ async function runToken(context) {
411
413
  );
412
414
  }
413
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
+
414
570
  // src/commands/deploy.ts
415
- async function runDeploy(context) {
571
+ async function runDeploy(context, deps = { deploy: deployDirectory, offerAutoDeploy }) {
416
572
  const { flags, output } = context;
417
573
  const link = flags.project ? { projectId: flags.project } : readProjectLink(context.cwd);
418
574
  if (flags.prod && !flags.yes && !flags.json && isInteractive()) {
@@ -422,12 +578,8 @@ async function runDeploy(context) {
422
578
  output.log(`
423
579
  \u{1F680} Deploying ${context.cwd}
424
580
  `);
425
- const localEnv = link ? {} : readProjectEnv(context.cwd);
426
- if (link)
427
- output.log(
428
- "\u25B8 using project env vars (manage them with `naro env`, sync local files with `naro env pull`)"
429
- );
430
- const result = await deployDirectory(
581
+ const canPrompt = !flags.json && !flags.yes && isInteractive();
582
+ const result = await deps.deploy(
431
583
  context.cwd,
432
584
  {
433
585
  name: flags.name,
@@ -435,10 +587,12 @@ async function runDeploy(context) {
435
587
  rootDirectory: flags.root ?? null,
436
588
  branch: flags.branch,
437
589
  production: flags.prod,
438
- // Local dotenv files seed the project ONCE (first, unlinked deploy).
439
- // After that the platform owns env — a stray .env.local must not
440
- // silently overwrite values set via the dashboard or `naro env add`.
441
- env: localEnv,
590
+ ...canPrompt && {
591
+ onMissingEnv: (keys) => promptForMissingEnv(
592
+ { cwd: context.cwd, prompter: context.prompter, output },
593
+ keys
594
+ )
595
+ },
442
596
  token: flags.token,
443
597
  allowLogin: !flags.json && !flags.yes
444
598
  },
@@ -450,13 +604,15 @@ async function runDeploy(context) {
450
604
  output.log("\u25B8 added .naro to .gitignore");
451
605
  output.log("\u25B8 linked (.naro/project.json)");
452
606
  }
607
+ await deps.offerAutoDeploy?.(context, result.projectId, { canPrompt });
453
608
  context.output.result(
454
609
  {
455
610
  deploymentId: result.deploymentId,
456
611
  projectId: result.projectId,
457
612
  status: result.status,
458
613
  url: result.url,
459
- target: result.production ? "production" : "preview"
614
+ target: result.production ? "production" : "preview",
615
+ missingEnvKeys: result.missingEnvKeys
460
616
  },
461
617
  [
462
618
  "",
@@ -1190,6 +1346,15 @@ function route(parsed) {
1190
1346
  return { handler: runPromote, positionals };
1191
1347
  case "rollback":
1192
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
+ }
1193
1358
  default:
1194
1359
  return null;
1195
1360
  }
@@ -0,0 +1,29 @@
1
+ export interface OutputStreams {
2
+ stdout(text: string): void;
3
+ stderr(text: string): void;
4
+ }
5
+ /**
6
+ * One Output per invocation. Human mode: progress + results on stdout.
7
+ * JSON mode (--json): stdout carries exactly one machine-readable JSON
8
+ * object; every human-facing line moves to stderr so agents never have to
9
+ * parse log noise.
10
+ */
11
+ export declare class Output {
12
+ readonly json: boolean;
13
+ readonly debug: boolean;
14
+ private readonly streams;
15
+ private resultPrinted;
16
+ constructor(json: boolean, debug: boolean, streams?: OutputStreams);
17
+ /** Progress/log line for humans. */
18
+ log(message: string): void;
19
+ /** Extra diagnostics, only with --debug. */
20
+ debugLog(message: string): void;
21
+ /**
22
+ * Final command result. JSON mode prints {success:true, ...data}; human
23
+ * mode prints the lines instead.
24
+ */
25
+ result(data: Record<string, unknown>, humanLines?: string[]): void;
26
+ /** Command failure: stable {success:false, code, message} in JSON mode. */
27
+ fail(error: unknown): void;
28
+ get hasResult(): boolean;
29
+ }
@@ -0,0 +1,17 @@
1
+ export interface Prompter {
2
+ /** Free-text question. */
3
+ ask(question: string): Promise<string>;
4
+ /** Question with hidden echo (secrets). */
5
+ askHidden(question: string): Promise<string>;
6
+ /** y/N confirmation, defaults to No. */
7
+ confirm(question: string): Promise<boolean>;
8
+ /** Pick one option by number; returns its index. */
9
+ select(question: string, options: string[]): Promise<number>;
10
+ }
11
+ export declare function isInteractive(): boolean;
12
+ /** Reads all of stdin — the CI path for piped secret values. */
13
+ export declare function readStdin(): Promise<string>;
14
+ export declare function createPrompter(options: {
15
+ json: boolean;
16
+ yes: boolean;
17
+ }): Prompter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -18,26 +18,26 @@
18
18
  "default": "./dist/deploy.js"
19
19
  }
20
20
  },
21
+ "scripts": {
22
+ "build": "tsup && pnpm build:types",
23
+ "build:types": "tsc --project tsconfig.build.json",
24
+ "test": "vitest run",
25
+ "typecheck": "tsc --noEmit",
26
+ "prepublishOnly": "pnpm build && node ../../scripts/check-publish.mjs naro-cli"
27
+ },
21
28
  "dependencies": {
22
29
  "@opennextjs/cloudflare": "1.20.2",
23
30
  "@supabase/supabase-js": "2.112.3",
24
31
  "wrangler": "4.124.0"
25
32
  },
26
33
  "devDependencies": {
34
+ "@next-deploy/build": "workspace:*",
35
+ "@next-deploy/core": "workspace:*",
36
+ "@next-deploy/config": "workspace:*",
27
37
  "tsup": "8.5.0",
28
38
  "tsx": "4.23.12",
29
39
  "typescript": "7.0.2",
30
40
  "vitest": "3.2.7",
31
- "@next-deploy/build": "0.1.0",
32
- "@next-deploy/config": "0.1.0",
33
- "@next-deploy/core": "0.1.0",
34
- "@next-deploy/sdk": "0.1.0"
35
- },
36
- "scripts": {
37
- "build": "tsup && pnpm build:types",
38
- "build:types": "tsc --project tsconfig.build.json",
39
- "test": "vitest run",
40
- "typecheck": "tsc --noEmit",
41
- "lint": "tsc --noEmit"
41
+ "@next-deploy/sdk": "workspace:*"
42
42
  }
43
- }
43
+ }