@apso/cli 0.42.1 → 0.43.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.
@@ -4,10 +4,14 @@ const tslib_1 = require("tslib");
4
4
  const path = tslib_1.__importStar(require("path"));
5
5
  const core_1 = require("@oclif/core");
6
6
  const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
+ const apsorc_parser_1 = require("../../lib/apsorc-parser");
7
8
  const verify_1 = require("../../lib/verify");
8
9
  class MigrateGenerate extends base_command_1.default {
9
10
  async run() {
10
11
  const { flags } = await this.parse(MigrateGenerate);
12
+ const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
13
+ if (unsupported)
14
+ this.error(unsupported);
11
15
  const result = await (0, verify_1.generateMigration)(process.cwd(), path.resolve(flags.baseline), flags.name, (0, verify_1.cliBinPath)(this.config.root));
12
16
  const summary = result.needed
13
17
  ? `Created ${result.file}`
@@ -4,10 +4,14 @@ const tslib_1 = require("tslib");
4
4
  const path = tslib_1.__importStar(require("path"));
5
5
  const core_1 = require("@oclif/core");
6
6
  const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
+ const apsorc_parser_1 = require("../../lib/apsorc-parser");
7
8
  const verify_1 = require("../../lib/verify");
8
9
  class MigrateVerify extends base_command_1.default {
9
10
  async run() {
10
11
  const { flags } = await this.parse(MigrateVerify);
12
+ const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
13
+ if (unsupported)
14
+ this.error(unsupported);
11
15
  const result = await (0, verify_1.verifyMigration)(process.cwd(), path.resolve(flags.baseline), flags.migration, (0, verify_1.cliBinPath)(this.config.root), flags["skip-build"]);
12
16
  (0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? "Migration verified." : undefined);
13
17
  if (!result.ok)
@@ -7,23 +7,24 @@ const apsorc_parser_1 = require("../../lib/apsorc-parser");
7
7
  const verify_1 = require("../../lib/verify");
8
8
  class TestSmoke extends base_command_1.default {
9
9
  async run() {
10
+ var _a;
10
11
  const { flags } = await this.parse(TestSmoke);
11
- const { entities, auth } = (0, apsorc_parser_1.parseApsorc)();
12
+ const { entities, relationshipMap, auth, language } = (0, apsorc_parser_1.parseApsorc)();
12
13
  // With auth configured every entity route needs a session, so only /health is checked.
13
- const names = auth ? [] : entities.map((e) => e.name);
14
- const result = await (0, verify_1.smokeTest)(process.cwd(), names, {
15
- skipBuild: flags["skip-build"],
16
- });
17
- (0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? "Smoke test passed." : undefined);
14
+ const result = await (0, verify_1.smokeTest)(process.cwd(), auth ? [] : entities, relationshipMap, { skipBuild: flags["skip-build"], language });
15
+ const summary = ((_a = result.steps[0]) === null || _a === void 0 ? void 0 : _a.name) === "skipped"
16
+ ? "Smoke test skipped."
17
+ : "Smoke test passed.";
18
+ (0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? summary : undefined);
18
19
  if (!result.ok)
19
20
  this.exit(1);
20
21
  }
21
22
  }
22
- TestSmoke.description = "Start the service on a local PGlite database and call /health and every entity's list endpoint (TypeScript services)";
23
+ TestSmoke.description = "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers";
23
24
  TestSmoke.examples = [`$ apso test smoke`];
24
25
  TestSmoke.flags = {
25
26
  "skip-build": core_1.Flags.boolean({
26
- description: "Use the existing dist/ build instead of building first",
27
+ description: "Use the existing dist/ build instead of building first (TypeScript)",
27
28
  default: false,
28
29
  }),
29
30
  json: core_1.Flags.boolean({
@@ -156,6 +156,9 @@ class GoGenerator extends base_1.BaseGenerator {
156
156
  createPrimaryKey,
157
157
  primaryKeyType,
158
158
  snakeCasedName: (0, casing_1.snakeCase)(name),
159
+ // Honor an explicit per-entity `table` override; otherwise derive from
160
+ // the entity name. See apsoai/cli#107 (Python/Go parity with #98).
161
+ tableName: entity.table || (0, casing_1.snakeCase)(name),
159
162
  createdAt,
160
163
  updatedAt,
161
164
  pluralizedName: (0, casing_1.camelCase)((0, pluralize_1.default)(name)),
@@ -99,6 +99,11 @@ class PythonGenerator extends base_1.BaseGenerator {
99
99
  createPrimaryKey,
100
100
  primaryKeyType,
101
101
  snakeCasedName: (0, casing_1.snakeCase)(name),
102
+ // Honor an explicit per-entity `table` override; otherwise derive from
103
+ // the entity name. See apsoai/cli#107 (Python/Go parity with #98).
104
+ tableName: entity.table || (0, casing_1.snakeCase)(name),
105
+ // Foreign keys name the referenced entity's real table, override included.
106
+ tableNames: Object.fromEntries(allEntities.map((e) => [e.name, e.table || (0, casing_1.snakeCase)(e.name)])),
102
107
  createdAt,
103
108
  updatedAt,
104
109
  pluralizedName: (0, casing_1.camelCase)((0, pluralize_1.default)(name)),
@@ -9,7 +9,7 @@ import (
9
9
  <% } %>
10
10
  )
11
11
 
12
- // <%= it.name %> represents the <%= it.snakeCasedName %> table
12
+ // <%= it.name %> represents the <%= it.tableName %> table
13
13
  type <%= it.name %> struct {
14
14
  <% if (it.createPrimaryKey) { %>
15
15
  <% if (it.primaryKeyType === 'uuid') { %>
@@ -48,5 +48,5 @@ type <%= it.name %> struct {
48
48
 
49
49
  // TableName overrides the table name
50
50
  func (<%= it.name %>) TableName() string {
51
- return "<%= it.snakeCasedName %>"
51
+ return "<%= it.tableName %>"
52
52
  }
@@ -25,11 +25,11 @@ if TYPE_CHECKING:
25
25
 
26
26
  <% /* Generate ManyToMany junction tables */ %>
27
27
  <% it.associations.filter(a => a.type === 'ManyToMany' && a.joinTable).forEach((assoc) => { %>
28
- <%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %> = Table(
29
- "<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>",
28
+ <%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %> = Table(
29
+ "<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>",
30
30
  Base.metadata,
31
- Column("<%= assoc.joinColumnName || `${it.snakeCasedName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.snakeCasedName %>.id"), primary_key=True),
32
- Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= assoc.name.toLowerCase() %>.id"), primary_key=True),
31
+ Column("<%= assoc.joinColumnName || `${it.tableName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableName %>.id"), primary_key=True),
32
+ Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"), primary_key=True),
33
33
  )
34
34
  <% }) %>
35
35
 
@@ -37,13 +37,13 @@ class <%= it.name %>(Base):
37
37
  """
38
38
  SQLAlchemy model for <%= it.name %>
39
39
  """
40
- __tablename__ = "<%= it.snakeCasedName %>"
40
+ __tablename__ = "<%= it.tableName %>"
41
41
 
42
42
  <% /* Generate table arguments for indexes and unique constraints */ %>
43
43
  <% if ((it.indexes && it.indexes.length > 0) || (it.uniques && it.uniques.length > 0)) { %>
44
44
  __table_args__ = (
45
45
  <% it.indexes.forEach((index) => { %>
46
- Index("<%= index.name || `idx_${it.snakeCasedName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>),
46
+ Index("<%= index.name || `idx_${it.tableName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>),
47
47
  <% }) %>
48
48
  <% it.uniques.forEach((unique) => { %>
49
49
  UniqueConstraint(<%~ unique.fields.map(f => `"${f}"`).join(', ') %><% if (unique.name) { %>, name="<%= unique.name %>"<% } %>),
@@ -93,7 +93,7 @@ columnsToProcess.forEach((field) => { -%>
93
93
  # Foreign key for <%= assoc.name %>
94
94
  <%= assoc.camelCasedId %>: Mapped[<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>Optional[UUID]<% } else { %>Optional[int]<% } %>] = mapped_column(
95
95
  <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>,
96
- ForeignKey("<%= assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>),
96
+ ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>),
97
97
  nullable=<%= assoc.nullable ? 'True' : 'False' %><% if (assoc.index) { %>,
98
98
  index=True<% } %>
99
99
  )
@@ -105,7 +105,7 @@ columnsToProcess.forEach((field) => { -%>
105
105
  <% } else if (assoc.type === 'ManyToMany') { %>
106
106
  <%= assoc.pluralizedRelationshipName %>: Mapped[List["<%= assoc.name %>"]] = relationship(
107
107
  "<%= assoc.name %>",
108
- secondary=<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>,
108
+ secondary=<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>,
109
109
  <% if (assoc.biDirectional) { %>back_populates="<%= assoc.inverseSidePropertyName %>",<% } %>
110
110
  lazy="selectin"
111
111
  )
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What the smoke test creates, reads and deletes: one row per entity, built
3
+ * from the .apsorc, in an order where every required parent exists first.
4
+ */
5
+ import { Entity } from "../types/entity";
6
+ import { Field } from "../types/field";
7
+ import { RelationshipMap } from "../types/relationship";
8
+ /** A value for a required field, or undefined when its type can't be filled. */
9
+ export declare function sampleValue(field: Field): unknown;
10
+ export interface PlannedEntity {
11
+ name: string;
12
+ payload: Record<string, unknown>;
13
+ /** Foreign keys to fill from parents created earlier: property -> parent entity. */
14
+ parents: {
15
+ property: string;
16
+ entity: string;
17
+ }[];
18
+ }
19
+ export interface CrudPlan {
20
+ /** Parents before children. */
21
+ order: PlannedEntity[];
22
+ skipped: {
23
+ name: string;
24
+ reason: string;
25
+ }[];
26
+ }
27
+ export declare function planCrud(entities: Entity[], relationships: RelationshipMap): CrudPlan;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * What the smoke test creates, reads and deletes: one row per entity, built
4
+ * from the .apsorc, in an order where every required parent exists first.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.planCrud = exports.sampleValue = void 0;
8
+ const crypto_1 = require("crypto");
9
+ const relationships_1 = require("../utils/relationships");
10
+ const DATE = "2024-01-01T00:00:00.000Z";
11
+ /** A value for a required field, or undefined when its type can't be filled. */
12
+ function sampleValue(field) {
13
+ var _a, _b;
14
+ if (field.is_email)
15
+ return "smoke@example.com";
16
+ switch (field.type) {
17
+ case "text":
18
+ case "string":
19
+ case "varchar":
20
+ return "smoke".slice(0, Math.min(5, (_a = field.length) !== null && _a !== void 0 ? _a : 5));
21
+ case "integer":
22
+ case "int":
23
+ case "bigint":
24
+ return 1;
25
+ case "decimal":
26
+ case "numeric":
27
+ case "float":
28
+ return 1.5;
29
+ case "boolean":
30
+ return true;
31
+ case "date":
32
+ case "timestamp":
33
+ case "timestamptz":
34
+ case "datetime":
35
+ return DATE;
36
+ case "enum":
37
+ return (_b = field.values) === null || _b === void 0 ? void 0 : _b[0];
38
+ case "uuid":
39
+ return (0, crypto_1.randomUUID)();
40
+ case "json":
41
+ case "jsonb":
42
+ case "json-plain":
43
+ return {};
44
+ default:
45
+ return undefined;
46
+ }
47
+ }
48
+ exports.sampleValue = sampleValue;
49
+ /** Required = not nullable, no default, and not a user-declared primary key. */
50
+ const required = (field) => !field.nullable &&
51
+ (field.default === undefined || field.default === null) &&
52
+ !field.primary;
53
+ function planCrud(entities, relationships) {
54
+ const pending = [];
55
+ const skipped = [];
56
+ for (const entity of entities) {
57
+ const payload = {};
58
+ const bad = (entity.fields || [])
59
+ .filter((field) => required(field))
60
+ .find((field) => {
61
+ payload[field.name] = sampleValue(field);
62
+ return payload[field.name] === undefined;
63
+ });
64
+ if (bad) {
65
+ skipped.push({
66
+ name: entity.name,
67
+ reason: `required field "${bad.name}" has type "${bad.type}", which the smoke test can't fill`,
68
+ });
69
+ continue;
70
+ }
71
+ const parents = (relationships[entity.name] || [])
72
+ .filter((r) => r.type === "ManyToOne" && !r.nullable)
73
+ .map((r) => ({ property: (0, relationships_1.getRelationshipIdField)(r), entity: r.name }));
74
+ pending.push({ name: entity.name, payload, parents });
75
+ }
76
+ // Topological order: take entities whose required parents are all planned.
77
+ const order = [];
78
+ const planned = new Set();
79
+ let progress = true;
80
+ while (progress) {
81
+ progress = false;
82
+ for (const p of pending) {
83
+ if (planned.has(p.name))
84
+ continue;
85
+ if (p.parents.every((parent) => planned.has(parent.entity))) {
86
+ order.push(p);
87
+ planned.add(p.name);
88
+ progress = true;
89
+ }
90
+ }
91
+ }
92
+ for (const p of pending) {
93
+ if (planned.has(p.name))
94
+ continue;
95
+ const missing = p.parents
96
+ .map((parent) => parent.entity)
97
+ .filter((name) => !planned.has(name));
98
+ skipped.push({
99
+ name: p.name,
100
+ reason: `needs a ${missing.join(", ")} row first, which can't be created (a cycle of required relationships or a skipped entity)`,
101
+ });
102
+ }
103
+ return { order, skipped };
104
+ }
105
+ exports.planCrud = planCrud;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Pre-deploy checks for a generated TypeScript service, run against PGlite:
3
- * generate the migration from the deployed schema, run it and check for drift,
2
+ * Pre-deploy checks for a generated service: generate the migration from the
3
+ * deployed schema, run it and check for drift (TypeScript only, on PGlite),
4
4
  * and smoke test the endpoints. The CLI and the in-browser editor (which runs
5
5
  * the CLI in a WebContainer) share these steps.
6
6
  */
@@ -13,6 +13,9 @@
13
13
  /// <reference types="node" />
14
14
  /// <reference types="node" />
15
15
  /// <reference types="node" />
16
+ import { Entity } from "../types/entity";
17
+ import { TargetLanguage } from "../types/generator";
18
+ import { RelationshipMap } from "../types/relationship";
16
19
  export interface Step {
17
20
  name: string;
18
21
  ok: boolean;
@@ -30,6 +33,12 @@ export declare const pgliteEnv: (dataDir: string) => NodeJS.ProcessEnv;
30
33
  * it, and schema:sync.
31
34
  */
32
35
  export declare function buildBaseline(projectDir: string, baselineApsorc: string, dataDir: string, cliBin: string): Promise<Step>;
36
+ /**
37
+ * Why `apso migrate` can't run for this project's language, or undefined.
38
+ * ponytail: Python (Alembic) and Go (goose) have no local scratch database to
39
+ * build the baseline on; add when they get one like PGlite for TypeScript.
40
+ */
41
+ export declare const migrateUnsupported: (language?: TargetLanguage) => string | undefined;
33
42
  export interface GenerateResult {
34
43
  ok: boolean;
35
44
  needed: boolean;
@@ -48,16 +57,22 @@ export declare function supportsPglite(projectDir: string): boolean;
48
57
  * database matches the project's entities (a second generate finds nothing).
49
58
  */
50
59
  export declare function verifyMigration(projectDir: string, baselineApsorc: string, migrationFile: string, cliBin: string, skipBuild?: boolean): Promise<CheckResult>;
60
+ /** The route each language's generated controller serves an entity on. */
61
+ export declare const entityRoute: (language: TargetLanguage, name: string) => string;
62
+ export interface SmokeOptions {
63
+ skipBuild?: boolean;
64
+ timeoutMs?: number;
65
+ language?: TargetLanguage;
66
+ }
51
67
  /**
52
- * Start the built service on a PGlite database synced to its entities and call
53
- * GET /health and the list endpoint of every entity.
54
- * ponytail: list only, no create/read/delete; add when payloads can be built
55
- * from required fields and relationships.
68
+ * Start the service and call GET /health, every entity's list endpoint, and a
69
+ * create/read/delete of one row per entity. TypeScript services run on a
70
+ * PGlite database synced to their entities; Python and Go services run on the
71
+ * database in DATABASE_URL.
56
72
  */
57
- export declare function smokeTest(projectDir: string, entityNames: string[], { skipBuild, timeoutMs }?: {
58
- skipBuild?: boolean | undefined;
59
- timeoutMs?: number | undefined;
60
- }): Promise<CheckResult>;
73
+ export declare function smokeTest(projectDir: string, entities: Entity[], relationships?: RelationshipMap, { skipBuild, timeoutMs, language, }?: SmokeOptions): Promise<CheckResult>;
74
+ /** KEY=value lines of the project's .env; no interpolation. */
75
+ export declare function readDotEnv(projectDir: string): Record<string, string>;
61
76
  /** The CLI's own entry point, for running `generate` in the baseline copy. */
62
77
  export declare const cliBinPath: (root: string) => string;
63
78
  /** Print a check's steps (or JSON) through the command's logger. */
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
- * Pre-deploy checks for a generated TypeScript service, run against PGlite:
4
- * generate the migration from the deployed schema, run it and check for drift,
3
+ * Pre-deploy checks for a generated service: generate the migration from the
4
+ * deployed schema, run it and check for drift (TypeScript only, on PGlite),
5
5
  * and smoke test the endpoints. The CLI and the in-browser editor (which runs
6
6
  * the CLI in a WebContainer) share these steps.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.report = exports.cliBinPath = exports.smokeTest = exports.verifyMigration = exports.supportsPglite = exports.generateMigration = exports.buildBaseline = exports.pgliteEnv = exports.run = void 0;
9
+ exports.report = exports.cliBinPath = exports.readDotEnv = exports.smokeTest = exports.entityRoute = exports.verifyMigration = exports.supportsPglite = exports.generateMigration = exports.migrateUnsupported = exports.buildBaseline = exports.pgliteEnv = exports.run = void 0;
10
10
  const tslib_1 = require("tslib");
11
11
  const child_process_1 = require("child_process");
12
12
  const fs = tslib_1.__importStar(require("fs"));
@@ -15,6 +15,8 @@ const net = tslib_1.__importStar(require("net"));
15
15
  const os = tslib_1.__importStar(require("os"));
16
16
  const path = tslib_1.__importStar(require("path"));
17
17
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
18
+ const casing_1 = require("../utils/casing");
19
+ const crud_plan_1 = require("./crud-plan");
18
20
  // eslint-disable-next-line no-control-regex
19
21
  const plain = (s) => s.replace(/\u001B\[[\d;]*m/g, "");
20
22
  const tail = (s, n = 4000) => {
@@ -83,6 +85,19 @@ async function buildBaseline(projectDir, baselineApsorc, dataDir, cliBin) {
83
85
  }
84
86
  }
85
87
  exports.buildBaseline = buildBaseline;
88
+ /**
89
+ * Why `apso migrate` can't run for this project's language, or undefined.
90
+ * ponytail: Python (Alembic) and Go (goose) have no local scratch database to
91
+ * build the baseline on; add when they get one like PGlite for TypeScript.
92
+ */
93
+ const migrateUnsupported = (language = "typescript") => {
94
+ if (language === "python")
95
+ return "apso migrate supports TypeScript services only for now. This is a Python service: use Alembic directly (alembic revision --autogenerate, alembic upgrade head, alembic check).";
96
+ if (language === "go")
97
+ return "apso migrate supports TypeScript services only for now. This is a Go service: write goose migrations in migrations/ and run them with `go run cmd/migrate/main.go up`.";
98
+ return undefined;
99
+ };
100
+ exports.migrateUnsupported = migrateUnsupported;
86
101
  /** Generate a migration from the deployed schema (baselineApsorc) to the project's entities. */
87
102
  async function generateMigration(projectDir, baselineApsorc, name, cliBin) {
88
103
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
@@ -199,93 +214,279 @@ const freePort = () => new Promise((resolve, reject) => {
199
214
  });
200
215
  s.on("error", reject);
201
216
  });
202
- const get = (url) => new Promise((resolve) => {
217
+ const request = (method, url, body) => new Promise((resolve) => {
218
+ const data = body === undefined ? undefined : JSON.stringify(body);
219
+ const headers = data
220
+ ? {
221
+ "content-type": "application/json",
222
+ "content-length": Buffer.byteLength(data),
223
+ }
224
+ : {};
203
225
  http
204
- .get(url, { timeout: 15000 }, (res) => {
205
- let body = "";
226
+ .request(url, { method, headers, timeout: 15000 }, (res) => {
227
+ let text = "";
206
228
  res.on("data", (d) => {
207
- body += d;
229
+ text += d;
208
230
  });
209
- res.on("end", () => { var _a; return resolve({ status: (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0, body }); });
231
+ res.on("end", () => { var _a; return resolve({ status: (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0, body: text }); });
210
232
  })
211
233
  .on("timeout", function () {
212
234
  this.destroy(new Error("No response within 15s"));
213
235
  })
214
- .on("error", (e) => resolve({ status: 0, body: e.message }));
236
+ .on("error", (e) => resolve({ status: 0, body: e.message }))
237
+ .end(data);
215
238
  });
239
+ const get = (url) => request("GET", url);
240
+ const expectStatus = (res, ...ok) => ({
241
+ ok: ok.includes(res.status),
242
+ output: ok.includes(res.status)
243
+ ? undefined
244
+ : `${res.status} ${tail(res.body, 500)}`,
245
+ });
246
+ const idOf = (body) => {
247
+ var _a;
248
+ try {
249
+ return (_a = JSON.parse(body).id) !== null && _a !== void 0 ? _a : undefined;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ };
255
+ /** The route each language's generated controller serves an entity on. */
256
+ const entityRoute = (language, name) => {
257
+ const plural = (0, pluralize_1.default)(name);
258
+ if (language === "python")
259
+ return `/api/${plural.toLowerCase()}`;
260
+ if (language === "go")
261
+ return `/api/${(0, casing_1.kebabCase)(plural)}`;
262
+ return `/${plural}`;
263
+ };
264
+ exports.entityRoute = entityRoute;
265
+ /* eslint-disable no-await-in-loop */
216
266
  /**
217
- * Start the built service on a PGlite database synced to its entities and call
218
- * GET /health and the list endpoint of every entity.
219
- * ponytail: list only, no create/read/delete; add when payloads can be built
220
- * from required fields and relationships.
267
+ * List every entity, then create, read and delete one row of each: parents
268
+ * before children on the way in, children before parents on the way out.
221
269
  */
222
- async function smokeTest(projectDir, entityNames, { skipBuild = false, timeoutMs = 60000 } = {}) {
223
- var _a, _b;
224
- if (!supportsPglite(projectDir))
225
- return NO_PGLITE;
226
- const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
227
- const steps = [];
228
- const done = (s) => {
229
- steps.push(s);
230
- return s.ok;
231
- };
232
- let server;
233
- try {
234
- if (!skipBuild && !done(step("build", await build(projectDir))))
235
- return { ok: false, steps };
236
- if (!done(step("schema", await typeorm(projectDir, ["schema:sync"], (0, exports.pgliteEnv)(dataDir)))))
237
- return { ok: false, steps };
238
- const port = await freePort();
239
- let log = "";
240
- server = (0, child_process_1.spawn)(process.execPath, ["dist/main"], {
241
- cwd: projectDir,
242
- env: { ...process.env, ...(0, exports.pgliteEnv)(dataDir), APP_PORT: String(port) },
270
+ async function checkEndpoints(base, language, entities, relationships, done) {
271
+ const route = (name) => (0, exports.entityRoute)(language, name);
272
+ for (const { name } of entities) {
273
+ done({
274
+ name: `GET ${route(name)}`,
275
+ ...expectStatus(await get(base + route(name)), 200),
243
276
  });
244
- (_a = server.stdout) === null || _a === void 0 ? void 0 : _a.on("data", (d) => {
245
- log += d;
277
+ }
278
+ const plan = (0, crud_plan_1.planCrud)(entities, relationships);
279
+ for (const s of plan.skipped)
280
+ done({ name: `skip ${s.name}`, ok: true, output: s.reason });
281
+ const ids = new Map();
282
+ for (const p of plan.order) {
283
+ const r = route(p.name);
284
+ const failed = p.parents.find((parent) => !ids.has(parent.entity));
285
+ if (failed) {
286
+ done({
287
+ name: `skip ${p.name}`,
288
+ ok: true,
289
+ output: `needs a ${failed.entity} row, which failed to create`,
290
+ });
291
+ continue;
292
+ }
293
+ const body = { ...p.payload };
294
+ for (const parent of p.parents)
295
+ body[parent.property] = ids.get(parent.entity);
296
+ const created = await request("POST", base + r, body);
297
+ const id = [200, 201].includes(created.status)
298
+ ? idOf(created.body)
299
+ : undefined;
300
+ if (!done({
301
+ name: `POST ${r}`,
302
+ ok: id !== undefined,
303
+ output: id === undefined
304
+ ? `${created.status} ${tail(created.body, 500)}`
305
+ : undefined,
306
+ }))
307
+ continue;
308
+ ids.set(p.name, id);
309
+ done({
310
+ name: `GET ${r}/${id}`,
311
+ ...expectStatus(await get(`${base}${r}/${id}`), 200),
246
312
  });
247
- (_b = server.stderr) === null || _b === void 0 ? void 0 : _b.on("data", (d) => {
248
- log += d;
313
+ }
314
+ for (const p of [...plan.order].reverse()) {
315
+ if (!ids.has(p.name))
316
+ continue;
317
+ const url = `${route(p.name)}/${ids.get(p.name)}`;
318
+ done({
319
+ name: `DELETE ${url}`,
320
+ ...expectStatus(await request("DELETE", base + url), 200, 204),
249
321
  });
322
+ }
323
+ }
324
+ /** Wait for the server's /health, run check against it, and stop the server. */
325
+ async function serve(server, port, timeoutMs, done, check) {
326
+ var _a, _b;
327
+ let log = "";
328
+ (_a = server.stdout) === null || _a === void 0 ? void 0 : _a.on("data", (d) => {
329
+ log += d;
330
+ });
331
+ (_b = server.stderr) === null || _b === void 0 ? void 0 : _b.on("data", (d) => {
332
+ log += d;
333
+ });
334
+ // A command that can't start (e.g. no python3) has no pid; keep its error.
335
+ server.on("error", (e) => {
336
+ log += e.message;
337
+ });
338
+ try {
250
339
  const base = `http://127.0.0.1:${port}`;
251
340
  const deadline = Date.now() + timeoutMs;
252
341
  let health = await get(`${base}/health`);
253
342
  while (health.status !== 200 &&
254
343
  Date.now() < deadline &&
255
- server.exitCode === null) {
256
- // eslint-disable-next-line no-await-in-loop
344
+ server.exitCode === null &&
345
+ server.pid !== undefined) {
257
346
  await new Promise((r) => {
258
347
  setTimeout(r, 500);
259
348
  });
260
- // eslint-disable-next-line no-await-in-loop
261
349
  health = await get(`${base}/health`);
262
350
  }
263
- if (!done({
351
+ if (done({
264
352
  name: "start",
265
353
  ok: health.status === 200,
266
354
  output: health.status === 200 ? undefined : tail(log),
267
355
  }))
356
+ await check(base);
357
+ }
358
+ finally {
359
+ server.kill();
360
+ }
361
+ }
362
+ /**
363
+ * Start the service and call GET /health, every entity's list endpoint, and a
364
+ * create/read/delete of one row per entity. TypeScript services run on a
365
+ * PGlite database synced to their entities; Python and Go services run on the
366
+ * database in DATABASE_URL.
367
+ */
368
+ async function smokeTest(projectDir, entities, relationships = {}, { skipBuild = false, timeoutMs = 60000, language = "typescript", } = {}) {
369
+ if (language !== "typescript")
370
+ return smokeTestOnDatabaseUrl(projectDir, entities, relationships, language, timeoutMs);
371
+ if (!supportsPglite(projectDir))
372
+ return NO_PGLITE;
373
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
374
+ const steps = [];
375
+ const done = (s) => {
376
+ steps.push(s);
377
+ return s.ok;
378
+ };
379
+ try {
380
+ if (!skipBuild && !done(step("build", await build(projectDir))))
268
381
  return { ok: false, steps };
269
- for (const name of entityNames) {
270
- const route = `/${(0, pluralize_1.default)(name)}`;
271
- // eslint-disable-next-line no-await-in-loop
272
- const res = await get(base + route);
273
- done({
274
- name: `GET ${route}`,
275
- ok: res.status === 200,
276
- output: res.status === 200
277
- ? undefined
278
- : `${res.status} ${tail(res.body, 500)}`,
279
- });
280
- }
382
+ if (!done(step("schema", await typeorm(projectDir, ["schema:sync"], (0, exports.pgliteEnv)(dataDir)))))
383
+ return { ok: false, steps };
384
+ const port = await freePort();
385
+ const server = (0, child_process_1.spawn)(process.execPath, ["dist/main"], {
386
+ cwd: projectDir,
387
+ env: { ...process.env, ...(0, exports.pgliteEnv)(dataDir), APP_PORT: String(port) },
388
+ });
389
+ await serve(server, port, timeoutMs, done, (base) => checkEndpoints(base, language, entities, relationships, done));
281
390
  return { ok: steps.every((s) => s.ok), steps };
282
391
  }
283
392
  finally {
284
- server === null || server === void 0 ? void 0 : server.kill();
285
393
  fs.rmSync(dataDir, { recursive: true, force: true });
286
394
  }
287
395
  }
288
396
  exports.smokeTest = smokeTest;
397
+ /** KEY=value lines of the project's .env; no interpolation. */
398
+ function readDotEnv(projectDir) {
399
+ let text = "";
400
+ try {
401
+ text = fs.readFileSync(path.join(projectDir, ".env"), "utf8");
402
+ }
403
+ catch {
404
+ return {};
405
+ }
406
+ const env = {};
407
+ for (const line of text.split(/\r?\n/)) {
408
+ const m = line.match(/^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*?)\s*$/);
409
+ if (m)
410
+ env[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2");
411
+ }
412
+ return env;
413
+ }
414
+ exports.readDotEnv = readDotEnv;
415
+ const canConnect = (host, port) => new Promise((resolve) => {
416
+ const s = net.connect({ host, port, timeout: 3000 });
417
+ const end = (ok) => {
418
+ s.destroy();
419
+ resolve(ok);
420
+ };
421
+ s.on("connect", () => end(true));
422
+ s.on("timeout", () => end(false));
423
+ s.on("error", () => end(false));
424
+ });
425
+ const skipped = (output) => ({
426
+ ok: true,
427
+ steps: [{ name: "skipped", ok: true, output }],
428
+ });
429
+ /**
430
+ * Python and Go services have no embedded database, so they run against the
431
+ * one in DATABASE_URL (environment first, then .env), as configured: the
432
+ * smoke test never creates tables, and it skips when no database answers.
433
+ */
434
+ async function smokeTestOnDatabaseUrl(projectDir, entities, relationships, language, timeoutMs) {
435
+ const env = { ...readDotEnv(projectDir), ...process.env };
436
+ if (!env.DATABASE_URL)
437
+ return skipped("No DATABASE_URL in the environment or .env, so there is no database to run the service against. Set it and rerun.");
438
+ let db;
439
+ try {
440
+ db = new URL(env.DATABASE_URL);
441
+ }
442
+ catch {
443
+ return {
444
+ ok: false,
445
+ steps: [
446
+ { name: "database", ok: false, output: "DATABASE_URL is not a URL." },
447
+ ],
448
+ };
449
+ }
450
+ const dbPort = Number(db.port) || 5432;
451
+ if (!(await canConnect(db.hostname, dbPort)))
452
+ return skipped(`No database is listening at ${db.hostname}:${dbPort} (DATABASE_URL), so the service can't start. Start the database and rerun.`);
453
+ const steps = [];
454
+ const done = (s) => {
455
+ steps.push(s);
456
+ return s.ok;
457
+ };
458
+ const port = await freePort();
459
+ const serverEnv = { ...env, PORT: String(port) };
460
+ const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-smoke-"));
461
+ try {
462
+ let server;
463
+ if (language === "go") {
464
+ // A built binary, not `go run`: killing `go run` can leave the server running.
465
+ const bin = path.join(binDir, "server");
466
+ if (!done(step("build", await run("go", ["build", "-o", bin, "./cmd"], projectDir))))
467
+ return { ok: false, steps };
468
+ server = (0, child_process_1.spawn)(bin, [], { cwd: projectDir, env: serverEnv });
469
+ }
470
+ else {
471
+ const venv = path.join(projectDir, ".venv", "bin", "python");
472
+ const python = fs.existsSync(venv) ? venv : "python3";
473
+ server = (0, child_process_1.spawn)(python, [
474
+ "-m",
475
+ "uvicorn",
476
+ "app.main:app",
477
+ "--host",
478
+ "127.0.0.1",
479
+ "--port",
480
+ String(port),
481
+ ], { cwd: projectDir, env: serverEnv });
482
+ }
483
+ await serve(server, port, timeoutMs, done, (base) => checkEndpoints(base, language, entities, relationships, done));
484
+ return { ok: steps.every((s) => s.ok), steps };
485
+ }
486
+ finally {
487
+ fs.rmSync(binDir, { recursive: true, force: true });
488
+ }
489
+ }
289
490
  /** The CLI's own entry point, for running `generate` in the baseline copy. */
290
491
  const cliBinPath = (root) => path.join(root, "bin", "run");
291
492
  exports.cliBinPath = cliBinPath;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.42.1",
9
+ "version": "0.43.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.42.1",
2
+ "version": "0.43.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
@@ -997,7 +997,7 @@
997
997
  },
998
998
  "test:smoke": {
999
999
  "id": "test:smoke",
1000
- "description": "Start the service on a local PGlite database and call /health and every entity's list endpoint (TypeScript services)",
1000
+ "description": "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers",
1001
1001
  "strict": true,
1002
1002
  "pluginName": "@apso/cli",
1003
1003
  "pluginAlias": "@apso/cli",
@@ -1011,7 +1011,7 @@
1011
1011
  "skip-build": {
1012
1012
  "name": "skip-build",
1013
1013
  "type": "boolean",
1014
- "description": "Use the existing dist/ build instead of building first",
1014
+ "description": "Use the existing dist/ build instead of building first (TypeScript)",
1015
1015
  "allowNo": false
1016
1016
  },
1017
1017
  "json": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",