@apso/cli 0.46.3 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export default class Deploy extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ env: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
6
7
  "no-wait": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
8
  yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
9
  "skip-migrate": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
@@ -5,6 +5,7 @@ const core_1 = require("@oclif/core");
5
5
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
6
6
  const config_1 = require("../lib/config");
7
7
  const services_1 = require("../lib/api/services");
8
+ const environments_1 = require("../lib/environments");
8
9
  const upgrade_1 = require("../lib/upgrade");
9
10
  const git = tslib_1.__importStar(require("../lib/git"));
10
11
  const apsorc_parser_1 = require("../lib/apsorc-parser");
@@ -15,6 +16,7 @@ const server_push_1 = require("../lib/deploy/server-push");
15
16
  class Deploy extends base_command_1.default {
16
17
  async run() {
17
18
  const { flags } = await this.parse(Deploy);
19
+ const env = (0, environments_1.asEnv)(flags.env);
18
20
  // Auth guard
19
21
  if (!config_1.credentials.isValid()) {
20
22
  this.error("Not logged in. Run 'apso login' first.");
@@ -73,10 +75,10 @@ class Deploy extends base_command_1.default {
73
75
  }
74
76
  // Determine source
75
77
  if (service.githubRepo || flags.repo) {
76
- this.log(`Deploying "${service.name}" from ${service.githubRepo || flags.repo}#${flags.branch}...`);
78
+ this.log(`Deploying "${service.name}" (${env}) from ${service.githubRepo || flags.repo}#${flags.branch}...`);
77
79
  }
78
80
  else {
79
- this.log(`Deploying "${service.name}" via Apso Platform...`);
81
+ this.log(`Deploying "${service.name}" (${env}) via Apso Platform...`);
80
82
  }
81
83
  // Confirmation
82
84
  if (!flags.yes && migrationCount > 0) {
@@ -98,24 +100,27 @@ class Deploy extends base_command_1.default {
98
100
  }
99
101
  }
100
102
  // Trigger build
101
- const build = await (0, upgrade_1.withUpgradeRetry)(() => services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug));
103
+ const build = await (0, upgrade_1.withUpgradeRetry)(() => services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug, env));
102
104
  this.log(`Build started: ${build.id}`);
103
105
  if (flags["no-wait"]) {
104
- this.log("Deploy triggered. Run 'apso status' to check progress.");
106
+ this.log(`Deploy triggered. Run 'apso status --env ${env}' to check progress.`);
105
107
  return;
106
108
  }
107
109
  // Poll with spinner
108
110
  const spinner = await (0, spinner_1.createSpinner)("Deploying...");
109
111
  spinner.start();
110
112
  const finalStatus = await services_1.buildApi.waitForCompletion(build.id, {
113
+ env,
111
114
  onProgress: (status) => {
112
115
  spinner.text = `Deploy status: ${status.status}...`;
113
116
  },
114
117
  });
115
118
  if (finalStatus.status === "success") {
116
119
  spinner.succeed("Deploy succeeded.");
117
- if (service.endpoint) {
118
- this.log(`Endpoint: ${service.endpoint}`);
120
+ const row = env === "production" ? undefined : await services_1.envApi.get(service.id, env);
121
+ const endpoint = row ? row.url : service.endpoint;
122
+ if (endpoint) {
123
+ this.log(`Endpoint: ${endpoint}`);
119
124
  }
120
125
  }
121
126
  else {
@@ -271,8 +276,10 @@ Deploy.examples = [
271
276
  `$ apso deploy --yes`,
272
277
  `$ apso deploy --skip-migrate`,
273
278
  `$ apso deploy --no-wait`,
279
+ `$ apso deploy --env staging`,
274
280
  ];
275
281
  Deploy.flags = {
282
+ env: environments_1.envFlag,
276
283
  "no-wait": core_1.Flags.boolean({
277
284
  description: "Trigger deploy without waiting for completion",
278
285
  default: false,
@@ -0,0 +1,12 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ export default class EnvAdd extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ environment: import("@oclif/core/lib/interfaces/parser").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const core_1 = require("@oclif/core");
5
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
6
+ const services_1 = require("../../lib/api/services");
7
+ const environments_1 = require("../../lib/environments");
8
+ class EnvAdd extends base_command_1.default {
9
+ async run() {
10
+ const { flags } = await this.parse(EnvAdd);
11
+ const service = await (0, environments_1.linkedService)(this);
12
+ if (await services_1.envApi.get(service.id, "staging")) {
13
+ this.log(`"${service.name}" already has staging. Deploy to it with 'apso deploy --env staging'.`);
14
+ return;
15
+ }
16
+ const ok = await (0, environments_1.confirmOrExit)(this, flags.yes, `Add staging to "${service.name}"? This starts the paid staging add-on.`);
17
+ if (!ok)
18
+ return this.log("Cancelled.");
19
+ await services_1.envApi.addStaging(service.id);
20
+ this.log(`Staging is being created for "${service.name}". Deploy to it with 'apso deploy --env staging'.`);
21
+ }
22
+ }
23
+ EnvAdd.description = "Add a staging environment to the linked service. It gets its own stack, URL and database branch, and starts the paid staging add-on.";
24
+ EnvAdd.examples = [`$ apso env add staging`, `$ apso env add staging --yes`];
25
+ EnvAdd.args = {
26
+ environment: core_1.Args.string({ description: "Environment to add", options: ["staging"], required: true }),
27
+ };
28
+ EnvAdd.flags = {
29
+ yes: core_1.Flags.boolean({ char: "y", description: "Skip the confirmation prompt", default: false }),
30
+ };
31
+ exports.default = EnvAdd;
@@ -0,0 +1,9 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ export default class EnvList extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const core_1 = require("@oclif/core");
5
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
6
+ const services_1 = require("../../lib/api/services");
7
+ const environments_1 = require("../../lib/environments");
8
+ class EnvList extends base_command_1.default {
9
+ async run() {
10
+ var _a;
11
+ const { flags } = await this.parse(EnvList);
12
+ const service = await (0, environments_1.linkedService)(this);
13
+ const envs = await services_1.envApi.list(service.id);
14
+ if (flags.json) {
15
+ this.log(JSON.stringify(envs, null, 2));
16
+ return;
17
+ }
18
+ for (const e of envs) {
19
+ this.log(`${e.environment.padEnd(12)}${(e.buildStatus || e.status || "-").padEnd(22)}${(_a = e.url) !== null && _a !== void 0 ? _a : "-"}`);
20
+ }
21
+ if (!envs.some((e) => e.environment === "staging")) {
22
+ this.log("\nNo staging environment. Add one with 'apso env add staging'.");
23
+ }
24
+ }
25
+ }
26
+ EnvList.description = "List the linked service's environments (production, staging)";
27
+ EnvList.examples = [`$ apso env list`, `$ apso env list --json`];
28
+ EnvList.flags = {
29
+ json: core_1.Flags.boolean({ description: "Output as JSON", default: false }),
30
+ };
31
+ exports.default = EnvList;
@@ -0,0 +1,12 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ export default class EnvRemove extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ environment: import("@oclif/core/lib/interfaces/parser").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const core_1 = require("@oclif/core");
5
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
6
+ const services_1 = require("../../lib/api/services");
7
+ const environments_1 = require("../../lib/environments");
8
+ class EnvRemove extends base_command_1.default {
9
+ async run() {
10
+ const { flags } = await this.parse(EnvRemove);
11
+ const service = await (0, environments_1.linkedService)(this);
12
+ if (!(await services_1.envApi.get(service.id, "staging"))) {
13
+ this.log(`"${service.name}" has no staging environment.`);
14
+ return;
15
+ }
16
+ const ok = await (0, environments_1.confirmOrExit)(this, flags.yes, `Tear down staging for "${service.name}"? Its stack and database branch are deleted.`);
17
+ if (!ok)
18
+ return this.log("Cancelled.");
19
+ await services_1.envApi.removeStaging(service.id);
20
+ this.log("Staging is being torn down. Production is unchanged.");
21
+ }
22
+ }
23
+ EnvRemove.description = "Tear down the linked service's staging environment and stop the add-on. Production is untouched.";
24
+ EnvRemove.examples = [`$ apso env remove staging`, `$ apso env remove staging --yes`];
25
+ EnvRemove.args = {
26
+ environment: core_1.Args.string({ description: "Environment to remove", options: ["staging"], required: true }),
27
+ };
28
+ EnvRemove.flags = {
29
+ yes: core_1.Flags.boolean({ char: "y", description: "Skip the confirmation prompt", default: false }),
30
+ };
31
+ exports.default = EnvRemove;
@@ -3,6 +3,7 @@ export default class Open extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ env: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
6
7
  endpoint: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
8
  dashboard: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
9
  };
@@ -8,6 +8,7 @@ const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
8
8
  const config_1 = require("../lib/config");
9
9
  const config_2 = require("../lib/config");
10
10
  const services_1 = require("../lib/api/services");
11
+ const environments_1 = require("../lib/environments");
11
12
  class Open extends base_command_1.default {
12
13
  async run() {
13
14
  const { flags } = await this.parse(Open);
@@ -24,11 +25,14 @@ class Open extends base_command_1.default {
24
25
  if (flags.endpoint) {
25
26
  // Open the live API endpoint
26
27
  const service = await services_1.servicesApi.get(link.workspaceSlug, link.serviceSlug);
27
- if (!service.endpoint) {
28
- this.error("Service has no endpoint. Deploy first with 'apso deploy'.");
28
+ const env = (0, environments_1.asEnv)(flags.env);
29
+ const row = env === "production" ? undefined : await services_1.envApi.get(service.id, env);
30
+ const endpoint = row ? row.url : service.endpoint;
31
+ if (!endpoint) {
32
+ this.error(`Service has no ${env} endpoint. Deploy first with 'apso deploy --env ${env}'.`);
29
33
  }
30
- this.log(`Opening endpoint: ${service.endpoint}`);
31
- openUrl(service.endpoint);
34
+ this.log(`Opening endpoint: ${endpoint}`);
35
+ openUrl(endpoint);
32
36
  }
33
37
  else {
34
38
  // Open the dashboard (default)
@@ -43,8 +47,10 @@ Open.examples = [
43
47
  `$ apso open`,
44
48
  `$ apso open --endpoint`,
45
49
  `$ apso open --dashboard`,
50
+ `$ apso open --endpoint --env staging`,
46
51
  ];
47
52
  Open.flags = {
53
+ env: environments_1.envFlag,
48
54
  endpoint: core_1.Flags.boolean({
49
55
  char: "e",
50
56
  description: "Open the service API endpoint",
@@ -0,0 +1,14 @@
1
+ import BaseCommand from "../lib/base-command";
2
+ import { type PromotePreview } from "../lib/api/services";
3
+ export default class Promote extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
+ "dry-run": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ "no-wait": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
14
+ export declare function describePreview(p: PromotePreview): string;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describePreview = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const core_1 = require("@oclif/core");
6
+ const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
7
+ const services_1 = require("../lib/api/services");
8
+ const environments_1 = require("../lib/environments");
9
+ const spinner_1 = require("../lib/utils/spinner");
10
+ class Promote extends base_command_1.default {
11
+ async run() {
12
+ const { flags } = await this.parse(Promote);
13
+ const service = await (0, environments_1.linkedService)(this);
14
+ const preview = await services_1.envApi.promotePreview(service.id);
15
+ if (flags.json)
16
+ this.log(JSON.stringify(preview, null, 2));
17
+ else
18
+ this.log(describePreview(preview));
19
+ if (preview.blocked)
20
+ this.error(`Promote blocked: ${preview.blocked}`);
21
+ if (flags["dry-run"])
22
+ return;
23
+ const ok = await (0, environments_1.confirmOrExit)(this, flags.yes, `Promote staging to production for "${service.name}"?`);
24
+ if (!ok)
25
+ return this.log("Cancelled.");
26
+ await services_1.envApi.promote(service.id);
27
+ if (flags["no-wait"]) {
28
+ this.log("Promote started. Run 'apso status' to check progress.");
29
+ return;
30
+ }
31
+ const spinner = await (0, spinner_1.createSpinner)("Promoting...");
32
+ spinner.start();
33
+ const result = await services_1.buildApi.waitForCompletion(service.id, {
34
+ onProgress: (s) => {
35
+ spinner.text = `Promote status: ${s.status}...`;
36
+ },
37
+ });
38
+ if (result.status === "success") {
39
+ spinner.succeed("Production now runs staging's build.");
40
+ }
41
+ else {
42
+ spinner.fail("Promote failed. Production is unchanged.");
43
+ this.exit(1);
44
+ }
45
+ }
46
+ }
47
+ Promote.description = "Promote staging to production. Production runs the image staging runs now, with no rebuild. Stops when the platform blocks the promote.";
48
+ Promote.examples = [
49
+ `$ apso promote`,
50
+ `$ apso promote --dry-run`,
51
+ `$ apso promote --dry-run --json`,
52
+ `$ apso promote --yes`,
53
+ ];
54
+ Promote.flags = {
55
+ yes: core_1.Flags.boolean({ char: "y", description: "Skip the confirmation prompt", default: false }),
56
+ "dry-run": core_1.Flags.boolean({ description: "Show what would be promoted and stop", default: false }),
57
+ json: core_1.Flags.boolean({ description: "Print the preview as JSON", default: false }),
58
+ "no-wait": core_1.Flags.boolean({ description: "Start the promote without waiting for it to finish", default: false }),
59
+ };
60
+ exports.default = Promote;
61
+ function describePreview(p) {
62
+ const lines = [`Commits: ${p.commits.length}`];
63
+ for (const c of p.commits.slice(0, 20))
64
+ lines.push(` ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]}`);
65
+ lines.push(`Migrations: ${p.migrations.length > 0 ? p.migrations.join(", ") : "none"}`);
66
+ if (p.missingEnvVars.length > 0)
67
+ lines.push(`Missing in production: ${p.missingEnvVars.join(", ")}`);
68
+ return lines.join("\n");
69
+ }
70
+ exports.describePreview = describePreview;
@@ -2,6 +2,8 @@ import BaseCommand from "../lib/base-command";
2
2
  export default class Status extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
- static flags: {};
5
+ static flags: {
6
+ env: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
+ };
6
8
  run(): Promise<void>;
7
9
  }
@@ -4,8 +4,11 @@ const tslib_1 = require("tslib");
4
4
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
5
5
  const config_1 = require("../lib/config");
6
6
  const services_1 = require("../lib/api/services");
7
+ const environments_1 = require("../lib/environments");
7
8
  class Status extends base_command_1.default {
8
9
  async run() {
10
+ const { flags } = await this.parse(Status);
11
+ const env = (0, environments_1.asEnv)(flags.env);
9
12
  // Auth guard
10
13
  if (!config_1.credentials.isValid()) {
11
14
  this.error("Not logged in. Run 'apso login' first.");
@@ -19,6 +22,21 @@ class Status extends base_command_1.default {
19
22
  const service = await services_1.servicesApi.get(link.workspaceId, link.serviceSlug);
20
23
  this.log(`Service: ${service.name} (${service.slug})`);
21
24
  this.log(`Workspace: ${link.workspaceSlug}`);
25
+ this.log(`Environment: ${env}`);
26
+ // Staging is its own row (stack, URL, build): show it and stop.
27
+ if (env !== "production") {
28
+ const row = await services_1.envApi.get(service.id, env);
29
+ if (!row) {
30
+ this.error(`"${service.name}" has no ${env} environment. Run 'apso env add ${env}'.`);
31
+ }
32
+ this.log(`Status: ${row.status || "-"}`);
33
+ this.log(`Build: ${row.buildStatus || "-"}`);
34
+ if (row.url)
35
+ this.log(`Endpoint: ${row.url}`);
36
+ if (row.lastDeployedAt)
37
+ this.log(`Last deployed: ${new Date(row.lastDeployedAt).toLocaleString()}`);
38
+ return;
39
+ }
22
40
  this.log(`Status: ${service.status}`);
23
41
  // Distinguish the account-level GitHub connection from this service's repo:
24
42
  // "GitHub: not connected" was misleading when the account WAS connected but
@@ -55,6 +73,6 @@ class Status extends base_command_1.default {
55
73
  }
56
74
  }
57
75
  Status.description = "Show the current service and latest build status";
58
- Status.examples = [`$ apso status`];
59
- Status.flags = {};
76
+ Status.examples = [`$ apso status`, `$ apso status --env staging`];
77
+ Status.flags = { env: environments_1.envFlag };
60
78
  exports.default = Status;
@@ -168,6 +168,12 @@ async function refreshAccessToken() {
168
168
  }),
169
169
  });
170
170
  if (!response.ok) {
171
+ // The server rejected the refresh token, so the stored session is dead. Clear it, or
172
+ // `apso login` trusts the local expiresAt and refuses to log in again (#178).
173
+ // Network errors land in the catch below and keep the credentials.
174
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
175
+ config_1.credentials.clear();
176
+ }
171
177
  return false;
172
178
  }
173
179
  const data = (await response.json());
@@ -84,16 +84,54 @@ export declare const schemaApi: {
84
84
  */
85
85
  export declare const buildApi: {
86
86
  /** Trigger a deploy for the linked service (POST /api/services/:id/deploy). */
87
- trigger(_workspaceSlug: string, serviceSlugOrId: string): Promise<BuildStatus>;
88
- /** No dedicated builds resource; derive status from the service record. */
89
- getStatus(serviceId: string): Promise<BuildStatus>;
87
+ trigger(_workspaceSlug: string, serviceSlugOrId: string, env?: ServiceEnv): Promise<BuildStatus>;
88
+ /** No dedicated builds resource: production reads the service record, staging its own environment row. */
89
+ getStatus(serviceId: string, env?: ServiceEnv): Promise<BuildStatus>;
90
90
  getLatest(_workspaceSlug: string, serviceSlugOrId: string): Promise<BuildStatus | null>;
91
91
  waitForCompletion(serviceId: string, options?: {
92
92
  maxWait?: number | undefined;
93
93
  pollInterval?: number | undefined;
94
94
  onProgress?: ((status: BuildStatus) => void) | undefined;
95
+ env?: ServiceEnv | undefined;
95
96
  } | undefined): Promise<BuildStatus>;
96
97
  };
98
+ /** Maps a platform build_status (service record or environment row) to the CLI's four states. */
99
+ export declare function toBuildStatus(serviceId: string, buildStatus: unknown, updatedAt?: string): BuildStatus;
100
+ /**
101
+ * Environments API. Each environment is a ServiceDeployment row with its own
102
+ * stack, URL, logs and database branch. Production always exists; staging is
103
+ * an add-on that `addStaging` starts (and bills) and `removeStaging` tears down.
104
+ */
105
+ export type ServiceEnv = "production" | "staging";
106
+ export declare const SERVICE_ENVS: ServiceEnv[];
107
+ export interface Environment {
108
+ id: string;
109
+ environment: string;
110
+ status: string;
111
+ buildStatus?: string;
112
+ url?: string;
113
+ lastDeployedAt?: string;
114
+ updatedAt?: string;
115
+ }
116
+ export interface PromotePreview {
117
+ blocked: string | null;
118
+ commits: {
119
+ sha: string;
120
+ message: string;
121
+ author?: string;
122
+ date?: string;
123
+ }[];
124
+ missingEnvVars: string[];
125
+ migrations: string[];
126
+ }
127
+ export declare const envApi: {
128
+ list(serviceId: string): Promise<Environment[]>;
129
+ get(serviceId: string, env: ServiceEnv): Promise<Environment | undefined>;
130
+ addStaging(serviceId: string): Promise<Raw>;
131
+ removeStaging(serviceId: string): Promise<Raw>;
132
+ promotePreview(serviceId: string): Promise<PromotePreview>;
133
+ promote(serviceId: string): Promise<Raw>;
134
+ };
97
135
  /**
98
136
  * GitHub API
99
137
  */
@@ -12,7 +12,7 @@
12
12
  * below.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.codeApi = exports.githubApi = exports.buildApi = exports.schemaApi = exports.servicesApi = exports.workspacesApi = exports.authApi = void 0;
15
+ exports.codeApi = exports.githubApi = exports.envApi = exports.SERVICE_ENVS = exports.toBuildStatus = exports.buildApi = exports.schemaApi = exports.servicesApi = exports.workspacesApi = exports.authApi = void 0;
16
16
  const client_1 = require("./client");
17
17
  function adaptWorkspace(raw) {
18
18
  var _a, _b, _c, _d;
@@ -236,14 +236,20 @@ function adaptSchemaFromService(raw) {
236
236
  */
237
237
  exports.buildApi = {
238
238
  /** Trigger a deploy for the linked service (POST /api/services/:id/deploy). */
239
- async trigger(_workspaceSlug, serviceSlugOrId) {
239
+ async trigger(_workspaceSlug, serviceSlugOrId, env = "production") {
240
240
  const id = await resolveServiceId(serviceSlugOrId);
241
- await client_1.api.post(`/api/services/${id}/deploy`, { target: "apso" });
242
- return buildStatusFromService(id);
241
+ await client_1.api.post(`/api/services/${id}/deploy`, { target: "apso", environment: env });
242
+ return this.getStatus(id, env);
243
243
  },
244
- /** No dedicated builds resource; derive status from the service record. */
245
- async getStatus(serviceId) {
246
- return buildStatusFromService(serviceId);
244
+ /** No dedicated builds resource: production reads the service record, staging its own environment row. */
245
+ async getStatus(serviceId, env = "production") {
246
+ var _a;
247
+ if (env === "production")
248
+ return buildStatusFromService(serviceId);
249
+ const row = await exports.envApi.get(serviceId, env);
250
+ if (!row)
251
+ throw new Error(`This service has no ${env} environment. Run 'apso env add ${env}'.`);
252
+ return toBuildStatus(serviceId, (_a = row.buildStatus) !== null && _a !== void 0 ? _a : row.status, row.updatedAt);
247
253
  },
248
254
  async getLatest(_workspaceSlug, serviceSlugOrId) {
249
255
  const id = await resolveServiceId(serviceSlugOrId);
@@ -256,7 +262,7 @@ exports.buildApi = {
256
262
  const startTime = Date.now();
257
263
  /* eslint-disable no-await-in-loop */
258
264
  while (Date.now() - startTime < maxWait) {
259
- const status = await this.getStatus(serviceId);
265
+ const status = await this.getStatus(serviceId, options === null || options === void 0 ? void 0 : options.env);
260
266
  if (options === null || options === void 0 ? void 0 : options.onProgress)
261
267
  options.onProgress(status);
262
268
  if (status.status === "success" || status.status === "failed")
@@ -270,18 +276,59 @@ exports.buildApi = {
270
276
  },
271
277
  };
272
278
  async function buildStatusFromService(serviceId) {
273
- var _a, _b, _c;
279
+ var _a;
274
280
  const raw = await client_1.api.get(`/api/services/${serviceId}`);
275
- const b = String((_a = raw.build_status) !== null && _a !== void 0 ? _a : "").toLowerCase();
281
+ return toBuildStatus(String(raw.id), raw.build_status, (_a = raw.updated_at) !== null && _a !== void 0 ? _a : raw.created_at);
282
+ }
283
+ /** Maps a platform build_status (service record or environment row) to the CLI's four states. */
284
+ function toBuildStatus(serviceId, buildStatus, updatedAt) {
285
+ const b = String(buildStatus !== null && buildStatus !== void 0 ? buildStatus : "").toLowerCase();
276
286
  const status = b === "ready" ? "success" : b === "failed" || b === "error" ? "failed" : b === "new" || b === "" ? "pending" : "building";
277
287
  return {
278
- id: String(raw.id),
279
- serviceId: String(raw.id),
288
+ id: serviceId,
289
+ serviceId,
280
290
  status,
281
- startedAt: (_c = (_b = raw.updated_at) !== null && _b !== void 0 ? _b : raw.created_at) !== null && _c !== void 0 ? _c : new Date().toISOString(),
282
- completedAt: status === "success" || status === "failed" ? raw.updated_at : undefined,
291
+ startedAt: updatedAt !== null && updatedAt !== void 0 ? updatedAt : new Date().toISOString(),
292
+ completedAt: status === "success" || status === "failed" ? updatedAt : undefined,
283
293
  };
284
294
  }
295
+ exports.toBuildStatus = toBuildStatus;
296
+ exports.SERVICE_ENVS = ["production", "staging"];
297
+ exports.envApi = {
298
+ async list(serviceId) {
299
+ var _a;
300
+ const raw = await client_1.api.get(`/api/services/${serviceId}/deployments`);
301
+ const rows = Array.isArray(raw) ? raw : (_a = raw === null || raw === void 0 ? void 0 : raw.data) !== null && _a !== void 0 ? _a : [];
302
+ return rows.map((r) => {
303
+ var _a, _b, _c, _d, _e;
304
+ return ({
305
+ id: String(r.id),
306
+ environment: String(r.environment_type),
307
+ status: String((_a = r.deployment_status) !== null && _a !== void 0 ? _a : ""),
308
+ buildStatus: (_b = r.build_status) !== null && _b !== void 0 ? _b : undefined,
309
+ url: (_c = r.deployment_url) !== null && _c !== void 0 ? _c : undefined,
310
+ lastDeployedAt: (_d = r.last_deployed_at) !== null && _d !== void 0 ? _d : undefined,
311
+ updatedAt: (_e = r.updated_at) !== null && _e !== void 0 ? _e : undefined,
312
+ });
313
+ });
314
+ },
315
+ async get(serviceId, env) {
316
+ const envs = await this.list(serviceId);
317
+ return envs.find((e) => e.environment === env);
318
+ },
319
+ addStaging(serviceId) {
320
+ return client_1.api.post(`/api/services/${serviceId}/environments/staging`);
321
+ },
322
+ removeStaging(serviceId) {
323
+ return client_1.api.delete(`/api/services/${serviceId}/environments/staging`);
324
+ },
325
+ promotePreview(serviceId) {
326
+ return client_1.api.get(`/api/services/${serviceId}/environments/promote-preview`);
327
+ },
328
+ promote(serviceId) {
329
+ return client_1.api.post(`/api/services/${serviceId}/promote`);
330
+ },
331
+ };
285
332
  /**
286
333
  * GitHub API
287
334
  */
@@ -0,0 +1,7 @@
1
+ import type { Command } from "@oclif/core";
2
+ import { type ServiceEnv } from "./api/services";
3
+ import type { Service } from "./api/types";
4
+ export declare const envFlag: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
5
+ export declare const asEnv: (value: string | undefined) => ServiceEnv;
6
+ export declare function linkedService(cmd: Command): Promise<Service>;
7
+ export declare function confirmOrExit(cmd: Command, yes: boolean, question: string): Promise<boolean>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.confirmOrExit = exports.linkedService = exports.asEnv = exports.envFlag = void 0;
27
+ const core_1 = require("@oclif/core");
28
+ const config_1 = require("./config");
29
+ const services_1 = require("./api/services");
30
+ const interactive_1 = require("./utils/interactive");
31
+ // --env for commands that act on one environment. Production is the default, so existing scripts behave as before.
32
+ exports.envFlag = core_1.Flags.string({
33
+ description: "Environment to target",
34
+ options: services_1.SERVICE_ENVS,
35
+ default: "production",
36
+ });
37
+ const asEnv = (value) => value === "staging" ? "staging" : "production";
38
+ exports.asEnv = asEnv;
39
+ // The linked service, after the login and link checks every platform command makes.
40
+ async function linkedService(cmd) {
41
+ if (!config_1.credentials.isValid())
42
+ cmd.error("Not logged in. Run 'apso login' first.");
43
+ const link = config_1.projectLink.read();
44
+ if (!link)
45
+ cmd.error("Project not linked. Run 'apso link' first.");
46
+ return services_1.servicesApi.get(link.workspaceId, link.serviceSlug);
47
+ }
48
+ exports.linkedService = linkedService;
49
+ // Asks before a step that costs money or replaces something; headless runs must pass --yes.
50
+ async function confirmOrExit(cmd, yes, question) {
51
+ if (yes)
52
+ return true;
53
+ if (!(0, interactive_1.isInteractive)())
54
+ cmd.error((0, interactive_1.missingFlag)(`${question} Pass --yes to proceed.`));
55
+ const inquirer = await Promise.resolve().then(() => __importStar(require("inquirer")));
56
+ const { ok } = await inquirer.default.prompt([
57
+ { type: "confirm", name: "ok", message: question, default: false },
58
+ ]);
59
+ return ok;
60
+ }
61
+ exports.confirmOrExit = confirmOrExit;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.46.3",
3
+ "version": "0.47.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.46.3",
9
+ "version": "0.47.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@apso/schema-tools": "^0.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.46.3",
2
+ "version": "0.47.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
@@ -49,9 +49,21 @@
49
49
  "$ apso deploy",
50
50
  "$ apso deploy --yes",
51
51
  "$ apso deploy --skip-migrate",
52
- "$ apso deploy --no-wait"
52
+ "$ apso deploy --no-wait",
53
+ "$ apso deploy --env staging"
53
54
  ],
54
55
  "flags": {
56
+ "env": {
57
+ "name": "env",
58
+ "type": "option",
59
+ "description": "Environment to target",
60
+ "multiple": false,
61
+ "options": [
62
+ "production",
63
+ "staging"
64
+ ],
65
+ "default": "production"
66
+ },
55
67
  "no-wait": {
56
68
  "name": "no-wait",
57
69
  "type": "boolean",
@@ -464,9 +476,21 @@
464
476
  "examples": [
465
477
  "$ apso open",
466
478
  "$ apso open --endpoint",
467
- "$ apso open --dashboard"
479
+ "$ apso open --dashboard",
480
+ "$ apso open --endpoint --env staging"
468
481
  ],
469
482
  "flags": {
483
+ "env": {
484
+ "name": "env",
485
+ "type": "option",
486
+ "description": "Environment to target",
487
+ "multiple": false,
488
+ "options": [
489
+ "production",
490
+ "staging"
491
+ ],
492
+ "default": "production"
493
+ },
470
494
  "endpoint": {
471
495
  "name": "endpoint",
472
496
  "type": "boolean",
@@ -515,6 +539,50 @@
515
539
  },
516
540
  "args": {}
517
541
  },
542
+ "promote": {
543
+ "id": "promote",
544
+ "description": "Promote staging to production. Production runs the image staging runs now, with no rebuild. Stops when the platform blocks the promote.",
545
+ "strict": true,
546
+ "pluginName": "@apso/cli",
547
+ "pluginAlias": "@apso/cli",
548
+ "pluginType": "core",
549
+ "aliases": [],
550
+ "hiddenAliases": [],
551
+ "examples": [
552
+ "$ apso promote",
553
+ "$ apso promote --dry-run",
554
+ "$ apso promote --dry-run --json",
555
+ "$ apso promote --yes"
556
+ ],
557
+ "flags": {
558
+ "yes": {
559
+ "name": "yes",
560
+ "type": "boolean",
561
+ "char": "y",
562
+ "description": "Skip the confirmation prompt",
563
+ "allowNo": false
564
+ },
565
+ "dry-run": {
566
+ "name": "dry-run",
567
+ "type": "boolean",
568
+ "description": "Show what would be promoted and stop",
569
+ "allowNo": false
570
+ },
571
+ "json": {
572
+ "name": "json",
573
+ "type": "boolean",
574
+ "description": "Print the preview as JSON",
575
+ "allowNo": false
576
+ },
577
+ "no-wait": {
578
+ "name": "no-wait",
579
+ "type": "boolean",
580
+ "description": "Start the promote without waiting for it to finish",
581
+ "allowNo": false
582
+ }
583
+ },
584
+ "args": {}
585
+ },
518
586
  "status": {
519
587
  "id": "status",
520
588
  "description": "Show the current service and latest build status",
@@ -525,9 +593,22 @@
525
593
  "aliases": [],
526
594
  "hiddenAliases": [],
527
595
  "examples": [
528
- "$ apso status"
596
+ "$ apso status",
597
+ "$ apso status --env staging"
529
598
  ],
530
- "flags": {},
599
+ "flags": {
600
+ "env": {
601
+ "name": "env",
602
+ "type": "option",
603
+ "description": "Environment to target",
604
+ "multiple": false,
605
+ "options": [
606
+ "production",
607
+ "staging"
608
+ ],
609
+ "default": "production"
610
+ }
611
+ },
531
612
  "args": {}
532
613
  },
533
614
  "unlink": {
@@ -629,6 +710,95 @@
629
710
  },
630
711
  "args": {}
631
712
  },
713
+ "env:add": {
714
+ "id": "env:add",
715
+ "description": "Add a staging environment to the linked service. It gets its own stack, URL and database branch, and starts the paid staging add-on.",
716
+ "strict": true,
717
+ "pluginName": "@apso/cli",
718
+ "pluginAlias": "@apso/cli",
719
+ "pluginType": "core",
720
+ "aliases": [],
721
+ "hiddenAliases": [],
722
+ "examples": [
723
+ "$ apso env add staging",
724
+ "$ apso env add staging --yes"
725
+ ],
726
+ "flags": {
727
+ "yes": {
728
+ "name": "yes",
729
+ "type": "boolean",
730
+ "char": "y",
731
+ "description": "Skip the confirmation prompt",
732
+ "allowNo": false
733
+ }
734
+ },
735
+ "args": {
736
+ "environment": {
737
+ "name": "environment",
738
+ "description": "Environment to add",
739
+ "required": true,
740
+ "options": [
741
+ "staging"
742
+ ]
743
+ }
744
+ }
745
+ },
746
+ "env:list": {
747
+ "id": "env:list",
748
+ "description": "List the linked service's environments (production, staging)",
749
+ "strict": true,
750
+ "pluginName": "@apso/cli",
751
+ "pluginAlias": "@apso/cli",
752
+ "pluginType": "core",
753
+ "aliases": [],
754
+ "hiddenAliases": [],
755
+ "examples": [
756
+ "$ apso env list",
757
+ "$ apso env list --json"
758
+ ],
759
+ "flags": {
760
+ "json": {
761
+ "name": "json",
762
+ "type": "boolean",
763
+ "description": "Output as JSON",
764
+ "allowNo": false
765
+ }
766
+ },
767
+ "args": {}
768
+ },
769
+ "env:remove": {
770
+ "id": "env:remove",
771
+ "description": "Tear down the linked service's staging environment and stop the add-on. Production is untouched.",
772
+ "strict": true,
773
+ "pluginName": "@apso/cli",
774
+ "pluginAlias": "@apso/cli",
775
+ "pluginType": "core",
776
+ "aliases": [],
777
+ "hiddenAliases": [],
778
+ "examples": [
779
+ "$ apso env remove staging",
780
+ "$ apso env remove staging --yes"
781
+ ],
782
+ "flags": {
783
+ "yes": {
784
+ "name": "yes",
785
+ "type": "boolean",
786
+ "char": "y",
787
+ "description": "Skip the confirmation prompt",
788
+ "allowNo": false
789
+ }
790
+ },
791
+ "args": {
792
+ "environment": {
793
+ "name": "environment",
794
+ "description": "Environment to remove",
795
+ "required": true,
796
+ "options": [
797
+ "staging"
798
+ ]
799
+ }
800
+ }
801
+ },
632
802
  "github:connect": {
633
803
  "id": "github:connect",
634
804
  "description": "Connect a GitHub account to push generated code to a repo",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.46.3",
3
+ "version": "0.47.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",
@@ -104,6 +104,9 @@
104
104
  },
105
105
  "import": {
106
106
  "description": "Import an existing database into a local .apsorc schema"
107
+ },
108
+ "env": {
109
+ "description": "Manage a service's environments (production, staging)"
107
110
  }
108
111
  },
109
112
  "update": {