@stacksjs/browser-extension 0.70.119 → 0.70.121
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 +28 -0
- package/dist/app-store-connect.d.ts +50 -0
- package/dist/app-store-connect.js +92 -0
- package/dist/chrome-web-store.d.ts +55 -0
- package/dist/chrome-web-store.js +137 -0
- package/dist/firefox-addons.d.ts +22 -0
- package/dist/firefox-addons.js +82 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/safari.d.ts +2 -0
- package/dist/safari.js +2 -2
- package/dist/types.d.ts +18 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -17,6 +17,29 @@ 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
|
+
|
|
20
43
|
## Safari
|
|
21
44
|
|
|
22
45
|
Safari Web Extensions ship inside a macOS app, so the safari target has two
|
|
@@ -28,6 +51,7 @@ Safari with MAIN-world content scripts + `match_about_blank`).
|
|
|
28
51
|
|
|
29
52
|
```sh
|
|
30
53
|
buddy extension:safari:init # scaffold the Xcode container app into safari/
|
|
54
|
+
buddy extension:safari:provision # register both Bundle IDs + check the app record
|
|
31
55
|
buddy extension:safari:app # build + sync into the appex + xcodebuild
|
|
32
56
|
buddy extension:safari:publish # signed archive + App Store Connect upload
|
|
33
57
|
```
|
|
@@ -53,6 +77,8 @@ export default defineExtension({
|
|
|
53
77
|
name: 'My Extension',
|
|
54
78
|
description: 'Does something useful.',
|
|
55
79
|
geckoId: 'my-ext@example.com', // required to ship on Firefox
|
|
80
|
+
chromeWebStore: { publisherId: 'publisher-id', itemId: 'extension-id' },
|
|
81
|
+
firefoxAddons: { license: 'MIT', categories: ['privacy-security'] },
|
|
56
82
|
safariBundleId: 'com.example.MyExtension', // Safari container app bundle id
|
|
57
83
|
targets: ['chrome', 'firefox'],
|
|
58
84
|
|
|
@@ -108,6 +134,8 @@ import {
|
|
|
108
134
|
buildExtension,
|
|
109
135
|
buildAllTargets,
|
|
110
136
|
packageExtension,
|
|
137
|
+
publishChromeExtension,
|
|
138
|
+
publishFirefoxExtension,
|
|
111
139
|
generateManifest,
|
|
112
140
|
rewriteBrowserNamespace,
|
|
113
141
|
scaffoldSafariApp,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AppStoreConnectAuth } from './safari';
|
|
2
|
+
import type { ExtensionConfig } from './types';
|
|
3
|
+
/** Generate the short-lived ES256 team token required by App Store Connect. */
|
|
4
|
+
export declare function appStoreConnectToken(auth: Required<AppStoreConnectAuth>, now?: unknown): string;
|
|
5
|
+
/**
|
|
6
|
+
* Register the explicit container and extension Bundle IDs required by a
|
|
7
|
+
* Safari Web Extension, then check for the manually-created App Store Connect
|
|
8
|
+
* app record. Apple does not expose an official API for creating that record.
|
|
9
|
+
*/
|
|
10
|
+
export declare function provisionSafariApp(config: ExtensionConfig, options?: SafariProvisionOptions): Promise<SafariProvisionResult>;
|
|
11
|
+
export declare interface AppStoreConnectResource<T extends Record<string, unknown>> {
|
|
12
|
+
type: string
|
|
13
|
+
id: string
|
|
14
|
+
attributes: T
|
|
15
|
+
}
|
|
16
|
+
export declare interface BundleIdAttributes extends Record<string, unknown> {
|
|
17
|
+
identifier: string
|
|
18
|
+
name: string
|
|
19
|
+
platform: BundleIdPlatform
|
|
20
|
+
seedId?: string
|
|
21
|
+
}
|
|
22
|
+
export declare interface AppAttributes extends Record<string, unknown> {
|
|
23
|
+
bundleId: string
|
|
24
|
+
name: string
|
|
25
|
+
primaryLocale: string
|
|
26
|
+
sku: string
|
|
27
|
+
}
|
|
28
|
+
export declare interface AppStoreConnectClientOptions extends AppStoreConnectAuth {
|
|
29
|
+
baseUrl?: string
|
|
30
|
+
fetch?: typeof globalThis.fetch
|
|
31
|
+
now?: () => number
|
|
32
|
+
}
|
|
33
|
+
export declare interface SafariProvisionOptions extends AppStoreConnectClientOptions {
|
|
34
|
+
checkOnly?: boolean
|
|
35
|
+
platform?: BundleIdPlatform
|
|
36
|
+
}
|
|
37
|
+
export declare interface SafariProvisionResult {
|
|
38
|
+
container: { identifier: string, exists: boolean, created: boolean }
|
|
39
|
+
extension: { identifier: string, exists: boolean, created: boolean }
|
|
40
|
+
appRecord: { exists: boolean, id?: string }
|
|
41
|
+
}
|
|
42
|
+
export type BundleIdPlatform = 'IOS' | 'MAC_OS' | 'UNIVERSAL';
|
|
43
|
+
/** Minimal official App Store Connect client for Safari provisioning checks. */
|
|
44
|
+
export declare class AppStoreConnectClient {
|
|
45
|
+
constructor(options?: AppStoreConnectClientOptions);
|
|
46
|
+
findBundleId(identifier: string): Promise<AppStoreConnectResource<BundleIdAttributes> | undefined>;
|
|
47
|
+
registerBundleId(identifier: string, name: string, platform?: BundleIdPlatform): Promise<AppStoreConnectResource<BundleIdAttributes>>;
|
|
48
|
+
ensureBundleId(identifier: string, name: string, options?: { checkOnly?: boolean, platform?: BundleIdPlatform }): Promise<{ bundleId?: AppStoreConnectResource<BundleIdAttributes>, created: boolean }>;
|
|
49
|
+
findApp(bundleId: string): Promise<AppStoreConnectResource<AppAttributes> | undefined>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolveAppStoreConnectAuth } from "./safari";
|
|
4
|
+
const appStoreConnectBaseUrl = "https://api.appstoreconnect.apple.com/v1";
|
|
5
|
+
function base64urlJson(value) {
|
|
6
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
7
|
+
}
|
|
8
|
+
export function appStoreConnectToken(auth, now = Math.floor(Date.now() / 1000)) {
|
|
9
|
+
const header = { alg: "ES256", kid: auth.keyId, typ: "JWT" }, payload = {
|
|
10
|
+
iss: auth.issuerId,
|
|
11
|
+
iat: now,
|
|
12
|
+
exp: now + 120,
|
|
13
|
+
aud: "appstoreconnect-v1"
|
|
14
|
+
}, input = `${base64urlJson(header)}.${base64urlJson(payload)}`;
|
|
15
|
+
let key;
|
|
16
|
+
try {
|
|
17
|
+
key = createPrivateKey(readFileSync(auth.keyPath, "utf8"));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw Error(`[browser-extension] App Store Connect API key could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
const signature = sign("sha256", Buffer.from(input), { key, dsaEncoding: "ieee-p1363" });
|
|
22
|
+
return `${input}.${signature.toString("base64url")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class AppStoreConnectClient {
|
|
26
|
+
auth;
|
|
27
|
+
baseUrl;
|
|
28
|
+
fetcher;
|
|
29
|
+
now;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.auth = resolveAppStoreConnectAuth(options);
|
|
32
|
+
this.baseUrl = (options.baseUrl ?? appStoreConnectBaseUrl).replace(/\/$/, "");
|
|
33
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
34
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
35
|
+
}
|
|
36
|
+
async request(path, init = {}) {
|
|
37
|
+
const response = await this.fetcher(`${this.baseUrl}${path}`, {
|
|
38
|
+
...init,
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
Authorization: `Bearer ${appStoreConnectToken(this.auth, this.now())}`,
|
|
42
|
+
...init.body ? { "Content-Type": "application/json" } : {},
|
|
43
|
+
...init.headers
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const details = (await response.json().catch(() => ({}))).errors?.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean).join("; ");
|
|
48
|
+
throw Error(`[browser-extension] App Store Connect ${init.method ?? "GET"} ${path} failed (${response.status})${details ? `: ${details}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
return await response.json();
|
|
51
|
+
}
|
|
52
|
+
async findBundleId(identifier) {
|
|
53
|
+
const query = new URLSearchParams({ "filter[identifier]": identifier });
|
|
54
|
+
return (await this.request(`/bundleIds?${query}`)).data.find((bundleId) => bundleId.attributes.identifier === identifier);
|
|
55
|
+
}
|
|
56
|
+
async registerBundleId(identifier, name, platform = "MAC_OS") {
|
|
57
|
+
return (await this.request("/bundleIds", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
data: {
|
|
61
|
+
type: "bundleIds",
|
|
62
|
+
attributes: { identifier, name, platform }
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})).data;
|
|
66
|
+
}
|
|
67
|
+
async ensureBundleId(identifier, name, options = {}) {
|
|
68
|
+
const existing = await this.findBundleId(identifier);
|
|
69
|
+
if (existing)
|
|
70
|
+
return { bundleId: existing, created: !1 };
|
|
71
|
+
if (options.checkOnly)
|
|
72
|
+
return { created: !1 };
|
|
73
|
+
return {
|
|
74
|
+
bundleId: await this.registerBundleId(identifier, name, options.platform),
|
|
75
|
+
created: !0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async findApp(bundleId) {
|
|
79
|
+
const query = new URLSearchParams({ "filter[bundleId]": bundleId });
|
|
80
|
+
return (await this.request(`/apps?${query}`)).data.find((app) => app.attributes.bundleId === bundleId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function provisionSafariApp(config, options = {}) {
|
|
84
|
+
if (!config.safariBundleId)
|
|
85
|
+
throw Error("[browser-extension] Safari provisioning needs safariBundleId in config/extension.ts");
|
|
86
|
+
const client = new AppStoreConnectClient(options), identifier = config.safariBundleId, extensionIdentifier = `${identifier}.Extension`, container = await client.ensureBundleId(identifier, config.name, options), extension = await client.ensureBundleId(extensionIdentifier, `${config.name} Safari Extension`, options), app = await client.findApp(identifier);
|
|
87
|
+
return {
|
|
88
|
+
container: { identifier, exists: Boolean(container.bundleId), created: container.created },
|
|
89
|
+
extension: { identifier: extensionIdentifier, exists: Boolean(extension.bundleId), created: extension.created },
|
|
90
|
+
appRecord: { exists: Boolean(app), id: app?.id }
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -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
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* app scaffold, appex resource sync, and xcodebuild pipeline.
|
|
9
9
|
*/
|
|
10
10
|
export * from './build';
|
|
11
|
+
export * from './app-store-connect';
|
|
12
|
+
export * from './chrome-web-store';
|
|
11
13
|
export * from './config';
|
|
14
|
+
export * from './firefox-addons';
|
|
12
15
|
export * from './manifest';
|
|
13
16
|
export * from './package';
|
|
14
17
|
export * from './safari';
|
package/dist/index.js
CHANGED
package/dist/safari.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export declare function syncSafariResources(config: ExtensionConfig, options?: S
|
|
|
38
38
|
* built `.app` path (undefined when xcodebuild was skipped/unavailable).
|
|
39
39
|
*/
|
|
40
40
|
export declare function buildSafariApp(config: ExtensionConfig, options?: SafariAppBuildOptions): Promise<{ appPath?: string, resources: string }>;
|
|
41
|
+
/** Resolve and validate App Store Connect credentials from options or environment variables. */
|
|
42
|
+
export declare function resolveAppStoreConnectAuth(options: AppStoreConnectAuth): Required<AppStoreConnectAuth>;
|
|
41
43
|
/**
|
|
42
44
|
* Create a signed Release archive and either validate it or upload it to App
|
|
43
45
|
* Store Connect. Xcode owns certificate/profile creation and the upload so the
|
package/dist/safari.js
CHANGED
|
@@ -132,7 +132,7 @@ export async function buildSafariApp(config, options = {}) {
|
|
|
132
132
|
await Bun.$`xcodebuild -project ${join(dir, `${appName}.xcodeproj`)} -scheme ${appName} -configuration ${configuration} -derivedDataPath ${derivedData} ${signing} build`;
|
|
133
133
|
return { appPath: join(derivedData, "Build", "Products", configuration, `${appName}.app`), resources };
|
|
134
134
|
}
|
|
135
|
-
function
|
|
135
|
+
export function resolveAppStoreConnectAuth(options) {
|
|
136
136
|
const keyId = options.keyId ?? process.env.APP_STORE_CONNECT_API_KEY_ID, issuerId = options.issuerId ?? process.env.APP_STORE_CONNECT_API_ISSUER_ID, keyPath = options.keyPath ?? process.env.APP_STORE_CONNECT_API_KEY_PATH, missing = [
|
|
137
137
|
!keyId && "APP_STORE_CONNECT_API_KEY_ID",
|
|
138
138
|
!issuerId && "APP_STORE_CONNECT_API_ISSUER_ID",
|
|
@@ -180,7 +180,7 @@ export async function publishSafariApp(config, options) {
|
|
|
180
180
|
const cwd = options.cwd ?? process.cwd(), teamId = options.teamId ?? config.safariTeamId;
|
|
181
181
|
if (!teamId)
|
|
182
182
|
throw Error("[browser-extension] Safari publishing needs safariTeamId in config/extension.ts or --team-id");
|
|
183
|
-
const auth =
|
|
183
|
+
const auth = resolveAppStoreConnectAuth(options), buildNumber = options.buildNumber ?? process.env.GITHUB_RUN_NUMBER ?? String(Math.floor(Date.now() / 1000));
|
|
184
184
|
if (!/^\d+(?:\.\d+){0,2}$/.test(buildNumber))
|
|
185
185
|
throw Error(`[browser-extension] invalid Safari build number ${buildNumber}; use one to three dot-separated integers`);
|
|
186
186
|
if (options.build !== !1)
|
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.
|
|
4
|
+
"version": "0.70.121",
|
|
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
|
}
|