@xano/cli 1.3.3 → 1.3.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +137 -3
  2. package/dist/base-command.d.ts +12 -1
  3. package/dist/base-command.js +36 -22
  4. package/dist/commands/branch/set_live/index.d.ts +14 -0
  5. package/dist/commands/branch/set_live/index.js +38 -0
  6. package/dist/commands/ephemeral/push/index.js +6 -1
  7. package/dist/commands/function/create/index.d.ts +15 -0
  8. package/dist/commands/function/create/index.js +39 -0
  9. package/dist/commands/function/edit/index.d.ts +15 -0
  10. package/dist/commands/function/edit/index.js +39 -0
  11. package/dist/commands/function/run/index.js +5 -11
  12. package/dist/commands/policy/catalogue/index.d.ts +16 -0
  13. package/dist/commands/policy/catalogue/index.js +60 -0
  14. package/dist/commands/policy/coverage/index.d.ts +17 -0
  15. package/dist/commands/policy/coverage/index.js +33 -0
  16. package/dist/commands/policy/create/index.d.ts +18 -0
  17. package/dist/commands/policy/create/index.js +44 -0
  18. package/dist/commands/policy/delete/index.d.ts +19 -0
  19. package/dist/commands/policy/delete/index.js +61 -0
  20. package/dist/commands/policy/evaluate/index.d.ts +32 -0
  21. package/dist/commands/policy/evaluate/index.js +71 -0
  22. package/dist/commands/policy/list/index.d.ts +14 -0
  23. package/dist/commands/policy/list/index.js +21 -0
  24. package/dist/commands/policy/parse/index.d.ts +19 -0
  25. package/dist/commands/policy/parse/index.js +18 -0
  26. package/dist/commands/policy/publish/index.d.ts +20 -0
  27. package/dist/commands/policy/publish/index.js +38 -0
  28. package/dist/commands/policy/runs/index.d.ts +54 -0
  29. package/dist/commands/policy/runs/index.js +273 -0
  30. package/dist/commands/policy/status/index.d.ts +21 -0
  31. package/dist/commands/policy/status/index.js +54 -0
  32. package/dist/commands/release/create/index.js +4 -0
  33. package/dist/commands/release/deploy/index.d.ts +19 -0
  34. package/dist/commands/release/deploy/index.js +68 -6
  35. package/dist/commands/release/import/index.js +5 -1
  36. package/dist/commands/release/list/index.d.ts +7 -0
  37. package/dist/commands/release/list/index.js +46 -9
  38. package/dist/commands/release/push/index.js +19 -1
  39. package/dist/commands/sandbox/delete/index.js +8 -0
  40. package/dist/commands/sandbox/pull/index.d.ts +7 -0
  41. package/dist/commands/sandbox/pull/index.js +49 -0
  42. package/dist/commands/sandbox/push/index.d.ts +4 -0
  43. package/dist/commands/sandbox/push/index.js +59 -2
  44. package/dist/commands/sandbox/reset/index.js +8 -0
  45. package/dist/commands/skills/pull/index.d.ts +30 -0
  46. package/dist/commands/skills/pull/index.js +118 -0
  47. package/dist/commands/tenant/create/index.d.ts +3 -0
  48. package/dist/commands/tenant/create/index.js +21 -0
  49. package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
  50. package/dist/commands/tenant/deploy_release/index.js +182 -4
  51. package/dist/commands/tenant/edit/index.d.ts +2 -0
  52. package/dist/commands/tenant/edit/index.js +39 -4
  53. package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
  54. package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
  55. package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
  56. package/dist/commands/tenant_deploy_request/create/index.js +108 -0
  57. package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
  58. package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
  59. package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
  60. package/dist/commands/tenant_deploy_request/get/index.js +113 -0
  61. package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
  62. package/dist/commands/tenant_deploy_request/list/index.js +122 -0
  63. package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
  64. package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
  65. package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
  66. package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
  67. package/dist/commands/workspace/pull/index.d.ts +17 -0
  68. package/dist/commands/workspace/pull/index.js +124 -7
  69. package/dist/commands/workspace/push/index.d.ts +23 -0
  70. package/dist/commands/workspace/push/index.js +188 -6
  71. package/dist/policy-command.d.ts +91 -0
  72. package/dist/policy-command.js +95 -0
  73. package/dist/utils/document-parser.d.ts +10 -0
  74. package/dist/utils/document-parser.js +21 -0
  75. package/dist/utils/export-compare.d.ts +52 -0
  76. package/dist/utils/export-compare.js +134 -0
  77. package/dist/utils/local-config.d.ts +23 -0
  78. package/dist/utils/local-config.js +62 -2
  79. package/dist/utils/multidoc-push.d.ts +124 -4
  80. package/dist/utils/multidoc-push.js +670 -49
  81. package/dist/utils/object-fetch.d.ts +86 -0
  82. package/dist/utils/object-fetch.js +265 -0
  83. package/dist/utils/policy/catalogue.d.ts +85 -0
  84. package/dist/utils/policy/catalogue.js +196 -0
  85. package/dist/utils/policy/coverage.d.ts +19 -0
  86. package/dist/utils/policy/coverage.js +93 -0
  87. package/dist/utils/policy/errors.d.ts +20 -0
  88. package/dist/utils/policy/errors.js +93 -0
  89. package/dist/utils/policy/feedback.d.ts +90 -0
  90. package/dist/utils/policy/feedback.js +210 -0
  91. package/dist/utils/policy/findings.d.ts +29 -0
  92. package/dist/utils/policy/findings.js +82 -0
  93. package/dist/utils/policy/gate.d.ts +138 -0
  94. package/dist/utils/policy/gate.js +193 -0
  95. package/dist/utils/policy/permission.d.ts +7 -0
  96. package/dist/utils/policy/permission.js +55 -0
  97. package/dist/utils/policy/push-gate.d.ts +5 -0
  98. package/dist/utils/policy/push-gate.js +43 -0
  99. package/dist/utils/policy/release.d.ts +85 -0
  100. package/dist/utils/policy/release.js +117 -0
  101. package/dist/utils/policy/request.d.ts +55 -0
  102. package/dist/utils/policy/request.js +119 -0
  103. package/dist/utils/policy/runs.d.ts +30 -0
  104. package/dist/utils/policy/runs.js +101 -0
  105. package/dist/utils/policy/status.d.ts +55 -0
  106. package/dist/utils/policy/status.js +127 -0
  107. package/dist/utils/policy/template.d.ts +66 -0
  108. package/dist/utils/policy/template.js +152 -0
  109. package/dist/utils/policy/types.d.ts +295 -0
  110. package/dist/utils/policy/types.js +1 -0
  111. package/dist/utils/reference-checker.d.ts +3 -0
  112. package/dist/utils/reference-checker.js +3 -2
  113. package/dist/utils/sandbox-marker.d.ts +9 -0
  114. package/dist/utils/sandbox-marker.js +119 -0
  115. package/dist/utils/sync-state.d.ts +71 -0
  116. package/dist/utils/sync-state.js +353 -0
  117. package/oclif.manifest.json +6472 -3181
  118. package/package.json +9 -1
package/README.md CHANGED
@@ -197,13 +197,16 @@ xano workspace delete -w <workspace_id> --force
197
197
  xano workspace pull
198
198
  xano workspace pull -d ./my-workspace # Specify output directory
199
199
  xano workspace pull -b dev # Specific branch
200
+ xano workspace pull -b '' # The live branch, whatever the profile's branch
200
201
  xano workspace pull --env --records # Include env vars and table records
201
202
  xano workspace pull --draft # Include draft changes
203
+ xano workspace pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
202
204
 
203
205
  # Push local files to workspace (defaults to current directory, only changed files)
204
206
  xano workspace push
205
207
  xano workspace push -d ./my-workspace # Push from a specific directory
206
208
  xano workspace push -b dev
209
+ xano workspace push -b '' # The live branch, whatever the profile's branch
207
210
  xano workspace push --sync # Full push — send all files, not just changed ones
208
211
  xano workspace push --sync --delete # Full push + delete remote objects not included
209
212
  xano workspace push --dry-run # Preview changes without pushing
@@ -212,10 +215,16 @@ xano workspace push --env # Include environment v
212
215
  xano workspace push --truncate # Truncate tables before import
213
216
  xano workspace push --no-transaction # Disable database transaction wrapping
214
217
  xano workspace push --no-guids # Skip writing GUIDs back to local files
218
+ xano workspace push --fast # Push only files that changed since last sync
219
+ xano workspace push --fast --refresh # Re-compare against the server export, then push
220
+ xano workspace push --no-fast # Skip --fast even when profile.yaml sets fast: true
221
+ xano workspace push --writeback # Write server-formatted XanoScript back to pushed files
222
+ xano workspace push --writeback --writeback-diff ./diffs # Also save local vs server copies for linter work
215
223
  xano workspace push --force # Skip preview and confirmation (for CI/CD)
216
224
  xano workspace push -i "function/*" # Push only matching files
217
225
  xano workspace push -e "table/*" # Push all files except tables
218
226
  xano workspace push -i "function/*" -e "**/test*" # Include functions, exclude tests
227
+ xano workspace push -o json # One JSON document: the preview (with --dry-run) or the import result
219
228
 
220
229
  # Pull from a git repository to local files (defaults to current directory)
221
230
  xano workspace git pull -r https://github.com/owner/repo
@@ -244,6 +253,44 @@ directory *adds* a file rather than overwriting, and renaming a workspace leaves
244
253
  the old-name file behind. Delete the stale ones, keeping the single document that
245
254
  matches your target workspace.
246
255
 
256
+ ### `.xano/sync_state.json`
257
+
258
+ `--fast` skips files whose contents still match a local sync
259
+ record at `<push dir>/.xano/sync_state.json`. The file is per-working-copy
260
+ state: it should not be committed. Ignoring it is up to you (the CLI never
261
+ edits `.gitignore` or any other VCS ignore file).
262
+
263
+ The record is created by `workspace pull --sync-state`, `sandbox pull --sync-state`,
264
+ `workspace push --fast`, or `sandbox push --fast`.
265
+ Once it exists, every successful workspace or sandbox push or pull updates it. A successful
266
+ `workspace push --sync --delete` or `sandbox push --sync --delete` also removes entries for
267
+ files deleted locally, which clears the "deleted locally" note. A default push or pull does
268
+ not create it. `--dry-run` never writes it, and neither does `pull --draft`, because
269
+ `--fast` compares against published versions. Push records the exact bytes it sent, so a
270
+ file edited while a push runs is sent again by the next `--fast` push. Delete the file to turn this off; the next
271
+ `--fast` push compares against the server export once (~99 seconds on a
272
+ ~1,100-document workspace) and rebuilds it. If that export fails, the CLI falls
273
+ back to a full dry-run.
274
+
275
+ Sandbox `--fast` also keeps a per-user marker at `sandbox_sync.json` next to your
276
+ credentials file (normally `~/.xano/sandbox_sync.json`). A successful sandbox push
277
+ or `sandbox pull --sync-state` writes a new token there and into the directory's
278
+ record. `sandbox reset` and `sandbox delete` clear the host's entries, as does a
279
+ sandbox push from another directory. If the tokens do not match, `--fast` compares
280
+ against the sandbox export once instead of trusting the record. After a reset or
281
+ push done outside this CLI or on another machine, use `--refresh`.
282
+
283
+ `--refresh` (requires `--fast`) runs that export comparison even when a record
284
+ already exists. Use it after edits in the Xano UI, a teammate's push, or when
285
+ the record looks wrong. `--fast` will not overwrite remote-only changes; a push
286
+ without `--fast` still will.
287
+
288
+ `--fast` cannot be combined with `--sync`. It works with `--force` and still
289
+ sends only the files the record (or export comparison) marked as changed.
290
+ With `--records`, tables that have `items` are always sent, as they are without
291
+ `--fast`. With `--env`, `--fast` compares every file with the dry-run, because the
292
+ record does not track environment variables.
293
+
247
294
  ### Flatten
248
295
 
249
296
  A hand-authored `.xs` file can hold several documents joined by `---`. That is
@@ -387,8 +434,8 @@ Input flexibility for `function run` (assembled into one JSON `input` object):
387
434
  to run against `live`; an unknown label is rejected by the server with
388
435
  `Invalid data source.`
389
436
 
390
- `-t/--tenant <name>` runs the function on that tenant instead of the workspace by sending
391
- the `X-Tenant` header (also used when reading the function's declared inputs).
437
+ `-t/--tenant <name>` runs the function on that tenant
438
+ (`/workspace/{id}/tenant/{name}/function/run`) instead of the workspace.
392
439
 
393
440
  Merge order is JSON base first, then `--data` overrides. Missing required inputs are
394
441
  prompted for on an interactive terminal; in a non-TTY (CI) context the command fails
@@ -610,9 +657,14 @@ xano tenant get <tenant_name>
610
657
  xano tenant create "My Tenant"
611
658
  xano tenant create "My Tenant" -d "Description" --type tier2 --cluster_id 1 --platform_id 5
612
659
  xano tenant create "My Tenant" --type tier2 --cluster_id 1 --license ./license.yaml
660
+ xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass
661
+ xano tenant create "Staging" --required_reviewers 0 --allow_quick_deploy
613
662
 
614
663
  # Edit a tenant
615
664
  xano tenant edit <tenant_name> --display "New Name" -d "New description"
665
+ xano tenant edit <tenant_name> --required_reviewers 1
666
+ xano tenant edit <tenant_name> --required_reviewers 0
667
+ xano tenant edit <tenant_name> --allow_quick_deploy
616
668
 
617
669
  # Delete a tenant (confirmation required)
618
670
  xano tenant delete <tenant_name>
@@ -663,6 +715,65 @@ xano tenant deploy_release <tenant_name> --release v1.0
663
715
  xano tenant deploy_platform <tenant_name> --platform_id 5 --license ./license.yaml
664
716
  ```
665
717
 
718
+ If a tenant has `required_reviewers` set above 0, `tenant deploy_release` fails with an
719
+ error naming the `tenant_deploy_request` command to run instead — see below. A tenant
720
+ with `allow_quick_deploy` enabled skips the gate entirely, regardless of
721
+ `required_reviewers`.
722
+
723
+ #### Tenant Deploy Requests
724
+
725
+ When a tenant has `required_reviewers` set above 0 and `allow_quick_deploy` is off (see
726
+ `tenant create`/`tenant edit` above), a release may only be deployed to it through an
727
+ **approved deploy request**, requiring that many distinct reviewer approvals. An author
728
+ with no deploy permission opens a request; a named reviewer who does hold deploy
729
+ permission on the tenant votes to approve it, and once enough reviewers have approved,
730
+ it deploys as part of that action. `allow_deploy_bypass` (set at `tenant create` time
731
+ only) additionally allows an authorized user to skip a specific request's gate via
732
+ `tenant_deploy_request bypass`, audited with a required reason.
733
+
734
+ ```bash
735
+ # List deploy requests in a workspace (results show the target tenant and release)
736
+ xano tenant_deploy_request list
737
+ xano tenant_deploy_request list --tenant prod --status pending
738
+ xano tenant_deploy_request list --tenant prod --release v1.2
739
+ xano tenant_deploy_request list --to-review
740
+
741
+ # Get details of a specific deploy request
742
+ xano tenant_deploy_request get <id>
743
+
744
+ # Open a deploy request (submits for review immediately unless --draft is passed)
745
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --reviewers 12,45
746
+ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --draft
747
+
748
+ # Edit a deploy request's title, description or reviewers
749
+ xano tenant_deploy_request edit <id> --title "Deploy v1.2.1 to prod"
750
+ xano tenant_deploy_request edit <id> --reviewers 12,45,67
751
+
752
+ # Change status: draft, submit, approve, request_changes, close, or reopen
753
+ # (approve records one reviewer's vote; once enough votes are in it deploys the
754
+ # release as part of that same call)
755
+ xano tenant_deploy_request set_status <id> --status submit
756
+ xano tenant_deploy_request set_status <id> --status approve
757
+ xano tenant_deploy_request set_status <id> --status request_changes --reason "Needs a migration note"
758
+ xano tenant_deploy_request set_status <id> --status close --reason "Superseded by a newer request"
759
+ xano tenant_deploy_request set_status <id> --status reopen
760
+
761
+ # Link a newer release to a request after changes were requested (author only)
762
+ xano tenant_deploy_request revision <id> --release v1.2.1 --note "Added the missing migration"
763
+
764
+ # Deploy past the approval gate under the deploy-bypass permission (requires the
765
+ # tenant's allow_deploy_bypass to be enabled, and a separately-grantable RBAC
766
+ # permission). Every use is audited.
767
+ xano tenant_deploy_request bypass <id> --reason "Prod incident, reviewer unavailable"
768
+ ```
769
+
770
+ CI example (e.g. a GitHub Action step):
771
+
772
+ ```bash
773
+ xano tenant_deploy_request create "$PR_TITLE" --tenant prod --release v1.2 \
774
+ --description "$PR_BODY" --reviewers 12,45 -o json
775
+ ```
776
+
666
777
  #### Tenant License
667
778
 
668
779
  ```bash
@@ -820,6 +931,7 @@ xano sandbox get -o json
820
931
  xano sandbox pull
821
932
  xano sandbox pull -d ./my-sandbox
822
933
  xano sandbox pull --env --records
934
+ xano sandbox pull --sync-state # Write .xano/sync_state.json; later pulls keep it current
823
935
 
824
936
  # Push local files to sandbox (defaults to current directory, only changed files)
825
937
  xano sandbox push
@@ -827,6 +939,11 @@ xano sandbox push -d ./my-workspace # Push from a specific
827
939
  xano sandbox push --sync # Full push — send all files
828
940
  xano sandbox push --sync --delete # Full push + delete remote objects not included
829
941
  xano sandbox push --dry-run # Preview changes without pushing
942
+ xano sandbox push --fast # Skip unchanged files using .xano/sync_state.json
943
+ xano sandbox push --fast --refresh # Re-compare against the sandbox export, then push
944
+ xano sandbox push --no-fast # Push without --fast even when profile.yaml sets fast: true
945
+ xano sandbox push --writeback # Write server-formatted XanoScript back after push
946
+ xano sandbox push --writeback --writeback-diff ./diffs # Also save local vs server copies
830
947
  xano sandbox push --records --env # Include records and environment variables
831
948
  xano sandbox push --truncate # Truncate tables before import
832
949
  xano sandbox push --no-guids # Skip writing GUIDs back to local files
@@ -972,11 +1089,28 @@ workspace: 110 # optional override
972
1089
  instance_origin: https://your-instance.xano.io # optional override
973
1090
  account_origin: https://app.xano.com # optional override
974
1091
  branch: main # optional override
1092
+ fast: true # optional: default workspace/sandbox push --fast
1093
+ writeback: true # optional: default workspace/sandbox push --writeback
1094
+ writeback_object_limit: 50 # optional: workspace push --writeback per-object fetch limit (default 25)
975
1095
  ```
976
1096
 
977
1097
  When a `profile.yaml` is in effect, every command prints the active target,
978
1098
  e.g. `Using profile 'staging' (workspace 110) · profile.yaml` (suppressed for
979
- `--output json`).
1099
+ `--output json`). `workspace push` and `sandbox push` also log
1100
+ `Using --fast --writeback · profile.yaml` when those keys turn the flags on.
1101
+ `--no-fast` and `--no-writeback` override them for one run. `--sync` ignores a
1102
+ `fast: true` rather than erroring.
1103
+
1104
+ `workspace push --writeback` fetches each pushed object individually when the push
1105
+ has at most `writeback_object_limit` documents (default 25), and otherwise downloads
1106
+ the full workspace export. Per-object requests grow with the number of documents;
1107
+ the export costs about the same no matter how many you pushed. Raise the limit for a
1108
+ large workspace where the export is slow; `0` always uses the export. If a per-object
1109
+ request fails, writeback falls back to the export. Sandbox push always uses the export.
1110
+ Writeback skips, with a warning, any file that changed on disk after the push read it.
1111
+
1112
+ An explicit `-p/--profile` or `XANO_PROFILE` ignores `profile.yaml` entirely,
1113
+ including `fast`, `writeback`, and `writeback_object_limit`.
980
1114
 
981
1115
  Generate one with `xano profile use`. It writes a self-documenting
982
1116
  `profile.yaml` (every overridable field is included as a commented example, so
@@ -63,6 +63,16 @@ export default abstract class BaseCommand extends Command {
63
63
  * Sets NODE_TLS_REJECT_UNAUTHORIZED=0 so all fetch() calls skip cert verification.
64
64
  */
65
65
  protected applyInsecureFromProfile(): void;
66
+ /**
67
+ * Report a failure as exit 1 for commands that reserve exit 2 for findings: operational errors,
68
+ * flag errors and oclif's own default exit 2 all become 1, and under `-o json` the failure is also
69
+ * written to stdout as `{"error": {"exit": 1, "message"}}`. A deliberate exit 0 passes through.
70
+ */
71
+ protected catchAsOperational(error: Error & {
72
+ oclif?: {
73
+ exit?: number;
74
+ };
75
+ }): Promise<void>;
66
76
  finally(_: Error | undefined): Promise<void>;
67
77
  /**
68
78
  * Get the resolved credentials file path, respecting --config flag and XANO_CONFIG env var.
@@ -78,6 +88,8 @@ export default abstract class BaseCommand extends Command {
78
88
  protected getOrCreateSandbox(profile: ProfileConfig, verbose: boolean): Promise<SandboxTenant>;
79
89
  protected getProfile(): string | undefined;
80
90
  init(): Promise<void>;
91
+ /** Raw-argv check: the parsed flags are not available yet when the banner prints. */
92
+ protected isJsonOutput(): boolean;
81
93
  protected loadCredentialsFile(): CredentialsFile | null;
82
94
  /**
83
95
  * Poll a static-host build until it reaches a terminal status (ok | error),
@@ -129,7 +141,6 @@ export default abstract class BaseCommand extends Command {
129
141
  verbose: boolean;
130
142
  workspaceId: string;
131
143
  }): Promise<string>;
132
- private isJsonOutput;
133
144
  /**
134
145
  * Find and parse the nearest project-local profile.yaml, unless an explicit
135
146
  * -p/XANO_PROFILE was given (in which case the local file is ignored).
@@ -156,6 +156,18 @@ export default class BaseCommand extends Command {
156
156
  // Don't fail the command if we can't read the profile for insecure check
157
157
  }
158
158
  }
159
+ /**
160
+ * Report a failure as exit 1 for commands that reserve exit 2 for findings: operational errors,
161
+ * flag errors and oclif's own default exit 2 all become 1, and under `-o json` the failure is also
162
+ * written to stdout as `{"error": {"exit": 1, "message"}}`. A deliberate exit 0 passes through.
163
+ */
164
+ async catchAsOperational(error) {
165
+ if (error.oclif?.exit === 0)
166
+ return super.catch(error);
167
+ if (this.isJsonOutput())
168
+ this.log(JSON.stringify({ error: { exit: 1, message: error.message } }, null, 2));
169
+ this.error(error, { exit: 1 });
170
+ }
159
171
  async finally(_) {
160
172
  if (this.updateNotice && !this.isJsonOutput()) {
161
173
  this.log(this.updateNotice);
@@ -221,6 +233,19 @@ export default class BaseCommand extends Command {
221
233
  const forceUpdateCheck = process.env.XANO_FORCE_UPDATE_CHECK === '1';
222
234
  this.updateNotice = checkForUpdate(this.config.version, forceUpdateCheck);
223
235
  }
236
+ /** Raw-argv check: the parsed flags are not available yet when the banner prints. */
237
+ isJsonOutput() {
238
+ const args = this.argv;
239
+ for (let i = 0; i < args.length; i++) {
240
+ if (args[i] === '--output' && args[i + 1] === 'json')
241
+ return true;
242
+ if (args[i] === '-o' && args[i + 1] === 'json')
243
+ return true;
244
+ if (args[i] === '--output=json' || args[i] === '-o=json')
245
+ return true;
246
+ }
247
+ return false;
248
+ }
224
249
  loadCredentialsFile() {
225
250
  const credentialsPath = this.getCredentialsPath();
226
251
  if (!fs.existsSync(credentialsPath)) {
@@ -354,18 +379,19 @@ export default class BaseCommand extends Command {
354
379
  ...(timeoutMs > 0 && !options.signal ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
355
380
  });
356
381
  const contentType = headers['Content-Type'] || 'application/json';
382
+ const logDiagnostic = this.isJsonOutput() ? this.logToStderr.bind(this) : this.log.bind(this);
357
383
  if (verbose) {
358
- this.log('');
359
- this.log('─'.repeat(60));
360
- this.log(`→ ${method} ${url}`);
361
- this.log(` Content-Type: ${contentType}`);
384
+ logDiagnostic('');
385
+ logDiagnostic('─'.repeat(60));
386
+ logDiagnostic(`→ ${method} ${url}`);
387
+ logDiagnostic(` Content-Type: ${contentType}`);
362
388
  if (authToken) {
363
- this.log(` Authorization: Bearer ${authToken.slice(0, 8)}...${authToken.slice(-4)}`);
389
+ logDiagnostic(` Authorization: Bearer ${authToken.slice(0, 8)}...${authToken.slice(-4)}`);
364
390
  }
365
391
  if (options.body) {
366
392
  const bodyStr = typeof options.body === 'string' ? options.body : String(options.body);
367
393
  const bodyPreview = bodyStr.length > 500 ? bodyStr.slice(0, 500) + '...' : bodyStr;
368
- this.log(` Body: ${bodyPreview}`);
394
+ logDiagnostic(` Body: ${bodyPreview}`);
369
395
  }
370
396
  }
371
397
  const startTime = Date.now();
@@ -381,7 +407,7 @@ export default class BaseCommand extends Command {
381
407
  if (dispatcherSupported && isInvalidDispatcherError(error)) {
382
408
  dispatcherSupported = false;
383
409
  if (verbose) {
384
- this.log(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
410
+ logDiagnostic(' (this Node runtime rejected undici dispatcher; retrying without it — see DEV-7773)');
385
411
  }
386
412
  response = await fetch(url, buildFetchOptions(false));
387
413
  }
@@ -391,9 +417,9 @@ export default class BaseCommand extends Command {
391
417
  }
392
418
  const elapsed = Date.now() - startTime;
393
419
  if (verbose) {
394
- this.log(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
395
- this.log('─'.repeat(60));
396
- this.log('');
420
+ logDiagnostic(`← ${response.status} ${response.statusText} (${elapsed}ms)`);
421
+ logDiagnostic('─'.repeat(60));
422
+ logDiagnostic('');
397
423
  }
398
424
  return response;
399
425
  }
@@ -468,18 +494,6 @@ export default class BaseCommand extends Command {
468
494
  conclude(`stopped waiting after ${Math.round(timeoutMs / 1000)}s (last status: ${stage || 'unknown'})`);
469
495
  return stage;
470
496
  }
471
- isJsonOutput() {
472
- const args = process.argv;
473
- for (let i = 0; i < args.length; i++) {
474
- if (args[i] === '--output' && args[i + 1] === 'json')
475
- return true;
476
- if (args[i] === '-o' && args[i + 1] === 'json')
477
- return true;
478
- if (args[i] === '--output=json' || args[i] === '-o=json')
479
- return true;
480
- }
481
- return false;
482
- }
483
497
  /**
484
498
  * Find and parse the nearest project-local profile.yaml, unless an explicit
485
499
  * -p/XANO_PROFILE was given (in which case the local file is ignored).
@@ -8,11 +8,25 @@ export default class BranchSetLive extends BaseCommand {
8
8
  static flags: {
9
9
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  workspace: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  };
17
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
18
+ private refusedByPolicyGate;
19
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
20
+ protected catch(error: Error & {
21
+ oclif?: {
22
+ exit?: number;
23
+ };
24
+ }): Promise<void>;
16
25
  run(): Promise<void>;
17
26
  private confirm;
27
+ /**
28
+ * The set-live gate refused: the verdict on stdout (under `-o json`, `{set_live: false, message,
29
+ * policy_gate}`), then the platform's message and how to proceed, exiting 2.
30
+ */
31
+ private refuseSetLive;
18
32
  }
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import BaseCommand from '../../../base-command.js';
3
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride, gateOverrideHint, gateRefusal, liveGateLines, quoted, } from '../../../utils/policy/gate.js';
3
4
  export default class BranchSetLive extends BaseCommand {
4
5
  static args = {
5
6
  branch_label: Args.string({
@@ -40,14 +41,25 @@ Branch 'v1' is now live
40
41
  options: ['summary', 'json'],
41
42
  required: false,
42
43
  }),
44
+ 'policy-override': Flags.string({ description: 'Set the branch live past a blocking set-live policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
43
45
  workspace: Flags.integer({
44
46
  char: 'w',
45
47
  description: 'Workspace ID (uses profile workspace if not provided)',
46
48
  required: false,
47
49
  }),
48
50
  };
51
+ /** Set once the set-live policy gate refuses, the one failure that exits 2. */
52
+ refusedByPolicyGate = false;
53
+ /** Exit 2 is the set-live gate's refusal; every other failure, flag errors included, exits 1. */
54
+ async catch(error) {
55
+ if (this.refusedByPolicyGate)
56
+ return super.catch(error);
57
+ return this.catchAsOperational(error);
58
+ }
49
59
  async run() {
50
60
  const { args, flags } = await this.parse(BranchSetLive);
61
+ if (blankPolicyOverride(flags['policy-override']))
62
+ this.error(BLANK_POLICY_OVERRIDE);
51
63
  const { profile } = this.resolveProfile(flags);
52
64
  // Get workspace ID from flag or profile
53
65
  const workspaceId = flags.workspace || profile.workspace;
@@ -66,16 +78,23 @@ Branch 'v1' is now live
66
78
  }
67
79
  // Construct the API URL
68
80
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/branch/${encodeURIComponent(branchLabel)}/live`;
81
+ // A reason from someone with workspace:policy update sets the branch live past a blocking gate.
82
+ const overrideReason = flags['policy-override']?.trim();
69
83
  // Set branch as live via the API
70
84
  try {
71
85
  const response = await this.verboseFetch(apiUrl, {
86
+ ...(overrideReason ? { body: JSON.stringify({ override_reason: overrideReason }) } : {}),
72
87
  headers: {
73
88
  'accept': 'application/json',
74
89
  'Authorization': `Bearer ${profile.access_token}`,
90
+ ...(overrideReason ? { 'Content-Type': 'application/json' } : {}),
75
91
  },
76
92
  method: 'POST',
77
93
  }, flags.verbose, profile.access_token);
78
94
  if (!response.ok) {
95
+ const refused = await gateRefusal(response);
96
+ if (refused)
97
+ this.refuseSetLive(refused, flags, branchLabel);
79
98
  const errorText = await response.text();
80
99
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
81
100
  }
@@ -89,6 +108,8 @@ Branch 'v1' is now live
89
108
  }
90
109
  }
91
110
  catch (error) {
111
+ if (this.refusedByPolicyGate)
112
+ throw error;
92
113
  if (error instanceof Error) {
93
114
  this.error(`Failed to set branch as live: ${error.message}`);
94
115
  }
@@ -111,4 +132,21 @@ Branch 'v1' is now live
111
132
  });
112
133
  });
113
134
  }
135
+ /**
136
+ * The set-live gate refused: the verdict on stdout (under `-o json`, `{set_live: false, message,
137
+ * policy_gate}`), then the platform's message and how to proceed, exiting 2.
138
+ */
139
+ refuseSetLive(refused, flags, branchLabel) {
140
+ if (flags.output === 'json') {
141
+ this.log(JSON.stringify({ message: refused.message, policy_gate: refused.answer, set_live: false }, null, 2));
142
+ }
143
+ else {
144
+ for (const line of liveGateLines(refused.answer, 'branch'))
145
+ this.log(line);
146
+ }
147
+ const workspace = flags.workspace ? ` -w ${flags.workspace}` : '';
148
+ const hint = gateOverrideHint(refused.answer, `xano branch set_live ${quoted(branchLabel)}${workspace} --policy-override "<why>"`);
149
+ this.refusedByPolicyGate = true;
150
+ this.error(`${refused.message}${hint}`, { exit: 2 });
151
+ }
114
152
  }
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import BaseCommand from '../../../base-command.js';
5
5
  import { isAwaited, waitForMicroservices, } from '../../../utils/microservice-wait.js';
6
6
  import { executePush } from '../../../utils/multidoc-push.js';
7
+ import { policiesSkippedNotice } from '../../../utils/policy/feedback.js';
7
8
  /** Status glyph for one microservice row in the `--wait` progress block. */
8
9
  const icon = (e) => {
9
10
  if (!isAwaited(e))
@@ -168,13 +169,17 @@ Skip preview and push immediately
168
169
  truncate: flags.truncate,
169
170
  verbose: flags.verbose,
170
171
  };
171
- await executePush({
172
+ const result = await executePush({
172
173
  accessToken: profile.access_token,
173
174
  branch: '',
174
175
  command: this,
175
176
  inputDir,
176
177
  verboseFetch: this.verboseFetch.bind(this),
177
178
  }, target, pushFlags);
179
+ // When the platform leaves policy files out, it says so once.
180
+ const skipped = policiesSkippedNotice(result.response ?? result.preview);
181
+ if (skipped)
182
+ this.warn(skipped);
178
183
  // --wait: after a real push, poll the tenant's live microservice status until
179
184
  // every auto-deployed microservice (tenant_deploy="auto") is ready or the
180
185
  // wait times out. Skipped on --dry-run (nothing was deployed).
@@ -7,13 +7,28 @@ export default class FunctionCreate extends BaseCommand {
7
7
  edit: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
8
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  };
17
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
18
+ private refusedByPolicyGate;
19
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
20
+ protected catch(error: Error & {
21
+ oclif?: {
22
+ exit?: number;
23
+ };
24
+ }): Promise<void>;
16
25
  run(): Promise<void>;
17
26
  private editFile;
18
27
  private readStdin;
28
+ /**
29
+ * The live-branch publish gate refused the new function: the verdict on stdout (under `-o json`,
30
+ * `{created: false, message, policy_gate}`), then the platform's message and how to proceed,
31
+ * exiting 2.
32
+ */
33
+ private refusePublish;
19
34
  }
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
4
4
  import * as os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import BaseCommand from '../../../base-command.js';
7
+ import { BLANK_POLICY_OVERRIDE, blankPolicyOverride, gateRefusal, publishRefusal, quoted } from '../../../utils/policy/gate.js';
7
8
  export default class FunctionCreate extends BaseCommand {
8
9
  static args = {};
9
10
  static description = 'Create a new function in a workspace';
@@ -59,6 +60,7 @@ Name: my_function
59
60
  options: ['summary', 'json'],
60
61
  required: false,
61
62
  }),
63
+ 'policy-override': Flags.string({ description: 'Create the function past a blocking live-branch publish policy gate with an audited reason (requires workspace:policy update)', hidden: true }),
62
64
  stdin: Flags.boolean({
63
65
  char: 's',
64
66
  default: false,
@@ -72,8 +74,18 @@ Name: my_function
72
74
  required: false,
73
75
  }),
74
76
  };
77
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
78
+ refusedByPolicyGate = false;
79
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
80
+ async catch(error) {
81
+ if (this.refusedByPolicyGate)
82
+ return super.catch(error);
83
+ return this.catchAsOperational(error);
84
+ }
75
85
  async run() {
76
86
  const { flags } = await this.parse(FunctionCreate);
87
+ if (blankPolicyOverride(flags['policy-override']))
88
+ this.error(BLANK_POLICY_OVERRIDE);
77
89
  const { profile, profileName } = this.resolveProfile(flags);
78
90
  // Determine workspace_id from flag or profile
79
91
  let workspaceId;
@@ -133,6 +145,10 @@ Name: my_function
133
145
  const queryParams = new URLSearchParams({
134
146
  include_xanoscript: 'false',
135
147
  });
148
+ // A reason from someone with workspace:policy update saves past a blocking publish gate.
149
+ const overrideReason = flags['policy-override']?.trim();
150
+ if (overrideReason)
151
+ queryParams.set('override_reason', overrideReason);
136
152
  const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/function?${queryParams.toString()}`;
137
153
  // Create function via API
138
154
  try {
@@ -146,6 +162,9 @@ Name: my_function
146
162
  method: 'POST',
147
163
  }, flags.verbose, profile.access_token);
148
164
  if (!response.ok) {
165
+ const refused = await gateRefusal(response);
166
+ if (refused)
167
+ this.refusePublish(refused, flags);
149
168
  const errorText = await response.text();
150
169
  this.error(`API request failed with status ${response.status}: ${response.statusText}\n${errorText}`);
151
170
  }
@@ -166,6 +185,8 @@ Name: my_function
166
185
  }
167
186
  }
168
187
  catch (error) {
188
+ if (this.refusedByPolicyGate)
189
+ throw error;
169
190
  if (error instanceof Error) {
170
191
  this.error(`Failed to create function: ${error.message}`);
171
192
  }
@@ -239,4 +260,22 @@ Name: my_function
239
260
  process.stdin.resume();
240
261
  });
241
262
  }
263
+ /**
264
+ * The live-branch publish gate refused the new function: the verdict on stdout (under `-o json`,
265
+ * `{created: false, message, policy_gate}`), then the platform's message and how to proceed,
266
+ * exiting 2.
267
+ */
268
+ refusePublish(refused, flags) {
269
+ const workspace = flags.workspace ? ` -w ${quoted(flags.workspace)}` : '';
270
+ const source = flags.file ? ` -f ${quoted(flags.file)}` : ' --stdin';
271
+ const { lines, message } = publishRefusal(refused, {
272
+ json: flags.output === 'json',
273
+ outcome: 'created',
274
+ rerun: `xano function create${workspace}${source} --policy-override "<why>"`,
275
+ });
276
+ for (const line of lines)
277
+ this.log(line);
278
+ this.refusedByPolicyGate = true;
279
+ this.error(message, { exit: 2 });
280
+ }
242
281
  }
@@ -9,6 +9,7 @@ export default class FunctionEdit extends BaseCommand {
9
9
  edit: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
11
  output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'policy-override': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  publish: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  stdin: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -16,10 +17,24 @@ export default class FunctionEdit extends BaseCommand {
16
17
  profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
18
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  };
20
+ /** Set once the publish policy gate refuses, the one failure that exits 2. */
21
+ private refusedByPolicyGate;
22
+ /** Exit 2 is the publish gate's refusal; every other failure, flag errors included, exits 1. */
23
+ protected catch(error: Error & {
24
+ oclif?: {
25
+ exit?: number;
26
+ };
27
+ }): Promise<void>;
19
28
  run(): Promise<void>;
20
29
  private editFile;
21
30
  private editFunctionContent;
22
31
  private fetchFunctionCode;
23
32
  private promptForFunctionId;
24
33
  private readStdin;
34
+ /**
35
+ * The live-branch publish gate refused the update: the verdict on stdout (under `-o json`,
36
+ * `{updated: false, message, policy_gate}`), then the platform's message and how to proceed,
37
+ * exiting 2.
38
+ */
39
+ private refusePublish;
25
40
  }