diff --git a/README.md b/README.md index 40276e8..f81aaab 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This first version intentionally stays small: - public manifest types for `idea-plugin.json`; - public runtime types for plugin modules exposing `activate(ctx)`; +- plugin-owned persistent storage through `ctx.storage`; - a stable `ctx.services` facade for workspace, background task and terminal operations; - public workspace file APIs for reading, writing, listing, stat and path resolution; - a bounded generic project-structure query API; @@ -47,7 +48,8 @@ examples/hello-plugin/build/hello-plugin-0.1.0.zip ``` Its ZIP root contains `idea-plugin.json` directly, with no wrapping parent directory. The -compiled ESM entrypoint is emitted at `dist/index.js`, matching the manifest `main` field. +compiled ESM entrypoint is emitted at `dist/index.js`, matching the manifest `main` field, +alongside the other compiled files imported by that entrypoint. ## Plugin shape @@ -66,6 +68,25 @@ TypeScript. } ``` +The `main` field is the ESM entrypoint loaded by IdeA. It may import other +JavaScript files from the same plugin package with relative specifiers: + +```js +import { COMMAND_ID } from "./constants.js"; +import { useWorkspaceSdk } from "./core/workspace.js"; +``` + +Those relative imports are served by IdeA through the `idea-plugin://` protocol, +so plugins do not need to be bundled into a single JavaScript file. A plain +`tsc` build that emits multiple ESM files under `dist/` is supported, as shown +by `examples/hello-plugin`. + +Only package-relative imports are resolved this way. Bare specifiers such as +`react`, `lodash` or any dependency expected from `node_modules` are not +resolved by the host at runtime. Third-party dependencies must be bundled into +the plugin output or vendored as relative files shipped inside the plugin +package. + The entrypoint exports an `activate(ctx)` function: ```ts @@ -129,6 +150,32 @@ user actions, effects or asynchronous callbacks, not unconditionally while rendering. Services are available from `ctx.services` to plugins declaring the `tooling` capability; layout props do not expose private runtime gateways. +## Plugin-Owned Storage + +Use `ctx.storage` for state owned by the plugin itself: internal counters, flags, +preferences, small caches and host-facing settings. This is the canonical place +for plugin-owned state because the host can scope and persist it outside the +user's project files. + +```ts +import type { ActivateContext } from "@idea/plugin-sdk"; + +const ACTIVATION_COUNT_KEY = "helloPlugin.activationCount"; + +export async function activate(ctx: ActivateContext): Promise { + const current = await ctx.storage?.get(ACTIVATION_COUNT_KEY); + const next = typeof current === "number" ? current + 1 : 1; + await ctx.storage?.set(ACTIVATION_COUNT_KEY, next); + + ctx.logger.info("activation count", { next }); +} +``` + +Do not write plugin-internal state into `.ideai/*` or other project files by +default. Use workspace files and config-document helpers only when the file is +project-owned content or project-owned configuration that the user expects to +see, review and version with the project. + ## Runtime Services Plugins declaring the `tooling` capability receive `ctx.services`. Plugins @@ -155,7 +202,9 @@ export async function activate(ctx: ActivateContext): Promise { Workspace paths are always relative to the project root. Hosts reject absolute paths, `..`, empty path segments and paths outside the sandbox. Text APIs use UTF-8; binary APIs use `Uint8Array`. Missing files reject on reads and resolve -to `{ exists: false }` from `stat`. +to `{ exists: false }` from `stat`. These APIs are for project-owned files: +source files, generated reports or user-visible artifacts. Use `ctx.storage` +instead for plugin-owned counters, flags, preferences and caches. ```ts import type { ActivateContext } from "@idea/plugin-sdk"; @@ -165,10 +214,10 @@ export async function activate(ctx: ActivateContext): Promise { const project = await workspace?.getCurrentProject(); if (!workspace || !project) return; - await workspace.writeTextFile(".ideai/hello-plugin.txt", "hello\n", project.id); + await workspace.writeTextFile("hello-plugin-report.txt", "hello\n", project.id); - const file = await workspace.readTextFile(".ideai/hello-plugin.txt", project.id); - const listing = await workspace.listDirectory(".ideai", project.id); + const file = await workspace.readTextFile("hello-plugin-report.txt", project.id); + const listing = await workspace.listDirectory(".", project.id); const stat = await workspace.stat(file.path, project.id); ctx.logger.info("workspace file", { @@ -327,7 +376,9 @@ subscription up to the requested/host-capped capacity, drained oldest-first, and ### Structured Config Documents Use `ctx.services.config` when a plugin needs to read or update a structured -configuration file without reimplementing parsing and serialization. +project-owned configuration file without reimplementing parsing and +serialization. Do not use project config documents as the default persistence +mechanism for plugin-internal state; use `ctx.storage` for that. First-lot format support is deliberately narrow: @@ -343,11 +394,11 @@ import type { ActivateContext } from "@idea/plugin-sdk"; export async function activate(ctx: ActivateContext): Promise { const config = await ctx.services?.config.readDocument({ - path: ".ideai/hello-plugin.json" + path: "tooling.config.json" }); await ctx.services?.config.updateDocument({ - path: ".ideai/hello-plugin.json", + path: "tooling.config.json", mode: "mergePatch", value: { enabled: true, diff --git a/examples/hello-plugin/README.md b/examples/hello-plugin/README.md index 0b53e1a..ca12ee0 100644 --- a/examples/hello-plugin/README.md +++ b/examples/hello-plugin/README.md @@ -8,22 +8,37 @@ It exercises the current plugin primitives end to end: - menu entry: `hello-plugin`; - command: `hello-plugin`, returning `hello-world`; - layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`. +- plugin-owned storage: activation count, command run count and initialization flag; - tooling capability: logs the focused workspace project when `ctx.services` is available. +The source is intentionally split across multiple TypeScript modules: + +- `src/index.ts` is the manifest entrypoint and imports relative ESM modules; +- `src/constants.ts` owns shared command/layout identifiers; +- `src/core/layout.ts` and `src/core/workspace.ts` hold feature logic. +- `src/core/storage.ts` keeps plugin-owned counters and flags in `ctx.storage`. + +The build uses plain `tsc`; it does not bundle the plugin into one file. The archive includes all +compiled `dist/**/*.js` files so IdeA can load `dist/index.js` and serve its package-relative imports +through `idea-plugin://`. Runtime imports from `node_modules` are outside this contract: vendor them +as relative files or bundle them into the plugin output before packaging. + ```sh npm run typecheck:examples npm run package:hello-plugin ``` The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`. -It contains `idea-plugin.json` at the ZIP root and the compiled ESM entrypoint at -`dist/index.js`, matching the manifest `main` field. +It contains `idea-plugin.json` at the ZIP root and the compiled multi-file ESM output under `dist/`, +including `dist/index.js`, matching the manifest `main` field. ## Diagnostics During activation the plugin logs: - whether the command and layout runtime registries are available; +- whether plugin-owned storage is available; +- activation count and initialization state stored through `ctx.storage`; - successful registration of the `hello-plugin` command; - successful registration of the `hello-plugin.hello-world` layout; - availability of the workspace service from the `tooling` runtime capability; diff --git a/examples/hello-plugin/src/constants.ts b/examples/hello-plugin/src/constants.ts new file mode 100644 index 0000000..74096a4 --- /dev/null +++ b/examples/hello-plugin/src/constants.ts @@ -0,0 +1,8 @@ +export const COMMAND_ID = "hello-plugin"; +export const LAYOUT_TYPE = "hello-plugin.hello-world"; +export const STORAGE_KEYS = { + activationCount: "helloPlugin.activationCount", + commandRunCount: "helloPlugin.commandRunCount", + initialized: "helloPlugin.initialized", + ownerAgentId: "helloPlugin.ownerAgentId" +} as const; diff --git a/examples/hello-plugin/src/core/layout.ts b/examples/hello-plugin/src/core/layout.ts new file mode 100644 index 0000000..08fb697 --- /dev/null +++ b/examples/hello-plugin/src/core/layout.ts @@ -0,0 +1,17 @@ +import type { PluginLayoutProps } from "@idea/plugin-sdk"; + +let hasLoggedFirstLayoutRender = false; + +export function HelloWorldLayout(props: PluginLayoutProps): string { + if (!hasLoggedFirstLayoutRender) { + hasLoggedFirstLayoutRender = true; + console.info("[hello-plugin] layout first render", { + projectId: props.projectId, + nodeId: props.nodeId, + layoutType: props.layoutType, + hasState: props.state !== undefined + }); + } + + return "hello-world"; +} diff --git a/examples/hello-plugin/src/core/storage.ts b/examples/hello-plugin/src/core/storage.ts new file mode 100644 index 0000000..cf50f09 --- /dev/null +++ b/examples/hello-plugin/src/core/storage.ts @@ -0,0 +1,32 @@ +import type { ActivateContext } from "@idea/plugin-sdk"; +import { STORAGE_KEYS } from "../constants.js"; + +export async function initializePluginStorage(ctx: ActivateContext): Promise { + if (!ctx.storage) { + ctx.logger.warn("plugin-owned storage unavailable"); + return; + } + + const activationCount = await incrementStoredNumber(ctx, STORAGE_KEYS.activationCount); + const initialized = await ctx.storage.get(STORAGE_KEYS.initialized); + if (!initialized) { + await ctx.storage.set(STORAGE_KEYS.initialized, true); + } + + ctx.logger.info("plugin-owned storage ready", { + activationCount, + initialized: initialized ?? false + }); +} + +export async function recordCommandRun(ctx: ActivateContext): Promise { + if (!ctx.storage) return undefined; + return incrementStoredNumber(ctx, STORAGE_KEYS.commandRunCount); +} + +async function incrementStoredNumber(ctx: ActivateContext, key: string): Promise { + const current = await ctx.storage?.get(key); + const next = typeof current === "number" && Number.isFinite(current) ? current + 1 : 1; + await ctx.storage?.set(key, next); + return next; +} diff --git a/examples/hello-plugin/src/core/workspace.ts b/examples/hello-plugin/src/core/workspace.ts new file mode 100644 index 0000000..937532e --- /dev/null +++ b/examples/hello-plugin/src/core/workspace.ts @@ -0,0 +1,98 @@ +import type { ActivateContext } from "@idea/plugin-sdk"; +import { STORAGE_KEYS } from "../constants.js"; + +export async function useWorkspaceSdk(ctx: ActivateContext): Promise { + const workspace = ctx.services?.workspace; + if (!workspace) return; + + const project = await workspace.getCurrentProject(); + if (!project) { + ctx.logger.info("workspace service available without a focused project"); + return; + } + + const listing = await workspace.listDirectory(".ideai", project.id); + const structure = await workspace.queryStructure({ + projectId: project.id, + maxDepth: 2, + maxEntries: 100 + }); + + ctx.logger.info("workspace project inspection complete", { + projectId: project.id, + ideaiEntries: listing.entries.length, + conventions: structure.conventions.map((convention) => convention.id) + }); + + const diagnostic = await ctx.services?.tooling.diagnose({ + projectId: project.id, + tools: [ + { + id: "echo", + executable: "echo", + versionArgs: ["hello-plugin-toolcheck"], + required: true + } + ], + env: [{ name: "PATH", required: true }], + files: [{ path: "idea-plugin.json", kind: "file" }] + }); + + ctx.logger.info("tooling diagnostic complete", { + ok: diagnostic?.ok, + echoVersion: diagnostic?.tools.find((tool) => tool.id === "echo")?.version, + messages: diagnostic?.messages + }); + + const watch = await workspace.watch(".ideai", (event) => { + ctx.logger.info("workspace watch event", { + path: event.path, + kind: event.kind, + operation: event.operation + }); + }, project.id); + ctx.subscriptions.push(watch); + + const events = await ctx.services?.events.subscribe( + { + projectId: project.id, + eventTypes: ["backgroundTaskChanged"], + pollIntervalMs: 2000, + onDropped: (count) => ctx.logger.warn("plugin events dropped", { count }) + }, + (event) => { + if (event.type === "backgroundTaskChanged") { + ctx.logger.info("background task changed", { + taskId: event.taskId, + state: event.state + }); + } + } + ); + if (events) ctx.subscriptions.push(events); + + const ownerAgentId = await ctx.storage?.get(STORAGE_KEYS.ownerAgentId); + if (!ownerAgentId) { + ctx.logger.info("command task example skipped: no owner agent configured"); + return; + } + + const task = await ctx.services?.tasks.runCommand({ + projectId: project.id, + ownerAgentId, + label: "Hello plugin command", + command: "echo", + args: ["hello from @idea/plugin-sdk"], + cwd: ".", + recordOnly: true + }); + + if (task) { + const status = await ctx.services?.tasks.getCommandStatus(task.taskId); + ctx.logger.info("command task launched", { + taskId: task.taskId, + state: status?.state ?? task.state, + exitCode: status?.exitCode ?? task.exitCode + }); + } +} diff --git a/examples/hello-plugin/src/index.ts b/examples/hello-plugin/src/index.ts index e9ab78f..210bbad 100644 --- a/examples/hello-plugin/src/index.ts +++ b/examples/hello-plugin/src/index.ts @@ -1,33 +1,20 @@ -import type { ActivateContext, IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk"; - -const COMMAND_ID = "hello-plugin"; -const LAYOUT_TYPE = "hello-plugin.hello-world"; - -let hasLoggedFirstLayoutRender = false; - -function HelloWorldLayout(props: PluginLayoutProps): string { - if (!hasLoggedFirstLayoutRender) { - hasLoggedFirstLayoutRender = true; - console.info("[hello-plugin] layout first render", { - projectId: props.projectId, - nodeId: props.nodeId, - layoutType: props.layoutType, - hasState: props.state !== undefined - }); - } - - return "hello-world"; -} +import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk"; +import { COMMAND_ID, LAYOUT_TYPE } from "./constants.js"; +import { HelloWorldLayout } from "./core/layout.js"; +import { initializePluginStorage, recordCommandRun } from "./core/storage.js"; +import { useWorkspaceSdk } from "./core/workspace.js"; export function activate(ctx: ActivateContext): void { ctx.logger.info("activating hello-plugin", { pluginId: ctx.pluginId, hasCommands: Boolean(ctx.commands), - hasLayouts: Boolean(ctx.layouts) + hasLayouts: Boolean(ctx.layouts), + hasStorage: Boolean(ctx.storage) }); - const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, () => { - ctx.logger.info("command executed", { commandId: COMMAND_ID }); + const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => { + const commandRunCount = await recordCommandRun(ctx); + ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount }); return "hello-world"; }); @@ -53,132 +40,10 @@ export function activate(ctx: ActivateContext): void { ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); } + void initializePluginStorage(ctx); void useWorkspaceSdk(ctx); } -async function useWorkspaceSdk(ctx: ActivateContext): Promise { - const workspace = ctx.services?.workspace; - if (!workspace) return; - - const project = await workspace.getCurrentProject(); - if (!project) { - ctx.logger.info("workspace service available without a focused project"); - return; - } - - const fixturePath = ".ideai/hello-plugin.txt"; - await workspace.writeTextFile(fixturePath, "hello from @idea/plugin-sdk\n", project.id); - const file = await workspace.readTextFile(fixturePath, project.id); - const stat = await workspace.stat(fixturePath, project.id); - const listing = await workspace.listDirectory(".ideai", project.id); - const structure = await workspace.queryStructure({ - projectId: project.id, - maxDepth: 2, - maxEntries: 100 - }); - - ctx.logger.info("workspace file round-trip complete", { - projectId: project.id, - path: file.path, - bytes: stat.len, - ideaiEntries: listing.entries.length, - conventions: structure.conventions.map((convention) => convention.id) - }); - - const diagnostic = await ctx.services?.tooling.diagnose({ - projectId: project.id, - tools: [ - { - id: "echo", - executable: "echo", - versionArgs: ["hello-plugin-toolcheck"], - required: true - } - ], - env: [{ name: "PATH", required: true }], - files: [{ path: fixturePath, kind: "file" }] - }); - - ctx.logger.info("tooling diagnostic complete", { - ok: diagnostic?.ok, - echoVersion: diagnostic?.tools.find((tool) => tool.id === "echo")?.version, - messages: diagnostic?.messages - }); - - const configPath = ".ideai/hello-plugin.json"; - await workspace.writeTextFile( - configPath, - JSON.stringify({ enabled: true, launches: 0 }, null, 2) + "\n", - project.id - ); - const configDocument = await ctx.services?.config.readDocument({ - projectId: project.id, - path: configPath - }); - await ctx.services?.config.updateDocument({ - projectId: project.id, - path: configPath, - mode: "mergePatch", - value: { lastFormat: configDocument?.format ?? "json", launches: 1 } - }); - ctx.logger.info("config document updated", { - path: configDocument?.path, - format: configDocument?.format - }); - - const watch = await workspace.watch(".ideai", (event) => { - ctx.logger.info("workspace watch event", { - path: event.path, - kind: event.kind, - operation: event.operation - }); - }, project.id); - ctx.subscriptions.push(watch); - - const events = await ctx.services?.events.subscribe( - { - projectId: project.id, - eventTypes: ["backgroundTaskChanged"], - pollIntervalMs: 2000, - onDropped: (count) => ctx.logger.warn("plugin events dropped", { count }) - }, - (event) => { - if (event.type === "backgroundTaskChanged") { - ctx.logger.info("background task changed", { - taskId: event.taskId, - state: event.state - }); - } - } - ); - if (events) ctx.subscriptions.push(events); - - const ownerAgentId = await ctx.storage?.get("helloPlugin.ownerAgentId"); - if (!ownerAgentId) { - ctx.logger.info("command task example skipped: no owner agent configured"); - return; - } - - const task = await ctx.services?.tasks.runCommand({ - projectId: project.id, - ownerAgentId, - label: "Hello plugin command", - command: "echo", - args: ["hello from @idea/plugin-sdk"], - cwd: ".", - recordOnly: true - }); - - if (task) { - const status = await ctx.services?.tasks.getCommandStatus(task.taskId); - ctx.logger.info("command task launched", { - taskId: task.taskId, - state: status?.state ?? task.state, - exitCode: status?.exitCode ?? task.exitCode - }); - } -} - const plugin: IdeAPluginModule = { activate }; diff --git a/scripts/package-hello-plugin.mjs b/scripts/package-hello-plugin.mjs index 3c8de90..f18b08e 100644 --- a/scripts/package-hello-plugin.mjs +++ b/scripts/package-hello-plugin.mjs @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; const pluginRoot = join(process.cwd(), "examples", "hello-plugin"); @@ -8,9 +8,13 @@ const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const main = requireString(manifest, "main"); const version = requireString(manifest, "version"); const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`); +const distEntries = await collectFiles(join(pluginRoot, "dist"), "dist"); +if (!distEntries.some((entry) => entry.archivePath === main)) { + throw new Error(`Built plugin dist does not contain manifest main: ${main}`); +} const archiveEntries = [ { archivePath: "idea-plugin.json", sourcePath: manifestPath }, - { archivePath: main, sourcePath: join(pluginRoot, main) }, + ...distEntries, { archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") } ]; const DOS_TIME_MIDNIGHT = 0; @@ -43,6 +47,23 @@ for (const entry of archiveEntries) { await writeFile(archivePath, createZip(files)); console.log(`created ${archivePath}`); +async function collectFiles(sourceDir, archiveDir) { + const entries = await readdir(sourceDir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name); + const archivePath = `${archiveDir}/${entry.name}`; + if (entry.isDirectory()) { + files.push(...await collectFiles(sourcePath, archivePath)); + } else if (entry.isFile()) { + files.push({ archivePath, sourcePath }); + } + } + + return files.sort((left, right) => left.archivePath.localeCompare(right.archivePath)); +} + function requireString(record, key) { if (typeof record[key] !== "string" || record[key].trim().length === 0) { throw new Error(`idea-plugin.json field "${key}" must be a non-empty string`); diff --git a/src/runtime.ts b/src/runtime.ts index f5edfa3..a4240b5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -36,6 +36,10 @@ export interface CommandDisposable { } export interface PluginStorage { + /** + * Plugin-owned persistent state. Use this for internal counters, flags, + * preferences and caches that should not be written into the user's project. + */ get(key: string): Promise; set(key: string, value: T): Promise; delete(key: string): Promise;