mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
* feat(plugins): declare display metadata in plugin manifests * style(website): format plugin page description * fix(plugins): preserve default names and clarify registry follow-up * test: fix stale plugin card selectors and terminal marker race
241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
|
import { resolveCliVersion } from "../../version.js";
|
|
|
|
const OVERVIEW = `<!--
|
|
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
|
|
paseo-plugin.json in your repository at the pinned commit and in your npm package.
|
|
Replace this comment with useful facts before publishing.
|
|
|
|
This overview is for people choosing a plugin in Paseo, on the plugin page under
|
|
its install command. README assumes a GitHub audience and includes technical
|
|
details, installation instructions, and badges. Long, AI-generated READMEs leave
|
|
people reading past that material to understand what the plugin does.
|
|
|
|
Write these parts in order, including only what helps someone decide to install:
|
|
|
|
1. Describe what your plugin is and does in plain terms first.
|
|
2. Explain how it works only when it is not obvious.
|
|
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
|
|
other plugins. Include applicable daemon version and operating system
|
|
requirements. Setup guidance is allowed; installation instructions are not.
|
|
4. Explain capabilities and settings worth understanding, what each option does,
|
|
what the plugin reads or sends and where, permissions, and known limits.
|
|
|
|
Length follows complexity; a theme needs one paragraph. Choose headings only
|
|
when they help. Use sentence case and plain factual language, with no em dashes.
|
|
Omit installation commands, badges, changelog, contributing or license sections,
|
|
marketing, and unsupported claims. Avoid implementation filler such as empty
|
|
cleanup functions, theme-token field lists, or lists of absent features.
|
|
-->
|
|
`;
|
|
|
|
const TSCONFIG = {
|
|
compilerOptions: {
|
|
target: "ES2020",
|
|
module: "ESNext",
|
|
moduleResolution: "Bundler",
|
|
lib: ["ES2023"],
|
|
types: ["react"],
|
|
jsx: "react-jsx",
|
|
strict: true,
|
|
skipLibCheck: true,
|
|
noEmit: true,
|
|
esModuleInterop: true,
|
|
allowSyntheticDefaultImports: true,
|
|
},
|
|
include: ["**/*.ts", "**/*.tsx"],
|
|
};
|
|
|
|
const CLIENT_ENTRY = `import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
import { GreetingScreen } from "./client/greeting";
|
|
|
|
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
return (
|
|
<SidebarRow
|
|
icon="MessageCircle"
|
|
active={currentScreen?.screenId === "greeting"}
|
|
onPress={() => openScreen({ screenId: "greeting" })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default function contribute(client: PluginClientContext) {
|
|
client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen });
|
|
client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem });
|
|
return () => {};
|
|
}
|
|
`;
|
|
|
|
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
import { createGreeting } from "./server/greeting";
|
|
import { greetingRpc } from "./shared/greeting";
|
|
|
|
export default function contribute(server: PluginServerContext) {
|
|
server.handle(greetingRpc, createGreeting);
|
|
return () => {};
|
|
}
|
|
`;
|
|
|
|
const SHARED_GREETING = `import { defineRpc } from "@getpaseo/plugin";
|
|
import { z } from "zod";
|
|
|
|
export const greetingRpc = defineRpc({
|
|
name: "greeting.create",
|
|
input: z.object({ name: z.string() }),
|
|
output: z.object({ message: z.string() }),
|
|
});
|
|
`;
|
|
|
|
const SERVER_GREETING = `import type { RpcInput } from "@getpaseo/plugin";
|
|
import { greetingRpc } from "../shared/greeting";
|
|
|
|
export function createGreeting({ name }: RpcInput<typeof greetingRpc>) {
|
|
return { message: "Hello, " + name + "!" };
|
|
}
|
|
`;
|
|
|
|
const CLIENT_GREETING = `import type { PluginScreenProps } from "@getpaseo/plugin/client";
|
|
import { useRpc } from "@getpaseo/plugin/client";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { useMemo } from "react";
|
|
import { Pressable, Text, View } from "react-native";
|
|
import { greetingRpc } from "../shared/greeting";
|
|
import { openExternal } from "./web";
|
|
|
|
export function GreetingScreen({ theme, layout }: PluginScreenProps) {
|
|
const createGreeting = useRpc(greetingRpc);
|
|
const greeting = useMutation({ mutationFn: createGreeting });
|
|
const styles = useMemo(
|
|
() => ({
|
|
screen: {
|
|
flex: 1,
|
|
padding: layout.compact ? 16 : 24,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
text: { color: theme.colors.foreground },
|
|
button: { padding: 12, backgroundColor: theme.colors.accent },
|
|
buttonText: { color: theme.colors.accentForeground },
|
|
}),
|
|
[theme, layout.compact],
|
|
);
|
|
return (
|
|
<View style={styles.screen}>
|
|
<Text style={styles.text}>{greeting.data?.message ?? "Ask the daemon for a greeting."}</Text>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Create greeting"
|
|
style={styles.button}
|
|
onPress={() => greeting.mutate({ name: "Paseo" })}
|
|
>
|
|
<Text style={styles.buttonText}>Create greeting</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
accessibilityRole="link"
|
|
accessibilityLabel="Open the Paseo website"
|
|
style={styles.button}
|
|
onPress={() => openExternal("https://paseo.sh")}
|
|
>
|
|
<Text style={styles.buttonText}>Open paseo.sh</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const CLIENT_WEB = `import { Linking, Platform } from "react-native";
|
|
|
|
// This plugin typechecks without the DOM library. Declare only what this module uses.
|
|
declare const window: { open(url: string, target: string, features: string): unknown };
|
|
|
|
export async function openExternal(url: string): Promise<void> {
|
|
if (Platform.OS === "web") {
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
return;
|
|
}
|
|
await Linking.openURL(url);
|
|
}
|
|
`;
|
|
|
|
export interface PluginScaffold {
|
|
id: string;
|
|
directory: string;
|
|
}
|
|
|
|
export async function scaffoldPluginDirectory(
|
|
targetDirectory: string,
|
|
requestedId?: string,
|
|
): Promise<PluginScaffold> {
|
|
const directory = path.resolve(targetDirectory);
|
|
const id = PluginIdSchema.parse(requestedId ?? path.basename(directory));
|
|
await mkdir(directory, { recursive: true });
|
|
const existing = await readdir(directory);
|
|
if (existing.length > 0) {
|
|
throw new Error(`Plugin directory must be empty: ${directory}`);
|
|
}
|
|
|
|
const version = resolveCliVersion();
|
|
const packageJson = {
|
|
name: id,
|
|
private: true,
|
|
version: "0.0.0",
|
|
files: [
|
|
"paseo-plugin.json",
|
|
"OVERVIEW.md",
|
|
"index.client.ts",
|
|
"index.client.tsx",
|
|
"index.server.ts",
|
|
"index.server.tsx",
|
|
"client/",
|
|
"server/",
|
|
"shared/",
|
|
],
|
|
scripts: { typecheck: "tsc --noEmit" },
|
|
devDependencies: {
|
|
"@getpaseo/plugin": version,
|
|
"@tanstack/react-query": "^5.90.11",
|
|
"@types/react": "~19.2.0",
|
|
react: "19.1.0",
|
|
"react-native": "0.81.5",
|
|
typescript: "^5.9.3",
|
|
zod: "^4.4.3",
|
|
},
|
|
};
|
|
const files = new Map<string, string>([
|
|
[
|
|
"paseo-plugin.json",
|
|
`${JSON.stringify(
|
|
{
|
|
id,
|
|
$comment:
|
|
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
|
|
media: [],
|
|
requirements: { paseo: `>=${version}` },
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
],
|
|
["OVERVIEW.md", OVERVIEW],
|
|
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
|
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
|
["index.client.tsx", CLIENT_ENTRY],
|
|
["index.server.ts", SERVER_ENTRY],
|
|
["shared/greeting.ts", SHARED_GREETING],
|
|
["server/greeting.ts", SERVER_GREETING],
|
|
["client/greeting.tsx", CLIENT_GREETING],
|
|
["client/web.ts", CLIENT_WEB],
|
|
]);
|
|
await Promise.all(
|
|
["shared", "server", "client"].map((name) => mkdir(path.join(directory, name))),
|
|
);
|
|
await Promise.all(
|
|
[...files].map(([filename, contents]) =>
|
|
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
|
|
),
|
|
);
|
|
return { id, directory };
|
|
}
|