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/9] 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/9] 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 From 47957793ac10eb42a24c44479d1fcc1d48f47ef5 Mon Sep 17 00:00:00 2001 From: Richard Solar Date: Mon, 22 Jun 2026 14:40:31 +0200 Subject: [PATCH 3/9] Update scripts/build-cli-bundles.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Hanuš --- scripts/build-cli-bundles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 6f70ea600..57e5f7409 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -70,7 +70,7 @@ const entryPoints = [ 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 +// `supportedArchitectures` (pnpm-workspace.yaml) 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) { From db4902f78fa2a5b04b4f0e65ace49a89c27f4f7d Mon Sep 17 00:00:00 2001 From: Richard Solar Date: Mon, 22 Jun 2026 14:42:43 +0200 Subject: [PATCH 4/9] Update pnpm-workspace.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Hanuš --- pnpm-workspace.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 81ae5202c..446576605 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,8 @@ minimumReleaseAgeExclude: overrides: tar: 7.5.16 +# Install native deps for all platforms, not just the current machine's, so the +# cross-platform bundle builds (scripts/build-cli-bundles.ts) can embed them. supportedArchitectures: os: - linux From 4ee0153e5b43f3ed15b5d91bad48eeb5dd0b3327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 22 Jun 2026 14:53:12 +0200 Subject: [PATCH 5/9] docs(keyring): clarify native subpackage d.ts rationale Explain why keyring native subpackage typings are declared by hand, not re-exported. --- src/lib/typings/keyring-native-subpackage.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/typings/keyring-native-subpackage.d.ts b/src/lib/typings/keyring-native-subpackage.d.ts index c6746f1d7..c61e0fb06 100644 --- a/src/lib/typings/keyring-native-subpackage.d.ts +++ b/src/lib/typings/keyring-native-subpackage.d.ts @@ -2,6 +2,12 @@ // 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. +// +// Declared by hand rather than re-exported from `@napi-rs/keyring`: the placeholder +// resolves to the `@napi-rs/keyring-` native subpackage (the module the bundle +// actually imports, bypassing the wrapper), and those subpackages ship only the `.node` +// binary with no `.d.ts`. We pin just the sync methods `credentials.ts` uses; the +// structural contract there is what guards against upstream drift. declare module '__APIFY_KEYRING_NATIVE_SUBPACKAGE__' { export class Entry { constructor(service: string, account: string); From 1ffccb1c2de37e85f88f3afe88d99a13d40a5c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 22 Jun 2026 13:05:52 +0000 Subject: [PATCH 6/9] fix(bundles): fail build if keyring placeholder is missing from fat JS replaceAll is silent when the placeholder is absent, which would silently ship a bundle that falls back to plaintext storage. Throw at build time instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/build-cli-bundles.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 57e5f7409..a1077dec9 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -169,6 +169,12 @@ for (const entryPoint of entryPoints) { // `.node`. Skip the rewrite when the on-disk file already targets this subpackage. const subpackage = keyringSubpackage(os, arch, Boolean(musl)); if (subpackage !== writtenSubpackage) { + // `replaceAll` is silent if the placeholder is gone, shipping a bundle that falls back to + // plaintext storage. Fail loud instead. + if (!fatEntrypointContent.includes(KEYRING_PLACEHOLDER)) { + throw new Error(`Keyring placeholder "${KEYRING_PLACEHOLDER}" not found in the fat JS for ${cliName}.`); + } + await writeFile(entrypointResultFilePath, fatEntrypointContent.replaceAll(KEYRING_PLACEHOLDER, subpackage)); writtenSubpackage = subpackage; } From 0b2c7b04dfe85f801fe2d6d23eb1c8aab26a9d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Wed, 24 Jun 2026 17:36:47 +0200 Subject: [PATCH 7/9] fix(bundles): always write patched fat js before compile --- scripts/build-cli-bundles.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index a1077dec9..e710ab5cc 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -128,9 +128,11 @@ for (const entryPoint of entryPoints) { `Promise.resolve().then(() => import_proxy_agent)`, ); - // 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; + // `replaceAll` is silent if the placeholder is gone, shipping a bundle that falls back to + // plaintext storage. Validate once up front and fail loud. + if (!fatEntrypointContent.includes(KEYRING_PLACEHOLDER)) { + throw new Error(`Keyring placeholder "${KEYRING_PLACEHOLDER}" not found in the fat JS for ${cliName}.`); + } 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 @@ -166,18 +168,10 @@ 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. + // `.node`. Rewrite every iteration so the compiled file is never the stale, unpatched + // Bun output — the proxy-agent fix above lands on disk here too, not just the keyring swap. const subpackage = keyringSubpackage(os, arch, Boolean(musl)); - if (subpackage !== writtenSubpackage) { - // `replaceAll` is silent if the placeholder is gone, shipping a bundle that falls back to - // plaintext storage. Fail loud instead. - if (!fatEntrypointContent.includes(KEYRING_PLACEHOLDER)) { - throw new Error(`Keyring placeholder "${KEYRING_PLACEHOLDER}" not found in the fat JS for ${cliName}.`); - } - - await writeFile(entrypointResultFilePath, fatEntrypointContent.replaceAll(KEYRING_PLACEHOLDER, subpackage)); - writtenSubpackage = subpackage; - } + await writeFile(entrypointResultFilePath, fatEntrypointContent.replaceAll(KEYRING_PLACEHOLDER, subpackage)); // Step 2: create the final executable bundle await build({ From 57b43e2f32a0902611b7b618312a5dfe9d034c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 25 Jun 2026 07:56:06 +0000 Subject: [PATCH 8/9] refactor(auth): re-export keyring Entry type from wrapper --- src/lib/typings/keyring-native-subpackage.d.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/lib/typings/keyring-native-subpackage.d.ts b/src/lib/typings/keyring-native-subpackage.d.ts index c61e0fb06..5f9b66136 100644 --- a/src/lib/typings/keyring-native-subpackage.d.ts +++ b/src/lib/typings/keyring-native-subpackage.d.ts @@ -3,16 +3,11 @@ // 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. // -// Declared by hand rather than re-exported from `@napi-rs/keyring`: the placeholder -// resolves to the `@napi-rs/keyring-` native subpackage (the module the bundle -// actually imports, bypassing the wrapper), and those subpackages ship only the `.node` -// binary with no `.d.ts`. We pin just the sync methods `credentials.ts` uses; the -// structural contract there is what guards against upstream drift. +// The type is sourced from the `@napi-rs/keyring` wrapper, which is decoupled from runtime +// resolution (the bundle rewrites the specifier in the fat JS; the `.d.ts` is never read by +// the bundler). The wrapper re-exports the same NAPI `Entry` the native subpackage binds, so +// this stays accurate for free. Re-exporting from the subpackage itself wouldn't work — those +// ship only the `.node` binary with no `.d.ts`, so `Entry` would silently degrade to `any`. declare module '__APIFY_KEYRING_NATIVE_SUBPACKAGE__' { - export class Entry { - constructor(service: string, account: string); - getPassword(): string | null; - setPassword(password: string): void; - deletePassword(): boolean; - } + export type { Entry } from '@napi-rs/keyring'; } From 4c84daa2484c6cc7ed3bedf3666039040e16239b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 25 Jun 2026 08:06:13 +0000 Subject: [PATCH 9/9] refactor: drop unused APIFY_CLI_MARKED_INSTALL_METHOD override --- src/lib/hooks/useCLIMetadata.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/hooks/useCLIMetadata.ts b/src/lib/hooks/useCLIMetadata.ts index c7b71ab60..a90a8c30d 100644 --- a/src/lib/hooks/useCLIMetadata.ts +++ b/src/lib/hooks/useCLIMetadata.ts @@ -29,11 +29,6 @@ export interface CLIMetadata { } function detectInstallMethod(): InstallMethod { - // Will be useful once npm versions move to running from bundles (and for testing) - if (process.env.APIFY_CLI_MARKED_INSTALL_METHOD) { - return process.env.APIFY_CLI_MARKED_INSTALL_METHOD as InstallMethod; - } - // This if check is special, and gets replaced with an always-true branch when running from bun bundles if (process.env.APIFY_CLI_BUNDLE) { return 'bundle';