@apso/cli 0.29.1 → 0.31.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.
@@ -8,13 +8,14 @@ const lib_1 = require("../lib");
8
8
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
9
9
  const perf_hooks_1 = require("perf_hooks");
10
10
  const file_system_1 = require("../lib/utils/file-system");
11
+ const git_hooks_1 = require("../lib/utils/git-hooks");
11
12
  class Generate extends base_command_1.default {
12
13
  async run() {
13
14
  var _a, _b;
14
15
  const { flags } = await this.parse(Generate);
15
16
  const skipFormat = flags["skip-format"];
16
17
  const totalBuildStart = perf_hooks_1.performance.now();
17
- const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, language: configLanguage } = (0, lib_1.parseApsorc)();
18
+ const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, coAuthor, language: configLanguage } = (0, lib_1.parseApsorc)();
18
19
  // Resolve language: flag > .apsorc > prompt
19
20
  let language;
20
21
  if (flags.language) {
@@ -235,6 +236,24 @@ class Generate extends base_command_1.default {
235
236
  }
236
237
  const totalBuildTime = perf_hooks_1.performance.now() - totalBuildStart;
237
238
  console.log(`[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms`);
239
+ // Best-effort: install the commit co-author hook so commits that include
240
+ // Apso-generated files credit Apso as a co-author. Never fails the command.
241
+ try {
242
+ const result = (0, git_hooks_1.installCoAuthorHook)(process.cwd(), {
243
+ disabled: coAuthor === false,
244
+ });
245
+ if (result.installed) {
246
+ console.log("[apso] Commit co-author hook installed (.apso/hooks)");
247
+ }
248
+ else if (result.reason === "custom-hookspath") {
249
+ console.log("[apso] A custom git core.hooksPath is already configured (e.g. husky), so the Apso co-author hook was not installed.\n" +
250
+ "[apso] To credit Apso on commits that include generated files, add this trailer to your existing prepare-commit-msg hook when the staged diff touches an `autogen/` path:\n" +
251
+ '[apso] git interpret-trailers --in-place --if-exists addIfDifferent --trailer "Co-authored-by: Apso <bot@apso.ai>" "$1"');
252
+ }
253
+ }
254
+ catch {
255
+ // Never let hook installation fail the generate command.
256
+ }
238
257
  }
239
258
  }
240
259
  Generate.description = "Generate code from .apsorc schema";
@@ -0,0 +1,42 @@
1
+ /// <reference types="node" />
2
+ import BaseCommand from "../../lib/base-command";
3
+ import { ApsorcImportOutput, ImportReport, Introspector } from "../../lib/import";
4
+ /**
5
+ * Resolve the Postgres connection string from an explicit flag or, failing
6
+ * that, the SUPABASE_DB_URL / DATABASE_URL environment variables. Returns
7
+ * undefined if none is set (the command then prompts interactively).
8
+ */
9
+ export declare function resolveConnectionString(flagValue: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
10
+ /**
11
+ * Run the full introspect -> convert -> validate pipeline against any
12
+ * Introspector. Throws if the generated relationships fail to parse. Pure of
13
+ * file/console I/O so it can be unit-tested with a fake Introspector.
14
+ */
15
+ export declare function importFromIntrospector(introspector: Introspector, schema: string): Promise<{
16
+ apsorc: ApsorcImportOutput;
17
+ report: ImportReport;
18
+ unknownTypes: string[];
19
+ }>;
20
+ /** Render the import report as a human-readable summary (contains no secrets). */
21
+ export declare function formatReport(report: ImportReport): string;
22
+ /**
23
+ * Write the generated .apsorc to disk, backing up any existing file first.
24
+ * Returns the backup path if one was made.
25
+ */
26
+ export declare function writeApsorcFile(apsorc: ApsorcImportOutput, outPath: string): {
27
+ backupPath?: string;
28
+ };
29
+ export default class ImportSupabase extends BaseCommand {
30
+ static description: string;
31
+ static examples: string[];
32
+ static flags: {
33
+ "connection-string": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
34
+ schema: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
35
+ out: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
36
+ "dry-run": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
37
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
38
+ };
39
+ /** Overridable for testing. */
40
+ protected makeIntrospector(connectionString: string): Introspector;
41
+ run(): Promise<void>;
42
+ }
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeApsorcFile = exports.formatReport = exports.importFromIntrospector = exports.resolveConnectionString = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const core_1 = require("@oclif/core");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
9
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
10
+ const config_1 = require("../../lib/config");
11
+ const apsorc_parser_1 = require("../../lib/apsorc-parser");
12
+ const relationships_1 = require("../../lib/utils/relationships");
13
+ const import_1 = require("../../lib/import");
14
+ /**
15
+ * Resolve the Postgres connection string from an explicit flag or, failing
16
+ * that, the SUPABASE_DB_URL / DATABASE_URL environment variables. Returns
17
+ * undefined if none is set (the command then prompts interactively).
18
+ */
19
+ function resolveConnectionString(flagValue, env = process.env) {
20
+ return flagValue || env.SUPABASE_DB_URL || env.DATABASE_URL || undefined;
21
+ }
22
+ exports.resolveConnectionString = resolveConnectionString;
23
+ /**
24
+ * Run the full introspect -> convert -> validate pipeline against any
25
+ * Introspector. Throws if the generated relationships fail to parse. Pure of
26
+ * file/console I/O so it can be unit-tested with a fake Introspector.
27
+ */
28
+ async function importFromIntrospector(introspector, schema) {
29
+ const introspected = await introspector.introspect(schema);
30
+ const { apsorc, report } = (0, import_1.pgToApsorc)(introspected);
31
+ // Validate by running the generated relationships through the real parser.
32
+ (0, relationships_1.parseRelationships)(apsorc.relationships);
33
+ return {
34
+ apsorc,
35
+ report,
36
+ unknownTypes: (0, import_1.findUnknownEmittedTypes)(apsorc),
37
+ };
38
+ }
39
+ exports.importFromIntrospector = importFromIntrospector;
40
+ /** Render the import report as a human-readable summary (contains no secrets). */
41
+ function formatReport(report) {
42
+ const lines = [];
43
+ lines.push(`Imported ${report.tablesImported.length} table(s) and ${report.relationships} relationship(s).`);
44
+ if (report.tablesImported.length > 0) {
45
+ lines.push(` Tables: ${report.tablesImported.join(", ")}`);
46
+ }
47
+ if (report.viewsSkipped.length > 0) {
48
+ lines.push(` Skipped views: ${report.viewsSkipped.join(", ")}`);
49
+ }
50
+ if (report.systemSchemasSkipped.length > 0) {
51
+ lines.push(` Skipped system schemas: ${report.systemSchemasSkipped.join(", ")}`);
52
+ }
53
+ const w = report.warnings;
54
+ const review = [];
55
+ if (w.compositePks.length > 0)
56
+ review.push(` - Composite primary keys (review): ${w.compositePks.join(", ")}`);
57
+ if (w.nonStandardPks.length > 0)
58
+ review.push(` - Non-"id" primary keys (kept as fields): ${w.nonStandardPks.join(", ")}`);
59
+ if (w.noPrimaryKey.length > 0)
60
+ review.push(` - Tables with no primary key: ${w.noPrimaryKey.join(", ")}`);
61
+ if (w.compositeFks.length > 0)
62
+ review.push(` - Composite foreign keys (kept as columns, no relationship): ${w.compositeFks.join(", ")}`);
63
+ if (w.fkColumnNameUnmapped.length > 0)
64
+ review.push(` - Foreign keys whose generated column may differ: ${w.fkColumnNameUnmapped.join(", ")}`);
65
+ if (w.joinTablesDetected.length > 0)
66
+ review.push(` - Join tables (could be modeled as ManyToMany): ${w.joinTablesDetected.join(", ")}`);
67
+ if (w.arraysLossy.length > 0)
68
+ review.push(` - Array columns reduced to text: ${w.arraysLossy.join(", ")}`);
69
+ if (w.typesDefaulted.length > 0)
70
+ review.push(` - Unknown types defaulted to text: ${w.typesDefaulted
71
+ .map((t) => `${t.column} (${t.udt})`)
72
+ .join(", ")}`);
73
+ if (w.defaultsDropped.length > 0)
74
+ review.push(` - Column defaults dropped: ${w.defaultsDropped.join(", ")}`);
75
+ if (review.length > 0) {
76
+ lines.push("", "Review the following (not fully represented):", ...review);
77
+ }
78
+ return lines.join("\n");
79
+ }
80
+ exports.formatReport = formatReport;
81
+ /**
82
+ * Write the generated .apsorc to disk, backing up any existing file first.
83
+ * Returns the backup path if one was made.
84
+ */
85
+ function writeApsorcFile(apsorc, outPath) {
86
+ let backupPath;
87
+ if (fs.existsSync(outPath)) {
88
+ backupPath = (0, config_1.createBackup)(outPath);
89
+ }
90
+ fs.writeFileSync(outPath, JSON.stringify(apsorc, null, 2), "utf-8");
91
+ return { backupPath };
92
+ }
93
+ exports.writeApsorcFile = writeApsorcFile;
94
+ class ImportSupabase extends base_command_1.default {
95
+ /** Overridable for testing. */
96
+ makeIntrospector(connectionString) {
97
+ return new import_1.PgIntrospector(connectionString);
98
+ }
99
+ async run() {
100
+ var _a, _b;
101
+ const { flags } = await this.parse(ImportSupabase);
102
+ let connectionString = resolveConnectionString(flags["connection-string"]);
103
+ if (!connectionString) {
104
+ const answer = await inquirer_1.default.prompt([
105
+ {
106
+ type: "password",
107
+ name: "connectionString",
108
+ mask: "*",
109
+ message: "Supabase Postgres connection string:",
110
+ validate: (input) => input.trim().length > 0 || "A connection string is required",
111
+ },
112
+ ]);
113
+ connectionString = answer.connectionString.trim();
114
+ }
115
+ try {
116
+ this.log("Connecting and introspecting schema (read-only)...");
117
+ const introspector = this.makeIntrospector(connectionString);
118
+ const { apsorc, report, unknownTypes } = await importFromIntrospector(introspector, flags.schema);
119
+ this.log("");
120
+ this.log(formatReport(report));
121
+ if (unknownTypes.length > 0) {
122
+ this.warn(`Some column types were not recognized and defaulted to text: ${[
123
+ ...new Set(unknownTypes),
124
+ ].join(", ")}`);
125
+ }
126
+ if (report.tablesImported.length === 0) {
127
+ this.warn(`No base tables found in schema "${flags.schema}". Nothing to write.`);
128
+ return;
129
+ }
130
+ if (flags["dry-run"]) {
131
+ this.log("");
132
+ this.log("Dry run — no file written.");
133
+ return;
134
+ }
135
+ const outPath = (_b = (_a = flags.out) !== null && _a !== void 0 ? _a : (0, apsorc_parser_1.findConfigPath)()) !== null && _b !== void 0 ? _b : path.join(process.cwd(), ".apsorc");
136
+ if (fs.existsSync(outPath) && !flags.yes) {
137
+ const { confirm } = await inquirer_1.default.prompt([
138
+ {
139
+ type: "confirm",
140
+ name: "confirm",
141
+ message: `Overwrite ${outPath} with the imported schema?`,
142
+ default: false,
143
+ },
144
+ ]);
145
+ if (!confirm) {
146
+ this.log("Import cancelled.");
147
+ return;
148
+ }
149
+ }
150
+ const { backupPath } = writeApsorcFile(apsorc, outPath);
151
+ if (backupPath)
152
+ this.log(`Backup created: ${backupPath}`);
153
+ this.log(`Schema written to ${outPath}`);
154
+ this.log("");
155
+ this.log("Next steps:");
156
+ this.log(" - Review the .apsorc, especially any items flagged above");
157
+ this.log(" - Run 'apso migrate' to validate the schema locally");
158
+ this.log(" - Run 'apso generate' to scaffold code");
159
+ }
160
+ catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ // Defense-in-depth: never surface the connection string in an error.
163
+ this.error((0, import_1.redactConnectionString)(message.split(connectionString).join("****")));
164
+ }
165
+ }
166
+ }
167
+ ImportSupabase.description = "Introspect an existing Supabase (Postgres) database and generate a local .apsorc schema. Read-only: it never writes to the source database.";
168
+ ImportSupabase.examples = [
169
+ `$ apso import supabase`,
170
+ `$ apso import supabase -c "postgresql://postgres:[pw]@db.[ref].supabase.co:5432/postgres"`,
171
+ `$ SUPABASE_DB_URL="postgresql://..." apso import supabase --dry-run`,
172
+ ];
173
+ ImportSupabase.flags = {
174
+ "connection-string": core_1.Flags.string({
175
+ char: "c",
176
+ description: "Postgres connection string. Falls back to SUPABASE_DB_URL or DATABASE_URL, then prompts.",
177
+ }),
178
+ schema: core_1.Flags.string({
179
+ description: "Database schema to import",
180
+ default: "public",
181
+ }),
182
+ out: core_1.Flags.string({
183
+ char: "o",
184
+ description: "Path to write the .apsorc (default: existing .apsorc or ./.apsorc)",
185
+ }),
186
+ "dry-run": core_1.Flags.boolean({
187
+ description: "Introspect and print a summary without writing any file",
188
+ default: false,
189
+ }),
190
+ yes: core_1.Flags.boolean({
191
+ char: "y",
192
+ description: "Skip the overwrite confirmation prompt",
193
+ default: false,
194
+ }),
195
+ };
196
+ exports.default = ImportSupabase;
@@ -12,5 +12,11 @@ export default class Init extends BaseCommand {
12
12
  private cloneExisting;
13
13
  private createNew;
14
14
  private runOffline;
15
+ /**
16
+ * Best-effort install of the Apso commit co-author hook. Never throws, never
17
+ * fails the command. No `.apsorc` may exist yet at init time, so we only
18
+ * respect the `APSO_NO_COAUTHOR=1` env opt-out (handled inside the installer).
19
+ */
20
+ private installCoAuthorHook;
15
21
  private postSetup;
16
22
  }
@@ -13,6 +13,7 @@ const upgrade_1 = require("../lib/upgrade");
13
13
  const interactive_1 = require("../lib/utils/interactive");
14
14
  const config_2 = require("../lib/config");
15
15
  const template_1 = require("../lib/utils/template");
16
+ const git_hooks_1 = require("../lib/utils/git-hooks");
16
17
  class Init extends base_command_1.default {
17
18
  async run() {
18
19
  const { flags } = await this.parse(Init);
@@ -119,6 +120,7 @@ class Init extends base_command_1.default {
119
120
  linkedAt: new Date().toISOString(),
120
121
  };
121
122
  config_1.projectLink.write(link, projectPath);
123
+ this.installCoAuthorHook(projectPath);
122
124
  this.log(`\nProject cloned to ${projectPath}`);
123
125
  this.log(`Linked to workspace "${workspace.name}" / service "${service.name}"`);
124
126
  }
@@ -286,7 +288,29 @@ class Init extends base_command_1.default {
286
288
  this.log(`Language: ${language}`);
287
289
  this.log('Run "apso login" to link this project to the Apso platform.');
288
290
  }
291
+ /**
292
+ * Best-effort install of the Apso commit co-author hook. Never throws, never
293
+ * fails the command. No `.apsorc` may exist yet at init time, so we only
294
+ * respect the `APSO_NO_COAUTHOR=1` env opt-out (handled inside the installer).
295
+ */
296
+ installCoAuthorHook(projectPath) {
297
+ try {
298
+ const result = (0, git_hooks_1.installCoAuthorHook)(projectPath);
299
+ if (result.installed) {
300
+ this.log("[apso] Commit co-author hook installed (.apso/hooks)");
301
+ }
302
+ else if (result.reason === "custom-hookspath") {
303
+ this.log("[apso] A custom git core.hooksPath is already configured (e.g. husky), so the Apso co-author hook was not installed.\n" +
304
+ "[apso] To credit Apso on commits that include generated files, add this trailer to your existing prepare-commit-msg hook when the staged diff touches an `autogen/` path:\n" +
305
+ '[apso] git interpret-trailers --in-place --if-exists addIfDifferent --trailer "Co-authored-by: Apso <bot@apso.ai>" "$1"');
306
+ }
307
+ }
308
+ catch {
309
+ // Never let hook installation fail init.
310
+ }
311
+ }
289
312
  async postSetup(projectPath, language) {
313
+ this.installCoAuthorHook(projectPath);
290
314
  switch (language) {
291
315
  case "typescript": {
292
316
  if (shelljs_1.default.which("npm")) {
@@ -25,6 +25,12 @@ export type ApsorcType = {
25
25
  * gets a generated controller unless it opts back in with `http: true`.
26
26
  */
27
27
  http?: boolean;
28
+ /**
29
+ * Opt-out for the Apso commit co-author hook. When false, the CLI will not
30
+ * install the `prepare-commit-msg` hook that appends the Apso co-author
31
+ * trailer. Defaults to enabled.
32
+ */
33
+ coAuthor?: boolean;
28
34
  };
29
35
  type ParsedApsorcData = {
30
36
  entities: Entity[];
@@ -41,6 +47,8 @@ type ParsedApsorc = {
41
47
  emitEvents?: boolean;
42
48
  /** Top-level default for HTTP controller generation. */
43
49
  http?: boolean;
50
+ /** Opt-out for the Apso commit co-author hook (defaults to enabled). */
51
+ coAuthor?: boolean;
44
52
  };
45
53
  export declare const parseApsorcV1: (apsorc: ApsorcType) => ParsedApsorcData;
46
54
  export declare const parseApsorcV2: (apsorc: ApsorcType) => ParsedApsorcData;
@@ -38,6 +38,7 @@ const parseRc = () => {
38
38
  const language = apsoConfig.language;
39
39
  const emitEvents = apsoConfig.emitEvents;
40
40
  const http = apsoConfig.http;
41
+ const coAuthor = apsoConfig.coAuthor;
41
42
  return {
42
43
  rootFolder,
43
44
  apiType,
@@ -48,6 +49,7 @@ const parseRc = () => {
48
49
  language,
49
50
  emitEvents,
50
51
  http,
52
+ coAuthor,
51
53
  };
52
54
  };
53
55
  const parseApsorc = () => {
@@ -69,6 +71,7 @@ const parseApsorc = () => {
69
71
  language: apsoConfig.language,
70
72
  emitEvents: apsoConfig.emitEvents,
71
73
  http: apsoConfig.http,
74
+ coAuthor: apsoConfig.coAuthor,
72
75
  ...(0, exports.parseApsorcV1)(apsoConfig),
73
76
  };
74
77
  if (debug) {
@@ -87,6 +90,7 @@ const parseApsorc = () => {
87
90
  language: apsoConfig.language,
88
91
  emitEvents: apsoConfig.emitEvents,
89
92
  http: apsoConfig.http,
93
+ coAuthor: apsoConfig.coAuthor,
90
94
  ...(() => {
91
95
  const relStart = perf_hooks_1.performance.now();
92
96
  const parsed = (0, exports.parseApsorcV2)(apsoConfig);
@@ -0,0 +1,4 @@
1
+ export * from "./types";
2
+ export { pgToApsorc, findUnknownEmittedTypes } from "./pg-to-apsorc";
3
+ export type { ApsorcImportOutput, ApsorcImportEntityOutput, ApsorcImportFieldOutput, ApsorcImportRelationshipOutput, } from "./pg-to-apsorc";
4
+ export { PgIntrospector, redactConnectionString, buildSchema, SYSTEM_SCHEMAS } from "./introspect";
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SYSTEM_SCHEMAS = exports.buildSchema = exports.redactConnectionString = exports.PgIntrospector = exports.findUnknownEmittedTypes = exports.pgToApsorc = void 0;
4
+ const tslib_1 = require("tslib");
5
+ tslib_1.__exportStar(require("./types"), exports);
6
+ var pg_to_apsorc_1 = require("./pg-to-apsorc");
7
+ Object.defineProperty(exports, "pgToApsorc", { enumerable: true, get: function () { return pg_to_apsorc_1.pgToApsorc; } });
8
+ Object.defineProperty(exports, "findUnknownEmittedTypes", { enumerable: true, get: function () { return pg_to_apsorc_1.findUnknownEmittedTypes; } });
9
+ var introspect_1 = require("./introspect");
10
+ Object.defineProperty(exports, "PgIntrospector", { enumerable: true, get: function () { return introspect_1.PgIntrospector; } });
11
+ Object.defineProperty(exports, "redactConnectionString", { enumerable: true, get: function () { return introspect_1.redactConnectionString; } });
12
+ Object.defineProperty(exports, "buildSchema", { enumerable: true, get: function () { return introspect_1.buildSchema; } });
13
+ Object.defineProperty(exports, "SYSTEM_SCHEMAS", { enumerable: true, get: function () { return introspect_1.SYSTEM_SCHEMAS; } });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Database Import — introspection layer
3
+ *
4
+ * Reads the schema of a live Postgres/Supabase database. This layer is
5
+ * deliberately thin and side-effect-light: it only issues read-only SELECTs
6
+ * against information_schema/pg_catalog and returns an `IntrospectedSchema`.
7
+ * All interpretation lives in the conversion layer (pg-to-apsorc.ts).
8
+ *
9
+ * `pg` is loaded lazily so importing this module (e.g. from the command) does
10
+ * not pull the driver until an actual connection is made.
11
+ */
12
+ import { IntrospectedSchema, Introspector } from "./types";
13
+ /** Supabase- and Postgres-managed schemas we never import from. */
14
+ export declare const SYSTEM_SCHEMAS: Set<string>;
15
+ /** Strip credentials from a connection string for safe display in errors. */
16
+ export declare function redactConnectionString(input: string): string;
17
+ /**
18
+ * Reads schema metadata from a Postgres database via a connection string.
19
+ * SSL is enabled by default (Supabase requires it).
20
+ */
21
+ export declare class PgIntrospector implements Introspector {
22
+ private readonly connectionString;
23
+ constructor(connectionString: string);
24
+ private connect;
25
+ introspect(schema: string): Promise<IntrospectedSchema>;
26
+ }
27
+ interface RawRows {
28
+ tableRows: any[];
29
+ columnRows: any[];
30
+ pkRows: any[];
31
+ fkRows: any[];
32
+ uniqueRows: any[];
33
+ indexRows: any[];
34
+ enumRows: any[];
35
+ schemaRows: any[];
36
+ }
37
+ /**
38
+ * Assemble raw query rows into an IntrospectedSchema. Exported (separate from
39
+ * the DB connection) so it can be unit-tested with fixture rows.
40
+ */
41
+ export declare function buildSchema(schema: string, raw: RawRows): IntrospectedSchema;
42
+ export {};