naro-cli 0.19.1 → 0.20.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
@@ -198,6 +198,7 @@ naro policy remove todos Drop every policy on the table: back to deny
198
198
 
199
199
  naro storage Naro Storage: on or off, limits, usage, today's operations, buckets
200
200
  naro storage enable Turn it on: the project's R2 bucket, bound to the backend
201
+ naro storage disable [--yes] Turn it off and keep the files: Storage calls and file URLs answer 404 until enable (asks first)
201
202
  naro storage bucket create avatars --public --file-size-limit 5MiB --allowed-mime-types image/png,image/jpeg
202
203
  naro storage bucket update avatars --private | --file-size-limit none | --allowed-mime-types any
203
204
  naro storage bucket update docs --public (asks first: anyone with an object's URL can download it)
@@ -210,7 +211,7 @@ naro storage mv naro://docs/a.pdf naro://docs/old/a.pdf Inside one bucke
210
211
  naro storage rm naro://avatars/u1/old.png [--recursive] (asks first)
211
212
  naro storage rm naro://avatars/u1/ --recursive Everything under u1/; written u1, an object u1 too
212
213
 
213
- naro promote <deployment-id> Publish a build to production
214
+ naro promote <deployment-id> [--to <environment>] Publish a build to production, or to an environment (0.20.0); says rebuild or reupload first, then asks
214
215
  naro rollback [deployment-id] Back to the previous production build
215
216
  ```
216
217
 
@@ -594,8 +595,16 @@ tables: a policy on a table that does not exist
594
595
  (`relation "nope" does not exist`), a name already taken, or a 33rd policy on
595
596
  one table fails the whole request with `400 DATABASE_QUERY_FAILED`, names the
596
597
  statement, and applies nothing — not even the `CREATE TABLE` before it. Policy
597
- statements, and statements that create, drop or rename a table, take no
598
- `params` (`400 VALIDATION_FAILED`): write the values into the SQL.
598
+ statements, statements that create, drop or rename a table, and statements
599
+ that add a generated column take no `params` (`400 VALIDATION_FAILED`): write
600
+ the values into the SQL.
601
+
602
+ `ALTER TABLE t ADD COLUMN c … AS (…)` computes the generated column for the
603
+ rows already in `t` right after it, as Postgres does: a row it cannot be
604
+ computed for (`json_extract` over text that is not JSON) fails the request
605
+ with SQLite's error (`400 DATABASE_QUERY_FAILED` `malformed JSON:
606
+ SQLITE_ERROR`) and nothing changes. Fix those rows first, in an earlier
607
+ statement.
599
608
 
600
609
  Policies go with their table, as in Postgres: `DROP TABLE` drops them, and a
601
610
  new table never inherits the leftovers of an earlier one with the same name
@@ -837,6 +846,110 @@ An `--update-check` you give replaces the default: without
837
846
 
838
847
  ## Release notes
839
848
 
849
+ ### 0.20.0
850
+
851
+ 0.19.1 went out on 2026-10-24 without these.
852
+
853
+ - `naro promote <deployment-id> --to <environment>` publishes a build to a
854
+ named environment. `--to` takes `production` (what no `--to` does) or an
855
+ environment's slug; an empty value, only blanks or `preview` fails before
856
+ any request with `VALIDATION_FAILED`
857
+ `--to must be production or an environment name.` The usage line is
858
+ `Usage: naro promote <deployment-id> [--to <environment>]`.
859
+ - Before it asks, it says what the promotion will do, from the control
860
+ plane's preview (`GET …/promote?target=`, which changes nothing):
861
+ `▸ rebuild: builds the same commit again with qa's variables` or
862
+ `▸ reupload: publishes the stored build`, then each warning as
863
+ `⚠ <warning>`. Without `--to` the same line is printed for production
864
+ (`… with production's variables`); that is one request more than 0.19.1
865
+ sent.
866
+ - A build moving between an environment and another surface (a preview or
867
+ production build to `qa`, a `qa` build to `staging` or to production) is
868
+ built again from its commit with the target's variables, and counts
869
+ against the plan's builds. Any other build is published as stored: one
870
+ promoted within its own surface, and a preview to production.
871
+ - The question is `Promote <id> to qa (<subdomain>--qa)?`; for production it
872
+ is the one it always was, `Promote <id> to production (<subdomain>)?`.
873
+ `--json` or no terminal without `--yes` still fails
874
+ `CONFIRMATION_REQUIRED`.
875
+ - Progress is `▸ promoting <id> to qa (rebuild)` (`(reupload)`), and the
876
+ result `✅ qa now serves a rebuild of <id>` or `✅ qa now serves <id>`; for
877
+ production `✅ Production now serves a rebuild of <id>` or, as before,
878
+ `✅ Production now serves <id>`. A rebuild is waited for up to 15 minutes
879
+ (a published build, 5 as before), and a failed one fails with its own
880
+ code and message. While a rebuild runs its build log lines print the way
881
+ `naro logs --type build --follow` prints them (`[BUILDING] next build`,
882
+ `[BUILDING] ✗ <error>`), on stderr with `--json` so stdout stays one
883
+ object; a published build prints none.
884
+ - `--json` adds `target` (`null` without `--to`) and `mode` (`rebuild` or
885
+ `reupload`): `{ deploymentId, sourceDeploymentId, projectId, target, mode, status, url }`.
886
+ `naro rollback`'s result gains `mode` too (as MCP's `rollback_deployment`
887
+ says it) and has no `target`: a rollback is always production's.
888
+ - `409 PROMOTE_NEEDS_GIT_SOURCE`
889
+ `Only deployments built from Git can be promoted to another environment. Deploy the code again with naro deploy instead.`
890
+ for a deployment `naro deploy` uploaded. This and every other refusal of
891
+ the preview (`404 ENVIRONMENT_NOT_FOUND` `Environment not found.`) stop
892
+ it in the control plane's words before it asks, and nothing is promoted.
893
+ - A rebuild checks out the pinned commit as a detached HEAD, not the
894
+ branch, so a build script that reads the branch name from git
895
+ (`git branch --show-current`, `git rev-parse --abbrev-ref HEAD`) sees
896
+ none.
897
+ - Against a control plane from before named environments 2a, which has no
898
+ preview (it answers 405) and whose promote ignores the target and
899
+ republishes to production, `--to <environment>` stops before it promotes
900
+ anything: `PROMOTE_TARGET_UNSUPPORTED`
901
+ `This control plane cannot promote to an environment yet. Deploy the latest web first, then try again.`
902
+ With no `--to` or `--to production` the 405 is ignored: no preview line,
903
+ and the promotion goes as it always did.
904
+ - `--to` belongs to `naro promote`; `naro rollback` refuses it before any
905
+ request with `VALIDATION_FAILED`
906
+ `--to is for naro promote: naro rollback always rolls production back.`
907
+ (it always targets production, and ignoring `--to` would roll production
908
+ back for someone who named an environment).
909
+ - Any other command given `--to` fails before any request with
910
+ `VALIDATION_FAILED` `--to is only for naro promote.`, so a flag a command
911
+ does not read is never dropped silently.
912
+ - `naro promote` and `naro rollback` refuse `--env` and any word after the
913
+ deployment id before any request, with `VALIDATION_FAILED`
914
+ `--env is not an option of naro promote: use --to <environment>.`,
915
+ `--env is not an option of naro rollback: naro rollback always rolls production back.`
916
+ or the usage line (`Usage: naro promote <deployment-id> [--to <environment>]`,
917
+ `Usage: naro rollback [deployment-id]`), instead of acting on production for
918
+ someone who named an environment (`naro promote d1 --env qa --yes` and
919
+ `naro promote d1 qa --yes` promoted to production in 0.19.1).
920
+ - A control plane with named environments 2a rebuilds a deployment of an
921
+ environment that is promoted to production, which it used to refuse with
922
+ `400 DEPLOYMENT_NOT_PROMOTABLE`. `naro promote <id>` says so before it
923
+ asks (`▸ rebuild: … with production's variables`) and waits up to 15
924
+ minutes; `naro rollback <id>` waits 15 minutes for such a rebuild too and
925
+ prints `✅ Production now serves a rebuild of <id>`. The build that
926
+ `naro rollback` picks on its own is never one.
927
+ - `naro storage disable [--yes]` turns the project's Storage off and keeps
928
+ its files (Storage 3-1k). It asks first —
929
+ `Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.`
930
+ — and without a terminal or with `--json` only `--yes` answers it. The
931
+ backend is uploaded again without the bucket: the app's Storage calls and
932
+ file URLs answer `404 STORAGE_NOT_ENABLED` until `naro storage enable`
933
+ binds the same bucket again, and the buckets, the files and their storage
934
+ cost stay. To delete the files instead, remove the buckets with
935
+ `naro storage bucket remove <name> --empty` while Storage is on. Storage
936
+ that was never turned on prints `Storage is already off.` and changes
937
+ nothing; Storage that is already off is finished without asking again (a
938
+ turn-off whose upload stopped answers `BACKEND_PROVISION_FAILED` and says
939
+ to run it again).
940
+ - `naro storage` says
941
+ `Storage: off since <YYYY-MM-DD HH:MM> UTC — buckets and files are kept`
942
+ while it is off, with the buckets.
943
+ - `naro storage ls`, `cp`, `mv` and `rm` read the Storage state before the
944
+ service key and stop with `STORAGE_OFF`
945
+ ``Storage is turned off for this project. Turn it on with `naro storage enable` first.``
946
+ while it is off. `naro storage bucket create` on Storage that is off gets
947
+ the control plane's `409 STORAGE_OFF`: it turns Storage on by itself only
948
+ when Storage was never on.
949
+ - Published naro-cli 0.19.1 and earlier show Storage that is off as `off`
950
+ and pass the backend's `404 STORAGE_NOT_ENABLED` on from `ls`, `cp`,
951
+ `mv` and `rm`; their `bucket create` stops at `409 STORAGE_OFF` too.
952
+
840
953
  ### 0.19.1
841
954
 
842
955
  0.19.0 went out on 2026-10-19 without these.
@@ -996,7 +1109,7 @@ RENAME` the way the control plane now reads it: a column rename whose
996
1109
  too), migration files the backend would stop at for Storage: a policy that
997
1110
  reads `storage.objects` in a subquery while the backend's ledger lacks
998
1111
  system migration 7 (it reads the ledger only when a file has one), and
999
- any statement on `storage.objects` (CREATE POLICY, DROP POLICY without
1112
+ a statement that needs `storage.objects` (CREATE POLICY, DROP POLICY without
1000
1113
  IF EXISTS, the `naro:owner` COMMENT) on a backend from before Storage or a
1001
1114
  project with none. It fails `BACKEND_OUTDATED`
1002
1115
  ``0002_b.sql needs storage.objects (statement 2 of 2), which is not available on this backend yet, so the reset would stop there with the schema already dropped. Nothing was changed. Run `naro backend enable` to update it, then try again.``
@@ -1556,7 +1669,7 @@ not fail but no strategy can ship it, `NO_DEPLOYMENT_STRATEGY`. Either way the m
1556
1669
  what each strategy failed at.
1557
1670
 
1558
1671
  `--yes` (or `-y`) suppresses every prompt — destructive actions
1559
- (production rollback/promote, env/domain/environment removal, `naro env import`, turning off an environment's protection, database reset, backend key
1672
+ (production rollback, promote to production or to an environment, env/domain/environment removal, `naro env import`, turning off an environment's protection, database reset, backend key
1560
1673
  rotation, policy removal and drop, opening the preview with
1561
1674
  `naro protection disable`, replacing or removing the bypass secret) run
1562
1675
  unconfirmed, so use it deliberately in
@@ -345,6 +345,8 @@ var DEPLOYMENT_ERROR_CODES = [
345
345
  "FORBIDDEN",
346
346
  "NOT_FOUND",
347
347
  "DEPLOYMENT_NOT_PROMOTABLE",
348
+ /** A promotion to another surface rebuilds the commit, and the source was not built from Git (spec 2026-10-18-named-environments §9.1). */
349
+ "PROMOTE_NEEDS_GIT_SOURCE",
348
350
  "DEPLOYMENT_NOT_CANCELABLE",
349
351
  "SUBDOMAIN_TAKEN",
350
352
  "FLAG_EXISTS",
@@ -511,6 +513,9 @@ function projectPath(teamSlug, projectSlug) {
511
513
  return `/${teamSlug}/projects/${projectSlug}`;
512
514
  }
513
515
 
516
+ // ../../packages/core/src/platform-mail.ts
517
+ var encoder = new TextEncoder();
518
+
514
519
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
515
520
  var external_exports = {};
516
521
  __export(external_exports, {
@@ -18506,6 +18511,7 @@ function forkedAppRoot(checkout, appRoot, forkCheckout) {
18506
18511
  }
18507
18512
 
18508
18513
  // ../../packages/build/src/local-build-executor.ts
18514
+ var PINNED_COMMIT_SHA = /^[0-9a-f]{40}$/;
18509
18515
  function concurrencyFromEnvironment() {
18510
18516
  const raw = Number(process.env.NARO_RESOLVER_CONCURRENCY ?? "1");
18511
18517
  return Number.isInteger(raw) && raw > 0 ? raw : 1;
@@ -18650,6 +18656,68 @@ var LocalBuildExecutor = class {
18650
18656
  constructor(dependencies = defaultLocalBuildDependencies) {
18651
18657
  this.dependencies = dependencies;
18652
18658
  }
18659
+ /**
18660
+ * Checks out one commit, whichever ref reaches it: GitHub serves any
18661
+ * reachable commit by SHA, which `clone --branch` cannot ask for. The URL
18662
+ * carries the installation token and is only ever an argument — runCommand
18663
+ * logs a command's output (redacted), never the command line, and a failed
18664
+ * command's message names the command, not its arguments.
18665
+ *
18666
+ * The commit is an argument of `git fetch`, so it is checked before any git
18667
+ * command runs: a value git could read as an option (`--upload-pack=…`) or a
18668
+ * ref name must not get that far. The build container checks it on the way
18669
+ * in; the local runner passes claim's input straight through, so this is the
18670
+ * check that covers it.
18671
+ */
18672
+ async checkoutPinnedCommit(commitSha, repositoryUrl, workspace, checkout, observer) {
18673
+ if (!PINNED_COMMIT_SHA.test(commitSha)) {
18674
+ throw new DeploymentError(
18675
+ "CLONE_FAILED",
18676
+ "The pinned commit is not a 40-character lowercase hex SHA."
18677
+ );
18678
+ }
18679
+ const git2 = (args) => this.dependencies.runCommand(
18680
+ {
18681
+ command: "git",
18682
+ args,
18683
+ cwd: workspace,
18684
+ timeoutMs: 12e4,
18685
+ stage: "CLONING",
18686
+ errorCode: "CLONE_FAILED"
18687
+ },
18688
+ observer
18689
+ );
18690
+ await git2(["init", "--quiet", checkout]);
18691
+ await git2(["-C", checkout, "remote", "add", "origin", "--", repositoryUrl]);
18692
+ try {
18693
+ await git2([
18694
+ "-C",
18695
+ checkout,
18696
+ "fetch",
18697
+ "--quiet",
18698
+ "--depth",
18699
+ "1",
18700
+ "origin",
18701
+ commitSha
18702
+ ]);
18703
+ } catch (error51) {
18704
+ if (!(error51 instanceof CommandExecutionError)) throw error51;
18705
+ if (error51.timedOut) throw error51;
18706
+ throw new DeploymentError(
18707
+ "CLONE_FAILED",
18708
+ `Could not fetch commit ${commitSha.slice(0, 7)}. It may have been removed from the repository.`,
18709
+ { cause: error51 }
18710
+ );
18711
+ }
18712
+ await git2([
18713
+ "-C",
18714
+ checkout,
18715
+ "checkout",
18716
+ "--quiet",
18717
+ "--detach",
18718
+ "FETCH_HEAD"
18719
+ ]);
18720
+ }
18653
18721
  async build(input, observer, cache) {
18654
18722
  const workspace = await this.dependencies.createWorkspace();
18655
18723
  const checkout = join18(workspace, "checkout");
@@ -18675,27 +18743,37 @@ var LocalBuildExecutor = class {
18675
18743
  message: `Build cache disabled: ${compressor} is not installed. Install it to reuse dependencies between deploys.`
18676
18744
  });
18677
18745
  }
18678
- await this.dependencies.runCommand(
18679
- {
18680
- command: "git",
18681
- args: [
18682
- "clone",
18683
- "--depth",
18684
- "1",
18685
- "--branch",
18686
- input.branch,
18687
- "--single-branch",
18688
- "--",
18689
- input.repositoryUrl,
18690
- checkout
18691
- ],
18692
- cwd: workspace,
18693
- timeoutMs: 12e4,
18694
- stage: "CLONING",
18695
- errorCode: "CLONE_FAILED"
18696
- },
18697
- observer
18698
- );
18746
+ if (input.commitSha !== void 0) {
18747
+ await this.checkoutPinnedCommit(
18748
+ input.commitSha,
18749
+ input.repositoryUrl,
18750
+ workspace,
18751
+ checkout,
18752
+ observer
18753
+ );
18754
+ } else {
18755
+ await this.dependencies.runCommand(
18756
+ {
18757
+ command: "git",
18758
+ args: [
18759
+ "clone",
18760
+ "--depth",
18761
+ "1",
18762
+ "--branch",
18763
+ input.branch,
18764
+ "--single-branch",
18765
+ "--",
18766
+ input.repositoryUrl,
18767
+ checkout
18768
+ ],
18769
+ cwd: workspace,
18770
+ timeoutMs: 12e4,
18771
+ stage: "CLONING",
18772
+ errorCode: "CLONE_FAILED"
18773
+ },
18774
+ observer
18775
+ );
18776
+ }
18699
18777
  const revision = await this.dependencies.runCommand(
18700
18778
  {
18701
18779
  command: "git",
@@ -18714,6 +18792,12 @@ var LocalBuildExecutor = class {
18714
18792
  "Git returned an invalid commit SHA."
18715
18793
  );
18716
18794
  }
18795
+ if (input.commitSha !== void 0 && gitCommitSha !== input.commitSha) {
18796
+ throw new DeploymentError(
18797
+ "CLONE_FAILED",
18798
+ `Checked out ${gitCommitSha.slice(0, 7)} instead of the pinned commit ${input.commitSha.slice(0, 7)}.`
18799
+ );
18800
+ }
18717
18801
  const reportedSha = gitCommitSha.length === 40 ? gitCommitSha : null;
18718
18802
  if (!reportedSha)
18719
18803
  await info2(
@@ -19129,6 +19213,14 @@ var NaroError = class extends Error {
19129
19213
  if (options?.details !== void 0) this.details = options.details;
19130
19214
  }
19131
19215
  };
19216
+ var PROMOTE_TARGET_UNSUPPORTED_MESSAGE = "This control plane cannot promote to an environment yet. Deploy the latest web first, then try again.";
19217
+ function promoteTargetUnsupported(cause) {
19218
+ return new NaroError(
19219
+ "PROMOTE_TARGET_UNSUPPORTED",
19220
+ PROMOTE_TARGET_UNSUPPORTED_MESSAGE,
19221
+ cause !== void 0 ? { cause } : void 0
19222
+ );
19223
+ }
19132
19224
  function isNaroError(error51) {
19133
19225
  return error51 instanceof NaroError || error51 instanceof Error && error51.name === "NaroError" && typeof error51.code === "string";
19134
19226
  }
@@ -19234,7 +19326,7 @@ var STYLES = `
19234
19326
  *{box-sizing:border-box}
19235
19327
  :root{
19236
19328
  --bg:#000;--surface:#0a0a0a;--surface-2:#111;--border:#232323;
19237
- --text:#ededed;--muted:#a1a1a1;--faint:#6f6f6f;
19329
+ --text:#ededed;--muted:#a1a1a1;--faint:#808080;
19238
19330
  --sans:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
19239
19331
  --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
19240
19332
  }
@@ -19698,6 +19790,8 @@ function deploymentInfo(row) {
19698
19790
  errorMessage: row.error_message,
19699
19791
  gitCommitSha: row.git_commit_sha,
19700
19792
  gitCommitMessage: row.git_commit_message,
19793
+ pinnedCommitSha: row.pinned_commit_sha ?? null,
19794
+ rebuiltFromDeploymentId: row.rebuilt_from_deployment_id ?? null,
19701
19795
  createdAt: row.created_at,
19702
19796
  startedAt: row.started_at,
19703
19797
  finishedAt: row.finished_at
@@ -19949,16 +20043,65 @@ var DeploymentsApi = class {
19949
20043
  return { current, target: production.slice(1).find(differs) ?? null };
19950
20044
  }
19951
20045
  /**
19952
- * Redeploys an existing READY deployment's artifact to production.
19953
- * Used by both `naro promote` (preview → production) and `naro rollback`
19954
- * (older production build → production).
20046
+ * Promotes an existing READY deployment: with no `target` (or
20047
+ * `production`) it goes to production — `naro promote` (preview →
20048
+ * production) and `naro rollback` (older production build → production).
20049
+ * A deployment from an environment is rebuilt from its commit with
20050
+ * production's variables (`mode: "rebuild"`); any other is published as
20051
+ * stored (`mode: "reupload"`). With an environment `target` the control
20052
+ * plane likewise rebuilds the deployment's commit with that environment's
20053
+ * variables or publishes the stored build.
20054
+ *
20055
+ * No `target` sends no body, exactly as clients before targets did. A
20056
+ * control plane from before targets ignores `target` and promotes to
20057
+ * production, so an environment target is first checked with
20058
+ * `promoteCheck`: a 405 there (no such route) stops with
20059
+ * `PROMOTE_TARGET_UNSUPPORTED` before anything is promoted. The target is
20060
+ * trimmed once, as the control plane trims it, so `" production"` is
20061
+ * production and asks no preview.
20062
+ */
20063
+ async promote(projectId, deploymentId, options = {}) {
20064
+ const target2 = options.target?.trim();
20065
+ if (target2 !== void 0 && target2 !== "production") {
20066
+ try {
20067
+ await this.promoteCheck(projectId, deploymentId, target2);
20068
+ } catch (error51) {
20069
+ if (isNaroError(error51) && error51.status === 405) {
20070
+ throw promoteTargetUnsupported(error51);
20071
+ }
20072
+ throw error51;
20073
+ }
20074
+ }
20075
+ const body = await this.client.request(this.promotePath(projectId, deploymentId), {
20076
+ method: "POST",
20077
+ ...target2 !== void 0 ? { body: { target: target2 } } : {},
20078
+ context: "Promote deployment"
20079
+ });
20080
+ return {
20081
+ ...deploymentInfo(body.deployment),
20082
+ mode: body.mode ?? "reupload"
20083
+ };
20084
+ }
20085
+ /**
20086
+ * What promoting to `target` would do, changing nothing: how it gets its
20087
+ * build, the migrations the target's database has not applied (null when
20088
+ * they cannot be compared) and the warnings to show first. Answers 405 from
20089
+ * a control plane that predates promote targets.
19955
20090
  */
19956
- async promote(projectId, deploymentId) {
20091
+ async promoteCheck(projectId, deploymentId, target2) {
20092
+ const query = target2 !== void 0 ? `?target=${encodeURIComponent(target2)}` : "";
19957
20093
  const body = await this.client.request(
19958
- `/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/promote`,
19959
- { method: "POST", context: "Promote deployment" }
20094
+ `${this.promotePath(projectId, deploymentId)}${query}`,
20095
+ { context: "Check promotion" }
19960
20096
  );
19961
- return deploymentInfo(body.deployment);
20097
+ return {
20098
+ mode: body.mode ?? "reupload",
20099
+ pendingMigrations: body.pendingMigrations ?? null,
20100
+ warnings: body.warnings ?? []
20101
+ };
20102
+ }
20103
+ promotePath(projectId, deploymentId) {
20104
+ return `/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/promote`;
19962
20105
  }
19963
20106
  /**
19964
20107
  * Stops an in-flight build (QUEUED through BUILDING) and marks the row
@@ -20713,6 +20856,7 @@ function storageInfo(body) {
20713
20856
  return {
20714
20857
  enabled: body.enabled,
20715
20858
  bucket: body.bucket ?? null,
20859
+ disabledAt: body.disabledAt ?? null,
20716
20860
  limits: {
20717
20861
  bytes: body.limits.bytes,
20718
20862
  objectBytes: body.limits.objectBytes,
@@ -20724,6 +20868,18 @@ function storageInfo(body) {
20724
20868
  buckets: (body.buckets ?? []).map(storageBucket)
20725
20869
  };
20726
20870
  }
20871
+ function storageRunFailure(error51) {
20872
+ const details = isNaroError(error51) ? error51.details : void 0;
20873
+ if (isNaroError(error51) && error51.code === "BACKEND_PROVISION_FAILED" && isRecord(details?.storage) && isRecord(details.backend))
20874
+ return new NaroError(error51.code, error51.message, {
20875
+ ...error51.status !== void 0 ? { status: error51.status } : {},
20876
+ details: {
20877
+ ...details,
20878
+ backend: backendInfo(details.backend)
20879
+ }
20880
+ });
20881
+ return error51;
20882
+ }
20727
20883
  function bucketFields(fields, into = {}) {
20728
20884
  for (const key of ["public", "fileSizeLimit", "allowedMimeTypes"])
20729
20885
  if (fields[key] !== void 0) into[key] = fields[key];
@@ -21152,16 +21308,45 @@ var StorageApi = class {
21152
21308
  }
21153
21309
  );
21154
21310
  } catch (error51) {
21155
- const details = isNaroError(error51) ? error51.details : void 0;
21156
- if (isNaroError(error51) && error51.code === "BACKEND_PROVISION_FAILED" && isRecord(details?.storage) && isRecord(details.backend))
21157
- throw new NaroError(error51.code, error51.message, {
21158
- ...error51.status !== void 0 ? { status: error51.status } : {},
21159
- details: {
21160
- ...details,
21161
- backend: backendInfo(details.backend)
21162
- }
21163
- });
21164
- throw error51;
21311
+ throw storageRunFailure(error51);
21312
+ }
21313
+ return storageInfo(body.storage);
21314
+ }
21315
+ /**
21316
+ * Turns Storage off and keeps its files (Storage 3-1k): the Naro worker is
21317
+ * uploaded again without the bucket, so the app's Storage calls and file
21318
+ * URLs answer `404 STORAGE_NOT_ENABLED` until `enable` binds the same
21319
+ * bucket again. The bucket, its files and their storage cost stay; while
21320
+ * it is off, bucket writes and the object routes answer `409
21321
+ * STORAGE_OFF`, and reads go on. Safe to repeat: Storage never on, or
21322
+ * already off, comes back as it is, and a turn-off that stopped is
21323
+ * finished. Two minutes, like `enable`.
21324
+ *
21325
+ * Refusals, in the order they are checked: `BACKEND_DISABLED`,
21326
+ * `FORBIDDEN` (`storage.manage`), `DATABASE_NOT_ENABLED`,
21327
+ * `BACKEND_NOT_READY` (no backend), `BACKEND_OFF`, `BACKEND_BUSY` (another
21328
+ * change holds the backend — or turning Storage on again took this
21329
+ * turn-off over, and Storage is on), `STORAGE_DISABLE_FAILED` (502: the
21330
+ * turn-off could not be recorded; nothing changed), and
21331
+ * `BACKEND_PROVISION_FAILED` (502) when it is
21332
+ * recorded but the worker's upload — or a step after it — stopped:
21333
+ * `details` holds `{ storage: { applied: false, enabled: false }, backend
21334
+ * }`, and `disable` again finishes. Never `STORAGE_DISABLED`: the control
21335
+ * plane's Storage switch does not hold a removal back.
21336
+ */
21337
+ async disable(projectId) {
21338
+ let body;
21339
+ try {
21340
+ body = await this.client.request(
21341
+ this.path(projectId),
21342
+ {
21343
+ method: "DELETE",
21344
+ context: "Turn off Storage",
21345
+ timeoutMs: PROVISION_TIMEOUT_MS
21346
+ }
21347
+ );
21348
+ } catch (error51) {
21349
+ throw storageRunFailure(error51);
21165
21350
  }
21166
21351
  return storageInfo(body.storage);
21167
21352
  }
@@ -31628,6 +31813,7 @@ export {
31628
31813
  splitStatements,
31629
31814
  ANALYTICS_FILTER_KEYS,
31630
31815
  NaroError,
31816
+ promoteTargetUnsupported,
31631
31817
  isNaroError,
31632
31818
  toNaroError,
31633
31819
  saveSession,
package/dist/deploy.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  readProjectEnv,
8
8
  slugify,
9
9
  workingTreeNotices
10
- } from "./chunk-ZO2BEC46.js";
10
+ } from "./chunk-SAA66XLD.js";
11
11
  import "./chunk-K3NQKI34.js";
12
12
  export {
13
13
  deployDirectory,
package/dist/index.js CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  policiesForTypes,
64
64
  policiesLostByReset,
65
65
  projectPath,
66
+ promoteTargetUnsupported,
66
67
  promptForMissingEnv,
67
68
  pullEnv,
68
69
  quoteIdent,
@@ -96,7 +97,7 @@ import {
96
97
  usageMonthOf,
97
98
  userForAccessToken,
98
99
  writeProjectLink
99
- } from "./chunk-ZO2BEC46.js";
100
+ } from "./chunk-SAA66XLD.js";
100
101
  import "./chunk-K3NQKI34.js";
101
102
 
102
103
  // src/args.ts
@@ -195,7 +196,8 @@ var STRING_FLAGS = {
195
196
  "--search": "search",
196
197
  "--copy-env": "copyEnv",
197
198
  "--database": "database",
198
- "--protection": "protection"
199
+ "--protection": "protection",
200
+ "--to": "to"
199
201
  };
200
202
  var SECRET_FLAGS = {
201
203
  "--api-key": "--api-key-stdin",
@@ -5523,17 +5525,27 @@ async function runProjectInspect(context) {
5523
5525
  }
5524
5526
 
5525
5527
  // src/commands/promote.ts
5526
- async function promoteAndWait(context, client, project, sourceDeploymentId, action) {
5528
+ var PROMOTE_USAGE = "Usage: naro promote <deployment-id> [--to <environment>]";
5529
+ var ROLLBACK_USAGE = "Usage: naro rollback [deployment-id]";
5530
+ var REBUILD_TIMEOUT_MS = 15 * 6e4;
5531
+ var REUPLOAD_TIMEOUT_MS = 5 * 6e4;
5532
+ async function promoteAndWait(context, client, project, sourceDeploymentId, action, target) {
5527
5533
  const promoted = await client.deployments.promote(
5528
5534
  project.id,
5529
- sourceDeploymentId
5535
+ sourceDeploymentId,
5536
+ target !== void 0 ? { target } : void 0
5530
5537
  );
5538
+ const rebuild = promoted.mode === "rebuild";
5531
5539
  context.output.log(
5532
- `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
5540
+ `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}${target !== void 0 ? ` to ${target} (${promoted.mode})` : ""}`
5541
+ );
5542
+ const detail = await client.deployments.wait(
5543
+ promoted.id,
5544
+ rebuild ? {
5545
+ timeoutMs: REBUILD_TIMEOUT_MS,
5546
+ onLog: (log) => context.output.log(formatBuildLog(log))
5547
+ } : { timeoutMs: REUPLOAD_TIMEOUT_MS }
5533
5548
  );
5534
- const detail = await client.deployments.wait(promoted.id, {
5535
- timeoutMs: 5 * 6e4
5536
- });
5537
5549
  if (detail.deployment.status !== "READY") {
5538
5550
  throw new NaroError(
5539
5551
  detail.deployment.errorCode ?? "DEPLOY_FAILED",
@@ -5541,42 +5553,104 @@ async function promoteAndWait(context, client, project, sourceDeploymentId, acti
5541
5553
  { details: { deploymentId: promoted.id } }
5542
5554
  );
5543
5555
  }
5556
+ const serving = target === void 0 || target === "production" ? "Production" : target;
5544
5557
  context.output.result(
5545
5558
  {
5546
5559
  deploymentId: promoted.id,
5547
5560
  sourceDeploymentId,
5548
5561
  projectId: project.id,
5562
+ // Both say the mode the control plane answered (MCP's rollback does
5563
+ // too). Only a promotion has a target: a rollback is production's.
5564
+ ...action === "promote" ? { target: target ?? null } : {},
5565
+ mode: promoted.mode,
5549
5566
  status: detail.deployment.status,
5550
5567
  url: detail.deployment.url
5551
5568
  },
5552
5569
  [
5553
- `\u2705 Production now serves ${sourceDeploymentId}`,
5570
+ `\u2705 ${serving} now serves ${rebuild ? "a rebuild of " : ""}${sourceDeploymentId}`,
5554
5571
  "",
5555
5572
  detail.deployment.url ?? ""
5556
5573
  ]
5557
5574
  );
5558
5575
  }
5576
+ function promoteTarget(flags) {
5577
+ if (flags.to === void 0) return void 0;
5578
+ const target = flags.to.trim();
5579
+ if (target === "" || target === "preview")
5580
+ throw new NaroError(
5581
+ "VALIDATION_FAILED",
5582
+ "--to must be production or an environment name."
5583
+ );
5584
+ return target;
5585
+ }
5586
+ function refuseWhatIsNotRead(context, usage, envMessage) {
5587
+ if (context.positionals.length > 1)
5588
+ throw new NaroError("VALIDATION_FAILED", usage);
5589
+ if (context.flags.env !== void 0)
5590
+ throw new NaroError("VALIDATION_FAILED", envMessage);
5591
+ }
5592
+ async function previewPromotion(context, client, projectId, sourceDeploymentId, target) {
5593
+ let check;
5594
+ try {
5595
+ check = await client.deployments.promoteCheck(
5596
+ projectId,
5597
+ sourceDeploymentId,
5598
+ target
5599
+ );
5600
+ } catch (error) {
5601
+ if (!isNaroError(error) || error.status !== 405) throw error;
5602
+ if (target !== void 0 && target !== "production")
5603
+ throw promoteTargetUnsupported(error);
5604
+ return;
5605
+ }
5606
+ context.output.log(
5607
+ check.mode === "rebuild" ? `\u25B8 rebuild: builds the same commit again with ${target ?? "production"}'s variables` : "\u25B8 reupload: publishes the stored build"
5608
+ );
5609
+ for (const warning of check.warnings)
5610
+ for (const line of warningLines(warning)) context.output.log(line);
5611
+ }
5559
5612
  async function runPromote(context) {
5560
- const [deploymentId] = context.positionalOrThrow(
5561
- 1,
5562
- "Usage: naro promote <deployment-id>"
5613
+ const [deploymentId] = context.positionalOrThrow(1, PROMOTE_USAGE);
5614
+ refuseWhatIsNotRead(
5615
+ context,
5616
+ PROMOTE_USAGE,
5617
+ "--env is not an option of naro promote: use --to <environment>."
5563
5618
  );
5619
+ const target = promoteTarget(context.flags);
5564
5620
  const client = await context.client();
5565
5621
  const projectId = await context.resolveProjectId();
5566
5622
  const project = await client.projects.get(projectId);
5623
+ await previewPromotion(
5624
+ context,
5625
+ client,
5626
+ projectId,
5627
+ deploymentId,
5628
+ target
5629
+ );
5567
5630
  await confirmOrAbort(
5568
5631
  context,
5569
- `Promote ${String(deploymentId)} to production (${project.subdomain})?`
5632
+ target === void 0 || target === "production" ? `Promote ${String(deploymentId)} to production (${project.subdomain})?` : `Promote ${String(deploymentId)} to ${target} (${project.subdomain}--${target})?`
5570
5633
  );
5571
5634
  await promoteAndWait(
5572
5635
  context,
5573
5636
  client,
5574
5637
  project,
5575
5638
  deploymentId,
5576
- "promote"
5639
+ "promote",
5640
+ target
5577
5641
  );
5578
5642
  }
5579
5643
  async function runRollback(context) {
5644
+ if (context.flags.to !== void 0)
5645
+ throw new NaroError(
5646
+ "VALIDATION_FAILED",
5647
+ "--to is for naro promote: naro rollback always rolls production back."
5648
+ );
5649
+ refuseWhatIsNotRead(
5650
+ context,
5651
+ ROLLBACK_USAGE,
5652
+ "--env is not an option of naro rollback: naro rollback always rolls production back."
5653
+ );
5580
5654
  const client = await context.client();
5581
5655
  const projectId = await context.resolveProjectId();
5582
5656
  const project = await client.projects.get(projectId);
@@ -5688,6 +5762,7 @@ var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --priva
5688
5762
  var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
5689
5763
  var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5690
5764
  var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
5765
+ var DISABLE_QUESTION3 = "Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.";
5691
5766
  var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
5692
5767
  var IDLE_PAGES = 3;
5693
5768
  function bucketLine(bucket) {
@@ -5708,6 +5783,12 @@ function utcClock(iso) {
5708
5783
  const at = new Date(iso);
5709
5784
  return Number.isNaN(at.getTime()) ? null : at.toISOString().slice(11, 16);
5710
5785
  }
5786
+ function utcMinute(iso) {
5787
+ const at = new Date(iso);
5788
+ if (Number.isNaN(at.getTime())) return printable(iso);
5789
+ const text = at.toISOString();
5790
+ return `${text.slice(0, 10)} ${text.slice(11, 16)} UTC`;
5791
+ }
5711
5792
  function warnedPercent(value, limit) {
5712
5793
  if (limit === null || !(limit > 0)) return null;
5713
5794
  const percent2 = Math.floor(value * 100 / limit);
@@ -5749,6 +5830,13 @@ function meterLines(info) {
5749
5830
  return lines;
5750
5831
  }
5751
5832
  function storageLines(info) {
5833
+ if (info.disabledAt !== null)
5834
+ return [
5835
+ `Storage: off since ${utcMinute(info.disabledAt)} \u2014 buckets and files are kept`,
5836
+ "Run `naro storage enable` to turn it on again: the same buckets and files come back.",
5837
+ info.buckets.length === 0 ? "Buckets: none" : `Buckets: ${String(info.buckets.length)}`,
5838
+ ...info.buckets.map(bucketLine)
5839
+ ];
5752
5840
  if (!info.enabled)
5753
5841
  return [
5754
5842
  "Storage: off",
@@ -5777,6 +5865,19 @@ async function enableStorage(client, projectId) {
5777
5865
  throw error;
5778
5866
  }
5779
5867
  }
5868
+ async function disableStorage(client, projectId) {
5869
+ try {
5870
+ return await client.storage.disable(projectId);
5871
+ } catch (error) {
5872
+ if (isNaroError(error) && error.code === "TIMEOUT")
5873
+ throw new NaroError(
5874
+ "TIMEOUT",
5875
+ "Turning Storage off did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage disable` again picks up where it stopped.",
5876
+ { cause: error }
5877
+ );
5878
+ throw error;
5879
+ }
5880
+ }
5780
5881
  function bucketName(context, usage) {
5781
5882
  if (context.positionals.length !== 1)
5782
5883
  throw new NaroError("VALIDATION_FAILED", usage);
@@ -5813,6 +5914,24 @@ async function runStorageEnable(context) {
5813
5914
  ...storageLines(storage)
5814
5915
  ]);
5815
5916
  }
5917
+ async function runStorageDisable(context) {
5918
+ const client = await context.client();
5919
+ const projectId = await context.resolveProjectId();
5920
+ const current = await client.storage.info(projectId);
5921
+ if (current.bucket === null) {
5922
+ context.output.result({ projectId, storage: current }, [
5923
+ "Storage is already off."
5924
+ ]);
5925
+ return;
5926
+ }
5927
+ if (current.disabledAt === null)
5928
+ await confirmOrAbort(context, DISABLE_QUESTION3);
5929
+ const storage = await disableStorage(client, projectId);
5930
+ context.output.result({ projectId, storage }, [
5931
+ "\u2705 Storage is off. Buckets and files are kept; naro storage enable turns it on again.",
5932
+ ...storageLines(storage)
5933
+ ]);
5934
+ }
5816
5935
  async function runStorageBucketList(context) {
5817
5936
  const client = await context.client();
5818
5937
  const projectId = await context.resolveProjectId();
@@ -8639,6 +8758,7 @@ var MV_USAGE = "Usage: naro storage mv naro://<bucket>/<from> naro://<bucket>/<t
8639
8758
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
8640
8759
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive] [--search <text>]";
8641
8760
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
8761
+ var STORAGE_OFF_MESSAGE = "Storage is turned off for this project. Turn it on with `naro storage enable` first.";
8642
8762
  var STORAGE_OUTDATED_MESSAGE = "The project backend is older than this Storage command. Run `naro backend enable` to update it, then try again.";
8643
8763
  var OLD_SEARCH_REASON = "search is not supported yet";
8644
8764
  function isOldSearchRefusal(error) {
@@ -8731,6 +8851,9 @@ async function objects(context) {
8731
8851
  "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
8732
8852
  );
8733
8853
  if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
8854
+ const state = await client.storage.info(projectId);
8855
+ if (state.disabledAt !== null)
8856
+ throw new NaroError("STORAGE_OFF", STORAGE_OFF_MESSAGE);
8734
8857
  let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
8735
8858
  if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
8736
8859
  throw new NaroError(
@@ -9600,6 +9723,8 @@ Usage:
9600
9723
  naro storage [status] Naro Storage: on or off, limits, what is stored, today's operations, the buckets
9601
9724
  naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
9602
9725
  (safe to re-run; applies a plan change's limits)
9726
+ naro storage disable [--yes] Turn Storage off and keep its files: its calls and file URLs answer 404
9727
+ until naro storage enable binds the same bucket again (asks first)
9603
9728
  naro storage bucket list The buckets: public or private, objects, bytes, limits
9604
9729
  naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]
9605
9730
  <size>: bytes, 5MB (1000s) or 5MiB (1024s); turns Storage on first if needed
@@ -9624,7 +9749,10 @@ Usage:
9624
9749
  Remove objects; --recursive removes what is under a folder (asks first):
9625
9750
  <path>/ is the folder alone, <path> also an object of that name
9626
9751
 
9627
- naro promote <deployment-id> Publish a build to production
9752
+ naro promote <deployment-id> [--to <environment>]
9753
+ Publish a build to production, or to an environment with --to; it says what it will do, then asks.
9754
+ A build moving between an environment and another surface is built again from its commit with the
9755
+ target's variables, which takes up to 15 minutes; any other build is published as stored.
9628
9756
  naro rollback [deployment-id] Roll production back to the previous build
9629
9757
 
9630
9758
  Global options:
@@ -9648,6 +9776,7 @@ Global options:
9648
9776
  --device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
9649
9777
  --browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
9650
9778
  --os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
9779
+ --to <environment> production (the default) or an environment name: where naro promote publishes the build (naro promote)
9651
9780
  --env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name)
9652
9781
  --branch <b> naro environments create, set: the branch whose pushes deploy to that environment. naro deploy: the default
9653
9782
  branch of a project this deploy creates or updates (the deploy's own branch follows where it goes)
@@ -9792,6 +9921,8 @@ function routeStorage(words) {
9792
9921
  return words.length <= 1 ? { handler: runStorageStatus, positionals: [] } : null;
9793
9922
  case "enable":
9794
9923
  return words.length === 1 ? { handler: runStorageEnable, positionals: [] } : null;
9924
+ case "disable":
9925
+ return words.length === 1 ? { handler: runStorageDisable, positionals: [] } : null;
9795
9926
  case "bucket":
9796
9927
  case "buckets":
9797
9928
  switch (action) {
@@ -9836,7 +9967,16 @@ function routeStorage(words) {
9836
9967
  return null;
9837
9968
  }
9838
9969
  }
9970
+ async function refuseTo() {
9971
+ throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
9972
+ }
9839
9973
  function route(parsed) {
9974
+ const routed = routeCommand(parsed);
9975
+ if (routed && parsed.flags.to !== void 0 && parsed.command !== "promote" && parsed.command !== "rollback")
9976
+ return { handler: refuseTo, positionals: routed.positionals };
9977
+ return routed;
9978
+ }
9979
+ function routeCommand(parsed) {
9840
9980
  const { command, positionals } = parsed;
9841
9981
  const sub = positionals[0];
9842
9982
  switch (command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -37,8 +37,8 @@
37
37
  "@next-deploy/build": "0.1.0",
38
38
  "@next-deploy/core": "0.1.0",
39
39
  "@next-deploy/config": "0.1.0",
40
- "@next-deploy/sdk": "0.1.0",
41
- "@next-deploy/data-core": "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",