Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/create-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ npm create @tabularis/plugin@latest my-driver

A runnable Rust project with:

- **`manifest.json`** aligned with the Tabularis plugin schema.
- **`.tabularium`** bundle manifest aligned with the Tabularis plugin schema.
- **33 JSON-RPC handlers pre-wired** — metadata methods return empty arrays (plugin loads cleanly), query/CRUD/DDL methods return `-32601` until you implement them.
- **`test_connection` placeholder** that returns success, so your driver appears in the connection picker immediately after `just dev-install`.
- **Working utilities**: `quote_identifier`, `paginate` — with unit tests — ready to use from your handlers.
Expand Down Expand Up @@ -68,12 +68,43 @@ just dev-install # builds and installs into ~/.local/share/tabulari

From there, fill in handlers in `src/handlers/metadata.rs`, then `query.rs`, then the rest. The generated `README.md` includes a feature-by-feature roadmap.

## Migrating an existing plugin

Plugins built before the registry cutover ship a `manifest.json`. The host now
reads a `.tabularium` bundle manifest (the `manifest.json` path survives only as
a deprecated fallback). To convert a project in place:

```bash
npx @tabularis/create-plugin migrate # current directory
npx @tabularis/create-plugin migrate ./my-driver
```

This writes `.tabularium` from your `manifest.json`, removes the old file, and
updates the `manifest.json` references in `release.yml`, `justfile`, and
`README.md`. It keeps a `id` that differs from `name` (the host uses it as the
plugin identity) and refuses to run if the manifest has no semver `version`,
which the registry requires.

By default the release workflow is left as-is (only its `manifest.json`
reference is renamed so the build keeps working). The hosted Tabularium registry
resolves the manifest from the **release assets**, so it needs `.tabularium`
published as a standalone asset. Add `--ci` to regenerate `release.yml` from the
registry-ready template:

```bash
npx @tabularis/create-plugin migrate ./my-driver --ci
```

`--ci` overwrites `release.yml` (re-apply any custom CI steps) and derives the
binary name from the manifest's `executable` field. Commit the result and
republish.

## Layout of the generated project

```
my-driver/
├── Cargo.toml
├── manifest.json
├── .tabularium
├── README.md
├── justfile # just build / test / dev-install / repl / lint / fmt
├── rust-toolchain.toml
Expand Down
2 changes: 1 addition & 1 deletion packages/create-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tabularis/create-plugin",
"version": "0.1.1",
"version": "0.2.0",
"description": "Scaffold a new Tabularis database driver plugin in seconds.",
"license": "Apache-2.0",
"homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/create-plugin",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-plugin/scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function scaffoldOne(kind: "network" | "file", withUi: boolean): void {
if (withUi) args.push("--with-ui");
run(process.execPath, [CLI, ...args], dir);

for (const expected of ["Cargo.toml", "manifest.json", "src/main.rs", "justfile"]) {
for (const expected of ["Cargo.toml", ".tabularium", "src/main.rs", "justfile"]) {
const p = join(target, expected);
if (!existsSync(p) || statSync(p).size === 0) {
throw new Error(`missing or empty: ${p}`);
Expand Down
26 changes: 25 additions & 1 deletion packages/create-plugin/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { parseArgs } from "node:util";
import { resolve } from "node:path";

import { printCreated, printError, printHelp } from "./print";
import { migratePlugin } from "./migrate";
import { printCreated, printError, printHelp, printMigrated } from "./print";
import { scaffold } from "./scaffold";
import { titleCase, validateDbType, validateName, validateQuote } from "./validate";

Expand All @@ -20,6 +21,7 @@ function main(argv: string[]): number {
quote: { type: "string" },
"with-ui": { type: "boolean", default: false },
"no-git": { type: "boolean", default: false },
ci: { type: "boolean", default: false },
dir: { type: "string" },
version: { type: "boolean", short: "v", default: false },
help: { type: "boolean", short: "h", default: false },
Expand All @@ -41,6 +43,10 @@ function main(argv: string[]): number {
return 0;
}

if (parsed.positionals[0] === "migrate") {
return runMigrate(parsed);
}

const rawName = parsed.positionals[0];
if (!rawName) {
printError("missing <name> argument");
Expand Down Expand Up @@ -92,5 +98,23 @@ function main(argv: string[]): number {
return 0;
}

/**
* `migrate [path]` — convert a legacy `manifest.json` plugin to a `.tabularium`
* bundle in place. Operates on the given path, or `--dir`, or the cwd.
*/
function runMigrate(parsed: ReturnType<typeof parseArgs>): number {
const target = resolve(
parsed.positionals[1] ?? (parsed.values.dir as string | undefined) ?? process.cwd(),
);
try {
const result = migratePlugin(target, { ci: Boolean(parsed.values.ci) });
printMigrated(target, result);
return 0;
} catch (err) {
printError(err instanceof Error ? err.message : String(err));
return 1;
}
}

const exitCode = main(process.argv.slice(2));
if (exitCode !== 0) process.exit(exitCode);
170 changes: 170 additions & 0 deletions packages/create-plugin/src/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { substitute } from "./substitute";

/** Semver as the registry accepts it: MAJOR.MINOR.PATCH, optional pre-release/build. No leading "v". */
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].+)?$/;

const RELEASE_WORKFLOW = ".github/workflows/release.yml";

/** Files whose `manifest.json` references must follow the rename (release.yml only when not regenerated). */
const REFERENCE_FILES = ["justfile", "README.md"];

export interface MigrateOptions {
/**
* Regenerate `.github/workflows/release.yml` from the registry-ready template
* instead of only renaming its `manifest.json` reference. Overwrites the file.
*/
ci?: boolean;
}

export interface MigrateResult {
/** Human-readable list of what changed, in apply order. */
changed: string[];
/** Non-fatal notices (e.g. a load-bearing `id` that was deliberately kept). */
warnings: string[];
/** True when the release workflow was regenerated from the template (`--ci`). */
ciRegenerated: boolean;
}

/** Template root: `../templates` from this module, in both `src/` (tests) and `dist/` (published). */
function templateRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "../templates");
}

/** Rename `manifest.json` → `.tabularium` references in a file, if present. */
function patchReferences(dir: string, rel: string, changed: string[]): void {
const p = join(dir, rel);
if (!existsSync(p)) return;
const before = readFileSync(p, "utf8");
// "manifest.json" is not a substring of "manifest.schema.json", so schema
// links are left untouched.
const after = before.split("manifest.json").join(".tabularium");
if (after !== before) {
writeFileSync(p, after, "utf8");
changed.push(`${rel} (references updated)`);
}
}

/** Render the registry-ready release workflow from the template with the plugin's binary name. */
function regenerateReleaseWorkflow(dir: string, binName: string): void {
const tmplPath = join(templateRoot(), "rust-driver", RELEASE_WORKFLOW + ".tmpl");
const rendered = substitute(readFileSync(tmplPath, "utf8"), { BIN_NAME: binName });
const out = join(dir, RELEASE_WORKFLOW);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, rendered, "utf8");
}

/**
* Migrate a plugin project from the legacy `manifest.json` to the canonical
* `.tabularium` bundle manifest the host now reads.
*
* Hard cutover (the `COMPAT(registry-ga)` fallback in the host is meant to go
* away): writes `.tabularium`, deletes `manifest.json`, and renames the
* `manifest.json` references in the release workflow, justfile, and README.
*
* With `options.ci`, the release workflow is instead **regenerated** from the
* registry-ready template so `.tabularium` is published as a standalone release
* asset (which the Tabularium registry requires). Without it, the CI structure
* is left alone — only its `manifest.json` reference is renamed so the build
* keeps working.
*
* `id` is dropped only when it equals `name`. The host falls back to `name` for
* the plugin identity when `id` is absent, so an `id` that differs from `name`
* is load-bearing and is kept (with a warning) rather than silently changing
* the plugin's identity.
*
* Throws on a missing/invalid manifest or a missing/invalid `version` — the
* registry rejects releases whose manifest has no semver `version`.
*/
export function migratePlugin(dir: string, options: MigrateOptions = {}): MigrateResult {
const manifestPath = join(dir, "manifest.json");
const tabulariumPath = join(dir, ".tabularium");

if (!existsSync(manifestPath)) {
if (existsSync(tabulariumPath)) {
return {
changed: [],
warnings: [
"Nothing to do: a .tabularium manifest is already present and there is no manifest.json to convert.",
],
ciRegenerated: false,
};
}
throw new Error(
`No manifest.json found in ${dir}. Run this from a plugin project root, or pass the path (e.g. \`migrate ./my-driver\`).`,
);
}

let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
} catch (err) {
throw new Error(
`manifest.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
);
}

const version = manifest.version;
if (typeof version !== "string" || !SEMVER_RE.test(version)) {
throw new Error(
`manifest.json has no valid "version" (found ${JSON.stringify(version)}). ` +
`The registry requires a semver version with no leading "v" (e.g. "1.0.0"). Add one, then migrate.`,
);
}

const warnings: string[] = [];
const changed: string[] = [];
let ciRegenerated = false;

const { id, name } = manifest as { id?: unknown; name?: unknown };
if (typeof id === "string") {
if (id === name) {
delete manifest.id;
} else {
warnings.push(
`Kept "id": ${JSON.stringify(id)} — it differs from "name" (${JSON.stringify(name)}), ` +
`so it identifies the plugin and is not redundant. Remove it manually only if you also rename the install directory.`,
);
}
}

writeFileSync(tabulariumPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
changed.push(".tabularium (written)");

rmSync(manifestPath);
changed.push("manifest.json (removed)");

// Follow the rename everywhere the bundle file is referenced.
for (const rel of REFERENCE_FILES) {
patchReferences(dir, rel, changed);
}

// Release workflow: regenerate to registry-ready only with --ci; otherwise
// just rename its reference so the existing CI keeps building.
if (options.ci) {
const executable = typeof manifest.executable === "string" ? manifest.executable.trim() : "";
if (!executable) {
warnings.push(
`--ci skipped for ${RELEASE_WORKFLOW}: the manifest has no "executable", so the workflow's binary name can't be derived. Renamed its reference instead — upgrade the CI by hand.`,
);
patchReferences(dir, RELEASE_WORKFLOW, changed);
} else {
const existed = existsSync(join(dir, RELEASE_WORKFLOW));
regenerateReleaseWorkflow(dir, executable);
changed.push(`${RELEASE_WORKFLOW} (${existed ? "regenerated" : "created"} from registry-ready template)`);
if (existed) {
warnings.push(
`Overwrote ${RELEASE_WORKFLOW} from the registry-ready template — re-apply any custom CI steps you had.`,
);
}
ciRegenerated = true;
}
} else {
patchReferences(dir, RELEASE_WORKFLOW, changed);
}

return { changed, warnings, ciRegenerated };
}
41 changes: 41 additions & 0 deletions packages/create-plugin/src/print.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import kleur from "kleur";

import type { MigrateResult } from "./migrate";

export function printCreated(slug: string, targetDir: string, withUi: boolean): void {
console.log("");
console.log(kleur.green("✓") + " " + kleur.bold(`Created ${slug}`) + kleur.dim(` at ${targetDir}`));
Expand All @@ -17,6 +19,36 @@ export function printCreated(slug: string, targetDir: string, withUi: boolean):
console.log("");
}

export function printMigrated(targetDir: string, result: MigrateResult): void {
console.log("");
if (result.changed.length === 0) {
console.log(kleur.yellow("•") + " " + (result.warnings[0] ?? "Nothing to migrate."));
console.log("");
return;
}
console.log(kleur.green("✓") + " " + kleur.bold("Migrated to .tabularium") + kleur.dim(` in ${targetDir}`));
console.log("");
for (const change of result.changed) {
console.log(" " + kleur.dim("•") + " " + change);
}
if (result.warnings.length > 0) {
console.log("");
for (const warning of result.warnings) {
console.log(kleur.yellow(" ! ") + warning);
}
}
console.log("");
if (result.ciRegenerated) {
console.log(kleur.dim("Release workflow is registry-ready (publishes .tabularium as a standalone"));
console.log(kleur.dim("asset). Commit the changes and republish."));
} else {
console.log(kleur.dim("Next: make your release workflow publish .tabularium as a standalone asset —"));
console.log(kleur.dim("the registry resolves the manifest from release assets, not the bundle zips."));
console.log(kleur.dim("Re-run with --ci to regenerate it from the template. Then commit and republish."));
}
console.log("");
}

export function printError(message: string): void {
console.error(kleur.red("✗ ") + message);
}
Expand All @@ -28,15 +60,22 @@ ${kleur.bold("@tabularis/create-plugin")} — scaffold a new Tabularis driver pl
${kleur.bold("Usage:")}
npm create @tabularis/plugin@latest [--] [options] <name>
npx @tabularis/create-plugin [options] <name>
npx @tabularis/create-plugin migrate [path]

${kleur.bold("Commands:")}
<name> Scaffold a new plugin (default)
migrate [path] Convert an existing manifest.json plugin to .tabularium

${kleur.bold("Arguments:")}
<name> Plugin name (slugified to lowercase with hyphens)
[path] Plugin project to migrate (default: current directory)

${kleur.bold("Options:")}
--db-type <kind> network | file | folder | api (default: network)
--quote <char> " | \` (default: ")
--with-ui Also scaffold a ui/ subworkspace using @tabularis/plugin-api
--no-git Skip \`git init\` on the new project
--ci (migrate) Regenerate release.yml from the registry-ready template
--dir <path> Target directory (default: ./<name>)
-v, --version Print version
-h, --help Print this help
Expand All @@ -45,5 +84,7 @@ ${kleur.bold("Examples:")}
npm create @tabularis/plugin@latest my-driver
npm create @tabularis/plugin@latest sqlite-like -- --db-type=file
npx @tabularis/create-plugin hackernews --db-type=api --with-ui
npx @tabularis/create-plugin migrate ./my-driver
npx @tabularis/create-plugin migrate ./my-driver --ci
`);
}
Loading