@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.1
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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/extensions/commands.ts +732 -0
- package/extensions/context.ts +37 -0
- package/extensions/deps.ts +32 -0
- package/extensions/heydb.ts +20 -0
- package/extensions/secret-input.ts +120 -0
- package/extensions/tools.ts +402 -0
- package/lib/about.ts +7 -0
- package/lib/config/operations.ts +75 -0
- package/lib/config/store.ts +73 -0
- package/lib/config/types.ts +37 -0
- package/lib/config/validate.ts +136 -0
- package/lib/engines/index.ts +66 -0
- package/lib/engines/mysql/catalog.ts +371 -0
- package/lib/engines/mysql/introspect.ts +186 -0
- package/lib/engines/postgres/catalog.ts +390 -0
- package/lib/engines/postgres/introspect.ts +240 -0
- package/lib/knowledge/operations.ts +223 -0
- package/lib/knowledge/query.ts +129 -0
- package/lib/knowledge/types.ts +64 -0
- package/lib/knowledge/validate.ts +151 -0
- package/lib/model/diff.ts +218 -0
- package/lib/model/hash.ts +88 -0
- package/lib/model/names.ts +141 -0
- package/lib/model/order.ts +47 -0
- package/lib/model/references.ts +251 -0
- package/lib/model/snapshot.ts +128 -0
- package/lib/model/stats.ts +97 -0
- package/lib/model/validate.ts +240 -0
- package/lib/render/markdown.ts +508 -0
- package/lib/search.ts +530 -0
- package/lib/secrets.ts +149 -0
- package/lib/store/envfile.ts +124 -0
- package/lib/store/files.ts +57 -0
- package/lib/store/guards.ts +68 -0
- package/lib/store/knowledge.ts +139 -0
- package/lib/store/project.ts +28 -0
- package/lib/store/snapshots.ts +143 -0
- package/lib/usecases/author.ts +51 -0
- package/lib/usecases/code-scan.ts +435 -0
- package/lib/usecases/init.ts +111 -0
- package/lib/usecases/knowledge.ts +214 -0
- package/lib/usecases/project.ts +377 -0
- package/lib/usecases/resolve.ts +186 -0
- package/lib/usecases/sharing.ts +83 -0
- package/lib/usecases/status.ts +37 -0
- package/lib/usecases/sync.ts +33 -0
- package/package.json +69 -5
- package/skills/heydb/SKILL.md +67 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { aboutMessage } from "../lib/about.ts";
|
|
7
|
+
import { addConnection, removeConnection } from "../lib/config/operations.ts";
|
|
8
|
+
import { loadConfig } from "../lib/config/store.ts";
|
|
9
|
+
import type { ConnectionConfig } from "../lib/config/types.ts";
|
|
10
|
+
import { validateConnection } from "../lib/config/validate.ts";
|
|
11
|
+
import type { Snapshot } from "../lib/model/snapshot.ts";
|
|
12
|
+
import { renderChanges, renderStatus } from "../lib/render/markdown.ts";
|
|
13
|
+
import { toConnectionName } from "../lib/store/guards.ts";
|
|
14
|
+
import { findProjectRoot } from "../lib/store/project.ts";
|
|
15
|
+
import { type SchemaUsage, findSchemaUsage } from "../lib/usecases/code-scan.ts";
|
|
16
|
+
import { initProject } from "../lib/usecases/init.ts";
|
|
17
|
+
import {
|
|
18
|
+
type ConnectionSummary,
|
|
19
|
+
type OpenedConnection,
|
|
20
|
+
ProjectError,
|
|
21
|
+
buildConnection,
|
|
22
|
+
captureLive,
|
|
23
|
+
chooseDefaultConnection,
|
|
24
|
+
forgetConnection,
|
|
25
|
+
listConnections,
|
|
26
|
+
openConnection,
|
|
27
|
+
openSnapshot,
|
|
28
|
+
projectConnections,
|
|
29
|
+
saveNewConnection,
|
|
30
|
+
savePassword,
|
|
31
|
+
} from "../lib/usecases/project.ts";
|
|
32
|
+
import { SHARE_NOTES_HINT, knowledgeSharing } from "../lib/usecases/sharing.ts";
|
|
33
|
+
import { checkStatus } from "../lib/usecases/status.ts";
|
|
34
|
+
import { syncSnapshot } from "../lib/usecases/sync.ts";
|
|
35
|
+
import type { HeydbDeps } from "./deps.ts";
|
|
36
|
+
import { askSecret } from "./secret-input.ts";
|
|
37
|
+
|
|
38
|
+
// Commands are what a person types. Unlike tools, /heydb:sync and
|
|
39
|
+
// /heydb:status connect to the database: a person chose to run them, and
|
|
40
|
+
// their own environment holds the password. Results are notifications;
|
|
41
|
+
// errors are notifications with level "error", never thrown at Pi.
|
|
42
|
+
//
|
|
43
|
+
// A password a person types (/heydb:connect, /heydb:password) goes from the
|
|
44
|
+
// masked input straight to .heydb/.env: never into a notification, a message,
|
|
45
|
+
// the session or an error.
|
|
46
|
+
|
|
47
|
+
type Command = Omit<RegisteredCommand, "name" | "sourceInfo">;
|
|
48
|
+
|
|
49
|
+
/** Sends a message into the session; it is shown and becomes context for the model. */
|
|
50
|
+
export type SendMessage = ExtensionAPI["sendMessage"];
|
|
51
|
+
|
|
52
|
+
const ENGINES = ["mysql", "mariadb", "postgresql"];
|
|
53
|
+
|
|
54
|
+
// What each engine is usually reached with: shown as hints, and the port is
|
|
55
|
+
// also what an empty answer means.
|
|
56
|
+
const ENGINE_DEFAULTS: Readonly<Record<string, { port: string; user: string }>> = {
|
|
57
|
+
mysql: { port: "3306", user: "root" },
|
|
58
|
+
mariadb: { port: "3306", user: "root" },
|
|
59
|
+
postgresql: { port: "5432", user: "postgres" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function registerHeydbCommands(pi: ExtensionAPI, deps: HeydbDeps): void {
|
|
63
|
+
for (const [name, command] of createHeydbCommands(deps, (message, options) => pi.sendMessage(message, options))) {
|
|
64
|
+
pi.registerCommand(name, command);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createHeydbCommands(deps: HeydbDeps, sendMessage: SendMessage): [name: string, command: Command][] {
|
|
69
|
+
// Commands that connect run one at a time, in order. Pi may start a second
|
|
70
|
+
// one while the first awaits the server; two syncs of one connection would
|
|
71
|
+
// race on its snapshot file, and under Pi's module loader two first-time
|
|
72
|
+
// loads of mysql2 at once break the second (seen with RPC; plain Node is fine).
|
|
73
|
+
let queue: Promise<void> = Promise.resolve();
|
|
74
|
+
const oneAtATime = (work: () => Promise<void>): Promise<void> => {
|
|
75
|
+
const run = queue.then(work);
|
|
76
|
+
queue = run.catch(() => {});
|
|
77
|
+
return run;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
[
|
|
82
|
+
"heydb:about",
|
|
83
|
+
{
|
|
84
|
+
description: "Show the installed heydb version",
|
|
85
|
+
handler: async (_args, ctx) => {
|
|
86
|
+
ctx.ui.notify(aboutMessage(), "info");
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
["heydb:init", { description: "Make this folder a heydb project (.heydb/)", handler: async (_args, ctx) => init(ctx) }],
|
|
91
|
+
[
|
|
92
|
+
"heydb:connect",
|
|
93
|
+
{
|
|
94
|
+
description: "Add a database connection (a typed password is kept in .heydb/.env, ignored by git)",
|
|
95
|
+
handler: (_args, ctx) => connect(deps, ctx),
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
"heydb:sync",
|
|
100
|
+
{
|
|
101
|
+
description: "Capture the live schema and store it if it changed: /heydb:sync [connection]",
|
|
102
|
+
getArgumentCompletions: (prefix) => completeConnection(deps, prefix),
|
|
103
|
+
handler: (args, ctx) => oneAtATime(() => sync(deps, sendMessage, args, ctx)),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
"heydb:status",
|
|
108
|
+
{
|
|
109
|
+
description: "Compare the live schema with the stored snapshot: /heydb:status [connection]",
|
|
110
|
+
getArgumentCompletions: (prefix) => completeConnection(deps, prefix),
|
|
111
|
+
handler: (args, ctx) => oneAtATime(() => status(deps, args, ctx)),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
"heydb:password",
|
|
116
|
+
{
|
|
117
|
+
description: "Type a connection's password, kept in .heydb/.env (ignored by git): /heydb:password [connection]",
|
|
118
|
+
getArgumentCompletions: (prefix) => completeConnection(deps, prefix),
|
|
119
|
+
handler: (args, ctx) => password(deps, args, ctx),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
"heydb:connections",
|
|
124
|
+
{
|
|
125
|
+
description: "List connections, change the default or remove one: /heydb:connections [list | use <name> | remove <name>]",
|
|
126
|
+
getArgumentCompletions: (prefix) => completeConnections(deps, prefix),
|
|
127
|
+
handler: (args, ctx) => connections(deps, args, ctx),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"heydb:learn-code",
|
|
132
|
+
{
|
|
133
|
+
description:
|
|
134
|
+
"Offer the agent the code that uses the tables, to note what it shows: /heydb:learn-code [connection] [path] (path: another folder to read)",
|
|
135
|
+
getArgumentCompletions: (prefix) => completeConnection(deps, prefix),
|
|
136
|
+
handler: (args, ctx) => learnCode(sendMessage, args, ctx),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function init(ctx: ExtensionCommandContext): void {
|
|
143
|
+
// Run inside an existing project, init fills in what is missing there instead of nesting a new one.
|
|
144
|
+
const root = findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
145
|
+
attempt(ctx, () => {
|
|
146
|
+
const { created } = initProject(root);
|
|
147
|
+
ctx.ui.notify(
|
|
148
|
+
created.length === 0
|
|
149
|
+
? `heydb project already initialized in ${root}.`
|
|
150
|
+
: `heydb project ready in ${root}: created ${created.join(", ")}. Next: /heydb:connect.`,
|
|
151
|
+
"info",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function connect(deps: HeydbDeps, ctx: ExtensionCommandContext): Promise<void> {
|
|
157
|
+
const root = findProjectRoot(ctx.cwd);
|
|
158
|
+
if (root === null) return failed(ctx, new ProjectError("not_initialized", ""));
|
|
159
|
+
if (!ctx.hasUI) {
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
"/heydb:connect needs an interactive session. Add the connection to .heydb/config.json instead, for example:\n" +
|
|
162
|
+
'"connections": { "local": { "engine": "mysql", "host": "127.0.0.1", "port": 3306, "user": "root", ' +
|
|
163
|
+
'"password": "env:HEYDB_LOCAL_PASSWORD", "databases": null } }\n' +
|
|
164
|
+
'"password" is only the name of the variable that holds it; set HEYDB_LOCAL_PASSWORD in your shell or in .heydb/.env.\n' +
|
|
165
|
+
'For PostgreSQL use "engine": "postgresql", port 5432, exactly one database in "databases" and, optionally, ' +
|
|
166
|
+
'"schemas": ["public"] (null captures every non-system schema).',
|
|
167
|
+
"info",
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The password is asked last, with the masked input: ctx.ui.input shows
|
|
173
|
+
// what is typed. config.json only holds the name of the variable that has it.
|
|
174
|
+
const name = await ctx.ui.input("Connection name", "local");
|
|
175
|
+
const engine = name === undefined ? undefined : await ctx.ui.select("Engine", ENGINES);
|
|
176
|
+
const defaults = ENGINE_DEFAULTS[engine ?? "mysql"] ?? { port: "3306", user: "root" };
|
|
177
|
+
const host = engine === undefined ? undefined : await ctx.ui.input("Host", "127.0.0.1");
|
|
178
|
+
const port = host === undefined ? undefined : await ctx.ui.input("Port", defaults.port);
|
|
179
|
+
const user = port === undefined ? undefined : await ctx.ui.input("User", defaults.user);
|
|
180
|
+
// A PostgreSQL connection reaches one database and chooses schemas inside it;
|
|
181
|
+
// in MySQL and MariaDB each database is its own schema.
|
|
182
|
+
const postgres = engine === "postgresql";
|
|
183
|
+
const databases =
|
|
184
|
+
user === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: postgres
|
|
187
|
+
? await ctx.ui.input("Database (exactly one: a PostgreSQL connection reaches one database)", "postgres")
|
|
188
|
+
: await ctx.ui.input("Databases (comma-separated, empty for all)", "shop, crm");
|
|
189
|
+
const schemas =
|
|
190
|
+
databases === undefined || !postgres
|
|
191
|
+
? ""
|
|
192
|
+
: await ctx.ui.input("Schemas (comma-separated, empty for all non-system schemas)", "public");
|
|
193
|
+
if (
|
|
194
|
+
name === undefined ||
|
|
195
|
+
engine === undefined ||
|
|
196
|
+
host === undefined ||
|
|
197
|
+
port === undefined ||
|
|
198
|
+
user === undefined ||
|
|
199
|
+
databases === undefined ||
|
|
200
|
+
schemas === undefined
|
|
201
|
+
) {
|
|
202
|
+
ctx.ui.notify("heydb:connect cancelled; nothing was saved.", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Everything is checked before the password is asked, so nobody types one for nothing.
|
|
207
|
+
let prepared: { connectionName: string; entry: ConnectionConfig; renamed: string };
|
|
208
|
+
try {
|
|
209
|
+
const typed = name.trim() === "" ? "local" : name.trim();
|
|
210
|
+
const connectionName = toConnectionName(typed);
|
|
211
|
+
if (connectionName === null) {
|
|
212
|
+
return ctx.ui.notify(
|
|
213
|
+
`"${typed}" has no letters or digits to build a connection name from; use lowercase letters, digits, "-" or "_".`,
|
|
214
|
+
"error",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const renamed =
|
|
218
|
+
connectionName === typed
|
|
219
|
+
? ""
|
|
220
|
+
: `Connection names are lowercase letters, digits, "-" and "_", so "${typed}" became "${connectionName}".\n`;
|
|
221
|
+
const answers = { engine, host, port: port.trim() === "" ? defaults.port : port, user, databases };
|
|
222
|
+
const built = buildConnection(connectionName, answers);
|
|
223
|
+
const entry = postgres ? validateConnection({ ...built, schemas: list(schemas) }, connectionName) : built;
|
|
224
|
+
addConnection(loadConfig(root), connectionName, entry); // only to refuse a taken name now
|
|
225
|
+
prepared = { connectionName, entry, renamed };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return failed(ctx, error);
|
|
228
|
+
}
|
|
229
|
+
const { connectionName, entry, renamed } = prepared;
|
|
230
|
+
const variable = variableOf(entry);
|
|
231
|
+
|
|
232
|
+
const typedPassword = await askSecret(
|
|
233
|
+
ctx,
|
|
234
|
+
`Password for ${connectionName} (stored in .heydb/.env, ignored by git; leave empty to set ${variable} yourself)`,
|
|
235
|
+
);
|
|
236
|
+
if (typedPassword === undefined) {
|
|
237
|
+
ctx.ui.notify("heydb:connect cancelled; nothing was saved.", "info");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
attempt(ctx, () => {
|
|
242
|
+
const { isDefault } = saveNewConnection(root, connectionName, entry);
|
|
243
|
+
const saved = `Saved connection "${connectionName}" (${describe(entry)})${isDefault ? " as the default" : ""}.\n${renamed}`;
|
|
244
|
+
// null: no terminal to type it in (RPC); empty: the person sets it themselves.
|
|
245
|
+
if (typedPassword === null || typedPassword === "") {
|
|
246
|
+
return ctx.ui.notify(
|
|
247
|
+
`${saved}heydb never stores passwords: set ${variable} in your shell or in .heydb/.env (ignored by git), ` +
|
|
248
|
+
`then run /heydb:sync ${connectionName}.`,
|
|
249
|
+
"info",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
savePassword(root, entry.password, typedPassword);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
throw new Error(`${saved}The password was not saved: ${message(error)}`, { cause: error });
|
|
256
|
+
}
|
|
257
|
+
ctx.ui.notify(
|
|
258
|
+
`${saved}Password saved in .heydb/.env as ${variable} (ignored by git). Next: /heydb:sync ${connectionName}.` +
|
|
259
|
+
environmentNote(deps, variable),
|
|
260
|
+
"info",
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Comma-separated names; nothing typed means null, "all". */
|
|
266
|
+
function list(text: string): string[] | null {
|
|
267
|
+
const items = text
|
|
268
|
+
.split(",")
|
|
269
|
+
.map((item) => item.trim())
|
|
270
|
+
.filter((item) => item !== "");
|
|
271
|
+
return items.length === 0 ? null : items;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function password(deps: HeydbDeps, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
275
|
+
const words = argumentWords(args);
|
|
276
|
+
if (words.length > 1) return ctx.ui.notify("Usage: /heydb:password [connection]", "error");
|
|
277
|
+
let opened: OpenedConnection;
|
|
278
|
+
try {
|
|
279
|
+
opened = openConnection(ctx.cwd, words[0]);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return failed(ctx, error);
|
|
282
|
+
}
|
|
283
|
+
const variable = variableOf(opened.connection);
|
|
284
|
+
const typed = await askSecret(ctx, `Password for ${opened.name} (stored in .heydb/.env, ignored by git; leave empty to keep it as it is)`);
|
|
285
|
+
if (typed === null) {
|
|
286
|
+
return ctx.ui.notify(
|
|
287
|
+
`Typing a password needs Pi's interactive terminal. Set ${variable} in your shell or in .heydb/.env (ignored by git).`,
|
|
288
|
+
"info",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (typed === undefined) return ctx.ui.notify("heydb:password cancelled; nothing was saved.", "info");
|
|
292
|
+
if (typed === "") return ctx.ui.notify(`Nothing changed: the password for "${opened.name}" was left as it is.`, "info");
|
|
293
|
+
attempt(ctx, () => {
|
|
294
|
+
savePassword(opened.root, opened.connection.password, typed);
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
`Password for "${opened.name}" saved in .heydb/.env as ${variable} (ignored by git). Next: /heydb:sync ${opened.name}.` +
|
|
297
|
+
environmentNote(deps, variable),
|
|
298
|
+
"info",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The environment wins over .heydb/.env, so a value saved there would otherwise be ignored without a word. */
|
|
304
|
+
function environmentNote(deps: HeydbDeps, variable: string): string {
|
|
305
|
+
if (deps.env[variable] === undefined) return "";
|
|
306
|
+
return `\nNote: ${variable} is also set in your environment, and that value wins over .heydb/.env.`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const CONNECTIONS_USAGE = "Usage: /heydb:connections [list | use <name> | remove <name>]";
|
|
310
|
+
const SUBCOMMANDS = ["list", "use", "remove"];
|
|
311
|
+
|
|
312
|
+
async function connections(deps: HeydbDeps, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
313
|
+
const [action = "list", name, ...extra] = argumentWords(args);
|
|
314
|
+
const takesName = action === "use" || action === "remove";
|
|
315
|
+
if (!SUBCOMMANDS.includes(action) || extra.length > 0 || takesName !== (name !== undefined)) {
|
|
316
|
+
return ctx.ui.notify(CONNECTIONS_USAGE, "error");
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const { root, connections: listed } = listConnections(ctx.cwd, deps.env);
|
|
320
|
+
if (name === undefined) {
|
|
321
|
+
const lines = [
|
|
322
|
+
`heydb connections in ${root}:`,
|
|
323
|
+
...listed.map(summaryLine),
|
|
324
|
+
...(await sharingLine(root, listed)),
|
|
325
|
+
"Change the default with /heydb:connections use <name>; set a password with /heydb:password <name>.",
|
|
326
|
+
];
|
|
327
|
+
return ctx.ui.notify(lines.join("\n"), "info");
|
|
328
|
+
}
|
|
329
|
+
if (action === "use") {
|
|
330
|
+
chooseDefaultConnection(root, name);
|
|
331
|
+
return ctx.ui.notify(`Default connection is now "${name}".`, "info");
|
|
332
|
+
}
|
|
333
|
+
await remove(root, name, ctx);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
failed(ctx, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function remove(root: string, name: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
340
|
+
// Checked before asking: nobody should confirm the removal of a connection that does not exist.
|
|
341
|
+
removeConnection(loadConfig(root), name);
|
|
342
|
+
if (!ctx.hasUI) {
|
|
343
|
+
return ctx.ui.notify("Removing a connection needs an interactive session to confirm it; edit .heydb/config.json instead.", "error");
|
|
344
|
+
}
|
|
345
|
+
const confirmed = await ctx.ui.confirm(
|
|
346
|
+
`Remove connection "${name}"?`,
|
|
347
|
+
"It is removed from .heydb/config.json and its password from .heydb/.env. Its snapshot and notes stay on disk.",
|
|
348
|
+
);
|
|
349
|
+
if (!confirmed) return ctx.ui.notify("heydb:connections remove cancelled; nothing was changed.", "info");
|
|
350
|
+
const { passwordRemoved, kept } = forgetConnection(root, name);
|
|
351
|
+
const lines = [`Removed connection "${name}"${passwordRemoved ? " and its password line in .heydb/.env" : ""}.`];
|
|
352
|
+
if (kept.length > 0) {
|
|
353
|
+
lines.push(`Kept on disk, so no notes are lost: ${kept.join(", ")}. Delete them yourself if you no longer need them.`);
|
|
354
|
+
}
|
|
355
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether git shares the notes, said once for the project: every connection
|
|
360
|
+
* keeps them in the same folder. A failing git check leaves the line out
|
|
361
|
+
* rather than hiding the list.
|
|
362
|
+
*/
|
|
363
|
+
async function sharingLine(root: string, listed: readonly ConnectionSummary[]): Promise<string[]> {
|
|
364
|
+
const connection = (listed.find((item) => item.isDefault) ?? listed[0])?.name;
|
|
365
|
+
try {
|
|
366
|
+
const sharing = await knowledgeSharing(root, connection === undefined ? {} : { connection });
|
|
367
|
+
switch (sharing.state) {
|
|
368
|
+
case "shared":
|
|
369
|
+
return ["Notes in .heydb/knowledge/ are visible to git: the team gets them once they are committed."];
|
|
370
|
+
case "no-git":
|
|
371
|
+
return ["No git repository here: notes in .heydb/knowledge/ stay on this machine."];
|
|
372
|
+
case "ignored":
|
|
373
|
+
return [`Notes in .heydb/knowledge/ are ignored by git (${sharing.rule}), so the team will not get them. ${SHARE_NOTES_HINT}`];
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const PASSWORD_STATES: Readonly<Record<ConnectionSummary["password"], string>> = {
|
|
381
|
+
environment: "set in your environment",
|
|
382
|
+
"env-file": "in .heydb/.env",
|
|
383
|
+
missing: "not set",
|
|
384
|
+
unreadable: ".heydb/.env is unreadable",
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
function summaryLine(item: ConnectionSummary): string {
|
|
388
|
+
return (
|
|
389
|
+
`- ${item.name}${item.isDefault ? " (default)" : ""}: ${describe(item.connection)} · ` +
|
|
390
|
+
`password ${variableOf(item.connection)}: ${PASSWORD_STATES[item.password]} · ${item.synced ? "synced" : "never synced"}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** "env:HEYDB_LOCAL_PASSWORD" → "HEYDB_LOCAL_PASSWORD"; config validation guarantees the prefix. */
|
|
395
|
+
function variableOf(entry: ConnectionConfig): string {
|
|
396
|
+
return entry.password.slice("env:".length);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function sync(deps: HeydbDeps, sendMessage: SendMessage, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
400
|
+
await withLive(deps, args, ctx, "sync", async (opened, live) => {
|
|
401
|
+
const result = syncSnapshot(opened.root, opened.name, live);
|
|
402
|
+
const facts = [result.written ? "snapshot written" : "unchanged", ...summary(live)];
|
|
403
|
+
if (result.written) facts.push(count(result.changes.length, "change"));
|
|
404
|
+
// A failing scan must not turn a good sync into an error: it only loses the offer.
|
|
405
|
+
const usage = await scanCode(ctx, opened.name, opened.root, live).catch(() => null);
|
|
406
|
+
const noCode = usage !== null && usage.matches.length === 0 ? `\n${NO_CODE}` : "";
|
|
407
|
+
ctx.ui.notify(`heydb sync ${opened.name}: ${facts.join(" · ")}.${noCode}`, "info");
|
|
408
|
+
// The changes also go into the session, so the model knows the schema it reads just moved.
|
|
409
|
+
if (result.written && result.changes.length > 0) {
|
|
410
|
+
await sendMessage({
|
|
411
|
+
customType: "heydb",
|
|
412
|
+
content: `heydb sync ${opened.name} changed the stored snapshot:\n\n${renderChanges(result.changes)}`,
|
|
413
|
+
display: true,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (usage !== null && usage.matches.length > 0 && ctx.hasUI) await offerCode(sendMessage, opened.name, usage, ctx);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Many heydb projects have no code at all (one made only to explore a
|
|
421
|
+
// database); there, the notes come from what the person explains.
|
|
422
|
+
const NO_CODE =
|
|
423
|
+
"No code using these tables was found here; heydb will learn from your conversations (the agent annotates what you explain).";
|
|
424
|
+
|
|
425
|
+
/** Another folder to read instead of the project's: its real path, and the path as typed (to repeat it). */
|
|
426
|
+
interface CodeFolder {
|
|
427
|
+
readonly root: string;
|
|
428
|
+
readonly typed: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Same offer as after a sync, from the stored snapshot: it never connects.
|
|
433
|
+
* The code may live elsewhere (`/heydb:learn-code acme ../legacy-app`); a
|
|
434
|
+
* single argument that is not a connection but an existing folder is that
|
|
435
|
+
* path, with the default connection.
|
|
436
|
+
*/
|
|
437
|
+
async function learnCode(sendMessage: SendMessage, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
438
|
+
const words = argumentWords(args);
|
|
439
|
+
if (words.length > 2) return ctx.ui.notify("Usage: /heydb:learn-code [connection] [path]", "error");
|
|
440
|
+
try {
|
|
441
|
+
const [name, path] = words.length === 1 && isFolderNotConnection(ctx.cwd, words[0] as string) ? [undefined, words[0]] : words;
|
|
442
|
+
const opened = openSnapshot(ctx.cwd, name);
|
|
443
|
+
const folder = path === undefined ? undefined : codeFolder(opened.root, path);
|
|
444
|
+
const usage = await scanCode(ctx, opened.name, folder?.root ?? opened.root, opened.snapshot);
|
|
445
|
+
if (usage.matches.length === 0) return ctx.ui.notify(`heydb learn-code ${opened.name}: ${NO_CODE}`, "info");
|
|
446
|
+
if (!ctx.hasUI) {
|
|
447
|
+
return ctx.ui.notify("/heydb:learn-code needs an interactive session to ask before the agent spends tokens.", "error");
|
|
448
|
+
}
|
|
449
|
+
await offerCode(sendMessage, opened.name, usage, ctx, folder);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
failed(ctx, error);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isFolderNotConnection(cwd: string, word: string): boolean {
|
|
456
|
+
const project = projectConnections(cwd);
|
|
457
|
+
if (project === null || project.names.includes(word)) return false;
|
|
458
|
+
try {
|
|
459
|
+
return statSync(resolve(project.root, word)).isDirectory();
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The folder to scan, relative to the project root or absolute. The scan
|
|
467
|
+
* starts at its real path and only goes down from there: links inside it are
|
|
468
|
+
* never followed, so nothing outside the chosen folder is read.
|
|
469
|
+
*/
|
|
470
|
+
function codeFolder(projectRoot: string, typed: string): CodeFolder {
|
|
471
|
+
const path = resolve(projectRoot, typed);
|
|
472
|
+
let isDirectory: boolean;
|
|
473
|
+
try {
|
|
474
|
+
isDirectory = statSync(path).isDirectory();
|
|
475
|
+
} catch {
|
|
476
|
+
throw new Error(`Folder "${typed}" does not exist.`);
|
|
477
|
+
}
|
|
478
|
+
if (!isDirectory) throw new Error(`"${typed}" is not a folder.`);
|
|
479
|
+
return { root: realpathSync(path), typed };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Asks before the agent reads code: that costs tokens, and the person may
|
|
484
|
+
* rather do it later. Accepted, one message starts an agent turn; while the
|
|
485
|
+
* agent works it waits as a follow-up instead of interrupting it.
|
|
486
|
+
*/
|
|
487
|
+
async function offerCode(
|
|
488
|
+
sendMessage: SendMessage,
|
|
489
|
+
connection: string,
|
|
490
|
+
usage: SchemaUsage,
|
|
491
|
+
ctx: ExtensionCommandContext,
|
|
492
|
+
folder?: CodeFolder,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
const examples = usage.matches.slice(0, 3).map((match) => match.name);
|
|
495
|
+
const where = folder === undefined ? "" : ` in ${folder.root}`;
|
|
496
|
+
const confirmed = await ctx.ui.confirm(
|
|
497
|
+
"Learn from this project's code?",
|
|
498
|
+
`Found ${usage.truncated ? "at least " : ""}${count(usage.filesMatched, "file")}${where} that use ${count(usage.tablesMatched, "table")} ` +
|
|
499
|
+
`(e.g. ${examples.join(", ")}). heydb can ask the agent to read them and save notes about what the code shows. It will use tokens.`,
|
|
500
|
+
);
|
|
501
|
+
if (!confirmed) {
|
|
502
|
+
const again = folder === undefined ? connection : `${connection} ${folder.typed}`;
|
|
503
|
+
return ctx.ui.notify(`Nothing was sent. Run /heydb:learn-code ${again} whenever you want the agent to read it.`, "info");
|
|
504
|
+
}
|
|
505
|
+
const idle = ctx.isIdle();
|
|
506
|
+
await sendMessage(
|
|
507
|
+
{ customType: "heydb", content: learnMessage(connection, usage, folder), display: true },
|
|
508
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
509
|
+
);
|
|
510
|
+
ctx.ui.notify(
|
|
511
|
+
idle
|
|
512
|
+
? "Asked the agent to read the code and note what it shows."
|
|
513
|
+
: "The agent is busy: it will read the code and note what it shows when it finishes its current work.",
|
|
514
|
+
"info",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The agent's instructions: which files, and what is worth a note. Bounded by
|
|
520
|
+
* the scan's top 30 tables. The rules are in English, so the language rule
|
|
521
|
+
* comes first: the person may well be writing in Spanish.
|
|
522
|
+
*/
|
|
523
|
+
function learnMessage(connection: string, usage: SchemaUsage, folder?: CodeFolder): string {
|
|
524
|
+
const example = usage.matches[0]?.files[0] ?? "src/Models/Order.php";
|
|
525
|
+
const source =
|
|
526
|
+
folder === undefined
|
|
527
|
+
? "this project's code."
|
|
528
|
+
: `the code in ${folder.root}. File paths below are relative to that folder.`;
|
|
529
|
+
return [
|
|
530
|
+
`heydb: the user agreed that you learn about the database of connection "${connection}" from ${source}`,
|
|
531
|
+
"",
|
|
532
|
+
"Files that use its tables, most used first:",
|
|
533
|
+
...usage.matches.map((match) => `- ${match.table} (${count(match.hits, "use")}): ${match.files.join(", ")}`),
|
|
534
|
+
"",
|
|
535
|
+
"Rules:",
|
|
536
|
+
"- Answer the user and write the notes in the language the user uses in this conversation (Spanish if they write Spanish); " +
|
|
537
|
+
"keep table and column names as they are.",
|
|
538
|
+
"- Read these files: models, repositories, queries. Before noting a table, read it with heydb_table and skip what its notes already say.",
|
|
539
|
+
"- Save with heydb_annotate only what the code makes explicit: what a table holds, valid values, enums and magic numbers with their meaning, units, soft-delete flags. " +
|
|
540
|
+
"Record joins the code makes without a foreign key with heydb_relate.",
|
|
541
|
+
`- End every note with its source, like "(code: ${example})".`,
|
|
542
|
+
"- Never invent or guess: what the code does not make clear is not saved.",
|
|
543
|
+
"- A few valuable notes beat many; stop after about 20.",
|
|
544
|
+
"- Finish with a short summary for the user: what you saved, and what remains unclear, as questions.",
|
|
545
|
+
].join("\n");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function status(deps: HeydbDeps, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
549
|
+
await withLive(deps, args, ctx, "status", async (opened, live) => {
|
|
550
|
+
const result = checkStatus(opened.root, opened.name, live);
|
|
551
|
+
const text = `heydb status ${opened.name}: ${renderStatus(result)}`;
|
|
552
|
+
switch (result.state) {
|
|
553
|
+
case "up_to_date":
|
|
554
|
+
return ctx.ui.notify(text, "info");
|
|
555
|
+
case "never_synced":
|
|
556
|
+
return ctx.ui.notify(`${text} Run /heydb:sync ${opened.name}.`, "warning");
|
|
557
|
+
case "drifted":
|
|
558
|
+
return ctx.ui.notify(`${text}\n\nRun /heydb:sync ${opened.name} to store it.`, "warning");
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Opens the connection named in `args` (or the default), captures its live schema and hands both to `work`. */
|
|
564
|
+
/** Looks for code that uses the tables, showing progress: on a large legacy codebase it takes seconds. */
|
|
565
|
+
async function scanCode(ctx: ExtensionCommandContext, connection: string, root: string, snapshot: Snapshot) {
|
|
566
|
+
const stop = showProgress(ctx, `heydb: ${connection} · looking for code that uses its tables…`);
|
|
567
|
+
try {
|
|
568
|
+
return await findSchemaUsage(root, snapshot);
|
|
569
|
+
} finally {
|
|
570
|
+
stop();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Footer status and widget slot heydb uses while it reads a database. */
|
|
575
|
+
const PROGRESS_KEY = "heydb";
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Shows `text` until the returned function is called. The footer alone is not
|
|
579
|
+
* enough: in a narrow terminal (a split) Gentle Shell paints a single status
|
|
580
|
+
* row and other statuses replace ours. A widget above the editor stays
|
|
581
|
+
* visible, and its elapsed seconds show the read is still going.
|
|
582
|
+
*/
|
|
583
|
+
function showProgress(ctx: ExtensionCommandContext, text: string): () => void {
|
|
584
|
+
const started = Date.now();
|
|
585
|
+
const paint = () => {
|
|
586
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
587
|
+
ctx.ui.setWidget(PROGRESS_KEY, [`${text} (${seconds}s)`]);
|
|
588
|
+
};
|
|
589
|
+
ctx.ui.setStatus(PROGRESS_KEY, text);
|
|
590
|
+
paint();
|
|
591
|
+
const timer = setInterval(paint, 1000);
|
|
592
|
+
timer.unref?.();
|
|
593
|
+
return () => {
|
|
594
|
+
clearInterval(timer);
|
|
595
|
+
ctx.ui.setWidget(PROGRESS_KEY, undefined);
|
|
596
|
+
ctx.ui.setStatus(PROGRESS_KEY, undefined);
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function withLive(
|
|
601
|
+
deps: HeydbDeps,
|
|
602
|
+
args: string,
|
|
603
|
+
ctx: ExtensionCommandContext,
|
|
604
|
+
action: string,
|
|
605
|
+
work: (opened: OpenedConnection, live: Snapshot) => Promise<void>,
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
// A second word is a mistake (two connections, a typo): better said than silently dropped.
|
|
608
|
+
const words = argumentWords(args);
|
|
609
|
+
if (words.length > 1) return ctx.ui.notify(`Usage: /heydb:${action} [connection]`, "error");
|
|
610
|
+
try {
|
|
611
|
+
const opened = openConnection(ctx.cwd, words[0]);
|
|
612
|
+
// Config and password errors explain themselves; a driver error such as
|
|
613
|
+
// "connect ECONNREFUSED" also needs to say what was being attempted.
|
|
614
|
+
// Reading a large server takes seconds, so the footer says what heydb is
|
|
615
|
+
// doing until it finishes or fails.
|
|
616
|
+
const introspect: HeydbDeps["introspect"] = async (entry, password) => {
|
|
617
|
+
const doing = action === "sync" ? "syncing" : "checking";
|
|
618
|
+
const text = `heydb: ${doing} ${opened.name} · reading the structure of ${entry.host}:${entry.port}…`;
|
|
619
|
+
const stop = showProgress(ctx, text);
|
|
620
|
+
try {
|
|
621
|
+
return await deps.introspect(entry, password);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
throw new Error(`heydb ${action} ${opened.name} failed: ${message(error)}`, { cause: error });
|
|
624
|
+
} finally {
|
|
625
|
+
stop();
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
await work(opened, await captureLive(opened, { env: deps.env, introspect }));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
failed(ctx, error);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The words of a command's arguments; none for an empty or blank string. */
|
|
635
|
+
function argumentWords(args: string): string[] {
|
|
636
|
+
return args
|
|
637
|
+
.trim()
|
|
638
|
+
.split(/\s+/)
|
|
639
|
+
.filter((word) => word !== "");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
type Completion = { value: string; label: string };
|
|
643
|
+
|
|
644
|
+
/** Connection names of the project in the current folder; nothing when there is none or its config is broken. */
|
|
645
|
+
function completeConnection(deps: HeydbDeps, prefix: string): Completion[] | null {
|
|
646
|
+
const items = connectionNames(deps)
|
|
647
|
+
.filter((name) => name.startsWith(prefix))
|
|
648
|
+
.map((name) => ({ value: name, label: name }));
|
|
649
|
+
return items.length > 0 ? items : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The subcommand first, then a connection name for "use" and "remove". Pi
|
|
654
|
+
* replaces everything typed after the command with the chosen value, so each
|
|
655
|
+
* value repeats the subcommand.
|
|
656
|
+
*/
|
|
657
|
+
function completeConnections(deps: HeydbDeps, prefix: string): Completion[] | null {
|
|
658
|
+
const [action = "", name, ...extra] = prefix.split(" ");
|
|
659
|
+
let items: Completion[];
|
|
660
|
+
if (name === undefined) {
|
|
661
|
+
items = SUBCOMMANDS.filter((item) => item.startsWith(action)).map((item) => ({
|
|
662
|
+
value: item === "list" ? item : `${item} `,
|
|
663
|
+
label: item,
|
|
664
|
+
}));
|
|
665
|
+
} else if ((action === "use" || action === "remove") && extra.length === 0) {
|
|
666
|
+
items = connectionNames(deps)
|
|
667
|
+
.filter((item) => item.startsWith(name))
|
|
668
|
+
.map((item) => ({ value: `${action} ${item}`, label: item }));
|
|
669
|
+
} else {
|
|
670
|
+
items = [];
|
|
671
|
+
}
|
|
672
|
+
return items.length > 0 ? items : null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function connectionNames(deps: HeydbDeps): string[] {
|
|
676
|
+
try {
|
|
677
|
+
return projectConnections(deps.cwd())?.names ?? [];
|
|
678
|
+
} catch {
|
|
679
|
+
return []; // completion must never fail loudly; the command itself will report the broken config
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function attempt(ctx: ExtensionCommandContext, work: () => void): void {
|
|
684
|
+
try {
|
|
685
|
+
work();
|
|
686
|
+
} catch (error) {
|
|
687
|
+
failed(ctx, error);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// A person can run slash commands, so the instructions name them directly.
|
|
692
|
+
function failed(ctx: ExtensionCommandContext, error: unknown): void {
|
|
693
|
+
if (error instanceof ProjectError) {
|
|
694
|
+
switch (error.code) {
|
|
695
|
+
case "not_initialized":
|
|
696
|
+
return ctx.ui.notify("No heydb project here: run /heydb:init first.", "error");
|
|
697
|
+
case "no_connections":
|
|
698
|
+
return ctx.ui.notify("No connections yet: run /heydb:connect first.", "error");
|
|
699
|
+
case "never_synced":
|
|
700
|
+
return ctx.ui.notify(`Connection "${error.connection}" was never synced: run /heydb:sync ${error.connection}.`, "error");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
ctx.ui.notify(message(error), "error");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function message(error: unknown): string {
|
|
707
|
+
return error instanceof Error ? error.message : String(error);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function describe(entry: ConnectionConfig): string {
|
|
711
|
+
const server = `${entry.engine} ${entry.user}@${entry.host}:${entry.port}`;
|
|
712
|
+
if (entry.engine === "postgresql") {
|
|
713
|
+
const schemas = entry.schemas == null ? "all schemas" : `schemas ${entry.schemas.join(", ")}`;
|
|
714
|
+
return `${server}, database ${entry.databases?.join(", ")}, ${schemas}`;
|
|
715
|
+
}
|
|
716
|
+
const databases = entry.databases === null ? "all databases" : `databases ${entry.databases.join(", ")}`;
|
|
717
|
+
return `${server}, ${databases}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function summary(snapshot: Snapshot): string[] {
|
|
721
|
+
const tables = snapshot.databases.reduce(
|
|
722
|
+
(total, database) => total + database.schemas.reduce((sum, schema) => sum + schema.tables.length, 0),
|
|
723
|
+
0,
|
|
724
|
+
);
|
|
725
|
+
const facts = [`${count(snapshot.databases.length, "database")}, ${count(tables, "table")}`];
|
|
726
|
+
if (snapshot.warnings.length > 0) facts.push(count(snapshot.warnings.length, "warning"));
|
|
727
|
+
return facts;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function count(n: number, noun: string): string {
|
|
731
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
732
|
+
}
|