@better-schemic/cli 0.1.0-alpha.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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@better-schemic/cli",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "The Better-schemic CLI — driver-agnostic schema migrations (gen/migrate/diff/push/pull/check).",
5
+ "license": "MIT",
6
+ "author": "Vertio Solutions",
7
+ "homepage": "https://github.com/NONSTANDARDCODE/better-schemic",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/NONSTANDARDCODE/better-schemic.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "bin": {
16
+ "better-schemic": "lib/cli.js",
17
+ "sc": "lib/cli.js"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "src",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ "./package.json": "./package.json"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "prepack": "bun run build",
33
+ "test": "bun test",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "biome check ."
36
+ },
37
+ "dependencies": {
38
+ "@better-schemic/core": "0.1.0-alpha.1",
39
+ "commander": "^14.0.2"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "^2.3.11",
43
+ "@better-schemic/surrealdb": "0.1.0-alpha.1",
44
+ "@types/bun": "latest",
45
+ "surrealdb": "^2.0.3",
46
+ "tsup": "^8",
47
+ "typescript": "^5",
48
+ "zod": "^4.3.5"
49
+ }
50
+ }
@@ -0,0 +1,40 @@
1
+ // Shared command-action runner: execute an action to completion, format any error the same way across
2
+ // EVERY command (built-ins, driver `sc <kind> <verb>`, and the inspect ls/info), and force `process.exit`
3
+ // once it settles so a lingering SDK connection handle can't keep the process alive.
4
+ // BETTER_SCHEMIC_DEBUG=1 (legacy: SCHEMIC_DEBUG=1) or --stack prints the full stack + the `.cause`
5
+ // chain; otherwise a one-line hint. Watch commands return a never-settling promise, so they keep
6
+ // running until SIGINT.
7
+ import { fail, style } from "@better-schemic/core";
8
+
9
+ // Legacy `SCHEMIC_DEBUG` still works; the `BETTER_SCHEMIC_` spelling wins when both are set.
10
+ const debugEnabled = (): boolean =>
11
+ !!process.env.BETTER_SCHEMIC_DEBUG || !!process.env.SCHEMIC_DEBUG;
12
+
13
+ const errMsg = (err: unknown) =>
14
+ err instanceof Error ? err.message : String(err);
15
+
16
+ /** Run `action`, then exit (0 on success / the set exitCode; 1 on a thrown error, printed cleanly). */
17
+ export function runAction(action: () => Promise<void>): void {
18
+ action().then(
19
+ () => process.exit(process.exitCode ?? 0),
20
+ (err: unknown) => {
21
+ console.error(`\n${fail(errMsg(err))}`);
22
+ if (debugEnabled() || process.argv.includes("--stack")) {
23
+ for (let e: unknown = err, depth = 0; e && depth < 8; depth++) {
24
+ console.error(
25
+ style.dim(e instanceof Error ? (e.stack ?? String(e)) : String(e)),
26
+ );
27
+ e = e instanceof Error ? e.cause : undefined;
28
+ if (e) console.error(style.dim("caused by:"));
29
+ }
30
+ } else {
31
+ console.error(
32
+ style.dim(
33
+ "(re-run with BETTER_SCHEMIC_DEBUG=1 or --stack for the stack trace)",
34
+ ),
35
+ );
36
+ }
37
+ process.exit(1);
38
+ },
39
+ );
40
+ }
@@ -0,0 +1,195 @@
1
+ // Dispatch for DRIVER-CONTRIBUTED CLI commands — `sc <kind> <verb> [args]` (e.g. surreal
2
+ // `sc access rotate <name>`). Core owns ONLY this mechanism:
3
+ // it discovers `driver.commands`, registers each under its kind, parses argv, resolves the connection,
4
+ // and dispatches to `command.run` with a CommandContext. The driver owns each kind/verb's meaning.
5
+ //
6
+ // Driver commands are DISCOVERED LAZILY: the driver is loaded from the project's config, so this
7
+ // registration runs (async) before `program.parse()`. With no config (e.g. `sc init`), it no-ops.
8
+
9
+ import { createInterface } from "node:readline/promises";
10
+ import {
11
+ type CommandContext,
12
+ type CommandIo,
13
+ type DriverCommand,
14
+ envSecretProvider,
15
+ fail,
16
+ getDriver,
17
+ loadProject,
18
+ ok,
19
+ type ParsedCommandArgs,
20
+ style,
21
+ } from "@better-schemic/core";
22
+ import type { Command } from "commander";
23
+ import { runAction } from "./action";
24
+ import { registerInspectVerbs } from "./inspect";
25
+ import { ensureDriver, type ResolveOpts, resolveOne } from "./resolve";
26
+
27
+ /** Stdio + prompt helpers handed to a command, so a driver never touches stdio directly. */
28
+ const io: CommandIo = {
29
+ ok: (m) => console.log(ok(m)),
30
+ fail: (m) => console.error(fail(m)),
31
+ info: (m) => console.log(m),
32
+ async prompt(question, opts) {
33
+ if (!process.stdin.isTTY)
34
+ throw new Error(
35
+ `"${question}" needs an interactive terminal (no value was provided non-interactively)`,
36
+ );
37
+ const rl = createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout,
40
+ });
41
+ try {
42
+ // `hidden` masks typed input (e.g. a password) so it never echoes to the screen.
43
+ if (opts?.hidden) {
44
+ const out = process.stdout;
45
+ const onData = () => out.write("\r");
46
+ process.stdin.on("data", onData);
47
+ try {
48
+ return (await rl.question(`${question} `)).trim();
49
+ } finally {
50
+ process.stdin.off("data", onData);
51
+ }
52
+ }
53
+ return (await rl.question(`${question} `)).trim();
54
+ } finally {
55
+ rl.close();
56
+ }
57
+ },
58
+ };
59
+
60
+ /** Pre-scan argv for `-c`/`--config <path>` so registration loads the right project's driver. */
61
+ function configFromArgv(argv: string[]): string | undefined {
62
+ for (let i = 0; i < argv.length; i++) {
63
+ if (argv[i] === "-c" || argv[i] === "--config") return argv[i + 1];
64
+ if (argv[i].startsWith("--config="))
65
+ return argv[i].slice("--config=".length);
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Parse a command's raw positionals + commander option bag into {@link ParsedCommandArgs}, per the
72
+ * command's declared `args`. Core collects ALL positionals (the driver validates arity); flags become
73
+ * a string (value-flag) or boolean. Throws if a `required` flag is missing. Pure — unit-testable.
74
+ */
75
+ export function toParsedArgs(
76
+ cmd: DriverCommand,
77
+ positionals: string[],
78
+ opts: Record<string, unknown>,
79
+ ): ParsedCommandArgs {
80
+ const flags: ParsedCommandArgs["flags"] = {};
81
+ for (const f of cmd.args?.flags ?? []) {
82
+ const v = opts[f.name];
83
+ flags[f.name] = f.value
84
+ ? (v as string | undefined)
85
+ : (v as boolean) === true;
86
+ if (f.required && (v === undefined || v === false))
87
+ throw new Error(`\`sc ${cmd.kind} ${cmd.verb}\` requires --${f.name}`);
88
+ }
89
+ return { positionals, flags };
90
+ }
91
+
92
+ /** Register one driver command as `sc <kind> <verb>` under its (shared) kind group. */
93
+ function addCommand(
94
+ kindGroup: Command,
95
+ cmd: DriverCommand,
96
+ resolveOpts: () => ResolveOpts,
97
+ ): void {
98
+ // Positionals are always collected raw (variadic) and the DRIVER validates arity; the declared
99
+ // `args.positionals` shape only feeds the usage line + help.
100
+ const usage = (cmd.args?.positionals ?? [])
101
+ .map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`))
102
+ .join(" ");
103
+ const verb = kindGroup
104
+ .command(`${cmd.verb} [args...]`)
105
+ .summary(cmd.summary)
106
+ .description(usage ? `${cmd.summary}\n\nArguments: ${usage}` : cmd.summary);
107
+
108
+ for (const f of cmd.args?.flags ?? []) {
109
+ const flag = f.value ? `--${f.name} <value>` : `--${f.name}`;
110
+ verb.option(flag, f.help ?? "");
111
+ }
112
+
113
+ verb.action((args: string[], opts: Record<string, unknown>) =>
114
+ runAction(async () => {
115
+ const parsed = toParsedArgs(cmd, args, opts);
116
+
117
+ const config = await resolveOne(resolveOpts());
118
+ const driver = getDriver(config.driver);
119
+ const conn = await driver.connect(config);
120
+ const ctx: CommandContext = {
121
+ conn,
122
+ config,
123
+ io,
124
+ secrets: envSecretProvider,
125
+ };
126
+ try {
127
+ await cmd.run(ctx, parsed);
128
+ } finally {
129
+ await driver.close(conn);
130
+ }
131
+ }),
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Register every `sc <kind> <verb>` command from the active driver's project: the CORE-provided
137
+ * `ls`/`info` for each registered kind (+ the top-level `sc ls` overview) and the driver's own
138
+ * contributed `commands` on top, grouped per kind. Runs before `program.parse()`; no-ops entirely when
139
+ * there's no project config (the driver is unknown, e.g. `sc init`). `resolveOpts` reads the global
140
+ * addressing flags (`--connection`/`--config`) off the invoked command at action time.
141
+ */
142
+ export async function registerDriverCommands(program: Command): Promise<void> {
143
+ let driverName: string;
144
+ const configPath = configFromArgv(process.argv.slice(2));
145
+ try {
146
+ const { config } = await loadProject({ config: configPath });
147
+ // Each connection entry carries its driver name directly (no resolve/connect needed): read it off
148
+ // any entry. A project's connections share one driver, so the first is enough.
149
+ const entry = Object.values(config.connections)[0] as
150
+ | { driver?: string }
151
+ | undefined;
152
+ if (!entry?.driver) return;
153
+ driverName = entry.driver;
154
+ } catch {
155
+ return; // no/invalid config (e.g. `sc init`) — driver commands simply aren't available
156
+ }
157
+
158
+ try {
159
+ await ensureDriver(driverName);
160
+ } catch {
161
+ return;
162
+ }
163
+ const driver = getDriver(driverName);
164
+
165
+ // Group verbs under a shared kind command, so the universal `sc <kind> ls`/`info` and any driver
166
+ // verbs (`sc access rotate`, `sc access check`) co-exist under one `sc <kind>` group.
167
+ const groups = new Map<string, Command>();
168
+ const groupFor = (kind: string): Command => {
169
+ let g = groups.get(kind);
170
+ if (!g) {
171
+ g = program
172
+ .command(kind)
173
+ .summary(`inspect + manage ${style.bold(kind)} resources`)
174
+ .description(
175
+ `\`${kind}\` commands: the universal \`ls\`/\`info\` (any kind) plus any driver verbs from @better-schemic/${driverName}.`,
176
+ );
177
+ groups.set(kind, g);
178
+ }
179
+ return g;
180
+ };
181
+
182
+ // The invoked verb command carries the global addressing flags; read them at action time.
183
+ const resolveOpts = (): ResolveOpts => {
184
+ const opts = program.opts() as { connection?: string; config?: string };
185
+ return { connection: opts.connection, config: opts.config ?? configPath };
186
+ };
187
+
188
+ // Core-provided READ inspection for EVERY registered kind (`sc <kind> ls`/`info` + top-level `sc ls`).
189
+ registerInspectVerbs(program, driver, groupFor, resolveOpts);
190
+
191
+ // The driver's own verbs (e.g. `access rotate`) on top of the universal ls/info.
192
+ for (const cmd of driver.commands ?? []) {
193
+ addCommand(groupFor(cmd.kind), cmd, resolveOpts);
194
+ }
195
+ }