naro-cli 0.16.0 → 0.17.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
@@ -88,7 +88,8 @@ naro env remove DATABASE_URL
88
88
  naro env pull [.env.local]
89
89
 
90
90
  naro domains list
91
- naro domains add www.example.com Prints the DNS records to create
91
+ naro domains add www.example.com Prints the CNAME to create; naro connects
92
+ it within ~5 minutes of the record appearing
92
93
  naro domains add example.com Apex: also connects www.example.com and
93
94
  redirects the apex to it (--no-www to skip)
94
95
  naro domains verify [host] Re-check DNS now and print what is missing
@@ -190,8 +191,9 @@ naro rollback [deployment-id] Back to the previous production build
190
191
  ```
191
192
 
192
193
  `naro domains list` reads the stored status; `naro domains verify` is the one
193
- that asks Cloudflare and the DNS again, so it is what turns a stuck "pending"
194
- into the reason it is stuck. It exits non-zero while a domain is not live —
194
+ that asks the DNS and Cloudflare again — and registers the domain on the spot
195
+ once its CNAME points at naro — so it is what turns a stuck "pending" into the
196
+ reason it is stuck. It exits non-zero while a domain is not live —
195
197
  with or without `--wait` — so CI can gate on it:
196
198
 
197
199
  ```bash
@@ -203,6 +205,15 @@ An apex (`example.com`) is added as a pair by default because most registrars
203
205
  goes on `www.example.com` and the apex answers with a 308 redirect to it.
204
206
  `--no-www` registers the apex alone.
205
207
 
208
+ A domain another naro project already has is not refused. `naro domains add`
209
+ fails with `DOMAIN_VERIFICATION_REQUIRED`, and the message names a TXT record:
210
+ `_naro-verify.<host>` with the value `naro-verify=<token>`. Create it, then run
211
+ `naro domains verify <host>`. Once the record is visible, the domain moves to
212
+ this project. After that, the CNAME is added as usual. Add the TXT record
213
+ before the CNAME: until the move, the other project still serves the domain.
214
+ The check lasts 7 days. `naro domains list` shows open checks with their
215
+ expiry, and adding the domain again prints the same token.
216
+
206
217
  `naro db dump` asks Cloudflare for the dump and streams it straight from
207
218
  Cloudflare's storage to the file — the bytes never pass through naro. While the
208
219
  export runs, Cloudflare holds every other query to the database, so dump a busy
@@ -793,6 +804,27 @@ An `--update-check` you give replaces the default: without
793
804
 
794
805
  ## Release notes
795
806
 
807
+ ### 0.17.0
808
+
809
+ - `naro domains add` prints one CNAME record and no TXT records. naro
810
+ registers the domain once the CNAME points at it, usually within about five
811
+ minutes. The status line reads `Waiting for DNS — add the CNAME above; naro
812
+ connects it within about five minutes.` `naro domains verify` registers it at
813
+ once.
814
+ - A domain another project holds now gets an ownership check instead of
815
+ `DOMAIN_EXISTS` (see the `naro domains` notes under Commands). `naro domains verify <host>`
816
+ reads the TXT record and moves the domain here. `naro domains list` shows
817
+ open checks under `Ownership checks (n):`. Against a control plane that
818
+ predates ownership checks, list shows none.
819
+ - A control plane without custom domains set up answers domain changes with
820
+ `503 DOMAINS_NOT_CONFIGURED` (`Custom domains are not configured on this
821
+ server.`), and the CLI prints that message. Before, the CLI got a 500.
822
+ - `naro deploy` builds Next.js apps that use npm faster:
823
+ - A second build candidate copies the installed packages instead of
824
+ installing them again.
825
+ - npm's audit and funding notices are off during the build. A project
826
+ `.npmrc` that turns audit on still wins.
827
+
796
828
  ### 0.16.0
797
829
 
798
830
  - `naro storage cp` uploads a file up to the object limit, under 4 GiB. Over
@@ -5,9 +5,9 @@ import {
5
5
 
6
6
  // src/deploy.ts
7
7
  import { existsSync as existsSync7 } from "fs";
8
- import { cp as cp2, mkdtemp as mkdtemp2, readFile as readFile13, rm as rm3 } from "fs/promises";
8
+ import { cp as cp2, mkdtemp as mkdtemp2, readFile as readFile13, rm as rm4 } from "fs/promises";
9
9
  import { tmpdir as tmpdir2 } from "os";
10
- import { basename as basename3, dirname as dirname4, join as join24, resolve as resolve10 } from "path";
10
+ import { basename as basename3, dirname as dirname5, join as join24, resolve as resolve10 } from "path";
11
11
  import { fileURLToPath } from "url";
12
12
 
13
13
  // ../../packages/build/src/artifact-upload.ts
@@ -102,7 +102,7 @@ var ERROR_CODE = {
102
102
  deps: "INSTALL_FAILED",
103
103
  next: "BUILD_FAILED"
104
104
  };
105
- async function computeDepsCacheKey(checkoutDir, packageManager) {
105
+ async function computeDepsCacheKey(checkoutDir, packageManager, extra = []) {
106
106
  for (const name of LOCKFILES) {
107
107
  let content;
108
108
  try {
@@ -110,7 +110,10 @@ async function computeDepsCacheKey(checkoutDir, packageManager) {
110
110
  } catch {
111
111
  continue;
112
112
  }
113
- const hash2 = createHash("sha256").update(name).update("\n").update(content).digest("hex");
113
+ const hasher = createHash("sha256").update(name).update("\n").update(content);
114
+ if (extra.length > 0) hasher.update(`
115
+ ${extra.join("\n")}`);
116
+ const hash2 = hasher.digest("hex");
114
117
  return `${packageManager}-${hash2}`;
115
118
  }
116
119
  return null;
@@ -262,9 +265,9 @@ function hashAsset(data, contentType2) {
262
265
  }
263
266
 
264
267
  // ../../packages/build/src/local-build-executor.ts
265
- import { access as access8, mkdtemp, readFile as readFile12, rm as rm2 } from "fs/promises";
268
+ import { access as access8, mkdtemp, readFile as readFile12, rm as rm3 } from "fs/promises";
266
269
  import { tmpdir } from "os";
267
- import { dirname as dirname3, join as join17, resolve as resolve7 } from "path";
270
+ import { dirname as dirname4, join as join17, resolve as resolve7 } from "path";
268
271
 
269
272
  // ../../packages/core/src/route-patterns.ts
270
273
  var ROUTE_PATTERN = /^\/[A-Za-z0-9\-._~%!$&()*+,;=:@/[\]]*$/;
@@ -15302,7 +15305,11 @@ function cacheEnv(dir) {
15302
15305
  function withSharedCache(spec, dir) {
15303
15306
  return {
15304
15307
  ...spec,
15305
- addArgs: (specs) => [...spec.addArgs(specs), ...spec.cacheArgs(dir)],
15308
+ addArgs: (specs) => [
15309
+ ...spec.addArgs(specs),
15310
+ ...spec.sharedCacheAddArgs ?? [],
15311
+ ...spec.cacheArgs(dir)
15312
+ ],
15306
15313
  addEnv: { ...spec.addEnv, ...cacheEnv(dir) }
15307
15314
  };
15308
15315
  }
@@ -15343,7 +15350,24 @@ async function detectPackageManager(root) {
15343
15350
  install: ["npm", "ci"],
15344
15351
  cacheArgs: (dir) => ["--cache", dir],
15345
15352
  // --no-save keeps package.json and the lockfile untouched.
15346
- addArgs: (specs) => ["npm", "install", "--no-save", ...specs]
15353
+ addArgs: (specs) => ["npm", "install", "--no-save", ...specs],
15354
+ // --prefer-offline takes package metadata from the restored cache instead
15355
+ // of asking the registry again for each of the toolchain's ~160 packages
15356
+ // (npm keeps metadata in the same cache, which the deps cache saves).
15357
+ // Measured on one vCPU like the build container: 8.8 s → 7.9 s locally,
15358
+ // and the live add took 17 s against 8.8 s here — the gap is registry
15359
+ // round trips.
15360
+ //
15361
+ // npm trusts cached metadata outright: a cached packument that lacks the
15362
+ // asked-for version fails with ETARGET instead of being fetched again
15363
+ // (pacote leaves that revalidation as a TODO). That cannot happen in the
15364
+ // build's own cache because its key carries the toolchain
15365
+ // (computeDepsCacheKey): a toolchain bump starts from an empty cache, and
15366
+ // the cache restored under a key was filled by this same add over the
15367
+ // same lockfile. A machine's default cache has no such key, so without
15368
+ // the build cache (no zstd, no transport — mostly the CLI) the add stays
15369
+ // online (PR #134 review).
15370
+ sharedCacheAddArgs: ["--prefer-offline"]
15347
15371
  };
15348
15372
  }
15349
15373
  if (await exists(join2(root, "yarn.lock"))) {
@@ -15691,14 +15715,18 @@ async function pathExists(path) {
15691
15715
  return false;
15692
15716
  }
15693
15717
  }
15718
+ var BUILD_NPMRC = [
15719
+ "# Host package-manager credentials are intentionally unavailable.",
15720
+ "audit=false",
15721
+ "fund=false",
15722
+ "update-notifier=false",
15723
+ ""
15724
+ ].join("\n");
15694
15725
  async function createPackageManagerConfig(appRoot, writer = async (path, contents) => {
15695
15726
  await writeFile3(path, contents, "utf8");
15696
15727
  }) {
15697
15728
  const packageManagerConfigPath = join5(appRoot, ".next-deploy-user.npmrc");
15698
- await writer(
15699
- packageManagerConfigPath,
15700
- "# Host package-manager credentials are intentionally unavailable.\n"
15701
- );
15729
+ await writer(packageManagerConfigPath, BUILD_NPMRC);
15702
15730
  return packageManagerConfigPath;
15703
15731
  }
15704
15732
  async function createPlatformBuildConfig(appRoot, deploymentId, writer = async (path, contents) => {
@@ -17964,8 +17992,11 @@ function createVinextCloudflareStrategy(options) {
17964
17992
  }
17965
17993
 
17966
17994
  // ../../packages/build/src/strategy/workspace.ts
17967
- import { cp } from "fs/promises";
17968
- import { basename as basename2, join as join16, relative as relative5, resolve as resolve6 } from "path";
17995
+ import { execFile } from "child_process";
17996
+ import { cp, mkdir as mkdir2, readdir as readdir4, rm as rm2 } from "fs/promises";
17997
+ import { basename as basename2, dirname as dirname3, join as join16, relative as relative5, resolve as resolve6 } from "path";
17998
+ import { promisify } from "util";
17999
+ var execFileAsync = promisify(execFile);
17969
18000
  var SKIPPED_ANYWHERE = /* @__PURE__ */ new Set(["node_modules"]);
17970
18001
  function shouldCopy(path) {
17971
18002
  const name = basename2(path);
@@ -17980,6 +18011,33 @@ async function copyCheckout(source, target2) {
17980
18011
  filter: (path) => resolve6(path) === origin || shouldCopy(path)
17981
18012
  });
17982
18013
  }
18014
+ async function copyInstalledPackages(source, target2) {
18015
+ const origin = resolve6(source);
18016
+ const installs = [];
18017
+ const walk2 = async (dir) => {
18018
+ for (const entry of await readdir4(dir, { withFileTypes: true })) {
18019
+ if (!entry.isDirectory() || entry.name === ".git") continue;
18020
+ const path = join16(dir, entry.name);
18021
+ if (entry.name === "node_modules") installs.push(path);
18022
+ else await walk2(path);
18023
+ }
18024
+ };
18025
+ await walk2(origin);
18026
+ for (const install of installs)
18027
+ await copyTree(install, join16(target2, relative5(origin, install)));
18028
+ }
18029
+ async function copyTree(from, to) {
18030
+ await mkdir2(dirname3(to), { recursive: true });
18031
+ if (process.platform !== "win32") {
18032
+ try {
18033
+ await execFileAsync("cp", ["-a", from, to]);
18034
+ return;
18035
+ } catch {
18036
+ await rm2(to, { recursive: true, force: true });
18037
+ }
18038
+ }
18039
+ await cp(from, to, { recursive: true, verbatimSymlinks: true });
18040
+ }
17983
18041
  function forkedAppRoot(checkout, appRoot, forkCheckout) {
17984
18042
  return join16(forkCheckout, relative5(checkout, appRoot));
17985
18043
  }
@@ -18004,7 +18062,7 @@ async function withRouteList(artifact, list, appRoot, observer) {
18004
18062
  }
18005
18063
  var defaultLocalBuildDependencies = {
18006
18064
  createWorkspace: () => mkdtemp(join17(tmpdir(), "next-deploy-build-")),
18007
- removeWorkspace: (path) => rm2(path, { recursive: true, force: true }),
18065
+ removeWorkspace: (path) => rm3(path, { recursive: true, force: true }),
18008
18066
  runCommand,
18009
18067
  detectPackageManager,
18010
18068
  resolveAppRoot,
@@ -18024,6 +18082,7 @@ var defaultLocalBuildDependencies = {
18024
18082
  readVinextArtifact,
18025
18083
  vinextCommand: "vinext",
18026
18084
  forkWorkspace: copyCheckout,
18085
+ copyInstalledPackages,
18027
18086
  collectProjectFacts,
18028
18087
  listNextRoutes,
18029
18088
  hashFingerprintInputs: hashStrategyInputs,
@@ -18045,7 +18104,7 @@ async function readInstalledNextVersion(appRoot, checkout) {
18045
18104
  } catch {
18046
18105
  }
18047
18106
  if (dir === stop) return null;
18048
- const parent = dirname3(dir);
18107
+ const parent = dirname4(dir);
18049
18108
  if (parent === dir) return null;
18050
18109
  dir = parent;
18051
18110
  }
@@ -18263,7 +18322,11 @@ var LocalBuildExecutor = class {
18263
18322
  let depsKey = null;
18264
18323
  let depsRestored = false;
18265
18324
  if (cachePorts) {
18266
- depsKey = await computeDepsCacheKey(checkout, packageManager.name);
18325
+ depsKey = await computeDepsCacheKey(
18326
+ checkout,
18327
+ packageManager.name,
18328
+ isNext ? VINEXT_TOOLCHAIN : []
18329
+ );
18267
18330
  if (depsKey) {
18268
18331
  depsRestored = await tryRestore(
18269
18332
  "deps",
@@ -18348,6 +18411,7 @@ var LocalBuildExecutor = class {
18348
18411
  const fork = this.dependencies.forkWorkspace ?? defaultFork;
18349
18412
  await fork(checkout, pristine);
18350
18413
  let forks = 0;
18414
+ const resolverConcurrency = this.dependencies.resolverConcurrency ?? 1;
18351
18415
  const forkWorkspace = async (label2) => {
18352
18416
  const target2 = join17(
18353
18417
  workspace,
@@ -18355,7 +18419,9 @@ var LocalBuildExecutor = class {
18355
18419
  `${String(forks++)}-${label2.replace(/[^a-z0-9-]/gi, "-")}`
18356
18420
  );
18357
18421
  await fork(pristine, target2);
18358
- await install(target2);
18422
+ if (packageManager.name === "npm" && resolverConcurrency === 1)
18423
+ await (this.dependencies.copyInstalledPackages ?? copyInstalledPackages)(checkout, target2);
18424
+ else await install(target2);
18359
18425
  return {
18360
18426
  checkout: target2,
18361
18427
  appRoot: forkedAppRoot(checkout, appRoot, target2)
@@ -18398,7 +18464,7 @@ var LocalBuildExecutor = class {
18398
18464
  {
18399
18465
  pinned: input.strategy ?? null,
18400
18466
  fingerprint: fingerprint.hash,
18401
- concurrency: this.dependencies.resolverConcurrency ?? 1,
18467
+ concurrency: resolverConcurrency,
18402
18468
  ...this.dependencies.now ? { now: this.dependencies.now } : {}
18403
18469
  }
18404
18470
  );
@@ -19533,6 +19599,31 @@ var DomainsApi = class {
19533
19599
  const match = await this.findByHostname(projectId, hostname3);
19534
19600
  return this.verify(projectId, match.id, options);
19535
19601
  }
19602
+ /**
19603
+ * Ownership checks the project opened for domains another project holds
19604
+ * (known-issues #156). Adding such a domain fails with
19605
+ * DOMAIN_VERIFICATION_REQUIRED and its check in `details.claim`.
19606
+ */
19607
+ async listClaims(projectId) {
19608
+ const body = await this.client.request(
19609
+ `/api/projects/${encodeURIComponent(projectId)}/domain-claims`,
19610
+ { context: "List ownership checks" }
19611
+ );
19612
+ return body.claims;
19613
+ }
19614
+ /** Reads the TXT record; when it matches, the domain moves to this project. */
19615
+ async verifyClaim(projectId, claimId) {
19616
+ return this.client.request(
19617
+ `/api/projects/${encodeURIComponent(projectId)}/domain-claims/${encodeURIComponent(claimId)}/verify`,
19618
+ { method: "POST", body: {}, context: "Verify domain ownership" }
19619
+ );
19620
+ }
19621
+ async removeClaim(projectId, claimId) {
19622
+ await this.client.request(
19623
+ `/api/projects/${encodeURIComponent(projectId)}/domain-claims/${encodeURIComponent(claimId)}`,
19624
+ { method: "DELETE", context: "Remove ownership check" }
19625
+ );
19626
+ }
19536
19627
  async removeById(projectId, domainId) {
19537
19628
  await this.client.request(
19538
19629
  `/api/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domainId)}`,
@@ -20002,6 +20093,14 @@ function bucketFields(fields, into = {}) {
20002
20093
  if (fields[key] !== void 0) into[key] = fields[key];
20003
20094
  return into;
20004
20095
  }
20096
+ function listFields(options) {
20097
+ const body = {};
20098
+ if (options.prefix !== void 0) body.prefix = options.prefix;
20099
+ if (options.limit !== void 0) body.limit = options.limit;
20100
+ if (options.offset !== void 0) body.offset = options.offset;
20101
+ if (options.sortBy !== void 0) body.sortBy = options.sortBy;
20102
+ return body;
20103
+ }
20005
20104
  var BackendApi = class {
20006
20105
  constructor(client) {
20007
20106
  this.client = client;
@@ -20526,6 +20625,60 @@ var StorageApi = class {
20526
20625
  );
20527
20626
  return { deleted: body.deleted, done: body.done };
20528
20627
  }
20628
+ /**
20629
+ * What is directly below a folder of a bucket — its folders first, by
20630
+ * name, then its files — through the control plane's object proxy
20631
+ * (Storage 3-1e): the project's Naro worker lists it with the stored
20632
+ * service key, so every object is listed whatever the policies say. There
20633
+ * is no search. Needs `storage.manage`, Storage on and the backend on and
20634
+ * ready (`STORAGE_NOT_ENABLED`, `BACKEND_OFF`, `BACKEND_NOT_READY`, 409);
20635
+ * the worker's refusals — `BUCKET_NOT_FOUND`, `INVALID_REQUEST` with
20636
+ * `details.path` and `details.reason` — come through as they are.
20637
+ */
20638
+ async listObjects(projectId, bucket, options = {}) {
20639
+ const body = await this.client.request(
20640
+ this.bucketPath(projectId, bucket, "/objects/list"),
20641
+ { method: "POST", body: listFields(options), context: "List objects" }
20642
+ );
20643
+ return body.objects;
20644
+ }
20645
+ /**
20646
+ * A link that downloads one object without a key, for `expiresIn` seconds
20647
+ * (1–3600 through the control plane; `VALIDATION_FAILED` past it), on the
20648
+ * project's URL. Replacing or deleting the object ends it. The path is
20649
+ * held to the object name rules (`INVALID_PATH`); one not there is the
20650
+ * worker's `OBJECT_NOT_FOUND`.
20651
+ */
20652
+ async signObject(projectId, bucket, path, options) {
20653
+ const body = await this.client.request(
20654
+ this.bucketPath(projectId, bucket, "/objects/sign"),
20655
+ {
20656
+ method: "POST",
20657
+ body: {
20658
+ path,
20659
+ expiresIn: options.expiresIn,
20660
+ // false is no download (supabase-js): left out, since the worker
20661
+ // answers it 400 INVALID_REQUEST — it is not a file name.
20662
+ ...options.download === void 0 || options.download === false ? {} : { download: options.download }
20663
+ },
20664
+ context: "Sign an object URL"
20665
+ }
20666
+ );
20667
+ return { signedUrl: body.signedUrl };
20668
+ }
20669
+ /**
20670
+ * Deletes up to 1,000 objects by their full paths and answers the ones it
20671
+ * deleted, in the order asked: a path not there, or being written, is left
20672
+ * out — not an error (supabase-js's `remove`). A folder is not deleted by
20673
+ * its name; list it and delete what is in it.
20674
+ */
20675
+ async removeObjects(projectId, bucket, prefixes) {
20676
+ const body = await this.client.request(
20677
+ this.bucketPath(projectId, bucket, "/objects"),
20678
+ { method: "DELETE", body: { prefixes }, context: "Remove objects" }
20679
+ );
20680
+ return body.objects;
20681
+ }
20529
20682
  };
20530
20683
 
20531
20684
  // ../../packages/sdk/src/database.ts
@@ -28135,14 +28288,14 @@ async function promptForMissingEnv(ctx, keys) {
28135
28288
  }
28136
28289
 
28137
28290
  // src/git-info.ts
28138
- import { execFile } from "child_process";
28291
+ import { execFile as execFile2 } from "child_process";
28139
28292
  var SUPPORTED_REMOTE = [
28140
28293
  /^https:\/\/[^\s]+$/,
28141
28294
  /^ssh:\/\/[^\s]+$/,
28142
28295
  /^git@[A-Za-z0-9.-]+:[A-Za-z0-9._/-]+(\.git)?$/
28143
28296
  ];
28144
28297
  var defaultExec = (args, cwd) => new Promise((resolve11, reject) => {
28145
- execFile("git", args, { cwd }, (error51, stdout) => {
28298
+ execFile2("git", args, { cwd }, (error51, stdout) => {
28146
28299
  if (error51) reject(error51);
28147
28300
  else resolve11(stdout);
28148
28301
  });
@@ -28385,11 +28538,11 @@ async function projectName(sourceDir) {
28385
28538
  }
28386
28539
  var COPY_SKIP = /(^|[\\/])(node_modules|\.next|\.open-next|\.git|\.wrangler|\.turbo|\.naro)([\\/]|$)/;
28387
28540
  function resolveBin(name) {
28388
- let dir = dirname4(fileURLToPath(import.meta.url));
28541
+ let dir = dirname5(fileURLToPath(import.meta.url));
28389
28542
  for (; ; ) {
28390
28543
  const candidate = join24(dir, "node_modules", ".bin", name);
28391
28544
  if (existsSync7(candidate)) return candidate;
28392
- const parent = dirname4(dir);
28545
+ const parent = dirname5(dir);
28393
28546
  if (parent === dir) return name;
28394
28547
  dir = parent;
28395
28548
  }
@@ -28415,7 +28568,7 @@ async function detectPackageManagerLenient(root) {
28415
28568
  function localDependencies(sourceDir) {
28416
28569
  return {
28417
28570
  createWorkspace: () => mkdtemp2(join24(tmpdir2(), "naro-build-")),
28418
- removeWorkspace: (path) => rm3(path, { recursive: true, force: true }),
28571
+ removeWorkspace: (path) => rm4(path, { recursive: true, force: true }),
28419
28572
  async runCommand(spec, observer) {
28420
28573
  if (spec.command === "git" && spec.args[0] === "clone") {
28421
28574
  const checkout = spec.args.at(-1);
package/dist/deploy.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseDotenv,
6
6
  readProjectEnv,
7
7
  slugify
8
- } from "./chunk-XNT7SINN.js";
8
+ } from "./chunk-PYRN5XR6.js";
9
9
  import "./chunk-K3NQKI34.js";
10
10
  export {
11
11
  deployDirectory,
package/dist/index.js CHANGED
@@ -82,7 +82,7 @@ import {
82
82
  toNaroError,
83
83
  unreadablePolicyComment,
84
84
  writeProjectLink
85
- } from "./chunk-XNT7SINN.js";
85
+ } from "./chunk-PYRN5XR6.js";
86
86
  import "./chunk-K3NQKI34.js";
87
87
 
88
88
  // src/args.ts
@@ -4560,21 +4560,69 @@ function domainLines(domain) {
4560
4560
  }
4561
4561
  return lines;
4562
4562
  }
4563
+ function claimLines(claim) {
4564
+ return [
4565
+ ` \u23F3 ${claim.hostname} \u2014 another project holds it. Publish this TXT record, then run \`naro domains verify ${claim.hostname}\`:`,
4566
+ ` TXT ${claim.record.name}`,
4567
+ ` host: ${claim.record.host}`,
4568
+ ` value: ${claim.record.value}`
4569
+ ];
4570
+ }
4571
+ async function openClaims(client, projectId) {
4572
+ try {
4573
+ return await client.domains.listClaims(projectId);
4574
+ } catch (error) {
4575
+ if (isNaroError(error) && error.code === "NOT_FOUND") return [];
4576
+ throw error;
4577
+ }
4578
+ }
4563
4579
  async function runDomainsList(context) {
4564
4580
  const client = await context.client();
4565
4581
  const projectId = await context.resolveProjectId();
4566
4582
  const domains = await client.domains.list(projectId);
4583
+ const claims = await openClaims(client, projectId);
4567
4584
  const pending = domains.filter((domain) => !isReady(domain));
4568
4585
  context.output.result(
4569
- { projectId, domains },
4570
- domains.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
4571
- `Domains (${String(domains.length)}):`,
4572
- ...domains.flatMap(domainLines),
4586
+ { projectId, domains, claims },
4587
+ domains.length === 0 && claims.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
4588
+ ...domains.length > 0 ? [
4589
+ `Domains (${String(domains.length)}):`,
4590
+ ...domains.flatMap(domainLines)
4591
+ ] : [],
4592
+ ...claims.length > 0 ? [
4593
+ ...domains.length > 0 ? [""] : [],
4594
+ `Ownership checks (${String(claims.length)}):`,
4595
+ ...claims.flatMap(claimLines)
4596
+ ] : [],
4573
4597
  // list reads our stored copy; only verify goes and looks again.
4574
4598
  ...pending.length > 0 ? ["", "Re-check DNS now: naro domains verify"] : []
4575
4599
  ]
4576
4600
  );
4577
4601
  }
4602
+ async function verifyClaimFor(context, client, projectId, hostname) {
4603
+ const wanted = hostname.toLowerCase().replace(/\.+$/, "");
4604
+ const claim = (await openClaims(client, projectId)).find(
4605
+ (candidate) => candidate.hostname === wanted
4606
+ );
4607
+ if (!claim) return false;
4608
+ const result = await client.domains.verifyClaim(projectId, claim.id);
4609
+ if (!result.verified) {
4610
+ const cause = result.claim.cause;
4611
+ throw new NaroError(
4612
+ cause?.code ?? "DOMAIN_VERIFICATION_REQUIRED",
4613
+ cause ? `${cause.message}${cause.fix ? ` ${cause.fix}` : ""}` : `${wanted} is not verified yet.`,
4614
+ { details: { claim: result.claim } }
4615
+ );
4616
+ }
4617
+ context.output.result({ projectId, verified: true, domain: result.domain }, [
4618
+ `\u2705 Ownership of ${result.domain.hostname} verified \u2014 it now belongs to this project.`,
4619
+ "",
4620
+ ...domainLines(result.domain),
4621
+ "",
4622
+ "Status: Waiting for DNS \u2014 add the CNAME above; naro connects it within about five minutes. Check with `naro domains verify`."
4623
+ ]);
4624
+ return true;
4625
+ }
4578
4626
  async function runDomainsAdd(context) {
4579
4627
  const [hostname] = context.positionalOrThrow(
4580
4628
  1,
@@ -4608,7 +4656,7 @@ async function runDomainsAdd(context) {
4608
4656
  ` refuses the record above, run \`naro domains add ${alternative}\`.`
4609
4657
  ] : [],
4610
4658
  "",
4611
- isReady(domain) ? "The domain is active." : "Status: Pending verification \u2014 add the DNS records above, then run `naro domains verify`."
4659
+ isReady(domain) ? "The domain is active." : "Status: Waiting for DNS \u2014 add the CNAME above; naro connects it within about five minutes. Check with `naro domains verify`."
4612
4660
  ]
4613
4661
  );
4614
4662
  }
@@ -4632,7 +4680,14 @@ async function runDomainsVerify(context, overrides = {}) {
4632
4680
  const progress = (line) => {
4633
4681
  if (!context.flags.json) context.output.log(line);
4634
4682
  };
4635
- let domains = await verifyAll(client, projectId, hostname);
4683
+ let domains;
4684
+ try {
4685
+ domains = await verifyAll(client, projectId, hostname);
4686
+ } catch (error) {
4687
+ if (hostname !== void 0 && isNaroError(error) && error.code === "DOMAIN_NOT_FOUND" && await verifyClaimFor(context, client, projectId, hostname))
4688
+ return;
4689
+ throw error;
4690
+ }
4636
4691
  if (domains.length === 0) {
4637
4692
  context.output.result({ projectId, domains, ready: false }, [
4638
4693
  "No custom domains. Add one with `naro domains add example.com`."
@@ -4691,7 +4746,7 @@ async function runDomainsVerify(context, overrides = {}) {
4691
4746
  const cause = notReady[0]?.cause;
4692
4747
  throw new NaroError(
4693
4748
  cause?.code ?? "DOMAIN_NOT_READY",
4694
- `${names} is not live yet.${cause ? ` ${cause.message}` : ""} Add the records above, then run \`naro domains verify --wait\`.`,
4749
+ `${names} is not live yet.${cause ? ` ${cause.message}` : ""} naro connects it within about five minutes of the CNAME appearing; \`naro domains verify --wait\` waits for it.`,
4695
4750
  { details }
4696
4751
  );
4697
4752
  }
@@ -5674,7 +5729,10 @@ async function call(config, options) {
5674
5729
  }
5675
5730
  }
5676
5731
  const token = options.withToken ? await config.tokens.get() : null;
5677
- const headers = { apikey: config.apiKey };
5732
+ const headers = {
5733
+ ...options.headers,
5734
+ apikey: config.apiKey
5735
+ };
5678
5736
  if (token !== null) headers.Authorization = `Bearer ${token}`;
5679
5737
  const init = {
5680
5738
  method: options.method,
@@ -7363,6 +7421,7 @@ async function readJson(response, guard) {
7363
7421
  return dataOf2(response.status, body, guard);
7364
7422
  }
7365
7423
  var ROUTE = "/storage/upload/multipart";
7424
+ var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
7366
7425
  var CONCURRENCY = 4;
7367
7426
  var ATTEMPTS = 4;
7368
7427
  var BACKOFF_MS = [1e3, 2e3, 4e3];
@@ -7393,6 +7452,8 @@ var sleepFor = (ms, signal) => new Promise((done) => {
7393
7452
  async function uploadInParts(a) {
7394
7453
  const { signal } = a;
7395
7454
  const sleep2 = a.sleep ?? sleepFor;
7455
+ const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
7456
+ const withToken = a.uploadToken === void 0;
7396
7457
  if (signal?.aborted) return fail(uploadAborted());
7397
7458
  const started = await call(a.http, {
7398
7459
  method: a.method,
@@ -7406,7 +7467,8 @@ async function uploadInParts(a) {
7406
7467
  // PUT always replaces: the worker refuses `upsert` on it.
7407
7468
  ...a.method === "POST" && a.upsert ? { upsert: true } : {}
7408
7469
  },
7409
- withToken: true
7470
+ headers: signed,
7471
+ withToken
7410
7472
  });
7411
7473
  if (started.error !== null) return fail(started.error);
7412
7474
  const plan = dataOf2(
@@ -7426,9 +7488,12 @@ async function uploadInParts(a) {
7426
7488
  }
7427
7489
  };
7428
7490
  const callOff = async () => {
7429
- await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
7430
- () => void 0
7431
- );
7491
+ await call(a.http, {
7492
+ method: "DELETE",
7493
+ path: base,
7494
+ headers: signed,
7495
+ withToken
7496
+ }).catch(() => void 0);
7432
7497
  };
7433
7498
  const etags = [];
7434
7499
  const failure = {
@@ -7443,9 +7508,9 @@ async function uploadInParts(a) {
7443
7508
  const sent = await send(a.http, {
7444
7509
  method: "PUT",
7445
7510
  path: `${base}/${String(n)}`,
7446
- headers: { "content-type": "application/octet-stream" },
7511
+ headers: { "content-type": "application/octet-stream", ...signed },
7447
7512
  body: bytes,
7448
- withToken: true,
7513
+ withToken,
7449
7514
  signal
7450
7515
  });
7451
7516
  if (sent.error !== null) return sent;
@@ -7495,7 +7560,8 @@ async function uploadInParts(a) {
7495
7560
  body: {
7496
7561
  parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
7497
7562
  },
7498
- withToken: true
7563
+ headers: signed,
7564
+ withToken
7499
7565
  });
7500
7566
  if (completed.error !== null) {
7501
7567
  if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
@@ -7509,7 +7575,8 @@ var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
7509
7575
  var STORAGE_MESSAGES = {
7510
7576
  bucket: "The bucket name is not valid.",
7511
7577
  request: "The storage request is not valid.",
7512
- body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
7578
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string.",
7579
+ signature: "The signed URL is not valid."
7513
7580
  };
7514
7581
  var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
7515
7582
  "authenticated",
@@ -7658,9 +7725,22 @@ function payloadOf(body, contentType) {
7658
7725
  };
7659
7726
  }
7660
7727
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
7728
+ var SIGNED_UPLOAD_PREFIX = "/_naro/v1/storage/object/upload/sign/";
7729
+ var TOKEN_TEXT = /^[\x21-\x7e]+$/;
7730
+ var TOKEN_PARAMETER = /([?&]token=)[^&#\s"'<>]*/g;
7731
+ function withoutToken(error, token) {
7732
+ const { details } = error;
7733
+ if (!isRecord(details) || typeof details.cause !== "string") return error;
7734
+ let cause = details.cause.replace(TOKEN_PARAMETER, "$1[redacted]");
7735
+ for (const form of [encodeURIComponent(token), token])
7736
+ cause = cause.replaceAll(form, "[redacted]");
7737
+ return { ...error, details: { ...details, cause } };
7738
+ }
7661
7739
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
7662
7740
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
7663
7741
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
7742
+ var isSignedUpload = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_UPLOAD_PREFIX) && typeof value.token === "string" && value.token !== "" && typeof value.path === "string";
7743
+ var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
7664
7744
  function listOf(guard) {
7665
7745
  return (value) => Array.isArray(value) && value.every(guard);
7666
7746
  }
@@ -7708,8 +7788,7 @@ var StorageFileApi = class {
7708
7788
  method,
7709
7789
  bucket: this.#bucket,
7710
7790
  path,
7711
- // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
7712
- body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
7791
+ body: partsBody(payload.body),
7713
7792
  size: payload.size,
7714
7793
  contentType: payload.type,
7715
7794
  cacheControl: cache,
@@ -7837,6 +7916,101 @@ var StorageFileApi = class {
7837
7916
  error: null
7838
7917
  };
7839
7918
  }
7919
+ /**
7920
+ * A token that lets whoever holds it upload `path` for two hours (Storage
7921
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
7922
+ * service key for any path, anon or a signed-in user only where the
7923
+ * policies let them upload. With `upsert: true` it may replace a file
7924
+ * that is there.
7925
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
7926
+ * without naro-js PUTs the bytes.
7927
+ */
7928
+ async createSignedUploadUrl(path, options = {}) {
7929
+ const target = this.#objectPath("upload/sign/", path);
7930
+ if (!target.ok) return fail(target.error);
7931
+ const upsert = (options ?? {}).upsert === true;
7932
+ const signed = await this.#json(
7933
+ {
7934
+ method: "POST",
7935
+ path: target.path,
7936
+ body: upsert ? { upsert: true } : {}
7937
+ },
7938
+ isSignedUpload
7939
+ );
7940
+ if (signed.error !== null) return signed;
7941
+ return {
7942
+ data: {
7943
+ signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}`,
7944
+ token: signed.data.token,
7945
+ path: signed.data.path
7946
+ },
7947
+ error: null
7948
+ };
7949
+ }
7950
+ /**
7951
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
7952
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
7953
+ * permission, so a page's anon-key client uploads where only a server
7954
+ * could sign. It works until the token expires, two hours from when it
7955
+ * was made; one made without `upsert` uploads one new file
7956
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
7957
+ * parts with the token on every step, and once started may finish after
7958
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
7959
+ * are `upload`'s.
7960
+ */
7961
+ async uploadToSignedUrl(path, token, body, options = {}) {
7962
+ const given = options ?? {};
7963
+ const target = this.#objectPath("upload/sign/", path);
7964
+ if (!target.ok) return fail(target.error);
7965
+ if (typeof token !== "string" || !TOKEN_TEXT.test(token))
7966
+ return fail({
7967
+ code: "INVALID_SIGNATURE",
7968
+ message: STORAGE_MESSAGES.signature
7969
+ });
7970
+ const payload = payloadOf(body, given.contentType);
7971
+ if (!payload.ok) return fail(payload.error);
7972
+ const cache = `max-age=${given.cacheControl ?? "3600"}`;
7973
+ const unreadable = headerProblem(given.contentType, cache);
7974
+ if (unreadable !== null) return fail(unreadable);
7975
+ const { signal } = given;
7976
+ if (signal?.aborted) return fail(uploadAborted());
7977
+ const uploaded = payload.size > MAX_SINGLE_UPLOAD_BYTES2 ? await uploadInParts({
7978
+ http: this.#http,
7979
+ method: "POST",
7980
+ bucket: this.#bucket,
7981
+ path,
7982
+ body: partsBody(payload.body),
7983
+ size: payload.size,
7984
+ contentType: payload.type,
7985
+ cacheControl: cache,
7986
+ upsert: false,
7987
+ uploadToken: token,
7988
+ ...given.onProgress ? { onProgress: given.onProgress } : {},
7989
+ signal
7990
+ }) : await this.#putSigned(target.path, token, payload, cache, signal);
7991
+ if (uploaded.error !== null) return fail(uploaded.error);
7992
+ return {
7993
+ data: { path: uploaded.data.path, fullPath: uploaded.data.fullPath },
7994
+ error: null
7995
+ };
7996
+ }
7997
+ /**
7998
+ * The one-request signed upload: the token in the query, no session — it
7999
+ * is the permission. A failure's cause never carries the token.
8000
+ */
8001
+ async #putSigned(path, token, payload, cache, signal) {
8002
+ const sent = await send(this.#http, {
8003
+ method: "PUT",
8004
+ path: `${path}?token=${encodeURIComponent(token)}`,
8005
+ headers: { "content-type": payload.type, "cache-control": cache },
8006
+ body: payload.body,
8007
+ withToken: false,
8008
+ signal
8009
+ });
8010
+ if (sent.error !== null) return fail(withoutToken(sent.error, token));
8011
+ const read = await readJson(sent.response, isUploadResult);
8012
+ return read.error === null ? read : fail(withoutToken(read.error, token));
8013
+ }
7840
8014
  /**
7841
8015
  * A public bucket's URL for the object. No request is made, so whether
7842
8016
  * the bucket is public is not checked (as in supabase-js); a path no URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -33,12 +33,12 @@
33
33
  "tsx": "4.23.12",
34
34
  "typescript": "7.0.2",
35
35
  "vitest": "3.2.7",
36
- "naro-js": "0.4.0",
36
+ "naro-js": "0.5.0",
37
+ "@next-deploy/config": "0.1.0",
37
38
  "@next-deploy/core": "0.1.0",
38
- "@next-deploy/data-core": "0.1.0",
39
- "@next-deploy/sdk": "0.1.0",
40
39
  "@next-deploy/build": "0.1.0",
41
- "@next-deploy/config": "0.1.0"
40
+ "@next-deploy/data-core": "0.1.0",
41
+ "@next-deploy/sdk": "0.1.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsup && pnpm build:types",