@apso/cli 0.23.0 → 0.25.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.
@@ -10,6 +10,7 @@ const git = tslib_1.__importStar(require("../lib/git"));
10
10
  const apsorc_parser_1 = require("../lib/apsorc-parser");
11
11
  const sandbox_1 = require("../lib/migrate/sandbox");
12
12
  const spinner_1 = require("../lib/utils/spinner");
13
+ const interactive_1 = require("../lib/utils/interactive");
13
14
  class Deploy extends base_command_1.default {
14
15
  async run() {
15
16
  const { flags } = await this.parse(Deploy);
@@ -78,6 +79,9 @@ class Deploy extends base_command_1.default {
78
79
  }
79
80
  // Confirmation
80
81
  if (!flags.yes && migrationCount > 0) {
82
+ if (!(0, interactive_1.isInteractive)()) {
83
+ this.error((0, interactive_1.missingFlag)(`About to deploy ${migrationCount} migration statement(s). Pass --yes to proceed.`));
84
+ }
81
85
  const inquirer = await Promise.resolve().then(() => tslib_1.__importStar(require("inquirer")));
82
86
  const { confirm } = await inquirer.default.prompt([
83
87
  {
@@ -136,9 +140,31 @@ class Deploy extends base_command_1.default {
136
140
  git.initRepo(cwd);
137
141
  // Pick the repo: prefer what the local project is already wired to, then the
138
142
  // service's connected repo, then --repo, then ask.
139
- const conn = await services_1.githubApi.getConnection();
143
+ let conn = await services_1.githubApi.getConnection();
140
144
  if (!conn.connected || !conn.connectionId) {
141
- this.error("GitHub is not connected. Run 'apso github connect' first, then re-run deploy.");
145
+ // Connecting GitHub is a browser OAuth flow a machine can't complete.
146
+ // Headless: fail with instructions. Interactive: offer to launch it now
147
+ // (opens the browser), wait for the human, then continue the deploy.
148
+ if (!(0, interactive_1.isInteractive)()) {
149
+ this.error("GitHub is not connected. Run 'apso github connect' first, then re-run deploy.");
150
+ }
151
+ const inquirer = await Promise.resolve().then(() => tslib_1.__importStar(require("inquirer")));
152
+ const { connectNow } = await inquirer.default.prompt([
153
+ {
154
+ type: "confirm",
155
+ name: "connectNow",
156
+ message: "GitHub is not connected (required to push code). Connect now? This opens your browser.",
157
+ default: true,
158
+ },
159
+ ]);
160
+ if (!connectNow) {
161
+ this.error("GitHub connection is required to deploy. Run 'apso github connect' when ready, then re-run deploy.");
162
+ }
163
+ await this.config.runCommand("github:connect", []);
164
+ conn = await services_1.githubApi.getConnection();
165
+ if (!conn.connected || !conn.connectionId) {
166
+ this.error("GitHub still isn't connected. Complete 'apso github connect' and re-run deploy.");
167
+ }
142
168
  }
143
169
  let repoFullName = git.localRepoFullName(cwd) || service.githubRepo || repoFlag;
144
170
  if (!repoFullName) {
@@ -10,6 +10,8 @@ const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
10
10
  const config_1 = require("../lib/config");
11
11
  const services_1 = require("../lib/api/services");
12
12
  const upgrade_1 = require("../lib/upgrade");
13
+ const interactive_1 = require("../lib/utils/interactive");
14
+ const config_2 = require("../lib/config");
13
15
  const template_1 = require("../lib/utils/template");
14
16
  class Init extends base_command_1.default {
15
17
  async run() {
@@ -28,18 +30,28 @@ class Init extends base_command_1.default {
28
30
  await (isAuthenticated ? this.runAuthenticated(flags) : this.runOffline(flags));
29
31
  }
30
32
  async runAuthenticated(flags) {
31
- // Ask: create new or clone existing
32
- const { action } = await inquirer_1.default.prompt([
33
- {
34
- type: "list",
35
- name: "action",
36
- message: "What would you like to do?",
37
- choices: [
38
- { name: "Create a new project", value: "create" },
39
- { name: "Clone an existing project", value: "clone" },
40
- ],
41
- },
42
- ]);
33
+ // Headless: init is the guided human path; require the essentials up-front so
34
+ // no prompt is reached. Agents should prefer 'apso generate' + 'apso link --create'.
35
+ if (!(0, interactive_1.isInteractive)() && (!flags.name || !flags.language)) {
36
+ this.error((0, interactive_1.missingFlag)("apso init needs --name and --language non-interactively (or use 'apso generate' + 'apso link --create')."));
37
+ }
38
+ // Ask: create new or clone existing. Headless: default to "create" (the
39
+ // scaffold path); requires --name/--language, which createNew enforces.
40
+ let action = "create";
41
+ if ((0, interactive_1.isInteractive)()) {
42
+ const answer = await inquirer_1.default.prompt([
43
+ {
44
+ type: "list",
45
+ name: "action",
46
+ message: "What would you like to do?",
47
+ choices: [
48
+ { name: "Create a new project", value: "create" },
49
+ { name: "Clone an existing project", value: "clone" },
50
+ ],
51
+ },
52
+ ]);
53
+ action = answer.action;
54
+ }
43
55
  await (action === "clone" ? this.cloneExisting() : this.createNew(flags));
44
56
  }
45
57
  async cloneExisting() {
@@ -158,26 +170,41 @@ class Init extends base_command_1.default {
158
170
  if (!resolvedPath.startsWith(path.resolve(process.cwd()))) {
159
171
  this.error("Invalid project name: path traversal detected");
160
172
  }
161
- // Select workspace
173
+ // Select workspace: active workspace (apso use), else prompt (interactive),
174
+ // else error headless.
162
175
  const workspaces = await services_1.workspacesApi.list();
163
176
  if (workspaces.length === 0) {
164
177
  this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
165
178
  }
166
- const { workspace } = await inquirer_1.default.prompt([
167
- {
168
- type: "list",
169
- name: "workspace",
170
- message: "Select a workspace:",
171
- choices: workspaces.map((ws) => ({
172
- name: `${ws.name} (${ws.slug})`,
173
- value: ws,
174
- })),
175
- },
176
- ]);
179
+ const activeSlug = config_2.globalConfig.read().activeWorkspaceSlug;
180
+ let workspace = activeSlug
181
+ ? workspaces.find((w) => w.slug === activeSlug)
182
+ : undefined;
183
+ if (!workspace) {
184
+ if (!(0, interactive_1.isInteractive)()) {
185
+ this.error((0, interactive_1.missingFlag)("Set a workspace first with 'apso use <slug>'."));
186
+ }
187
+ const answer = await inquirer_1.default.prompt([
188
+ {
189
+ type: "list",
190
+ name: "workspace",
191
+ message: "Select a workspace:",
192
+ choices: workspaces.map((ws) => ({
193
+ name: `${ws.name} (${ws.slug})`,
194
+ value: ws,
195
+ })),
196
+ },
197
+ ]);
198
+ workspace = answer.workspace;
199
+ }
200
+ if (!workspace) {
201
+ throw new Error("No workspace selected.");
202
+ }
203
+ const ws = workspace;
177
204
  // Create service on platform
178
- this.log(`Creating service "${projectName}" in workspace "${workspace.name}"...`);
205
+ this.log(`Creating service "${projectName}" in workspace "${ws.name}"...`);
179
206
  const serviceName = projectName;
180
- const service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.id, {
207
+ const service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(ws.id, {
181
208
  name: serviceName,
182
209
  }));
183
210
  // Clone template
@@ -196,6 +223,12 @@ class Init extends base_command_1.default {
196
223
  this.log(`\nProject created at ${projectPath}`);
197
224
  this.log(`Language: ${language}`);
198
225
  this.log(`Linked to workspace "${workspace.name}" / service "${service.name}"`);
226
+ this.log("");
227
+ this.log("Next steps:");
228
+ this.log(` cd ${projectName}`);
229
+ this.log(" # edit .apsorc to shape your schema, then:");
230
+ this.log(" apso generate # scaffold code from the schema");
231
+ this.log(" apso deploy # ship it (prompts to connect GitHub if needed)");
199
232
  }
200
233
  async runOffline(flags) {
201
234
  // Prompt for name if not provided
@@ -5,6 +5,7 @@ export default class Link extends BaseCommand {
5
5
  static flags: {
6
6
  workspace: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
7
  service: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ create: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
9
  force: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
10
  };
10
11
  run(): Promise<void>;
@@ -9,6 +9,7 @@ const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
9
9
  const config_1 = require("../lib/config");
10
10
  const services_1 = require("../lib/api/services");
11
11
  const upgrade_1 = require("../lib/upgrade");
12
+ const interactive_1 = require("../lib/utils/interactive");
12
13
  class Link extends base_command_1.default {
13
14
  async run() {
14
15
  const { flags } = await this.parse(Link);
@@ -24,6 +25,9 @@ class Link extends base_command_1.default {
24
25
  // Check if already linked
25
26
  if (config_1.projectLink.exists() && !flags.force) {
26
27
  const existing = config_1.projectLink.read();
28
+ if (!(0, interactive_1.isInteractive)()) {
29
+ this.error((0, interactive_1.missingFlag)(`Already linked to ${existing === null || existing === void 0 ? void 0 : existing.workspaceSlug}/${existing === null || existing === void 0 ? void 0 : existing.serviceSlug}. Pass --force to overwrite.`));
30
+ }
27
31
  const { confirm } = await inquirer_1.default.prompt([
28
32
  {
29
33
  type: "confirm",
@@ -37,16 +41,24 @@ class Link extends base_command_1.default {
37
41
  return;
38
42
  }
39
43
  }
40
- // Determine workspace
44
+ // Determine workspace: --workspace flag, then the active workspace, then a
45
+ // prompt (interactive only).
41
46
  let workspace;
47
+ const active = config_1.globalConfig.read();
42
48
  if (flags.workspace) {
43
49
  workspace = await services_1.workspacesApi.get(flags.workspace);
44
50
  }
51
+ else if (active.activeWorkspaceSlug) {
52
+ workspace = await services_1.workspacesApi.get(active.activeWorkspaceSlug);
53
+ }
45
54
  else {
46
55
  const workspaces = await services_1.workspacesApi.list();
47
56
  if (workspaces.length === 0) {
48
57
  this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
49
58
  }
59
+ if (!(0, interactive_1.isInteractive)()) {
60
+ this.error((0, interactive_1.missingFlag)("Pass --workspace <slug> or set one with 'apso use'."));
61
+ }
50
62
  const response = await inquirer_1.default.prompt([
51
63
  {
52
64
  type: "list",
@@ -60,11 +72,19 @@ class Link extends base_command_1.default {
60
72
  ]);
61
73
  workspace = response.workspace;
62
74
  }
63
- // Determine service
75
+ // Determine service: --service links an existing one, --create makes a new
76
+ // one, otherwise prompt (interactive only).
64
77
  let service;
65
78
  if (flags.service) {
66
79
  service = await services_1.servicesApi.get(workspace.id, flags.service);
67
80
  }
81
+ else if (flags.create) {
82
+ this.log(`Creating service "${flags.create}"...`);
83
+ service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.id, { name: flags.create }));
84
+ }
85
+ else if (!(0, interactive_1.isInteractive)()) {
86
+ this.error((0, interactive_1.missingFlag)("Pass --service <slug> to link an existing service, or --create <name> to make a new one."));
87
+ }
68
88
  else {
69
89
  const servicesResponse = await services_1.servicesApi.list(workspace.id);
70
90
  const services = servicesResponse.data;
@@ -87,7 +107,6 @@ class Link extends base_command_1.default {
87
107
  service = selectedService;
88
108
  }
89
109
  else {
90
- // Create new service
91
110
  const { serviceName } = await inquirer_1.default.prompt([
92
111
  {
93
112
  type: "input",
@@ -120,16 +139,21 @@ Link.description = "Link this project to a platform service";
120
139
  Link.examples = [
121
140
  `$ apso link`,
122
141
  `$ apso link --workspace my-team --service my-api`,
142
+ `$ apso link --workspace my-team --create my-new-api`,
123
143
  `$ apso link --force`,
124
144
  ];
125
145
  Link.flags = {
126
146
  workspace: core_1.Flags.string({
127
147
  char: "w",
128
- description: "Workspace slug",
148
+ description: "Workspace slug (defaults to the active workspace)",
129
149
  }),
130
150
  service: core_1.Flags.string({
131
151
  char: "s",
132
- description: "Service slug",
152
+ description: "Existing service slug to link",
153
+ }),
154
+ create: core_1.Flags.string({
155
+ char: "c",
156
+ description: "Create a new service with this name and link it",
133
157
  }),
134
158
  force: core_1.Flags.boolean({
135
159
  char: "f",
@@ -14,6 +14,7 @@ const child_process_1 = require("child_process");
14
14
  const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
15
15
  const client_1 = require("./api/client");
16
16
  const config_1 = require("./config");
17
+ const interactive_1 = require("./utils/interactive");
17
18
  /**
18
19
  * Open a URL in the default browser (best-effort).
19
20
  */
@@ -75,6 +76,12 @@ async function withUpgradeRetry(action) {
75
76
  }
76
77
  catch (error) {
77
78
  if (error instanceof client_1.ApiClientError && error.isLimitError()) {
79
+ // Headless (agent/CI): never open a browser or prompt — surface the limit
80
+ // as a clear error with the upgrade URL so the caller can act on it.
81
+ if (!(0, interactive_1.isInteractive)()) {
82
+ const upgradeUrl = `${config_1.globalConfig.read().webUrl}${error.upgradeUrl || "/billing/upgrade"}`;
83
+ throw new Error(`${error.message || "Plan limit reached."} Upgrade at ${upgradeUrl}`);
84
+ }
78
85
  const retry = await promptUpgrade(error);
79
86
  if (retry) {
80
87
  return action();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Interactive vs headless detection.
3
+ *
4
+ * Humans get prompts; agents and CI must run headless — every value comes from
5
+ * a flag and the CLI never blocks on a prompt. Headless is forced by
6
+ * APSO_NONINTERACTIVE=1, by CI=true, or auto-detected when stdin/stdout is not
7
+ * a TTY (e.g. an agent capturing output). Commands should check `isInteractive()`
8
+ * and, when false, require the equivalent flag instead of prompting.
9
+ */
10
+ export declare function isInteractive(): boolean;
11
+ /**
12
+ * Message for a missing required value in headless mode. Name the flag that
13
+ * would have supplied it so an agent can retry deterministically.
14
+ */
15
+ export declare function missingFlag(details: string): string;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.missingFlag = exports.isInteractive = void 0;
4
+ /**
5
+ * Interactive vs headless detection.
6
+ *
7
+ * Humans get prompts; agents and CI must run headless — every value comes from
8
+ * a flag and the CLI never blocks on a prompt. Headless is forced by
9
+ * APSO_NONINTERACTIVE=1, by CI=true, or auto-detected when stdin/stdout is not
10
+ * a TTY (e.g. an agent capturing output). Commands should check `isInteractive()`
11
+ * and, when false, require the equivalent flag instead of prompting.
12
+ */
13
+ function isInteractive() {
14
+ if (process.env.APSO_NONINTERACTIVE === "1" ||
15
+ process.env.APSO_NONINTERACTIVE === "true") {
16
+ return false;
17
+ }
18
+ if (process.env.CI === "true" || process.env.CI === "1") {
19
+ return false;
20
+ }
21
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
22
+ }
23
+ exports.isInteractive = isInteractive;
24
+ /**
25
+ * Message for a missing required value in headless mode. Name the flag that
26
+ * would have supplied it so an agent can retry deterministically.
27
+ */
28
+ function missingFlag(details) {
29
+ return `Running non-interactively (no TTY / CI / APSO_NONINTERACTIVE). ${details}`;
30
+ }
31
+ exports.missingFlag = missingFlag;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.23.0",
9
+ "version": "0.25.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@biomejs/biome": "^1.9.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.23.0",
2
+ "version": "0.25.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
@@ -259,6 +259,7 @@
259
259
  "examples": [
260
260
  "$ apso link",
261
261
  "$ apso link --workspace my-team --service my-api",
262
+ "$ apso link --workspace my-team --create my-new-api",
262
263
  "$ apso link --force"
263
264
  ],
264
265
  "flags": {
@@ -266,14 +267,21 @@
266
267
  "name": "workspace",
267
268
  "type": "option",
268
269
  "char": "w",
269
- "description": "Workspace slug",
270
+ "description": "Workspace slug (defaults to the active workspace)",
270
271
  "multiple": false
271
272
  },
272
273
  "service": {
273
274
  "name": "service",
274
275
  "type": "option",
275
276
  "char": "s",
276
- "description": "Service slug",
277
+ "description": "Existing service slug to link",
278
+ "multiple": false
279
+ },
280
+ "create": {
281
+ "name": "create",
282
+ "type": "option",
283
+ "char": "c",
284
+ "description": "Create a new service with this name and link it",
277
285
  "multiple": false
278
286
  },
279
287
  "force": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",