@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.
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — conversion layer
4
+ *
5
+ * Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database)
6
+ * into a `.apsorc`-shaped object. This is a pure function: same input always
7
+ * yields the same output and report, with no I/O — which keeps it exhaustively
8
+ * unit-testable.
9
+ *
10
+ * The reverse type map here is the inverse of `fieldTypeToColumnType`
11
+ * (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the
12
+ * platform derives FK column names in `getRelationshipIdField`
13
+ * (src/lib/utils/relationships/parse.ts) so imported relationships round-trip.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.findUnknownEmittedTypes = exports.pgToApsorc = exports.deriveToName = exports.parseDefault = exports.pgTypeToApsorcType = void 0;
17
+ const casing_1 = require("../utils/casing");
18
+ const field_1 = require("../utils/field");
19
+ const relationships_1 = require("../utils/relationships");
20
+ /** Maps a Postgres udt_name to the closest `.apsorc` field type. */
21
+ const udtToApsorcType = {
22
+ int2: "smallint",
23
+ int4: "integer",
24
+ int8: "bigint",
25
+ float4: "real",
26
+ float8: "double",
27
+ numeric: "numeric",
28
+ money: "money",
29
+ bool: "boolean",
30
+ varchar: "varchar",
31
+ bpchar: "char",
32
+ text: "text",
33
+ uuid: "uuid",
34
+ json: "json",
35
+ jsonb: "jsonb",
36
+ date: "date",
37
+ timestamp: "timestamp",
38
+ timestamptz: "timestamptz",
39
+ time: "time",
40
+ timetz: "timetz",
41
+ bytea: "bytea",
42
+ xml: "xml",
43
+ inet: "inet",
44
+ cidr: "inet",
45
+ interval: "interval",
46
+ tsvector: "tsvector",
47
+ int4range: "int4range",
48
+ // PostGIS — pass-through (these are also keys in fieldTypeToColumnType)
49
+ point: "point",
50
+ geometry: "geometry",
51
+ geography: "geography",
52
+ };
53
+ const TIMESTAMP_UDTS = new Set(["timestamp", "timestamptz"]);
54
+ const AUTO_TIMESTAMP_DEFAULTS = new Set([
55
+ "now()",
56
+ "current_timestamp",
57
+ "transaction_timestamp()",
58
+ "clock_timestamp()",
59
+ ]);
60
+ /**
61
+ * Resolve a column's `.apsorc` field type from its Postgres type. Postgres
62
+ * arrays (udt_name starting with "_") collapse to `array`; user enums become
63
+ * `enum`; unknown types fall back to `text`.
64
+ */
65
+ function pgTypeToApsorcType(col) {
66
+ if (col.isEnum) {
67
+ return { type: "enum" };
68
+ }
69
+ if (col.dataType === "ARRAY" || col.udtName.startsWith("_")) {
70
+ return { type: "array", lossy: "array" };
71
+ }
72
+ const mapped = udtToApsorcType[col.udtName];
73
+ if (mapped) {
74
+ return { type: mapped };
75
+ }
76
+ return { type: "text", lossy: "defaulted" };
77
+ }
78
+ exports.pgTypeToApsorcType = pgTypeToApsorcType;
79
+ /**
80
+ * Translate a raw Postgres default expression into an `.apsorc` default value.
81
+ * Returns `{ drop: true }` when the default cannot be represented (function
82
+ * calls, sequence/uuid generators). Auto-timestamp and sequence defaults are
83
+ * dropped silently by callers that handle them structurally.
84
+ */
85
+ function parseDefault(raw, apsorcType) {
86
+ if (raw === null)
87
+ return {};
88
+ const expr = raw.trim();
89
+ const lower = expr.toLowerCase();
90
+ // Sequence / generator / function defaults can't be a literal .apsorc value.
91
+ if (lower.startsWith("nextval(") ||
92
+ lower.startsWith("gen_random_uuid(") ||
93
+ lower.startsWith("uuid_generate_v4(") ||
94
+ AUTO_TIMESTAMP_DEFAULTS.has(lower)) {
95
+ return { drop: true };
96
+ }
97
+ // Strip a trailing ::type cast, e.g. 'active'::text or 0::integer.
98
+ const withoutCast = expr.replace(/::[\s\w".[\]]+$/, "").trim();
99
+ // Quoted string literal.
100
+ if (withoutCast.startsWith("'") && withoutCast.endsWith("'")) {
101
+ const inner = withoutCast.slice(1, -1).replace(/''/g, "'");
102
+ return { value: inner };
103
+ }
104
+ if (lower === "true" || lower === "false") {
105
+ return { value: lower === "true" };
106
+ }
107
+ if (/^-?\d+(\.\d+)?$/.test(withoutCast)) {
108
+ if (apsorcType === "numeric" || apsorcType === "decimal") {
109
+ return { value: withoutCast };
110
+ }
111
+ return { value: Number(withoutCast) };
112
+ }
113
+ // Anything else (function calls, complex expressions) — drop with a warning.
114
+ return { drop: true };
115
+ }
116
+ exports.parseDefault = parseDefault;
117
+ /**
118
+ * Derive the relationship `to_name` so the platform regenerates a FK column
119
+ * matching the source column. Returns `undefined` when the default naming
120
+ * (`${camelCase(target)}Id`) already produces the source column. Sets
121
+ * `unmapped: true` when no name round-trips to the source column.
122
+ */
123
+ function deriveToName(sourceColumn, targetTable) {
124
+ // The platform always generates camelCase FK columns, while Postgres/Supabase
125
+ // columns are typically snake_case. Compare on the camelCased form so a
126
+ // snake_case source that is semantically identical isn't flagged as a mismatch.
127
+ const source = (0, casing_1.camelCase)(sourceColumn);
128
+ const defaultColumn = (0, relationships_1.getRelationshipIdField)({
129
+ name: targetTable,
130
+ type: "ManyToOne",
131
+ });
132
+ if (defaultColumn === source) {
133
+ return {};
134
+ }
135
+ // Strip a trailing Id / _id and use the remainder as the reference name.
136
+ const base = sourceColumn.replace(/(_id|Id)$/, "");
137
+ if (base && base !== sourceColumn) {
138
+ const candidate = (0, casing_1.camelCase)(base);
139
+ const roundTrip = (0, relationships_1.getRelationshipIdField)({
140
+ name: targetTable,
141
+ type: "ManyToOne",
142
+ referenceName: candidate,
143
+ });
144
+ if (roundTrip === source) {
145
+ return { toName: candidate };
146
+ }
147
+ }
148
+ // Best-effort: emit a reference name but flag that the generated FK column
149
+ // may not match the source column exactly.
150
+ return { toName: (0, casing_1.camelCase)(base || sourceColumn), unmapped: true };
151
+ }
152
+ exports.deriveToName = deriveToName;
153
+ function emptyReport(schema) {
154
+ return {
155
+ tablesImported: [],
156
+ relationships: 0,
157
+ viewsSkipped: schema.skipped.views,
158
+ systemSchemasSkipped: schema.skipped.systemSchemas,
159
+ warnings: {
160
+ arraysLossy: [],
161
+ compositePks: [],
162
+ compositeFks: [],
163
+ nonStandardPks: [],
164
+ noPrimaryKey: [],
165
+ typesDefaulted: [],
166
+ fkColumnNameUnmapped: [],
167
+ defaultsDropped: [],
168
+ joinTablesDetected: [],
169
+ },
170
+ };
171
+ }
172
+ function isJoinTable(table) {
173
+ const singleColumnFkNames = table.foreignKeys
174
+ .filter((fk) => fk.columns.length === 1)
175
+ .map((fk) => fk.columns[0]);
176
+ return (table.foreignKeys.length === 2 &&
177
+ singleColumnFkNames.length === 2 &&
178
+ table.primaryKey.length === 2 &&
179
+ table.primaryKey.every((c) => singleColumnFkNames.includes(c)));
180
+ }
181
+ /**
182
+ * Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a
183
+ * report of everything that could not be represented losslessly.
184
+ */
185
+ function pgToApsorc(schema) {
186
+ const report = emptyReport(schema);
187
+ const enumLabels = new Map(schema.enums.map((e) => [e.name, e.labels]));
188
+ const entities = [];
189
+ const relationships = [];
190
+ for (const table of schema.tables) {
191
+ report.tablesImported.push(table.name);
192
+ if (isJoinTable(table)) {
193
+ report.warnings.joinTablesDetected.push(table.name);
194
+ }
195
+ const entity = { name: table.name };
196
+ // Columns covered by a single-column FK are materialized by the
197
+ // relationship, so they must not also be emitted as scalar fields.
198
+ const fkColumns = new Map();
199
+ for (const fk of table.foreignKeys) {
200
+ if (fk.columns.length === 1) {
201
+ fkColumns.set(fk.columns[0], fk);
202
+ }
203
+ else {
204
+ report.warnings.compositeFks.push(table.name);
205
+ }
206
+ }
207
+ // --- Primary key handling ---
208
+ const pk = table.primaryKey;
209
+ const pkSet = new Set(pk);
210
+ let pkAsField = false;
211
+ if (pk.length === 1) {
212
+ const pkCol = table.columns.find((c) => c.name === pk[0]);
213
+ if (pk[0] === "id" && pkCol) {
214
+ const t = pgTypeToApsorcType(pkCol).type;
215
+ if (t === "uuid")
216
+ entity.primaryKeyType = "uuid";
217
+ else if (t === "text" || t === "varchar")
218
+ entity.primaryKeyType = "text";
219
+ // integer/bigint/smallint id => default serial, omit primaryKeyType.
220
+ }
221
+ else {
222
+ // Single, non-"id" PK: emit it as a primary field.
223
+ pkAsField = true;
224
+ report.warnings.nonStandardPks.push(table.name);
225
+ }
226
+ }
227
+ else if (pk.length > 1) {
228
+ pkAsField = true;
229
+ report.warnings.compositePks.push(table.name);
230
+ }
231
+ else {
232
+ report.warnings.noPrimaryKey.push(table.name);
233
+ }
234
+ // --- created_at / updated_at detection ---
235
+ const autoTimestamps = new Set();
236
+ for (const name of ["created_at", "updated_at"]) {
237
+ const col = table.columns.find((c) => c.name === name);
238
+ if (col && TIMESTAMP_UDTS.has(col.udtName)) {
239
+ entity[name] = true;
240
+ autoTimestamps.add(name);
241
+ }
242
+ }
243
+ // --- Fields ---
244
+ const uniqueSingle = new Set();
245
+ const uniqueComposite = [];
246
+ for (const uc of table.uniqueConstraints) {
247
+ if (uc.columns.length === 1)
248
+ uniqueSingle.add(uc.columns[0]);
249
+ else
250
+ uniqueComposite.push({ fields: uc.columns, name: uc.name });
251
+ }
252
+ const indexSingle = new Set();
253
+ const indexComposite = [];
254
+ for (const idx of table.indexes) {
255
+ const cols = idx.columns;
256
+ // Skip indexes that merely back the PK or a unique constraint.
257
+ const backsPk = cols.length === pk.length && cols.every((c) => pkSet.has(c));
258
+ const backsUnique = table.uniqueConstraints.some((uc) => uc.columns.length === cols.length &&
259
+ uc.columns.every((c) => cols.includes(c)));
260
+ if (backsPk || backsUnique)
261
+ continue;
262
+ if (cols.length === 1)
263
+ indexSingle.add(cols[0]);
264
+ else
265
+ indexComposite.push({ fields: cols, unique: idx.unique });
266
+ }
267
+ const fields = [];
268
+ const sortedColumns = [...table.columns].sort((a, b) => a.ordinal - b.ordinal);
269
+ for (const col of sortedColumns) {
270
+ // Skip columns represented elsewhere.
271
+ if (fkColumns.has(col.name))
272
+ continue;
273
+ if (autoTimestamps.has(col.name))
274
+ continue;
275
+ if (pk.length === 1 && col.name === pk[0] && !pkAsField)
276
+ continue;
277
+ const resolved = pgTypeToApsorcType(col);
278
+ if (resolved.lossy === "array") {
279
+ report.warnings.arraysLossy.push(`${table.name}.${col.name}`);
280
+ }
281
+ else if (resolved.lossy === "defaulted") {
282
+ report.warnings.typesDefaulted.push({
283
+ column: `${table.name}.${col.name}`,
284
+ udt: col.udtName,
285
+ });
286
+ }
287
+ const field = {
288
+ name: col.name,
289
+ type: resolved.type,
290
+ };
291
+ if (col.nullable)
292
+ field.nullable = true;
293
+ if (uniqueSingle.has(col.name))
294
+ field.unique = true;
295
+ if (indexSingle.has(col.name))
296
+ field.index = true;
297
+ if (pkSet.has(col.name) && pkAsField)
298
+ field.primary = true;
299
+ if ((resolved.type === "varchar" || resolved.type === "char") && col.charMaxLength)
300
+ field.length = col.charMaxLength;
301
+ if (resolved.type === "numeric" || resolved.type === "decimal") {
302
+ if (col.numericPrecision)
303
+ field.precision = col.numericPrecision;
304
+ if (col.numericScale !== null)
305
+ field.scale = col.numericScale;
306
+ }
307
+ if (resolved.type === "enum") {
308
+ const labels = col.enumTypeName
309
+ ? enumLabels.get(col.enumTypeName)
310
+ : undefined;
311
+ if (labels)
312
+ field.values = labels;
313
+ }
314
+ // Defaults (skip for PK columns — those are implicit/sequence-driven).
315
+ if (!(pkSet.has(col.name) && !pkAsField)) {
316
+ const parsed = parseDefault(col.default, resolved.type);
317
+ if (parsed.drop && col.default !== null) {
318
+ report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
319
+ }
320
+ else if ("value" in parsed) {
321
+ if (resolved.type === "enum") {
322
+ // Only keep an enum default that is one of the allowed labels.
323
+ if (field.values && field.values.includes(String(parsed.value))) {
324
+ field.default = parsed.value;
325
+ }
326
+ else {
327
+ report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
328
+ }
329
+ }
330
+ else {
331
+ field.default = parsed.value;
332
+ }
333
+ }
334
+ }
335
+ fields.push(field);
336
+ }
337
+ if (fields.length > 0)
338
+ entity.fields = fields;
339
+ if (uniqueComposite.length > 0)
340
+ entity.uniques = uniqueComposite;
341
+ if (indexComposite.length > 0)
342
+ entity.indexes = indexComposite;
343
+ entities.push(entity);
344
+ // --- Foreign keys -> ManyToOne relationships ---
345
+ for (const fk of table.foreignKeys) {
346
+ if (fk.columns.length !== 1)
347
+ continue; // composite FK handled above
348
+ const sourceColumn = fk.columns[0];
349
+ const col = table.columns.find((c) => c.name === sourceColumn);
350
+ const rel = {
351
+ from: table.name,
352
+ to: fk.referencedTable,
353
+ type: "ManyToOne",
354
+ };
355
+ const { toName, unmapped } = deriveToName(sourceColumn, fk.referencedTable);
356
+ if (toName)
357
+ rel.to_name = toName;
358
+ if (unmapped) {
359
+ report.warnings.fkColumnNameUnmapped.push(`${table.name}.${sourceColumn}`);
360
+ }
361
+ if (col === null || col === void 0 ? void 0 : col.nullable)
362
+ rel.nullable = true;
363
+ if (fk.onDelete === "CASCADE")
364
+ rel.cascadeDelete = true;
365
+ relationships.push(rel);
366
+ }
367
+ }
368
+ report.relationships = relationships.length;
369
+ return {
370
+ apsorc: {
371
+ version: 2,
372
+ rootFolder: "src",
373
+ apiType: "rest",
374
+ entities,
375
+ relationships,
376
+ },
377
+ report,
378
+ };
379
+ }
380
+ exports.pgToApsorc = pgToApsorc;
381
+ /**
382
+ * Invariant guard: every emitted field type must be a recognized `.apsorc`
383
+ * column type, so code generation never silently falls back to varchar.
384
+ * Returns the list of any offending types (empty when valid).
385
+ */
386
+ function findUnknownEmittedTypes(output) {
387
+ var _a;
388
+ const unknown = [];
389
+ for (const entity of output.entities) {
390
+ for (const field of (_a = entity.fields) !== null && _a !== void 0 ? _a : []) {
391
+ if (field.type === "enum")
392
+ continue; // enum dispatched separately
393
+ if (!(field.type in field_1.fieldTypeToColumnType))
394
+ unknown.push(field.type);
395
+ }
396
+ }
397
+ return unknown;
398
+ }
399
+ exports.findUnknownEmittedTypes = findUnknownEmittedTypes;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Database Import — intermediate model
3
+ *
4
+ * These types are the contract between the introspection layer (which reads a
5
+ * live Postgres database) and the conversion layer (which turns that into a
6
+ * `.apsorc`). Keeping the introspector behind the `Introspector` interface lets
7
+ * the conversion logic and the command be unit-tested with a fake source,
8
+ * without a real database connection.
9
+ */
10
+ /** ON DELETE referential action of a foreign key. */
11
+ export type OnDeleteAction = "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION" | "SET DEFAULT";
12
+ /** A single introspected column. */
13
+ export interface IntrospectedColumn {
14
+ name: string;
15
+ /** pg_catalog udt_name, e.g. "int4", "varchar", "_text", "timestamptz". */
16
+ udtName: string;
17
+ /** information_schema.data_type, used to detect "USER-DEFINED" (enum) and "ARRAY". */
18
+ dataType: string;
19
+ nullable: boolean;
20
+ /** Raw default expression, e.g. "nextval('...')", "now()", "'active'::text". */
21
+ default: string | null;
22
+ charMaxLength: number | null;
23
+ numericPrecision: number | null;
24
+ numericScale: number | null;
25
+ ordinal: number;
26
+ /** True when the column's type is a user-defined enum. */
27
+ isEnum: boolean;
28
+ /** Links to IntrospectedEnum.name when isEnum is true. */
29
+ enumTypeName?: string;
30
+ }
31
+ /** A foreign key constraint (may be composite). */
32
+ export interface IntrospectedFk {
33
+ /** Local column(s) participating in the FK. */
34
+ columns: string[];
35
+ referencedTable: string;
36
+ referencedColumns: string[];
37
+ onDelete: OnDeleteAction;
38
+ }
39
+ /** A unique constraint (may be composite). */
40
+ export interface IntrospectedUnique {
41
+ name: string;
42
+ columns: string[];
43
+ }
44
+ /** A secondary index (may be composite). */
45
+ export interface IntrospectedIndex {
46
+ name: string;
47
+ columns: string[];
48
+ unique: boolean;
49
+ }
50
+ /** A user-defined enum type and its labels (in sort order). */
51
+ export interface IntrospectedEnum {
52
+ name: string;
53
+ labels: string[];
54
+ }
55
+ /** A single introspected base table. */
56
+ export interface IntrospectedTable {
57
+ name: string;
58
+ columns: IntrospectedColumn[];
59
+ /** Ordered primary-key column names ([] if the table has no primary key). */
60
+ primaryKey: string[];
61
+ foreignKeys: IntrospectedFk[];
62
+ uniqueConstraints: IntrospectedUnique[];
63
+ indexes: IntrospectedIndex[];
64
+ }
65
+ /** The full result of introspecting one Postgres schema. */
66
+ export interface IntrospectedSchema {
67
+ schema: string;
68
+ tables: IntrospectedTable[];
69
+ enums: IntrospectedEnum[];
70
+ /** Objects intentionally not imported, surfaced in the summary report. */
71
+ skipped: {
72
+ views: string[];
73
+ systemSchemas: string[];
74
+ };
75
+ }
76
+ /**
77
+ * The boundary the command depends on. The real implementation
78
+ * (PgIntrospector) connects to Postgres; tests provide a fake.
79
+ */
80
+ export interface Introspector {
81
+ introspect(schema: string): Promise<IntrospectedSchema>;
82
+ }
83
+ /**
84
+ * Everything the import couldn't represent losslessly, plus counts, used to
85
+ * print a summary the user can review before/after writing the `.apsorc`.
86
+ */
87
+ export interface ImportReport {
88
+ tablesImported: string[];
89
+ relationships: number;
90
+ viewsSkipped: string[];
91
+ systemSchemasSkipped: string[];
92
+ warnings: {
93
+ /** "table.column" whose Postgres array type was reduced to text. */
94
+ arraysLossy: string[];
95
+ /** Tables with composite primary keys (emitted as primary:true fields). */
96
+ compositePks: string[];
97
+ /** Tables with composite foreign keys (kept as scalar columns, no relationship). */
98
+ compositeFks: string[];
99
+ /** Tables whose single PK is not named "id" (emitted as a primary:true field). */
100
+ nonStandardPks: string[];
101
+ /** Tables with no primary key at all. */
102
+ noPrimaryKey: string[];
103
+ /** Columns whose type was unknown and defaulted to text. */
104
+ typesDefaulted: Array<{
105
+ column: string;
106
+ udt: string;
107
+ }>;
108
+ /** FK columns whose name could not be round-tripped to a relationship name. */
109
+ fkColumnNameUnmapped: string[];
110
+ /** "table.column" defaults that were dropped (unsupported expressions). */
111
+ defaultsDropped: string[];
112
+ /** Tables that look like pure join tables (candidates for manual ManyToMany). */
113
+ joinTablesDetected: string[];
114
+ };
115
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — intermediate model
4
+ *
5
+ * These types are the contract between the introspection layer (which reads a
6
+ * live Postgres database) and the conversion layer (which turns that into a
7
+ * `.apsorc`). Keeping the introspector behind the `Introspector` interface lets
8
+ * the conversion logic and the command be unit-tested with a fake source,
9
+ * without a real database connection.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The Apso co-author git trailer. Appended to commits that include
3
+ * Apso-generated work so Apso is credited as a co-author.
4
+ */
5
+ export declare const APSO_COAUTHOR_TRAILER = "Co-authored-by: Apso <bot@apso.ai>";
6
+ /**
7
+ * Relative path (from the repo top-level) where the managed hook is installed.
8
+ * Modern git resolves a relative `core.hooksPath` from the repository root.
9
+ */
10
+ export declare const APSO_HOOKS_DIR = ".apso/hooks";
11
+ /**
12
+ * Builds the `prepare-commit-msg` bash script that appends the Apso co-author
13
+ * trailer to commits that touch Apso-generated paths.
14
+ *
15
+ * Pure function (no side effects) so it can be unit-tested directly.
16
+ *
17
+ * Behaviour of the generated script:
18
+ * - Skips merge/squash commits (`$2` is `merge` or `squash`).
19
+ * - Honors the `APSO_NO_COAUTHOR=1` opt-out.
20
+ * - Only acts when the staged changes include an Apso-generated path
21
+ * (a path segment named `autogen/`).
22
+ * - Adds the trailer idempotently via `git interpret-trailers`, which handles
23
+ * trailer-block formatting + dedupe and coexists with other `Co-authored-by`
24
+ * trailers (e.g. Claude).
25
+ */
26
+ export declare function buildCoAuthorHookScript(): string;
27
+ export interface InstallCoAuthorHookOptions {
28
+ /** When true, skip installation (e.g. `.apsorc` set `coAuthor: false`). */
29
+ disabled?: boolean;
30
+ }
31
+ export interface InstallCoAuthorHookResult {
32
+ installed: boolean;
33
+ reason?: "disabled" | "not-a-git-repo" | "custom-hookspath" | "error";
34
+ }
35
+ /**
36
+ * Installs the Apso co-author `prepare-commit-msg` hook into a project.
37
+ *
38
+ * Best-effort: never throws. Callers can log the result.
39
+ *
40
+ * - Respects the `disabled` option and the `APSO_NO_COAUTHOR=1` env opt-out.
41
+ * - No-ops (returns `not-a-git-repo`) outside a git work tree.
42
+ * - Writes `<projectRoot>/.apso/hooks/prepare-commit-msg` (mode 0755).
43
+ * - Sets the repo-local `core.hooksPath` to `.apso/hooks` when unset; leaves it
44
+ * alone (and reports success) when it's already `.apso/hooks`; refuses to
45
+ * clobber a custom `core.hooksPath` (husky etc.) and reports
46
+ * `custom-hookspath` so the caller can guide the user.
47
+ */
48
+ export declare function installCoAuthorHook(projectRoot: string, opts?: InstallCoAuthorHookOptions): InstallCoAuthorHookResult;