@stacksjs/browser-extension 0.70.120 → 0.70.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,52 @@ buddy extension:build --target safari # → dist-safari/ (browser.* namespace,
17
17
  buddy extension:package # build + zip store-ready archives
18
18
  ```
19
19
 
20
+ ## Store publishing
21
+
22
+ Stacks owns the store-specific upload clients as well as the builds. Chrome
23
+ uses Web Store API v2 with a service account; Firefox uses Mozilla's official
24
+ `web-ext` client and AMO v5 API; Safari uses App Store Connect and Xcode.
25
+
26
+ ```sh
27
+ buddy extension:chrome:status
28
+ buddy extension:chrome:publish # build, zip, upload, submit for review
29
+ buddy extension:chrome:publish --upload-only
30
+ buddy extension:firefox:publish # build, submit, sign through AMO
31
+ buddy extension:safari:provision # register Bundle IDs + check app record
32
+ buddy extension:safari:publish
33
+ ```
34
+
35
+ Chrome reads `CHROME_WEB_STORE_SERVICE_ACCOUNT_PATH` (or
36
+ `GOOGLE_APPLICATION_CREDENTIALS`) and the configured
37
+ `chromeWebStore.publisherId`/`itemId`. The API only updates existing items, so
38
+ create the initial Developer Dashboard item once and link the service-account
39
+ email to the publisher account. Firefox reads `AMO_JWT_ISSUER` and
40
+ `AMO_JWT_SECRET`; `web-ext` can create the initial listing when
41
+ `firefoxAddons.license` and `firefoxAddons.categories` are configured.
42
+
43
+ For tag-driven publication, call Stacks' reusable workflow from the extension
44
+ repository instead of duplicating store orchestration:
45
+
46
+ ```yaml
47
+ jobs:
48
+ publish:
49
+ uses: stacksjs/stacks/.github/workflows/browser-extension-release.yml@v0.70.122
50
+ with:
51
+ chrome-publisher-id: ${{ vars.CHROME_WEB_STORE_PUBLISHER_ID }}
52
+ safari-enabled: ${{ vars.ENABLE_SAFARI_PUBLISH == 'true' }}
53
+ secrets:
54
+ CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }}
55
+ AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }}
56
+ AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }}
57
+ APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
58
+ APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
59
+ APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
60
+ ```
61
+
62
+ It packages every configured target, publishes Chrome and Firefox in
63
+ independent jobs, optionally uploads Safari with stable Xcode, and creates the
64
+ GitHub Release only after the enabled stores succeed.
65
+
20
66
  ## Safari
21
67
 
22
68
  Safari Web Extensions ship inside a macOS app, so the safari target has two
@@ -54,6 +100,8 @@ export default defineExtension({
54
100
  name: 'My Extension',
55
101
  description: 'Does something useful.',
56
102
  geckoId: 'my-ext@example.com', // required to ship on Firefox
103
+ chromeWebStore: { publisherId: 'publisher-id', itemId: 'extension-id' },
104
+ firefoxAddons: { license: 'MIT', categories: ['privacy-security'] },
57
105
  safariBundleId: 'com.example.MyExtension', // Safari container app bundle id
58
106
  targets: ['chrome', 'firefox'],
59
107
 
@@ -109,6 +157,8 @@ import {
109
157
  buildExtension,
110
158
  buildAllTargets,
111
159
  packageExtension,
160
+ publishChromeExtension,
161
+ publishFirefoxExtension,
112
162
  generateManifest,
113
163
  rewriteBrowserNamespace,
114
164
  scaffoldSafariApp,
@@ -0,0 +1,55 @@
1
+ import type { ChromeWebStoreConfig, ExtensionConfig } from './types';
2
+ /** Build the RS256 assertion Google exchanges for a short-lived OAuth token. */
3
+ export declare function chromeWebStoreServiceAccountAssertion(serviceAccount: GoogleServiceAccount, now?: unknown): string;
4
+ /** Build, package, upload, and optionally submit an existing Chrome Web Store item. */
5
+ export declare function publishChromeExtension(config: ExtensionConfig, options: ChromeWebStorePublishOptions): Promise<{ packagePath: string, upload: ChromeUploadResult, publish?: ChromePublishResult }>;
6
+ export declare interface GoogleServiceAccount {
7
+ client_email: string
8
+ private_key: string
9
+ token_uri?: string
10
+ }
11
+ export declare interface ChromeWebStoreAuth {
12
+ accessToken?: string
13
+ serviceAccountPath?: string
14
+ }
15
+ export declare interface ChromeWebStoreClientOptions extends ChromeWebStoreAuth {
16
+ baseUrl?: string
17
+ fetch?: typeof globalThis.fetch
18
+ now?: () => number
19
+ wait?: (milliseconds: number) => Promise<void>
20
+ }
21
+ export declare interface ChromeWebStoreStatus {
22
+ name: string
23
+ itemId: string
24
+ lastAsyncUploadState?: 'UPLOAD_STATE_UNSPECIFIED' | 'SUCCEEDED' | 'IN_PROGRESS' | 'FAILED' | 'NOT_FOUND'
25
+ publishedItemRevisionStatus?: { state: string, distributionChannels?: Array<{ deployPercentage: number, crxVersion: string }> }
26
+ submittedItemRevisionStatus?: { state: string, distributionChannels?: Array<{ deployPercentage: number, crxVersion: string }> }
27
+ takenDown?: boolean
28
+ warned?: boolean
29
+ }
30
+ export declare interface ChromeUploadResult {
31
+ name: string
32
+ itemId: string
33
+ crxVersion?: string
34
+ uploadState: 'UPLOAD_STATE_UNSPECIFIED' | 'SUCCEEDED' | 'IN_PROGRESS' | 'FAILED' | 'NOT_FOUND'
35
+ }
36
+ export declare interface ChromePublishResult {
37
+ name: string
38
+ itemId: string
39
+ state: string
40
+ warningInfo?: { warnings?: Array<{ reason: string, description: string }> }
41
+ }
42
+ export declare interface ChromeWebStorePublishOptions extends ChromeWebStoreClientOptions {
43
+ version: string
44
+ cwd?: string
45
+ packagePath?: string
46
+ uploadOnly?: boolean
47
+ blockOnWarnings?: boolean
48
+ }
49
+ export declare class ChromeWebStoreClient {
50
+ constructor(options?: ChromeWebStoreClientOptions);
51
+ fetchStatus(config: ChromeWebStoreConfig): Promise<ChromeWebStoreStatus>;
52
+ upload(config: ChromeWebStoreConfig, packagePath: string): Promise<ChromeUploadResult>;
53
+ publish(config: ChromeWebStoreConfig, options?: { blockOnWarnings?: boolean }): Promise<ChromePublishResult>;
54
+ waitForUpload(config: ChromeWebStoreConfig, maxAttempts?: number): Promise<ChromeWebStoreStatus>;
55
+ }
@@ -0,0 +1,137 @@
1
+ import { createPrivateKey, sign } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { packageExtension } from "./package";
5
+ const chromeWebStoreScope = "https://www.googleapis.com/auth/chromewebstore", chromeWebStoreBaseUrl = "https://chromewebstore.googleapis.com";
6
+ function base64urlJson(value) {
7
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
8
+ }
9
+ export function chromeWebStoreServiceAccountAssertion(serviceAccount, now = Math.floor(Date.now() / 1000)) {
10
+ const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token", input = `${base64urlJson({ alg: "RS256", typ: "JWT" })}.${base64urlJson({
11
+ iss: serviceAccount.client_email,
12
+ scope: chromeWebStoreScope,
13
+ aud: tokenUri,
14
+ iat: now,
15
+ exp: now + 3600
16
+ })}`;
17
+ let key;
18
+ try {
19
+ key = createPrivateKey(serviceAccount.private_key);
20
+ } catch (error) {
21
+ throw Error(`[browser-extension] Chrome Web Store service-account key could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ return `${input}.${sign("sha256", Buffer.from(input), key).toString("base64url")}`;
24
+ }
25
+ async function resolveChromeAccessToken(options) {
26
+ const supplied = options.accessToken ?? process.env.CHROME_WEB_STORE_ACCESS_TOKEN;
27
+ if (supplied)
28
+ return supplied;
29
+ const configuredPath = options.serviceAccountPath ?? process.env.CHROME_WEB_STORE_SERVICE_ACCOUNT_PATH ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
30
+ if (!configuredPath)
31
+ throw Error("[browser-extension] missing Chrome Web Store credentials: CHROME_WEB_STORE_SERVICE_ACCOUNT_PATH or CHROME_WEB_STORE_ACCESS_TOKEN");
32
+ const keyPath = resolve(configuredPath);
33
+ if (!existsSync(keyPath))
34
+ throw Error(`[browser-extension] Chrome Web Store service-account key not found: ${keyPath}`);
35
+ let serviceAccount;
36
+ try {
37
+ serviceAccount = JSON.parse(readFileSync(keyPath, "utf8"));
38
+ } catch (error) {
39
+ throw Error(`[browser-extension] Chrome Web Store service-account JSON could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ if (!serviceAccount.client_email || !serviceAccount.private_key)
42
+ throw Error("[browser-extension] Chrome Web Store service-account JSON needs client_email and private_key");
43
+ const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token", response = await (options.fetch ?? globalThis.fetch)(tokenUri, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
46
+ body: new URLSearchParams({
47
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
48
+ assertion: chromeWebStoreServiceAccountAssertion(serviceAccount, options.now?.())
49
+ })
50
+ }), body = await response.json().catch(() => ({}));
51
+ if (!response.ok || !body.access_token)
52
+ throw Error(`[browser-extension] Google OAuth token exchange failed (${response.status})${body.error_description ? `: ${body.error_description}` : ""}`);
53
+ return body.access_token;
54
+ }
55
+ function storeItemName(config) {
56
+ return `publishers/${encodeURIComponent(config.publisherId)}/items/${encodeURIComponent(config.itemId)}`;
57
+ }
58
+
59
+ export class ChromeWebStoreClient {
60
+ options;
61
+ baseUrl;
62
+ accessToken;
63
+ constructor(options = {}) {
64
+ this.options = options;
65
+ this.baseUrl = (options.baseUrl ?? chromeWebStoreBaseUrl).replace(/\/$/, "");
66
+ }
67
+ async request(path, init = {}) {
68
+ this.accessToken ??= resolveChromeAccessToken(this.options);
69
+ const response = await (this.options.fetch ?? globalThis.fetch)(`${this.baseUrl}${path}`, {
70
+ ...init,
71
+ headers: {
72
+ Authorization: `Bearer ${await this.accessToken}`,
73
+ ...init.headers
74
+ }
75
+ });
76
+ if (!response.ok) {
77
+ const body = await response.json().catch(() => ({})), detail = body.error?.message ?? body.message;
78
+ throw Error(`[browser-extension] Chrome Web Store ${init.method ?? "GET"} ${path} failed (${response.status})${detail ? `: ${detail}` : ""}`);
79
+ }
80
+ return await response.json();
81
+ }
82
+ fetchStatus(config) {
83
+ return this.request(`/v2/${storeItemName(config)}:fetchStatus`);
84
+ }
85
+ async upload(config, packagePath) {
86
+ const file = Bun.file(resolve(packagePath));
87
+ if (!await file.exists())
88
+ throw Error(`[browser-extension] Chrome package not found: ${resolve(packagePath)}`);
89
+ return await this.request(`/upload/v2/${storeItemName(config)}:upload`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/zip" },
92
+ body: new Uint8Array(await file.arrayBuffer())
93
+ });
94
+ }
95
+ publish(config, options = {}) {
96
+ if (config.deployPercentage !== void 0 && (!Number.isInteger(config.deployPercentage) || config.deployPercentage < 0 || config.deployPercentage > 100))
97
+ throw Error("[browser-extension] chromeWebStore.deployPercentage must be an integer from 0 to 100");
98
+ const body = {
99
+ publishType: config.publishType ?? "DEFAULT_PUBLISH",
100
+ skipReview: config.skipReview ?? !1,
101
+ blockOnWarnings: options.blockOnWarnings ?? !0
102
+ };
103
+ if (config.deployPercentage !== void 0)
104
+ body.deployInfos = [{ deployPercentage: config.deployPercentage }];
105
+ return this.request(`/v2/${storeItemName(config)}:publish`, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify(body)
109
+ });
110
+ }
111
+ async waitForUpload(config, maxAttempts = 30) {
112
+ for (let attempt = 0;attempt < maxAttempts; attempt += 1) {
113
+ const status = await this.fetchStatus(config);
114
+ if (status.lastAsyncUploadState !== "IN_PROGRESS") {
115
+ if (status.lastAsyncUploadState === "FAILED")
116
+ throw Error("[browser-extension] Chrome Web Store package processing failed");
117
+ return status;
118
+ }
119
+ await (this.options.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))))(2000);
120
+ }
121
+ throw Error("[browser-extension] Chrome Web Store package processing timed out");
122
+ }
123
+ }
124
+ export async function publishChromeExtension(config, options) {
125
+ if (!config.chromeWebStore)
126
+ throw Error("[browser-extension] Chrome publishing needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");
127
+ const cwd = options.cwd ?? process.cwd(), packagePath = options.packagePath ?? await packageExtension(config, { target: "chrome", version: options.version, cwd }), client = new ChromeWebStoreClient(options), upload = await client.upload(config.chromeWebStore, packagePath);
128
+ if (upload.uploadState === "IN_PROGRESS")
129
+ await client.waitForUpload(config.chromeWebStore);
130
+ else if (upload.uploadState !== "SUCCEEDED")
131
+ throw Error(`[browser-extension] Chrome Web Store upload failed with state ${upload.uploadState}`);
132
+ return {
133
+ packagePath,
134
+ upload,
135
+ publish: options.uploadOnly ? void 0 : await client.publish(config.chromeWebStore, { blockOnWarnings: options.blockOnWarnings })
136
+ };
137
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExtensionConfig, FirefoxAddonsConfig } from './types';
2
+ /** Metadata accepted by AMO v5 when web-ext creates the first listed version. */
3
+ export declare function firefoxListingMetadata(config: ExtensionConfig, store: FirefoxAddonsConfig): Record<string, unknown> | undefined;
4
+ /** Build and submit a Firefox extension through Mozilla's official web-ext/AMO v5 flow. */
5
+ export declare function publishFirefoxExtension(config: ExtensionConfig, options: FirefoxPublishOptions): Promise<FirefoxPublishResult>;
6
+ export declare interface FirefoxAddonsAuth {
7
+ issuer?: string
8
+ secret?: string
9
+ }
10
+ export declare interface FirefoxPublishOptions extends FirefoxAddonsAuth {
11
+ version: string
12
+ cwd?: string
13
+ build?: boolean
14
+ sourceCodePath?: string
15
+ timeout?: number
16
+ approvalTimeout?: number
17
+ }
18
+ export declare interface FirefoxPublishResult {
19
+ artifactsDir: string
20
+ artifacts: string[]
21
+ channel: 'listed' | 'unlisted'
22
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { buildExtension, resolveOutdir } from "./build";
6
+ function resolveFirefoxAuth(options) {
7
+ const issuer = options.issuer ?? process.env.AMO_JWT_ISSUER ?? process.env.WEB_EXT_API_KEY, secret = options.secret ?? process.env.AMO_JWT_SECRET ?? process.env.WEB_EXT_API_SECRET, missing = [!issuer && "AMO_JWT_ISSUER", !secret && "AMO_JWT_SECRET"].filter(Boolean);
8
+ if (missing.length)
9
+ throw Error(`[browser-extension] missing Firefox Add-ons credentials: ${missing.join(", ")}`);
10
+ return { issuer, secret };
11
+ }
12
+ export function firefoxListingMetadata(config, store) {
13
+ if (!store.license && !store.categories?.length)
14
+ return;
15
+ if (!store.license || !store.categories?.length)
16
+ throw Error("[browser-extension] a new Firefox listing needs both firefoxAddons.license and firefoxAddons.categories");
17
+ return {
18
+ version: { license: store.license },
19
+ categories: { firefox: store.categories },
20
+ summary: { "en-US": config.description },
21
+ ...store.homepage ? { homepage: { "en-US": store.homepage } } : {},
22
+ ...store.supportEmail ? { support_email: { "en-US": store.supportEmail } } : {},
23
+ requires_payment: store.requiresPayment ?? !1
24
+ };
25
+ }
26
+ export async function publishFirefoxExtension(config, options) {
27
+ if (!config.geckoId)
28
+ throw Error("[browser-extension] Firefox publishing needs geckoId in config/extension.ts");
29
+ const store = config.firefoxAddons ?? {}, auth = resolveFirefoxAuth(options), cwd = options.cwd ?? process.cwd();
30
+ if (options.build !== !1)
31
+ await buildExtension(config, { target: "firefox", version: options.version, cwd });
32
+ const sourceDir = resolve(cwd, resolveOutdir(config, "firefox")), artifactsDir = resolve(cwd, store.artifactsDir ?? "web-ext-artifacts"), executable = Bun.which("web-ext");
33
+ if (!executable)
34
+ throw Error("[browser-extension] web-ext is unavailable; reinstall @stacksjs/browser-extension dependencies");
35
+ await mkdir(artifactsDir, { recursive: !0 });
36
+ const before = new Set(existsSync(artifactsDir) ? readdirSync(artifactsDir) : []), tempDir = await mkdtemp(join(tmpdir(), "stacks-firefox-publish-"));
37
+ try {
38
+ const args = [
39
+ executable,
40
+ "sign",
41
+ "--source-dir",
42
+ sourceDir,
43
+ "--artifacts-dir",
44
+ artifactsDir,
45
+ "--channel",
46
+ store.channel ?? "listed",
47
+ "--timeout",
48
+ String(options.timeout ?? 300000),
49
+ "--approval-timeout",
50
+ String(options.approvalTimeout ?? 0),
51
+ "--no-input",
52
+ "--boring"
53
+ ], metadata = firefoxListingMetadata(config, store);
54
+ if (metadata) {
55
+ const metadataPath = join(tempDir, "amo-metadata.json");
56
+ await Bun.write(metadataPath, `${JSON.stringify(metadata, null, 2)}
57
+ `);
58
+ args.push("--amo-metadata", metadataPath);
59
+ }
60
+ if (options.sourceCodePath)
61
+ args.push("--upload-source-code", resolve(cwd, options.sourceCodePath));
62
+ const exitCode = await Bun.spawn(args, {
63
+ cwd,
64
+ env: {
65
+ ...process.env,
66
+ WEB_EXT_API_KEY: auth.issuer,
67
+ WEB_EXT_API_SECRET: auth.secret
68
+ },
69
+ stdout: "inherit",
70
+ stderr: "inherit"
71
+ }).exited;
72
+ if (exitCode !== 0)
73
+ throw Error(`[browser-extension] Firefox Add-ons submission failed (${exitCode})`);
74
+ } finally {
75
+ await rm(tempDir, { recursive: !0, force: !0 });
76
+ }
77
+ return {
78
+ artifactsDir,
79
+ artifacts: readdirSync(artifactsDir).filter((file) => !before.has(file)),
80
+ channel: store.channel ?? "listed"
81
+ };
82
+ }
package/dist/index.d.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  */
10
10
  export * from './build';
11
11
  export * from './app-store-connect';
12
+ export * from './chrome-web-store';
12
13
  export * from './config';
14
+ export * from './firefox-addons';
13
15
  export * from './manifest';
14
16
  export * from './package';
15
17
  export * from './safari';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./build";
2
2
  export * from "./app-store-connect";
3
+ export * from "./chrome-web-store";
3
4
  export * from "./config";
5
+ export * from "./firefox-addons";
4
6
  export * from "./manifest";
5
7
  export * from "./package";
6
8
  export * from "./safari";
package/dist/types.d.ts CHANGED
@@ -52,10 +52,28 @@ export declare interface BuildContext {
52
52
  version: string
53
53
  cwd: string
54
54
  }
55
+ export declare interface ChromeWebStoreConfig {
56
+ publisherId: string
57
+ itemId: string
58
+ publishType?: 'DEFAULT_PUBLISH' | 'STAGED_PUBLISH'
59
+ deployPercentage?: number
60
+ skipReview?: boolean
61
+ }
62
+ export declare interface FirefoxAddonsConfig {
63
+ channel?: 'listed' | 'unlisted'
64
+ license?: string
65
+ categories?: string[]
66
+ homepage?: string
67
+ supportEmail?: string
68
+ requiresPayment?: boolean
69
+ artifactsDir?: string
70
+ }
55
71
  export declare interface ExtensionConfig {
56
72
  name: string
57
73
  description: string
58
74
  geckoId?: string
75
+ chromeWebStore?: ChromeWebStoreConfig
76
+ firefoxAddons?: FirefoxAddonsConfig
59
77
  safariBundleId?: string
60
78
  safariTeamId?: string
61
79
  safariExclude?: string[]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.120",
4
+ "version": "0.70.122",
5
5
  "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -59,5 +59,8 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "better-dx": "^0.2.17"
62
+ },
63
+ "dependencies": {
64
+ "web-ext": "^10.5.0"
62
65
  }
63
66
  }