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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ platform/src/views/register.html
plugins/blog/views/blog/editor.html
plugins/papertrail/*
tools/plugin-templates/spa/vite.config.js
tools/plugin-templates/spa/plugin.json
tools/plugin-templates/custom/plugin.json
137 changes: 137 additions & 0 deletions bin/sv.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { createRequire } from "module";

import createPluginDatabaseManager from "../platform/src/services/plugin-database-manager.js";

const require = createRequire(import.meta.url);
const pkg = require("../package.json");

Expand Down Expand Up @@ -44,6 +46,7 @@ if (!aliasLoaded) {
const BUILD_MANIFEST_SCRIPT = resolve(__dirname, "../tools/build-manifest.mjs");
const MANIFEST_PATH = resolve(__dirname, "../manifest.json");
const PLUGINS_DIR = resolve(__dirname, "../plugins");
const DATA_DIR = resolve(__dirname, "../data");
const execFileAsync = promisify(execFile);
const COPY_SKIP_DIRS = new Set([".git", ".hg", ".svn"]);
const COPY_SKIP_FILES = new Set([".DS_Store"]);
Expand Down Expand Up @@ -105,6 +108,7 @@ function printUsage() {
remove <namespace>
show <namespace> [--json]
validate <path>
db <info|ensure> <namespace>

migrate
deploy [--plugin <id>] [--dry-run]
Expand Down Expand Up @@ -518,6 +522,46 @@ function createMigrationStateTemplate() {
return { core: [], plugins: {} };
}

function normalizePluginDatabaseMode(input) {
if (!input) return "shared";
const normalized = String(input).toLowerCase();
if (normalized === "exclusive") return "exclusive-sqlite";
if (normalized === "shared") return "shared";
if (normalized === "exclusive-sqlite") return "exclusive-sqlite";
throw new Error(`Unknown plugin database mode "${input}". Use "shared" or "exclusive".`);
}

function formatDatabaseBlockForTemplate(mode) {
if (mode === "exclusive-sqlite") {
return '{\n "mode": "exclusive-sqlite",\n "provider": "sqlite"\n }';
}
return '{\n "mode": "shared"\n }';
}

function createCliPluginDatabaseManager() {
const sqliteDir = resolve(DATA_DIR, "plugins");
return createPluginDatabaseManager({
sqlite: { baseDir: sqliteDir },
sharedDatasourceUrl: process.env.PLUGIN_DATABASE_URL || process.env.DATABASE_URL || null,
});
}

async function resolveInstalledPlugin(selector) {
const installed = await indexInstalledPlugins();
const match =
installed.byNamespace.get(selector) ||
installed.byId.get(selector) ||
[...installed.byNamespace.values()].find((entry) => entry.id === selector);
if (!match) {
throw new Error(
`Unknown plugin "${selector}". Use a namespace or manifest id (e.g., @org/plugin).`
);
}
const { manifest } = await readPluginManifest(match.dir);
const namespace = manifest.namespace || match.namespace || selector;
return { manifest, namespace, dir: match.dir };
}

async function loadMigrationState() {
try {
const raw = await fs.readFile(MIGRATION_STATE_PATH, "utf8");
Expand Down Expand Up @@ -651,6 +695,7 @@ const commands = {
Examples:
sv plugins add ./src/plugins/blog
sv plugins enable @sovereign/blog
sv plugins db ensure blog
sv plugins list --json
`,

Expand All @@ -668,6 +713,8 @@ const commands = {
const outputJson = Boolean(args.flags.json);
const type = String(args.flags.type || "custom").toLowerCase();
const version = args.flags.version || "0.1.0";
const databaseModeFlag = args.flags.db || args.flags.database || "shared";
const databaseMode = normalizePluginDatabaseMode(databaseModeFlag);
const displayName =
args.flags.name || args.flags["display-name"] || toTitleCase(namespace) || namespace;
const description =
Expand Down Expand Up @@ -713,6 +760,7 @@ const commands = {
DEV_ORIGIN: devOrigin,
PREVIEW_PORT: String(previewPort),
LIB_GLOBAL: libraryGlobal,
PLUGIN_DATABASE_BLOCK: formatDatabaseBlockForTemplate(databaseMode),
};

let templateDir;
Expand All @@ -731,6 +779,7 @@ const commands = {
namespace,
id: pluginId,
type,
databaseMode,
targetDir,
templateDir,
dryRun,
Expand All @@ -746,11 +795,99 @@ const commands = {
console.log(
`Created ${type} plugin "${pluginId}" in ${targetDir} from template ${templateDir}.`
);
console.log(`Database mode: ${databaseMode}`);
if (!skipManifest) {
console.log(`Manifest updated via tools/build-manifest.mjs.`);
} else {
console.log(`--skip-manifest set; run "sv manifest generate" when ready.`);
}
if (databaseMode === "exclusive-sqlite") {
console.log(
`Run "sv plugins db ensure ${namespace}" to provision the dedicated SQLite database.`
);
}
}
},
},

db: {
__help__: `
Usage:
sv plugins db info <namespace> [--json]
sv plugins db ensure <namespace> [--json]
`,
desc: "Inspect or provision a plugin's dedicated database (SQLite-only for now)",
async run(args) {
const action = args._[2];
if (!action || !["info", "ensure"].includes(action)) {
exitUsage(`Usage: sv plugins db <info|ensure> <namespace> [--json]`);
}
const selector = args._[3];
if (!selector) {
exitUsage(
`Missing plugin namespace. Usage: sv plugins db ${action} <namespace> [--json]`
);
}

const { manifest, namespace } = await resolveInstalledPlugin(selector);
const dbConfig = manifest?.sovereign?.database || {};
const mode = dbConfig.mode || "shared";
const manager = createCliPluginDatabaseManager();

if (mode === "shared") {
const note = `Plugin ${namespace} is configured for shared database mode. Declare sovereign.database.mode="exclusive-sqlite" to enable a dedicated file.`;
const payload = {
namespace,
pluginId: manifest.id,
mode,
provider: manager.sharedProvider,
url: manager.sharedDatasourceUrl,
path: null,
message: note,
};
if (args.flags.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(note);
}
return;
}

if (mode !== "exclusive-sqlite") {
throw new Error(
`Plugin ${namespace} requests unsupported database mode "${mode}". Only "exclusive-sqlite" is available right now.`
);
}

const descriptor = await manager.resolveDatasource(manifest, {
namespace,
pluginId: manifest.id,
});

const payload = {
namespace,
pluginId: manifest.id,
mode: descriptor.mode,
provider: descriptor.provider,
url: descriptor.url,
path: descriptor.path || null,
};

if (args.flags.json) {
console.log(JSON.stringify(payload, null, 2));
return;
}

if (action === "ensure") {
console.log(
`[plugins] ${namespace}: ensured SQLite database at ${payload.path || descriptor.url}.`
);
} else {
console.log(`Plugin: ${namespace}`);
console.log(`Mode: ${payload.mode}`);
console.log(`Provider: ${payload.provider}`);
console.log(`URL: ${payload.url}`);
console.log(`Path: ${payload.path || "(n/a)"}`);
}
},
},
Expand Down
51 changes: 35 additions & 16 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,36 @@ sv [global options] <namespace> <command> [args]

| Command | Purpose |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `sv plugins create <namespace>` | Scaffold a new plugin from the built-in templates. |
| `sv plugins create <namespace>` | Scaffold a new plugin from the built-in templates (with optional per-plugin DB config). |
| `sv plugins add <spec>` | Install a plugin from a directory or git URL. |
| `sv plugins list [--json] [--enabled\|--disabled]` | Inspect installed plugins plus their enablement state. |
| `sv plugins enable <namespace>` | Turn on a plugin by clearing `draft`/`devOnly` in its manifest and rebuilding the workspace manifest. |
| `sv plugins disable <namespace>` | Take a plugin offline by forcing `draft`/`devOnly` in its manifest and rebuilding the workspace manifest. |
| `sv plugins remove <namespace>` | Unregister a disabled plugin after safety checks, optionally archiving its files. |
| `sv plugins show <namespace> [--json]` | Inspect plugin manifest details plus enablement status. |
| `sv plugins validate <path>` | Lint a plugin directory for manifest correctness and required files. |
| `sv plugins db <info\|ensure> <namespace>` | Inspect or provision a plugin’s dedicated SQLite database. |

### `sv plugins create <namespace> [--type custom|spa]`

Bootstraps a plugin directory under `plugins/<namespace>` using the curated templates stored in `tools/plugin-templates`. The command rejects duplicate namespaces or manifest ids, copies either the `custom` or `spa` scaffold, replaces placeholders (name, description, ids, dev server port, etc.), and optionally rebuilds `manifest.json`.

**Flags**

| Flag | Effect |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| `--type <custom\|spa>` | Choose which scaffold to use (`custom` by default). |
| `--name "<display name>"` | Human-facing plugin name; defaults to the namespace in Title Case. |
| `--description "<text>"` | Short description embedded into the manifest. |
| `--id <@scope/name>` | Override the generated `plugin.json#id` (`@sovereign/<namespace>` by default). |
| `--version <semver>` | Initial version string (`0.1.0` by default). |
| `--author "<name>"` | Author metadata stored in the manifest. |
| `--license "<identifier>"` | License string stored in the manifest. |
| `--dev-port <port>` | Override the dev server port embedded in SPA manifests (random port between 4100–4299 by default). |
| `--skip-manifest` | Skip running `sv manifest generate` after scaffolding. |
| `--dry-run` | Print what would happen without writing files. |
| `--json` | Emit a JSON summary instead of human-readable text. |
| Flag | Effect |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| `--type <custom\|spa>` | Choose which scaffold to use (`custom` by default). |
| `--name "<display name>"` | Human-facing plugin name; defaults to the namespace in Title Case. |
| `--description "<text>"` | Short description embedded into the manifest. |
| `--id <@scope/name>` | Override the generated `plugin.json#id` (`@sovereign/<namespace>` by default). |
| `--version <semver>` | Initial version string (`0.1.0` by default). |
| `--author "<name>"` | Author metadata stored in the manifest. |
| `--license "<identifier>"` | License string stored in the manifest. |
| `--dev-port <port>` | Override the dev server port embedded in SPA manifests (random port between 4100–4299 by default). |
| `--db <shared\|exclusive>` | Embed the desired `sovereign.database.mode` block (`shared` by default, `exclusive` → SQLite file). |
| `--skip-manifest` | Skip running `sv manifest generate` after scaffolding. |
| `--dry-run` | Print what would happen without writing files. |
| `--json` | Emit a JSON summary instead of human-readable text. |

**Example**

Expand All @@ -58,6 +60,9 @@ sv plugins create acme-support --name "Acme Support Desk"

# Create an SPA plugin using a specific id + dev port
sv plugins create companion --type spa --id @acme/companion --dev-port 4500

# Create an exclusive-database plugin (provisions via SQLite)
sv plugins create papertrail --db exclusive
```

### `sv plugins add <spec>`
Expand Down Expand Up @@ -199,6 +204,17 @@ sv plugins show @sovereign/blog --json

Runs a fast lint over a plugin directory. Validation ensures the directory exists, `plugin.json` passes the same basic schema checks used during `sv plugins add`, and that type-specific files exist (`index.js` for `custom`, `dist/index.js` for `spa`). Failures are printed and the command exits with status `1`. Passing validations print a short success line.

### `sv plugins db <info|ensure> <namespace>`

Utility commands for the per-plugin database manager. They only support SQLite-backed exclusives right now.

| Subcommand | Effect |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `info <namespace>` | Prints the current datasource descriptor (mode, provider, DSN/path). Shared-mode plugins emit a friendly note. |
| `ensure <namespace>` | Creates or reuses the plugin’s dedicated SQLite file under `data/plugins/<id>.db` and prints the path. |

Both subcommands accept `--json` for script-friendly output. If the plugin manifest still declares `sovereign.database.mode = "shared"`, the commands exit early with guidance on switching to `exclusive-sqlite`.

**Flags**

| Flag | Effect |
Expand All @@ -208,8 +224,11 @@ Runs a fast lint over a plugin directory. Validation ensures the directory exist
**Example**

```
sv plugins validate ./plugins/blog
sv plugins validate ../my-plugin --json
# Inspect the datasource descriptor
sv plugins db info papertrail

# Provision the SQLite file and print JSON diagnostics
sv plugins db ensure papertrail --json
```

## Manifest Commands
Expand Down
18 changes: 9 additions & 9 deletions docs/plugins/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ Each plugin manifest defines host access under `sovereign.platformCapabilities`.

The manifest builder resolves these flags into `plugins.<namespace>.sovereign.platformCapabilitiesResolved` inside `manifest.json`, so ops and UI surfaces can display the exact set (e.g., `["database","git"]`). Only the following allow‑listed capabilities are currently accepted:

| Capability | Injected Service | Notes |
| ------------ | ---------------------------- | ------------------------------- |
| `database` | Prisma client (`ctx.prisma`) | Full DB access |
| `git` | Git manager helpers | Used by blog/papertrail |
| `fs` | File-system adapter | Scoped to plugin storage |
| `env` | `refreshEnvCache` | Refreshes env config |
| `uuid` | `uuid()` helper | Deterministic IDs |
| `mailer` | Transactional mailer | Sends email |
| `fileUpload` | Upload helper (experimental) | Disabled in prod until hardened |
| Capability | Injected Service | Notes |
| ------------ | ---------------------------- | ------------------------------------------------------------------------------------- |
| `database` | Prisma client (`ctx.prisma`) | Shared DB by default; `exclusive-sqlite` plugins receive their isolated Prisma client |
| `git` | Git manager helpers | Used by blog/papertrail |
| `fs` | File-system adapter | Scoped to plugin storage |
| `env` | `refreshEnvCache` | Refreshes env config |
| `uuid` | `uuid()` helper | Deterministic IDs |
| `mailer` | Transactional mailer | Sends email |
| `fileUpload` | Upload helper (experimental) | Disabled in prod until hardened |

Requests for unknown caps, or caps disabled in production, cause bootstrap errors. During development you may temporarily bypass declarations by exporting `DEV_ALLOW_ALL_CAPS=true`, but this is noisy and should never reach production.

Expand Down
Loading