@apso/cli 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/deploy.d.ts +12 -0
- package/dist/commands/deploy.js +80 -3
- package/dist/commands/generate.js +55 -16
- package/dist/commands/github/connect.d.ts +19 -0
- package/dist/commands/github/connect.js +95 -0
- package/dist/commands/init.js +5 -3
- package/dist/commands/link.js +3 -2
- package/dist/commands/mcp/serve.js +15 -0
- package/dist/lib/api/client.d.ts +9 -0
- package/dist/lib/api/client.js +60 -1
- package/dist/lib/api/services.d.ts +45 -90
- package/dist/lib/api/services.js +259 -126
- package/dist/lib/apsorc-parser.d.ts +7 -0
- package/dist/lib/apsorc-parser.js +4 -0
- package/dist/lib/biome/biome.json +21 -0
- package/dist/lib/config/types.js +4 -1
- package/dist/lib/generators/typescript.js +2 -1
- package/dist/lib/git.d.ts +23 -0
- package/dist/lib/git.js +108 -0
- package/dist/lib/templates/guards/guards.module.eta +18 -12
- package/dist/lib/templates/rest/module-rest.eta +4 -5
- package/dist/lib/types/entity.d.ts +21 -0
- package/dist/lib/types/generator.d.ts +10 -0
- package/dist/lib/upgrade.d.ts +19 -0
- package/dist/lib/upgrade.js +86 -0
- package/npm-shrinkwrap.json +158 -2
- package/oclif.manifest.json +56 -1
- package/package.json +3 -2
|
@@ -6,6 +6,18 @@ export default class Deploy extends BaseCommand {
|
|
|
6
6
|
"no-wait": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
7
7
|
yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
8
8
|
"skip-migrate": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
"skip-push": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
repo: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
11
|
+
branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
9
12
|
};
|
|
10
13
|
run(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Push the local scaffold to the service's GitHub repo with the user's own
|
|
16
|
+
* git, and make sure the repo is connected to the service so the provisioner
|
|
17
|
+
* can clone it at deploy time. No code passes through Apso — we read the local
|
|
18
|
+
* git and push straight to GitHub.
|
|
19
|
+
*/
|
|
20
|
+
private syncCodeToGithub;
|
|
21
|
+
/** Let the user pick one of their GitHub repos to deploy from. */
|
|
22
|
+
private promptRepoSelection;
|
|
11
23
|
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -5,6 +5,8 @@ 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 upgrade_1 = require("../lib/upgrade");
|
|
9
|
+
const git = tslib_1.__importStar(require("../lib/git"));
|
|
8
10
|
const apsorc_parser_1 = require("../lib/apsorc-parser");
|
|
9
11
|
const sandbox_1 = require("../lib/migrate/sandbox");
|
|
10
12
|
const spinner_1 = require("../lib/utils/spinner");
|
|
@@ -62,9 +64,14 @@ class Deploy extends base_command_1.default {
|
|
|
62
64
|
this.log("No schema changes detected.");
|
|
63
65
|
}
|
|
64
66
|
}
|
|
67
|
+
// Sync local code to the connected GitHub repo (the deploy builds from it).
|
|
68
|
+
// Skip for redeploys of already-synced code.
|
|
69
|
+
if (!flags["skip-push"]) {
|
|
70
|
+
await this.syncCodeToGithub(service, flags.repo, flags.branch);
|
|
71
|
+
}
|
|
65
72
|
// Determine source
|
|
66
|
-
if (service.githubRepo) {
|
|
67
|
-
this.log(`Deploying "${service.name}" from ${service.githubRepo}#${
|
|
73
|
+
if (service.githubRepo || flags.repo) {
|
|
74
|
+
this.log(`Deploying "${service.name}" from ${service.githubRepo || flags.repo}#${flags.branch}...`);
|
|
68
75
|
}
|
|
69
76
|
else {
|
|
70
77
|
this.log(`Deploying "${service.name}" via Apso Platform...`);
|
|
@@ -86,7 +93,7 @@ class Deploy extends base_command_1.default {
|
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
95
|
// Trigger build
|
|
89
|
-
const build = await services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug);
|
|
96
|
+
const build = await (0, upgrade_1.withUpgradeRetry)(() => services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug));
|
|
90
97
|
this.log(`Build started: ${build.id}`);
|
|
91
98
|
if (flags["no-wait"]) {
|
|
92
99
|
this.log("Deploy triggered. Run 'apso status' to check progress.");
|
|
@@ -115,6 +122,65 @@ class Deploy extends base_command_1.default {
|
|
|
115
122
|
this.exit(1);
|
|
116
123
|
}
|
|
117
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Push the local scaffold to the service's GitHub repo with the user's own
|
|
127
|
+
* git, and make sure the repo is connected to the service so the provisioner
|
|
128
|
+
* can clone it at deploy time. No code passes through Apso — we read the local
|
|
129
|
+
* git and push straight to GitHub.
|
|
130
|
+
*/
|
|
131
|
+
async syncCodeToGithub(service, repoFlag, branch) {
|
|
132
|
+
const cwd = process.cwd();
|
|
133
|
+
if (!git.gitAvailable()) {
|
|
134
|
+
this.error("git is required to deploy. Install git and try again.");
|
|
135
|
+
}
|
|
136
|
+
git.initRepo(cwd);
|
|
137
|
+
// Pick the repo: prefer what the local project is already wired to, then the
|
|
138
|
+
// service's connected repo, then --repo, then ask.
|
|
139
|
+
const conn = await services_1.githubApi.getConnection();
|
|
140
|
+
if (!conn.connected || !conn.connectionId) {
|
|
141
|
+
this.error("GitHub is not connected. Run 'apso github connect' first, then re-run deploy.");
|
|
142
|
+
}
|
|
143
|
+
let repoFullName = git.localRepoFullName(cwd) || service.githubRepo || repoFlag;
|
|
144
|
+
if (!repoFullName) {
|
|
145
|
+
repoFullName = await this.promptRepoSelection(conn.connectionId);
|
|
146
|
+
}
|
|
147
|
+
// Connect the repo to the service (records ServiceRepository) unless the
|
|
148
|
+
// service is already linked to this exact repo.
|
|
149
|
+
if (service.githubRepo !== repoFullName) {
|
|
150
|
+
this.log(`Connecting repository ${repoFullName}...`);
|
|
151
|
+
await services_1.githubApi.connectRepo(service.id, conn.connectionId, repoFullName);
|
|
152
|
+
}
|
|
153
|
+
// Point the local repo at it and push with the user's git credentials.
|
|
154
|
+
git.setOrigin(cwd, git.httpsUrlFor(repoFullName));
|
|
155
|
+
const committed = git.commitAll(cwd, "Deploy via Apso CLI");
|
|
156
|
+
this.log(committed
|
|
157
|
+
? `Committed local changes; pushing to ${repoFullName}#${branch}...`
|
|
158
|
+
: `Pushing to ${repoFullName}#${branch}...`);
|
|
159
|
+
try {
|
|
160
|
+
git.pushHead(cwd, branch);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
164
|
+
this.error(`git push to ${repoFullName} failed. Ensure you have push access and your git credentials are set up.\n${msg}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Let the user pick one of their GitHub repos to deploy from. */
|
|
168
|
+
async promptRepoSelection(connectionId) {
|
|
169
|
+
const repos = await services_1.githubApi.listRepos("", { connectionId, pageSize: 100 });
|
|
170
|
+
if (!repos.data.length) {
|
|
171
|
+
this.error("No GitHub repositories available. Create one on GitHub (or pass --repo owner/name).");
|
|
172
|
+
}
|
|
173
|
+
const inquirer = await Promise.resolve().then(() => tslib_1.__importStar(require("inquirer")));
|
|
174
|
+
const { repo } = await inquirer.default.prompt([
|
|
175
|
+
{
|
|
176
|
+
type: "list",
|
|
177
|
+
name: "repo",
|
|
178
|
+
message: "Select a GitHub repository to deploy from:",
|
|
179
|
+
choices: repos.data.map((r) => ({ name: r.fullName, value: r.fullName })),
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
return repo;
|
|
183
|
+
}
|
|
118
184
|
}
|
|
119
185
|
Deploy.description = "Deploy the linked service";
|
|
120
186
|
Deploy.examples = [
|
|
@@ -137,5 +203,16 @@ Deploy.flags = {
|
|
|
137
203
|
description: "Skip local migration validation",
|
|
138
204
|
default: false,
|
|
139
205
|
}),
|
|
206
|
+
"skip-push": core_1.Flags.boolean({
|
|
207
|
+
description: "Skip uploading + pushing code (redeploy current code)",
|
|
208
|
+
default: false,
|
|
209
|
+
}),
|
|
210
|
+
repo: core_1.Flags.string({
|
|
211
|
+
description: "GitHub repo (owner/name) to connect when none is linked yet",
|
|
212
|
+
}),
|
|
213
|
+
branch: core_1.Flags.string({
|
|
214
|
+
description: "Branch to push to",
|
|
215
|
+
default: "main",
|
|
216
|
+
}),
|
|
140
217
|
};
|
|
141
218
|
exports.default = Deploy;
|
|
@@ -10,10 +10,11 @@ const perf_hooks_1 = require("perf_hooks");
|
|
|
10
10
|
const file_system_1 = require("../lib/utils/file-system");
|
|
11
11
|
class Generate extends base_command_1.default {
|
|
12
12
|
async run() {
|
|
13
|
+
var _a, _b;
|
|
13
14
|
const { flags } = await this.parse(Generate);
|
|
14
15
|
const skipFormat = flags["skip-format"];
|
|
15
16
|
const totalBuildStart = perf_hooks_1.performance.now();
|
|
16
|
-
const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, language: configLanguage } = (0, lib_1.parseApsorc)();
|
|
17
|
+
const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, language: configLanguage } = (0, lib_1.parseApsorc)();
|
|
17
18
|
// Resolve language: flag > .apsorc > prompt
|
|
18
19
|
let language;
|
|
19
20
|
if (flags.language) {
|
|
@@ -64,6 +65,7 @@ class Generate extends base_command_1.default {
|
|
|
64
65
|
relationshipMap,
|
|
65
66
|
auth,
|
|
66
67
|
emitEvents,
|
|
68
|
+
http,
|
|
67
69
|
};
|
|
68
70
|
const generator = (0, lib_1.createGenerator)(generatorConfig);
|
|
69
71
|
const validationResult = generator.validateConfig(generatorConfig);
|
|
@@ -94,8 +96,21 @@ class Generate extends base_command_1.default {
|
|
|
94
96
|
console.log(`[apso] Building... ${entity.name}`);
|
|
95
97
|
const entityBuildStart = perf_hooks_1.performance.now();
|
|
96
98
|
const entityRelationships = relationshipMap[entity.name] || [];
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
+
// Effective HTTP-controller flag (TypeScript REST only for now): entity
|
|
100
|
+
// overrides the top-level default, which defaults to true. When false, the
|
|
101
|
+
// controller is neither generated nor wired into the module (a hand-written
|
|
102
|
+
// controller owns the route). GraphQL uses resolvers, so the flag doesn't
|
|
103
|
+
// apply there. Python (FastAPI) and Go (Gin) wire routers differently and
|
|
104
|
+
// don't yet honor this flag — keep it a no-op for them (controllers always
|
|
105
|
+
// generated) so we never emit broken wiring (a skipped router still
|
|
106
|
+
// referenced by the index module). Tracked separately for Python/Go.
|
|
107
|
+
const includeController = language === "typescript" && lowerCaseApiType === "rest"
|
|
108
|
+
? ((_b = (_a = entity.http) !== null && _a !== void 0 ? _a : http) !== null && _b !== void 0 ? _b : true)
|
|
109
|
+
: true;
|
|
110
|
+
if (!includeController) {
|
|
111
|
+
console.log(`[apso] ${entity.name}: http=false — skipping generated controller`);
|
|
112
|
+
}
|
|
113
|
+
const generationTasks = [
|
|
99
114
|
generator.generateEntity({
|
|
100
115
|
entity,
|
|
101
116
|
relationships: entityRelationships,
|
|
@@ -115,20 +130,25 @@ class Generate extends base_command_1.default {
|
|
|
115
130
|
apiType: lowerCaseApiType,
|
|
116
131
|
relationshipMap,
|
|
117
132
|
}),
|
|
118
|
-
generator.
|
|
133
|
+
generator.generateModule({
|
|
119
134
|
entity,
|
|
120
135
|
relationships: entityRelationships,
|
|
121
136
|
allEntities: entities,
|
|
122
137
|
apiType: lowerCaseApiType,
|
|
123
|
-
|
|
138
|
+
includeController,
|
|
124
139
|
}),
|
|
125
|
-
|
|
140
|
+
];
|
|
141
|
+
if (includeController) {
|
|
142
|
+
generationTasks.push(generator.generateController({
|
|
126
143
|
entity,
|
|
127
144
|
relationships: entityRelationships,
|
|
128
145
|
allEntities: entities,
|
|
129
146
|
apiType: lowerCaseApiType,
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
relationshipMap,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
// eslint-disable-next-line no-await-in-loop
|
|
151
|
+
const allFiles = await Promise.all(generationTasks);
|
|
132
152
|
// eslint-disable-next-line no-await-in-loop
|
|
133
153
|
for (const files of allFiles) {
|
|
134
154
|
for (const file of files) {
|
|
@@ -164,17 +184,36 @@ class Generate extends base_command_1.default {
|
|
|
164
184
|
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
165
185
|
}
|
|
166
186
|
generatedFileCount += indexFiles.length;
|
|
167
|
-
// Format generated files (TypeScript only)
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
187
|
+
// Format generated files (TypeScript only) with Biome — bundled with the
|
|
188
|
+
// CLI and invoked directly, so it's a fast native pass (~ms) with no npx
|
|
189
|
+
// download and no dependence on the project having a formatter installed.
|
|
190
|
+
// Scoped to the autogen directory so we never touch hand-written code
|
|
191
|
+
// elsewhere in the project tree.
|
|
171
192
|
if (language === "typescript" && !skipFormat && generatedFileCount > 0) {
|
|
172
193
|
const formatStart = perf_hooks_1.performance.now();
|
|
173
194
|
console.log("[apso] Formatting generated files...");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
try {
|
|
196
|
+
const biomeBin = require.resolve("@biomejs/biome/bin/biome");
|
|
197
|
+
// Use the CLI's bundled Biome config: 2-space style (Biome defaults to
|
|
198
|
+
// tabs) and, critically, unsafeParameterDecoratorsEnabled so Biome can
|
|
199
|
+
// parse NestJS parameter decorators (@Body(), @ParsedRequest(), ...);
|
|
200
|
+
// without it Biome aborts with parse errors on the generated controllers.
|
|
201
|
+
const biomeConfigDir = path.join(__dirname, "..", "lib", "biome");
|
|
202
|
+
await this.runCommand("node", [
|
|
203
|
+
biomeBin,
|
|
204
|
+
"format",
|
|
205
|
+
"--write",
|
|
206
|
+
`--config-path=${biomeConfigDir}`,
|
|
207
|
+
autogenPath,
|
|
208
|
+
], true);
|
|
209
|
+
const formatTime = perf_hooks_1.performance.now() - formatStart;
|
|
210
|
+
console.log(`[apso] Finished formatting in ${formatTime.toFixed(2)} ms`);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// Formatting is best-effort — never fail generation over it.
|
|
214
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
215
|
+
console.warn(`[apso] Skipped formatting (${msg})`);
|
|
216
|
+
}
|
|
178
217
|
}
|
|
179
218
|
else if (skipFormat) {
|
|
180
219
|
console.log("[apso] Skipping formatting (--skip-format flag set)");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import BaseCommand from "../../lib/base-command";
|
|
2
|
+
/**
|
|
3
|
+
* `apso github connect` — walk the user through connecting their GitHub
|
|
4
|
+
* account so the CLI can push generated code to a real repository.
|
|
5
|
+
*
|
|
6
|
+
* The GitHub OAuth handshake is a browser+cookie flow on the app, so we open
|
|
7
|
+
* the app's authorize URL (where the user is signed in) and poll the platform
|
|
8
|
+
* until the connection appears — the same pattern as `apso login`.
|
|
9
|
+
*/
|
|
10
|
+
export default class GithubConnect extends BaseCommand {
|
|
11
|
+
static description: string;
|
|
12
|
+
static examples: string[];
|
|
13
|
+
static flags: {
|
|
14
|
+
help: import("@oclif/core/lib/interfaces").BooleanFlag<void>;
|
|
15
|
+
"workspace-id": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
16
|
+
timeout: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 child_process_1 = require("child_process");
|
|
6
|
+
const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
|
|
7
|
+
const config_1 = require("../../lib/config");
|
|
8
|
+
const services_1 = require("../../lib/api/services");
|
|
9
|
+
/**
|
|
10
|
+
* Open a URL in the default browser (best-effort).
|
|
11
|
+
*/
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const platform = process.platform;
|
|
15
|
+
const command = platform === "darwin"
|
|
16
|
+
? `open "${url}"`
|
|
17
|
+
: platform === "win32"
|
|
18
|
+
? `start "" "${url}"`
|
|
19
|
+
: `xdg-open "${url}"`;
|
|
20
|
+
(0, child_process_1.exec)(command, () => resolve());
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
|
+
/**
|
|
25
|
+
* `apso github connect` — walk the user through connecting their GitHub
|
|
26
|
+
* account so the CLI can push generated code to a real repository.
|
|
27
|
+
*
|
|
28
|
+
* The GitHub OAuth handshake is a browser+cookie flow on the app, so we open
|
|
29
|
+
* the app's authorize URL (where the user is signed in) and poll the platform
|
|
30
|
+
* until the connection appears — the same pattern as `apso login`.
|
|
31
|
+
*/
|
|
32
|
+
class GithubConnect extends base_command_1.default {
|
|
33
|
+
async run() {
|
|
34
|
+
var _a;
|
|
35
|
+
const { flags } = await this.parse(GithubConnect);
|
|
36
|
+
if (!config_1.credentials.isValid()) {
|
|
37
|
+
this.error("Not authenticated. Run 'apso login' first.");
|
|
38
|
+
}
|
|
39
|
+
const workspaceId = flags["workspace-id"] ||
|
|
40
|
+
process.env.APSO_WORKSPACE_ID ||
|
|
41
|
+
((_a = config_1.projectLink.read()) === null || _a === void 0 ? void 0 : _a.workspaceId);
|
|
42
|
+
if (!workspaceId) {
|
|
43
|
+
this.error("No workspace. Run 'apso link' first, or pass --workspace-id.");
|
|
44
|
+
}
|
|
45
|
+
// Already connected?
|
|
46
|
+
const existing = await services_1.githubApi.getConnection();
|
|
47
|
+
if (existing.connected) {
|
|
48
|
+
this.log(`GitHub is already connected${existing.login ? ` as ${existing.login}` : ""}.`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const config = config_1.globalConfig.read();
|
|
52
|
+
const authorizeUrl = `${config.webUrl}/api/github/authorize?workspaceId=${workspaceId}`;
|
|
53
|
+
this.log("Opening GitHub authorization in your browser...");
|
|
54
|
+
this.log("");
|
|
55
|
+
this.log("If the browser doesn't open, visit this URL:");
|
|
56
|
+
this.log(` ${authorizeUrl}`);
|
|
57
|
+
this.log("");
|
|
58
|
+
this.log("You must be signed in to the Apso app in your browser to authorize.");
|
|
59
|
+
this.log("");
|
|
60
|
+
await openBrowser(authorizeUrl);
|
|
61
|
+
this.log("Waiting for the GitHub connection to complete...");
|
|
62
|
+
const deadline = Date.now() + flags.timeout * 1000;
|
|
63
|
+
/* eslint-disable no-await-in-loop */
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
await sleep(3000);
|
|
66
|
+
try {
|
|
67
|
+
const conn = await services_1.githubApi.getConnection();
|
|
68
|
+
if (conn.connected) {
|
|
69
|
+
this.log("");
|
|
70
|
+
this.log(`Connected to GitHub${conn.login ? ` as ${conn.login}` : ""}.`);
|
|
71
|
+
this.log("Next: run 'apso deploy' to push your code and deploy.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// transient; keep polling
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/* eslint-enable no-await-in-loop */
|
|
80
|
+
this.error("Timed out waiting for the GitHub connection. Re-run 'apso github connect' to try again.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
GithubConnect.description = "Connect a GitHub account to push generated code to a repo";
|
|
84
|
+
GithubConnect.examples = ["$ apso github connect"];
|
|
85
|
+
GithubConnect.flags = {
|
|
86
|
+
help: core_1.Flags.help({ char: "h" }),
|
|
87
|
+
"workspace-id": core_1.Flags.string({
|
|
88
|
+
description: "Workspace id to connect (defaults to the linked project)",
|
|
89
|
+
}),
|
|
90
|
+
timeout: core_1.Flags.integer({
|
|
91
|
+
description: "Seconds to wait for the connection",
|
|
92
|
+
default: 300,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
exports.default = GithubConnect;
|
package/dist/commands/init.js
CHANGED
|
@@ -9,6 +9,7 @@ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
|
|
|
9
9
|
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
|
+
const upgrade_1 = require("../lib/upgrade");
|
|
12
13
|
const template_1 = require("../lib/utils/template");
|
|
13
14
|
class Init extends base_command_1.default {
|
|
14
15
|
async run() {
|
|
@@ -175,9 +176,10 @@ class Init extends base_command_1.default {
|
|
|
175
176
|
]);
|
|
176
177
|
// Create service on platform
|
|
177
178
|
this.log(`Creating service "${projectName}" in workspace "${workspace.name}"...`);
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
const serviceName = projectName;
|
|
180
|
+
const service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.slug, {
|
|
181
|
+
name: serviceName,
|
|
182
|
+
}));
|
|
181
183
|
// Clone template
|
|
182
184
|
(0, template_1.cloneTemplate)(projectPath, language, this.log.bind(this));
|
|
183
185
|
(0, template_1.initGitRepo)(projectPath);
|
package/dist/commands/link.js
CHANGED
|
@@ -8,6 +8,7 @@ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
|
|
|
8
8
|
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
|
+
const upgrade_1 = require("../lib/upgrade");
|
|
11
12
|
class Link extends base_command_1.default {
|
|
12
13
|
async run() {
|
|
13
14
|
const { flags } = await this.parse(Link);
|
|
@@ -96,9 +97,9 @@ class Link extends base_command_1.default {
|
|
|
96
97
|
},
|
|
97
98
|
]);
|
|
98
99
|
this.log(`Creating service "${serviceName}"...`);
|
|
99
|
-
service = await services_1.servicesApi.create(workspace.slug, {
|
|
100
|
+
service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.slug, {
|
|
100
101
|
name: serviceName,
|
|
101
|
-
});
|
|
102
|
+
}));
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
// Write link
|
|
@@ -924,6 +924,7 @@ const INLINE_SCHEMA_REFERENCE = `# Schema Quick Reference
|
|
|
924
924
|
"primaryKeyType": "serial",
|
|
925
925
|
"scopeBy": "organizationId",
|
|
926
926
|
"emitEvents": true,
|
|
927
|
+
"http": false,
|
|
927
928
|
"fields": [
|
|
928
929
|
{ "name": "fieldName", "type": "text", "length": 100 }
|
|
929
930
|
],
|
|
@@ -946,6 +947,20 @@ manifest at \`autogen/events/event-emitting.entities.ts\` exporting
|
|
|
946
947
|
\`@apso/domain-events\` library and is wired by the \`domain-events\` skill; the
|
|
947
948
|
CLI no longer generates the engine code.
|
|
948
949
|
|
|
950
|
+
## HTTP controllers (http)
|
|
951
|
+
Controllers are generated by default. Set \`http: false\` on an entity (or a
|
|
952
|
+
top-level \`http: false\` default) to NOT generate/mount its HTTP controller while
|
|
953
|
+
still generating the entity, service, DTOs, and module (\`TypeOrmModule.forFeature\`
|
|
954
|
+
+ the service). Use this when a hand-written controller owns the route for that
|
|
955
|
+
entity (e.g. a custom/Stripe-shaped surface). Effective value is
|
|
956
|
+
\`entity.http ?? <top-level http> ?? true\`.
|
|
957
|
+
|
|
958
|
+
Per framework: NestJS and Gin (Go) actually suppress the controller — those
|
|
959
|
+
frameworks can't cleanly override a generated route. For FastAPI it's a no-op:
|
|
960
|
+
override by registering your router ahead of the generated \`autogen_router\`
|
|
961
|
+
(FastAPI matches in registration order). Currently implemented for NestJS; Go
|
|
962
|
+
is tracked separately.
|
|
963
|
+
|
|
949
964
|
## Relationship Types
|
|
950
965
|
- OneToMany: parent has many children
|
|
951
966
|
- ManyToOne: child belongs to parent
|
package/dist/lib/api/client.d.ts
CHANGED
|
@@ -15,11 +15,20 @@ import { ApiRequestOptions } from "./types";
|
|
|
15
15
|
export declare class ApiClientError extends Error {
|
|
16
16
|
statusCode: number;
|
|
17
17
|
details?: Record<string, unknown> | undefined;
|
|
18
|
+
/** Machine code from the server (e.g. SERVICE_LIMIT_REACHED), when present. */
|
|
19
|
+
code?: string;
|
|
20
|
+
/** Relative URL to send the user to resolve the error (e.g. an upgrade page). */
|
|
21
|
+
upgradeUrl?: string;
|
|
18
22
|
constructor(message: string, statusCode: number, details?: Record<string, unknown> | undefined);
|
|
19
23
|
/**
|
|
20
24
|
* Check if error is due to authentication failure
|
|
21
25
|
*/
|
|
22
26
|
isAuthError(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Check if error is an entitlement/plan limit that the user can resolve by
|
|
29
|
+
* upgrading (e.g. the free-tier service or deploy limit).
|
|
30
|
+
*/
|
|
31
|
+
isLimitError(): boolean;
|
|
23
32
|
/**
|
|
24
33
|
* Check if error is a rate limit error
|
|
25
34
|
*/
|
package/dist/lib/api/client.js
CHANGED
|
@@ -40,6 +40,16 @@ class ApiClientError extends Error {
|
|
|
40
40
|
isAuthError() {
|
|
41
41
|
return this.statusCode === 401;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if error is an entitlement/plan limit that the user can resolve by
|
|
45
|
+
* upgrading (e.g. the free-tier service or deploy limit).
|
|
46
|
+
*/
|
|
47
|
+
isLimitError() {
|
|
48
|
+
return (this.statusCode === 403 &&
|
|
49
|
+
(this.code === "SERVICE_LIMIT_REACHED" ||
|
|
50
|
+
this.code === "LIMIT_REACHED" ||
|
|
51
|
+
Boolean(this.upgradeUrl)));
|
|
52
|
+
}
|
|
43
53
|
/**
|
|
44
54
|
* Check if error is a rate limit error
|
|
45
55
|
*/
|
|
@@ -82,6 +92,40 @@ function sleep(ms) {
|
|
|
82
92
|
setTimeout(resolve, ms);
|
|
83
93
|
});
|
|
84
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Pull an entitlement/limit code + upgrade URL out of an error body. Handles
|
|
97
|
+
* both a flat body ({ code, upgradeUrl, message }) and the BFF's wrapped shape
|
|
98
|
+
* where the backend error is a JSON string in `.error`.
|
|
99
|
+
*/
|
|
100
|
+
function extractLimitInfo(errorData) {
|
|
101
|
+
const candidates = [];
|
|
102
|
+
if (errorData && typeof errorData === "object") {
|
|
103
|
+
const obj = errorData;
|
|
104
|
+
candidates.push(obj);
|
|
105
|
+
if (typeof obj.error === "string") {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(obj.error);
|
|
108
|
+
if (parsed && typeof parsed === "object")
|
|
109
|
+
candidates.push(parsed);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* not JSON — ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const c of candidates) {
|
|
117
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
118
|
+
const upgradeUrl = typeof c.upgradeUrl === "string" ? c.upgradeUrl : undefined;
|
|
119
|
+
if (code || upgradeUrl) {
|
|
120
|
+
return {
|
|
121
|
+
code,
|
|
122
|
+
upgradeUrl,
|
|
123
|
+
message: typeof c.message === "string" ? c.message : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
85
129
|
/**
|
|
86
130
|
* Calculate backoff delay with jitter
|
|
87
131
|
*/
|
|
@@ -148,6 +192,7 @@ async function refreshAccessToken() {
|
|
|
148
192
|
*/
|
|
149
193
|
/* eslint-disable no-await-in-loop */
|
|
150
194
|
async function apiRequest(path, options = {}) {
|
|
195
|
+
var _a;
|
|
151
196
|
const { method = "GET", body, params, headers = {}, timeout = DEFAULT_TIMEOUT, skipAuth = false, } = options;
|
|
152
197
|
const config = config_1.globalConfig.read();
|
|
153
198
|
const url = buildUrl(config.apiUrl, path, params);
|
|
@@ -175,6 +220,16 @@ async function apiRequest(path, options = {}) {
|
|
|
175
220
|
if (freshCreds) {
|
|
176
221
|
requestHeaders.Authorization = `Bearer ${freshCreds.tokens.accessToken}`;
|
|
177
222
|
}
|
|
223
|
+
// The BFF's workspace-scoped routes read the active workspace from an
|
|
224
|
+
// X-Workspace-Id header for Bearer clients (browsers use a signed cookie).
|
|
225
|
+
// Resolve the active workspace id from, in order: an explicit header the
|
|
226
|
+
// caller passed, the APSO_WORKSPACE_ID env override, or the linked project.
|
|
227
|
+
if (!requestHeaders["X-Workspace-Id"]) {
|
|
228
|
+
const activeWorkspaceId = process.env.APSO_WORKSPACE_ID || ((_a = config_1.projectLink.read()) === null || _a === void 0 ? void 0 : _a.workspaceId);
|
|
229
|
+
if (activeWorkspaceId) {
|
|
230
|
+
requestHeaders["X-Workspace-Id"] = String(activeWorkspaceId);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
178
233
|
}
|
|
179
234
|
// Make request with retries
|
|
180
235
|
let lastError = null;
|
|
@@ -235,7 +290,11 @@ async function apiRequest(path, options = {}) {
|
|
|
235
290
|
continue;
|
|
236
291
|
}
|
|
237
292
|
// Client errors - don't retry
|
|
238
|
-
|
|
293
|
+
const limit = extractLimitInfo(errorData);
|
|
294
|
+
const clientError = new ApiClientError(limit.message || errorData.message || "Request failed", response.status, errorData.details);
|
|
295
|
+
clientError.code = limit.code;
|
|
296
|
+
clientError.upgradeUrl = limit.upgradeUrl;
|
|
297
|
+
throw clientError;
|
|
239
298
|
}
|
|
240
299
|
catch (error) {
|
|
241
300
|
if (error instanceof ApiClientError) {
|