Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ Install the CLI package:
npm install -g firefox-cli
```

Print the extension path and native-host setup guidance:
Print the extension download URL and native-host setup guidance:

```sh
firefox-cli setup
```

Install the extension shown by `firefox-cli setup` - a signed `extension/firefox-cli.xpi`; open it in Firefox and accept the install prompt.
Install the extension from the URL shown by `firefox-cli setup`; open it in Firefox and accept the install prompt. The URL is selected from the update manifest for the matching CLI version.

Register the native messaging host:

Expand Down
2 changes: 1 addition & 1 deletion docs/all-commands-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Create `UPLOAD_FILE` with any small text payload. Capture IDs from JSON output w
## Steps

- [ ] Run `$CLI --version`; expect version text and exit code 0.
- [ ] Run `$CLI setup --json`; expect extension and native-host manifest paths.
- [ ] Run `$CLI setup --json`; expect an extension install URL and native-host manifest path.
- [ ] Run `$CLI setup native-host --dry-run --json`; expect a manifest plan and `"dryRun": true`.
- [ ] Run `$CLI setup native-host --json`; expect the disposable native-host manifest to be written.
- [ ] Run `$CLI doctor --json`; expect the disposable extension connection to be `"connected"`.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ The npm package contains:
- `bin/firefox-cli.js`, the user-facing launcher;
- `bin/<platform>/<binary>`, the native executable for the package platform;
- `lib/platform-binary.js`, runtime platform-binary resolution;
- `extension/development`, an unsigned extension directory for local loading;
- `extension/firefox-cli.xpi`, when a signed release extension artifact is packaged.

The signed extension XPI is distributed through the update manifest URL reported by `firefox-cli setup`. The setup command selects the download whose extension version matches the CLI version.

`doctor --fix` repairs native-host manifests after package moves or upgrades.

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ Release signing:

- `bun run extension:sign` signs `dist/extension` with `web-ext sign`.
- Set `WEB_EXT_JWT_ISSUER` to the Mozilla Add-ons JWT issuer and `WEB_EXT_JWT_SECRET` to the corresponding JWT secret.
- `bun run release:check:signed` requires `dist/package/extension/firefox-cli.xpi` in the assembled package.
- `bun run release:check:signed` requires a signed `dist/extension-artifacts/firefox-cli-<version>.xpi` artifact and matching provenance.
8 changes: 4 additions & 4 deletions docs/firefox-cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The CLI supports full browser control where Firefox WebExtensions allow it: navi
The happy path:

1. User installs the npm package and gets one executable: `firefox-cli`.
2. User manually installs or temporarily loads the Firefox extension from the URL/path printed by `firefox-cli setup`.
2. User manually installs or temporarily loads the Firefox extension from the URL printed by `firefox-cli setup`.
3. User runs `firefox-cli setup native-host` or `firefox-cli doctor --fix` to register the native messaging host.
4. User opens the extension popup and approves the first connection after seeing native-host identity details.
5. Commands control the active Firefox tab/window unless a command or flag selects another target.
Expand Down Expand Up @@ -334,7 +334,7 @@ Global options:

Setup and diagnostics:

- `firefox-cli setup`: print extension install/load instructions and native-host setup status.
- `firefox-cli setup`: print extension download instructions and native-host setup status.
- `firefox-cli setup native-host`: register/update the native messaging host.
- `firefox-cli doctor`: diagnose extension install, native manifest, host path, extension connection, approval, Firefox status, and protocol version.
- `firefox-cli doctor --fix`: repair native-host registration when possible.
Expand Down Expand Up @@ -478,7 +478,7 @@ Batch:

Errors should be concise and actionable:

- If extension is not installed: print the install/load link or local path.
- If extension is not installed: print the matching extension download URL.
- If native host is not registered: print `firefox-cli setup native-host`.
- If Firefox is not running or extension is disconnected: tell the user to open Firefox and check the extension popup.
- If first-use approval is pending: tell the user to open the extension popup and approve.
Expand Down Expand Up @@ -551,7 +551,7 @@ Package requirements:
- Postinstall may print setup guidance but must not silently mutate Firefox configuration without an explicit setup command.
- `doctor --fix` handles moved package paths after npm upgrades.
- Host-mode stdout is reserved for native messaging frames.
- Release-candidate package verification requires `extension/firefox-cli.xpi` in the assembled package.
- Release-candidate verification requires a signed `dist/extension-artifacts/firefox-cli-<version>.xpi` artifact and matching provenance.

Manual extension install is in scope; Mozilla store/public listing automation is out of scope.

Expand Down
6 changes: 3 additions & 3 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ npm install -g firefox-cli
firefox-cli setup
```

`firefox-cli setup` prints the extension artifact path and native-host setup guidance. `firefox-cli setup --json` also includes the planned native-host manifest path.
`firefox-cli setup` prints the extension download URL and native-host setup guidance. `firefox-cli setup --json` also includes the planned native-host manifest path.

Repository checkouts use:

Expand All @@ -26,9 +26,9 @@ node dist/package/bin/firefox-cli.js setup

## Install The Extension

Release packages include `extension/firefox-cli.xpi` when the signed XPI artifact is present. Open that file in Firefox and accept the install prompt.
Open the extension URL printed by `firefox-cli setup` in Firefox and accept the install prompt. The URL points at the signed XPI for the matching CLI version.

Development packages include an unsigned extension directory:
Repository checkouts can build an unsigned extension directory:

```sh
bun run extension:build
Expand Down
125 changes: 102 additions & 23 deletions packages/cli/src/cli-setup-doctor.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, posix } from "node:path";
import { access, readFile, writeFile } from "node:fs/promises";
import { posix } from "node:path";
import { createErrorResponse } from "@firefox-cli/protocol";
import { createTempDir } from "@firefox-cli/test-support";
import { describe, expect, it } from "vitest";
import { baseDependencies, baseDependenciesWithoutExtensionPath, parseSetupDryRunOutput } from "./cli-test-support.js";
import { baseDependencies, extensionUpdatesForVersion, parseSetupDryRunOutput } from "./cli-test-support.js";
import { runCli } from "./index.js";

describe("runCli setup and doctor", () => {
Expand All @@ -23,43 +23,108 @@ describe("runCli setup and doctor", () => {
await expect(readFile(darwinManifestPath(homeDir), "utf8")).resolves.toContain(binaryPath);
});

it("prints setup guidance with extension artifact location", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
extensionPath: "/opt/firefox-cli/extension/development",
});
it("prints setup guidance with a matching extension download URL", async () => {
const output = await runCli(["setup"], baseDependencies());

expect(output).toEqual({
exitCode: 0,
stdout: [
"firefox-cli setup",
"Extension: load /opt/firefox-cli/extension/development in Firefox about:debugging.",
"Extension: download and install https://github.com/respawn-llc/firefox-cli/releases/download/v0.0.0/firefox-cli-0.0.0.xpi in Firefox.",
"Native host: run `firefox-cli setup native-host`.",
"",
].join("\n"),
stderr: "",
});
});

it("prefers packaged signed extension path in setup guidance", async () => {
const packageRoot = await createTempDir("firefox-cli-package");
await mkdir(join(packageRoot, "extension"), { recursive: true });
await writeFile(join(packageRoot, "extension/firefox-cli.xpi"), "signed xpi\n");
const dependencies = baseDependenciesWithoutExtensionPath();

it("prints setup JSON with a matching extension download URL", async () => {
const jsonOutput = await runCli(["setup", "--json"], {
...dependencies,
packageRoot,
});
const textOutput = await runCli(["setup"], {
...dependencies,
packageRoot,
...baseDependencies(),
version: "0.1.1",
fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.1.1"),
});

expect(JSON.parse(jsonOutput.stdout)).toMatchObject({
extensionPath: join(packageRoot, "extension/firefox-cli.xpi"),
extensionInstallUrl: "https://github.com/respawn-llc/firefox-cli/releases/download/v0.1.1/firefox-cli-0.1.1.xpi",
});
});

it("rejects setup guidance when the update manifest lacks the CLI version", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
version: "0.1.1",
fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.1.0"),
});

expect(output).toEqual({
exitCode: 1,
stdout: "",
stderr: "No firefox-cli extension download found for CLI version 0.1.1 in https://opensource.respawn.pro/firefox-cli/updates.json.\n",
});
});

it("rejects setup guidance when the matching update version is duplicated", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
fetchExtensionUpdates: async () => ({
addons: {
"ff-cli-bridge@respawn.pro": {
updates: [
{ version: "0.0.0", update_link: "https://example.invalid/one.xpi" },
{ version: "0.0.0", update_link: "https://example.invalid/two.xpi" },
],
},
},
}),
});

expect(output).toEqual({
exitCode: 1,
stdout: "",
stderr: "Invalid firefox-cli extension update manifest: duplicate entries for version 0.0.0.\n",
});
});

it("rejects setup guidance when the update manifest is malformed", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
fetchExtensionUpdates: async () => ({ addons: {} }),
});

expect(output).toEqual({
exitCode: 1,
stdout: "",
stderr: "Invalid firefox-cli extension update manifest: expected extension update manifest add-on ff-cli-bridge@respawn.pro to be an object.\n",
});
});

it("rejects setup guidance when the matching update URL is not HTTPS", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.0.0", "http://example.invalid/firefox-cli-0.0.0.xpi"),
});

expect(output).toEqual({
exitCode: 1,
stdout: "",
stderr: "Invalid firefox-cli extension download URL for version 0.0.0: expected HTTPS URL.\n",
});
});

it("reports update manifest fetch failures during setup", async () => {
const output = await runCli(["setup"], {
...baseDependencies(),
fetchExtensionUpdates: async () => {
throw new Error("offline");
},
});

expect(output).toEqual({
exitCode: 1,
stdout: "",
stderr: "Failed to fetch firefox-cli extension update manifest from https://opensource.respawn.pro/firefox-cli/updates.json: offline\n",
});
expect(textOutput.stdout).toContain(`Extension: install ${join(packageRoot, "extension/firefox-cli.xpi")} in Firefox.`);
});

it("prints setup native-host dry-run JSON without writing the manifest", async () => {
Expand All @@ -78,6 +143,20 @@ describe("runCli setup and doctor", () => {
await expect(access(parsed.manifestPath)).rejects.toMatchObject({ code: "ENOENT" });
});

it("does not fetch extension update metadata for native-host setup", async () => {
const homeDir = await createTempDir("firefox-cli-home");
const output = await runCli(["setup", "native-host", "--dry-run"], {
...baseDependencies(),
homeDir,
fetchExtensionUpdates: async () => {
throw new Error("network should not be used");
},
});

expect(output.exitCode).toBe(0);
expect(output.stdout).toContain("Native host manifest planned:");
});

it("reports doctor setup state and fixes a missing manifest", async () => {
const homeDir = await createTempDir("firefox-cli-home");
const binaryPath = "/opt/firefox-cli/bin/darwin-arm64/firefox-cli";
Expand Down
30 changes: 15 additions & 15 deletions packages/cli/src/cli-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export function baseDependencies(): CliDependencies {
arch: "arm64",
homeDir: "/Users/tester",
binaryPath: "/opt/firefox-cli/bin/darwin-arm64/firefox-cli",
extensionPath: "/opt/firefox-cli/extension/development",
packageRoot: "/opt/firefox-cli",
cwd: "/work",
fetchExtensionUpdates: async () => extensionUpdatesForVersion("0.0.0"),
sendRequest: async (request) =>
createErrorResponse(request.id, {
code: "NATIVE_HOST_UNAVAILABLE",
Expand All @@ -20,21 +20,21 @@ export function baseDependencies(): CliDependencies {
};
}

export function baseDependenciesWithoutExtensionPath(): CliDependencies {
export function extensionUpdatesForVersion(
version: string,
updateLink = `https://github.com/respawn-llc/firefox-cli/releases/download/v${version}/firefox-cli-${version}.xpi`,
) {
return {
version: "0.0.0",
platform: "darwin",
arch: "arm64",
homeDir: "/Users/tester",
binaryPath: "/opt/firefox-cli/bin/darwin-arm64/firefox-cli",
packageRoot: "/opt/firefox-cli",
cwd: "/work",
sendRequest: async (request) =>
createErrorResponse(request.id, {
code: "NATIVE_HOST_UNAVAILABLE",
message: "firefox-cli native host is not running.",
}),
clearPairState: async () => undefined,
addons: {
"ff-cli-bridge@respawn.pro": {
updates: [
{
version,
update_link: updateLink,
},
],
},
},
};
}

Expand Down
28 changes: 7 additions & 21 deletions packages/cli/src/commands/setup-doctor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile } from "node:fs/promises";
import {
isPersistedJsonFileError,
parseNativeMessagingManifestJson,
planNativeMessagingManifest,
writeNativeMessagingManifest,
} from "@firefox-cli/native-host";
import { optionalAppDataDir } from "../default-dependencies.js";
import { resolveExtensionInstallUrl } from "../extension-updates.js";
import { ok } from "../result.js";
import { createNoopRequest, sendOrUnavailable } from "../transport.js";
import type { CliDependencies, CliResult } from "../types.js";
Expand All @@ -18,12 +18,12 @@ export async function setup(args: readonly string[], dependencies: CliDependenci

if (args.length === 0 || args.includes("--json")) {
const plan = await createManifestPlan(dependencies);
const extensionPath = await resolveExtensionInstallPath(dependencies);
const extensionInstallUrl = await resolveExtensionInstallUrl(dependencies.version, dependencies.fetchExtensionUpdates);
if (args.includes("--json")) {
return ok(
`${JSON.stringify(
{
extensionPath,
extensionInstallUrl,
nativeHostManifestPath: plan.manifestPath,
},
null,
Expand All @@ -32,7 +32,7 @@ export async function setup(args: readonly string[], dependencies: CliDependenci
);
}

return ok(["firefox-cli setup", formatExtensionSetupInstruction(extensionPath), "Native host: run `firefox-cli setup native-host`.", ""].join("\n"));
return ok(["firefox-cli setup", formatExtensionSetupInstruction(extensionInstallUrl), "Native host: run `firefox-cli setup native-host`.", ""].join("\n"));
}

if (args[0] !== "native-host") {
Expand Down Expand Up @@ -129,22 +129,8 @@ async function createManifestPlan(dependencies: CliDependencies) {
});
}

async function resolveExtensionInstallPath(dependencies: CliDependencies): Promise<string> {
if (dependencies.extensionPath !== undefined) {
return dependencies.extensionPath;
}

const signedXpiPath = resolve(dependencies.packageRoot, "extension/firefox-cli.xpi");
try {
await access(signedXpiPath);
return signedXpiPath;
} catch {
return resolve(dependencies.packageRoot, "extension/development");
}
}

function formatExtensionSetupInstruction(extensionPath: string): string {
return extensionPath.endsWith(".xpi") ? `Extension: install ${extensionPath} in Firefox.` : `Extension: load ${extensionPath} in Firefox about:debugging.`;
function formatExtensionSetupInstruction(extensionInstallUrl: string): string {
return `Extension: download and install ${extensionInstallUrl} in Firefox.`;
}

async function readNativeHostManifestStatus(plan: Awaited<ReturnType<typeof createManifestPlan>>): Promise<
Expand Down
Loading
Loading