feat(plugins): prepare scaffolded packages for publishing

This commit is contained in:
Mohamed Boudra
2026-09-17 09:57:13 +02:00
parent 86b3c2dda3
commit 78f26b77bc
6 changed files with 282 additions and 191 deletions
@@ -330,6 +330,67 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
page,
pluginEnvironment,
}, testInfo) => {
const directory = await installLocalPluginWithStatusExamples(page, pluginEnvironment, testInfo);
await reloadAndRecoverLocalPlugin(page, pluginEnvironment.client, directory);
await toggleRemoveAndReinstallLocalPlugin(page, directory);
});
test("installs a Git source after a failed source remains editable", async ({
page,
pluginEnvironment,
}, testInfo) => {
const source = await prepareCompactGitInstall(page, pluginEnvironment);
await retryGitInstallAndInspectRows(page, source, testInfo);
});
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
page,
pluginEnvironment,
}) => {
const directory = await prepareLegacyPluginHost(page, pluginEnvironment, sourceSupport);
await verifyLegacyPluginManagement(page, directory);
});
}
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
]) {
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
page,
pluginEnvironment,
}, testInfo) => {
await prepareNpmPluginHost(page, pluginEnvironment, viewport);
await retryNpmInstallAndInspectRow(page, viewport.width, testInfo);
await reloadAndRemoveNpmPlugin(page);
});
}
async function openNpmPluginSettings(page: Page, width: number) {
if (width < 600) await openCompactPluginSettings(page);
else await openPluginSettings(page);
}
async function expectSourceHierarchy(page: Page, description: string, source: string) {
const descriptionText = page.getByText(description, { exact: false });
const sourceText = page.getByText(source, { exact: false });
const descriptionSize = await descriptionText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
const sourceSize = await sourceText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
expect(sourceSize).toBeLessThan(descriptionSize);
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
}
async function installLocalPluginWithStatusExamples(
page: Page,
pluginEnvironment: PluginEnvironment,
testInfo: TestInfo,
) {
const directory = await mkdtemp(path.join(pluginEnvironment.directory, "local-"));
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
@@ -385,6 +446,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await page.getByRole("button", { name: "Close", exact: true }).click();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
return directory;
}
async function reloadAndRecoverLocalPlugin(
page: Page,
client: PluginEnvironment["client"],
directory: string,
) {
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
@@ -422,7 +491,9 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
}
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
await openPluginSettings(page);
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
@@ -443,12 +514,9 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
});
}
test("installs a Git source after a failed source remains editable", async ({
page,
pluginEnvironment,
}, testInfo) => {
async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnvironment) {
const root = pluginEnvironment.directory;
const repository = await createGitPluginRepository(root);
const longDirectory = await createDirectoryPlugin(
@@ -477,6 +545,16 @@ test("installs a Git source after a failed source remains editable", async ({
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
return { repository, missingSource };
}
async function retryGitInstallAndInspectRows(
page: Page,
source: { repository: string; missingSource: string },
testInfo: TestInfo,
) {
const { repository, missingSource } = source;
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
@@ -499,94 +577,81 @@ test("installs a Git source after a failed source remains editable", async ({
await waitForSettledPosition(removeAction);
await capturePluginInstallForm(page, testInfo, "compact-menu");
await page.keyboard.press("Escape");
});
}
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
async function prepareLegacyPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
sourceSupport: boolean | undefined,
) {
const directory = await createDirectoryPlugin(
pluginEnvironment.directory,
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const { client } = pluginEnvironment;
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
return directory;
}
async function verifyLegacyPluginManagement(page: Page, directory: string) {
await expect(
page.getByText("Update this host to install plugins", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
await expect(page.getByText(directory, { exact: true })).toBeVisible();
await selectPluginAction(page, "legacy-source-plugin", "Reload");
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
}
async function prepareNpmPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
viewport: { width: number; height: number },
) {
const { client } = pluginEnvironment;
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
}
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
await installPlugin(page, "npm:missing-plugin");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toBeVisible();
await expectSourceHierarchy(
page,
pluginEnvironment,
}) => {
const directory = await createDirectoryPlugin(
pluginEnvironment.directory,
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const { client } = pluginEnvironment;
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
await expect(
page.getByText("Update this host to install plugins", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
await expect(page.getByText(directory, { exact: true })).toBeVisible();
await selectPluginAction(page, "legacy-source-plugin", "Reload");
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
"Installed from the npm fixture registry",
"npm:@paseo-fixture/review · 2.0.0",
);
await expect(
page.getByText("Installed from the npm fixture registry", { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`npm-plugin-${width}.png`),
animations: "disabled",
});
}
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
]) {
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
page,
pluginEnvironment,
}, testInfo) => {
const { client } = pluginEnvironment;
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
await installPlugin(page, "npm:missing-plugin");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toBeVisible();
await expectSourceHierarchy(
page,
"Installed from the npm fixture registry",
"npm:@paseo-fixture/review · 2.0.0",
);
await expect(
page.getByText("Installed from the npm fixture registry", { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`npm-plugin-${viewport.width}.png`),
animations: "disabled",
});
await selectPluginAction(page, "npm-review", "Reload");
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "npm-review", "Remove");
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
});
}
async function openNpmPluginSettings(page: Page, width: number) {
if (width < 600) await openCompactPluginSettings(page);
else await openPluginSettings(page);
}
async function expectSourceHierarchy(page: Page, description: string, source: string) {
const descriptionText = page.getByText(description, { exact: false });
const sourceText = page.getByText(source, { exact: false });
const descriptionSize = await descriptionText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
const sourceSize = await sourceText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
expect(sourceSize).toBeLessThan(descriptionSize);
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
async function reloadAndRemoveNpmPlugin(page: Page) {
await selectPluginAction(page, "npm-review", "Reload");
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "npm-review", "Remove");
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
@@ -58,6 +58,16 @@ describe("plugin scaffold", () => {
name: "hello-plugin",
private: true,
version: "0.0.0",
files: [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": cliPackageJson.version,
@@ -147,6 +147,16 @@ export async function scaffoldPluginDirectory(
name: id,
private: true,
version: "0.0.0",
files: [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": version,
+2
View File
@@ -3625,7 +3625,9 @@ export const ServerInfoStatusPayloadSchema = z
pluginLogs: z.boolean().optional(),
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
pluginGitManagement: z.boolean().optional(),
// COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers.
pluginSourceInstallation: z.boolean().optional(),
// COMPAT(pluginSourceUpdates): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports reviewed updates.
pluginSourceUpdates: z.boolean().optional(),
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
@@ -4,7 +4,7 @@ import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-
describe.each(["daemon", "app"] as const)("plugin requirements on %s", (runtime) => {
it("rejects legacy manifests on the first breaking release with migration instructions", () => {
expect(() => assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime })).toThrow(
/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/,
/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/migration/,
);
});
+104 -100
View File
@@ -8,41 +8,27 @@ category: Plugins
# Publish a plugin
Publish your plugin so other people can install and use it in Paseo. Once you have a working
[plugin project](/docs/plugins), choose where to share it:
Publish your plugin so other people can install and use it in Paseo. Start with a working
[plugin project](/docs/plugins), then choose where to share it:
- [npm](#publish-on-npm): publish a package on the public npm registry.
- [Private registry](#use-a-private-github-package): share a package with your team using GitHub Packages.
- [GitHub or Git](#share-through-github-or-git): let users install directly from a repository.
- [GitHub or Git](#share-through-github-or-git): let users install from a repository.
## Publish on npm
Give your `package.json` a unique package name and version, and include the plugin files in the
published package. For a plugin with both client and server entries, add these fields to the
scaffold's existing `package.json`:
The scaffold prepares the package files and development dependencies. You choose the package name
and release version.
```json
{
"name": "@acme/paseo-review",
"version": "1.0.0",
"private": false,
"files": [
"paseo-plugin.json",
"index.client.tsx",
"index.server.ts",
"client/",
"server/",
"shared/"
]
}
### 1. Set your package name and version
From the plugin directory, set your package details and allow publication. Replace `@acme` with your npm scope:
```bash
npm pkg set name=@acme/paseo-review version=1.0.0
npm pkg delete private
```
The scaffold sets `private: true`; change it to `false` to allow publication.
Replace `@acme` with your npm scope. Match the entry filenames to your project, and include any
additional assets your plugin reads. Keep the scaffold's development dependencies and scripts.
The package name identifies the npm source; `paseo-plugin.json` supplies the installed plugin ID.
From the plugin directory, check and publish:
### 2. Check and publish
```bash
npm run typecheck
@@ -50,113 +36,131 @@ npm pack --dry-run
npm publish --access public
```
Inspect the pack output for the manifest, entries, imported files, and assets before publishing.
Then test the published package through Paseo. npm must be on the daemon host's `PATH` for installation:
Check the pack output includes any assets you added to the project.
### 3. Test the published plugin
On a daemon host with npm available:
```bash
paseo plugin install npm:@acme/paseo-review@1.0.0
```
Users can paste `npm:@acme/paseo-review` into **Settings → Plugins → Plugin source** to install the
latest release. An explicit version selects that installation; it does not pin future updates.
See [source identifiers](/docs/plugins/reference#plugin-sources) for tags, ranges, and subdirectories.
Users can also paste `npm:@acme/paseo-review` into **Settings → Plugins → Plugin source**.
### Dependencies and generated files
:::example[Package configuration]
Put libraries your plugin needs at runtime in `dependencies`. Paseo uses npm to install those
libraries and their transitive dependencies. Development dependencies are omitted and automatic
peer dependency installation is disabled. Keep Paseo's host modules—the plugin SDK, React, React
Native, TanStack Query, and Zod—in `devDependencies` for authoring; Paseo supplies their runtime
instances. Declare other required peer modules as dependencies.
Paseo compiles TypeScript and bundles imports separately for the client and server. You do not need
to precompile or bundle an ordinary TypeScript plugin. The entry names remain `index.client.ts`
or `.tsx` and `index.server.ts` or `.tsx`; at least one is required. npm's `main` and `exports`
fields do not select Paseo entries.
If your plugin generates code or assets, generate them before publishing and include the output
in `files`. Prebuilt JavaScript can live under `client/`, `server/`, or `shared/`, imported by the
normal TypeScript entry. Preserve the [runtime boundaries](/docs/plugins/reference#project-files)
and host module imports. Paseo still compiles the entries.
npm lifecycle scripts, including dependency `install`, `postinstall`, and `prepare` scripts, do not
run when Paseo acquires a package. Publish ready-to-use files where possible. If installation needs
host-specific preparation, such as rebuilding a native dependency, declare it in the manifest's
[`build` commands](/docs/plugins/reference#cli-reference). These commands run on installation and
update after the Paseo requirements check. An npm plugin does not need a build command to install
its ordinary dependencies.
## Use a private GitHub package
Paseo uses the daemon user's npm configuration and environment for registries and authentication.
For a company plugin, publish a scoped package such as `@acme/paseo-review` to GitHub Packages.
Add the repository and publication registry to its `package.json`:
The scaffold includes this `files` list in `package.json`:
```json
{
"name": "@acme/paseo-review",
"repository": {
"type": "git",
"url": "https://github.com/acme/paseo-review.git"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
"files": [
"paseo-plugin.json",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/"
]
}
```
Authenticate with GitHub Packages and run `npm publish` from the plugin directory. Follow
[GitHub's npm registry guide](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry)
for publishing credentials and package access permissions.
- Add any assets stored outside these directories to `files`.
- Keep the scaffold's SDK and host libraries in `devDependencies`.
- Add other runtime libraries with `npm install <package>`. Paseo installs their dependencies too.
- The npm package name identifies the source. The manifest's `id` identifies the installed plugin.
To install the private package, configure npm **on the daemon host, as the user running Paseo**.
Add the scope mapping to that user's `~/.npmrc`:
See the [project reference](/docs/plugins/reference#project-files) for entry points and runtime boundaries.
```ini
@acme:registry=https://npm.pkg.github.com
```
:::
Log in with a GitHub personal access token (classic) with `read:packages` and access to the package:
### Plugins with a build step
Paseo compiles TypeScript. An ordinary plugin needs no separate build before publication.
If your plugin generates files, include the generated output in the package.
**Installation scripts do not run automatically.** If a dependency needs host-specific setup,
declare a [preparation command](/docs/plugins/reference#cli-reference).
:::example[Generated files and dependencies]
- Generate code and assets before running `npm publish`.
- Keep generated JavaScript in its runtime directory and import it from the TypeScript entry.
- Include generated assets in `files`.
- Keep host-provided modules external when producing your own bundle.
- Remove Git-only dependency-install commands from the published manifest; npm installation already
installs production dependencies.
Paseo skips npm lifecycle scripts during installation, including dependency scripts. For example,
a native dependency that needs rebuilding requires an explicit preparation command.
:::
:::example[Publish a private package with GitHub Packages]
You can publish a company plugin to GitHub Packages. Follow the npm steps above, using your
organization's scope, and replace the publish command with:
```bash
npm login --scope=@acme --auth-type=legacy --registry=https://npm.pkg.github.com
paseo plugin install npm:@acme/paseo-review
npm publish --registry=https://npm.pkg.github.com
```
Use your GitHub username and the token as the password. Keep credentials on the daemon host;
enter only the source identifier in the app. Updates use the host's current npm configuration too.
Other npm-compatible registries use the same npm configuration mechanism.
[Configure GitHub authentication and package access](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry)
before publishing.
To install the plugin, configure npm **on the daemon host, as the user running Paseo**:
1. Add your organization's registry to `~/.npmrc`:
```ini
@acme:registry=https://npm.pkg.github.com
```
2. Log in with your GitHub username and a personal access token (classic) as the password.
The token needs `read:packages` and access to the package.
```bash
npm login --scope=@acme --auth-type=legacy --registry=https://npm.pkg.github.com
```
3. Install the plugin:
```bash
paseo plugin install npm:@acme/paseo-review
```
Paseo uses the host's npm registry settings and credentials for installation and updates.
In the app, enter only the source identifier.
:::
## Share through GitHub or Git
You can also share a repository containing the plugin project:
Push the plugin project to a repository. Users can install it with:
```bash
paseo plugin install github:acme/paseo-review
```
For another Git host:
```bash
paseo plugin install git:https://git.example.com/acme/paseo-review.git
```
For a plugin that only imports host modules, no preparation is needed. If it has runtime npm
dependencies, commit `package.json` and `package-lock.json`, then add this to `paseo-plugin.json`:
If your plugin has runtime npm dependencies, commit `package-lock.json` and add a preparation
command to `paseo-plugin.json`:
```json
{
"id": "paseo-review",
"requirements": { "paseo": ">=0.8.0" },
"build": [["npm", "ci", "--omit=dev"]]
}
```
`npm ci` installs from the committed lockfile and fails if it disagrees with `package.json`.
`--omit=dev` leaves authoring tools out of the installation. Paseo runs the command in the plugin
directory on the daemon host, where npm must be on `PATH`. Git installation does not infer this
command from the presence of a package file.
- `npm ci` installs the versions in the committed lockfile.
- `--omit=dev` excludes development tools.
- npm must be available on the daemon host.
If additional preparation needs development tools, `--omit=dev` will not install those tools.
Prefer generating and publishing the required files on npm. If you distribute the same project on
npm and Git, omit the dependency-install build command from the published manifest: npm acquisition
already installs production dependencies.
Set `requirements.paseo` to the earliest release whose APIs your plugin uses. `>=0.8.0` permits
compatible later releases, including 0.9; add an upper bound only for a known incompatibility.
See [requirements](/docs/plugins/reference#requirements).
Plugins that only use Paseo's host libraries need no preparation command.