diff --git a/packages/create-plugin/README.md b/packages/create-plugin/README.md index 69b769c6..a4059dd4 100644 --- a/packages/create-plugin/README.md +++ b/packages/create-plugin/README.md @@ -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. @@ -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 diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json index 93aebdd7..03be5f17 100644 --- a/packages/create-plugin/package.json +++ b/packages/create-plugin/package.json @@ -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", diff --git a/packages/create-plugin/scripts/smoke.ts b/packages/create-plugin/scripts/smoke.ts index 5b5112bf..32f3cf1b 100644 --- a/packages/create-plugin/scripts/smoke.ts +++ b/packages/create-plugin/scripts/smoke.ts @@ -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}`); @@ -45,7 +45,7 @@ function scaffoldOne(kind: "network" | "file", withUi: boolean): void { } if (withUi) { - for (const expected of ["ui/package.json", "ui/vite.config.ts", "ui/src/index.tsx"]) { + for (const expected of ["ui/package.json", "ui/vite.config.ts", "ui/src/index.tsx", "locales/en.json"]) { const p = join(target, expected); if (!existsSync(p) || statSync(p).size === 0) { throw new Error(`missing or empty: ${p}`); diff --git a/packages/create-plugin/src/cli.ts b/packages/create-plugin/src/cli.ts index 41b4fe87..295558ac 100644 --- a/packages/create-plugin/src/cli.ts +++ b/packages/create-plugin/src/cli.ts @@ -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"; @@ -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 }, @@ -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 argument"); @@ -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): 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); diff --git a/packages/create-plugin/src/migrate.ts b/packages/create-plugin/src/migrate.ts new file mode 100644 index 00000000..249ae12a --- /dev/null +++ b/packages/create-plugin/src/migrate.ts @@ -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; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + } 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 }; +} diff --git a/packages/create-plugin/src/print.ts b/packages/create-plugin/src/print.ts index ff7a1783..2ecda732 100644 --- a/packages/create-plugin/src/print.ts +++ b/packages/create-plugin/src/print.ts @@ -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}`)); @@ -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); } @@ -28,15 +60,22 @@ ${kleur.bold("@tabularis/create-plugin")} — scaffold a new Tabularis driver pl ${kleur.bold("Usage:")} npm create @tabularis/plugin@latest [--] [options] npx @tabularis/create-plugin [options] + npx @tabularis/create-plugin migrate [path] + +${kleur.bold("Commands:")} + Scaffold a new plugin (default) + migrate [path] Convert an existing manifest.json plugin to .tabularium ${kleur.bold("Arguments:")} Plugin name (slugified to lowercase with hyphens) + [path] Plugin project to migrate (default: current directory) ${kleur.bold("Options:")} --db-type network | file | folder | api (default: network) --quote " | \` (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 Target directory (default: ./) -v, --version Print version -h, --help Print this help @@ -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 `); } diff --git a/packages/create-plugin/src/scaffold.ts b/packages/create-plugin/src/scaffold.ts index 998f86e1..a7c959ba 100644 --- a/packages/create-plugin/src/scaffold.ts +++ b/packages/create-plugin/src/scaffold.ts @@ -124,6 +124,9 @@ export function scaffold(opts: ScaffoldOptions): void { if (opts.withUi) { copyTemplate("ui-extension", join(opts.targetDir, "ui"), vars); + // i18n strings live at the plugin root (`/locales/.json`), not + // under `ui/` — that's where the host loads them from. + copyTemplate("ui-extension-locales", opts.targetDir, vars); } if (opts.gitInit) { diff --git a/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl b/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl index 97129022..c98923a5 100644 --- a/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl +++ b/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl @@ -5,9 +5,6 @@ on: tags: - "v*" -permissions: - contents: write - jobs: build: name: ${{ matrix.platform-label }} @@ -44,7 +41,7 @@ jobs: binary-suffix: ".exe" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -63,7 +60,7 @@ jobs: - name: Setup Node if: steps.ui.outputs.present == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: "20" @@ -93,7 +90,10 @@ jobs: STAGE=staging mkdir -p "$STAGE" cp target/${{ matrix.target }}/release/${BIN_NAME}${{ matrix.binary-suffix }} "$STAGE/" - cp manifest.json "$STAGE/" + cp .tabularium "$STAGE/" + if [ -d assets ]; then + cp -r assets "$STAGE/" + fi if [ -f ui/dist/index.js ]; then mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" @@ -107,14 +107,44 @@ jobs: $stage = "staging" New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item "target\${{ matrix.target }}\release\${BIN_NAME}${{ matrix.binary-suffix }}" $stage - Copy-Item "manifest.json" $stage + Copy-Item ".tabularium" $stage + if (Test-Path "assets") { + Copy-Item -Recurse "assets" "$stage\assets" + } if (Test-Path "ui\dist\index.js") { New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } Compress-Archive -Path "$stage\*" -DestinationPath "${BIN_NAME}-${{ matrix.platform-label }}.zip" - - name: Upload release asset + - name: Stash artifact + uses: actions/upload-artifact@v5 + with: + name: ${BIN_NAME}-${{ matrix.platform-label }} + path: ${BIN_NAME}-${{ matrix.platform-label }}.zip + retention-days: 1 + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout (for the .tabularium manifest asset) + uses: actions/checkout@v5 + + - name: Download all build artifacts + uses: actions/download-artifact@v5 + with: + path: artifacts + merge-multiple: true + + - name: Publish release uses: softprops/action-gh-release@v2 with: - files: ${BIN_NAME}-${{ matrix.platform-label }}.zip + # .tabularium ships as a standalone asset: the registry resolves the + # manifest from release assets, not the git ref. + files: | + artifacts/*.zip + .tabularium diff --git a/packages/create-plugin/templates/rust-driver/manifest.json.tmpl b/packages/create-plugin/templates/rust-driver/.tabularium.tmpl similarity index 100% rename from packages/create-plugin/templates/rust-driver/manifest.json.tmpl rename to packages/create-plugin/templates/rust-driver/.tabularium.tmpl diff --git a/packages/create-plugin/templates/rust-driver/justfile.tmpl b/packages/create-plugin/templates/rust-driver/justfile.tmpl index e3c3f10f..7a1ddde3 100644 --- a/packages/create-plugin/templates/rust-driver/justfile.tmpl +++ b/packages/create-plugin/templates/rust-driver/justfile.tmpl @@ -61,7 +61,7 @@ build-ui: dev-install: build mkdir -p ~/.local/share/tabularis/plugins/${ID} cp target/debug/${BIN_NAME} ~/.local/share/tabularis/plugins/${ID}/ - cp manifest.json ~/.local/share/tabularis/plugins/${ID}/ + cp .tabularium ~/.local/share/tabularis/plugins/${ID}/ @if [ -f ui/dist/index.js ]; then \ mkdir -p ~/.local/share/tabularis/plugins/${ID}/ui/dist; \ cp ui/dist/index.js ~/.local/share/tabularis/plugins/${ID}/ui/dist/; \ @@ -73,7 +73,7 @@ dev-install: build dev-install: build mkdir -p "$HOME/Library/Application Support/tabularis/plugins/${ID}" cp target/debug/${BIN_NAME} "$HOME/Library/Application Support/tabularis/plugins/${ID}/" - cp manifest.json "$HOME/Library/Application Support/tabularis/plugins/${ID}/" + cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/${ID}/" @if [ -f ui/dist/index.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/${ID}/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/${ID}/ui/dist/"; \ @@ -86,7 +86,7 @@ dev-install: build $dest = Join-Path $env:APPDATA "tabularis\plugins\${ID}" New-Item -ItemType Directory -Force -Path $dest | Out-Null Copy-Item "target\debug\${BIN_NAME}.exe" $dest - Copy-Item "manifest.json" $dest + Copy-Item ".tabularium" $dest if (Test-Path "ui\dist\index.js") { New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist") diff --git a/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs b/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs index 5ced64ad..029d4742 100644 --- a/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs +++ b/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs @@ -3,7 +3,7 @@ //! Implement these to enable inline row editing in the Tabularis data grid. //! Not implementing them means the grid is read-only for this driver, //! which is a valid stance — set `capabilities.readonly: true` in -//! manifest.json to hide the edit UI altogether. +//! the `.tabularium` manifest to hide the edit UI altogether. use serde_json::Value; diff --git a/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs b/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs index fdadb23f..6534b81d 100644 --- a/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs +++ b/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs @@ -14,7 +14,7 @@ pub fn get_databases(id: Value, _params: &Value) -> Value { } pub fn get_schemas(id: Value, _params: &Value) -> Value { - // Only meaningful if `capabilities.schemas` is true in manifest.json. + // Only meaningful if `capabilities.schemas` is true in the `.tabularium` manifest. ok_response(id, json!([])) } diff --git a/packages/create-plugin/templates/ui-extension-locales/locales/en.json.tmpl b/packages/create-plugin/templates/ui-extension-locales/locales/en.json.tmpl new file mode 100644 index 00000000..8eb10e58 --- /dev/null +++ b/packages/create-plugin/templates/ui-extension-locales/locales/en.json.tmpl @@ -0,0 +1,4 @@ +{ + "toolbar.label": "${DISPLAY_NAME}", + "toolbar.greeting": "Hello from ${DISPLAY_NAME}! Active table: {table}" +} diff --git a/packages/create-plugin/templates/ui-extension/src/index.tsx.tmpl b/packages/create-plugin/templates/ui-extension/src/index.tsx.tmpl index c8b73715..0e69d88a 100644 --- a/packages/create-plugin/templates/ui-extension/src/index.tsx.tmpl +++ b/packages/create-plugin/templates/ui-extension/src/index.tsx.tmpl @@ -7,15 +7,19 @@ // See the full slot list at: // https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md#available-slots -import { defineSlot, usePluginToast } from "@tabularis/plugin-api"; +import { + defineSlot, + usePluginToast, + usePluginTranslation, +} from "@tabularis/plugin-api"; const Toolbar = defineSlot("data-grid.toolbar.actions", ({ context }) => { const { showInfo } = usePluginToast(); + // Strings come from `locales/.json` at the plugin root. + const t = usePluginTranslation("${ID}"); const handleClick = () => { - showInfo( - "Hello from ${DISPLAY_NAME}! Active table: " + (context.tableName ?? "(none)"), - ); + showInfo(t("toolbar.greeting", { table: context.tableName ?? "(none)" })); }; return ( @@ -32,7 +36,7 @@ const Toolbar = defineSlot("data-grid.toolbar.actions", ({ context }) => { cursor: "pointer", }} > - ${DISPLAY_NAME} + {t("toolbar.label")} ); }); diff --git a/packages/create-plugin/tests/migrate.test.ts b/packages/create-plugin/tests/migrate.test.ts new file mode 100644 index 00000000..926fe752 --- /dev/null +++ b/packages/create-plugin/tests/migrate.test.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { migratePlugin } from "../src/migrate"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ctp-migrate-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function writeManifest(manifest: Record): void { + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8"); +} + +describe("migratePlugin", () => { + it("writes .tabularium, removes manifest.json, and reports both", () => { + writeManifest({ id: "my-driver", name: "My Driver", version: "1.2.3", description: "x" }); + + const result = migratePlugin(dir); + + expect(existsSync(join(dir, ".tabularium"))).toBe(true); + expect(existsSync(join(dir, "manifest.json"))).toBe(false); + expect(result.changed).toContain(".tabularium (written)"); + expect(result.changed).toContain("manifest.json (removed)"); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.version).toBe("1.2.3"); + }); + + it("keeps a load-bearing id that differs from name (with a warning)", () => { + writeManifest({ id: "my-driver", name: "My Driver", version: "1.0.0", description: "x" }); + + const result = migratePlugin(dir); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.id).toBe("my-driver"); + expect(result.warnings.join("\n")).toMatch(/Kept "id"/); + }); + + it("drops a redundant id that equals name", () => { + writeManifest({ id: "my-driver", name: "my-driver", version: "1.0.0", description: "x" }); + + migratePlugin(dir); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.id).toBeUndefined(); + expect(written.name).toBe("my-driver"); + }); + + it("rewrites manifest.json references in workflow, justfile, and README", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + writeFileSync(join(dir, "justfile"), "cp manifest.json ~/plugins/\n", "utf8"); + // A schema link must NOT be rewritten — "manifest.json" is not a substring of it. + writeFileSync(join(dir, "README.md"), "See manifest.json and manifest.schema.json\n", "utf8"); + + migratePlugin(dir); + + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + expect(readFileSync(join(dir, "justfile"), "utf8")).toBe("cp .tabularium ~/plugins/\n"); + expect(readFileSync(join(dir, "README.md"), "utf8")).toBe("See .tabularium and manifest.schema.json\n"); + }); + + it("throws when version is missing or not semver", () => { + writeManifest({ name: "d", description: "x" }); + expect(() => migratePlugin(dir)).toThrow(/version/); + + writeManifest({ name: "d", version: "v1.0", description: "x" }); + expect(() => migratePlugin(dir)).toThrow(/version/); + }); + + it("throws on invalid JSON", () => { + writeFileSync(join(dir, "manifest.json"), "{ not json", "utf8"); + expect(() => migratePlugin(dir)).toThrow(/valid JSON/); + }); + + it("is a no-op when only .tabularium is present", () => { + writeFileSync(join(dir, ".tabularium"), "{}\n", "utf8"); + const result = migratePlugin(dir); + expect(result.changed).toHaveLength(0); + expect(result.warnings[0]).toMatch(/already present/); + }); + + it("throws when there is no manifest at all", () => { + expect(() => migratePlugin(dir)).toThrow(/No manifest.json/); + }); + + it("only renames the release.yml reference without --ci (CI structure untouched)", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x", executable: "d-plugin" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir); + + expect(result.ciRegenerated).toBe(false); + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + }); + + it("regenerates release.yml from the registry-ready template with --ci", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x", executable: "duckdb-plugin" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir, { ci: true }); + + expect(result.ciRegenerated).toBe(true); + const yml = readFileSync(join(dir, ".github/workflows/release.yml"), "utf8"); + // Registry-ready structure + standalone manifest asset. + expect(yml).toContain("actions/upload-artifact"); + expect(yml).toContain("Publish GitHub release"); + expect(yml).toMatch(/files: \|[\s\S]*\.tabularium/); + // BIN_NAME substituted from manifest.executable; no template placeholders left. + expect(yml).toContain("duckdb-plugin-${{ matrix.platform-label }}.zip"); + expect(yml).not.toContain("${BIN_NAME}"); + expect(yml).not.toContain(".tmpl"); + // Overwriting an existing workflow warns. + expect(result.warnings.join("\n")).toMatch(/Overwrote .*release\.yml/); + }); + + it("falls back to a reference rename when --ci has no executable to work with", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir, { ci: true }); + + expect(result.ciRegenerated).toBe(false); + expect(result.warnings.join("\n")).toMatch(/--ci skipped/); + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + }); +}); diff --git a/packages/plugin-api/src/hooks.ts b/packages/plugin-api/src/hooks.ts index 621b1d86..e6da0fa6 100644 --- a/packages/plugin-api/src/hooks.ts +++ b/packages/plugin-api/src/hooks.ts @@ -34,7 +34,7 @@ export function usePluginToast(): UsePluginToastReturn { /** * Read and write settings owned by a specific plugin. - * Pass your plugin id (the one declared in manifest.json). + * Pass your plugin id (the one declared in the `.tabularium` manifest). */ export function usePluginSetting(pluginId: string): UsePluginSettingReturn { return getHost().usePluginSetting(pluginId); diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index 7872da07..baf11117 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -113,7 +113,7 @@ export interface SlotComponentProps { } /** - * Manifest-level UI extension declaration (what authors put in manifest.json). + * Manifest-level UI extension declaration (what authors put in the `.tabularium` manifest). */ export interface UIExtensionDeclaration { slot: SlotName; diff --git a/plugins/tabularium-extensions.schema.json b/plugins/tabularium-extensions.schema.json new file mode 100644 index 00000000..8b6a9069 --- /dev/null +++ b/plugins/tabularium-extensions.schema.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tabularis.dev/schemas/tabularium-extensions.json", + "title": "Tabularis extensions for the Tabularium manifest", + "description": "JSON-Schema fragment that registers Tabularis-specific fields on top of Tabularium's core manifest. Paste this into your Tabularium admin panel under /admin/manifest as the global extension. The registry merges it with the core schema and serves the result at /manifest.schema.json, so plugin authors get autocomplete for the whole manifest from a single URL.\n\nNaming rules enforced by Tabularium:\n * field names match /^[A-Za-z_][A-Za-z0-9_-]*$/\n * cannot shadow a core field (name, description, kind, tags, license, icon, screenshots, readme, readmes, documentation_url, homepage, support, min_runtime_version, category)\n * type whitelist: string | number | integer | boolean | array | object\n * no $ref, max depth 6, max 32 properties per object\n\nFor the runtime manifest.json that ships inside the plugin zip, see plugins/manifest.schema.json.", + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "minLength": 1, + "maxLength": 64, + "description": "Stable driver identifier used as `ConnectionParams.driver` and as the on-disk plugin folder name (e.g. \"duckdb\", \"firestore\"). Must match the registry slug." + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$", + "minLength": 1, + "maxLength": 40, + "description": "Semver of this driver release. Should match the release tag the registry indexes (`v1.4.0` → `1.4.0`)." + }, + "executable": { + "type": "string", + "maxLength": 200, + "description": "Relative path to the plugin executable inside the ZIP (without `.exe`; Tabularis appends it on Windows)." + }, + "interpreter": { + "type": "string", + "maxLength": 100, + "description": "Interpreter for script-based plugins (e.g. `python3`, `node`). Omit for native binaries." + }, + "default_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port pre-filled in the connection modal. Omit for file/folder-based drivers." + }, + "default_username": { + "type": "string", + "maxLength": 100, + "description": "Username pre-filled in the connection modal (e.g. `postgres`, `root`)." + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$", + "description": "Hex accent colour shown in the connection picker and sidebar (e.g. `#f97316`)." + }, + "lucide_icon": { + "type": "string", + "maxLength": 60, + "description": "Lucide icon name used inline beside the driver entry (e.g. `database`, `network`). Distinct from the core `icon` field, which is the catalogue logo URL." + }, + "capabilities": { + "type": "object", + "description": "Feature flags that decide which UI sections Tabularis shows for this driver. All flags are optional; absent flags default to `false` except `connection_string`, `alter_primary_key`, and `manage_tables` (default `true`).", + "properties": { + "schemas": { + "type": "boolean", + "description": "Database supports multiple named schemas (e.g. PostgreSQL `public`, MySQL databases). Shows the schema selector." + }, + "views": { + "type": "boolean", + "description": "Enable the Views section in the database explorer." + }, + "routines": { + "type": "boolean", + "description": "Stored procedures and functions surface in the explorer." + }, + "triggers": { + "type": "boolean", + "description": "Enable trigger listing and management." + }, + "file_based": { + "type": "boolean", + "description": "Connection points at a single file (e.g. SQLite). Replaces host/port with a file picker." + }, + "folder_based": { + "type": "boolean", + "description": "Connection points at a directory (e.g. CSV folder). Replaces host/port with a folder picker." + }, + "connection_string": { + "type": "boolean", + "description": "Show the connection-string import field on the connection modal. Default `true` for network drivers." + }, + "connection_string_example": { + "type": "string", + "maxLength": 200, + "description": "Placeholder shown inside the connection-string input (e.g. `postgres://user:pass@host:5432/db`)." + }, + "identifier_quote": { + "type": "string", + "maxLength": 2, + "description": "Character used to quote SQL identifiers — `\"` for ANSI, `` ` `` for MySQL-style." + }, + "alter_primary_key": { + "type": "boolean", + "description": "Driver supports altering primary keys on existing tables via ALTER TABLE." + }, + "alter_column": { + "type": "boolean", + "description": "Driver supports ALTER TABLE MODIFY/ALTER COLUMN on existing tables." + }, + "create_foreign_keys": { + "type": "boolean", + "description": "Driver enforces foreign key constraints." + }, + "manage_tables": { + "type": "boolean", + "description": "Driver supports CREATE/ALTER/DROP TABLE. Doesn't control index or FK operations. Default `true`." + }, + "auto_increment_keyword": { + "type": "string", + "maxLength": 40, + "description": "Keyword appended to the column definition for auto-increment columns (e.g. `AUTO_INCREMENT` for MySQL). Empty means the driver doesn't use a keyword." + }, + "serial_type": { + "type": "string", + "maxLength": 40, + "description": "Replacement data type for auto-increment columns (e.g. `SERIAL` for PostgreSQL). Empty means the driver doesn't use type substitution." + }, + "inline_pk": { + "type": "boolean", + "description": "Primary key is declared inline in the column definition (e.g. SQLite `AUTOINCREMENT`)." + }, + "no_connection_required": { + "type": "boolean", + "description": "API-based driver with no host/port/credentials. Hides the connection form entirely." + }, + "readonly": { + "type": "boolean", + "description": "Disables INSERT/UPDATE/DELETE in the UI. Also hides table/column management." + } + } + }, + "settings": { + "type": "array", + "maxItems": 32, + "description": "User-configurable settings rendered on Settings → Plugin → .", + "items": { + "type": "object", + "required": ["key", "label", "type"], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "maxLength": 60, + "description": "Setting identifier; passed to the plugin process as `settings[key]`." + }, + "label": { + "type": "string", + "maxLength": 100, + "description": "Label shown next to the input in the settings UI." + }, + "type": { + "type": "string", + "maxLength": 20, + "description": "Input type. Tabularis renders `string`, `boolean`, `number`, `select`, `password`, `path` (and `folder` for folder pickers)." + }, + "default": { + "description": "Default value, any JSON type matching `type`." + }, + "description": { + "type": "string", + "maxLength": 280, + "description": "Helper text shown below the input." + }, + "required": { + "type": "boolean", + "description": "Block driver startup when the setting is empty." + }, + "options": { + "type": "array", + "maxItems": 32, + "description": "Enum values for `type: select`.", + "items": { + "type": "string", + "maxLength": 80 + } + } + } + } + }, + "data_types": { + "type": "array", + "maxItems": 32, + "description": "SQL data types this driver advertises in the column-creation UI.", + "items": { + "type": "object", + "required": ["name", "category", "requires_length", "requires_precision"], + "properties": { + "name": { + "type": "string", + "maxLength": 60, + "description": "Type name as it appears in DDL (e.g. `VARCHAR`, `BIGINT`)." + }, + "category": { + "type": "string", + "maxLength": 20, + "description": "UI grouping: `numeric`, `string`, `date`, `binary`, `json`, `spatial`, or `other`." + }, + "requires_length": { + "type": "boolean", + "description": "Type takes a length argument (e.g. `VARCHAR(255)`)." + }, + "requires_precision": { + "type": "boolean", + "description": "Type takes a precision/scale argument (e.g. `DECIMAL(10,2)`)." + }, + "default_length": { + "type": "string", + "maxLength": 20, + "description": "Optional default length value (e.g. `255` for `VARCHAR`)." + } + } + } + }, + "ui_extensions": { + "type": "array", + "maxItems": 32, + "description": "Slot-based UI injections. See plugins/PLUGIN_GUIDE.md §3b for available slot names.", + "items": { + "type": "object", + "required": ["slot", "module"], + "properties": { + "slot": { + "type": "string", + "maxLength": 80, + "description": "Target slot identifier (e.g. `row-edit-modal.field.after`)." + }, + "module": { + "type": "string", + "maxLength": 200, + "description": "Path to the JS module inside the plugin folder (e.g. `dist/index.js`)." + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "Ordering weight when multiple plugins target the same slot. Lower runs first." + } + } + } + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5a72f87..0d4e08a7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -904,6 +904,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1459,6 +1479,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1845,6 +1874,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2427,6 +2462,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2435,7 +2476,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2443,6 +2484,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2603,6 +2649,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2642,7 +2689,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3876,6 +3923,17 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.75" @@ -3936,6 +3994,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4575,6 +4643,72 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "progenitor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced2eadb9776a201d0585b4b072fd44d7d2104e0f3452d967b5a78966f4855cf" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296003fd74e64c77aeb2c10eae850eb543211a8557dd3b3de6f4230b5071e44b" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17e5363daa50bf1cccfade6b0fb970d2278758fd5cfa9ab69f25028e4b1afa3" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap 2.13.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4972aec926d1e06d6abc11ab3f063d2f7063be3dd46fd2839442c14d8e48f3ed" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.117", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4924,6 +5058,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "rend" version = "0.4.2" @@ -4933,6 +5077,47 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -4972,7 +5157,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -5157,6 +5342,16 @@ dependencies = [ "yasna", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -5324,6 +5519,7 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ + "chrono", "dyn-clone", "indexmap 1.9.3", "schemars_derive", @@ -5573,6 +5769,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6188,7 +6396,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.13.1" +version = "0.13.2" dependencies = [ "async-trait", "base64 0.22.1", @@ -6207,37 +6415,65 @@ dependencies = [ "notify", "once_cell", "openssl", - "reqwest", + "reqwest 0.13.2", "russh", "russh-keys", "rust_decimal", "rustls", "rustls-pemfile", "rustls-platform-verifier", + "semver", "serde", "serde_json", "serde_yaml", "sha2", "sqlx", "sysinfo", + "tabularium-sdk", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", "tokio-postgres", "tokio-postgres-rustls", "ulid", + "url", "urlencoding", "uuid", "zip", ] +[[package]] +name = "tabularium-sdk" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295f2f940d4d9987af7161a68b4046df35a7876279d00b8612c7a56f0bbeb42" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-core", + "openapiv3", + "prettyplease", + "progenitor", + "progenitor-client", + "rand 0.8.5", + "regress", + "reqwest 0.12.28", + "serde", + "serde_json", + "syn 2.0.117", + "uuid", +] + [[package]] name = "tao" version = "0.34.5" @@ -6343,7 +6579,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -6459,6 +6695,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -6543,6 +6800,22 @@ dependencies = [ "zbus 5.14.0", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus 5.14.0", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" @@ -6559,7 +6832,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -6788,6 +7061,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -7161,6 +7443,53 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.117", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.1.0" @@ -7549,6 +7878,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -7717,6 +8059,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7882,20 +8233,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -7977,13 +8315,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.6.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings", ] [[package]] @@ -8004,15 +8342,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -8022,15 +8351,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6256ae38..49797bf8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,7 +55,12 @@ directories = "6.0.0" serde_yaml = "0.9.34" sysinfo = { version = "0.32", features = ["system"] } zip = "4.2.0" +tabularium-sdk = "0.2" +url = "2" +semver = "1" tauri-plugin-clipboard-manager = "2" +tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } deadpool-postgres = "0.14.1" # rustls is used for the PostgreSQL deadpool TLS path. native-tls (used by diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4deb0601..84ad5324 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "fs:scope-appdata-recursive", "opener:default", "clipboard-manager:allow-read-text", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-write-text", + "deep-link:default" ] } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d6d1f6c3..50f5735e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -42,7 +42,18 @@ pub struct AppConfig { pub copy_format: Option, pub csv_delimiter: Option, pub active_external_drivers: Option>, + /// COMPAT(registry-ga): legacy config key from before the Tabularium API + /// cutover. Read once by `compat::migrate_legacy_config`, then cleared. + #[serde(default)] pub custom_registry_url: Option, + /// COMPAT(registry-ga): override URL for the legacy static `registry.json` + /// merged into the catalogue during migration. Defaults to the built-in + /// GitHub-hosted file when unset. + #[serde(default)] + pub legacy_registry_url: Option, + /// Base URL of the Tabularium plugin registry (https://tabularium.wiki). + /// Defaults to the built-in instance when unset. + pub tabularium_registry_url: Option, pub plugins: Option>, pub editor_theme: Option, pub editor_font_family: Option, @@ -164,7 +175,8 @@ pub fn load_config_internal(app: &AppHandle) -> AppConfig let config_path = config_dir.join("config.json"); if config_path.exists() { if let Ok(content) = fs::read_to_string(config_path) { - if let Ok(config) = serde_json::from_str(&content) { + if let Ok(mut config) = serde_json::from_str::(&content) { + crate::plugins::compat::migrate_legacy_config(&mut config); // COMPAT(registry-ga) cache_config(&config); return config; } @@ -259,6 +271,9 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.active_external_drivers.is_some() { existing_config.active_external_drivers = config.active_external_drivers; } + if config.tabularium_registry_url.is_some() { + existing_config.tabularium_registry_url = config.tabularium_registry_url; + } if config.plugins.is_some() { existing_config.plugins = config.plugins; } diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index f92245a3..568e2691 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -63,6 +63,12 @@ pub struct DriverCapabilities { /// Folder-based database (e.g. CSV directory); connection points to a directory instead of a file. #[serde(default)] pub folder_based: bool, + /// The driver exposes a single implicit database, so there is nothing to + /// select or name (e.g. a flat search/document store like Meilisearch). + /// Skips the database tab and the database-name field in the connection + /// modal. Network drivers only. + #[serde(default)] + pub single_database: bool, /// Enables connection string import input in the connection modal. /// Defaults to `true` for backward compatibility. #[serde(default = "default_true", alias = "connectionString")] @@ -182,6 +188,16 @@ pub struct PluginManifest { /// built-in entries without relying on a hardcoded ID list. #[serde(default)] pub is_builtin: bool, + /// Concrete database engine this driver targets (registry manifest + /// `engine`, e.g. `"meilisearch"`). `None` for built-ins (the frontend + /// supplies their engine/paradigms). Lets the connection catalogue place + /// locally-installed, not-yet-published plugins. + #[serde(default)] + pub engine: Option, + /// Data-model families, primary first (registry manifest `paradigms`, + /// e.g. `["search", "document"]`). Empty for built-ins. + #[serde(default)] + pub paradigms: Vec, /// Default username pre-filled in the connection modal (e.g. `"postgres"`, /// `"root"`). Empty string for drivers that have no default. #[serde(default)] diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index a01e5591..b39a3257 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1207,6 +1207,7 @@ impl MysqlDriver { default_port: Some(3306), capabilities: DriverCapabilities { schemas: false, + single_database: false, views: true, routines: true, file_based: false, @@ -1228,6 +1229,8 @@ impl MysqlDriver { sql_dialect: SqlDialect::Mysql, }, is_builtin: true, + engine: Some("mysql".to_string()), + paradigms: vec!["sql".to_string()], default_username: "root".to_string(), color: "#f97316".to_string(), icon: "mysql".to_string(), diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 50ba7480..8444f0b8 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1352,6 +1352,7 @@ impl PostgresDriver { default_port: Some(5432), capabilities: DriverCapabilities { schemas: true, + single_database: false, views: true, routines: true, file_based: false, @@ -1373,6 +1374,8 @@ impl PostgresDriver { sql_dialect: SqlDialect::Postgres, }, is_builtin: true, + engine: Some("postgres".to_string()), + paradigms: vec!["sql".to_string()], default_username: "postgres".to_string(), color: "#3b82f6".to_string(), icon: "postgres".to_string(), diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 2d0d405c..48de5b6c 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -877,6 +877,7 @@ impl SqliteDriver { default_port: None, capabilities: DriverCapabilities { schemas: false, + single_database: false, views: true, routines: false, file_based: true, @@ -898,6 +899,8 @@ impl SqliteDriver { sql_dialect: SqlDialect::Sqlite, }, is_builtin: true, + engine: Some("sqlite".to_string()), + paradigms: vec!["sql".to_string()], default_username: String::new(), color: "#06b6d4".to_string(), icon: "sqlite".to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e42c1003..92077570 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,11 +160,29 @@ pub fn run() { sqlx::any::install_default_drivers(); tauri::Builder::default() + // Singleton: a second launch (typically a `tabularis://...` URL + // clicked while the app is already running) hands its argv to the + // first instance and exits. With `features = ["deep-link"]`, the + // plugin auto-routes those URLs through `on_open_url` — no manual + // argv parsing needed on our side; the empty callback exists only + // to satisfy the plugin signature. + // + // Order matters: must be the FIRST plugin in the chain so it can + // intercept duplicate launches before any heavy initialisation. + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + log::info!("Duplicate launch detected — forwarded to existing instance"); + if let Some(win) = tauri::Manager::get_webview_window(app, "main") { + let _ = win.unminimize(); + let _ = win.set_focus(); + } + })) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) + .manage(crate::plugins::deep_link::PendingInstall::default()) .manage(commands::QueryCancellationState::default()) .manage(export::ExportCancellationState::default()) .manage(dump_commands::DumpCancellationState::default()) @@ -207,6 +225,45 @@ pub fn run() { }); } + // Subscribe to `tabularis://` deep links so a registry's + // "Open in App" button can hand us a plugin slug + version. + // The handler emits a frontend event; the React side opens the + // install confirmation modal. See `plugins::deep_link`. + // + // On Linux/Windows the scheme is only auto-registered when the + // app is installed from a bundled package. Under `tauri dev` + // the binary lives in `target/debug/...`, so call + // `register("tabularis")` here — it writes the desktop / xdg-mime + // entry pointing at the current binary so Firefox & friends can + // route `tabularis://...` to us. The call is a no-op on macOS + // (handled by Info.plist) and idempotent across restarts. + { + use tauri_plugin_deep_link::DeepLinkExt; + let handle = app.handle().clone(); + let deep_link = app.deep_link(); + #[cfg(any(target_os = "linux", all(debug_assertions, target_os = "windows")))] + if let Err(e) = deep_link.register("tabularis") { + log::warn!("Failed to register tabularis:// scheme: {}", e); + } + deep_link.on_open_url({ + let handle = handle.clone(); + move |event| { + for url in event.urls() { + crate::plugins::deep_link::handle_url(&handle, url.as_str()); + } + } + }); + // Cold-start path: when the OS launched us *because of* a + // tabularis:// URL, on_open_url won't fire — the URL is + // already consumed by the launch handshake. Pull it out via + // get_current() and route it through the same handler. + if let Ok(Some(urls)) = deep_link.get_current() { + for url in urls { + crate::plugins::deep_link::handle_url(&handle, url.as_str()); + } + } + } + // Watch for pending MCP approval requests and run periodic cleanup. ai_approval_watcher::spawn(app.handle().clone()); @@ -450,6 +507,8 @@ pub fn run() { plugins::commands::get_plugin_manifest, plugins::commands::get_plugin_dir, plugins::commands::read_plugin_file, + plugins::commands::fetch_tabularium_plugin_preview, + plugins::deep_link::consume_pending_deep_link_install, plugins::manager::get_plugin_startup_errors, // JSON Viewer json_viewer::open_json_viewer_window, diff --git a/src-tauri/src/plugins/commands.rs b/src-tauri/src/plugins/commands.rs index f0bae479..8b2b80a2 100644 --- a/src-tauri/src/plugins/commands.rs +++ b/src-tauri/src/plugins/commands.rs @@ -4,67 +4,152 @@ use std::time::Duration; use crate::drivers::driver_trait::PluginManifest; use crate::plugins::installer::{self, InstalledPluginInfo}; use crate::plugins::manager::ConfigManifest; -use crate::plugins::registry::{self, RegistryPluginWithStatus, RegistryReleaseWithStatus}; +use crate::plugins::registry::{self, RegistryPlugin, RegistryPluginWithStatus, RegistryReleaseWithStatus}; use tauri::AppHandle; use tokio::time::sleep; +/// Resolves which Tabularium registry to talk to. Operators pin a +/// URL via `tabularium_registry_url` in `config.json`; otherwise the +/// built-in default applies. +fn registry_base_url(config: &crate::config::AppConfig) -> &str { + config + .tabularium_registry_url + .as_deref() + .unwrap_or(registry::DEFAULT_TABULARIUM_URL) +} + #[tauri::command] pub async fn fetch_plugin_registry( app: AppHandle, ) -> Result, String> { let config = crate::config::load_config_internal(&app); - let remote = registry::fetch_registry(config.custom_registry_url.as_deref()).await?; + let base_url = registry_base_url(&config).trim_end_matches('/').to_string(); + // COMPAT(registry-ga): merge the API with the legacy static registry.json so + // not-yet-migrated plugins stay visible during the transition. + let legacy_url = crate::plugins::compat::legacy_registry_url(&config); + let remote = crate::plugins::compat::resolve_registry(&base_url, &legacy_url).await?; let installed = installer::list_installed()?; let platform = registry::get_current_platform(); - let result = remote + let result: Vec = remote .plugins .into_iter() - .map(|plugin| { + .map(|mut plugin| { let installed_version = installed .iter() .find(|i| i.id == plugin.id) .map(|i| i.version.clone()); + // Only a real Tabularium API serves plugin detail pages. A legacy + // `.json` base would make the frontend build a broken + // `…/registry.json/plugins/` link, so leave it unset there. + if !base_url.ends_with(".json") { + plugin.registry_base_url = Some(base_url.clone()); + } + to_plugin_with_status(plugin, installed_version, &platform) + }) + .collect(); - let update_available = installed_version - .as_ref() - .map(|iv| iv != &plugin.latest_version) - .unwrap_or(false); - - let releases: Vec = plugin - .releases - .iter() - .map(|r| { - let platform_supported = - r.assets.contains_key(&platform) || r.assets.contains_key("universal"); - RegistryReleaseWithStatus { - version: r.version.clone(), - min_tabularis_version: r.min_tabularis_version.clone(), - platform_supported, - } - }) - .collect(); + Ok(result) +} - let platform_supported = releases - .iter() - .any(|r| r.version == plugin.latest_version && r.platform_supported); - - RegistryPluginWithStatus { - id: plugin.id, - name: plugin.name, - description: plugin.description, - author: plugin.author, - homepage: plugin.homepage, - latest_version: plugin.latest_version, - releases, - installed_version, - update_available, +/// Builds a `RegistryPluginWithStatus` from a registry plugin + the installed +/// version (if any), computing per-release platform support and a SemVer-aware +/// `update_available`. `install_action` is left `None` here; the deeplink +/// preview path sets it explicitly. +fn to_plugin_with_status( + plugin: RegistryPlugin, + installed_version: Option, + platform: &str, +) -> RegistryPluginWithStatus { + let releases: Vec = plugin + .releases + .iter() + .map(|r| { + let platform_supported = + r.assets.contains_key(platform) || r.assets.contains_key("universal"); + RegistryReleaseWithStatus { + version: r.version.clone(), + min_tabularis_version: r.min_tabularis_version.clone(), platform_supported, } }) .collect(); - Ok(result) + let platform_supported = releases + .iter() + .any(|r| r.version == plugin.latest_version && r.platform_supported); + + // SemVer-aware: "update" classification doubles as `update_available`. + let action = registry::classify_install(installed_version.as_deref(), &plugin.latest_version); + let update_available = matches!(action, registry::InstallAction::Update); + + RegistryPluginWithStatus { + id: plugin.id, + name: plugin.name, + description: plugin.description, + author: plugin.author, + homepage: plugin.homepage, + latest_version: plugin.latest_version, + releases, + installed_version, + update_available, + platform_supported, + icon: plugin.icon, + repo_url: plugin.repo_url, + kind: plugin.kind, + tags: plugin.tags, + category: plugin.category, + downloads: plugin.downloads, + registry_base_url: plugin.registry_base_url, + engine: plugin.engine, + paradigms: plugin.paradigms, + verified: plugin.verified, + install_action: None, + } +} + +/// API-path install resolution: resolves the concrete target version, confirms +/// the platform is supported (+ sha256), and returns the registry's TRACKED +/// download URL. Returns `(download_url, expected_sha256, target_version)`. +async fn resolve_api_install_asset( + base: &str, + plugin_id: &str, + version: Option<&str>, + platform: &str, +) -> Result<(String, Option, String), String> { + let target_version = match version { + Some(v) => v.to_string(), + None => { + let detail = crate::plugins::tabularium::fetch_plugin_detail(base, plugin_id).await?; + if !detail.latest_version.is_empty() { + detail.latest_version + } else { + detail + .releases + .first() + .map(|r| r.version.clone()) + .ok_or_else(|| { + format!("Plugin '{}' has no releases on the registry", plugin_id) + })? + } + } + }; + // Resolve the asset for its sha256 + to confirm the platform is supported, + // but download via the registry's TRACKED redirect so the download counter + // increments. Use the dedicated `/latest` endpoint when no version was + // pinned, otherwise the versioned `/releases/{version}` endpoint. + let asset = + registry::resolve_tabularium_asset(base, plugin_id, &target_version, platform).await?; + let download_url = match version { + Some(_) => crate::plugins::tabularium::tracked_download_url( + base, + plugin_id, + &target_version, + platform, + ), + None => crate::plugins::tabularium::tracked_latest_download_url(base, plugin_id, platform), + }; + Ok((download_url, asset.expected_sha256, target_version)) } #[tauri::command] @@ -80,36 +165,49 @@ pub async fn install_plugin( sleep(Duration::from_millis(500)).await; let config = crate::config::load_config_internal(&app); - let remote = registry::fetch_registry(config.custom_registry_url.as_deref()).await?; let platform = registry::get_current_platform(); + let base = registry_base_url(&config); - let plugin = remote - .plugins - .iter() - .find(|p| p.id == plugin_id) - .ok_or_else(|| format!("Plugin '{}' not found in registry", plugin_id))?; - - let target_version = version.as_deref().unwrap_or(&plugin.latest_version); - - let release = plugin - .releases - .iter() - .find(|r| r.version == target_version) - .ok_or_else(|| format!("No release found for version {}", target_version))?; - - let download_url = release - .assets - .get(&platform) - .or_else(|| release.assets.get("universal")) - .ok_or_else(|| { - format!( - "Plugin '{}' does not support platform '{}'", - plugin_id, platform - ) - })?; - - installer::download_and_install(&plugin_id, download_url).await?; + // Resolve the download URL + expected SHA-256 + concrete target version. + // Resolution order: + // 1. COMPAT(registry-ga): if the configured registry URL is a static + // `.json` file, resolve directly from it (direct download, no sha256). + // 2. Otherwise use the Tabularium API + the TRACKED redirect download. + // 3. COMPAT(registry-ga): if the API doesn't know the plugin (it lives + // only in the legacy registry, not yet migrated), fall back to the + // configured legacy `registry.json`'s direct asset. + let (download_url, expected_sha256, target_version) = + if let Some(res) = + crate::plugins::compat::resolve_static_asset(base, &plugin_id, version.as_deref(), &platform) + .await + { + let asset = res?; + (asset.download_url, asset.expected_sha256, asset.version) + } else { + match resolve_api_install_asset(base, &plugin_id, version.as_deref(), &platform).await { + Ok(resolved) => resolved, + Err(api_err) => { + // COMPAT(registry-ga): legacy-registry install fallback. + let legacy_url = crate::plugins::compat::legacy_registry_url(&config); + match crate::plugins::compat::fetch_static_asset( + &legacy_url, + &plugin_id, + version.as_deref(), + &platform, + ) + .await + { + Ok(asset) => (asset.download_url, asset.expected_sha256, asset.version), + Err(_) => return Err(api_err), + } + } + } + }; + installer::download_and_install(&plugin_id, &download_url, expected_sha256.as_deref()).await?; + // Verify the installed manifest matches what the registry advertised. The + // canonical schema uses `name` as the identity, so `installed_plugin.id` + // falls back to the manifest `name` when no legacy `id` is present. let installed_plugin = installer::read_installed_plugin(&plugin_id)?; if installed_plugin.id != plugin_id { return Err(format!( @@ -180,27 +278,26 @@ pub async fn enable_plugin(app: AppHandle, plugin_id: String) -> Result<(), Stri Ok(()) } -/// Reads a plugin's manifest.json from disk and returns a PluginManifest. +/// Reads a plugin's `.tabularium` manifest from disk and returns a PluginManifest. /// Useful for retrieving setting definitions for disabled plugins. #[tauri::command] pub async fn get_plugin_manifest(plugin_id: String) -> Result { let plugins_dir = installer::get_plugins_dir()?; - let manifest_path = plugins_dir.join(&plugin_id).join("manifest.json"); + let plugin_dir = plugins_dir.join(&plugin_id); - let manifest_str = fs::read_to_string(&manifest_path) + let config: ConfigManifest = installer::read_manifest(&plugin_dir) .map_err(|e| format!("Failed to read manifest for '{}': {}", plugin_id, e))?; - let config: ConfigManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse manifest for '{}': {}", plugin_id, e))?; - Ok(PluginManifest { - id: config.id, + id: config.id.unwrap_or_else(|| config.name.clone()), name: config.name, version: config.version, description: config.description, default_port: config.default_port, capabilities: config.capabilities, is_builtin: false, + engine: config.engine, + paradigms: config.paradigms, default_username: config.default_username.unwrap_or_default(), color: config.color, icon: config.icon, @@ -223,6 +320,53 @@ pub fn get_plugin_dir(plugin_id: String) -> Result { .map(|s| s.to_string()) } +/// Fetches a rich plugin preview from a Tabularium registry, used by the +/// `tabularis://` deep-link confirmation modal. When `registry_url` is +/// omitted the user's configured registry (or the built-in default) is +/// queried instead. Returns `RegistryPluginWithStatus` populated with the +/// installed version and a resolved `install_action` so the modal can show +/// Install / Update / "already installed". +/// +/// NB: the deeplink *preview* talks to the Tabularium API directly (no static +/// `registry.json` support). This is intentional — `tabularis://` links target +/// the new registry — so there is deliberately no `COMPAT(registry-ga)` marker +/// here. (`install_plugin` itself DOES support static registries, so the +/// catalogue install path stays fully backwards-compatible.) +#[tauri::command] +pub async fn fetch_tabularium_plugin_preview( + app: AppHandle, + slug: String, + registry_url: Option, + version: Option, +) -> Result { + let config = crate::config::load_config_internal(&app); + let base = registry_url + .as_deref() + .map(str::to_string) + .unwrap_or_else(|| registry_base_url(&config).to_string()); + let mut plugin = crate::plugins::tabularium::fetch_plugin_detail(&base, &slug).await?; + plugin.registry_base_url = Some(base.trim_end_matches('/').to_string()); + + let installed_version = installer::list_installed()? + .into_iter() + .find(|i| i.id == slug) + .map(|i| i.version); + let platform = registry::get_current_platform(); + + // Target = the version the deeplink will install: the pinned version if the + // link specified one, otherwise the registry's latest. + let target = version + .as_deref() + .filter(|v| !v.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.latest_version.clone()); + let action = registry::classify_install(installed_version.as_deref(), &target); + + let mut with_status = to_plugin_with_status(plugin, installed_version, &platform); + with_status.install_action = Some(action); + Ok(with_status) +} + /// Reads a file from an installed plugin's directory. /// The `file_path` must be a relative path with no `..` components. #[tauri::command] diff --git a/src-tauri/src/plugins/compat.rs b/src-tauri/src/plugins/compat.rs new file mode 100644 index 00000000..75162fca --- /dev/null +++ b/src-tauri/src/plugins/compat.rs @@ -0,0 +1,384 @@ +//! BACKWARDS-COMPAT LAYER — remove after the Tabularium registry GA once all +//! published plugins have migrated to the new manifest/registry format. +//! +//! Everything legacy lives here so removal is mechanical: +//! 1. Delete this file. +//! 2. `grep -rn "COMPAT(registry-ga)"` and revert each marked call site +//! (config.rs, commands.rs, installer.rs). +//! 3. Remove `pub mod compat;` from plugins/mod.rs. +//! +//! See docs/superpowers/specs/2026-06-06-deeplink-versioning-and-bc-layer-design.md +//! +//! Covers four legacy paths: +//! 1. Config key alias `custom_registry_url` -> `tabularium_registry_url` +//! 2. Static flat `registry.json` fetcher (pre-API registries) +//! 3. Legacy GitHub-raw default URL fallback +//! 4. Legacy `manifest.json` bundle manifest + +use std::path::Path; + +use crate::plugins::registry::{self, PluginRegistry}; + +/// Legacy default registry: the flat `registry.json` hosted on GitHub that +/// `main` shipped before the Tabularium API cutover. Used only as a +/// last-resort fallback (see `resolve_registry`). +pub const LEGACY_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/TabularisDB/tabularis/main/plugins/registry.json"; + +/// Fetches and parses a legacy flat-JSON registry from `url`. The +/// `PluginRegistry` struct still deserializes the old schema unchanged +/// (new fields are `#[serde(default)]`). +pub async fn fetch_legacy_registry(url: &str) -> Result { + let response = reqwest::get(url) + .await + .map_err(|e| format!("Failed to fetch legacy plugin registry: {}", e))?; + if !response.status().is_success() { + return Err(format!( + "Legacy registry at {} returned HTTP {}", + url, + response.status() + )); + } + response + .json::() + .await + .map_err(|e| format!("Failed to parse legacy plugin registry: {}", e)) +} + +/// COMPAT(registry-ga): registry-fetch entry point that MERGES the Tabularium +/// API with the legacy static `registry.json`, so plugins not yet migrated to +/// the API stay visible during the transition. +/// +/// Order: +/// 1. If `base_url` ends in `.json`, that file IS the source — return it +/// verbatim (no API, no merge). +/// 2. Otherwise fetch the API and the legacy registry, then UNION them with +/// `merge_registries` (API entry wins on id conflict). +/// 3. If only one side is reachable, use it (API down → legacy only; legacy +/// unreachable → API only). +/// 4. If both fail, surface the ORIGINAL API error. +pub async fn resolve_registry(base_url: &str, legacy_url: &str) -> Result { + // Explicit static registry — the configured file is the sole source. + if base_url.ends_with(".json") { + return fetch_legacy_registry(base_url).await; + } + + let api = registry::fetch_tabularium_registry(base_url).await; + let legacy = fetch_legacy_registry(legacy_url).await; + + match (api, legacy) { + (Ok(api), Ok(legacy)) => Ok(merge_registries(api, legacy)), + (Ok(api), Err(e)) => { + log::warn!("Legacy registry merge skipped ({}): {}", legacy_url, e); + Ok(api) + } + (Err(e), Ok(legacy)) => { + log::warn!( + "Tabularium API failed ({}): {} — using legacy registry only", + base_url, + e + ); + Ok(legacy) + } + (Err(api_err), Err(_)) => Err(api_err), + } +} + +/// Union two registries, preferring `api` entries on id conflict. Plugins that +/// exist only in `legacy` (not yet migrated to the API) are appended. +fn merge_registries(api: PluginRegistry, legacy: PluginRegistry) -> PluginRegistry { + use std::collections::HashSet; + let seen: HashSet<&str> = api.plugins.iter().map(|p| p.id.as_str()).collect(); + let extra: Vec<_> = legacy + .plugins + .into_iter() + .filter(|p| !seen.contains(p.id.as_str())) + .collect(); + let mut plugins = api.plugins; + plugins.extend(extra); + PluginRegistry { + schema_version: 1, + plugins, + } +} + +/// COMPAT(registry-ga): URL of the legacy static `registry.json` to merge with +/// the API. Configurable via `legacy_registry_url`; defaults to the built-in +/// GitHub-hosted file. +pub fn legacy_registry_url(config: &crate::config::AppConfig) -> String { + config + .legacy_registry_url + .clone() + .unwrap_or_else(|| LEGACY_REGISTRY_URL.to_string()) +} + +/// COMPAT(registry-ga): reads a legacy `manifest.json` bundle manifest for +/// plugins published before the `.tabularium` cutover. Same JSON shape — the +/// canonical structs tolerate the old flat fields via `#[serde(default)]`. +pub fn read_legacy_manifest(dir: &Path) -> Option> { + let path = dir.join("manifest.json"); + if !path.exists() { + return None; + } + let read = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read legacy manifest {:?}: {}", path, e)) + .and_then(|s| { + serde_json::from_str::(&s) + .map_err(|e| format!("Failed to parse legacy manifest {:?}: {}", path, e)) + }); + Some(read) +} + +/// Folds a legacy `custom_registry_url` into `tabularium_registry_url` when the +/// new key is unset, then clears the legacy field so it never round-trips back +/// to disk. The new key always wins if both are present. +pub fn migrate_legacy_config(config: &mut crate::config::AppConfig) { + if let Some(legacy) = config.custom_registry_url.take() { + if config.tabularium_registry_url.is_none() { + config.tabularium_registry_url = Some(legacy); + } + } +} + +/// COMPAT(registry-ga): a plugin asset resolved from a static flat `registry.json`. +/// Static registries carry no per-asset sha256 and have no tracked-download +/// endpoint, so `download_url` is the direct asset URL (matching the pre-API +/// install path) and `expected_sha256` is always `None`. +pub struct StaticAsset { + pub download_url: String, + pub expected_sha256: Option, + pub version: String, +} + +/// COMPAT(registry-ga): resolve a plugin's download asset from a static flat +/// `registry.json` (configured registry URL ending in `.json`). Returns `None` +/// when `base_url` is not a static registry, signalling the caller to use the +/// Tabularium API path instead. +pub async fn resolve_static_asset( + base_url: &str, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Option> { + if !base_url.ends_with(".json") { + return None; + } + Some(fetch_static_asset(base_url, slug, version, platform).await) +} + +/// COMPAT(registry-ga): fetch + resolve a plugin asset from a specific static +/// `registry.json` URL. Used as the install fallback for plugins that live only +/// in the legacy registry (not yet migrated to the API), regardless of the +/// configured registry's URL shape. +pub async fn fetch_static_asset( + url: &str, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Result { + let registry = fetch_legacy_registry(url).await?; + pick_static_asset(®istry, slug, version, platform) +} + +/// Pure asset picker over an already-fetched static registry — mirrors the +/// pre-API install resolution: find the plugin, pick the requested release +/// (or latest), then the platform asset or the `universal` fallback. +fn pick_static_asset( + registry: &PluginRegistry, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Result { + let plugin = registry + .plugins + .iter() + .find(|p| p.id == slug) + .ok_or_else(|| format!("Plugin '{}' not found in registry", slug))?; + let target_version = version + .map(str::to_string) + .unwrap_or_else(|| plugin.latest_version.clone()); + let release = plugin + .releases + .iter() + .find(|r| r.version == target_version) + .ok_or_else(|| format!("No release '{}' for plugin '{}'", target_version, slug))?; + let download_url = release + .assets + .get(platform) + .or_else(|| release.assets.get("universal")) + .cloned() + .ok_or_else(|| format!("Plugin '{}' does not support platform '{}'", slug, platform))?; + Ok(StaticAsset { + download_url, + expected_sha256: None, + version: target_version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use crate::plugins::registry::{PluginRegistry, PluginRelease, RegistryPlugin}; + use std::collections::HashMap; + + fn static_registry() -> PluginRegistry { + let mut assets = HashMap::new(); + assets.insert( + "linux-x64".to_string(), + "https://host/firestore-0.5.0-linux-x64.zip".to_string(), + ); + assets.insert( + "universal".to_string(), + "https://host/firestore-0.5.0-universal.zip".to_string(), + ); + PluginRegistry { + schema_version: 1, + plugins: vec![RegistryPlugin { + id: "firestore".to_string(), + latest_version: "0.5.0".to_string(), + releases: vec![PluginRelease { + version: "0.5.0".to_string(), + min_tabularis_version: None, + assets, + }], + ..Default::default() + }], + } + } + + #[test] + fn static_asset_picks_platform_then_latest() { + let reg = static_registry(); + let a = pick_static_asset(®, "firestore", None, "linux-x64").expect("resolves"); + assert_eq!(a.download_url, "https://host/firestore-0.5.0-linux-x64.zip"); + assert_eq!(a.version, "0.5.0"); // None → latest + assert!(a.expected_sha256.is_none()); // static registries carry no sha + } + + #[test] + fn static_asset_falls_back_to_universal() { + let reg = static_registry(); + let a = pick_static_asset(®, "firestore", Some("0.5.0"), "win-x64").expect("universal"); + assert_eq!(a.download_url, "https://host/firestore-0.5.0-universal.zip"); + } + + #[test] + fn static_asset_errors_on_unknown_plugin_release_or_platform() { + let reg = static_registry(); + assert!(pick_static_asset(®, "nope", None, "linux-x64").is_err()); + assert!(pick_static_asset(®, "firestore", Some("9.9.9"), "linux-x64").is_err()); + let mut reg2 = static_registry(); + reg2.plugins[0].releases[0].assets.remove("universal"); + reg2.plugins[0].releases[0].assets.remove("linux-x64"); + reg2.plugins[0].releases[0] + .assets + .insert("darwin-arm64".to_string(), "x".to_string()); + assert!(pick_static_asset(®2, "firestore", None, "linux-x64").is_err()); + } + + #[tokio::test] + async fn resolve_static_asset_returns_none_for_non_json_base() { + // Non-.json base must defer to the API path (None), no network hit. + assert!(resolve_static_asset("https://registry.tabularis.dev", "firestore", None, "linux-x64") + .await + .is_none()); + } + + fn registry_with_ids(ids: &[&str]) -> PluginRegistry { + PluginRegistry { + schema_version: 1, + plugins: ids + .iter() + .map(|id| RegistryPlugin { + id: (*id).to_string(), + latest_version: "1.0.0".to_string(), + ..Default::default() + }) + .collect(), + } + } + + #[test] + fn merge_unions_legacy_only_plugins_and_api_wins_on_conflict() { + let api = PluginRegistry { + schema_version: 1, + plugins: vec![RegistryPlugin { + id: "firestore".to_string(), + latest_version: "0.5.0".to_string(), // API version + ..Default::default() + }], + }; + let legacy = registry_with_ids(&["firestore", "duckdb", "csv"]); // legacy firestore is 1.0.0 + let merged = merge_registries(api, legacy); + let ids: Vec<&str> = merged.plugins.iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids, vec!["firestore", "duckdb", "csv"]); // API firestore first, legacy-only appended + let fs = merged.plugins.iter().find(|p| p.id == "firestore").unwrap(); + assert_eq!(fs.latest_version, "0.5.0", "API entry must win on id conflict"); + } + + #[test] + fn legacy_registry_url_defaults_then_honours_config() { + let mut cfg = AppConfig::default(); + assert_eq!(legacy_registry_url(&cfg), LEGACY_REGISTRY_URL); + cfg.legacy_registry_url = Some("https://self.host/registry.json".into()); + assert_eq!(legacy_registry_url(&cfg), "https://self.host/registry.json"); + } + + #[test] + fn migrates_legacy_registry_key_when_new_unset() { + let mut cfg = AppConfig { + custom_registry_url: Some("https://old.example/registry.json".into()), + tabularium_registry_url: None, + ..Default::default() + }; + migrate_legacy_config(&mut cfg); + assert_eq!( + cfg.tabularium_registry_url.as_deref(), + Some("https://old.example/registry.json") + ); + assert!(cfg.custom_registry_url.is_none(), "legacy key must be cleared"); + } + + #[test] + fn new_key_wins_over_legacy() { + let mut cfg = AppConfig { + custom_registry_url: Some("https://old.example".into()), + tabularium_registry_url: Some("https://new.example".into()), + ..Default::default() + }; + migrate_legacy_config(&mut cfg); + assert_eq!(cfg.tabularium_registry_url.as_deref(), Some("https://new.example")); + assert!(cfg.custom_registry_url.is_none()); + } + + #[tokio::test] + async fn json_suffix_url_takes_legacy_path() { + // A bogus .json URL must fail via the legacy fetcher (network error), + // proving it never went through the Tabularium SDK path. + let err = resolve_registry("http://127.0.0.1:0/registry.json", LEGACY_REGISTRY_URL) + .await + .unwrap_err(); + assert!( + err.contains("legacy plugin registry"), + "expected legacy-path error, got: {err}" + ); + } + + #[test] + fn reads_legacy_manifest_json() { + use crate::plugins::manager::ConfigManifest; + let dir = std::env::temp_dir().join("tab-compat-test-manifest"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("manifest.json"), + r#"{"name":"legacy","version":"1.0.0","description":"old"}"#, + ) + .unwrap(); + let parsed: ConfigManifest = read_legacy_manifest(&dir).unwrap().unwrap(); + assert_eq!(parsed.name, "legacy"); + assert_eq!(parsed.version, "1.0.0"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/plugins/deep_link.rs b/src-tauri/src/plugins/deep_link.rs new file mode 100644 index 00000000..f2510c5a --- /dev/null +++ b/src-tauri/src/plugins/deep_link.rs @@ -0,0 +1,236 @@ +//! `tabularis://` deep-link handling. +//! +//! When a Tabularium registry renders an "Open in App" button (see its +//! `GET /api/instance/info` `appUrlSchemes` payload), the registry mints a +//! URL of the form: +//! +//! ```text +//! tabularis://install/?version=®istry= +//! ``` +//! +//! * `` — required path segment, the plugin id on the registry. +//! * `version` — optional, pins a release; absent ⇒ latest. +//! * `registry` — optional, full base URL of the registry that minted +//! the link. Lets a user installed against registry A +//! follow a link from registry B; the frontend should +//! prompt before switching the configured registry. +//! +//! The Tauri layer parses incoming URLs into a [`PluginInstallRequest`] +//! and emits it on the `tabularis://plugin-install` event so the React +//! frontend can show the install confirmation modal. Anything else (an +//! unknown action, a malformed slug) is logged and dropped — we never +//! auto-install without a user click. + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; +use url::Url; + +/// Tauri-managed state holding the most recent install request that has not +/// yet been picked up by the frontend. Buffered separately from the emitted +/// event so we don't drop URLs that arrive before the React listener mounts +/// (typical for cold-start launches). +#[derive(Default)] +pub struct PendingInstall(pub Mutex>); + +/// Event name the frontend listens on. Kept stable as part of the +/// public contract with the Tabularium registry. +pub const PLUGIN_INSTALL_EVENT: &str = "tabularis://plugin-install"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstallRequest { + /// Plugin slug on the registry (matches the manifest `id`). + pub slug: String, + /// Optional pinned version. `None` ⇒ install latest. + pub version: Option, + /// Optional source registry. `None` ⇒ use whichever registry the + /// user currently has configured. + pub registry: Option, +} + +/// Parses a `tabularis://...` URL into a typed install request. +/// Returns `None` for URLs we don't (yet) handle so callers can ignore +/// them quietly. +/// +/// Accepted forms (Tabularium currently emits the query-based one): +/// * `tabularis://install?slug=&version=®istry=` +/// * `tabularis://install/?version=®istry=` +/// * `tabularis:install/` (some launchers strip the `//`) +pub fn parse_install_url(raw: &str) -> Option { + let url = Url::parse(raw).ok()?; + if url.scheme() != "tabularis" { + return None; + } + + // Action = host if present (`tabularis://install...`), otherwise the + // first path segment (`tabularis:install/...`). + let host = url.host_str(); + let action_from_path = || { + url.path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string() + }; + let action = host.map(str::to_string).unwrap_or_else(action_from_path); + if action != "install" { + log::debug!("Ignoring tabularis:// URL with unknown action: {}", raw); + return None; + } + + // Collect query first — Tabularium's "Open in App" link puts slug there. + let mut version = None; + let mut registry = None; + let mut query_slug: Option = None; + for (k, v) in url.query_pairs() { + match k.as_ref() { + "slug" => query_slug = Some(v.into_owned()), + "version" => version = Some(v.into_owned()), + "registry" => registry = Some(v.into_owned()), + _ => {} + } + } + + // Fall back to path segments after the action if no `?slug=` was given. + let slug = query_slug.or_else(|| { + let mut segments = url + .path_segments() + .map(|s| s.collect::>()) + .unwrap_or_default(); + if host.is_none() && segments.first().copied() == Some("install") { + segments.remove(0); + } + segments.into_iter().next().map(|s| s.to_string()) + })?; + if !is_valid_slug(&slug) { + log::warn!("Rejected tabularis:// URL — bad slug: {:?}", slug); + return None; + } + + Some(PluginInstallRequest { + slug, + version, + registry, + }) +} + +/// Slug must match Tabularium's own pattern (`^[a-z0-9][a-z0-9-]*$`, +/// length 1–64). Stops path-traversal-ish input from reaching the installer +/// without forcing assumptions about the registry's host (self-hosting must +/// keep working without an allowlist). +fn is_valid_slug(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 { + return false; + } + let valid_char = |b: &u8| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-'; + bytes.iter().all(valid_char) && bytes.first().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +/// Dispatch a single URL to the frontend. Called from: +/// * the deep-link plugin's `on_open_url` handler (warm handoff), +/// * the cold-start `get_current()` path on app boot, +/// * the `single_instance` callback when a second launch forwards args. +/// +/// We both **emit** an event (so an already-mounted listener reacts +/// immediately) AND **stash** the request in `PendingInstall` so a fresh +/// React subscription can drain it on mount. Without the stash a cold-start +/// URL is delivered before the webview's event listener exists and silently +/// dropped. +pub fn handle_url(app: &AppHandle, raw: &str) { + let Some(req) = parse_install_url(raw) else { + log::info!("Ignoring tabularis:// URL: {}", raw); + return; + }; + + // Stash for cold-start replay; the frontend pulls this on mount via + // `consume_pending_deep_link_install`. + if let Some(state) = app.try_state::() { + if let Ok(mut slot) = state.0.lock() { + *slot = Some(req.clone()); + } + } + + if let Err(err) = app.emit(PLUGIN_INSTALL_EVENT, &req) { + log::error!( + "Failed to emit {}: {} (request: {:?})", + PLUGIN_INSTALL_EVENT, + err, + req + ); + } else { + log::info!("Emitted {} for slug '{}'", PLUGIN_INSTALL_EVENT, req.slug); + } + + // Focus the main window so the user sees the confirmation modal — + // a second launch may otherwise leave Tabularis in the background. + if let Some(win) = app.get_webview_window("main") { + let _ = win.unminimize(); + let _ = win.set_focus(); + } +} + +/// Drains the pending deep-link install request, if any. Called from the +/// frontend immediately after the listener mounts to recover URLs that +/// arrived during cold-start before the event subscription existed. +#[tauri::command] +pub fn consume_pending_deep_link_install( + state: tauri::State<'_, PendingInstall>, +) -> Option { + state.0.lock().ok().and_then(|mut slot| slot.take()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_canonical_install_url() { + let req = + parse_install_url("tabularis://install/duckdb?version=0.2.0®istry=https%3A%2F%2Fr.example") + .expect("valid URL"); + assert_eq!(req.slug, "duckdb"); + assert_eq!(req.version.as_deref(), Some("0.2.0")); + assert_eq!(req.registry.as_deref(), Some("https://r.example")); + } + + #[test] + fn parses_query_based_install_url() { + // Format emitted by the Tabularium frontend's "Open in App" button. + let req = parse_install_url( + "tabularis://install?registry=https%3A%2F%2Fregistry.spitzli.dev&slug=firestore-tabularis&version=0.2.0", + ) + .expect("valid URL"); + assert_eq!(req.slug, "firestore-tabularis"); + assert_eq!(req.version.as_deref(), Some("0.2.0")); + assert_eq!(req.registry.as_deref(), Some("https://registry.spitzli.dev")); + } + + #[test] + fn parses_minimal_install_url() { + let req = parse_install_url("tabularis://install/csv").expect("valid URL"); + assert_eq!(req.slug, "csv"); + assert!(req.version.is_none()); + assert!(req.registry.is_none()); + } + + #[test] + fn rejects_other_schemes() { + assert!(parse_install_url("https://example.com").is_none()); + assert!(parse_install_url("tabularium://install/foo").is_none()); + } + + #[test] + fn rejects_unknown_actions() { + assert!(parse_install_url("tabularis://browse/foo").is_none()); + } + + #[test] + fn rejects_missing_slug() { + assert!(parse_install_url("tabularis://install/").is_none()); + assert!(parse_install_url("tabularis://install").is_none()); + } +} diff --git a/src-tauri/src/plugins/installer.rs b/src-tauri/src/plugins/installer.rs index 861c98b3..8975f15c 100644 --- a/src-tauri/src/plugins/installer.rs +++ b/src-tauri/src/plugins/installer.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct InstalledPluginInfo { @@ -15,8 +16,12 @@ pub struct InstalledPluginInfo { #[derive(Deserialize)] struct InstalledPluginManifest { - id: String, + /// Legacy manifests carried an explicit `id`; the canonical schema uses + /// `name` as the slug/identity, so this is optional and falls back to `name`. + #[serde(default)] + id: Option, name: String, + /// The registry guarantees `version` in the manifest (`.tabularium`). version: String, description: String, } @@ -32,16 +37,42 @@ pub fn get_plugins_dir() -> Result { Ok(plugins_dir) } -pub(crate) fn read_plugin_info_from_dir(path: &Path) -> Result { - let manifest_path = path.join("manifest.json"); - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", manifest_path, e))?; +/// Canonical plugin bundle manifest. JSON content. The preferred manifest; the +/// only fallback is the removable `manifest.json` legacy path in `read_manifest` +/// (see `COMPAT(registry-ga)`), which goes away once all plugins republish. +const MANIFEST_FILE: &str = ".tabularium"; + +/// Whether a directory contains a `.tabularium` bundle manifest. +pub fn has_manifest(dir: &Path) -> bool { + dir.join(MANIFEST_FILE).exists() +} + +/// Reads and deserialises a plugin bundle's `.tabularium` manifest (JSON). +pub fn read_manifest(dir: &Path) -> Result { + let path = dir.join(MANIFEST_FILE); + if !path.exists() { + // COMPAT(registry-ga): fall back to legacy manifest.json. + if let Some(legacy) = crate::plugins::compat::read_legacy_manifest::(dir) { + log::warn!("Using legacy manifest.json in {:?} — republish as .tabularium", dir); + return legacy; + } + return Err(format!( + "No .tabularium manifest in {:?} — this plugin bundle must ship a .tabularium (JSON)", + dir + )); + } + let manifest_str = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", path, e))?; + serde_json::from_str(&manifest_str) + .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", path, e)) +} - let manifest: InstalledPluginManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", manifest_path, e))?; +pub(crate) fn read_plugin_info_from_dir(path: &Path) -> Result { + let manifest: InstalledPluginManifest = read_manifest(path)?; + let id = manifest.id.unwrap_or_else(|| manifest.name.clone()); Ok(InstalledPluginInfo { - id: manifest.id, + id, name: manifest.name, version: manifest.version, description: manifest.description, @@ -53,7 +84,11 @@ pub fn read_installed_plugin(plugin_id: &str) -> Result Result<(), String> { +pub async fn download_and_install( + plugin_id: &str, + download_url: &str, + expected_sha256: Option<&str>, +) -> Result<(), String> { let plugins_dir = get_plugins_dir()?; let tmp_dir = plugins_dir.join(format!(".tmp-{}", plugin_id)); let final_dir = plugins_dir.join(plugin_id); @@ -111,6 +146,31 @@ pub async fn download_and_install(plugin_id: &str, download_url: &str) -> Result content_type ); + // Verify SHA-256 if the registry advertised one. The Tabularium + // registry signs releases with a sha256 in the integrity envelope + // (see https://tabularium.wiki/docs/#/consuming) — refusing to install + // on mismatch is what protects users from a tampered upstream asset. + // The legacy GitHub-hosted registry doesn't publish hashes, so this + // check is opt-in per call. + if let Some(expected) = expected_sha256 { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(expected) { + log::error!( + "Plugin '{}' SHA-256 mismatch: expected {}, got {}", + plugin_id, + expected, + actual + ); + return Err(format!( + "SHA-256 mismatch for plugin '{}': expected {}, got {} — asset may be tampered or corrupted", + plugin_id, expected, actual + )); + } + log::info!("Plugin '{}' SHA-256 verified ({})", plugin_id, actual); + } + // Extract to temp dir fs::create_dir_all(&tmp_dir).map_err(|e| format!("Failed to create temp directory: {}", e))?; @@ -168,20 +228,17 @@ pub async fn download_and_install(plugin_id: &str, download_url: &str) -> Result } } - // Validate manifest.json exists - let manifest_path = tmp_dir.join("manifest.json"); - if !manifest_path.exists() { + // Validate the bundle ships a `.tabularium` manifest and that it deserialises + // into a well-formed manifest with the required fields (notably `version` — + // the strict-mode drift catch). + if !has_manifest(&tmp_dir) { fs::remove_dir_all(&tmp_dir).ok(); - return Err("Plugin archive does not contain manifest.json".to_string()); + return Err("Plugin archive does not contain a .tabularium manifest".to_string()); } - - // Validate manifest.json parses correctly - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read manifest.json: {}", e))?; - serde_json::from_str::(&manifest_str).map_err(|e| { + if let Err(e) = read_manifest::(&tmp_dir) { fs::remove_dir_all(&tmp_dir).ok(); - format!("Invalid manifest.json: {}", e) - })?; + return Err(format!("Invalid plugin manifest: {}", e)); + } // Remove existing plugin dir if present if final_dir.exists() { @@ -234,8 +291,7 @@ pub fn list_installed() -> Result, String> { } } - let manifest_path = path.join("manifest.json"); - if !manifest_path.exists() { + if !has_manifest(&path) { continue; } diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index 9121034c..74d16427 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -30,8 +30,11 @@ pub fn get_plugin_startup_errors() -> Vec { #[derive(Serialize, Deserialize)] pub struct ConfigManifest { - pub id: String, + /// Legacy field; the canonical schema uses `name` as the identity/slug. + #[serde(default)] + pub id: Option, pub name: String, + /// The registry guarantees `version` in the manifest (`.tabularium`). pub version: String, pub description: String, #[serde(default)] @@ -49,6 +52,13 @@ pub struct ConfigManifest { pub color: String, #[serde(default)] pub icon: String, + /// Registry manifest `engine` — surfaced so the connection catalogue can + /// group locally-installed plugins. + #[serde(default)] + pub engine: Option, + /// Registry manifest `paradigms`, primary first. + #[serde(default)] + pub paradigms: Vec, #[serde(default)] pub interpreter: Option, #[serde(default)] @@ -145,37 +155,31 @@ pub async fn load_plugin_from_dir( interpreter_override: Option, settings: HashMap, ) -> Result<(), String> { - let manifest_path = path.join("manifest.json"); - if !manifest_path.exists() { - return Err(format!("manifest.json not found in {:?}", path)); - } - - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", manifest_path, e))?; - - let config: ConfigManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", manifest_path, e))?; + let config: ConfigManifest = crate::plugins::installer::read_manifest(path)?; // Refuse plugins that claim a built-in driver id. Registration is a plain // insert keyed by id, so otherwise a plugin with id "mysql"/"postgres"/ // "sqlite" would shadow the built-in driver and receive existing // connections' resolved credentials. + let plugin_id = config.id.clone().unwrap_or_else(|| config.name.clone()); const BUILTIN_DRIVER_IDS: [&str; 3] = ["mysql", "postgres", "sqlite"]; - if BUILTIN_DRIVER_IDS.contains(&config.id.as_str()) { + if BUILTIN_DRIVER_IDS.contains(&plugin_id.as_str()) { return Err(format!( "Plugin id '{}' collides with a built-in driver and was refused", - config.id + plugin_id )); } let manifest = PluginManifest { - id: config.id, + id: plugin_id, name: config.name, version: config.version, description: config.description, default_port: config.default_port, capabilities: config.capabilities, is_builtin: false, + engine: config.engine, + paradigms: config.paradigms, default_username: config.default_username.unwrap_or_default(), color: config.color, icon: config.icon, diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index b2836f1e..07d2c103 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -1,9 +1,12 @@ pub mod commands; +pub mod compat; // COMPAT(registry-ga): remove with the BC layer +pub mod deep_link; pub mod driver; pub mod installer; pub mod manager; pub mod registry; pub mod rpc; +pub mod tabularium; #[cfg(test)] mod tests; diff --git a/src-tauri/src/plugins/registry.rs b/src-tauri/src/plugins/registry.rs index 3c67769a..de7802df 100644 --- a/src-tauri/src/plugins/registry.rs +++ b/src-tauri/src/plugins/registry.rs @@ -2,8 +2,53 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -const REGISTRY_URL: &str = - "https://raw.githubusercontent.com/TabularisDB/tabularis/main/plugins/registry.json"; +use crate::plugins::tabularium; + +/// Built-in Tabularium registry used when the user has not pinned one +/// in `config.json`. Operators can override via `tabularium_registry_url`. +pub const DEFAULT_TABULARIUM_URL: &str = "https://registry.tabularis.dev"; + +/// Resolved action for a deeplink/install decision, derived from the installed +/// version vs the target version. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InstallAction { + /// Not installed — offer install. + Install, + /// Installed, older than target — offer update. + Update, + /// Installed at >= target (incl. equal and downgrade links) — no action. + UpToDate, +} + +/// SemVer-aware classification. The registry requires semver-formatted +/// versions; if either side fails to parse we degrade to string comparison +/// (equal => up-to-date, else => update) and log, rather than crash callers. +pub fn classify_install(installed: Option<&str>, target: &str) -> InstallAction { + let Some(installed) = installed else { + return InstallAction::Install; + }; + match (semver::Version::parse(installed), semver::Version::parse(target)) { + (Ok(cur), Ok(tgt)) => { + if cur < tgt { + InstallAction::Update + } else { + InstallAction::UpToDate + } + } + _ => { + log::warn!( + "Non-semver version compare (installed={:?}, target={:?}); string fallback", + installed, target + ); + if installed == target { + InstallAction::UpToDate + } else { + InstallAction::Update + } + } + } +} #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PluginRegistry { @@ -11,7 +56,7 @@ pub struct PluginRegistry { pub plugins: Vec, } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct RegistryPlugin { pub id: String, pub name: String, @@ -20,6 +65,42 @@ pub struct RegistryPlugin { pub homepage: String, pub latest_version: String, pub releases: Vec, + // -- Richer Tabularium-only fields. All optional so the legacy flat + // GitHub registry deserializes unchanged (Serde defaults to None / + // empty Vec for absent fields). + /// URL of the plugin's square logo, when the manifest declares one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Repository URL — surfaced separately from `homepage` because the + /// Tabularium API distinguishes them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + /// Admin-defined kind from `/api/kinds` (e.g. `driver`, `theme`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Tags / categories the author declared on the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Optional category from the registry's facets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Aggregate download count, when the registry tracks it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Base URL of the registry that served this plugin — set by the + /// preview / catalogue commands so the frontend can link card titles + /// to the registry's detail page (`/plugins/`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_base_url: Option, + /// Concrete database the driver connects to (manifest `extensions.engine`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + /// Data-model families (manifest `extensions.paradigms`), primary first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paradigms: Vec, + /// Registry-assigned verification flag (top-level `verified`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub verified: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -48,6 +129,36 @@ pub struct RegistryPluginWithStatus { pub installed_version: Option, pub update_available: bool, pub platform_supported: bool, + // -- Richer Tabularium-only fields, surfaced verbatim to the frontend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Base URL of the registry that served this plugin. Lets the frontend + /// link the card title to the registry's detail page (`/plugins/`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_base_url: Option, + /// Concrete database the driver connects to (manifest `extensions.engine`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + /// Data-model families (manifest `extensions.paradigms`), primary first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paradigms: Vec, + /// Registry-assigned verification flag (top-level `verified`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub verified: bool, + /// Deeplink-only: resolved action (install / update / up_to_date) for the + /// confirmation modal. `None` outside the deeplink preview path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install_action: Option, } pub fn get_current_platform() -> String { @@ -63,17 +174,81 @@ pub fn get_current_platform() -> String { } } -pub async fn fetch_registry(custom_url: Option<&str>) -> Result { - let url = custom_url.unwrap_or(REGISTRY_URL); +/// Fetch a Tabularium-flavoured registry and adapt it to the legacy +/// `PluginRegistry` shape. The plugin list endpoint omits per-release detail, +/// so for each plugin we follow up with `GET /api/plugins/{slug}` to get the +/// full release list — the rest of the install pipeline (frontend cards, +/// version picker, asset resolution) needs `releases[].assets` populated. +pub async fn fetch_tabularium_registry(base_url: &str) -> Result { + let list = tabularium::fetch_plugin_list(base_url).await?; + + // Fetch every plugin's detail concurrently instead of N sequential + // round-trips. A failed detail call degrades to the list item (entry + // visible but not installable — matches "platform unsupported" UX). + let plugins: Vec = + futures::future::join_all(list.into_iter().map(|item| async move { + let slug = item.id.clone(); + match tabularium::fetch_plugin_detail(base_url, &slug).await { + Ok(detail) => detail, + Err(err) => { + log::warn!( + "Tabularium detail fetch failed for {}: {} — falling back to list item", + slug, + err + ); + item + } + } + })) + .await; - let response = reqwest::get(url) - .await - .map_err(|e| format!("Failed to fetch plugin registry: {}", e))?; + Ok(PluginRegistry { + schema_version: 1, + plugins, + }) +} - let registry: PluginRegistry = response - .json() - .await - .map_err(|e| format!("Failed to parse plugin registry: {}", e))?; +/// Thin wrapper so callers don't have to import the SDK adapter type. +pub use crate::plugins::tabularium::AssetResolution as TabulariumAssetResolution; + +pub async fn resolve_tabularium_asset( + base_url: &str, + slug: &str, + version: &str, + platform: &str, +) -> Result { + tabularium::resolve_asset(base_url, slug, version, platform).await +} - Ok(registry) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_not_installed_is_install() { + assert_eq!(classify_install(None, "1.2.3"), InstallAction::Install); + } + + #[test] + fn classify_older_installed_is_update() { + assert_eq!(classify_install(Some("1.0.0"), "1.2.3"), InstallAction::Update); + assert_eq!(classify_install(Some("0.9.0"), "0.10.0"), InstallAction::Update); + } + + #[test] + fn classify_equal_is_up_to_date() { + assert_eq!(classify_install(Some("1.2.3"), "1.2.3"), InstallAction::UpToDate); + } + + #[test] + fn classify_newer_installed_is_up_to_date() { + // Downgrade link: never auto-downgrade. + assert_eq!(classify_install(Some("2.0.0"), "1.2.3"), InstallAction::UpToDate); + } + + #[test] + fn classify_unparseable_falls_back_to_string_compare() { + assert_eq!(classify_install(Some("weird"), "weird"), InstallAction::UpToDate); + assert_eq!(classify_install(Some("weird"), "other"), InstallAction::Update); + } } diff --git a/src-tauri/src/plugins/tabularium.rs b/src-tauri/src/plugins/tabularium.rs new file mode 100644 index 00000000..6b2f6cbe --- /dev/null +++ b/src-tauri/src/plugins/tabularium.rs @@ -0,0 +1,399 @@ +//! Tabularium registry client. +//! +//! Tabularium is a self-hosted plugin registry (https://tabularium.wiki). +//! HTTP transport and typed deserialization are delegated to the +//! [`tabularium_sdk`] crate — a progenitor-generated client kept in sync +//! with the registry's OpenAPI spec. This module only adapts the SDK's +//! response shapes into the legacy `RegistryPlugin` representation that +//! the rest of Tabularis already speaks. +//! +//! Endpoints used: +//! * `list_plugins` — paginated catalogue +//! * `get_plugin` — detail + releases (each with per-asset metadata) +//! * `get_release_integrity` — JWS / sha256 envelope (used in installer) + +use std::collections::HashMap; + +use tabularium_sdk::Client; + +use crate::plugins::registry::{PluginRelease, RegistryPlugin}; + +/// Strips a trailing `/` so the SDK doesn't end up with `//api/...` URLs. +fn normalise_base(base: &str) -> &str { + base.trim_end_matches('/') +} + +/// Map a Tabularium platform key (the registry stores them as +/// `linux-x64` / `darwin-arm64` / `win-x64` / `universal`, which already +/// matches Tabularis) into the legacy key. Kept as a function in case +/// the registry ever introduces variants that need re-keying. +fn normalise_platform_key(raw: &str) -> String { + match raw { + // Common aliases — defensive in case the registry serves these. + "linux-amd64" | "linux-x86_64" => "linux-x64".to_string(), + "linux-aarch64" => "linux-arm64".to_string(), + "darwin-amd64" | "darwin-x86_64" | "macos-x64" => "darwin-x64".to_string(), + "darwin-aarch64" | "macos-arm64" => "darwin-arm64".to_string(), + "windows-x64" | "win-amd64" | "windows-amd64" => "win-x64".to_string(), + _ => raw.to_string(), + } +} + +/// Build a fresh SDK client against the operator-configured base URL. +/// The client is cheap to construct (wraps a `reqwest::Client`), so callers +/// don't need to cache it for one-shot fetches. +fn make_client(base_url: &str) -> Client { + Client::new(normalise_base(base_url)) +} + +/// Fetch the first page of plugins. The SDK exposes pagination via +/// `limit(...).page(...)`; we ask for the maximum the registry serves +/// (200 fits the typical Tabularis install in one round-trip). +pub async fn fetch_plugin_list(base_url: &str) -> Result, String> { + let client = make_client(base_url); + let resp = client + .list_plugins() + .limit("200") + .send() + .await + .map_err(|e| format!("Tabularium list_plugins failed: {}", e))?; + let body = resp.into_inner(); + Ok(body.plugins.into_iter().map(list_item_to_plugin).collect()) +} + +/// Fetch full detail for one plugin (releases + per-asset sha256/url). +pub async fn fetch_plugin_detail( + base_url: &str, + slug: &str, +) -> Result { + let client = make_client(base_url); + let resp = client + .get_plugin() + .slug(slug) + .send() + .await + .map_err(|e| format!("Tabularium get_plugin '{}' failed: {}", slug, e))?; + Ok(detail_to_plugin(resp.into_inner())) +} + +/// Splits a platform key (`{os}-{arch}`, e.g. `linux-x64`) into the separate +/// `os` / `arch` the tracked-download endpoints expect. A key without a `-` +/// yields `(key, "")`. +fn split_platform(platform: &str) -> (&str, &str) { + platform.split_once('-').unwrap_or((platform, "")) +} + +/// Builds the registry's **tracked** download URL for a specific version: +/// `{base}/api/plugins/{slug}/releases/{version}?os={os}&arch={arch}&redirect=1`. +/// Hitting this endpoint increments the plugin's download counter, then +/// 302-redirects to the real asset (reqwest follows the redirect automatically). +pub fn tracked_download_url(base_url: &str, slug: &str, version: &str, platform: &str) -> String { + let base = base_url.trim_end_matches('/'); + let (os, arch) = split_platform(platform); + format!( + "{}/api/plugins/{}/releases/{}?os={}&arch={}&redirect=1", + base, slug, version, os, arch + ) +} + +/// Builds the registry's **tracked latest** download URL: +/// `{base}/api/plugins/{slug}/latest?os={os}&arch={arch}&redirect=1`. +/// Used when installing the latest version (no pinned version) so the registry +/// records a "latest" download; like the versioned endpoint it 302-redirects to +/// the asset. +pub fn tracked_latest_download_url(base_url: &str, slug: &str, platform: &str) -> String { + let base = base_url.trim_end_matches('/'); + let (os, arch) = split_platform(platform); + format!( + "{}/api/plugins/{}/latest?os={}&arch={}&redirect=1", + base, slug, os, arch + ) +} + +/// Per-platform asset resolution for installation. Returns the download URL +/// plus the SHA-256 the registry expects (when published — `None` for legacy +/// releases pre-Phase-2 integrity backfill). +pub struct AssetResolution { + pub download_url: String, + pub expected_sha256: Option, +} + +pub async fn resolve_asset( + base_url: &str, + slug: &str, + version: &str, + platform: &str, +) -> Result { + let client = make_client(base_url); + let raw = client + .get_plugin() + .slug(slug) + .send() + .await + .map_err(|e| format!("Tabularium get_plugin '{}' failed: {}", slug, e))? + .into_inner(); + let release = raw + .releases + .iter() + .find(|r| r.version == version) + .ok_or_else(|| format!("No release '{}' for plugin '{}'", version, slug))?; + + // The per-platform asset map (`releases[].assets`) is left untyped by the + // SDK (`patternProperties` in OpenAPI → `serde_json::Map`). + // We read `url` and `sha256` directly off the JSON object. + let pick = pick_asset_entry(&release.assets, platform); + let entry = match pick { + Some(e) => e, + None => { + return Err(format!( + "Plugin '{}' has no asset for platform '{}'", + slug, platform + )) + } + }; + let url = entry + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Tabularium asset for '{}' is missing 'url'", slug))?; + let sha = entry + .get("sha256") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(AssetResolution { + download_url: url, + expected_sha256: sha, + }) +} + +/// Picks the JSON object describing the asset for the requested platform. +/// Tries the exact key first, then known aliases (matched via +/// [`normalise_platform_key`]), then the `universal` fallback. +fn pick_asset_entry<'a>( + assets: &'a serde_json::Map, + platform: &str, +) -> Option<&'a serde_json::Map> { + let direct = assets.get(platform).and_then(|v| v.as_object()); + if direct.is_some() { + return direct; + } + for (k, v) in assets.iter() { + if normalise_platform_key(k) == platform { + if let Some(obj) = v.as_object() { + return Some(obj); + } + } + } + assets.get("universal").and_then(|v| v.as_object()) +} + +// ----------------------------------------------------------------------------- +// SDK → legacy shape adapters +// ----------------------------------------------------------------------------- + +fn list_item_to_plugin(item: tabularium_sdk::types::ListPluginsResponsePluginsItem) -> RegistryPlugin { + let homepage = choose_homepage(item.homepage.clone(), item.repo_url.clone()); + let facets = serde_json::to_value(&item).unwrap_or(serde_json::Value::Null); + let (engine, paradigms, verified) = extract_driver_facets(&facets); + RegistryPlugin { + id: item.id, + name: item.name, + description: item.description, + author: item.author, + homepage, + latest_version: item.latest_version.unwrap_or_default(), + releases: Vec::new(), + icon: item.icon_url, + repo_url: nonempty(item.repo_url), + kind: None, // not on list items — fetched via detail + tags: item.tags, + category: item.category, + downloads: Some(item.downloads.max(0.0) as u64), + registry_base_url: None, + engine, + paradigms, + verified, + } +} + +fn detail_to_plugin(detail: tabularium_sdk::types::GetPluginResponse) -> RegistryPlugin { + let latest = detail.latest_version.clone().unwrap_or_else(|| { + detail + .releases + .first() + .map(|r| r.version.clone()) + .unwrap_or_default() + }); + + // `kind` is folded into `tags` by the registry (Tabularium spec) so the + // SDK doesn't expose a separate field. Recover it as the first tag that + // matches the registry's kind pattern (`^[a-z0-9][a-z0-9-]*$`) — best + // effort, falls back to None when the heuristic doesn't match. + let kind = detail + .tags + .iter() + .find(|t| !t.is_empty() && t.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')) + .cloned(); + + let facets = serde_json::to_value(&detail).unwrap_or(serde_json::Value::Null); + let (engine, paradigms, verified) = extract_driver_facets(&facets); + + RegistryPlugin { + id: detail.id, + name: detail.name, + description: detail.description, + author: detail.author, + homepage: choose_homepage(detail.homepage.clone(), detail.repo_url.clone()), + latest_version: latest, + releases: detail.releases.iter().map(release_to_legacy).collect(), + icon: detail.icon_url, + repo_url: nonempty(detail.repo_url), + kind, + tags: detail.tags, + category: detail.category, + downloads: Some(detail.downloads.max(0.0) as u64), + registry_base_url: None, + engine, + paradigms, + verified, + } +} + +fn nonempty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn release_to_legacy(r: &tabularium_sdk::types::GetPluginResponseReleasesItem) -> PluginRelease { + let mut assets: HashMap = HashMap::new(); + for (platform_raw, value) in r.assets.iter() { + let Some(url) = value.get("url").and_then(|v| v.as_str()) else { + continue; + }; + let key = normalise_platform_key(platform_raw); + assets.entry(key).or_insert_with(|| url.to_string()); + } + PluginRelease { + version: r.version.clone(), + min_tabularis_version: r.min_runtime_version.clone(), + assets, + } +} + +/// Read the Tabularis driver facets out of a serialized registry plugin: +/// `verified` (top-level) and `engine`/`paradigms` (nested under `extensions`). +/// Works off `serde_json::Value` so it does not depend on the exact generated +/// SDK field types — only that the SDK round-trips these keys. +fn extract_driver_facets(item: &serde_json::Value) -> (Option, Vec, bool) { + let verified = item.get("verified").and_then(|v| v.as_bool()).unwrap_or(false); + let ext = item.get("extensions"); + let engine = ext + .and_then(|e| e.get("engine")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let paradigms = ext + .and_then(|e| e.get("paradigms")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + (engine, paradigms, verified) +} + +/// Picks the first non-empty value between explicit `homepage` and the +/// repo URL — the legacy registry only carries a single homepage field, +/// so we collapse both Tabularium fields into one. +fn choose_homepage(homepage: String, repo: String) -> String { + if !homepage.is_empty() { + homepage + } else { + repo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_driver_facets_reads_engine_paradigms_verified() { + let json = serde_json::json!({ + "verified": true, + "extensions": { "engine": "firestore", "paradigms": ["document", "vector"] } + }); + let (engine, paradigms, verified) = extract_driver_facets(&json); + assert_eq!(engine.as_deref(), Some("firestore")); + assert_eq!(paradigms, vec!["document".to_string(), "vector".to_string()]); + assert!(verified); + } + + #[test] + fn extract_driver_facets_defaults_when_absent() { + let json = serde_json::json!({ "name": "x" }); + let (engine, paradigms, verified) = extract_driver_facets(&json); + assert_eq!(engine, None); + assert!(paradigms.is_empty()); + assert!(!verified); + } + + #[test] + fn normalise_platform_key_handles_aliases() { + assert_eq!(normalise_platform_key("linux-x64"), "linux-x64"); + assert_eq!(normalise_platform_key("linux-amd64"), "linux-x64"); + assert_eq!(normalise_platform_key("linux-aarch64"), "linux-arm64"); + assert_eq!(normalise_platform_key("darwin-aarch64"), "darwin-arm64"); + assert_eq!(normalise_platform_key("windows-x64"), "win-x64"); + assert_eq!(normalise_platform_key("universal"), "universal"); + assert_eq!(normalise_platform_key("freebsd-x64"), "freebsd-x64"); + } + + #[test] + fn choose_homepage_prefers_explicit_homepage() { + assert_eq!( + choose_homepage("https://home".into(), "https://repo".into()), + "https://home" + ); + assert_eq!( + choose_homepage(String::new(), "https://repo".into()), + "https://repo" + ); + assert_eq!(choose_homepage(String::new(), String::new()), ""); + } + + #[test] + fn builds_tracked_url_from_platform_key() { + // Platform keys are `{os}-{arch}`; the endpoint wants them split. + assert_eq!( + tracked_download_url("https://registry.tabularis.dev", "firestore", "0.2.0", "linux-x64"), + "https://registry.tabularis.dev/api/plugins/firestore/releases/0.2.0?os=linux&arch=x64&redirect=1" + ); + assert_eq!( + tracked_download_url("https://registry.tabularis.dev/", "duckdb", "1.0.0", "darwin-arm64"), + "https://registry.tabularis.dev/api/plugins/duckdb/releases/1.0.0?os=darwin&arch=arm64&redirect=1" + ); + } + + #[test] + fn builds_tracked_latest_url() { + assert_eq!( + tracked_latest_download_url("https://registry.tabularis.dev/", "firestore", "linux-x64"), + "https://registry.tabularis.dev/api/plugins/firestore/latest?os=linux&arch=x64&redirect=1" + ); + } + + #[test] + fn tracked_url_handles_platform_without_dash() { + // Defensive: an unsplittable platform still produces a usable URL. + assert_eq!( + tracked_download_url("https://r.example", "x", "1.0.0", "universal"), + "https://r.example/api/plugins/x/releases/1.0.0?os=universal&arch=&redirect=1" + ); + } +} diff --git a/src-tauri/src/plugins/tests.rs b/src-tauri/src/plugins/tests.rs index d04ab2d9..cf654fa4 100644 --- a/src-tauri/src/plugins/tests.rs +++ b/src-tauri/src/plugins/tests.rs @@ -5,33 +5,58 @@ use tempfile::tempdir; use super::installer::read_plugin_info_from_dir; #[test] -fn reads_installed_plugin_info_from_manifest() { +fn reads_canonical_tabularium_manifest() { + // The canonical bundle ships `.tabularium` (JSON content). It drops `id` + // (name is the slug) and keeps the required `version`; identity falls back + // to `name`. let dir = tempdir().expect("temp dir"); - let manifest_path = dir.path().join("manifest.json"); fs::write( - &manifest_path, + dir.path().join(".tabularium"), r#"{ - "id": "google-sheets", - "name": "Google Sheets", - "version": "0.2.0", - "description": "Query Sheets" + "name": "firestore", + "kind": "driver", + "version": "0.3.8", + "description": "Firestore driver" }"#, ) - .expect("write manifest"); + .expect("write .tabularium"); let plugin = read_plugin_info_from_dir(dir.path()).expect("read manifest"); - assert_eq!(plugin.id, "google-sheets"); - assert_eq!(plugin.name, "Google Sheets"); + assert_eq!(plugin.id, "firestore"); + assert_eq!(plugin.name, "firestore"); + assert_eq!(plugin.version, "0.3.8"); + assert_eq!(plugin.description, "Firestore driver"); +} + +#[test] +fn falls_back_to_legacy_manifest_json() { + // COMPAT(registry-ga): a bundle that ships only the legacy manifest.json + // now loads successfully via the compat fallback until the publisher + // migrates to .tabularium. + let dir = tempdir().expect("temp dir"); + fs::write( + dir.path().join("manifest.json"), + r#"{ "name": "google-sheets", "version": "0.2.0", "description": "Query Sheets" }"#, + ) + .expect("write manifest"); + + let plugin = read_plugin_info_from_dir(dir.path()).expect("legacy fallback must succeed"); + assert_eq!(plugin.name, "google-sheets"); assert_eq!(plugin.version, "0.2.0"); - assert_eq!(plugin.description, "Query Sheets"); +} + +#[test] +fn errors_when_no_manifest_present() { + let dir = tempdir().expect("temp dir"); + let error = read_plugin_info_from_dir(dir.path()).expect_err("no manifest"); + assert!(error.contains("No .tabularium manifest")); } #[test] fn returns_error_for_invalid_manifest() { let dir = tempdir().expect("temp dir"); - let manifest_path = dir.path().join("manifest.json"); - fs::write(&manifest_path, "{ invalid json").expect("write manifest"); + fs::write(dir.path().join(".tabularium"), "{ invalid json").expect("write manifest"); let error = read_plugin_info_from_dir(dir.path()).expect_err("invalid manifest"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c3c16cc3..5aca8619 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -61,6 +61,11 @@ "https://github.com/TabularisDB/tabularis/releases/latest/download/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY0NzY0QjNEQjI4QjFEQjcKUldTM0hZdXlQVXQyWkRYdmRJOEJhVEpYTit2VXRYS0drTit1bmthSHVzcWlQK09Wb2l5cVpOWXAK" + }, + "deep-link": { + "desktop": { + "schemes": ["tabularis"] + } } } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e1ce1bec..6c549181 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,9 +21,11 @@ import { UpdateNotificationModal } from "./components/modals/UpdateNotificationM import { CommunityModal } from "./components/modals/CommunityModal"; import { WhatsNewModal } from "./components/modals/WhatsNewModal"; import { AiApprovalGate } from "./components/modals/AiApprovalGate"; +import { PluginInstallConfirmModal } from "./components/modals/PluginInstallConfirmModal"; import { useUpdate } from "./hooks/useUpdate"; import { useChangelog } from "./hooks/useChangelog"; import { useSettings } from "./hooks/useSettings"; +import { useDeepLinkInstall } from "./hooks/useDeepLinkInstall"; import { APP_VERSION } from "./version"; import { isVersionAtMost, isVersionNewer } from "./utils/versionCompare"; @@ -40,6 +42,7 @@ export function App() { } = useUpdate(); const { settings, updateSetting, isLoading: isSettingsLoading } = useSettings(); const [isDebugMode, setIsDebugMode] = useState(false); + const deepLinkInstall = useDeepLinkInstall(); const [isCommunityModalDismissed, setIsCommunityModalDismissed] = useState(false); const lastSeenVersion = localStorage.getItem(WHATS_NEW_VERSION_KEY); @@ -167,6 +170,22 @@ export function App() { /> + + { + void deepLinkInstall.confirm(); + }} + onCancel={deepLinkInstall.cancel} + configuredRegistry={settings.tabulariumRegistryUrl ?? null} + /> ); } diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 093a2d2e..ab956160 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -4,6 +4,7 @@ import { X, Check, AlertCircle, + ArrowLeft, Loader2, Database, Settings, @@ -12,9 +13,9 @@ import { CheckSquare, Square, Plug, - Info, Eye, EyeOff, + ShieldCheck, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import type { ConnectionAppearance } from "../../contexts/DatabaseContext"; @@ -26,9 +27,9 @@ import { K8sConnectionsModal } from "./K8sConnectionsModal"; import { Select } from "../ui/Select"; import { SlotAnchor } from "../ui/SlotAnchor"; import { useDrivers } from "../../hooks/useDrivers"; +import { useSettings } from "../../hooks/useSettings"; import { usePluginSlotRegistry } from "../../hooks/usePluginSlotRegistry"; import { Modal } from "../ui/Modal"; -import type { PluginManifest } from "../../types/plugins"; import { loadSshConnections, type SshConnection } from "../../utils/ssh"; import { loadK8sConnections, @@ -45,6 +46,30 @@ import { parseConnectionString, toConnectionParams, } from "../../utils/connectionStringParser"; +import { useConnectionCatalogue } from "../../hooks/useConnectionCatalogue"; +import { ConnectionCatalogue } from "./connection/ConnectionCatalogue"; +import { DriverVersionPicker } from "./connection/DriverVersionPicker"; +import { InstallGate } from "./connection/InstallGate"; +import { + resolveEngineSelection, + type EngineGroup, + type CatalogueDriver, +} from "../../utils/connectionCatalogue"; + +// Accent colors per data paradigm, used for driver chips in the configure header. +const PARADIGM_ACCENT: Record = { + sql: "#3b82f6", + relational: "#3b82f6", + nosql: "#10b981", + document: "#10b981", + "key-value": "#14b8a6", + vector: "#a855f7", + graph: "#f59e0b", + timeseries: "#ec4899", + search: "#6366f1", +}; + +const paradigmAccent = (p: string): string => PARADIGM_ACCENT[p] ?? "#64748b"; interface ConnectionParams { driver: string; @@ -156,11 +181,83 @@ export const NewConnectionModal = ({ initialConnection, }: NewConnectionModalProps) => { const { t } = useTranslation(); - const { drivers } = useDrivers(); + const { drivers, refresh: refreshDrivers } = useDrivers(); + const { settings, updateSetting } = useSettings(); + + // ── wizard step ── + const isEditing = Boolean(initialConnection); + const [step, setStep] = useState<"catalogue" | "form">( + isEditing ? "form" : "catalogue", + ); + const [pendingGroup, setPendingGroup] = useState(null); + const catalogue = useConnectionCatalogue(); // ── form state ── const [driver, setDriver] = useState("mysql"); const activeDriver = drivers.find((d) => d.id === driver) ?? drivers[0]; + + // ── driver install state ── + const [installStatus, setInstallStatus] = useState< + "idle" | "installing" | "error" + >("idle"); + const [installError, setInstallError] = useState(); + + const installDriver = async ( + slug: string, + version: string, + ): Promise => { + setInstallStatus("installing"); + setInstallError(undefined); + try { + await invoke("install_plugin", { pluginId: slug, version }); + // install_plugin hot-registers the driver, but the connection modal only + // surfaces external drivers that are in `activeExternalDrivers`. Installing + // from the catalogue is an explicit opt-in, so activate it — otherwise the + // freshly-installed driver is filtered out and selection falls back to a + // built-in (mysql). + await updateSetting( + "activeExternalDrivers", + Array.from(new Set([...(settings.activeExternalDrivers ?? []), slug])), + ); + catalogue.refresh(); + refreshDrivers(); + setInstallStatus("idle"); + return true; + } catch (e) { + setInstallStatus("error"); + setInstallError( + typeof e === "string" ? e : e instanceof Error ? e.message : JSON.stringify(e), + ); + return false; + } + }; + + const activeCatalogueDriver = + catalogue.groups + .flatMap((g) => g.drivers) + .find((d) => d.slug === driver) ?? null; + const activeDriverNotInstalled = + activeCatalogueDriver != null && !activeCatalogueDriver.installed; + + // Accent + glyph for the active driver, preferring the registry's icon URL + // (the real plugin logo) and falling back to the built-in driver glyph. + const driverAccent = + activeCatalogueDriver?.color || + paradigmAccent(activeCatalogueDriver?.paradigms?.[0] ?? "") || + "#64748b"; + const renderDriverGlyph = (size: number) => { + const icon = activeCatalogueDriver?.icon ?? activeDriver?.icon ?? ""; + if (/^https?:\/\//.test(icon) || icon.startsWith("data:")) { + return ( + + ); + } + return getDriverIcon(activeDriver, size); + }; const [name, setName] = useState(""); const [formData, setFormData] = useState>({ host: "localhost", @@ -320,6 +417,9 @@ export const NewConnectionModal = ({ defaultValue: "e.g. mysql://user:pass@localhost:3306/db", }); const isMultiDb = isMultiDatabaseCapable(activeDriver?.capabilities); + // Flat single-database store (e.g. Meilisearch): no database to select or name. + const singleDatabase = + activeDriver?.capabilities?.single_database === true; // ── plugin slot: connection-modal.connection_content ── const slotRegistry = usePluginSlotRegistry(); @@ -539,6 +639,10 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setPendingGroup(null); + setInstallStatus("idle"); + setInstallError(undefined); + setStep(initialConnection ? "form" : "catalogue"); setIsK8sPortOverridden(false); if (initialConnection) { @@ -649,9 +753,46 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setInstallStatus("idle"); + setInstallError(undefined); + }; + + const goToForm = (d: CatalogueDriver) => { + handleDriverChange(d.slug); + setPendingGroup(null); + setStep("form"); + // Picking an already-installed external driver from the catalogue is an + // explicit intent to use it — activate it so the form resolves it instead of + // falling back to a built-in. (Not-installed drivers route through the + // InstallGate, which activates on install.) + if (!d.isBuiltin && d.installed) { + void updateSetting( + "activeExternalDrivers", + Array.from(new Set([...(settings.activeExternalDrivers ?? []), d.slug])), + ); + } + }; + + const handleEngineSelect = (group: EngineGroup) => { + const sel = resolveEngineSelection(group); + if (sel.mode === "pick-driver") { + setPendingGroup(group); + } else if (sel.driver) { + // not-installed drivers are routed to the InstallGate by the form-step render; proceed either way + goToForm(sel.driver); + } }; const testConnection = async () => { + if (step === "catalogue") return; + if (installStatus === "installing") return; + if (activeDriverNotInstalled && activeCatalogueDriver) { + const ok = await installDriver( + activeCatalogueDriver.slug, + activeCatalogueDriver.latestVersion, + ); + if (!ok) return; // banner shows the error; do not proceed + } setStatus("testing"); setMessage(""); setTestResult(null); @@ -700,6 +841,15 @@ export const NewConnectionModal = ({ }; const saveConnection = async () => { + if (step === "catalogue") return; + if (installStatus === "installing") return; + if (activeDriverNotInstalled && activeCatalogueDriver) { + const ok = await installDriver( + activeCatalogueDriver.slug, + activeCatalogueDriver.latestVersion, + ); + if (!ok) return; // banner shows the error; do not proceed + } if (!name.trim()) { setStatus("error"); setMessage(t("newConnection.nameRequired")); @@ -719,6 +869,7 @@ export const NewConnectionModal = ({ } } else if ( !noConnectionRequired && + !singleDatabase && (!formData.database || (typeof formData.database === "string" && !formData.database.trim())) ) { @@ -740,7 +891,11 @@ export const NewConnectionModal = ({ ? selectedDatabasesState.length === 1 ? selectedDatabasesState[0] : selectedDatabasesState - : formData.database, + : singleDatabase + ? typeof formData.database === "string" && formData.database.trim() + ? formData.database + : driver + : formData.database, }; const appearancePayload = appearance.icon || appearance.accentColor ? appearance : undefined; @@ -864,13 +1019,27 @@ export const NewConnectionModal = ({ context={dbFieldSlotContext} /> ) : ( -
- -

- {t("newConnection.noGeneralSettings", { - defaultValue: "No general settings available for this driver.", - })} -

+
+ + {renderDriverGlyph(24)} + +
+

+ {t("newConnection.noConnectionDetailsTitle", { + defaultValue: "No connection details needed", + })} +

+

+ {t("newConnection.noConnectionDetailsBody", { + driver: activeDriver?.name ?? driver, + defaultValue: + "{{driver}} connects without a host or port. Just give this connection a name and save it. Driver-specific options live in Settings → Plugins.", + })} +

+
) ) : activeDriver?.capabilities?.file_based === true || @@ -1016,7 +1185,7 @@ export const NewConnectionModal = ({
{/* Database (single) — only shown for non-multi-db drivers */} - {!isMultiDb && ( + {!isMultiDb && !singleDatabase && (