diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index b0cc98976b..5b7042d8fa 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -3182,8 +3182,8 @@ msgid "Rewards will be sent on Ethereum to" msgstr "Rewards will be sent on Ethereum to" #: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx -msgid "Bridging without swapping is not yet supported. Let us know if you want this feature!" -msgstr "Bridging without swapping is not yet supported. Let us know if you want this feature!" +#~ msgid "Bridging without swapping is not yet supported. Let us know if you want this feature!" +#~ msgstr "Bridging without swapping is not yet supported. Let us know if you want this feature!" #: apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderCodeInfo.tsx msgid "Rewards end" @@ -7128,8 +7128,8 @@ msgid "Advanced" msgstr "Advanced" #: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx -msgid "Not yet supported" -msgstr "Not yet supported" +#~ msgid "Not yet supported" +#~ msgstr "Not yet supported" #: apps/cowswap-frontend/src/modules/affiliate/pure/AffiliateTraderModal/AffiliateTradeCodeForm.tsx #: apps/cowswap-frontend/src/modules/hooksStore/pure/HookListItem/index.tsx diff --git a/apps/cowswap-frontend/src/modules/tradeQuote/hooks/__snapshots__/useTradeQuotePolling.test.tsx.snap b/apps/cowswap-frontend/src/modules/tradeQuote/hooks/__snapshots__/useTradeQuotePolling.test.tsx.snap index 401f665287..175d30693e 100644 --- a/apps/cowswap-frontend/src/modules/tradeQuote/hooks/__snapshots__/useTradeQuotePolling.test.tsx.snap +++ b/apps/cowswap-frontend/src/modules/tradeQuote/hooks/__snapshots__/useTradeQuotePolling.test.tsx.snap @@ -78,6 +78,7 @@ exports[`useTradeQuotePolling() When wallet is connected Then should put account "validFor": 1800, }, { + "allowIntermediateEqSellToken": true, "appData": undefined, "getCorrelatedTokens": [Function], "getSlippageSuggestion": undefined, diff --git a/apps/cowswap-frontend/src/modules/tradeQuote/services/fetchAndProcessQuote.test.ts b/apps/cowswap-frontend/src/modules/tradeQuote/services/fetchAndProcessQuote.test.ts index 66d8ae7c15..341db0a378 100644 --- a/apps/cowswap-frontend/src/modules/tradeQuote/services/fetchAndProcessQuote.test.ts +++ b/apps/cowswap-frontend/src/modules/tradeQuote/services/fetchAndProcessQuote.test.ts @@ -171,6 +171,7 @@ describe('fetchAndProcessQuote', () => { quoteRequest: { priceQuality: PriceQuality.FAST, }, + allowIntermediateEqSellToken: true, appData: mockAppData, quoteSigner: undefined, getSlippageSuggestion: undefined, @@ -214,6 +215,7 @@ describe('fetchAndProcessQuote', () => { quoteRequest: { priceQuality: PriceQuality.FAST, }, + allowIntermediateEqSellToken: true, appData: mockAppData, quoteSigner: {}, }), @@ -251,6 +253,7 @@ describe('fetchAndProcessQuote', () => { quoteRequest: { priceQuality: PriceQuality.OPTIMAL, }, + allowIntermediateEqSellToken: true, appData: mockAppData, quoteSigner: undefined, getSlippageSuggestion: undefined, @@ -285,6 +288,7 @@ describe('fetchAndProcessQuote', () => { quoteRequest: { priceQuality: PriceQuality.FAST, }, + allowIntermediateEqSellToken: true, appData: mockAppData, quoteSigner: undefined, getSlippageSuggestion: undefined, @@ -684,6 +688,7 @@ describe('fetchAndProcessQuote', () => { quoteRequest: { priceQuality: PriceQuality.FAST, }, + allowIntermediateEqSellToken: true, appData: undefined, quoteSigner: undefined, getSlippageSuggestion: undefined, diff --git a/tools/scripts/install.js b/tools/scripts/install.js index 95e9687645..7af80a9c89 100644 --- a/tools/scripts/install.js +++ b/tools/scripts/install.js @@ -1,5 +1,6 @@ const { execSync } = require('child_process') const fs = require('fs') +const os = require('os') const path = require('path') const ROOT_DIR = path.resolve(__dirname, '../..') @@ -189,10 +190,10 @@ function stripLockfileEntries(keys) { * Runs pnpm install in the repository root. * * Uses a frozen lockfile by default. When `authToken` is provided (SDK preview install) - * it injects the GitHub Packages registry/auth (and an optional rewrite pnpmfile) into - * the child pnpm process via `npm_config_*` env vars, then runs with - * `--no-frozen-lockfile`. The auth token never touches the filesystem, so a crash, - * SIGKILL, or Ctrl-C cannot leave a secret in the tracked `.npmrc`. + * it injects the GitHub Packages registry/auth through a temporary npmrc userconfig + * (and an optional rewrite pnpmfile) into the child pnpm process, then runs with + * `--no-frozen-lockfile`. The auth token is written only to an OS temp directory + * npmrc with restrictive permissions and is removed after the install attempt. */ function runPnpmInstall(authToken, rewriteMap = {}, forceResolveKeys = []) { if (!authToken) { @@ -211,13 +212,7 @@ function runPnpmInstall(authToken, rewriteMap = {}, forceResolveKeys = []) { console.log('[install.js] Installing with SDK PR version (pnpm install --no-frozen-lockfile)...') - // pnpm follows the npm convention of reading config from `npm_config_` env vars. - // Passing the registry/auth this way keeps the token off disk entirely. - const childEnv = { - ...process.env, - 'npm_config_@cowprotocol:registry': 'https://npm.pkg.github.com', - 'npm_config_//npm.pkg.github.com/:_authToken': authToken, - } + const childEnv = { ...process.env } if (hasRewrites) { // `pnpmfile` is a pnpm-specific config key (no secret); pnpm resolves it relative to cwd. @@ -225,8 +220,13 @@ function runPnpmInstall(authToken, rewriteMap = {}, forceResolveKeys = []) { } let originalLockfile = null + let tempNpmrcDir = null try { + const tempNpmrc = createTempNpmrc(authToken) + tempNpmrcDir = tempNpmrc.dir + childEnv.npm_config_userconfig = tempNpmrc.path + if (hasRewrites) createTempPnpmfile(rewriteMap) if (hasForceResolve) originalLockfile = stripLockfileEntries(forceResolveKeys) @@ -238,7 +238,7 @@ function runPnpmInstall(authToken, rewriteMap = {}, forceResolveKeys = []) { } finally { if (hasRewrites) { try { - removeTempPnpmfile() + fs.rmSync(TEMP_PNPMFILE_PATH, { force: true }) } catch (err) { console.warn('[install.js] Failed to remove temp pnpmfile:', err) } @@ -253,9 +253,41 @@ function runPnpmInstall(authToken, rewriteMap = {}, forceResolveKeys = []) { console.warn('[install.js] Failed to restore pnpm-lock.yaml:', err) } } + if (tempNpmrcDir !== null) { + try { + fs.rmSync(tempNpmrcDir, { force: true, recursive: true }) + } catch (err) { + console.warn('[install.js] Failed to remove temp npmrc:', err) + } + } } } +/** + * Creates a temporary npmrc containing the GitHub Packages registry/auth settings. + * + * pnpm 10 reliably consumes these scoped auth settings from an npmrc passed through + * `npm_config_userconfig`; passing the same keys directly as environment variables is + * not portable across build providers. + */ +function createTempNpmrc(authToken) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cowswap-pnpm-')) + const npmrcPath = path.join(dir, '.npmrc') + + try { + fs.writeFileSync( + npmrcPath, + `@cowprotocol:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=${authToken}\n`, + { mode: 0o600 }, + ) + } catch (err) { + fs.rmSync(dir, { force: true, recursive: true }) + throw err + } + + return { dir, path: npmrcPath } +} + /** * Creates a temporary pnpm hook file that rewrites workspace manifests in memory. * @@ -285,10 +317,3 @@ function createTempPnpmfile(rewriteMap) { fs.writeFileSync(TEMP_PNPMFILE_PATH, pnpmfile) } - -/** - * Deletes the temporary pnpm hook file if it exists. - */ -function removeTempPnpmfile() { - fs.rmSync(TEMP_PNPMFILE_PATH, { force: true }) -} diff --git a/tools/scripts/install.test.mjs b/tools/scripts/install.test.mjs new file mode 100644 index 0000000000..40d6b97f68 --- /dev/null +++ b/tools/scripts/install.test.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import vm from 'node:vm' + +describe('install.js', () => { + it('passes GitHub Packages auth to pnpm through a temporary npmrc', () => { + const context = createInstallScriptContext() + + context.run() + + assert.equal(context.execCalls.length, 1) + const [{ command, options }] = context.execCalls + + assert.equal(command, 'pnpm install --no-frozen-lockfile') + assert.equal(options.cwd, '/repo') + assert.equal(options.env.npm_config_userconfig, '/tmp/cowswap-pnpm-test/.npmrc') + assert.equal(options.env['npm_config_//npm.pkg.github.com/:_authToken'], undefined) + assert.equal(options.env['npm_config_@cowprotocol:registry'], undefined) + + assertTempNpmrcWrittenAndRemoved(context) + }) + + it('removes the temporary npmrc when pnpm install fails', () => { + const failure = new Error('fail') + const exitError = new Error('process.exit') + let exitCode + const context = createInstallScriptContext({ + execSync() { + throw failure + }, + processExit(code) { + exitCode = code + throw exitError + }, + }) + let caughtError + + try { + context.run() + } catch (err) { + caughtError = err + } + + assert.equal(caughtError, exitError) + assert.equal(exitCode, 1) + assertTempNpmrcWrittenAndRemoved(context) + }) +}) + +function createInstallScriptContext({ execSync = () => {}, processExit = defaultProcessExit } = {}) { + const script = readFileSync(new URL('./install.js', import.meta.url), 'utf8') + const execCalls = [] + const writes = [] + const removals = [] + + const fakeFs = { + existsSync() { + return false + }, + mkdtempSync(prefix) { + assert.equal(prefix, '/tmp/cowswap-pnpm-') + return '/tmp/cowswap-pnpm-test' + }, + readFileSync() { + throw new Error('unexpected fs.readFileSync call') + }, + readdirSync() { + throw new Error('unexpected fs.readdirSync call') + }, + rmSync(path, options) { + removals.push({ path, options }) + }, + writeFileSync(path, content, options) { + writes.push({ path, content, options }) + }, + } + + const sandbox = { + __dirname: '/repo/tools/scripts', + console: { + error() {}, + log() {}, + warn() {}, + }, + process: { + env: { PACKAGE_READ_AUTH_TOKEN: 'secret-token' }, + exit(code) { + processExit(code) + }, + }, + require(id) { + if (id === 'child_process') { + return { + execSync(command, options) { + execCalls.push({ command, options }) + execSync(command, options) + }, + } + } + + if (id === 'fs') return fakeFs + if (id === 'os') { + return { + tmpdir() { + return '/tmp' + }, + } + } + if (id === 'path') return pathModule + if (id === '../../apps/cowswap-frontend/package.json') { + return { + dependencies: { + '@cowprotocol/sdk-bridging': '4.1.2-pr-897-c3d02aa9.0', + }, + } + } + + throw new Error(`unexpected require(${id})`) + }, + } + + return { + execCalls, + removals, + run() { + vm.runInNewContext( + `(function(require, process, console, __dirname) {\n${script}\n})(require, process, console, __dirname)`, + sandbox, + ) + }, + writes, + } +} + +function defaultProcessExit(code) { + throw new Error(`unexpected process.exit(${code})`) +} + +function assertTempNpmrcWrittenAndRemoved({ removals, writes }) { + assert.equal(writes.length, 1) + assert.equal(writes[0].path, '/tmp/cowswap-pnpm-test/.npmrc') + assert.equal( + writes[0].content, + '@cowprotocol:registry=https://npm.pkg.github.com\n' + '//npm.pkg.github.com/:_authToken=secret-token\n', + ) + assert.equal(writes[0].options.mode, 0o600) + + assert.equal(removals.length, 1) + assert.equal(removals[0].path, '/tmp/cowswap-pnpm-test') + assert.equal(removals[0].options.force, true) + assert.equal(removals[0].options.recursive, true) +} + +const pathModule = { + join(...parts) { + return parts.join('/').replace(/\/+/g, '/') + }, + resolve(...parts) { + const joined = parts.join('/') + const segments = [] + + for (const segment of joined.split('/')) { + if (!segment || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + + return '/' + segments.join('/') + }, +}