@deaduchiha/orbito 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/orbito.js CHANGED
@@ -2118,9 +2118,9 @@ __exportCjs(exports_commander, {
2118
2118
  import process2 from "node:process";
2119
2119
 
2120
2120
  // src/cli.ts
2121
- import crypto from "node:crypto";
2122
- import fs3 from "node:fs";
2123
- import path3 from "node:path";
2121
+ import crypto2 from "node:crypto";
2122
+ import fs4 from "node:fs";
2123
+ import path4 from "node:path";
2124
2124
 
2125
2125
  // ../../node_modules/.bun/commander@14.0.3/node_modules/commander/index.js
2126
2126
  var { Argument } = require_argument();
@@ -2154,37 +2154,6 @@ var {
2154
2154
  Option: Option2,
2155
2155
  Help: Help2
2156
2156
  } = exports_commander;
2157
- // package.json
2158
- var package_default = {
2159
- name: "@deaduchiha/orbito",
2160
- version: "0.1.0",
2161
- description: "Deploy, follow logs and check migrations of apps on an Orbito server",
2162
- license: "UNLICENSED",
2163
- bin: {
2164
- orbito: "dist/orbito.js"
2165
- },
2166
- files: [
2167
- "dist",
2168
- "config.schema.json"
2169
- ],
2170
- type: "module",
2171
- exports: {
2172
- "./config.schema.json": "./config.schema.json"
2173
- },
2174
- scripts: {
2175
- build: "bun build src/main.ts --target node --outfile dist/orbito.js && bun run schema",
2176
- schema: "bun scripts/write-schema.ts",
2177
- test: "bun test",
2178
- prepack: "bun run build"
2179
- },
2180
- engines: {
2181
- node: ">=20"
2182
- },
2183
- devDependencies: {
2184
- commander: "^14.0.0",
2185
- zod: "^4.6.7"
2186
- }
2187
- };
2188
2157
 
2189
2158
  // ../../node_modules/.bun/zod@4.6.7/node_modules/zod/v4/core/util.js
2190
2159
  function getEnumValues(entries) {
@@ -3173,6 +3142,7 @@ function datetime(args) {
3173
3142
  return new RegExp(`^${dateSource}T(?:${timeRegex})$`);
3174
3143
  }
3175
3144
  var anyString = /^[\s\S]{0,}$/;
3145
+ var integer = /^-?\d+$/;
3176
3146
  var number = /^-?\d+(?:\.\d+)?$/;
3177
3147
  var boolean = /^(?:true|false)$/i;
3178
3148
  var lowercase = /^[^A-Z]*$/;
@@ -4643,6 +4613,161 @@ function handleIntersectionResults(result, left, right) {
4643
4613
  result.value = merged.data;
4644
4614
  return result;
4645
4615
  }
4616
+ var $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => {
4617
+ $ZodType.init(inst, def);
4618
+ const memo = globalConfig.memoizer;
4619
+ memo?.attach(inst);
4620
+ inst._zod.parse = (payload, ctx) => {
4621
+ const input = payload.value;
4622
+ if (!isPlainObject(input)) {
4623
+ payload.issues.push({
4624
+ expected: "record",
4625
+ code: "invalid_type",
4626
+ input,
4627
+ inst
4628
+ });
4629
+ return payload;
4630
+ }
4631
+ const proms = [];
4632
+ const values = def.keyType._zod.values;
4633
+ if (values && !def.partial) {
4634
+ payload.value = memo ? memo.alloc(inst, payload, {}, ctx) : {};
4635
+ const recordKeys = new Set;
4636
+ for (const key of values) {
4637
+ if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") {
4638
+ recordKeys.add(typeof key === "number" ? key.toString() : key);
4639
+ if (key === "__proto__")
4640
+ continue;
4641
+ const keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx);
4642
+ if (keyResult instanceof Promise) {
4643
+ throw new Error("Async schemas not supported in object keys currently");
4644
+ }
4645
+ if (keyResult.issues.length) {
4646
+ payload.issues.push({
4647
+ code: "invalid_key",
4648
+ origin: "record",
4649
+ issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())),
4650
+ input: key,
4651
+ path: [key],
4652
+ inst
4653
+ });
4654
+ continue;
4655
+ }
4656
+ const outKey = keyResult.value;
4657
+ if (outKey === "__proto__")
4658
+ continue;
4659
+ const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx);
4660
+ if (result instanceof Promise) {
4661
+ proms.push(result.then((result) => {
4662
+ if (result.issues.length) {
4663
+ payload.issues.push(...prefixIssues(key, result.issues));
4664
+ }
4665
+ payload.value[outKey] = result.value;
4666
+ }));
4667
+ } else {
4668
+ if (result.issues.length) {
4669
+ payload.issues.push(...prefixIssues(key, result.issues));
4670
+ }
4671
+ payload.value[outKey] = result.value;
4672
+ }
4673
+ }
4674
+ }
4675
+ let unrecognized;
4676
+ for (const key in input) {
4677
+ if (!recordKeys.has(key)) {
4678
+ if (def.mode === "loose") {
4679
+ if (key === "__proto__")
4680
+ continue;
4681
+ payload.value[key] = input[key];
4682
+ } else {
4683
+ unrecognized = unrecognized ?? [];
4684
+ unrecognized.push(key);
4685
+ }
4686
+ }
4687
+ }
4688
+ if (unrecognized && unrecognized.length > 0) {
4689
+ payload.issues.push({
4690
+ code: "unrecognized_keys",
4691
+ input,
4692
+ inst,
4693
+ keys: unrecognized,
4694
+ continue: true
4695
+ });
4696
+ }
4697
+ } else {
4698
+ payload.value = memo ? memo.alloc(inst, payload, {}, ctx) : {};
4699
+ let unrecognized;
4700
+ for (const key of Reflect.ownKeys(input)) {
4701
+ if (key === "__proto__")
4702
+ continue;
4703
+ if (!Object.prototype.propertyIsEnumerable.call(input, key))
4704
+ continue;
4705
+ let keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx);
4706
+ if (keyResult instanceof Promise) {
4707
+ throw new Error("Async schemas not supported in object keys currently");
4708
+ }
4709
+ const checkNumericKey = typeof key === "string" && number.test(key) && keyResult.issues.length;
4710
+ if (checkNumericKey) {
4711
+ const retryResult = def.keyType._zod.run({ value: Number(key), issues: [] }, ctx);
4712
+ if (retryResult instanceof Promise) {
4713
+ throw new Error("Async schemas not supported in object keys currently");
4714
+ }
4715
+ if (retryResult.issues.length === 0) {
4716
+ keyResult = retryResult;
4717
+ }
4718
+ }
4719
+ if (keyResult.issues.length) {
4720
+ if (def.mode === "loose") {
4721
+ payload.value[key] = input[key];
4722
+ } else if (values) {
4723
+ unrecognized = unrecognized ?? [];
4724
+ unrecognized.push(key);
4725
+ } else {
4726
+ payload.issues.push({
4727
+ code: "invalid_key",
4728
+ origin: "record",
4729
+ issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())),
4730
+ input: key,
4731
+ path: [key],
4732
+ inst
4733
+ });
4734
+ }
4735
+ continue;
4736
+ }
4737
+ const outKey = keyResult.value;
4738
+ if (outKey === "__proto__")
4739
+ continue;
4740
+ const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx);
4741
+ if (result instanceof Promise) {
4742
+ proms.push(result.then((result) => {
4743
+ if (result.issues.length) {
4744
+ payload.issues.push(...prefixIssues(key, result.issues));
4745
+ }
4746
+ payload.value[outKey] = result.value;
4747
+ }));
4748
+ } else {
4749
+ if (result.issues.length) {
4750
+ payload.issues.push(...prefixIssues(key, result.issues));
4751
+ }
4752
+ payload.value[outKey] = result.value;
4753
+ }
4754
+ }
4755
+ if (unrecognized && unrecognized.length > 0) {
4756
+ payload.issues.push({
4757
+ code: "unrecognized_keys",
4758
+ input,
4759
+ inst,
4760
+ keys: unrecognized,
4761
+ continue: true
4762
+ });
4763
+ }
4764
+ }
4765
+ if (proms.length) {
4766
+ return Promise.all(proms).then(() => payload);
4767
+ }
4768
+ return payload;
4769
+ };
4770
+ });
4646
4771
  var $ZodEnum = /* @__PURE__ */ $constructor("$ZodEnum", (inst, def) => {
4647
4772
  $ZodType.init(inst, def);
4648
4773
  const values = getEnumValues(def.entries);
@@ -6683,7 +6808,112 @@ var intersectionProcessor = (schema, ctx, json, params) => {
6683
6808
  json.allOf = allOf;
6684
6809
  ctx.intersections.push(allOf);
6685
6810
  };
6811
+ function stringifyKeyNames(bySchema, json, visited) {
6812
+ if (json.$ref) {
6813
+ if (visited.has(json))
6814
+ return json;
6815
+ visited.add(json);
6816
+ const def = bySchema.get(json)?.def;
6817
+ if (!def)
6818
+ return json;
6819
+ const inlined = stringifyKeyNames(bySchema, def, visited);
6820
+ return inlined === def ? json : inlined;
6821
+ }
6822
+ for (const keyword of ["anyOf", "oneOf"]) {
6823
+ const branches = json[keyword];
6824
+ if (!Array.isArray(branches))
6825
+ continue;
6826
+ const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
6827
+ if (mapped.some((branch, i) => branch !== branches[i]))
6828
+ json = { ...json, [keyword]: mapped };
6829
+ }
6830
+ const types = Array.isArray(json.type) ? json.type : [json.type];
6831
+ const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
6832
+ const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
6833
+ if (!numericType && !values?.some((v) => typeof v === "number"))
6834
+ return json;
6835
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
6836
+ if (rest.enum)
6837
+ rest.enum = rest.enum.map((v) => typeof v === "number" ? String(v) : v);
6838
+ else if (typeof rest.const === "number")
6839
+ rest.const = String(rest.const);
6840
+ if (!numericType)
6841
+ return rest;
6842
+ rest.type = "string";
6843
+ if (!values)
6844
+ rest.pattern = (types.includes("number") ? number : integer).source;
6845
+ return rest;
6846
+ }
6686
6847
  var pendingRecords = new WeakMap;
6848
+ function rewriteKeyNames(ctx) {
6849
+ const bySchema = new Map;
6850
+ for (const entry of ctx.seen.values()) {
6851
+ if (entry.def && !bySchema.has(entry.schema))
6852
+ bySchema.set(entry.schema, entry);
6853
+ }
6854
+ const rewrites = new Map;
6855
+ for (const record of pendingRecords.get(ctx) ?? []) {
6856
+ const seen = ctx.seen.get(record);
6857
+ const names = (seen?.def ?? seen?.schema)?.propertyNames;
6858
+ if (!names || names === true || rewrites.has(names))
6859
+ continue;
6860
+ const rewritten = stringifyKeyNames(bySchema, names, new Set);
6861
+ if (rewritten !== names)
6862
+ rewrites.set(names, rewritten);
6863
+ }
6864
+ if (!rewrites.size)
6865
+ return;
6866
+ for (const entry of ctx.seen.values()) {
6867
+ for (const carrier of [entry.schema, entry.def]) {
6868
+ const rewritten = carrier && rewrites.get(carrier.propertyNames);
6869
+ if (rewritten)
6870
+ carrier.propertyNames = rewritten;
6871
+ }
6872
+ }
6873
+ }
6874
+ var recordProcessor = (schema, ctx, _json, params) => {
6875
+ const json = _json;
6876
+ const def = schema._zod.def;
6877
+ json.type = "object";
6878
+ const keyType = def.keyType;
6879
+ const patterns = aggregateChecks(keyType).patterns;
6880
+ if (def.mode === "loose" && patterns && patterns.size > 0) {
6881
+ const valueSchema = processSchema(def.valueType, ctx, {
6882
+ ...params,
6883
+ path: [...params.path, "patternProperties", "*"]
6884
+ });
6885
+ json.patternProperties = {};
6886
+ for (const pattern of patterns) {
6887
+ assignProp(json.patternProperties, exactPattern(pattern).source, valueSchema);
6888
+ }
6889
+ } else {
6890
+ if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") {
6891
+ json.propertyNames = processSchema(def.keyType, ctx, {
6892
+ ...params,
6893
+ path: [...params.path, "propertyNames"]
6894
+ });
6895
+ let pending = pendingRecords.get(ctx);
6896
+ if (!pending) {
6897
+ pending = [];
6898
+ pendingRecords.set(ctx, pending);
6899
+ ctx.deferred.push(() => rewriteKeyNames(ctx));
6900
+ }
6901
+ pending.push(schema);
6902
+ }
6903
+ json.additionalProperties = processSchema(def.valueType, ctx, {
6904
+ ...params,
6905
+ path: [...params.path, "additionalProperties"]
6906
+ });
6907
+ }
6908
+ const keyValues = keyType._zod.values;
6909
+ const omittableOnInput = ctx.io === "input" && inputOptin(def.valueType) !== undefined;
6910
+ if (keyValues && !def.partial && !omittableOnInput) {
6911
+ const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number");
6912
+ if (validKeyValues.length > 0) {
6913
+ json.required = validKeyValues.map(String);
6914
+ }
6915
+ }
6916
+ };
6687
6917
  var nullableProcessor = (schema, ctx, json, params) => {
6688
6918
  const def = schema._zod.def;
6689
6919
  const inner = processSchema(def.innerType, ctx, params);
@@ -7466,6 +7696,30 @@ function intersection(left, right) {
7466
7696
  right
7467
7697
  });
7468
7698
  }
7699
+ var ZodRecord = /* @__PURE__ */ $constructor("ZodRecord", (inst, def) => {
7700
+ _ensureDefaultMemoizer();
7701
+ $ZodRecord.init(inst, def);
7702
+ ZodType.init(inst, def);
7703
+ inst._zod.processJSONSchema = (ctx, json, params) => recordProcessor(inst, ctx, json, params);
7704
+ inst.keyType = def.keyType;
7705
+ inst.valueType = def.valueType;
7706
+ });
7707
+ function record(keyType, valueType, params) {
7708
+ if (!valueType || !valueType._zod) {
7709
+ return new ZodRecord({
7710
+ type: "record",
7711
+ keyType: string2(),
7712
+ valueType: keyType,
7713
+ ...normalizeParams(valueType)
7714
+ });
7715
+ }
7716
+ return new ZodRecord({
7717
+ type: "record",
7718
+ keyType,
7719
+ valueType,
7720
+ ...normalizeParams(params)
7721
+ });
7722
+ }
7469
7723
  var ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => {
7470
7724
  $ZodEnum.init(inst, def);
7471
7725
  ZodType.init(inst, def);
@@ -7704,23 +7958,108 @@ function refine(fn, _params = {}) {
7704
7958
  function superRefine(fn, params) {
7705
7959
  return _superRefine(fn, params);
7706
7960
  }
7961
+ // package.json
7962
+ var package_default = {
7963
+ name: "@deaduchiha/orbito",
7964
+ version: "0.1.2",
7965
+ description: "Deploy, follow logs and check migrations of apps on an Orbito server",
7966
+ license: "UNLICENSED",
7967
+ bin: {
7968
+ orbito: "dist/orbito.js"
7969
+ },
7970
+ files: [
7971
+ "dist",
7972
+ "config.schema.json"
7973
+ ],
7974
+ type: "module",
7975
+ exports: {
7976
+ "./config.schema.json": "./config.schema.json"
7977
+ },
7978
+ scripts: {
7979
+ build: "bun build src/main.ts --target node --outfile dist/orbito.js && bun run schema",
7980
+ schema: "bun scripts/write-schema.ts",
7981
+ test: "bun test",
7982
+ prepack: "bun run build"
7983
+ },
7984
+ engines: {
7985
+ node: ">=20"
7986
+ },
7987
+ devDependencies: {
7988
+ commander: "^14.0.0",
7989
+ zod: "^4.6.7"
7990
+ }
7991
+ };
7992
+
7707
7993
  // src/contracts.ts
7708
7994
  var API_VERSION = 1;
7709
7995
  var API_PREFIX = "/api/cli/v1";
7710
7996
  var CLI_CLIENT_ID = "orbito-cli";
7711
7997
  var APP_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
7712
7998
  var LEVELS = ["read", "deploy", "migrate"];
7999
+ var DesiredShape = {
8000
+ preset: string2().max(40).optional(),
8001
+ branch: string2().max(100).optional(),
8002
+ install: string2().max(10).optional(),
8003
+ build: string2().max(10).optional(),
8004
+ migrate: string2().max(10).optional(),
8005
+ restart: string2().max(10).optional(),
8006
+ pm2Name: string2().max(64).optional(),
8007
+ healthPath: string2().max(201).optional(),
8008
+ layout: string2().max(10).optional(),
8009
+ shared: array(string2().max(200)).max(50).optional(),
8010
+ vars: record(string2().max(64), string2().max(4096)).optional(),
8011
+ secrets: strictObject({ required: array(string2().max(64)).max(100) }).optional()
8012
+ };
8013
+ var Desired = strictObject(DesiredShape);
7713
8014
  var ConfigSchema = strictObject({
7714
8015
  $schema: string2().optional(),
7715
- version: literal(1, { error: "Unsupported orbito.json version: this CLI reads version 1" }),
8016
+ version: union([literal(1), literal(2)], {
8017
+ error: "Unsupported orbito.json version: this CLI reads versions 1 and 2"
8018
+ }),
7716
8019
  server: url({ protocol: /^https?$/, error: "server must be an http(s) URL" }),
7717
- appId: string2().regex(APP_ID, "appId must be an Orbito app id such as my-app")
8020
+ appId: string2().regex(APP_ID, "appId must be an Orbito app id such as my-app"),
8021
+ ...DesiredShape,
8022
+ dev: strictObject({
8023
+ database: _enum(["sqlite", "postgres"]).optional(),
8024
+ postgres: string2().regex(/^\d{2}(\.\d{1,2})?$/, "postgres must be a version such as 18.6").optional()
8025
+ }).optional()
8026
+ }).refine((c) => c.version === 2 || Object.keys(c).every((k) => !(k in DesiredShape) && k !== "dev"), {
8027
+ error: 'Build and runtime settings need "version": 2 in orbito.json'
8028
+ });
8029
+ function desiredOf(c) {
8030
+ return Object.fromEntries(Object.entries(c).filter(([k]) => (k in DesiredShape)));
8031
+ }
8032
+ var ManifestPlan = object({
8033
+ appId: string2(),
8034
+ changes: array(object({ op: _enum(["add", "change"]), what: string2(), detail: string2() })),
8035
+ blockers: array(string2()),
8036
+ upToDate: boolean2(),
8037
+ hash: string2()
8038
+ });
8039
+ var ApplyManifest = object({ desired: Desired, hash: string2().max(80) });
8040
+ var UnmanagedProject = object({
8041
+ project: string2(),
8042
+ name: string2(),
8043
+ suggestedPort: number2().nullable(),
8044
+ runtimes: array(string2())
7718
8045
  });
7719
8046
  var Capabilities = object({
7720
8047
  api: number2().int(),
7721
8048
  features: object({
7722
8049
  migrations: boolean2(),
7723
- stagedReleases: boolean2()
8050
+ stagedReleases: boolean2(),
8051
+ rollbackPlan: boolean2().optional(),
8052
+ envOnly: boolean2().optional(),
8053
+ envCommands: boolean2().optional(),
8054
+ usage: boolean2().optional(),
8055
+ dbBackups: boolean2().optional(),
8056
+ manifest: boolean2().optional(),
8057
+ domains: boolean2().optional(),
8058
+ buckets: boolean2().optional(),
8059
+ envValues: boolean2().optional(),
8060
+ environments: boolean2().optional(),
8061
+ export: boolean2().optional(),
8062
+ licence: boolean2().optional()
7724
8063
  })
7725
8064
  });
7726
8065
  var Identity = object({ kind: _enum(["user", "automation"]), label: string2() });
@@ -7773,12 +8112,100 @@ var SubmitDeploy = object({
7773
8112
  sha: string2().regex(/^[0-9a-f]{40}$/),
7774
8113
  recipeHash: string2().min(8).max(80),
7775
8114
  idempotencyKey: string2().regex(/^[A-Za-z0-9_-]{8,80}$/),
7776
- confirmMigrations: boolean2().optional()
8115
+ confirmMigrations: boolean2().optional(),
8116
+ envOnly: boolean2().optional()
7777
8117
  });
7778
8118
  var SubmitRollback = object({
7779
8119
  idempotencyKey: string2().regex(/^[A-Za-z0-9_-]{8,80}$/),
7780
8120
  deploymentId: string2().max(60).optional()
7781
8121
  });
8122
+ var EnvList = array(object({
8123
+ name: string2(),
8124
+ secret: boolean2(),
8125
+ updatedAt: string2(),
8126
+ value: string2().optional()
8127
+ }));
8128
+ var SetEnv = object({
8129
+ name: string2().max(200),
8130
+ value: string2(),
8131
+ secret: boolean2().optional()
8132
+ });
8133
+ var AppUsage = object({
8134
+ appId: string2(),
8135
+ range: string2(),
8136
+ measured: boolean2(),
8137
+ at: string2().nullable(),
8138
+ memoryBytes: number2().nullable(),
8139
+ memoryPeakBytes: number2().nullable(),
8140
+ cpuPercent: number2().nullable(),
8141
+ pids: number2().nullable(),
8142
+ restarts: number2().nullable(),
8143
+ oomKills: number2().nullable(),
8144
+ diskBytes: number2().nullable(),
8145
+ requests: number2().nullable().optional(),
8146
+ errors: number2().nullable().optional(),
8147
+ p50Ms: number2().nullable().optional(),
8148
+ p95Ms: number2().nullable().optional(),
8149
+ alerts: array(object({ kind: string2(), message: string2(), since: string2().nullable() })).optional(),
8150
+ points: array(object({
8151
+ at: string2(),
8152
+ memoryBytes: number2().nullable(),
8153
+ cpuPercent: number2().nullable(),
8154
+ p95Ms: number2().nullable().optional()
8155
+ }))
8156
+ });
8157
+ var RestoreDump = object({
8158
+ file: string2().max(100),
8159
+ confirm: string2().max(80)
8160
+ });
8161
+ var DomainList = object({
8162
+ domains: array(object({ domain: string2(), stage: string2(), detail: string2() })),
8163
+ applied: array(string2()).optional(),
8164
+ note: string2().nullable().optional()
8165
+ });
8166
+ var AddConnection = object({
8167
+ name: string2().max(40),
8168
+ endpoint: string2().max(300),
8169
+ region: string2().max(60),
8170
+ accessKey: string2().max(300),
8171
+ secretKey: string2().max(300)
8172
+ });
8173
+ var StorageConnection = object({
8174
+ name: string2(),
8175
+ endpoint: string2(),
8176
+ region: string2(),
8177
+ verifiedAt: string2().nullable()
8178
+ });
8179
+ var CreateBucket = object({ connection: string2().max(40), binding: string2().max(31).optional() });
8180
+ var AppBucket = object({
8181
+ binding: string2(),
8182
+ bucket: string2(),
8183
+ connection: string2(),
8184
+ bytes: number2().nullable(),
8185
+ objects: number2().nullable(),
8186
+ measuredAt: string2().nullable()
8187
+ });
8188
+ var CreateEnvironment = object({
8189
+ name: string2().max(12),
8190
+ port: number2().int().optional(),
8191
+ branch: string2().max(100).optional()
8192
+ });
8193
+ var AppEnvironment = object({
8194
+ id: string2(),
8195
+ environment: string2(),
8196
+ port: number2().nullable(),
8197
+ deployedSha: string2().nullable()
8198
+ });
8199
+ var LicenceState = object({
8200
+ limited: boolean2(),
8201
+ plan: string2(),
8202
+ customer: string2().nullable(),
8203
+ maxApps: number2().nullable(),
8204
+ used: number2(),
8205
+ expires: string2().nullable(),
8206
+ problem: string2().nullable()
8207
+ });
8208
+ var RollbackPlanRequest = object({ deploymentId: string2().max(60) });
7782
8209
  var DeployEvent = object({
7783
8210
  id: number2().int(),
7784
8211
  ts: string2(),
@@ -7809,10 +8236,13 @@ var EXIT = {
7809
8236
  invalid: 2,
7810
8237
  denied: 3,
7811
8238
  connect: 4,
8239
+ confirm: 5,
8240
+ conflict: 6,
7812
8241
  interrupted: 130
7813
8242
  };
7814
8243
 
7815
8244
  // src/runtime.ts
8245
+ import { spawn } from "node:child_process";
7816
8246
  import fs from "node:fs";
7817
8247
  import os from "node:os";
7818
8248
  import path from "node:path";
@@ -7845,6 +8275,44 @@ function realRuntime() {
7845
8275
  resolve(/^y(es)?$/i.test(a.trim()));
7846
8276
  });
7847
8277
  }),
8278
+ ask: (question) => new Promise((resolve) => {
8279
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
8280
+ rl.question(question, (a) => {
8281
+ rl.close();
8282
+ resolve(a);
8283
+ });
8284
+ }),
8285
+ color: Boolean(process.stdout.isTTY && !env.NO_COLOR && env.TERM !== "dumb") || Boolean(env.FORCE_COLOR),
8286
+ onEnter: (fn) => {
8287
+ const rl = readline.createInterface({ input: process.stdin });
8288
+ rl.once("line", () => {
8289
+ rl.close();
8290
+ fn();
8291
+ });
8292
+ return () => rl.close();
8293
+ },
8294
+ openUrl: (url) => {
8295
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
8296
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
8297
+ },
8298
+ run: (cmd, args, opts = {}) => new Promise((resolve, reject) => {
8299
+ const child = spawn(cmd, args, {
8300
+ cwd: process.cwd(),
8301
+ env: { ...env, ...opts.env },
8302
+ stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit"
8303
+ });
8304
+ let out = "";
8305
+ child.stdout?.on("data", (d) => out += String(d));
8306
+ child.stderr?.on("data", (d) => out += String(d));
8307
+ child.once("error", reject);
8308
+ child.once("close", (code) => resolve({ code: code ?? 1, out }));
8309
+ }),
8310
+ readStdin: async () => {
8311
+ const chunks = [];
8312
+ for await (const c of process.stdin)
8313
+ chunks.push(c);
8314
+ return Buffer.concat(chunks).toString("utf8");
8315
+ },
7848
8316
  onInterrupt: (fn) => {
7849
8317
  process.on("SIGINT", fn);
7850
8318
  return () => process.off("SIGINT", fn);
@@ -7893,13 +8361,28 @@ function loadToken(rt, server) {
7893
8361
  return null;
7894
8362
  return c.token;
7895
8363
  }
8364
+ var settingsFile = (rt) => path.join(rt.configDir, "settings.json");
8365
+ function savedServer(rt) {
8366
+ try {
8367
+ const s = JSON.parse(fs.readFileSync(settingsFile(rt), "utf8"));
8368
+ return s.server ? origin(s.server) : null;
8369
+ } catch {
8370
+ return null;
8371
+ }
8372
+ }
8373
+ function saveServer(rt, server) {
8374
+ fs.mkdirSync(rt.configDir, { recursive: true, mode: 448 });
8375
+ fs.writeFileSync(settingsFile(rt), JSON.stringify({ server: origin(server) }, null, 2), {
8376
+ mode: 384
8377
+ });
8378
+ }
7896
8379
 
7897
8380
  // src/client.ts
7898
8381
  class ApiFailure extends CliError {
7899
8382
  status;
7900
8383
  code;
7901
8384
  constructor(message, status, code) {
7902
- super(message, status === 502 || status === 503 || status === 504 ? EXIT.connect : status === 401 || status === 403 || status === 404 ? EXIT.denied : status === 400 || status === 426 ? EXIT.invalid : EXIT.failed);
8385
+ super(message, status === 502 || status === 503 || status === 504 ? EXIT.connect : status === 401 || status === 403 || status === 404 ? EXIT.denied : status === 409 ? EXIT.conflict : status === 400 || status === 426 ? EXIT.invalid : EXIT.failed);
7903
8386
  this.status = status;
7904
8387
  this.code = code;
7905
8388
  }
@@ -7963,20 +8446,270 @@ class Client {
7963
8446
  }
7964
8447
  }
7965
8448
 
7966
- // src/config.ts
8449
+ // src/completion.ts
8450
+ var shown = (c) => c.commands.filter((s) => s.name() !== "help");
8451
+ var nextWords = (c) => [
8452
+ ...shown(c).flatMap((s) => [s.name(), ...s.aliases()]),
8453
+ ...c.options.flatMap((o) => o.long ? [o.long] : [])
8454
+ ];
8455
+ function bash(program) {
8456
+ const cases = shown(program).map((c) => ` ${[c.name(), ...c.aliases()].join("|")}) words="${nextWords(c).join(" ")}" ;;`).join(`
8457
+ `);
8458
+ return `_orbito() {
8459
+ local cur="\${COMP_WORDS[COMP_CWORD]}" words="${nextWords(program).join(" ")}"
8460
+ if [ "$COMP_CWORD" -gt 1 ]; then
8461
+ case "\${COMP_WORDS[1]}" in
8462
+ ${cases}
8463
+ esac
8464
+ fi
8465
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
8466
+ }
8467
+ complete -F _orbito orbito
8468
+ `;
8469
+ }
8470
+ function fish(program) {
8471
+ const lines = [
8472
+ `complete -c orbito -f -n __fish_use_subcommand -a "${shown(program).flatMap((c) => [c.name(), ...c.aliases()]).join(" ")}"`
8473
+ ];
8474
+ for (const c of shown(program)) {
8475
+ const names = [c.name(), ...c.aliases()].join(" ");
8476
+ const subs = shown(c).map((s) => s.name());
8477
+ if (subs.length)
8478
+ lines.push(`complete -c orbito -f -n "__fish_seen_subcommand_from ${names}" -a "${subs.join(" ")}"`);
8479
+ for (const o of c.options)
8480
+ if (o.long)
8481
+ lines.push(`complete -c orbito -n "__fish_seen_subcommand_from ${names}" -l ${o.long.slice(2)}`);
8482
+ }
8483
+ return lines.join(`
8484
+ `) + `
8485
+ `;
8486
+ }
8487
+ function completionScript(shell, program) {
8488
+ if (shell === "bash")
8489
+ return bash(program);
8490
+ if (shell === "zsh")
8491
+ return `autoload -U +X compinit && compinit
8492
+ autoload -U +X bashcompinit && bashcompinit
8493
+ ${bash(program)}`;
8494
+ if (shell === "fish")
8495
+ return fish(program);
8496
+ return null;
8497
+ }
8498
+
8499
+ // src/dev.ts
8500
+ import crypto from "node:crypto";
7967
8501
  import fs2 from "node:fs";
8502
+ import net from "node:net";
7968
8503
  import path2 from "node:path";
8504
+ var ENV_FILE = ".env.local";
8505
+ var STATE_DIR = ".orbito";
8506
+ var DEFAULT_POSTGRES = "18.6";
8507
+ var SQLITE_FILE = "dev.db";
8508
+ var containerName = (c) => `orbito-dev-${c.appId}`;
8509
+ var stateFile = (rt) => path2.join(rt.cwd, STATE_DIR, "postgres.json");
8510
+ function packageManager(cwd) {
8511
+ if (["bun.lock", "bun.lockb"].some((f) => fs2.existsSync(path2.join(cwd, f))))
8512
+ return "bun";
8513
+ if (fs2.existsSync(path2.join(cwd, "pnpm-lock.yaml")))
8514
+ return "pnpm";
8515
+ return "npm";
8516
+ }
8517
+ function scriptsOf(cwd) {
8518
+ try {
8519
+ const pkg = JSON.parse(fs2.readFileSync(path2.join(cwd, "package.json"), "utf8"));
8520
+ return pkg.scripts ?? {};
8521
+ } catch {
8522
+ return {};
8523
+ }
8524
+ }
8525
+ function parseEnv(text) {
8526
+ const out = {};
8527
+ for (const line of text.split(/\r?\n/)) {
8528
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
8529
+ if (!m)
8530
+ continue;
8531
+ const v = m[2].trim();
8532
+ out[m[1]] = /^(['"]).*\1$/.test(v) ? v.slice(1, -1) : v;
8533
+ }
8534
+ return out;
8535
+ }
8536
+ var readEnvLocal = (rt) => {
8537
+ try {
8538
+ return parseEnv(fs2.readFileSync(path2.join(rt.cwd, ENV_FILE), "utf8"));
8539
+ } catch {
8540
+ return {};
8541
+ }
8542
+ };
8543
+ function writeEnvLocal(rt, values) {
8544
+ const file = path2.join(rt.cwd, ENV_FILE);
8545
+ const lines = fs2.existsSync(file) ? fs2.readFileSync(file, "utf8").split(`
8546
+ `) : [];
8547
+ const left = new Map(Object.entries(values));
8548
+ const out = lines.map((line) => {
8549
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1];
8550
+ if (!name || !left.has(name))
8551
+ return line;
8552
+ const v = left.get(name);
8553
+ left.delete(name);
8554
+ return `${name}=${v}`;
8555
+ });
8556
+ while (out.length > 0 && out[out.length - 1] === "")
8557
+ out.pop();
8558
+ for (const [k, v] of left)
8559
+ out.push(`${k}=${v}`);
8560
+ fs2.writeFileSync(file, `${out.join(`
8561
+ `)}
8562
+ `, { mode: 384 });
8563
+ fs2.chmodSync(file, 384);
8564
+ }
8565
+ async function ensureIgnored(rt) {
8566
+ const tracked = await rt.run("git", ["ls-files", "--error-unmatch", ENV_FILE], { capture: true }).catch(() => null);
8567
+ if (tracked?.code === 0)
8568
+ throw new CliError(`${ENV_FILE} is committed to git. Remove it first: git rm --cached ${ENV_FILE}`, EXIT.failed);
8569
+ const file = path2.join(rt.cwd, ".gitignore");
8570
+ const text = fs2.existsSync(file) ? fs2.readFileSync(file, "utf8") : "";
8571
+ const have = text.split(`
8572
+ `).map((l) => l.trim());
8573
+ const missing = [ENV_FILE, `${STATE_DIR}/`].filter((p) => !have.includes(p));
8574
+ if (missing.length > 0)
8575
+ fs2.writeFileSync(file, `${text}${text && !text.endsWith(`
8576
+ `) ? `
8577
+ ` : ""}${missing.join(`
8578
+ `)}
8579
+ `);
8580
+ }
8581
+ var isLocalUrl = (url) => {
8582
+ if (url.startsWith("file:"))
8583
+ return true;
8584
+ try {
8585
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
8586
+ } catch {
8587
+ return false;
8588
+ }
8589
+ };
8590
+ var freePort = () => new Promise((resolve, reject) => {
8591
+ const s = net.createServer();
8592
+ s.once("error", reject);
8593
+ s.listen(0, "127.0.0.1", () => {
8594
+ const { port } = s.address();
8595
+ s.close(() => resolve(port));
8596
+ });
8597
+ });
8598
+ async function docker(rt, args) {
8599
+ const r = await rt.run("docker", args, { capture: true }).catch(() => null);
8600
+ if (!r)
8601
+ throw new CliError("Docker is needed for a local PostgreSQL. Install and start it, or use SQLite.", EXIT.failed);
8602
+ return r;
8603
+ }
8604
+ async function startPostgres(rt, c, version) {
8605
+ const name = containerName(c);
8606
+ let state = null;
8607
+ try {
8608
+ state = JSON.parse(fs2.readFileSync(stateFile(rt), "utf8"));
8609
+ } catch {}
8610
+ const running = await docker(rt, ["inspect", "-f", "{{.State.Running}}", name]);
8611
+ if (running.code !== 0) {
8612
+ state ??= { password: crypto.randomBytes(18).toString("base64url"), port: await freePort() };
8613
+ const data = Number(version.split(".")[0]) >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data";
8614
+ const made = await docker(rt, [
8615
+ "run",
8616
+ "-d",
8617
+ "--name",
8618
+ name,
8619
+ "-e",
8620
+ `POSTGRES_PASSWORD=${state.password}`,
8621
+ "-e",
8622
+ "POSTGRES_DB=app",
8623
+ "-p",
8624
+ `127.0.0.1:${state.port}:5432`,
8625
+ "-v",
8626
+ `${name}:${data}`,
8627
+ `postgres:${version}-alpine`
8628
+ ]);
8629
+ if (made.code !== 0)
8630
+ throw new CliError(`Could not start PostgreSQL: ${made.out}`, EXIT.failed);
8631
+ fs2.mkdirSync(path2.dirname(stateFile(rt)), { recursive: true });
8632
+ fs2.writeFileSync(stateFile(rt), JSON.stringify(state), { mode: 384 });
8633
+ } else if (running.out.trim() !== "true") {
8634
+ await docker(rt, ["start", name]);
8635
+ }
8636
+ if (!state)
8637
+ throw new CliError(`The container ${name} exists but ${stateFile(rt)} is gone. Run: orbito db reset`, EXIT.failed);
8638
+ for (let i = 0;i < 90; i++) {
8639
+ const ready = await docker(rt, ["exec", name, "pg_isready", "-U", "postgres", "-d", "app", "-h", "127.0.0.1"]);
8640
+ if (ready.code === 0)
8641
+ return `postgresql://postgres:${encodeURIComponent(state.password)}@127.0.0.1:${state.port}/app`;
8642
+ await rt.sleep(1000);
8643
+ }
8644
+ throw new CliError("PostgreSQL did not become ready in time.", EXIT.failed);
8645
+ }
8646
+ async function setupDatabase(rt, c) {
8647
+ await ensureIgnored(rt);
8648
+ const existing = readEnvLocal(rt).DATABASE_URL;
8649
+ const engine = c.dev?.database;
8650
+ const managedUrl = existing?.startsWith(`file:./${STATE_DIR}/`);
8651
+ if (existing && !isLocalUrl(existing))
8652
+ throw new CliError("DATABASE_URL in .env.local is not on this machine. orbito dev never connects to a remote database. Remove it, then run this again.", EXIT.failed);
8653
+ if (existing && !managedUrl && !fs2.existsSync(stateFile(rt)))
8654
+ return { engine: "external", url: existing };
8655
+ if (engine === "postgres") {
8656
+ const url = await startPostgres(rt, c, c.dev?.postgres ?? DEFAULT_POSTGRES);
8657
+ writeEnvLocal(rt, { DATABASE_URL: url });
8658
+ return { engine: "postgres", url };
8659
+ }
8660
+ if (engine === "sqlite") {
8661
+ fs2.mkdirSync(path2.join(rt.cwd, STATE_DIR), { recursive: true });
8662
+ const url = `file:./${STATE_DIR}/${SQLITE_FILE}`;
8663
+ writeEnvLocal(rt, { DATABASE_URL: url });
8664
+ return { engine: "sqlite", url };
8665
+ }
8666
+ return { engine: "none", url: null };
8667
+ }
8668
+ async function runScript(rt, script) {
8669
+ if (!scriptsOf(rt.cwd)[script])
8670
+ return null;
8671
+ const pm = packageManager(rt.cwd);
8672
+ const r = await rt.run(pm, ["run", script], { env: readEnvLocal(rt) });
8673
+ return r.code;
8674
+ }
8675
+ async function dropDatabase(rt, c) {
8676
+ if (c.dev?.database === "postgres") {
8677
+ const name = containerName(c);
8678
+ await docker(rt, ["rm", "-f", name]);
8679
+ await docker(rt, ["volume", "rm", "-f", name]);
8680
+ fs2.rmSync(stateFile(rt), { force: true });
8681
+ const file = path2.join(rt.cwd, ENV_FILE);
8682
+ if (fs2.existsSync(file))
8683
+ fs2.writeFileSync(file, fs2.readFileSync(file, "utf8").replace(/^\s*(?:export\s+)?DATABASE_URL=.*\n?/m, ""));
8684
+ } else if (c.dev?.database === "sqlite") {
8685
+ for (const suffix of ["", "-wal", "-shm"])
8686
+ fs2.rmSync(path2.join(rt.cwd, STATE_DIR, `${SQLITE_FILE}${suffix}`), { force: true });
8687
+ } else {
8688
+ throw new CliError('Say which local database this app uses: "dev": { "database": "sqlite" } in orbito.json.', EXIT.invalid);
8689
+ }
8690
+ }
8691
+ function shellCommand(c, cwd) {
8692
+ if (c.dev?.database === "postgres")
8693
+ return ["docker", ["exec", "-it", containerName(c), "psql", "-U", "postgres", "-d", "app"]];
8694
+ if (c.dev?.database === "sqlite")
8695
+ return ["sqlite3", [path2.join(cwd, STATE_DIR, SQLITE_FILE)]];
8696
+ throw new CliError('Say which local database this app uses: "dev": { "database": "sqlite" } in orbito.json.', EXIT.invalid);
8697
+ }
8698
+
8699
+ // src/config.ts
8700
+ import fs3 from "node:fs";
8701
+ import path3 from "node:path";
7969
8702
  var CONFIG_FILE = "orbito.json";
7970
8703
  function configPath(rt, explicit) {
7971
- return path2.resolve(rt.cwd, explicit ?? CONFIG_FILE);
8704
+ return path3.resolve(rt.cwd, explicit ?? CONFIG_FILE);
7972
8705
  }
7973
8706
  function loadConfig(rt, explicit) {
7974
8707
  const file = configPath(rt, explicit);
7975
8708
  let raw;
7976
8709
  try {
7977
- raw = fs2.readFileSync(file, "utf8");
8710
+ raw = fs3.readFileSync(file, "utf8");
7978
8711
  } catch {
7979
- throw new CliError(`No ${path2.basename(file)} found at ${file}. Run: orbito init --app <id>`, EXIT.invalid);
8712
+ throw new CliError(`No ${path3.basename(file)} found at ${file}. Run: orbito init --app <id>`, EXIT.invalid);
7980
8713
  }
7981
8714
  let json;
7982
8715
  try {
@@ -7995,11 +8728,28 @@ ${lines.join(`
7995
8728
  }
7996
8729
  function writeConfig(rt, config, explicit) {
7997
8730
  const file = configPath(rt, explicit);
7998
- fs2.writeFileSync(file, JSON.stringify(config, null, 2) + `
8731
+ fs3.writeFileSync(file, JSON.stringify(config, null, 2) + `
7999
8732
  `);
8000
8733
  return file;
8001
8734
  }
8002
8735
 
8736
+ // src/ui.ts
8737
+ function styles(rt) {
8738
+ const on = rt.color === true;
8739
+ const wrap = (open, close) => (t) => on ? `\x1B[${open}m${t}\x1B[${close}m` : t;
8740
+ return {
8741
+ orange: wrap("38;5;208", "39"),
8742
+ bold: wrap("1", "22"),
8743
+ dim: wrap("2", "22"),
8744
+ green: wrap("32", "39")
8745
+ };
8746
+ }
8747
+ function table(rows, gap = 2) {
8748
+ const len = (t) => t.replace(/\x1b\[[0-9;]*m/g, "").length;
8749
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => len(r[i] ?? ""))));
8750
+ return rows.map((r) => r.map((c, i) => i === r.length - 1 ? c : c + " ".repeat(widths[i] - len(c) + gap)).join(""));
8751
+ }
8752
+
8003
8753
  // src/cli.ts
8004
8754
  var VERSION = package_default.version;
8005
8755
  var DEPLOY_SCRIPTS = {
@@ -8011,7 +8761,24 @@ var DEPLOY_SCRIPTS = {
8011
8761
  async function run(argv, rt) {
8012
8762
  const program = new Command2;
8013
8763
  let exit = EXIT.ok;
8014
- program.name("orbito").version(VERSION).option("--config <path>", "path to orbito.json (for monorepos)").option("--json", "machine-readable output on stdout (newline-delimited for streams)").exitOverride().configureOutput({ writeOut: rt.out, writeErr: rt.err });
8764
+ program.name("orbito").version(`${VERSION} (CLI API ${API_VERSION})`).option("--config <path>", "path to orbito.json (for monorepos)").option("--json", "machine-readable output on stdout (newline-delimited for streams)").option("--env <name>", "act on another environment of the app, for example staging").exitOverride().configureOutput({ writeOut: rt.out, writeErr: rt.err }).addHelpText("after", `
8765
+ Examples:
8766
+ orbito login
8767
+ orbito whoami
8768
+ orbito deploy --dry-run
8769
+ orbito deploy --yes --json
8770
+ orbito logs --lines 50
8771
+
8772
+ Exit codes:
8773
+ 0 success
8774
+ 1 the operation failed
8775
+ 2 invalid usage or configuration
8776
+ 3 not logged in, or no access
8777
+ 4 server unreachable
8778
+ 5 needs confirmation (no terminal and no --yes, or answered no)
8779
+ 6 refused by the server because of the current state
8780
+ 130 stopped following with Ctrl+C (the job continues on the server)
8781
+ `);
8015
8782
  const g = () => program.opts();
8016
8783
  const emit = (v) => rt.out(JSON.stringify(v) + `
8017
8784
  `);
@@ -8019,11 +8786,32 @@ async function run(argv, rt) {
8019
8786
  `);
8020
8787
  const say = (s) => g().json ? info(s) : rt.out(s + `
8021
8788
  `);
8022
- const cfg = () => loadConfig(rt, g().config);
8789
+ const confirmOr = async (question, yes) => {
8790
+ if (yes)
8791
+ return;
8792
+ if (!rt.isTTY)
8793
+ throw new CliError("Not a terminal: pass --yes to accept the plan.", EXIT.confirm);
8794
+ if (!await rt.confirm(question))
8795
+ throw new CliError("Cancelled.", EXIT.confirm);
8796
+ };
8797
+ const cfg = () => {
8798
+ const c = loadConfig(rt, g().config);
8799
+ const app = rt.env.ORBITO_APP;
8800
+ if (app && !APP_ID.test(app))
8801
+ throw new CliError(`ORBITO_APP is not an app id: ${app}`, EXIT.invalid);
8802
+ const base = app || c.appId;
8803
+ const env = g().env;
8804
+ if (!env || env === "production")
8805
+ return { ...c, appId: base };
8806
+ const appId = `${base}-${env}`;
8807
+ if (!APP_ID.test(appId))
8808
+ throw new CliError(`'${env}' is not an environment name.`, EXIT.invalid);
8809
+ return { ...c, appId };
8810
+ };
8023
8811
  const clientFor = (server, needToken = true) => {
8024
8812
  const token = loadToken(rt, server);
8025
8813
  if (needToken && !token)
8026
- throw new CliError(`Not logged in to ${server}. Run: orbito login --server ${server}`, EXIT.denied);
8814
+ throw new CliError(`Not logged in to ${server}. Run: orbito login`, EXIT.denied);
8027
8815
  return new Client(rt, server, token);
8028
8816
  };
8029
8817
  const authed = () => {
@@ -8032,14 +8820,39 @@ async function run(argv, rt) {
8032
8820
  };
8033
8821
  async function checkServer(api) {
8034
8822
  const caps = Capabilities.safeParse(await api.call("GET", "/capabilities"));
8035
- if (!caps.success || caps.data.api !== API_VERSION)
8823
+ if (!caps.success)
8036
8824
  throw new CliError(`This server does not speak CLI API ${API_VERSION}. Update the CLI or the server.`, EXIT.invalid);
8825
+ if (caps.data.api > API_VERSION)
8826
+ throw new CliError(`This server speaks CLI API ${caps.data.api}, this CLI speaks ${API_VERSION}. Update the CLI: npm install -g @deaduchiha/orbito`, EXIT.invalid);
8827
+ if (caps.data.api < API_VERSION)
8828
+ throw new CliError(`This server speaks CLI API ${caps.data.api}, this CLI speaks ${API_VERSION}. Update the server: orbito-update`, EXIT.invalid);
8037
8829
  return caps.data;
8038
8830
  }
8039
- program.command("login").description("authorize this machine through your browser").requiredOption("--server <url>", "Orbito server, for example https://orbito.example.com").action(async (o) => {
8831
+ async function resolveServer(flag) {
8832
+ let raw = flag || rt.env.ORBITO_SERVER;
8833
+ if (!raw) {
8834
+ try {
8835
+ raw = loadConfig(rt, g().config).server;
8836
+ } catch {}
8837
+ }
8838
+ raw ||= savedServer(rt) ?? undefined;
8839
+ if (!raw) {
8840
+ if (!rt.isTTY)
8841
+ throw new CliError("No server. Set ORBITO_SERVER or pass --server <url>.", EXIT.invalid);
8842
+ raw = (await rt.ask("Orbito server (for example orbito.example.com): ")).trim();
8843
+ if (!raw)
8844
+ throw new CliError("Cancelled.", EXIT.invalid);
8845
+ }
8846
+ try {
8847
+ return new URL(/^https?:\/\//.test(raw) ? raw : `https://${raw}`).origin;
8848
+ } catch {
8849
+ throw new CliError("The server must be a URL, for example https://orbito.example.com", EXIT.invalid);
8850
+ }
8851
+ }
8852
+ async function loginTo(serverUrl) {
8040
8853
  let server;
8041
8854
  try {
8042
- server = new URL(o.server);
8855
+ server = new URL(serverUrl);
8043
8856
  } catch {
8044
8857
  throw new CliError("--server must be a URL", EXIT.invalid);
8045
8858
  }
@@ -8048,7 +8861,10 @@ async function run(argv, rt) {
8048
8861
  try {
8049
8862
  return await rt.fetch(auth(p), {
8050
8863
  method: "POST",
8051
- headers: { "content-type": "application/json", origin: server.origin },
8864
+ headers: {
8865
+ "content-type": "application/json",
8866
+ origin: server.origin
8867
+ },
8052
8868
  body: JSON.stringify(body)
8053
8869
  });
8054
8870
  } catch (e) {
@@ -8062,43 +8878,60 @@ async function run(argv, rt) {
8062
8878
  if (!start.ok)
8063
8879
  throw new CliError(`The server refused the login (${start.status}).`, EXIT.denied);
8064
8880
  const d = await start.json();
8065
- info(`Open ${d.verification_uri_complete ?? d.verification_uri} and check that the code is ${d.user_code}.`);
8881
+ const link = d.verification_uri_complete ?? d.verification_uri;
8882
+ info(`Open ${link} and check that the code is ${d.user_code}.`);
8066
8883
  info("If you are asked to sign in, sign in and open the link again.");
8067
- let wait = Math.max(1, d.interval) * 1000;
8068
- const until = Date.now() + d.expires_in * 1000;
8069
- while (Date.now() < until) {
8070
- await rt.sleep(wait);
8071
- const t = await post("/device/token", {
8072
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
8073
- device_code: d.device_code,
8074
- client_id: CLI_CLIENT_ID
8075
- });
8076
- const body = await t.json().catch(() => ({}));
8077
- if (t.ok && body.access_token) {
8078
- const ex = await new Client(rt, server.origin, null).call("POST", "/auth/exchange", {
8079
- sessionToken: body.access_token,
8080
- label: `orbito cli on ${rt.hostname}`
8081
- });
8082
- saveCredential(rt, server.origin, { token: ex.token, expiresAt: ex.expiresAt });
8083
- say(`Logged in to ${server.origin}. Apps: ${ex.apps.map((a) => `${a.id} (${a.level})`).join(", ") || "none yet"}`);
8084
- if (g().json)
8085
- emit({ type: "login", server: server.origin, apps: ex.apps });
8086
- return;
8884
+ let stopEnter = () => {};
8885
+ try {
8886
+ if (rt.isTTY && rt.onEnter && rt.openUrl && new URL(link).origin === server.origin) {
8887
+ info("Press Enter to open it in your browser.");
8888
+ stopEnter = rt.onEnter(() => rt.openUrl(link));
8087
8889
  }
8088
- if (body.error === "authorization_pending")
8089
- continue;
8090
- if (body.error === "slow_down" || t.status === 429) {
8091
- wait += 5000;
8092
- continue;
8890
+ } catch {}
8891
+ try {
8892
+ let wait = Math.max(1, d.interval) * 1000;
8893
+ const until = Date.now() + d.expires_in * 1000;
8894
+ while (Date.now() < until) {
8895
+ await rt.sleep(wait);
8896
+ const t = await post("/device/token", {
8897
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
8898
+ device_code: d.device_code,
8899
+ client_id: CLI_CLIENT_ID
8900
+ });
8901
+ const body = await t.json().catch(() => ({}));
8902
+ if (t.ok && body.access_token) {
8903
+ const ex = await new Client(rt, server.origin, null).call("POST", "/auth/exchange", {
8904
+ sessionToken: body.access_token,
8905
+ label: `orbito cli on ${rt.hostname}`
8906
+ });
8907
+ saveServer(rt, server.origin);
8908
+ saveCredential(rt, server.origin, {
8909
+ token: ex.token,
8910
+ expiresAt: ex.expiresAt
8911
+ });
8912
+ say(`Logged in to ${server.origin}. Apps: ${ex.apps.map((a) => `${a.id} (${a.level})`).join(", ") || "none yet"}`);
8913
+ if (g().json)
8914
+ emit({ type: "login", server: server.origin, apps: ex.apps });
8915
+ return;
8916
+ }
8917
+ if (body.error === "authorization_pending")
8918
+ continue;
8919
+ if (body.error === "slow_down" || t.status === 429) {
8920
+ wait += 5000;
8921
+ continue;
8922
+ }
8923
+ if (body.error === "access_denied")
8924
+ throw new CliError("The login was denied in the browser.", EXIT.denied);
8925
+ if (body.error === "expired_token")
8926
+ break;
8927
+ throw new CliError(`Login failed: ${body.error ?? t.status}`, EXIT.failed);
8093
8928
  }
8094
- if (body.error === "access_denied")
8095
- throw new CliError("The login was denied in the browser.", EXIT.denied);
8096
- if (body.error === "expired_token")
8097
- break;
8098
- throw new CliError(`Login failed: ${body.error ?? t.status}`, EXIT.failed);
8929
+ throw new CliError("The login code expired. Run orbito login again.", EXIT.denied);
8930
+ } finally {
8931
+ stopEnter();
8099
8932
  }
8100
- throw new CliError("The login code expired. Run orbito login again.", EXIT.denied);
8101
- });
8933
+ }
8934
+ program.command("login").description("authorize this machine through your browser").option("--server <url>", "Orbito server; defaults to orbito.json, then the last server used, then asks").action(async (o) => loginTo(await resolveServer(o.server)));
8102
8935
  program.command("logout").description("revoke and remove the machine credential").option("--server <url>", "defaults to the server in orbito.json").action(async (o) => {
8103
8936
  const server = o.server ?? cfg().server;
8104
8937
  const token = loadToken(rt, server);
@@ -8113,57 +8946,130 @@ async function run(argv, rt) {
8113
8946
  forgetCredential(rt, server);
8114
8947
  } catch {}
8115
8948
  say("Logged out.");
8949
+ if (g().json)
8950
+ emit({ type: "logout", server });
8116
8951
  });
8117
- program.command("whoami").description("show server, identity and granted apps").action(async () => {
8118
- const { c, api } = authed();
8952
+ program.command("whoami").description("show who you are, the server, and your access").option("--all", "list every app you can reach").action(async (o) => {
8953
+ let c;
8954
+ let api;
8955
+ try {
8956
+ ({ c, api } = authed());
8957
+ } catch (e) {
8958
+ if (!rt.env.ORBITO_SERVER && !savedServer(rt))
8959
+ throw e;
8960
+ c = { server: await resolveServer() };
8961
+ api = clientFor(c.server);
8962
+ }
8119
8963
  const me = await api.call("GET", "/me");
8120
8964
  if (g().json)
8121
8965
  return emit({ server: c.server, ...me });
8122
- rt.out(`${me.identity.label} (${me.identity.kind}) on ${c.server}
8123
- `);
8966
+ const st = styles(rt);
8967
+ const host = new URL(c.server).host;
8968
+ const here = me.apps.find((a) => a.id === c.appId);
8969
+ const levels = new Map;
8124
8970
  for (const a of me.apps)
8125
- rt.out(` ${a.id} ${a.level} ${a.name}
8971
+ levels.set(a.level, (levels.get(a.level) ?? 0) + 1);
8972
+ const summary = [...levels].map(([l, n]) => `${n} ${l}`).join(", ");
8973
+ const rows = [
8974
+ ["Account", `${st.bold(me.identity.label)} ${st.dim(`(${me.identity.kind})`)}`],
8975
+ ["Server", host],
8976
+ ...c.appId ? [
8977
+ [
8978
+ "Project",
8979
+ here ? `${st.orange(here.id)} ${st.dim("·")} ${here.level}` : `${c.appId} ${st.dim("· no access")}`
8980
+ ]
8981
+ ] : [],
8982
+ [
8983
+ "Access",
8984
+ `${me.apps.length} app${me.apps.length === 1 ? "" : "s"} ${st.dim(summary ? `· ${summary}` : "")}`
8985
+ ]
8986
+ ];
8987
+ rt.out(`
8988
+ ${st.orange("◆")} ${st.bold("Orbito")}
8989
+
8990
+ `);
8991
+ for (const l of table(rows.map(([k, v]) => [st.dim(k), v])))
8992
+ rt.out(` ${l}
8993
+ `);
8994
+ if (o.all) {
8995
+ rt.out(`
8996
+ `);
8997
+ const head = ["APP", "ACCESS", "NAME"].map((h) => st.dim(h));
8998
+ const body = me.apps.map((a) => [
8999
+ a.id === c.appId ? st.orange(`● ${a.id}`) : ` ${a.id}`,
9000
+ a.level,
9001
+ a.name
9002
+ ]);
9003
+ for (const l of table([head.map((h, i) => i === 0 ? ` ${h}` : h), ...body]))
9004
+ rt.out(` ${l}
9005
+ `);
9006
+ } else if (me.apps.length > 1) {
9007
+ rt.out(`
9008
+ ${st.dim("Run")} orbito whoami --all ${st.dim("to list every app.")}
8126
9009
  `);
8127
- });
8128
- program.command("init").description("validate access and create orbito.json").requiredOption("--app <id>", "the app id shown in the Orbito dashboard").option("--server <url>", "Orbito server (defaults to the one in an existing orbito.json)").option("--scripts", "add deploy, tail, deploy:status and db:remote:status to package.json").action(async (o) => {
8129
- let server = o.server;
8130
- if (!server) {
8131
- try {
8132
- server = loadConfig(rt, g().config).server;
8133
- } catch {
8134
- throw new CliError("Pass --server <url> the first time.", EXIT.invalid);
8135
- }
8136
9010
  }
8137
- const api = clientFor(server);
8138
- const me = await api.call("GET", "/me");
8139
- const mine = me.apps.find((a) => a.id === o.app);
9011
+ rt.out(`
9012
+ `);
9013
+ });
9014
+ program.command("init").description("link this repository to an Orbito app (asks what it needs)").option("--app <id>", "the app id; omit to pick from the apps you can reach").option("--server <url>", "Orbito server (defaults to the one in an existing orbito.json)").option("--scripts", "add deploy, tail, deploy:status and db:remote:status to package.json").action(async (o) => {
9015
+ const server = await resolveServer(o.server);
9016
+ if (!loadToken(rt, server)) {
9017
+ info(`Not logged in to ${server} yet, logging in first.`);
9018
+ await loginTo(server);
9019
+ }
9020
+ const me = await clientFor(server).call("GET", "/me");
9021
+ let app = o.app;
9022
+ if (!app) {
9023
+ if (me.apps.length === 0)
9024
+ throw new CliError("You have no apps on this server yet. Ask an administrator for access.", EXIT.denied);
9025
+ if (me.apps.length === 1)
9026
+ app = me.apps[0].id;
9027
+ else {
9028
+ const list = me.apps.map((a, i) => ` ${i + 1}) ${a.id} (${a.level}) ${a.name}`).join(`
9029
+ `);
9030
+ if (!rt.isTTY)
9031
+ throw new CliError(`Pass --app <id>. Apps you can use:
9032
+ ${list}`, EXIT.invalid);
9033
+ info(`Which app is this repository?
9034
+ ${list}`);
9035
+ const n = Number((await rt.ask("Number: ")).trim());
9036
+ if (!Number.isInteger(n) || n < 1 || n > me.apps.length)
9037
+ throw new CliError("Cancelled.", EXIT.invalid);
9038
+ app = me.apps[n - 1].id;
9039
+ }
9040
+ }
9041
+ const mine = me.apps.find((a) => a.id === app);
8140
9042
  if (!mine)
8141
- throw new CliError(`You have no access to an app named '${o.app}'. If an administrator just gave you access, run: orbito login --server ${server}`, EXIT.denied);
9043
+ throw new CliError(`You have no access to an app named '${app}'. Apps you can use: ${me.apps.map((a) => a.id).join(", ") || "none"}. If an administrator just gave you access, run: orbito login --server ${server}`, EXIT.denied);
8142
9044
  const file = writeConfig(rt, {
8143
- $schema: "./node_modules/@orbito/cli/config.schema.json",
9045
+ $schema: "./node_modules/@deaduchiha/orbito/config.schema.json",
8144
9046
  version: 1,
8145
- server: new URL(server).origin,
8146
- appId: o.app
9047
+ server,
9048
+ appId: app
8147
9049
  }, g().config);
8148
- say(`Wrote ${file} (you have '${mine.level}' on ${o.app}). Commit it; credentials stay outside the repository.`);
8149
- const pkgFile = path3.join(path3.dirname(file), "package.json");
8150
- if (fs3.existsSync(pkgFile)) {
8151
- const pkg = JSON.parse(fs3.readFileSync(pkgFile, "utf8"));
9050
+ say(`Linked to '${app}' on ${server} (you have '${mine.level}'). Wrote ${file}; commit it, credentials stay outside the repository.`);
9051
+ const pkgFile = path4.join(path4.dirname(file), "package.json");
9052
+ if (fs4.existsSync(pkgFile)) {
9053
+ const pkg = JSON.parse(fs4.readFileSync(pkgFile, "utf8"));
8152
9054
  const add = Object.entries(DEPLOY_SCRIPTS).filter(([k]) => !pkg.scripts?.[k]);
8153
9055
  if (add.length > 0) {
8154
9056
  say(`package.json scripts to add:
8155
9057
  ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8156
9058
  `)}`);
8157
- const yes = o.scripts || rt.isTTY && await rt.confirm("Add them?");
9059
+ const yes = o.scripts || rt.isTTY && !/^n/i.test((await rt.ask("Add them? [Y/n] ")).trim());
8158
9060
  if (yes) {
8159
9061
  pkg.scripts = { ...pkg.scripts, ...Object.fromEntries(add) };
8160
- fs3.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + `
9062
+ fs4.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + `
8161
9063
  `);
8162
9064
  say("Added.");
8163
9065
  } else
8164
9066
  say("Not added. Pass --scripts to add them.");
8165
9067
  }
8166
9068
  }
9069
+ say(`
9070
+ Next: orbito doctor then orbito deploy --dry-run`);
9071
+ if (g().json)
9072
+ emit({ type: "init", server, appId: app, level: mine.level, file });
8167
9073
  });
8168
9074
  const configCmd = program.command("config").description("configuration commands");
8169
9075
  configCmd.command("validate").description("validate orbito.json and server compatibility").action(async () => {
@@ -8173,6 +9079,34 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8173
9079
  if (g().json)
8174
9080
  emit({ ok: true, server: c.server, appId: c.appId, api: caps.api });
8175
9081
  });
9082
+ configCmd.command("show").description("show each setting and where it came from (never prints a token)").action(() => {
9083
+ const c = cfg();
9084
+ const file = configPath(rt, g().config);
9085
+ const settings = {
9086
+ server: { value: c.server, source: file },
9087
+ appId: { value: c.appId, source: rt.env.ORBITO_APP ? "ORBITO_APP" : file },
9088
+ token: {
9089
+ source: rt.env.ORBITO_TOKEN ? "ORBITO_TOKEN" : loadToken(rt, c.server) ? "saved login" : "none"
9090
+ }
9091
+ };
9092
+ if (g().json)
9093
+ return emit({ type: "config", ...settings });
9094
+ rt.out(`server ${c.server} (${settings.server.source})
9095
+ `);
9096
+ rt.out(`appId ${c.appId} (${settings.appId.source})
9097
+ `);
9098
+ rt.out(`token ${settings.token.source}
9099
+ `);
9100
+ });
9101
+ program.command("completion").description("print a shell completion script").argument("<shell>", "bash, zsh or fish").addHelpText("after", `
9102
+ Example:
9103
+ orbito completion zsh >> ~/.zshrc
9104
+ `).action((shell) => {
9105
+ const script = completionScript(shell, program);
9106
+ if (!script)
9107
+ throw new CliError("Supported shells: bash, zsh, fish.", EXIT.invalid);
9108
+ rt.out(script);
9109
+ });
8176
9110
  program.command("status").description("deployed commit, health and active job").action(async () => {
8177
9111
  const { c, api } = authed();
8178
9112
  const s = await api.call("GET", `/apps/${c.appId}/status`, undefined, AppStatus);
@@ -8231,12 +9165,14 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8231
9165
  if (g().json)
8232
9166
  emit({ type: "result", ...d });
8233
9167
  else
8234
- say(d.status === "deployed" ? `Done: ${d.sha?.slice(0, 12) ?? ""} is live.` : `${d.status}: ${d.reason ?? ""}`);
9168
+ say(d.status === "deployed" ? `Done: ${d.sha?.slice(0, 12) ?? ""} is live.${d.reason ? ` ${d.reason}` : ""}` : `${d.status}: ${d.reason ?? ""}`);
8235
9169
  return d.status === "deployed" ? EXIT.ok : EXIT.failed;
8236
9170
  }
8237
- program.command("deploy").description("plan, confirm, deploy and follow progress").option("--dry-run", "show the target commit, recipe, migrations and blockers; change nothing").option("--detach", "submit and return the deployment id").option("--yes", "accept the displayed plan without asking (never skips permissions or blockers)").action(async (o) => {
9171
+ program.command("deploy").description("plan, confirm, deploy and follow progress").option("--dry-run", "show the target commit, recipe, migrations and blockers; change nothing").option("--detach", "submit and return the deployment id").option("--yes", "accept the displayed plan without asking (never skips permissions or blockers)").option("--env-only", "apply the variables only: keep the running commit, run no migrations").action(async (o) => {
8238
9172
  const { c, api } = authed();
8239
9173
  const caps = await checkServer(api);
9174
+ if (o.envOnly && !caps.features.envOnly)
9175
+ throw new CliError("This server cannot apply variables only. Update the server: orbito-update", EXIT.invalid);
8240
9176
  const plan = await api.call("POST", `/apps/${c.appId}/deploy/plan`, {}, DeployPlan);
8241
9177
  if (g().json)
8242
9178
  emit({ type: "plan", ...plan });
@@ -8248,7 +9184,10 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8248
9184
  if (plan.recipe)
8249
9185
  rt.out(` recipe install ${plan.recipe.install}, build ${plan.recipe.build}, migrate ${plan.recipe.migrate}, restart ${plan.recipe.restart}
8250
9186
  `);
8251
- rt.out(` migration ${plan.migration.required ? `yes (needs '${plan.migration.neededLevel}')` : "no"}
9187
+ rt.out(` migration ${plan.migration.required && !o.envOnly ? `yes (needs '${plan.migration.neededLevel}')` : "no"}
9188
+ `);
9189
+ if (o.envOnly)
9190
+ rt.out(` NOTE variables only: the running commit stays.
8252
9191
  `);
8253
9192
  for (const b of plan.blockers)
8254
9193
  rt.out(` BLOCKED ${b}
@@ -8265,19 +9204,15 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8265
9204
  return;
8266
9205
  if (!plan.sha)
8267
9206
  throw new CliError("The commit could not be resolved.", EXIT.failed);
8268
- if (plan.upToDate)
9207
+ if (plan.upToDate && !o.envOnly)
8269
9208
  return say("Already up to date.");
8270
- if (!o.yes) {
8271
- if (!rt.isTTY)
8272
- throw new CliError("Not a terminal: pass --yes to accept the plan.", EXIT.invalid);
8273
- if (!await rt.confirm("Deploy this?"))
8274
- throw new CliError("Cancelled.", EXIT.invalid);
8275
- }
9209
+ await confirmOr("Deploy this?", o.yes);
8276
9210
  const body = {
8277
9211
  sha: plan.sha,
8278
9212
  recipeHash: plan.recipeHash,
8279
- idempotencyKey: crypto.randomBytes(12).toString("hex"),
8280
- ...plan.migration.required ? { confirmMigrations: true } : {}
9213
+ idempotencyKey: crypto2.randomBytes(12).toString("hex"),
9214
+ ...plan.migration.required && !o.envOnly ? { confirmMigrations: true } : {},
9215
+ ...o.envOnly ? { envOnly: true } : {}
8281
9216
  };
8282
9217
  let sub = null;
8283
9218
  for (let i = 0;!sub; i++) {
@@ -8302,6 +9237,423 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8302
9237
  }
8303
9238
  exit = await finish(api, sub.deploymentId);
8304
9239
  });
9240
+ async function planFrom(api, c) {
9241
+ if (!(await checkServer(api)).features.manifest)
9242
+ throw new CliError("This server cannot plan from orbito.json. Update the server: orbito-update", EXIT.invalid);
9243
+ const desired = desiredOf(c);
9244
+ const plan = await api.call("POST", `/apps/${c.appId}/plan`, desired, ManifestPlan);
9245
+ if (g().json)
9246
+ emit({ type: "plan", ...plan });
9247
+ else {
9248
+ if (plan.upToDate)
9249
+ say("Nothing to do: the server matches orbito.json.");
9250
+ for (const x of plan.changes)
9251
+ rt.out(`${x.op === "add" ? "+" : "~"} ${x.what}: ${x.detail}
9252
+ `);
9253
+ for (const b of plan.blockers)
9254
+ rt.out(`! ${b}
9255
+ `);
9256
+ }
9257
+ return { desired, plan };
9258
+ }
9259
+ program.command("plan").description("compare orbito.json with the server; changes nothing").action(async () => {
9260
+ const { c, api } = authed();
9261
+ if ((await planFrom(api, c)).plan.blockers.length > 0)
9262
+ exit = EXIT.failed;
9263
+ });
9264
+ program.command("apply").description("make the server match orbito.json; running it twice changes nothing").option("--yes", "accept the displayed plan without asking (never skips blockers)").action(async (o) => {
9265
+ const { c, api } = authed();
9266
+ const { desired, plan } = await planFrom(api, c);
9267
+ if (plan.blockers.length > 0)
9268
+ throw new CliError("The plan is blocked.", EXIT.failed);
9269
+ if (plan.upToDate)
9270
+ return;
9271
+ await confirmOr("Apply this?", o.yes);
9272
+ const done = await api.call("POST", `/apps/${c.appId}/apply`, { desired, hash: plan.hash });
9273
+ if (g().json)
9274
+ return emit({ type: "applied", ...done });
9275
+ say(`Applied ${done.applied} change(s). They take effect on the next deploy: orbito deploy`);
9276
+ });
9277
+ const serverFor = (explicit) => {
9278
+ if (explicit)
9279
+ return clientFor(new URL(explicit).origin);
9280
+ return clientFor(cfg().server);
9281
+ };
9282
+ const needManifest = async (api) => {
9283
+ if (!(await checkServer(api)).features.manifest)
9284
+ throw new CliError("This server cannot list projects. Update the server: orbito-update", EXIT.invalid);
9285
+ };
9286
+ program.command("projects").description("projects on the server that are not set up as apps yet (administrators)").option("--server <url>", "defaults to the server in orbito.json").action(async (o) => {
9287
+ const api = serverFor(o.server);
9288
+ await needManifest(api);
9289
+ const rows = await api.call("GET", "/projects");
9290
+ if (g().json)
9291
+ return emit(rows);
9292
+ if (rows.length === 0)
9293
+ return say("Every project found on the server is set up.");
9294
+ for (const r of rows)
9295
+ rt.out(`${r.project} ${r.runtimes.join(",") || "not running"} port ${r.suggestedPort ?? "-"}
9296
+ `);
9297
+ });
9298
+ program.command("adopt").description("set a project up as an app. Nothing on the server changes: it is watched until you deploy").argument("<project>", "a name from orbito projects").option("--server <url>", "defaults to the server in orbito.json").action(async (project, o) => {
9299
+ const api = serverFor(o.server);
9300
+ await needManifest(api);
9301
+ const made = await api.call("POST", "/projects/adopt", { project });
9302
+ if (g().json)
9303
+ return emit({ type: "adopted", ...made });
9304
+ say(`'${project}' is now the app '${made.appId}'. It runs as before. Next: orbito init --app ${made.appId}`);
9305
+ });
9306
+ const needBuckets = async (api) => {
9307
+ if (!(await checkServer(api)).features.buckets)
9308
+ throw new CliError("This server has no storage commands. Update the server: orbito-update", EXIT.invalid);
9309
+ };
9310
+ const connection = program.command("storage").description("S3-compatible providers for app files (administrators)").command("connection").description("providers Orbito can create buckets on");
9311
+ connection.command("add").description("add a provider. It is saved only after a real check of every operation").argument("<name>").requiredOption("--endpoint <url>", "for example https://s3.ir-thr-at1.arvanstorage.ir").requiredOption("--region <region>", "for example ir-thr-at1").requiredOption("--access-key <key>", "the access key id").option("--server <url>", "defaults to the server in orbito.json").addHelpText("after", `
9312
+ The secret key is read from stdin, never from an argument:
9313
+ printf %s "$SECRET" | orbito storage connection add arvan --endpoint ... --region ... --access-key ...
9314
+ `).action(async (name, o) => {
9315
+ const api = serverFor(o.server);
9316
+ await needBuckets(api);
9317
+ const raw = rt.isTTY ? await rt.ask("Secret key (shown as you type): ") : await rt.readStdin();
9318
+ const secretKey = raw.trim();
9319
+ if (!secretKey)
9320
+ throw new CliError("Pipe in the secret key.", EXIT.invalid);
9321
+ const made = await api.call("POST", "/storage/connections", {
9322
+ name,
9323
+ endpoint: o.endpoint,
9324
+ region: o.region,
9325
+ accessKey: o.accessKey,
9326
+ secretKey
9327
+ });
9328
+ if (g().json)
9329
+ return emit({ type: "connection", ...made });
9330
+ say(`Checked and saved '${made.name}'. Next: orbito bucket create --connection ${made.name}`);
9331
+ });
9332
+ connection.command("ls").description("names and endpoints; keys are never shown").option("--server <url>", "defaults to the server in orbito.json").action(async (o) => {
9333
+ const api = serverFor(o.server);
9334
+ await needBuckets(api);
9335
+ const rows = await api.call("GET", "/storage/connections", undefined, array(StorageConnection));
9336
+ if (g().json)
9337
+ return emit(rows);
9338
+ for (const r of rows)
9339
+ rt.out(`${r.name} ${r.endpoint} ${r.region} checked ${r.verifiedAt ?? "never"}
9340
+ `);
9341
+ });
9342
+ connection.command("verify").description("run the check again").argument("<name>").option("--server <url>", "defaults to the server in orbito.json").action(async (name, o) => {
9343
+ const api = serverFor(o.server);
9344
+ await needBuckets(api);
9345
+ const done = await api.call("POST", `/storage/connections/${encodeURIComponent(name)}/verify`, {});
9346
+ if (g().json)
9347
+ return emit({ type: "connection", ...done });
9348
+ say(`'${done.name}' passed every step.`);
9349
+ });
9350
+ const bucketCmd = program.command("bucket").description("the app's buckets (sizes are measured, never limited)");
9351
+ bucketCmd.command("ls").action(async () => {
9352
+ const { c, api } = authed();
9353
+ await needBuckets(api);
9354
+ const rows = await api.call("GET", `/apps/${c.appId}/buckets`, undefined, array(AppBucket));
9355
+ if (g().json)
9356
+ return emit(rows);
9357
+ for (const b of rows)
9358
+ rt.out(`${b.binding} ${b.bucket} on ${b.connection} ${b.bytes === null ? "not measured yet" : `${b.bytes} bytes, ${b.objects} objects`}
9359
+ `);
9360
+ });
9361
+ bucketCmd.command("create").description("create a private bucket and bind it to the app as S3_* variables").requiredOption("--connection <name>", "from orbito storage connection ls").option("--binding <name>", "for a second bucket: UPLOADS gives UPLOADS_S3_* variables").action(async (o) => {
9362
+ const { c, api } = authed();
9363
+ await needBuckets(api);
9364
+ const made = await api.call("POST", `/apps/${c.appId}/buckets`, o);
9365
+ if (g().json)
9366
+ return emit({ type: "bucket", ...made });
9367
+ say(`Created ${made.bucket}, bound as ${made.binding}. It takes effect on the next deploy: orbito deploy --env-only`);
9368
+ info(`Note: ${made.note}`);
9369
+ });
9370
+ bucketCmd.command("remove").description("unbind a bucket. The bucket and its files stay at the provider").argument("<binding>").option("--confirm <binding>", "the binding again, for scripts").action(async (binding, o) => {
9371
+ const { c, api } = authed();
9372
+ await needBuckets(api);
9373
+ let typed = o.confirm;
9374
+ if (!typed) {
9375
+ if (!rt.isTTY)
9376
+ throw new CliError(`Not a terminal: pass --confirm ${binding}.`, EXIT.confirm);
9377
+ typed = (await rt.ask(`Type ${binding} to confirm: `)).trim();
9378
+ }
9379
+ if (typed !== binding)
9380
+ throw new CliError("It did not match. Nothing was changed.", EXIT.confirm);
9381
+ const done = await api.call("DELETE", `/apps/${c.appId}/buckets/${encodeURIComponent(binding)}?confirm=${encodeURIComponent(typed)}`);
9382
+ if (g().json)
9383
+ return emit({ type: "bucket.removed", binding, note: done.note });
9384
+ say(`Unbound ${binding}. ${done.note}`);
9385
+ });
9386
+ const environmentCmd = program.command("environment").description("the app's other environments, such as staging");
9387
+ const needEnvironments = async (api) => {
9388
+ if (!(await checkServer(api)).features.environments)
9389
+ throw new CliError("This server has no environments. Update the server: orbito-update", EXIT.invalid);
9390
+ };
9391
+ environmentCmd.command("ls").action(async () => {
9392
+ const { c, api } = authed();
9393
+ await needEnvironments(api);
9394
+ const rows = await api.call("GET", `/apps/${c.appId}/environments`, undefined, array(AppEnvironment));
9395
+ if (g().json)
9396
+ return emit(rows);
9397
+ if (rows.length === 0)
9398
+ say("Only production so far.");
9399
+ for (const e of rows)
9400
+ rt.out(`${e.environment} ${e.id} port ${e.port ?? "-"} ${e.deployedSha?.slice(0, 12) ?? "not deployed"}
9401
+ `);
9402
+ });
9403
+ environmentCmd.command("create").description("add an environment: its own folder, user, port, database and variables (administrators)").argument("<name>", "for example staging").option("--port <port>", "the port it listens on; omit to let Orbito pick a free one").option("--branch <branch>", "the branch it deploys; defaults to the same as production").action(async (name, o) => {
9404
+ const { c, api } = authed();
9405
+ await needEnvironments(api);
9406
+ const made = await api.call("POST", `/apps/${c.appId}/environments`, {
9407
+ name,
9408
+ ...o.port ? { port: Number(o.port) } : {},
9409
+ ...o.branch ? { branch: o.branch } : {}
9410
+ });
9411
+ if (g().json)
9412
+ return emit({ type: "environment", ...made });
9413
+ say(`Created '${made.id}' on port ${made.port}. Copied ${made.copied.length} plain variable(s); production's secrets and database were not copied.`);
9414
+ for (const n of made.secretsToSet)
9415
+ say(` set it: orbito env set ${n} --secret --env ${name}`);
9416
+ say(`Next: orbito deploy --env ${name}`);
9417
+ });
9418
+ program.command("export").description("write files to run the app without Orbito: Dockerfile, Compose, a systemd unit, .env.example").option("--out <dir>", "an empty or new folder", "orbito-export").action(async (o) => {
9419
+ const { c, api } = authed();
9420
+ if (!(await checkServer(api)).features.export)
9421
+ throw new CliError("This server cannot export. Update the server: orbito-update", EXIT.invalid);
9422
+ const dir = path4.resolve(rt.cwd, o.out);
9423
+ if (fs4.existsSync(dir) && fs4.readdirSync(dir).length > 0)
9424
+ throw new CliError(`${dir} is not empty. Nothing was written.`, EXIT.invalid);
9425
+ const { files } = await api.call("GET", `/apps/${c.appId}/export`);
9426
+ fs4.mkdirSync(dir, { recursive: true });
9427
+ for (const [name, text] of Object.entries(files)) {
9428
+ if (name !== path4.basename(name))
9429
+ throw new CliError(`The server sent an odd file name: ${name}`, EXIT.failed);
9430
+ fs4.writeFileSync(path4.join(dir, name), text);
9431
+ }
9432
+ if (g().json)
9433
+ return emit({ type: "export", dir, files: Object.keys(files) });
9434
+ say(`Wrote ${Object.keys(files).length} files to ${dir}. They hold variable names, never values.`);
9435
+ });
9436
+ program.command("licence").description("the plan, the limit and how many apps count").option("--server <url>", "defaults to the server in orbito.json").action(async (o) => {
9437
+ const api = serverFor(o.server);
9438
+ if (!(await checkServer(api)).features.licence)
9439
+ throw new CliError("This server does not report a licence. Update the server: orbito-update", EXIT.invalid);
9440
+ const s = await api.call("GET", "/licence", undefined, LicenceState);
9441
+ if (g().json)
9442
+ return emit(s);
9443
+ rt.out(`${s.plan}${s.customer ? ` for ${s.customer}` : ""}: ${s.used} of ${s.maxApps ?? "unlimited"} apps
9444
+ `);
9445
+ if (s.expires)
9446
+ rt.out(`Expires ${s.expires.slice(0, 10)}
9447
+ `);
9448
+ if (s.problem)
9449
+ rt.out(`${s.problem}
9450
+ `);
9451
+ });
9452
+ const domainCmd = program.command("domain").description("the app's domains, DNS and certificates");
9453
+ const needDomains = async (api) => {
9454
+ if (!(await checkServer(api)).features.domains)
9455
+ throw new CliError("This server has no domain commands. Update the server: orbito-update", EXIT.invalid);
9456
+ };
9457
+ const showDomains = (list) => {
9458
+ if (g().json)
9459
+ return emit(list);
9460
+ for (const d of list.domains)
9461
+ rt.out(`${d.domain} ${d.stage}
9462
+ ${d.detail}
9463
+ `);
9464
+ if (list.applied?.length)
9465
+ rt.out(`Routed: ${list.applied.join(", ")}
9466
+ `);
9467
+ if (list.note)
9468
+ rt.out(`${list.note}
9469
+ `);
9470
+ };
9471
+ domainCmd.command("ls").description("each domain and how far along it is").action(async () => {
9472
+ const { c, api } = authed();
9473
+ await needDomains(api);
9474
+ showDomains(await api.call("GET", `/apps/${c.appId}/domains`, undefined, DomainList));
9475
+ });
9476
+ domainCmd.command("add").description("add a domain; it is routed once its DNS points here").argument("<domain>").action(async (domain) => {
9477
+ const { c, api } = authed();
9478
+ await needDomains(api);
9479
+ showDomains(await api.call("POST", `/apps/${c.appId}/domains`, { domain }, DomainList));
9480
+ });
9481
+ domainCmd.command("check").description("check DNS again and route what is ready").action(async () => {
9482
+ const { c, api } = authed();
9483
+ await needDomains(api);
9484
+ showDomains(await api.call("POST", `/apps/${c.appId}/domains/check`, {}, DomainList));
9485
+ });
9486
+ domainCmd.command("remove").description("remove a domain; needs the domain typed again, or --confirm <domain>").argument("<domain>").option("--dry-run", "say what would happen; change nothing").option("--confirm <domain>", "the domain again, for scripts").action(async (domain, o) => {
9487
+ const { c, api } = authed();
9488
+ await needDomains(api);
9489
+ say(`This stops '${c.appId}' answering on ${domain}.`);
9490
+ if (o.dryRun)
9491
+ return;
9492
+ let typed = o.confirm;
9493
+ if (!typed) {
9494
+ if (!rt.isTTY)
9495
+ throw new CliError(`Not a terminal: pass --confirm ${domain}.`, EXIT.confirm);
9496
+ typed = (await rt.ask(`Type ${domain} to confirm: `)).trim();
9497
+ }
9498
+ if (typed !== domain)
9499
+ throw new CliError("It did not match. Nothing was changed.", EXIT.confirm);
9500
+ const done = await api.call("DELETE", `/apps/${c.appId}/domains/${encodeURIComponent(domain)}`);
9501
+ if (g().json)
9502
+ return emit({ type: "domain.removed", domain, note: done.note });
9503
+ say(`Removed ${domain}.${done.note ? ` ${done.note}` : ""}`);
9504
+ });
9505
+ program.command("dev").description("start the local database, migrate, and run the dev script. Needs no Orbito server").option("--setup-only", "stop before running the dev script").action(async (o) => {
9506
+ const c = cfg();
9507
+ const database = await setupDatabase(rt, c);
9508
+ if (database.engine === "none")
9509
+ info('No local database: add "dev": { "database": "sqlite" } to orbito.json to get one.');
9510
+ else
9511
+ info(`Local database: ${database.engine}`);
9512
+ const migrated = await runScript(rt, "db:migrate");
9513
+ if (migrated)
9514
+ throw new CliError("db:migrate failed.", EXIT.failed);
9515
+ if (o.setupOnly)
9516
+ return;
9517
+ const code = await runScript(rt, "dev");
9518
+ if (code === null)
9519
+ throw new CliError("package.json has no dev script.", EXIT.invalid);
9520
+ exit = code;
9521
+ });
9522
+ program.command("usage").description("what the app uses: memory, CPU, processes, restarts, disk (never limited)").option("--range <range>", "24h, 7d or 30d", "24h").action(async (o) => {
9523
+ const { c, api } = authed();
9524
+ if (!(await checkServer(api)).features.usage)
9525
+ throw new CliError("This server does not measure usage. Update the server: orbito-update", EXIT.invalid);
9526
+ const u = await api.call("GET", `/apps/${c.appId}/usage?range=${encodeURIComponent(o.range)}`, undefined, AppUsage);
9527
+ if (g().json)
9528
+ return emit(u);
9529
+ if (!u.measured)
9530
+ return say("No usage recorded yet. Only apps run by systemd are measured so far.");
9531
+ const mb = (n) => n === null ? "-" : `${(n / 1048576).toFixed(0)} MB`;
9532
+ rt.out(`${c.appId} at ${u.at}
9533
+ `);
9534
+ rt.out(` memory ${mb(u.memoryBytes)} (peak ${mb(u.memoryPeakBytes)} in ${u.range})
9535
+ `);
9536
+ rt.out(` cpu ${u.cpuPercent ?? "-"}% of one core
9537
+ `);
9538
+ rt.out(` processes ${u.pids ?? "-"}
9539
+ `);
9540
+ rt.out(` restarts ${u.restarts ?? "-"}, out of memory ${u.oomKills ?? "-"}
9541
+ `);
9542
+ rt.out(` disk ${mb(u.diskBytes)}
9543
+ `);
9544
+ if (u.p50Ms != null)
9545
+ rt.out(` response p50 ${u.p50Ms} ms, p95 ${u.p95Ms} ms, ${u.requests} requests, ${u.errors} server errors in ${u.range}
9546
+ `);
9547
+ for (const a of u.alerts ?? [])
9548
+ rt.out(` ALERT ${a.message}
9549
+ `);
9550
+ });
9551
+ const envCmd = program.command("env").description("variables Orbito holds for the app");
9552
+ const needEnvCommands = async (api) => {
9553
+ if (!(await checkServer(api)).features.envCommands)
9554
+ throw new CliError("This server has no env commands. Update the server: orbito-update", EXIT.invalid);
9555
+ };
9556
+ envCmd.command("pull").description("copy variables to .env.local. Secrets are not copied: development uses its own").action(async () => {
9557
+ const { c, api } = authed();
9558
+ if (!(await checkServer(api)).features.envValues)
9559
+ throw new CliError("This server cannot do this. Update the server: orbito-update", EXIT.invalid);
9560
+ await ensureIgnored(rt);
9561
+ const rows = await api.call("GET", `/apps/${c.appId}/env?values=plain`, undefined, EnvList);
9562
+ const have = readEnvLocal(rt);
9563
+ const pulled = rows.filter((v) => !v.secret && v.value !== undefined && v.name !== "DATABASE_URL");
9564
+ writeEnvLocal(rt, Object.fromEntries(pulled.map((v) => [v.name, v.value])));
9565
+ const missing = rows.filter((v) => v.secret && !(v.name in have) && v.name !== "DATABASE_URL").map((v) => v.name);
9566
+ if (g().json)
9567
+ return emit({ type: "env.pull", pulled: pulled.map((v) => v.name), secretsToSet: missing });
9568
+ say(`Wrote ${pulled.length} variable(s) to .env.local.`);
9569
+ if (missing.length)
9570
+ say(`Secrets to set yourself in .env.local: ${missing.join(", ")}`);
9571
+ });
9572
+ envCmd.command("diff").description("names that differ between .env.local and the server; values are never shown").action(async () => {
9573
+ const { c, api } = authed();
9574
+ await needEnvCommands(api);
9575
+ const rows = await api.call("GET", `/apps/${c.appId}/env`, undefined, EnvList);
9576
+ const local = new Set(Object.keys(readEnvLocal(rt)));
9577
+ const server = new Set(rows.map((r) => r.name));
9578
+ const onlyLocal = [...local].filter((n) => !server.has(n)).sort();
9579
+ const onlyServer = [...server].filter((n) => !local.has(n)).sort();
9580
+ if (g().json)
9581
+ return emit({ type: "env.diff", onlyLocal, onlyServer });
9582
+ for (const n of onlyLocal)
9583
+ rt.out(`local only ${n}
9584
+ `);
9585
+ for (const n of onlyServer)
9586
+ rt.out(`server only ${n}
9587
+ `);
9588
+ if (!onlyLocal.length && !onlyServer.length)
9589
+ say("The same names on both sides.");
9590
+ });
9591
+ envCmd.command("push").description("add variables from a file to the server. Never replaces one unless --overwrite").argument("<file>", "an env file").option("--overwrite", "also replace variables the server already has").option("--dry-run", "list what would change; change nothing").option("--yes", "do not ask").action(async (file, o) => {
9592
+ const { c, api } = authed();
9593
+ await needEnvCommands(api);
9594
+ let text;
9595
+ try {
9596
+ text = fs4.readFileSync(path4.resolve(rt.cwd, file), "utf8");
9597
+ } catch {
9598
+ throw new CliError(`Cannot read ${file}.`, EXIT.invalid);
9599
+ }
9600
+ const rows = await api.call("GET", `/apps/${c.appId}/env`, undefined, EnvList);
9601
+ const server = new Set(rows.map((r) => r.name));
9602
+ const all = Object.entries(parseEnv(text)).filter(([n]) => n !== "DATABASE_URL");
9603
+ const send = all.filter(([n]) => o.overwrite || !server.has(n));
9604
+ for (const [n] of send)
9605
+ rt[g().json ? "err" : "out"](`${server.has(n) ? "~" : "+"} ${n}
9606
+ `);
9607
+ const skipped = all.length - send.length;
9608
+ if (skipped)
9609
+ info(`Left ${skipped} the server already has. Pass --overwrite to replace them.`);
9610
+ if (o.dryRun || send.length === 0)
9611
+ return;
9612
+ await confirmOr("Send these to the server?", o.yes);
9613
+ for (const [name, value] of send)
9614
+ await api.call("POST", `/apps/${c.appId}/env`, { name, value });
9615
+ if (g().json)
9616
+ return emit({ type: "env.push", names: send.map(([n]) => n) });
9617
+ say(`Sent ${send.length}. They take effect on the next deploy: orbito deploy --env-only`);
9618
+ });
9619
+ envCmd.command("ls").description("names only; values are never printed").action(async () => {
9620
+ const { c, api } = authed();
9621
+ await needEnvCommands(api);
9622
+ const rows = await api.call("GET", `/apps/${c.appId}/env`, undefined, EnvList);
9623
+ if (g().json)
9624
+ return emit(rows);
9625
+ for (const v of rows)
9626
+ rt.out(`${v.name}${v.secret ? " (secret)" : ""} ${v.updatedAt}
9627
+ `);
9628
+ });
9629
+ envCmd.command("set").description("set a variable; the value comes from stdin, never from an argument").argument("<name>").option("--secret", "treat it as a secret").addHelpText("after", `
9630
+ Example:
9631
+ printf %s "$VALUE" | orbito env set API_KEY --secret
9632
+ `).action(async (name, o) => {
9633
+ const { c, api } = authed();
9634
+ await needEnvCommands(api);
9635
+ const raw = rt.isTTY ? await rt.ask(`Value for ${name} (shown as you type): `) : await rt.readStdin();
9636
+ const value = raw.replace(/\r?\n$/, "");
9637
+ await api.call("POST", `/apps/${c.appId}/env`, {
9638
+ name,
9639
+ value,
9640
+ ...o.secret ? { secret: true } : {}
9641
+ });
9642
+ say(`Set ${name}. It takes effect on the next deploy: orbito deploy --env-only`);
9643
+ if (g().json)
9644
+ emit({ type: "env.set", name });
9645
+ });
9646
+ envCmd.command("unset").description("remove a variable from what Orbito holds").argument("<name>").option("--dry-run", "say what would be removed; change nothing").option("--yes", "do not ask").action(async (name, o) => {
9647
+ const { c, api } = authed();
9648
+ await needEnvCommands(api);
9649
+ if (o.dryRun)
9650
+ return say(`Would remove ${name}.`);
9651
+ await confirmOr(`Remove ${name}?`, o.yes);
9652
+ await api.call("DELETE", `/apps/${c.appId}/env/${encodeURIComponent(name)}`);
9653
+ say(`Removed ${name} from Orbito. A deploy does not delete it from the .env already on the server.`);
9654
+ if (g().json)
9655
+ emit({ type: "env.unset", name });
9656
+ });
8305
9657
  const dep = program.command("deployments").description("deployment history");
8306
9658
  dep.command("list").description("recent deployments").option("--limit <n>", "how many", "10").action(async (o) => {
8307
9659
  const { c, api } = authed();
@@ -8312,7 +9664,7 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8312
9664
  rt.out(`${d.id} ${d.startedAt} ${d.kind} ${d.status} ${d.sha?.slice(0, 12) ?? "-"} ${d.actor}
8313
9665
  `);
8314
9666
  });
8315
- program.command("tail").description("follow runtime logs, or a deployment with --deployment").option("--deployment <id>", "follow a deployment and migration output instead").option("--lines <n>", "lines of history first (0-500)", "100").option("--since <iso>", "continue after this time (container logs only)").action(async (o) => {
9667
+ program.command("tail").alias("logs").description("follow runtime logs, or a deployment with --deployment").option("--deployment <id>", "follow a deployment and migration output instead").option("--lines <n>", "lines of history first (0-500)", "100").option("--since <iso>", "continue after this time (container logs only)").action(async (o) => {
8316
9668
  const { c, api } = authed();
8317
9669
  if (o.deployment) {
8318
9670
  if (await follow(api, o.deployment) === "interrupted")
@@ -8385,19 +9737,129 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8385
9737
  `);
8386
9738
  for (const r of s.recent)
8387
9739
  rt.out(` ${r.at} ${r.deploymentId} ${r.migration} backup ${r.backup ?? "-"}
9740
+ `);
9741
+ });
9742
+ db.command("copy").description("replace an environment's data with a fresh copy of production's (use with --env)").option("--confirm <id>", "the environment id, for scripts; --yes is not accepted here").action(async (o) => {
9743
+ const { c, api } = authed();
9744
+ await needEnvironments(api);
9745
+ say(`This replaces all data of '${c.appId}' with a copy of production's. Real data will be in this environment. A safety dump is taken first.`);
9746
+ let typed = o.confirm;
9747
+ if (!typed) {
9748
+ if (!rt.isTTY)
9749
+ throw new CliError(`Not a terminal: pass --confirm ${c.appId}.`, EXIT.confirm);
9750
+ typed = (await rt.ask(`Type ${c.appId} to confirm: `)).trim();
9751
+ }
9752
+ if (typed !== c.appId)
9753
+ throw new CliError("It did not match. Nothing was changed.", EXIT.confirm);
9754
+ const done = await api.call("POST", `/apps/${c.appId}/db/copy`, { confirm: typed });
9755
+ if (g().json)
9756
+ return emit({ type: "db.copy", ...done });
9757
+ rt.out(`Copied from ${done.from}. Safety dump: ${done.safety}
9758
+ `);
9759
+ });
9760
+ db.command("seed").description("local: run the db:seed script against the local database").action(async () => {
9761
+ const code = await runScript(rt, "db:seed");
9762
+ if (code === null)
9763
+ throw new CliError("package.json has no db:seed script.", EXIT.invalid);
9764
+ exit = code;
9765
+ });
9766
+ db.command("studio").description("local: run the db:studio script against the local database").action(async () => {
9767
+ const code = await runScript(rt, "db:studio");
9768
+ if (code === null)
9769
+ throw new CliError("package.json has no db:studio script.", EXIT.invalid);
9770
+ exit = code;
9771
+ });
9772
+ db.command("shell").description("local: open a shell on the local database").action(async () => {
9773
+ const [cmd, args] = shellCommand(cfg(), rt.cwd);
9774
+ exit = (await rt.run(cmd, args)).code;
9775
+ });
9776
+ db.command("reset").description("local: delete the local database, then migrate and seed. Never touches a server").option("--confirm <app-id>", "the app id, for scripts").action(async (o) => {
9777
+ const c = cfg();
9778
+ let typed = o.confirm;
9779
+ if (!typed) {
9780
+ if (!rt.isTTY)
9781
+ throw new CliError(`Not a terminal: pass --confirm ${c.appId}.`, EXIT.confirm);
9782
+ typed = (await rt.ask(`This deletes the local database. Type ${c.appId} to confirm: `)).trim();
9783
+ }
9784
+ if (typed !== c.appId)
9785
+ throw new CliError("The app id did not match. Nothing was changed.", EXIT.confirm);
9786
+ await dropDatabase(rt, c);
9787
+ await setupDatabase(rt, c);
9788
+ for (const script of ["db:migrate", "db:seed"]) {
9789
+ const code = await runScript(rt, script);
9790
+ if (code)
9791
+ throw new CliError(`${script} failed.`, EXIT.failed);
9792
+ }
9793
+ say("The local database was recreated.");
9794
+ });
9795
+ const needDbBackups = async (api) => {
9796
+ if (!(await checkServer(api)).features.dbBackups)
9797
+ throw new CliError("This server has no database backup commands. Update the server: orbito-update", EXIT.invalid);
9798
+ };
9799
+ db.command("backup").description("take a dump of the database now").action(async () => {
9800
+ const { c, api } = authed();
9801
+ await needDbBackups(api);
9802
+ const made = await api.call("POST", `/apps/${c.appId}/db/backup`, {});
9803
+ if (g().json)
9804
+ return emit({ type: "backup", ...made });
9805
+ rt.out(`${made.file} (${made.bytes} bytes)
9806
+ `);
9807
+ });
9808
+ db.command("dumps").description("the dumps kept on the server").action(async () => {
9809
+ const { c, api } = authed();
9810
+ await needDbBackups(api);
9811
+ const rows = await api.call("GET", `/apps/${c.appId}/db/dumps`);
9812
+ if (g().json)
9813
+ return emit(rows);
9814
+ for (const d of rows)
9815
+ rt.out(`${d.name} ${d.bytes} bytes ${d.at}
9816
+ `);
9817
+ });
9818
+ db.command("restore").description("replace the database with a kept dump (a safety dump is taken first; needs the app id typed)").argument("<file>", "a name from orbito db dumps").option("--dry-run", "say what would happen; change nothing").option("--confirm <app-id>", "the app id, for scripts; --yes is not accepted here").action(async (file, o) => {
9819
+ const { c, api } = authed();
9820
+ await needDbBackups(api);
9821
+ say(`This replaces all data of '${c.appId}' with ${file}. A safety dump is taken first.`);
9822
+ if (o.dryRun)
9823
+ return;
9824
+ let typed = o.confirm;
9825
+ if (!typed) {
9826
+ if (!rt.isTTY)
9827
+ throw new CliError(`Not a terminal: pass --confirm ${c.appId}.`, EXIT.confirm);
9828
+ typed = (await rt.ask(`Type ${c.appId} to confirm: `)).trim();
9829
+ }
9830
+ if (typed !== c.appId)
9831
+ throw new CliError("The app id did not match. Nothing was changed.", EXIT.confirm);
9832
+ const done = await api.call("POST", `/apps/${c.appId}/db/restore`, { file, confirm: typed });
9833
+ if (g().json)
9834
+ return emit({ type: "restore", ...done });
9835
+ rt.out(`Restored ${done.restored}. Safety dump: ${done.safety}
8388
9836
  `);
8389
9837
  });
8390
9838
  db.command("migrate").description("apply migrations from a deployment (refused unless the server can prove it is safe)").requiredOption("--deployment <id>", "the deployment whose source to migrate from").action(async () => {
8391
9839
  const { c, api } = authed();
8392
9840
  await api.call("POST", `/apps/${c.appId}/db/migrate`, {});
8393
9841
  });
8394
- program.command("rollback").description("restore the earlier code release (refused if the database changed since)").requiredOption("--deployment <id>", "the latest successful deployment, which is undone").option("--detach", "return the deployment id without following").action(async (o) => {
9842
+ program.command("rollback").description("restore the earlier code release (refused if the database changed since)").requiredOption("--deployment <id>", "the latest successful deployment, which is undone").option("--dry-run", "show what would be undone, or why it is refused; change nothing").option("--yes", "accept the displayed plan without asking").option("--detach", "return the deployment id without following").action(async (o) => {
8395
9843
  const { c, api } = authed();
9844
+ if (!(await checkServer(api)).features.rollbackPlan)
9845
+ throw new CliError("This server cannot plan a rollback. Update the server: orbito-update", EXIT.invalid);
9846
+ const plan = await api.call("POST", `/apps/${c.appId}/rollback/plan`, { deploymentId: o.deployment }, Deployment);
9847
+ if (g().json)
9848
+ emit({ type: "plan", ...plan });
9849
+ else
9850
+ rt.out(`Roll back ${plan.id}: ${plan.sha?.slice(0, 12) ?? "unknown"} -> ${plan.from?.slice(0, 12) ?? "the earlier release"}
9851
+ `);
9852
+ if (o.dryRun)
9853
+ return;
9854
+ await confirmOr("Roll back?", o.yes);
8396
9855
  const sub = await api.call("POST", `/apps/${c.appId}/rollback`, {
8397
- idempotencyKey: crypto.randomBytes(12).toString("hex"),
9856
+ idempotencyKey: crypto2.randomBytes(12).toString("hex"),
8398
9857
  deploymentId: o.deployment
8399
9858
  });
8400
- say(`Submitted ${sub.deploymentId}.`);
9859
+ if (g().json)
9860
+ emit({ type: "submitted", ...sub });
9861
+ else
9862
+ say(`Submitted ${sub.deploymentId}.`);
8401
9863
  if (o.detach)
8402
9864
  return;
8403
9865
  if (await follow(api, sub.deploymentId) === "interrupted")
@@ -8411,7 +9873,11 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8411
9873
  try {
8412
9874
  results.push({ check: name, ok: true, detail: await fn() });
8413
9875
  } catch (e) {
8414
- results.push({ check: name, ok: false, detail: e.message });
9876
+ results.push({
9877
+ check: name,
9878
+ ok: false,
9879
+ detail: e.message
9880
+ });
8415
9881
  }
8416
9882
  };
8417
9883
  let c = null;
@@ -8455,7 +9921,11 @@ ${add.map(([k, v]) => ` "${k}": "${v}"`).join(`
8455
9921
  return e.exitCode === 0 ? EXIT.ok : EXIT.invalid;
8456
9922
  const err = e;
8457
9923
  if (g().json)
8458
- emit({ type: "error", error: err.message, code: e instanceof ApiFailure ? e.code : "error" });
9924
+ emit({
9925
+ type: "error",
9926
+ error: err.message,
9927
+ code: e instanceof ApiFailure ? e.code : "error"
9928
+ });
8459
9929
  else
8460
9930
  info(err.message);
8461
9931
  return e instanceof CliError ? e.exit : EXIT.failed;
@@ -8498,5 +9968,55 @@ async function readSse(res, signal, on) {
8498
9968
  }
8499
9969
  }
8500
9970
 
9971
+ // src/update.ts
9972
+ import fs5 from "node:fs";
9973
+ import path5 from "node:path";
9974
+ var DAY = 24 * 60 * 60 * 1000;
9975
+ var newer = (a, b) => {
9976
+ const p = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
9977
+ const [x, y] = [p(a), p(b)];
9978
+ for (let i = 0;i < 3; i++)
9979
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
9980
+ return (x[i] ?? 0) > (y[i] ?? 0);
9981
+ return false;
9982
+ };
9983
+ async function updateNotice(rt, name, current) {
9984
+ if (!rt.isTTY || rt.env.CI || rt.env.ORBITO_NO_UPDATE_CHECK)
9985
+ return null;
9986
+ const cache = path5.join(rt.configDir, "update-check.json");
9987
+ let latest;
9988
+ try {
9989
+ const c = JSON.parse(fs5.readFileSync(cache, "utf8"));
9990
+ if (Date.now() - c.at < DAY)
9991
+ latest = c.latest;
9992
+ } catch {}
9993
+ if (!latest) {
9994
+ try {
9995
+ const registry = (rt.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/$/, "");
9996
+ const res = await rt.fetch(`${registry}/${name.replace("/", "%2f")}/latest`, {
9997
+ signal: AbortSignal.timeout(1500)
9998
+ });
9999
+ if (!res.ok)
10000
+ return null;
10001
+ latest = (await res.json()).version;
10002
+ if (!latest)
10003
+ return null;
10004
+ fs5.mkdirSync(rt.configDir, { recursive: true, mode: 448 });
10005
+ fs5.writeFileSync(cache, JSON.stringify({ at: Date.now(), latest }));
10006
+ } catch {
10007
+ return null;
10008
+ }
10009
+ }
10010
+ return newer(latest, current) ? `Update available: ${current} → ${latest}. Run: bun add --dev --exact ${name}@latest` : null;
10011
+ }
10012
+
8501
10013
  // src/main.ts
8502
- process2.exitCode = await run(process2.argv.slice(2), realRuntime());
10014
+ var rt = realRuntime();
10015
+ var argv = process2.argv.slice(2);
10016
+ var notice = argv.includes("--json") ? Promise.resolve(null) : updateNotice(rt, "@deaduchiha/orbito", VERSION);
10017
+ process2.exitCode = await run(argv, rt);
10018
+ var n = await notice;
10019
+ if (n)
10020
+ rt.err(`
10021
+ ${n}
10022
+ `);