feat(sdk): ESM multi-fichiers + storage plugin-owned (#134/#139)
Documente et illustre deux contrats SDK : - Multi-fichiers ESM : le `main` du manifeste peut importer d'autres fichiers du package via specifiers relatifs, servis par IdeA sur `idea-plugin://` (build `tsc` non bundlé). Le packager embarque tout `dist/**/*.js` et vérifie la présence du `main`. Les bare specifiers (`node_modules`) restent hors contrat : à bundler ou vendorer. - Storage plugin-owned : `ctx.storage` est la place canonique de l'état interne du plugin (compteurs, flags, préférences, caches), hors des fichiers projet. Les APIs workspace/config restent pour le contenu project-owned. L'exemple hello-plugin est éclaté en modules (constants, core/layout, core/workspace, core/storage) pour exercer l'import relatif et le storage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
67
README.md
67
README.md
@ -6,6 +6,7 @@ This first version intentionally stays small:
|
|||||||
|
|
||||||
- public manifest types for `idea-plugin.json`;
|
- public manifest types for `idea-plugin.json`;
|
||||||
- public runtime types for plugin modules exposing `activate(ctx)`;
|
- 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;
|
- a stable `ctx.services` facade for workspace, background task and terminal operations;
|
||||||
- public workspace file APIs for reading, writing, listing, stat and path resolution;
|
- public workspace file APIs for reading, writing, listing, stat and path resolution;
|
||||||
- a bounded generic project-structure query API;
|
- 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
|
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
|
## 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:
|
The entrypoint exports an `activate(ctx)` function:
|
||||||
|
|
||||||
```ts
|
```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
|
rendering. Services are available from `ctx.services` to plugins declaring the
|
||||||
`tooling` capability; layout props do not expose private runtime gateways.
|
`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<void> {
|
||||||
|
const current = await ctx.storage?.get<number>(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
|
## Runtime Services
|
||||||
|
|
||||||
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
||||||
@ -155,7 +202,9 @@ export async function activate(ctx: ActivateContext): Promise<void> {
|
|||||||
Workspace paths are always relative to the project root. Hosts reject absolute
|
Workspace paths are always relative to the project root. Hosts reject absolute
|
||||||
paths, `..`, empty path segments and paths outside the sandbox. Text APIs use
|
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
|
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
|
```ts
|
||||||
import type { ActivateContext } from "@idea/plugin-sdk";
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
@ -165,10 +214,10 @@ export async function activate(ctx: ActivateContext): Promise<void> {
|
|||||||
const project = await workspace?.getCurrentProject();
|
const project = await workspace?.getCurrentProject();
|
||||||
if (!workspace || !project) return;
|
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 file = await workspace.readTextFile("hello-plugin-report.txt", project.id);
|
||||||
const listing = await workspace.listDirectory(".ideai", project.id);
|
const listing = await workspace.listDirectory(".", project.id);
|
||||||
const stat = await workspace.stat(file.path, project.id);
|
const stat = await workspace.stat(file.path, project.id);
|
||||||
|
|
||||||
ctx.logger.info("workspace file", {
|
ctx.logger.info("workspace file", {
|
||||||
@ -327,7 +376,9 @@ subscription up to the requested/host-capped capacity, drained oldest-first, and
|
|||||||
### Structured Config Documents
|
### Structured Config Documents
|
||||||
|
|
||||||
Use `ctx.services.config` when a plugin needs to read or update a structured
|
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:
|
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<void> {
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
const config = await ctx.services?.config.readDocument({
|
const config = await ctx.services?.config.readDocument({
|
||||||
path: ".ideai/hello-plugin.json"
|
path: "tooling.config.json"
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.services?.config.updateDocument({
|
await ctx.services?.config.updateDocument({
|
||||||
path: ".ideai/hello-plugin.json",
|
path: "tooling.config.json",
|
||||||
mode: "mergePatch",
|
mode: "mergePatch",
|
||||||
value: {
|
value: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@ -8,22 +8,37 @@ It exercises the current plugin primitives end to end:
|
|||||||
- menu entry: `hello-plugin`;
|
- menu entry: `hello-plugin`;
|
||||||
- command: `hello-plugin`, returning `hello-world`;
|
- command: `hello-plugin`, returning `hello-world`;
|
||||||
- layout contribution: `hello-plugin.hello-world`, rendered as `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.
|
- 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
|
```sh
|
||||||
npm run typecheck:examples
|
npm run typecheck:examples
|
||||||
npm run package:hello-plugin
|
npm run package:hello-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`.
|
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
|
It contains `idea-plugin.json` at the ZIP root and the compiled multi-file ESM output under `dist/`,
|
||||||
`dist/index.js`, matching the manifest `main` field.
|
including `dist/index.js`, matching the manifest `main` field.
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
During activation the plugin logs:
|
During activation the plugin logs:
|
||||||
|
|
||||||
- whether the command and layout runtime registries are available;
|
- 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` command;
|
||||||
- successful registration of the `hello-plugin.hello-world` layout;
|
- successful registration of the `hello-plugin.hello-world` layout;
|
||||||
- availability of the workspace service from the `tooling` runtime capability;
|
- availability of the workspace service from the `tooling` runtime capability;
|
||||||
|
|||||||
8
examples/hello-plugin/src/constants.ts
Normal file
8
examples/hello-plugin/src/constants.ts
Normal file
@ -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;
|
||||||
17
examples/hello-plugin/src/core/layout.ts
Normal file
17
examples/hello-plugin/src/core/layout.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
32
examples/hello-plugin/src/core/storage.ts
Normal file
32
examples/hello-plugin/src/core/storage.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
import { STORAGE_KEYS } from "../constants.js";
|
||||||
|
|
||||||
|
export async function initializePluginStorage(ctx: ActivateContext): Promise<void> {
|
||||||
|
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<boolean>(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<number | undefined> {
|
||||||
|
if (!ctx.storage) return undefined;
|
||||||
|
return incrementStoredNumber(ctx, STORAGE_KEYS.commandRunCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function incrementStoredNumber(ctx: ActivateContext, key: string): Promise<number> {
|
||||||
|
const current = await ctx.storage?.get<number>(key);
|
||||||
|
const next = typeof current === "number" && Number.isFinite(current) ? current + 1 : 1;
|
||||||
|
await ctx.storage?.set(key, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
98
examples/hello-plugin/src/core/workspace.ts
Normal file
98
examples/hello-plugin/src/core/workspace.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
import { STORAGE_KEYS } from "../constants.js";
|
||||||
|
|
||||||
|
export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
|
||||||
|
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<string>(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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,33 +1,20 @@
|
|||||||
import type { ActivateContext, IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk";
|
import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
|
||||||
|
import { COMMAND_ID, LAYOUT_TYPE } from "./constants.js";
|
||||||
const COMMAND_ID = "hello-plugin";
|
import { HelloWorldLayout } from "./core/layout.js";
|
||||||
const LAYOUT_TYPE = "hello-plugin.hello-world";
|
import { initializePluginStorage, recordCommandRun } from "./core/storage.js";
|
||||||
|
import { useWorkspaceSdk } from "./core/workspace.js";
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activate(ctx: ActivateContext): void {
|
export function activate(ctx: ActivateContext): void {
|
||||||
ctx.logger.info("activating hello-plugin", {
|
ctx.logger.info("activating hello-plugin", {
|
||||||
pluginId: ctx.pluginId,
|
pluginId: ctx.pluginId,
|
||||||
hasCommands: Boolean(ctx.commands),
|
hasCommands: Boolean(ctx.commands),
|
||||||
hasLayouts: Boolean(ctx.layouts)
|
hasLayouts: Boolean(ctx.layouts),
|
||||||
|
hasStorage: Boolean(ctx.storage)
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, () => {
|
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => {
|
||||||
ctx.logger.info("command executed", { commandId: COMMAND_ID });
|
const commandRunCount = await recordCommandRun(ctx);
|
||||||
|
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount });
|
||||||
return "hello-world";
|
return "hello-world";
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,132 +40,10 @@ export function activate(ctx: ActivateContext): void {
|
|||||||
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
|
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void initializePluginStorage(ctx);
|
||||||
void useWorkspaceSdk(ctx);
|
void useWorkspaceSdk(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
|
|
||||||
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<string>("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 = {
|
const plugin: IdeAPluginModule = {
|
||||||
activate
|
activate
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
const pluginRoot = join(process.cwd(), "examples", "hello-plugin");
|
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 main = requireString(manifest, "main");
|
||||||
const version = requireString(manifest, "version");
|
const version = requireString(manifest, "version");
|
||||||
const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`);
|
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 = [
|
const archiveEntries = [
|
||||||
{ archivePath: "idea-plugin.json", sourcePath: manifestPath },
|
{ archivePath: "idea-plugin.json", sourcePath: manifestPath },
|
||||||
{ archivePath: main, sourcePath: join(pluginRoot, main) },
|
...distEntries,
|
||||||
{ archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") }
|
{ archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") }
|
||||||
];
|
];
|
||||||
const DOS_TIME_MIDNIGHT = 0;
|
const DOS_TIME_MIDNIGHT = 0;
|
||||||
@ -43,6 +47,23 @@ for (const entry of archiveEntries) {
|
|||||||
await writeFile(archivePath, createZip(files));
|
await writeFile(archivePath, createZip(files));
|
||||||
console.log(`created ${archivePath}`);
|
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) {
|
function requireString(record, key) {
|
||||||
if (typeof record[key] !== "string" || record[key].trim().length === 0) {
|
if (typeof record[key] !== "string" || record[key].trim().length === 0) {
|
||||||
throw new Error(`idea-plugin.json field "${key}" must be a non-empty string`);
|
throw new Error(`idea-plugin.json field "${key}" must be a non-empty string`);
|
||||||
|
|||||||
@ -36,6 +36,10 @@ export interface CommandDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginStorage {
|
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<T = unknown>(key: string): Promise<T | undefined>;
|
get<T = unknown>(key: string): Promise<T | undefined>;
|
||||||
set<T = unknown>(key: string, value: T): Promise<void>;
|
set<T = unknown>(key: string, value: T): Promise<void>;
|
||||||
delete(key: string): Promise<void>;
|
delete(key: string): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user