From 80e925f56ef2269fe406322cce63e36012856a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Tue, 9 Jun 2026 14:59:43 +0000 Subject: [PATCH 1/2] feat(auth): use OS keyring in bundle distributions Bundle binaries (bun --compile) silently fell back to plaintext file storage because @napi-rs/keyring's createRequire-based platform loader isn't followed by --compile, so no native .node was embedded. Embed exactly one platform subpackage per bundle instead: - build-cli-bundles.ts keeps a placeholder keyring import external in the fat-JS step so the literal import() survives, then rewrites it per target to @napi-rs/keyring- so --compile resolves and embeds that one .node. The rewrite/write runs once per subpackage, not per target (baseline variants share their sibling's subpackage). - credentials.ts imports that placeholder in bundle mode (useCLIMetadata().installMethod === 'bundle') and the @napi-rs/keyring wrapper otherwise; all fallback/migration behavior is unchanged. - package.json pins pnpm.supportedArchitectures so every target's native subpackage is installed at build time (affects this repo's installs only, never npm consumers or the shipped bundle). - login.ts drops the now-obsolete "install via npm for keyring" hint. Closes #1170 Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 7 +++ scripts/build-cli-bundles.ts | 52 +++++++++++++++---- src/commands/auth/login.ts | 4 -- src/lib/credentials.ts | 28 ++++++++-- .../typings/keyring-native-subpackage.d.ts | 12 +++++ 5 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 src/lib/typings/keyring-native-subpackage.d.ts diff --git a/package.json b/package.json index 6ba0350fd..7084132f6 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,13 @@ "pnpm": "10.33.4" }, "packageManager": "pnpm@10.33.4", + "pnpm": { + "supportedArchitectures": { + "os": ["linux", "darwin", "win32"], + "cpu": ["x64", "arm64"], + "libc": ["glibc", "musl"] + } + }, "devEngines": { "runtime": [ { diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 5ae25d03d..6f70ea600 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -63,6 +63,28 @@ const entryPoints = [ fileURLToPath(new URL('../src/entrypoints/actor.ts', import.meta.url)), ]; +// Placeholder specifier that `credentials.ts` imports for the OS keyring in bundle mode. +// Kept external in the fat-JS step so the literal `import()` survives, then rewritten per +// target below to the matching `@napi-rs/keyring-` subpackage so Bun's `--compile` +// embeds that one native `.node`. Must match the specifier in `src/lib/credentials.ts`. +const KEYRING_PLACEHOLDER = '__APIFY_KEYRING_NATIVE_SUBPACKAGE__'; + +// Maps the compiled (os, arch, libc) to the napi-rs keyring subpackage that ships its `.node`. +// `pnpm.supportedArchitectures` (package.json) forces all of these into node_modules at build +// time so each target can resolve its own, regardless of the build machine's platform. +function keyringSubpackage(os: string, arch: string, musl: boolean): string { + switch (os) { + case 'linux': + return `@napi-rs/keyring-linux-${arch}-${musl ? 'musl' : 'gnu'}`; + case 'darwin': + return `@napi-rs/keyring-darwin-${arch}`; + case 'windows': + return `@napi-rs/keyring-win32-${arch}-msvc`; + default: + throw new Error(`No @napi-rs/keyring subpackage known for ${os}-${arch}`); + } +} + await rm(new URL('../bundles/', import.meta.url), { recursive: true, force: true }); // #region Inject the fact the CLI is ran in a bundle, instead of installed through npm/volta @@ -92,21 +114,23 @@ for (const entryPoint of entryPoints) { conditions: 'node', target: 'bun', sourcemap: 'none', + // Keep the keyring placeholder literal `import()` intact so it can be rewritten and + // resolved per target in step 2 (Bun only embeds a `.node` when --compile resolves it). + external: [KEYRING_PLACEHOLDER], }); const entrypointResultFilePath = result.outputs[0]!.path; - // Fix apify client js (it now lazy loads proxy-agent, which makes bun skip it from the bundle) - { - const entrypointResultFileContent = await result.outputs[0]!.text(); + // Fix apify client js (it now lazy loads proxy-agent, which makes bun skip it from the bundle). + // Kept in memory only — the per-target write below is what lands on disk before each compile. + const fatEntrypointContent = (await result.outputs[0]!.text()).replace( + `(0, utils_1.dynamicNodeImport)("proxy-agent")`, + `Promise.resolve().then(() => import_proxy_agent)`, + ); - const newEntrypointResultFileContent = entrypointResultFileContent.replace( - `(0, utils_1.dynamicNodeImport)("proxy-agent")`, - `Promise.resolve().then(() => import_proxy_agent)`, - ); - - await writeFile(entrypointResultFilePath, newEntrypointResultFileContent); - } + // The on-disk fat JS only varies by keyring subpackage, so we rewrite it once per subpackage + // rather than once per target (baseline variants share their sibling's subpackage). + let writtenSubpackage: string | undefined; for (const target of targets) { // eslint-disable-next-line prefer-const -- somehow it cannot tell that os and arch cannot be "const" while the rest are let @@ -141,6 +165,14 @@ for (const entryPoint of entryPoints) { console.log(`Building ${cliName} for ${target} (result: ${fileName})...`); + // Point the keyring import at this target's native subpackage so --compile embeds its + // `.node`. Skip the rewrite when the on-disk file already targets this subpackage. + const subpackage = keyringSubpackage(os, arch, Boolean(musl)); + if (subpackage !== writtenSubpackage) { + await writeFile(entrypointResultFilePath, fatEntrypointContent.replaceAll(KEYRING_PLACEHOLDER, subpackage)); + writtenSubpackage = subpackage; + } + // Step 2: create the final executable bundle await build({ entrypoints: [entrypointResultFilePath], diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 697f6f06d..ceb1498d3 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -42,10 +42,6 @@ const tryToLogin = async (token: string) => { tokenLocation = 'your OS keyring'; } else if (process.env.APIFY_DISABLE_KEYRING === '1') { tokenLocation = `${AUTH_FILE_PATH()} (OS keyring disabled via APIFY_DISABLE_KEYRING)`; - } else if (process.env.APIFY_CLI_BUNDLE) { - // Bundle distributions ship without the OS keyring native module — see - // https://github.com/apify/apify-cli/issues for the tracking issue. - tokenLocation = `${AUTH_FILE_PATH()} (OS keyring not available in bundle installs; install via npm for keyring storage, or set APIFY_DISABLE_KEYRING=1 to silence)`; } else { tokenLocation = `${AUTH_FILE_PATH()} (OS keyring unavailable; set APIFY_DISABLE_KEYRING=1 to silence)`; } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index bbde4a20d..f4a8e2d1d 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -3,6 +3,7 @@ import process from 'node:process'; import { AUTH_FILE_PATH } from './consts.js'; import { ensureApifyDirectory } from './files.js'; +import { useCLIMetadata } from './hooks/useCLIMetadata.js'; import { cliDebugPrint } from './utils/cliDebugPrint.js'; const KEYRING_SERVICE = 'com.apify.cli'; @@ -41,15 +42,36 @@ export function __resetCredentialsForTests() { async function loadKeyringModule(): Promise { if (cachedKeyringModule !== undefined) return cachedKeyringModule; + cachedKeyringModule = await importKeyringModule(); + return cachedKeyringModule; +} + +async function importKeyringModule(): Promise { + // Bundle distributions can't load the `@napi-rs/keyring` wrapper: its createRequire-based + // platform loader isn't followed by Bun's `--compile`, so the native module never makes it + // into the binary. Instead each bundle embeds exactly one platform subpackage, and the + // specifier below is rewritten to it at build time (see scripts/build-cli-bundles.ts). + if (useCLIMetadata().installMethod === 'bundle') { + try { + const mod = (await import('__APIFY_KEYRING_NATIVE_SUBPACKAGE__')) as Partial & { + default?: Partial; + }; + const Entry = mod.Entry ?? mod.default?.Entry; + return Entry ? { Entry } : null; + } catch (err) { + cliDebugPrint('credentials', 'failed to load bundled keyring', err); + return null; + } + } + try { // Indirect specifier so tsc doesn't try to resolve the module at compile time. const specifier = '@napi-rs/keyring'; - cachedKeyringModule = (await import(specifier)) as KeyringModule; + return (await import(specifier)) as KeyringModule; } catch (err) { cliDebugPrint('credentials', 'failed to load @napi-rs/keyring', err); - cachedKeyringModule = null; + return null; } - return cachedKeyringModule; } /** diff --git a/src/lib/typings/keyring-native-subpackage.d.ts b/src/lib/typings/keyring-native-subpackage.d.ts new file mode 100644 index 000000000..c6746f1d7 --- /dev/null +++ b/src/lib/typings/keyring-native-subpackage.d.ts @@ -0,0 +1,12 @@ +// The specifier below is a build-time placeholder. `scripts/build-cli-bundles.ts` +// rewrites it to the platform-specific `@napi-rs/keyring-` subpackage for each +// compiled bundle, so Bun's `--compile` embeds that one native `.node`. Outside bundles +// the import is never executed. This declaration just keeps tsc/oxlint happy. +declare module '__APIFY_KEYRING_NATIVE_SUBPACKAGE__' { + export class Entry { + constructor(service: string, account: string); + getPassword(): string | null; + setPassword(password: string): void; + deletePassword(): boolean; + } +} From c97c0d1e0c9fae15382013da58367c62e9807d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Tue, 9 Jun 2026 15:24:38 +0000 Subject: [PATCH 2/2] chore(pnpm): centralize supportedArchitectures in pnpm-workspace.yaml Move the keyring cross-platform target config out of package.json's pnpm field into pnpm-workspace.yaml, alongside the other pnpm settings (allowBuilds, overrides, nodeLinker). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 7 ------- pnpm-workspace.yaml | 12 ++++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7084132f6..6ba0350fd 100644 --- a/package.json +++ b/package.json @@ -152,13 +152,6 @@ "pnpm": "10.33.4" }, "packageManager": "pnpm@10.33.4", - "pnpm": { - "supportedArchitectures": { - "os": ["linux", "darwin", "win32"], - "cpu": ["x64", "arm64"], - "libc": ["glibc", "musl"] - } - }, "devEngines": { "runtime": [ { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index afef247b3..14103f3fd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,18 @@ minimumReleaseAgeExclude: overrides: tar: 7.5.15 +supportedArchitectures: + os: + - linux + - darwin + - win32 + cpu: + - x64 + - arm64 + libc: + - glibc + - musl + # Unfortunately, several parts of this project expect everything to be hoisted up in the node_modules directory... # And fixing it would take longer than just enabling this and shedding a tear nodeLinker: hoisted