diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36890f91e86..2ac894eca26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,6 @@ jobs: > If the versions don't match, you might have multiple global instances installed. > Use `which shopify` to find out which one you are running and uninstall it." - comment_package_manager: 'npm' comment_command_flags: '--@shopify:registry=https://registry.npmjs.org' build_script: "node bin/update-cli-kit-version.js && pnpm nx run-many --target=bundle --all --skip-nx-cache --output-style=stream && pnpm refresh-manifests" env: diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index f4afe424ad9..8749e17fd9e 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -234,7 +234,7 @@ jobs: - name: Build run: pnpm nx run-many --all --skip-nx-cache --target=build --output-style=stream - name: Install Playwright Chromium - run: npx playwright install chromium + run: pnpm exec playwright install chromium working-directory: packages/e2e - name: Rebuild node-pty run: pnpm rebuild node-pty @@ -247,7 +247,7 @@ jobs: E2E_STORE_FQDN: ${{ secrets.E2E_STORE_FQDN }} E2E_SECONDARY_CLIENT_ID: ${{ secrets.E2E_SECONDARY_CLIENT_ID }} E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} - run: npx playwright test + run: pnpm exec playwright test - name: Upload Playwright report uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} diff --git a/bin/create-notification-pr.js b/bin/create-notification-pr.js index 3e65c4fe274..207ebb550a5 100755 --- a/bin/create-notification-pr.js +++ b/bin/create-notification-pr.js @@ -65,7 +65,7 @@ async function createPR() { '**Please update the release highlights before merging.**', '', '### How to test', - '- `npx http-server ~/src/github.com/Shopify/static-cdn-assets`', + '- `pnpx http-server ~/src/github.com/Shopify/static-cdn-assets`', '- `SHOPIFY_CLI_NOTIFICATIONS_URL=http://127.0.0.1:8080/static-24h/cli/notifications.json shopify version`', "- You may need to clear the CLI cache with `shopify cache clear` and run the command twice to see the notification (it's fetched in the background).", '', diff --git a/bin/docs/build-dev-docs.sh b/bin/docs/build-dev-docs.sh index 960754f8bd1..98eedfed6fb 100644 --- a/bin/docs/build-dev-docs.sh +++ b/bin/docs/build-dev-docs.sh @@ -1,12 +1,12 @@ echo "STARTING" -COMPILE_DOCS="npx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && npx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" -COMPILE_STATIC_PAGES="npx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && npx generate-docs --isLandingPage --input ./docs-shopify.dev/static --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/static/*.doc.js" -COMPILE_CATEGORY_PAGES="npx tsc docs-shopify.dev/categories/*.doc.ts --moduleResolution node --target esNext && generate-docs --isCategoryPage --input ./docs-shopify.dev/categories --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/categories/*.doc.js" +COMPILE_DOCS="pnpx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && pnpx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" +COMPILE_STATIC_PAGES="pnpx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && pnpx generate-docs --isLandingPage --input ./docs-shopify.dev/static --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/static/*.doc.js" +COMPILE_CATEGORY_PAGES="pnpx tsc docs-shopify.dev/categories/*.doc.ts --moduleResolution node --target esNext && pnpx generate-docs --isCategoryPage --input ./docs-shopify.dev/categories --output ./docs-shopify.dev/generated && rm -rf docs-shopify.dev/categories/*.doc.js" if [ "$1" = "isTest" ]; then -COMPILE_DOCS="npx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && npx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" -COMPILE_STATIC_PAGES="npx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && npx generate-docs --isLandingPage --input ./docs-shopify.dev/static/docs-shopify.dev --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/static/*.doc.js" +COMPILE_DOCS="pnpx tsc --project bin/docs/tsconfig.docs.json --moduleResolution node --target esNext && pnpx generate-docs --overridePath ./bin/docs/typeOverride.json --input ./docs-shopify.dev/commands --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/commands/**/*.doc.js docs-shopify.dev/commands/*.doc.js" +COMPILE_STATIC_PAGES="pnpx tsc docs-shopify.dev/static/*.doc.ts --moduleResolution node --target esNext && pnpx generate-docs --isLandingPage --input ./docs-shopify.dev/static/docs-shopify.dev --output ./docs-shopify.dev/static/temp && rm -rf docs-shopify.dev/static/*.doc.js" fi echo $1 diff --git a/dev.yml b/dev.yml index 9fae8e5435f..aabc7393898 100644 --- a/dev.yml +++ b/dev.yml @@ -23,7 +23,7 @@ up: meet: 'true' - custom: name: 'Install Playwright Chromium' - met?: cd packages/e2e && npx playwright install chromium + met?: cd packages/e2e && pnpx playwright install chromium meet: 'true' - custom: name: 'Rebuild node-pty' diff --git a/package.json b/package.json index 2c1f05eb392..d8d82599c2b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bundle-for-release": "nx run-many --target=bundle --all --skip-nx-cache", "changeset-manifests": "changeset version && pnpm install --no-frozen-lockfile && pnpm refresh-manifests && pnpm refresh-readme && pnpm refresh-code-documentation && bin/update-cli-kit-version.js", "clean": "nx run-many --target=clean --all --skip-nx-cache && nx reset", - "create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager npm", + "create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager pnpm", "deploy-experimental": "node bin/deploy-experimental.js", "graph": "nx graph", "graphql-codegen:get-graphql-schemas": "bin/get-graphql-schemas.js", diff --git a/packages/app/src/cli/models/project/project-integration.test.ts b/packages/app/src/cli/models/project/project-integration.test.ts index d9eca152934..a72994a26da 100644 --- a/packages/app/src/cli/models/project/project-integration.test.ts +++ b/packages/app/src/cli/models/project/project-integration.test.ts @@ -79,6 +79,8 @@ dev = "npm run dev" // package.json (needed by the loader) await writeFile(joinPath(dir, 'package.json'), JSON.stringify({name: 'test-app', dependencies: {}})) + // Pin npm: getPackageManager walks up to ancestors if no lockfile is found + await writeFile(joinPath(dir, 'package-lock.json'), '') } // Load specifications once — this is expensive (loads all extension specs from disk) diff --git a/packages/app/src/cli/services/app/config/use.test.ts b/packages/app/src/cli/services/app/config/use.test.ts index 21f6f46ec55..428e0c0a41b 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -45,6 +45,7 @@ describe('use', () => { } writeFileSync(joinPath(tmp, 'package.json'), '{}') writeFileSync(joinPath(tmp, 'shopify.app.toml'), '') + writeFileSync(joinPath(tmp, 'package-lock.json'), '') // When await use(options) diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index addea2de099..80e5a235fd4 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -21,6 +21,7 @@ import { } from './binaries.js' import {testApp, testFunctionExtension} from '../../models/app/app.test-data.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager' import {exec} from '@shopify/cli-kit/node/system' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {inTemporaryDirectory, mkdir, readFileSync, writeFile, removeFile} from '@shopify/cli-kit/node/fs' @@ -28,6 +29,13 @@ import {build as esBuild} from 'esbuild' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/node-package-manager', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getPackageManager: vi.fn(), + } +}) vi.mock('./binaries.js', async (importOriginal) => { const actual: any = await importOriginal() @@ -76,6 +84,7 @@ beforeEach(async () => { stderr = {write: vi.fn()} stdout = {write: vi.fn()} signal = vi.fn() + vi.mocked(getPackageManager).mockResolvedValue('npm') }) describe('buildGraphqlTypes', () => { @@ -95,6 +104,34 @@ describe('buildGraphqlTypes', () => { }) }) + test('generate types uses pnpm exec when package manager is pnpm', {timeout: 20000}, async () => { + vi.mocked(getPackageManager).mockResolvedValueOnce('pnpm') + const ourFunction = await testFunctionExtension({entryPath: 'src/index.js'}) + + const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app}) + + await expect(got).resolves.toBeUndefined() + expect(exec).toHaveBeenCalledWith('pnpm', ['exec', '--', 'graphql-code-generator', '--config', 'package.json'], { + cwd: ourFunction.directory, + stderr, + signal, + }) + }) + + test('generate types uses yarn without exec subcommand when package manager is yarn', {timeout: 20000}, async () => { + vi.mocked(getPackageManager).mockResolvedValueOnce('yarn') + const ourFunction = await testFunctionExtension({entryPath: 'src/index.js'}) + + const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app}) + + await expect(got).resolves.toBeUndefined() + expect(exec).toHaveBeenCalledWith('yarn', ['--', 'graphql-code-generator', '--config', 'package.json'], { + cwd: ourFunction.directory, + stderr, + signal, + }) + }) + test('errors if function is not a JS function and no typegen_command', async () => { // Given const ourFunction = await testFunctionExtension() diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index 7e8caf4f6fa..8ab867d20cb 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -24,6 +24,7 @@ import {renderTasks} from '@shopify/cli-kit/node/ui' import {pickBy} from '@shopify/cli-kit/common/object' import {runWithTimer} from '@shopify/cli-kit/node/metadata' import {AbortError} from '@shopify/cli-kit/node/error' +import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager' import {Writable} from 'stream' export const PREFERRED_FUNCTION_NPM_PACKAGE_MAJOR_VERSION = '2' @@ -144,7 +145,12 @@ export async function buildGraphqlTypes( } return runWithTimer('cmd_all_timing_network_ms')(async () => { - return exec('npm', ['exec', '--', 'graphql-code-generator', '--config', 'package.json'], { + const packageManager = await getPackageManager(fun.directory) + const execArgs = ['--', 'graphql-code-generator', '--config', 'package.json'] + if (packageManager !== 'yarn') { + execArgs.unshift('exec') + } + return exec(packageManager, execArgs, { cwd: fun.directory, stderr: options.stderr, signal: options.signal, diff --git a/packages/cli-kit/src/public/node/node-package-manager.test.ts b/packages/cli-kit/src/public/node/node-package-manager.test.ts index 5c22f54ecde..8516249743f 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.test.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.test.ts @@ -20,6 +20,7 @@ import { checkForCachedNewVersion, inferPackageManager, PackageManager, + npmLockfile, } from './node-package-manager.js' import {captureOutput, exec} from './system.js' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from './fs.js' @@ -845,8 +846,9 @@ describe('writePackageJSON', () => { describe('getPackageManager', () => { test('finds if npm is being used', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given + // Given — pin NPM in the temp project await writePackageJSON(tmpDir, {name: 'mock name'}) + await writeFile(joinPath(tmpDir, npmLockfile), '') // Then const packageManager = await getPackageManager(tmpDir) @@ -878,6 +880,19 @@ describe('getPackageManager', () => { }) }) + test('finds pnpm from a nested workspace package when the lockfile is only at the repo root', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await writePackageJSON(tmpDir, {name: 'root'}) + await writeFile(joinPath(tmpDir, 'pnpm-lock.yaml'), '') + const nested = joinPath(tmpDir, 'extensions', 'cart-transformer') + await mkdir(nested) + await writePackageJSON(nested, {name: 'cart-transformer'}) + + const packageManager = await getPackageManager(nested) + expect(packageManager).toEqual('pnpm') + }) + }) + test('falls back to packageManagerFromUserAgent when no package.json is found', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given — no package.json in tmpDir, stub user agent to yarn diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index bdbce5aff7e..b35afb8b6d5 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -1,7 +1,7 @@ import {AbortError, BugError} from './error.js' import {AbortController, AbortSignal} from './abort.js' import {exec} from './system.js' -import {fileExists, readFile, writeFile, findPathUp, glob} from './fs.js' +import {fileExists, readFile, writeFile, findPathUp, glob, fileExistsSync} from './fs.js' import {dirname, joinPath} from './path.js' import {runWithTimer} from './metadata.js' import {inferPackageManagerForGlobalCLI} from './is-global.js' @@ -111,21 +111,29 @@ export function packageManagerFromUserAgent(env = process.env): PackageManager { /** * Returns the dependency manager used in a directory. + * Walks upward from `fromDirectory` so workspace packages (e.g. `extensions/my-fn/package.json`) + * still resolve to the repo root lockfile (`pnpm-lock.yaml`). + * If no lockfile is found, it falls back to the package manager from the user agent. + * If the package manager from the user agent is unknown, it returns 'npm'. * @param fromDirectory - The starting directory * @returns The dependency manager */ export async function getPackageManager(fromDirectory: string): Promise { - const packageJsonPath = await findPathUp('package.json', {cwd: fromDirectory, type: 'file'}) - if (!packageJsonPath) { - return packageManagerFromUserAgent() + let current = fromDirectory + outputDebug(outputContent`Looking for a lockfile in ${outputToken.path(current)}...`) + while (true) { + if (fileExistsSync(joinPath(current, yarnLockfile))) return 'yarn' + if (fileExistsSync(joinPath(current, pnpmLockfile))) return 'pnpm' + if (fileExistsSync(joinPath(current, bunLockfile))) return 'bun' + if (fileExistsSync(joinPath(current, npmLockfile))) return 'npm' + const parent = dirname(current) + if (parent === current) break + current = parent } - const directory = dirname(packageJsonPath) - outputDebug(outputContent`Obtaining the dependency manager in directory ${outputToken.path(directory)}...`) + const pm: PackageManager = packageManagerFromUserAgent() + if (pm !== 'unknown') return pm - if (await fileExists(joinPath(directory, yarnLockfile))) return 'yarn' - if (await fileExists(joinPath(directory, pnpmLockfile))) return 'pnpm' - if (await fileExists(joinPath(directory, bunLockfile))) return 'bun' return 'npm' } diff --git a/packages/e2e/scripts/cleanup-test-apps.ts b/packages/e2e/scripts/cleanup-test-apps.ts index 4785c73e4b3..74750a25014 100644 --- a/packages/e2e/scripts/cleanup-test-apps.ts +++ b/packages/e2e/scripts/cleanup-test-apps.ts @@ -1,6 +1,6 @@ /** * Deletes all test apps from the dev dashboard via browser automation. - * Run: npx tsx packages/e2e/scripts/cleanup-test-apps.ts + * Run: pnpx tsx packages/e2e/scripts/cleanup-test-apps.ts * * Pass --dry-run to list apps without deleting. * Pass --filter to only delete apps matching the pattern. @@ -128,7 +128,7 @@ async function main() { await page.waitForTimeout(2000) // Check for 500 error and retry - const pageText = await page.textContent('body') ?? '' + const pageText = (await page.textContent('body')) ?? '' if (pageText.includes('500') || pageText.includes('Internal Server Error')) { console.log('Got 500 error, retrying...') await page.reload({waitUntil: 'domcontentloaded'}) @@ -136,7 +136,10 @@ async function main() { } // Check for org selection page - const orgLink = page.locator('a, button').filter({hasText: /core-build|cli-e2e/i}).first() + const orgLink = page + .locator('a, button') + .filter({hasText: /core-build|cli-e2e/i}) + .first() if (await orgLink.isVisible({timeout: 3000}).catch(() => false)) { console.log('Org selection detected, clicking...') await orgLink.click() diff --git a/packages/e2e/scripts/create-test-apps.ts b/packages/e2e/scripts/create-test-apps.ts index 89bbe732a6c..91d7e602b22 100644 --- a/packages/e2e/scripts/create-test-apps.ts +++ b/packages/e2e/scripts/create-test-apps.ts @@ -1,6 +1,6 @@ /** * Creates test apps in the authenticated org and prints their client IDs. - * Run: npx tsx packages/e2e/scripts/create-test-apps.ts + * Run: pnpx tsx packages/e2e/scripts/create-test-apps.ts */ import * as fs from 'fs' @@ -39,7 +39,7 @@ if (!email || !password) { } const baseEnv: Record = { - ...process.env as Record, + ...(process.env as Record), NODE_OPTIONS: '', SHOPIFY_RUN_AS_USER: '0', FORCE_COLOR: '0', @@ -86,16 +86,16 @@ async function createAppInteractive(tmpDir: string, appName: string): Promise { @@ -104,11 +104,7 @@ async function createAppInteractive(tmpDir: string, appName: string): Promise output, prompt, 60_000) @@ -130,9 +126,7 @@ async function createAppInteractive(tmpDir: string, appName: string): Promise e.isDirectory() && fs.existsSync(path.join(appDir, e.name, 'shopify.app.toml')), - ) + const created = entries.find((e) => e.isDirectory() && fs.existsSync(path.join(appDir, e.name, 'shopify.app.toml'))) if (!created) throw new Error(`No app directory found in ${appDir}`) const tomlPath = path.join(appDir, created.name, 'shopify.app.toml') @@ -147,11 +141,16 @@ async function oauthLogin() { const nodePty = await import('node-pty') const spawnEnv = {...baseEnv, BROWSER: 'none'} const pty = nodePty.spawn('node', [cliPath, 'auth', 'login'], { - name: 'xterm-color', cols: 120, rows: 30, env: spawnEnv, + name: 'xterm-color', + cols: 120, + rows: 30, + env: spawnEnv, }) let output = '' - pty.onData((data: string) => { output += data }) + pty.onData((data: string) => { + output += data + }) await waitForText(() => output, 'Press any key to open the login page', 30_000) pty.write(' ') @@ -169,7 +168,9 @@ async function oauthLogin() { await completeLogin(page, urlMatch[0], email!, password!) await waitForText(() => output, 'Logged in', 60_000) - try { pty.kill() } catch {} + try { + pty.kill() + } catch {} await browser.close() } diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index 138e1c8c104..910cd7fa35b 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -29,7 +29,7 @@ export async function createApp(ctx: { const name = ctx.name ?? 'e2e-test-app' const template = ctx.template ?? 'reactRouter' const packageManager = - ctx.packageManager ?? (process.env.E2E_PACKAGE_MANAGER as 'npm' | 'yarn' | 'pnpm' | 'bun') ?? 'npm' + ctx.packageManager ?? (process.env.E2E_PACKAGE_MANAGER as 'npm' | 'yarn' | 'pnpm' | 'bun') ?? 'pnpm' const args = [ '--name',