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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-system-proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@offckb/cli': minor
---

Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Include a context probe and a pinned, repository-only Devnet Explorer adapter experiment; registry installation and background services remain deferred.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ coverage/

# Sisyphus plans and sessions
.sisyphus/

# Local upstream sources for the experimental Explorer adapter
.prototype/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,10 @@ sudo chown -R $(whoami) /usr/local/lib/node_modules
npm install -g @offckb/cli
```

## Experimental local plugins

Local tool packages can be explicitly linked with `offckb plugin link <directory>`, inspected with `offckb plugin list`, and run as `offckb <plugin-command>`. The host supplies devnet context and owns foreground cleanup. See the [plugin guide](docs/plugins.md) for the context probe, experimental Devnet Explorer adapter, and current limits. Registry installation and background plugins are not implemented yet.

## Contributing

check [development doc](/docs/develop.md)
206 changes: 206 additions & 0 deletions docs/plugin-system-proposal.md

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Experimental local tool plugins

This is the first implementation stage of the [plugin proposal](plugin-system-proposal.md): explicit local registration, a versioned environment snapshot, and foreground process ownership. Registry downloads, `plugin add`, background plugins, `node --with`, and Fiber migration are deferred.

## Try the protocol

Build the CLI in this checkout, then explicitly link a local package you trust:

```sh
pnpm build
node build/index.js plugin link ./examples/plugins/context-probe
node build/index.js --json plugin list
node build/index.js --json context-probe -- --json "path with spaces" -- literal
node build/index.js context-probe --help
node build/index.js plugin unlink context-probe
```

The probe demonstrates exact argument forwarding and one JSON result. An uninitialized devnet is represented as `unknown`; the probe still works. Plugin code executes with your user permissions. Linking reads static metadata and never runs an install script or plugin module.

`link` stores a canonical package path. It does not copy or install dependencies. The package author/user prepares its runtime first. `unlink` removes the registration and keeps the package, data and cache. A foreground lease prevents concurrent runs of the same plugin and unlink/relink while it is running.

Registrations default to `<offckb data directory>/plugins`. `OFFCKB_PLUGIN_HOME` selects an isolated registry/data/cache directory, useful for development. It does not change core settings or select another devnet. Discovery probes `registry.json` even for core commands; an absent registry is not created. A broken registry does not block core commands or core help; `plugin list` reports the registry error without overwriting it. Invalid and incompatible packages remain removable. Future core command collisions are reported as `shadowed`, with the core command taking priority.

## Package declaration

```json
{
"name": "my-offckb-tool",
"version": "0.1.0",
"engines": { "node": ">=20" },
"offckb": {
"apiVersion": 1,
"command": "my-tool",
"entry": "dist/cli.mjs",
"description": "A local development tool"
}
}
```

The entry must be a regular `.js`, `.cjs` or `.mjs` file whose real path stays inside the package. TypeScript compilation belongs to the package. Dependencies resolve from its own directory. The host launches the entry with its current Node executable, using argv arrays and inherited stdio/cwd; it does not import the tool into the ncc bundle.

Commands contain lowercase letters, digits and hyphens, starting with a letter (maximum 48 characters). Core names, `help`, and already-linked command names are reserved. Node engines and protocol version are checked before execution.

## Context and output

On execution, `OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file:

- `apiVersion: 1`, `offckbVersion`, `cwd`, and `output: "json" | "text"`.
- `cli.node` and `cli.entry`: the current Node/CLI paths, not a PATH lookup.
- `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: persistent, plugin-specific storage.
- `devnet.rpcUrl`, `proxyUrl`, `kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`.
- Optional `devnet.diagnostic: { code: "LIST_HASHES_FAILED", message: string }` when local metadata export fails. Consumers must tolerate additive fields in API version 1.

The scripts file contains a `devnet` record using the existing `system-scripts` entry shape. It and the context are removed after the child closes, including failures and handled signals. Data/cache remain. No account keys or complete settings object are copied. RPC URLs can contain credentials; plugins must avoid dumping the context or raw URLs to logs.

Chain metadata comes from the configured managed CKB binary's local `list-hashes` output, with a three-second limit and no shell interpolation. A missing config/binary yields `kind: "unknown"`, null chain identity and no scripts file. Timeout, nonzero exit or invalid output yields the same unknown identity plus the diagnostic above; raw subprocess errors/stderr are excluded. Chain-independent plugins can continue. Invalid core settings or inability to write the context still fails the invocation. A custom fork marker without a recognized public genesis also yields `unknown`. Plugins needing a known chain must reject these cases, and must verify the RPC genesis against the context before interpreting addresses/scripts. A custom `node --binary-path` with no matching managed binary is not yet supported by the context exporter.

Standalone `offckb my-tool --help` / `-h` is delegated without exporting context or reading core settings. Tools should handle that request before reading `OFFCKB_PLUGIN_CONTEXT`. With additional arguments, normal context preparation still runs; the host does not guess whether a help-looking token is an option value or literal plugin argument.

Core `--json` must precede the plugin command:

```sh
offckb --json my-tool --project .
offckb my-tool --json
offckb --json my-tool -- --json -- literal
```

The first invocation selects core JSON mode. In the second, `--json` is a plugin argument and core JSON mode stays off. The host consumes only an immediately following `--` separator; all remaining tokens, including another `--`, are preserved. Existing core commands retain their flag normalization.

After delegation, the child owns stdout, including its single JSON success result. The host never appends its fallback result; it propagates child exit status. Diagnostics and structured failures belong on stderr. This preserves the host's stream contract but does not validate or sandbox a misbehaving plugin's output.

## Explorer experiment

The example adapter uses `wildanrhmn/ckb-devnet-explorer` at commit `477af4400a716353d140fc1a5b8fee8175f74fed` (0.2.0), verified unchanged on 2026-10-29. It is **an unpublished, repository-only experiment**, using pinned private source imports. Formal upstream entry points and a release maintainer are still needed before distribution.

The [formal-distribution gate](plugin-system-proposal.md#explorer-正式分发门禁) requires either released upstream APIs covered by semver or exclusive use of its public CLI with a pinned version and contract tests. A formal plugin PR retaining source replacements, private imports, tsx export-map inspection or the **PROTOTYPE-ONLY** `OFFCKB_EXPLORER_SOURCE` override must not merge. A successful local experiment does not clear this gate.

Prepare a source checkout at that revision:

```sh
git clone https://github.com/wildanrhmn/ckb-devnet-explorer.git /tmp/ckb-devnet-explorer
git -C /tmp/ckb-devnet-explorer checkout 477af4400a716353d140fc1a5b8fee8175f74fed
node scripts/prepare-explorer-prototype.mjs /tmp/ckb-devnet-explorer
npm install --prefix .prototype/explorer --omit=dev --ignore-scripts

node build/index.js plugin link ./examples/plugins/explorer
node build/index.js node --daemon
node build/index.js explorer --project /path/to/deployed-project --port 7070
```

The preparation script copies source into ignored `.prototype/explorer`; it does not edit the checkout supplied by the user. The local copy adds an explicit server host and moves the upstream startup message to stderr. The adapter reads system scripts from the host snapshot, merges `deployment/scripts.json` from selected projects, and never invokes `offckb` from PATH. It loads tsx's declared ESM entry, matching upstream's ESM loader.

Node 22+ and an initialized ordinary devnet are required. Forks and unknown chain identities are rejected before listening. RPC genesis must match the supplied scripts context; missing/lagging built-in indexer is reported separately. The web server binds `127.0.0.1` and defaults to 7070; `--port 0` selects a free port. `--project` is repeatable and defaults to invocation cwd. There is no arbitrary `--rpc` override or automatic browser launch.

Ctrl+C stops the Explorer command, while a separately running CKB daemon continues. Restart Explorer after redeployment, chain reset or chain switching so its script names and caches reload. Upstream acceptance of new APIs is not required for this local experiment, but the experiment is not a promise to maintain a fork.

## Validation and remaining limits

```sh
pnpm test --runInBand
pnpm typecheck
pnpm lint
pnpm build
# Optional: prepared Explorer runtime and an official CKB 0.208.0 binary are required.
node scripts/plugin-smoke.mjs /path/to/ckb
```

The opt-in smoke test uses the production ncc bundle, random local ports, temporary settings and a copied devnet. It checks exact argv, single JSON result, offline help, continued probe execution after a real `list-hashes` failure, the real Explorer page/API, current RPC/project context, refusal to unlink a running plugin, signal cleanup, CKB remaining alive, chain reset/restart and rejection of an unknown fork marker. It redirects `os.homedir()` only inside its test workers before loading the bundle; it does not change HOME or user settings. Downloaded binaries, source and runtime dependencies are not shipped.

The second-tool paper review uses existing `src/cmd/status.ts`: ckb-tui would consume `proxyUrl`, retain TTY checks and forward native `-r`/optional `-t` arguments from a Node adapter. The current context lacks the optional TCP log address, so the minimal adapter could use RPC-only mode; adding that address or requiring full log panels needs a later explicit protocol decision. Native TTY/job-control behavior still requires testing. Existing `status` stays in core.

POSIX plugin processes use an invocation-owned group. SIGINT/SIGTERM/SIGHUP are forwarded; a three-second shutdown limit and final group cleanup prevent owned descendants from remaining after the tool exits. The host never signals a saved PID. Windows immediately uses `taskkill /T /F` without a graceful period. The signal test skips Windows; passing its ordinary spawn tests does not verify this branch. Native Windows interrupt, process-tree cleanup and exit-status validation remain open for Phase 1. Plugins must not detach their own unmanaged daemons.

SIGKILL, host crashes or power loss can leave `registry.lock`, `<command>.running`, or temporary context directories. This stage fails closed on those locks: verify the owner/tool has stopped before removing the named stale file, then retry. Automatic crash recovery, full runtime identity management, concurrent instances and background services are deferred. Registry locks are short-lived and registration writes use atomic rename; tool source itself remains mutable because this is explicit local linking.
19 changes: 19 additions & 0 deletions examples/plugins/context-probe/cli.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const fs = require('node:fs');

if (process.argv.slice(2).includes('--help')) {
console.log('Usage: offckb context-probe [arguments...]\nPrints the invocation context and unmodified arguments.');
} else {
const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8'));
// Do not dump the whole context: RPC URLs can contain credentials.
const result = {
ok: true,
command: 'context-probe',
argv: process.argv.slice(2),
cwd: context.cwd,
output: context.output,
kind: context.devnet.kind,
diagnosticCode: context.devnet.diagnostic?.code,
cliEntry: context.cli.entry,
};
console.log(JSON.stringify(result));
}
11 changes: 11 additions & 0 deletions examples/plugins/context-probe/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "offckb-context-probe",
"version": "0.1.0",
"private": true,
"offckb": {
"apiVersion": 1,
"command": "context-probe",
"entry": "cli.cjs",
"description": "Inspect plugin context and argument forwarding (development example)"
}
}
135 changes: 135 additions & 0 deletions examples/plugins/explorer/cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';

const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: offckb explorer [--project <directory>]... [--port <0-65535>]\nExperimental adapter: Node 22+, ordinary initialized devnet, prepared upstream source.',
);
process.exit(0);
}

let json = false;
try {
const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8'));
json = context.output === 'json';
if (context.apiVersion !== 1) throw new Error('Unsupported offckb context version');
if (
context.devnet.kind !== 'pure-devnet' ||
context.devnet.addressPrefix !== 'ckt' ||
!context.devnet.systemScriptsFile
) {
throw new Error(
'Explorer currently requires an initialized ordinary devnet; forks and unknown chains are unsupported.',
);
}
const { values } = parseArgs({
args,
options: {
project: { type: 'string', multiple: true },
port: { type: 'string', default: '7070' },
},
});
const port = Number(values.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port must be an integer from 0 to 65535');
const projects = (values.project ?? [context.cwd]).map((directory) => path.resolve(context.cwd, directory));
// PROTOTYPE-ONLY: remove this override together with private source imports before formal distribution.
const source = path.resolve(
process.env.OFFCKB_EXPLORER_SOURCE || fileURLToPath(new URL('../../../.prototype/explorer', import.meta.url)),
);
const marker = path.join(source, '.offckb-prototype.json');
if (!fs.statSync(marker).isFile() || JSON.parse(fs.readFileSync(marker, 'utf8')).apiVersion !== 1) {
throw new Error('Prepare the pinned Explorer source first; see docs/plugins.md');
}
const require = createRequire(path.join(source, 'package.json'));
const tsxManifest = require.resolve('tsx/package.json');
const tsx = JSON.parse(fs.readFileSync(tsxManifest, 'utf8'));
const esmApi = tsx.exports['./esm/api'].import.default;
const { register } = await import(pathToFileURL(path.resolve(path.dirname(tsxManifest), esmApi)).href);
register();
// Deliberately pinned private imports for this experiment, not a published compatibility contract.
const [{ startServer }, { Explorer }, { Resolver }, { loadProjectScripts }] = await Promise.all(
['server', 'explorer', 'resolver', 'scripts'].map(
(name) => import(pathToFileURL(path.join(source, 'src', `${name}.ts`)).href),
),
);
const exported = JSON.parse(fs.readFileSync(context.devnet.systemScriptsFile, 'utf8'));
const known = new Map();
for (const [name, entry] of Object.entries(exported.devnet)) {
const script = entry?.script ?? entry;
if (!script?.codeHash || !script.hashType) continue;
const dep = script.cellDeps?.[0]?.cellDep;
known.set(`${script.codeHash.toLowerCase()}:${script.hashType}`, {
name,
source: 'genesis',
codeHash: script.codeHash.toLowerCase(),
hashType: script.hashType,
location: dep ? { txHash: dep.outPoint.txHash, index: Number(dep.outPoint.index), depType: dep.depType } : null,
});
}
for (const project of projects) for (const [key, script] of loadProjectScripts(project)) known.set(key, script);
const rpc = async (method, params = []) => {
const response = await fetch(context.devnet.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error('Devnet RPC request failed');
const result = await response.json();
if (result.error) throw new Error(`Devnet RPC ${method} is unavailable`);
return result.result;
};
if ((await rpc('get_block_hash', ['0x0'])) !== context.devnet.genesisHash) {
throw new Error('Configured RPC genesis does not match the offckb devnet context.');
}
const tip = BigInt(await rpc('get_tip_block_number'));
let indexerReady = false;
try {
indexerReady = BigInt((await rpc('get_indexer_tip')).block_number) >= tip;
} catch {
/* Report separately. */
}
if (!indexerReady)
console.error('CKB indexer is missing or behind; some Explorer queries may fail or be incomplete.');
const server = startServer(
new Explorer(context.devnet.rpcUrl, new Resolver(known)),
port,
context.devnet.rpcUrl,
projects,
'127.0.0.1',
);
server.once('listening', () => {
const url = `http://127.0.0.1:${server.address().port}`;
if (json) console.log(JSON.stringify({ ok: true, command: 'explorer', url, indexerReady }));
else console.error(`Explorer: ${url}`);
});
server.on('error', (error) => {
console.error(
json
? JSON.stringify({ ok: false, code: 'EXPLORER_SERVER_FAILED', message: error.code || 'Server failed' })
: `Explorer server failed: ${error.code || 'unknown error'}`,
);
process.exitCode = 1;
});
for (const [signal, code] of [
['SIGINT', 130],
['SIGTERM', 143],
['SIGHUP', 129],
]) {
process.once(signal, () => {
server.close(() => {
process.exitCode = code;
});
server.closeAllConnections();
});
}
} catch (error) {
const message =
error.code === 'ENOENT' ? 'Prepared Explorer source or context is missing; see docs/plugins.md' : error.message;
console.error(json ? JSON.stringify({ ok: false, code: 'EXPLORER_START_FAILED', message }) : message);
process.exitCode = 1;
}
15 changes: 15 additions & 0 deletions examples/plugins/explorer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "offckb-explorer-prototype",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"offckb": {
"apiVersion": 1,
"command": "explorer",
"entry": "cli.mjs",
"description": "Browse the current ordinary devnet (experimental upstream adapter)"
}
}
Loading
Loading