diff --git a/.changeset/dogfood-actions-plugin-package.md b/.changeset/dogfood-actions-plugin-package.md new file mode 100644 index 0000000..0583948 --- /dev/null +++ b/.changeset/dogfood-actions-plugin-package.md @@ -0,0 +1,6 @@ +--- +"@jmfederico/pi-web": patch +"@jmfederico/pi-web-actions": patch +--- + +Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and private API dogfooding notes. diff --git a/docs/plugins.md b/docs/plugins.md index 6855161..09ccc61 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -127,6 +127,29 @@ ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id Reload the Pi Web browser tab. Pi Web serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes. +## First-party separate plugin packages + +First-party plugins that are published as their own npm packages can live in this repository under `plugins/*` as npm workspaces. These packages are **not bundled** into the main `@jmfederico/pi-web` npm package automatically; they are separate packages that share CI, tests, and local development tooling with the main repo. + +A separate plugin package should: + +- use type-only imports from `@jmfederico/pi-web/plugin-api` when it needs shared Pi Web plugin interfaces; this subpath is currently a `.d.ts`-only dogfooding surface, not a runtime JavaScript module; +- keep its Pi Web metadata in its own `package.json` with `piWeb.plugins` entries pointing at built JavaScript in `dist/`; +- include a package-level `build` script and `prepack` script so `npm pack --workspace ` and `npm publish --workspace ` produce a usable plugin package; +- use a local symlink into `~/.pi-web/plugins/` while developing; +- document any private Pi Web APIs it dogfoods until those APIs become stable plugin runtime helpers. + +Typical local development loop: + +```bash +npm --workspace @jmfederico/pi-web-actions run dev +mkdir -p ~/.pi-web/plugins +ln -s /path/to/pi-web/plugins/actions ~/.pi-web/plugins/actions +curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +``` + +The main Pi Web `dev:web` script watches bundled plugins in `pi-web-plugins/`. Separate workspace packages should run their own package-level watcher when needed. + ## Discovery and packaging Pi Web builds `/pi-web-plugins/manifest.json` from these sources: diff --git a/eslint.config.js b/eslint.config.js index ae08e23..15ad8d1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,10 +5,10 @@ import tseslint from "typescript-eslint"; export default defineConfig([ { - ignores: ["dist/**", "node_modules/**"], + ignores: ["dist/**", "node_modules/**", "plugins/*/dist/**"], }, { - files: ["src/**/*.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", "vite.config.ts", "vitest.config.ts"], + files: ["src/**/*.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", "plugins/**/*.ts", "vite.config.ts", "vitest.config.ts"], extends: [ js.configs.recommended, tseslint.configs.strictTypeChecked, diff --git a/package-lock.json b/package-lock.json index 1b18a37..d79b1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@jmfederico/pi-web", "version": "1.202605.10", "license": "MIT", + "workspaces": [ + ".", + "plugins/*" + ], "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", @@ -2322,6 +2326,14 @@ } } }, + "node_modules/@jmfederico/pi-web": { + "resolved": "", + "link": true + }, + "node_modules/@jmfederico/pi-web-actions": { + "resolved": "plugins/actions", + "link": true + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -8667,6 +8679,23 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "plugins/actions": { + "name": "@jmfederico/pi-web-actions", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "peerDependencies": { + "@jmfederico/pi-web": ">=1.202605.10 <2" + }, + "peerDependenciesMeta": { + "@jmfederico/pi-web": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 731be50..fc53c6c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "license": "MIT", "author": "Federico Jaramillo Martinez", "type": "module", + "workspaces": [ + ".", + "plugins/*" + ], "bin": { "pi-web": "dist/cli.js", "pi-web-server": "dist/server/index.js", @@ -17,7 +21,8 @@ "LICENSE", "extensions", "docs/plugins.md", - "docs/assets" + "docs/assets", + "plugin-api.d.ts" ], "scripts": { "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", @@ -26,15 +31,16 @@ "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "dev:plugins": "node scripts/build-plugins.mjs --watch", - "build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build", + "build": "tsc -p tsconfig.build.json && npm run build:plugins && npm run build:plugin-packages && vite build", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", + "build:plugin-packages": "bash -c 'set -e; shopt -s nullglob; for package in plugins/*/package.json; do dir=${package%/package.json}; (cd \"$dir\" && npm run build --if-present); done'", "typecheck": "tsc --noEmit", - "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts", + "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" \"plugins/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm run lint && npm test", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", - "clean": "rm -rf dist", + "clean": "rm -rf dist plugins/*/dist", "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", diff --git a/plugin-api.d.ts b/plugin-api.d.ts new file mode 100644 index 0000000..722136d --- /dev/null +++ b/plugin-api.d.ts @@ -0,0 +1,146 @@ +import type { TemplateResult } from "lit"; + +export type PluginId = string; +export type LocalContributionId = string; +export type QualifiedContributionId = string; +export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; + +export interface PiWebPlugin { + apiVersion: 1; + name: string; + activate: (context: PluginActivationContext) => PluginActivationResult; +} + +export interface PluginActivationContext { + apiVersion: 1; + pluginId: PluginId; + html: HtmlTemplateTag; +} + +export interface PluginActivationResult { + contributions: PluginContributions; +} + +export interface PluginContributions { + actions?: PluginAction[]; + workspacePanels?: WorkspacePanelContribution[]; + workspaceLabels?: WorkspaceLabelContribution[]; + themes?: ThemeContribution[]; + themePairs?: ThemePairContribution[]; +} + +export interface PluginRuntimeState { + selectedWorkspace?: Workspace; + selectedSession?: unknown; + workspaceTool?: string; + mainView?: string; + piWebStatus?: unknown; +} + +export interface PluginRuntimeContext { + state: PluginRuntimeState; + openActionPalette: () => void; + focusPrompt: () => void; + addProject: () => void | Promise; + configureAuth: () => void | Promise; + logoutAuth: () => void | Promise; + openThemePicker: () => void; + selectMainView: (view: string) => void; + selectWorkspaceTool: (tool: QualifiedContributionId) => void; + openTerminal?: (options?: { terminalId?: string | undefined }) => void; + refreshFiles: () => void | Promise; + refreshGit: () => void | Promise; + startSession: () => void | Promise; + archiveSession: () => void | Promise; + stopActiveWork: () => void | Promise; +} + +export interface PluginAction { + id: LocalContributionId; + title: string; + description?: string; + shortcut?: string; + group?: string; + enabled?: (context: PluginRuntimeContext) => boolean; + run: (context: PluginRuntimeContext) => void | Promise; +} + +export interface Workspace { + id: string; + projectId: string; + path: string; + label: string; + branch?: string; + isMain: boolean; + isGitRepo: boolean; + isGitWorktree: boolean; +} + +export interface WorkspacePanelContext { + workspace: Workspace; + state?: PluginRuntimeState; + openTerminal?: (options?: { terminalId?: string | undefined }) => void; +} + +export interface WorkspacePanelContribution { + id: LocalContributionId; + title: string; + order?: number; + visible?: (context: WorkspacePanelContext) => boolean; + badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; + render: (context: WorkspacePanelContext) => TemplateResult; +} + +export interface WorkspaceLabelContext { + workspace: Workspace; + state?: PluginRuntimeState; +} + +export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem; + +export interface WorkspaceLabelTextItem { + type: "text"; + text: string; + title?: string; +} + +export interface WorkspaceLabelLinkItem { + type: "link"; + text: string; + href: string; + title?: string; + target?: "_blank" | "_self"; +} + +export interface WorkspaceLabelRenderItem { + type: "render"; + render: () => TemplateResult; +} + +export interface WorkspaceLabelContribution { + id: LocalContributionId; + order?: number; + visible?: (context: WorkspaceLabelContext) => boolean; + items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[]; +} + +export type ThemeColorScheme = "dark" | "light"; +export type ThemeTokens = Record; + +export interface ThemeContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + colorScheme: ThemeColorScheme; + tokens: ThemeTokens; +} + +export interface ThemePairContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + light: LocalContributionId; + dark: LocalContributionId; +} diff --git a/plugins/actions/README.md b/plugins/actions/README.md new file mode 100644 index 0000000..9cb1a94 --- /dev/null +++ b/plugins/actions/README.md @@ -0,0 +1,82 @@ +# Pi Web Actions + +Configurable workspace actions for Pi Web. + +The plugin adds an **Actions** workspace tab. Actions create a new Pi Web terminal, send the configured shell command, and switch to the Terminal tab so the user can monitor progress or take over. + +## Configuration + +Create `.pi-web/actions.json` in the workspace root: + +```json +{ + "version": 1, + "actions": [ + { + "id": "docker.start", + "title": "Start Docker", + "group": "Docker", + "description": "Start the local Docker Compose environment.", + "command": "./docker/scripts/docker-compose-dev up -d" + }, + { + "id": "db.reset", + "title": "Reset DB", + "group": "Database", + "command": "go -C klingit-go run ./cli db reset", + "confirm": true + } + ] +} +``` + +Fields: + +- `version`: must be `1`. +- `actions`: array of action definitions. +- `id`: stable action id, matching `^[a-z][a-z0-9.-]*$`. +- `title`: button label. +- `command`: literal shell command sent to the terminal. +- `description`: optional explanatory text. +- `group`: optional group heading. +- `confirm`: optional boolean. When true, the browser asks before dispatching the command. + +Commands run in the workspace root because Pi Web creates the terminal for that workspace. + +After editing `.pi-web/actions.json`, click **Refresh** in the Actions tab or reload the browser tab. The plugin does not watch the file automatically. + +## Development in this monorepo + +This package is developed as a separate npm package, not as a bundled Pi Web plugin. For local development: + +```bash +npm --workspace @jmfederico/pi-web-actions run dev +mkdir -p ~/.pi-web/plugins +ln -s /srv/dev/pi-web/plugins/actions ~/.pi-web/plugins/actions +``` + +Then reload Pi Web and check discovery: + +```bash +curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +``` + +Build the package before publishing or packing: + +```bash +npm --workspace @jmfederico/pi-web-actions run build +npm pack --workspace @jmfederico/pi-web-actions --dry-run +``` + +## Beta/private API note + +This plugin intentionally dogfoods private Pi Web browser APIs for reading workspace files and creating/writing terminals. Those APIs are not yet stable public plugin APIs, so compatibility is best-effort and may require updates alongside Pi Web releases. + +## Notes + +This plugin intentionally keeps v1 simple: + +- static JSON only; +- no variables or templating; +- every action creates a new terminal; +- command prompting/extra input should be handled by the script itself. diff --git a/plugins/actions/package.json b/plugins/actions/package.json new file mode 100644 index 0000000..2c5b44a --- /dev/null +++ b/plugins/actions/package.json @@ -0,0 +1,48 @@ +{ + "name": "@jmfederico/pi-web-actions", + "version": "0.1.0", + "description": "Configurable workspace actions plugin for Pi Web.", + "license": "MIT", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "dev": "tsc -w -p tsconfig.json", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --config ../../vitest.config.ts", + "prepack": "npm run build" + }, + "keywords": [ + "pi-package", + "pi-web", + "pi-web-plugin", + "actions", + "workspace" + ], + "peerDependencies": { + "@jmfederico/pi-web": ">=1.202605.10 <2" + }, + "peerDependenciesMeta": { + "@jmfederico/pi-web": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "piWeb": { + "plugins": [ + { + "id": "actions", + "module": "dist/pi-web-plugin.js" + } + ] + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts new file mode 100644 index 0000000..fb1ae28 --- /dev/null +++ b/plugins/actions/src/actionsPanelElement.ts @@ -0,0 +1,289 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; +import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js"; +import { openTerminalPanel, requestPiWebRender } from "./piWebPrivateUi.js"; +import { loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; + +export const actionsPanelTagName = "pi-web-actions-panel"; + +export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void; + +const configChangedEvent = "pi-web-actions-config-changed"; + +type ConfigState = + | { kind: "loading" } + | WorkspaceActionsConfigLoadResult; + +const configCache = new Map(); + +export function defineActionsPanelElement(): void { + if (!customElements.get(actionsPanelTagName)) customElements.define(actionsPanelTagName, PiWebActionsPanel); +} + +export function actionsPanelBadge(workspace: Workspace): string | number | undefined { + const state = getCachedWorkspaceConfig(workspace); + if (state?.kind === "unavailable") return "!"; + if (state?.kind === "loaded" && state.config.actions.length > 0) return state.config.actions.length; + return undefined; +} + +class PiWebActionsPanel extends HTMLElement { + private workspaceValue: Workspace | undefined; + private openTerminalValue: OpenTerminal | undefined; + private runningActionId: string | undefined; + private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined; + private readonly root: ShadowRoot; + private readonly onConfigChanged = () => { + this.render(); + }; + + constructor() { + super(); + this.root = this.attachShadow({ mode: "open" }); + } + + set workspace(value: Workspace | undefined) { + this.workspaceValue = value; + this.render(); + } + + set openTerminal(value: OpenTerminal | undefined) { + this.openTerminalValue = value; + } + + connectedCallback(): void { + window.addEventListener(configChangedEvent, this.onConfigChanged); + this.render(); + } + + disconnectedCallback(): void { + window.removeEventListener(configChangedEvent, this.onConfigChanged); + } + + private render(): void { + const workspace = this.workspaceValue; + if (workspace === undefined) { + this.root.innerHTML = `${actionStyles()}
Select a workspace.
`; + return; + } + + const state = getOrLoadWorkspaceConfig(workspace); + this.root.innerHTML = ` + ${actionStyles()} +
+ Workspace Actions + + + + +
+
+ ${this.renderConfigState(state)} +
+ `; + + this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => { + void this.refreshConfig(workspace); + }); + + for (const button of this.root.querySelectorAll("button[data-action-id]")) { + button.addEventListener("click", () => { + const action = actionFromConfigState(state, button.getAttribute("data-action-id")); + if (action !== undefined) void this.dispatchAction(workspace, action); + }); + } + + this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => { + this.openWorkspaceTerminal(workspace); + }); + } + + private renderConfigState(state: ConfigState): string { + if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

${this.renderStatus()}`; + if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`; + if (state.config.actions.length === 0) return `

No actions configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.

${this.renderStatus()}`; + return ` +

Actions create a new workspace terminal, send the command, then switch to the Terminal tab. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

+ ${renderActionGroups(state.config.actions, this.runningActionId)} + ${this.renderStatus()} + `; + } + + private renderStatus(): string { + if (this.status === undefined) return ""; + const detail = this.status.detail === undefined ? "" : `
${escapeHtml(this.status.detail)}
`; + return `
${escapeHtml(this.status.message)}${detail}
`; + } + + private async refreshConfig(workspace: Workspace): Promise { + this.status = { kind: "info", message: `Refreshing ${ACTIONS_CONFIG_PATH}…` }; + configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" }); + this.render(); + + const state = await refreshWorkspaceConfig(workspace); + this.status = state.kind === "loaded" + ? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` } + : undefined; + this.render(); + } + + private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise { + if (this.runningActionId !== undefined) return; + if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return; + + this.runningActionId = action.id; + this.status = { kind: "info", message: `Creating terminal for ${action.title}…` }; + this.render(); + + try { + const terminal = await createWorkspaceTerminal(workspace, action.title); + this.status = { kind: "info", message: `Dispatching command to ${terminal.name}…` }; + this.render(); + + await sendTerminalCommand(workspace, terminal.id, action.command); + this.status = { + kind: "success", + message: `Dispatched to terminal “${terminal.name}”.`, + detail: action.command, + }; + this.runningActionId = undefined; + this.render(); + this.openWorkspaceTerminal(workspace, terminal.id); + } catch (error) { + this.runningActionId = undefined; + this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; + this.render(); + } + } + + private openWorkspaceTerminal(workspace: Workspace, terminalId?: string): void { + if (this.openTerminalValue !== undefined) { + if (terminalId === undefined) this.openTerminalValue(); + else this.openTerminalValue({ terminalId }); + return; + } + openTerminalPanel(workspace, terminalId); + } +} + +function getCachedWorkspaceConfig(workspace: Workspace): ConfigState | undefined { + return configCache.get(cacheKeyForWorkspace(workspace)); +} + +function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState { + const cached = getCachedWorkspaceConfig(workspace); + if (cached !== undefined) return cached; + + const loading: ConfigState = { kind: "loading" }; + configCache.set(cacheKeyForWorkspace(workspace), loading); + void refreshWorkspaceConfig(workspace); + return loading; +} + +async function refreshWorkspaceConfig(workspace: Workspace): Promise { + const key = cacheKeyForWorkspace(workspace); + const state = await loadWorkspaceActionsConfig(workspace).catch((error: unknown): ConfigState => ({ + kind: "unavailable", + message: `No valid ${ACTIONS_CONFIG_PATH} found.`, + hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, + detail: error instanceof Error ? error.message : String(error), + })); + configCache.set(key, state); + requestPiWebRender(); + window.dispatchEvent(new Event(configChangedEvent)); + return state; +} + +function cacheKeyForWorkspace(workspace: Workspace): string { + return `${workspace.projectId}:${workspace.id}`; +} + +function renderUnavailableState(state: Extract): string { + const detail = state.detail === undefined ? "" : `
${escapeHtml(state.detail)}
`; + return `
${escapeHtml(state.message)}

${escapeHtml(state.hint)}

${detail}
`; +} + +function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string { + return `
${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}
`; +} + +function groupActions(actions: WorkspaceAction[]): { title: string | undefined; actions: WorkspaceAction[] }[] { + const groups: { title: string | undefined; actions: WorkspaceAction[] }[] = []; + for (const action of actions) { + const title = action.group; + let group = groups.find((candidate) => candidate.title === title); + if (group === undefined) { + group = { title, actions: [] }; + groups.push(group); + } + group.actions.push(action); + } + return groups; +} + +function renderActionGroup(group: { title: string | undefined; actions: WorkspaceAction[] }, runningActionId: string | undefined): string { + const title = group.title === undefined ? "" : `

${escapeHtml(group.title)}

`; + return `
${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}
`; +} + +function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string { + const running = runningActionId === action.id; + const disabled = runningActionId !== undefined; + const description = action.description === undefined ? "" : `${escapeHtml(action.description)}`; + return ` +
+
+ ${escapeHtml(action.title)} + ${description} + ${escapeHtml(action.command)} +
+ +
+ `; +} + +function actionFromConfigState(state: ConfigState, actionId: string | null): WorkspaceAction | undefined { + if (state.kind !== "loaded" || actionId === null) return undefined; + return state.config.actions.find((action) => action.id === actionId); +} + +function actionStyles(): string { + return ` + + `; +} + +function escapeHtml(value: unknown): string { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function escapeAttr(value: unknown): string { + return escapeHtml(value).replaceAll('"', """); +} diff --git a/plugins/actions/src/config.test.ts b/plugins/actions/src/config.test.ts new file mode 100644 index 0000000..aa8a19b --- /dev/null +++ b/plugins/actions/src/config.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { parseActionsConfigText } from "./config"; + +describe("workspace actions config", () => { + it("parses a minimal version 1 config", () => { + expect(parseActionsConfigText(JSON.stringify({ + version: 1, + actions: [ + { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset" }, + ], + }))).toEqual({ + ok: true, + config: { + version: 1, + actions: [ + { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset", confirm: false }, + ], + }, + }); + }); + + it("parses optional group, description, and confirm fields", () => { + expect(parseActionsConfigText(JSON.stringify({ + version: 1, + actions: [ + { + id: "docker.start", + title: "Start Docker", + description: "Start the dev stack.", + group: "Docker", + command: "./docker/scripts/docker-compose-dev up -d", + confirm: true, + }, + ], + }))).toEqual({ + ok: true, + config: { + version: 1, + actions: [ + { + id: "docker.start", + title: "Start Docker", + description: "Start the dev stack.", + group: "Docker", + command: "./docker/scripts/docker-compose-dev up -d", + confirm: true, + }, + ], + }, + }); + }); + + it("accepts an empty actions array", () => { + expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [] }))).toEqual({ + ok: true, + config: { version: 1, actions: [] }, + }); + }); + + it("rejects invalid JSON and unsupported versions", () => { + expect(parseActionsConfigText("{")).toMatchObject({ ok: false }); + expect(parseActionsConfigText(JSON.stringify({ version: 2, actions: [] }))).toEqual({ + ok: false, + error: "Config version must be 1", + }); + }); + + it("rejects missing, empty, or duplicate required fields", () => { + expect(parseActionsConfigText(JSON.stringify({ version: 1 }))).toEqual({ + ok: false, + error: "Config actions must be an array", + }); + expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({ + ok: false, + error: "Action 1 id must be a non-empty string", + }); + expect(parseActionsConfigText(JSON.stringify({ + version: 1, + actions: [ + { id: "one", title: "One", command: "cmd" }, + { id: "one", title: "Again", command: "cmd" }, + ], + }))).toEqual({ + ok: false, + error: "Duplicate action id: one", + }); + }); + + it("rejects invalid optional field types", () => { + expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({ + ok: false, + error: "Action 1 confirm must be a boolean", + }); + expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({ + ok: false, + error: "Action 1 group must be a non-empty string when provided", + }); + }); +}); diff --git a/plugins/actions/src/config.ts b/plugins/actions/src/config.ts new file mode 100644 index 0000000..8dc57fd --- /dev/null +++ b/plugins/actions/src/config.ts @@ -0,0 +1,121 @@ +export const ACTIONS_CONFIG_PATH = ".pi-web/actions.json"; +export const ACTIONS_CONFIG_VERSION = 1; + +const actionIdPattern = /^[a-z][a-z0-9.-]*$/u; + +export interface WorkspaceActionsConfig { + version: typeof ACTIONS_CONFIG_VERSION; + actions: WorkspaceAction[]; +} + +export interface WorkspaceAction { + id: string; + title: string; + command: string; + description?: string; + group?: string; + confirm: boolean; +} + +export type ParseActionsConfigResult = + | { ok: true; config: WorkspaceActionsConfig } + | { ok: false; error: string }; + +export function parseActionsConfigText(text: string): ParseActionsConfigResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { ok: false, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + return parseActionsConfig(parsed); +} + +export function parseActionsConfig(value: unknown): ParseActionsConfigResult { + if (!isRecord(value)) return invalid("Config must be an object"); + if (value["version"] !== ACTIONS_CONFIG_VERSION) return invalid("Config version must be 1"); + + const actions = value["actions"]; + if (!Array.isArray(actions)) return invalid("Config actions must be an array"); + + const ids = new Set(); + const parsedActions: WorkspaceAction[] = []; + for (const [index, action] of actions.entries()) { + const parsedAction = parseAction(action, index); + if (!parsedAction.ok) return parsedAction; + if (ids.has(parsedAction.action.id)) return invalid(`Duplicate action id: ${parsedAction.action.id}`); + ids.add(parsedAction.action.id); + parsedActions.push(parsedAction.action); + } + + return { ok: true, config: { version: ACTIONS_CONFIG_VERSION, actions: parsedActions } }; +} + +type ParseActionResult = + | { ok: true; action: WorkspaceAction } + | { ok: false; error: string }; + +function parseAction(value: unknown, index: number): ParseActionResult { + const label = `Action ${String(index + 1)}`; + if (!isRecord(value)) return invalid(`${label} must be an object`); + + const id = requireNonEmptyString(value, "id", label); + if (!id.ok) return id; + if (!actionIdPattern.test(id.value)) return invalid(`${label} id must match ${actionIdPattern.source}`); + + const title = requireNonEmptyString(value, "title", label); + if (!title.ok) return title; + + const command = requireNonEmptyString(value, "command", label); + if (!command.ok) return command; + + const description = optionalNonEmptyString(value, "description", label); + if (!description.ok) return description; + + const group = optionalNonEmptyString(value, "group", label); + if (!group.ok) return group; + + const confirm = value["confirm"]; + if (confirm !== undefined && typeof confirm !== "boolean") return invalid(`${label} confirm must be a boolean`); + + return { + ok: true, + action: { + id: id.value, + title: title.value, + command: command.value, + ...(description.value === undefined ? {} : { description: description.value }), + ...(group.value === undefined ? {} : { group: group.value }), + confirm: confirm ?? false, + }, + }; +} + +type StringFieldResult = + | { ok: true; value: string } + | { ok: false; error: string }; + +type OptionalStringFieldResult = + | { ok: true; value: string | undefined } + | { ok: false; error: string }; + +function requireNonEmptyString(record: Record, key: string, label: string): StringFieldResult { + const value = record[key]; + if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string`); + return { ok: true, value }; +} + +function optionalNonEmptyString(record: Record, key: string, label: string): OptionalStringFieldResult { + const value = record[key]; + if (value === undefined) return { ok: true, value: undefined }; + if (typeof value !== "string" || value.trim() === "") return invalid(`${label} ${key} must be a non-empty string when provided`); + return { ok: true, value }; +} + +function invalid(error: string): { ok: false; error: string } { + return { ok: false, error }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/actions/src/pi-web-plugin.ts b/plugins/actions/src/pi-web-plugin.ts new file mode 100644 index 0000000..6d6fb6c --- /dev/null +++ b/plugins/actions/src/pi-web-plugin.ts @@ -0,0 +1,40 @@ +import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; +import { ACTIONS_CONFIG_PATH } from "./config.js"; +import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js"; + +const plugin: PiWebPlugin = { + apiVersion: 1, + name: "Workspace Actions", + activate: ({ pluginId, html }) => { + defineActionsPanelElement(); + + return { + contributions: { + actions: [ + { + id: "workspace.open-actions", + title: "Open Workspace Actions", + description: `Open the workspace Actions tab. Configure actions in ${ACTIONS_CONFIG_PATH}.`, + group: "Workspace", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + if (context.state.selectedWorkspace === undefined) return; + context.selectWorkspaceTool(`${pluginId}:workspace.actions`); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.actions", + title: "Actions", + order: 40, + badge: ({ workspace }) => actionsPanelBadge(workspace), + render: ({ workspace, openTerminal }) => html``, + }, + ], + }, + }; + }, +}; + +export default plugin; diff --git a/plugins/actions/src/piWebPrivateUi.ts b/plugins/actions/src/piWebPrivateUi.ts new file mode 100644 index 0000000..1888463 --- /dev/null +++ b/plugins/actions/src/piWebPrivateUi.ts @@ -0,0 +1,70 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { terminalToolId, type TerminalInfo } from "./terminalDispatcher.js"; + +interface TerminalPanelElement { + terminals: TerminalInfo[]; + selectTerminal: (terminalId: string) => void; +} + +interface Updatable { + requestUpdate: () => void; +} + +/** + * Private Pi Web UI fallback used while the plugin API is still being dogfooded. + * The current host provides a panel `openTerminal` helper; keep this fallback contained + * for older hosts and replace/remove it once the public helper is required. + */ +export function openTerminalPanel(workspace: Workspace, terminalId?: string): void { + const url = new URL(window.location.href); + url.searchParams.set("project", workspace.projectId); + url.searchParams.set("workspace", workspace.id); + url.searchParams.set("tool", terminalToolId); + url.searchParams.set("view", terminalToolId); + window.history.pushState({}, "", url); + dispatchPopState(); + if (terminalId !== undefined) selectTerminalWhenAvailable(terminalId); +} + +export function requestPiWebRender(): void { + const app = document.querySelector("pi-web-app"); + if (isUpdatable(app)) app.requestUpdate(); +} + +function dispatchPopState(): void { + if (typeof PopStateEvent === "function") { + window.dispatchEvent(new PopStateEvent("popstate")); + return; + } + window.dispatchEvent(new Event("popstate")); +} + +function selectTerminalWhenAvailable(terminalId: string, attempt = 0): void { + const terminalPanel = findTerminalPanel(); + const terminals = terminalPanel?.terminals ?? []; + const hasTerminal = terminals.some((terminal) => terminal.id === terminalId); + + if (terminalPanel !== undefined && hasTerminal) { + terminalPanel.selectTerminal(terminalId); + return; + } + + if (attempt < 50) window.setTimeout(() => { selectTerminalWhenAvailable(terminalId, attempt + 1); }, 150); +} + +function findTerminalPanel(): TerminalPanelElement | undefined { + const panel = document.querySelector("workspace-panel")?.shadowRoot?.querySelector("terminal-panel"); + return isTerminalPanelElement(panel) ? panel : undefined; +} + +function isTerminalPanelElement(value: unknown): value is TerminalPanelElement { + return isRecord(value) && Array.isArray(value["terminals"]) && typeof value["selectTerminal"] === "function"; +} + +function isUpdatable(value: unknown): value is Updatable { + return isRecord(value) && typeof value["requestUpdate"] === "function"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/plugins/actions/src/terminalDispatcher.test.ts b/plugins/actions/src/terminalDispatcher.test.ts new file mode 100644 index 0000000..030ac18 --- /dev/null +++ b/plugins/actions/src/terminalDispatcher.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { actionTerminalCols, actionTerminalRows, createWorkspaceTerminal, normalizeTerminalCommand, parseTerminalInfo, parseTerminalSocketMessage, terminalSocketUrl, type FetchLike } from "./terminalDispatcher"; + +const workspace: Workspace = { + id: "workspace 1", + projectId: "project/1", + path: "/repo", + label: "repo", + isMain: false, + isGitRepo: true, + isGitWorktree: true, +}; + +describe("terminal dispatcher", () => { + it("normalizes commands for terminal input", () => { + expect(normalizeTerminalCommand("npm test")).toBe("npm test\r"); + expect(normalizeTerminalCommand("npm test\n")).toBe("npm test\r"); + expect(normalizeTerminalCommand("npm test\r\n")).toBe("npm test\r"); + }); + + it("builds terminal socket URLs from the current host", () => { + expect(terminalSocketUrl(workspace, "term/1", { protocol: "https:", host: "example.test" })).toBe( + `wss://example.test/api/projects/project%2F1/workspaces/workspace%201/terminals/term%2F1/socket?cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`, + ); + }); + + it("parses terminal socket messages", () => { + expect(parseTerminalSocketMessage(JSON.stringify({ type: "error", message: "boom" }))).toEqual({ type: "error", message: "boom" }); + expect(parseTerminalSocketMessage(JSON.stringify({ type: "output", data: "hello" }))).toEqual({ type: "output" }); + expect(parseTerminalSocketMessage("not json")).toBeUndefined(); + }); + + it("creates terminals through the private workspace terminal endpoint", async () => { + let capturedRequest: { input: string; init: RequestInit | undefined } | undefined; + const fetcher: FetchLike = (input, init) => { + capturedRequest = { input, init }; + return Promise.resolve(new Response(JSON.stringify({ id: "t1", name: "Action: Build" }), { status: 200 })); + }; + + await expect(createWorkspaceTerminal(workspace, "Build", fetcher)).resolves.toEqual({ id: "t1", name: "Action: Build" }); + + if (capturedRequest === undefined) throw new Error("Expected terminal request"); + expect(capturedRequest.input).toBe("/api/projects/project%2F1/workspaces/workspace%201/terminals"); + expect(capturedRequest.init?.method).toBe("POST"); + expect(capturedRequest.init?.body).toBe(JSON.stringify({ name: "Action: Build", cols: actionTerminalCols, rows: actionTerminalRows })); + }); + + it("falls back to a generated terminal name when the response omits one", () => { + expect(parseTerminalInfo({ id: "t1" }, "Build")).toEqual({ id: "t1", name: "Action: Build" }); + }); +}); diff --git a/plugins/actions/src/terminalDispatcher.ts b/plugins/actions/src/terminalDispatcher.ts new file mode 100644 index 0000000..11b6d40 --- /dev/null +++ b/plugins/actions/src/terminalDispatcher.ts @@ -0,0 +1,162 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; + +export const terminalToolId = "core:workspace.terminal"; +export const actionTerminalCols = 120; +export const actionTerminalRows = 32; + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export interface TerminalInfo { + id: string; + name: string; +} + +interface ServerTerminalMessage { + type: string; + message?: string; +} + +interface TerminalCommandDeps { + createWebSocket: (url: string) => WebSocket; + setTimeout: typeof window.setTimeout; + clearTimeout: typeof window.clearTimeout; +} + +interface TerminalLocation { + protocol: string; + host: string; +} + +export async function createWorkspaceTerminal( + workspace: Workspace, + actionTitle: string, + fetcher: FetchLike = window.fetch.bind(window), +): Promise { + const response = await fetcher(`/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: `Action: ${actionTitle}`, cols: actionTerminalCols, rows: actionTerminalRows }), + }); + if (!response.ok) throw new Error(`Failed to create terminal: ${String(response.status)}`); + return parseTerminalInfo(await response.json(), actionTitle); +} + +export function parseTerminalInfo(value: unknown, actionTitle: string): TerminalInfo { + if (!isRecord(value) || typeof value["id"] !== "string") throw new Error("Failed to create terminal: invalid response"); + const name = value["name"]; + return { id: value["id"], name: typeof name === "string" && name !== "" ? name : `Action: ${actionTitle}` }; +} + +export function sendTerminalCommand(workspace: Workspace, terminalId: string, command: string, deps = defaultTerminalCommandDeps()): Promise { + return new Promise((resolve, reject) => { + const socket = deps.createWebSocket(terminalSocketUrl(workspace, terminalId)); + const input = normalizeTerminalCommand(command); + let settled = false; + let sent = false; + let fallbackTimer: number | undefined; + let completionTimer: number | undefined; + const timeout = deps.setTimeout(() => { + finish(new Error("Timed out while dispatching command to terminal")); + }, 15000); + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + deps.clearTimeout(timeout); + if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer); + if (completionTimer !== undefined) deps.clearTimeout(completionTimer); + try { + socket.close(); + } catch { + // Ignore close failures. + } + if (error === undefined) resolve(); + else reject(error); + }; + + const scheduleFinishAfterOutput = () => { + completionTimer = deps.setTimeout(() => { finish(); }, 300); + }; + + const send = () => { + if (settled || sent || socket.readyState !== WebSocket.OPEN) return; + sent = true; + socket.send(JSON.stringify({ type: "input", data: input })); + completionTimer = deps.setTimeout(() => { finish(); }, 5000); + }; + + socket.addEventListener("open", () => { + fallbackTimer = deps.setTimeout(send, 3000); + }); + socket.addEventListener("message", (event: MessageEvent) => { + void socketDataToText(event.data).then((text) => { + const message = parseTerminalSocketMessage(text); + if (message?.type === "error") { + finish(new Error(message.message ?? "Terminal socket error")); + return; + } + if (sent) { + scheduleFinishAfterOutput(); + return; + } + if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer); + deps.setTimeout(send, 100); + }).catch(() => { + if (sent) { + scheduleFinishAfterOutput(); + return; + } + if (fallbackTimer !== undefined) deps.clearTimeout(fallbackTimer); + deps.setTimeout(send, 100); + }); + }); + socket.addEventListener("close", () => { + if (!sent) finish(new Error("Terminal socket closed before the command was dispatched")); + else finish(); + }); + socket.addEventListener("error", () => { finish(new Error("Failed to connect to terminal socket")); }); + }); +} + +export function normalizeTerminalCommand(command: string): string { + return `${command.replace(/\r?\n$/u, "")}\r`; +} + +export async function socketDataToText(data: unknown): Promise { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (data instanceof Blob) return await data.text(); + return String(data); +} + +export function parseTerminalSocketMessage(text: string): ServerTerminalMessage | undefined { + try { + const message: unknown = JSON.parse(text); + if (!isRecord(message) || typeof message["type"] !== "string") return undefined; + const rawMessage = message["message"]; + return { + type: message["type"], + ...(typeof rawMessage === "string" ? { message: rawMessage } : {}), + }; + } catch { + return undefined; + } +} + +export function terminalSocketUrl(workspace: Workspace, terminalId: string, location: TerminalLocation = window.location): string { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const query = `cols=${String(actionTerminalCols)}&rows=${String(actionTerminalRows)}`; + return `${protocol}//${location.host}/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/terminals/${encodeURIComponent(terminalId)}/socket?${query}`; +} + +function defaultTerminalCommandDeps(): TerminalCommandDeps { + return { + createWebSocket: (url) => new WebSocket(url), + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/actions/src/workspaceActionsClient.test.ts b/plugins/actions/src/workspaceActionsClient.test.ts new file mode 100644 index 0000000..dbe1bac --- /dev/null +++ b/plugins/actions/src/workspaceActionsClient.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { ACTIONS_CONFIG_PATH } from "./config"; +import { loadWorkspaceActionsConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceActionsClient"; + +const workspace: Workspace = { + id: "workspace 1", + projectId: "project/1", + path: "/repo", + label: "repo", + isMain: false, + isGitRepo: true, + isGitWorktree: true, +}; + +describe("workspace actions client", () => { + it("builds the private workspace file URL", () => { + expect(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Factions.json"); + }); + + it("loads and parses a valid actions config", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 1, actions: [{ id: "build", title: "Build", command: "npm run build" }] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "loaded", + config: { + version: 1, + actions: [{ id: "build", title: "Build", command: "npm run build", confirm: false }], + }, + }); + }); + + it("returns a visible unavailable state instead of throwing on request failures", async () => { + const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); + + await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + message: `No valid ${ACTIONS_CONFIG_PATH} found.`, + hint: `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, + detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400`, + }); + }); + + it("returns parser details for invalid config files", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 2, actions: [] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + detail: "Config version must be 1", + }); + }); + + it("validates workspace file responses", () => { + expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); + expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); +} diff --git a/plugins/actions/src/workspaceActionsClient.ts b/plugins/actions/src/workspaceActionsClient.ts new file mode 100644 index 0000000..008552f --- /dev/null +++ b/plugins/actions/src/workspaceActionsClient.ts @@ -0,0 +1,77 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js"; + +export const actionsConfigUnavailableMessage = `No valid ${ACTIONS_CONFIG_PATH} found.`; +export const actionsConfigRefreshHint = `Add or fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`; + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export type WorkspaceActionsConfigLoadResult = + | { kind: "loaded"; config: WorkspaceActionsConfig } + | { kind: "unavailable"; message: string; hint: string; detail?: string }; + +interface WorkspaceFileResponse { + content: string; + truncated: boolean; + binary: boolean; +} + +export async function loadWorkspaceActionsConfig( + workspace: Workspace, + deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) }, +): Promise { + let response: Response; + try { + response = await deps.fetch(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH), { cache: "no-store" }); + } catch (error) { + return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + } + + if (!response.ok) return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: HTTP ${String(response.status)}`); + + let body: unknown; + try { + body = await response.json(); + } catch (error) { + return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + } + + const file = parseWorkspaceFileResponse(body); + if (file === undefined) return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}`); + if (file.binary) return unavailable(`${ACTIONS_CONFIG_PATH} must be a text file`); + if (file.truncated) return unavailable(`${ACTIONS_CONFIG_PATH} is too large and was truncated`); + + const result = parseActionsConfigText(file.content); + if (!result.ok) return unavailable(result.error); + return { kind: "loaded", config: result.config }; +} + +export function workspaceFileUrl(workspace: Workspace, path: string): string { + return `/api/projects/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(path)}`; +} + +export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileResponse | undefined { + if (!isRecord(value)) return undefined; + const content = value["content"]; + const truncated = value["truncated"]; + const binary = value["binary"]; + if (typeof content !== "string" || typeof truncated !== "boolean" || typeof binary !== "boolean") return undefined; + return { content, truncated, binary }; +} + +function unavailable(detail: string): WorkspaceActionsConfigLoadResult { + return { + kind: "unavailable", + message: actionsConfigUnavailableMessage, + hint: actionsConfigRefreshHint, + detail, + }; +} + +function formatUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/actions/tsconfig.json b/plugins/actions/tsconfig.json new file mode 100644 index 0000000..9423744 --- /dev/null +++ b/plugins/actions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "types": [] + }, + "include": ["src/**/*.ts", "../../plugin-api.d.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 8c6c695..937bb87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,13 +24,19 @@ ], "types": [ "node" - ] + ], + "baseUrl": ".", + "paths": { + "@jmfederico/pi-web/plugin-api": ["./plugin-api.d.ts"] + } }, "include": [ "src/**/*.ts", "vite.config.ts", "vitest.config.ts", "extensions/**/*.ts", - "pi-web-plugins/**/*.ts" + "pi-web-plugins/**/*.ts", + "plugins/**/*.ts", + "plugin-api.d.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts index ae847ff..5c2e530 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "plugins/**/*.test.ts"], }, });