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 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; + } +}