@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/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/cli.js +2014 -0
- package/package.json +50 -0
- package/src/cli/action.ts +40 -0
- package/src/cli/driver-commands.ts +195 -0
- package/src/cli/index.ts +1358 -0
- package/src/cli/init.ts +56 -0
- package/src/cli/inspect.ts +318 -0
- package/src/cli/migrate.ts +606 -0
- package/src/cli/portable-diff.ts +91 -0
- package/src/cli/resolve.ts +333 -0
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("[2K\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
|
+
}
|