Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm eslint \"src/**/*.ts\" 'bin/*.js' ",
"command": "pnpm eslint src 'bin/*.js'",
"cwd": "packages/cli"
}
},
"lint:fix": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm eslint 'src/**/*.ts' 'bin/*.js' --fix",
"command": "pnpm eslint src 'bin/*.js' --fix",
"cwd": "packages/cli"
}
},
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/cli/commands/docs/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export async function writeCommandDocumentation(
const previewDescription = command.summary ?? description ?? ''
const cleanPreview = previewDescription.replace(/`/g, '\\`').replace(/https:\/\/shopify\.dev/g, '')

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const category = hasTopic && !generalTopics.includes(topic!) ? topic : 'general commands'

const docString = `// This is an autogenerated file. Don't edit this file manually.
Expand Down Expand Up @@ -139,7 +138,7 @@ export async function writeCommandFlagInterface(
const flag = command.flags[flagName]
if (!flag) return
if (flag.hidden) return
const flagDescription = flag.description || ''
const flagDescription = flag.description ?? ''
const char = flag.char ? `-${flag.char}, ` : ''
const type = flag.type === 'option' ? 'string' : "''"
const value = flag.type === 'option' ? ' <value>' : ''
Expand Down
6 changes: 1 addition & 5 deletions packages/cli/src/cli/commands/store/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import {beforeEach, describe, expect, test, vi} from 'vitest'
import StoreAuth from './auth.js'
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/store/auth/index.js')
vi.mock('../../services/store/auth/result.js', () => ({
createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})),
}))

describe('store auth command', () => {
beforeEach(() => {
vi.clearAllMocks()
})

test('passes parsed flags through to the auth service', async () => {
await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products'])

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli/commands/store/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
import Command from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {Flags} from '@oclif/core'
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'

export default class StoreAuth extends Command {
static summary = 'Authenticate an app against a store for store commands.'
Expand Down
15 changes: 3 additions & 12 deletions packages/cli/src/cli/commands/store/execute.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {beforeEach, describe, expect, test, vi} from 'vitest'
import StoreExecute from './execute.js'
import {executeStoreOperation} from '../../services/store/execute/index.js'
import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../../services/store/execute/index.js')
vi.mock('../../services/store/execute/result.js')

describe('store execute command', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}})
})

Expand All @@ -24,11 +23,7 @@ describe('store execute command', () => {
version: undefined,
allowMutations: false,
})
expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(
{data: {shop: {name: 'Test shop'}}},
undefined,
'text',
)
expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith({data: {shop: {name: 'Test shop'}}}, undefined, 'text')
})

test('passes the query file through to the service', async () => {
Expand All @@ -46,11 +41,7 @@ describe('store execute command', () => {
test('writes json output when --json is provided', async () => {
await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json'])

expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(
{data: {shop: {name: 'Test shop'}}},
undefined,
'json',
)
expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith({data: {shop: {name: 'Test shop'}}}, undefined, 'json')
})

test('defines the expected flags', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli/commands/store/execute.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {executeStoreOperation} from '../../services/store/execute/index.js'
import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js'
import Command from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {resolvePath} from '@shopify/cli-kit/node/path'
import {Flags} from '@oclif/core'
import {executeStoreOperation} from '../../services/store/execute/index.js'
import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js'

export default class StoreExecute extends Command {
static summary = 'Execute GraphQL queries and mutations on a store.'
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/services/kitchen-sink/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function staticService() {
})

renderInfo({
headline: 'About your app',
headline: 'About your app.',
customSections: [
{
body: {
Expand Down
16 changes: 6 additions & 10 deletions packages/cli/src/cli/services/store/auth/callback.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {createServer} from 'http'
import {describe, expect, test} from 'vitest'
import {waitForStoreAuthCode} from './callback.js'
import {describe, expect, test} from 'vitest'
import {createServer} from 'http'

async function getAvailablePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
return new Promise<number>((resolve, reject) => {
const server = createServer()

server.on('error', reject)
Expand All @@ -26,12 +26,7 @@ async function getAvailablePort(): Promise<number> {
})
}

function callbackParams(options?: {
code?: string
shop?: string
state?: string
error?: string
}): URLSearchParams {
function callbackParams(options?: {code?: string; shop?: string; state?: string; error?: string}): URLSearchParams {
const params = new URLSearchParams()
params.set('shop', options?.shop ?? 'shop.myshopify.com')
params.set('state', options?.state ?? 'state-123')
Expand Down Expand Up @@ -101,7 +96,8 @@ describe('store auth callback server', () => {
}),
).rejects.toMatchObject({
message: 'OAuth callback store does not match the requested store.',
tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:',
tryMessage:
'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:',
nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes <comma-separated-scopes>'}]],
})
})
Expand Down
34 changes: 13 additions & 21 deletions packages/cli/src/cli/services/store/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js'
import {retryStoreAuthWithPermanentDomainError} from './recovery.js'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {timingSafeEqual} from 'crypto'
import {createServer} from 'http'
import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js'
import {retryStoreAuthWithPermanentDomainError} from './recovery.js'

export interface WaitForAuthCodeOptions {
store: string
Expand All @@ -15,16 +15,8 @@ export interface WaitForAuthCodeOptions {
}

function renderAuthCallbackPage(title: string, message: string): string {
const safeTitle = title
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const safeMessage = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const safeTitle = title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const safeMessage = message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

return `<!doctype html>
<html lang="en">
Expand Down Expand Up @@ -120,7 +112,9 @@ export async function waitForStoreAuthCode({
res.setHeader('Content-Type', 'text/html')
res.setHeader('Connection', 'close')
res.once('finish', () => settle(() => resolve(code)))
res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.'))
res.end(
renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.'),
)
})

const settle = (callback: () => void) => {
Expand Down Expand Up @@ -162,24 +156,22 @@ export async function waitForStoreAuthCode({
settleWithError(error)
})

server.listen(port, '127.0.0.1', async () => {
server.listen(port, '127.0.0.1', () => {
isListening = true
outputDebug(
outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`,
)

if (!onListening) return

try {
await onListening()
} catch (error) {
Promise.resolve(onListening()).catch((error: unknown) => {
settleWithError(error instanceof Error ? error : new Error(String(error)))
}
})
})
})
}

function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false
return timingSafeEqual(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))
}
26 changes: 16 additions & 10 deletions packages/cli/src/cli/services/store/auth/existing-scopes.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {resolveExistingStoreAuthScopes} from './existing-scopes.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {getCurrentStoredStoreAppSession} from './session-store.js'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'

vi.mock('./session-store.js')
vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()}))
vi.mock('@shopify/cli-kit/node/api/graphql')
vi.mock('@shopify/cli-kit/node/api/admin', async () => {
const actual = await vi.importActual<typeof import('@shopify/cli-kit/node/api/admin')>('@shopify/cli-kit/node/api/admin')
const actual = await vi.importActual<typeof import('@shopify/cli-kit/node/api/admin')>(
'@shopify/cli-kit/node/api/admin',
)
return {
...actual,
adminUrl: vi.fn(),
Expand All @@ -20,19 +22,20 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => {

describe('resolveExistingStoreAuthScopes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json')
})

afterEach(() => {
vi.restoreAllMocks()
mockAndCaptureOutput().clear()
})

test('returns no scopes when no stored auth exists', async () => {
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined)

await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true})
await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({
scopes: [],
authoritative: true,
})
expect(loadStoredStoreSession).not.toHaveBeenCalled()
expect(graphqlRequest).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -120,7 +123,8 @@ describe('resolveExistingStoreAuthScopes', () => {
errors: '[API] Invalid API key or access token (unrecognized login or wrong password)',
},
request: {
query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }',
query:
'#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }',
},
})
vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError)
Expand All @@ -129,7 +133,9 @@ describe('resolveExistingStoreAuthScopes', () => {
scopes: ['read_orders'],
authoritative: false,
})
expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token')
expect(output.debug()).toContain(
'after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token',
)
expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes')
})

Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/cli/services/store/auth/existing-scopes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {getCurrentStoredStoreAppSession} from './session-store.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {fetchCurrentStoreAuthScopes} from './token-client.js'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

export interface ResolvedStoreAuthScopes {
scopes: string[]
Expand Down Expand Up @@ -46,9 +46,12 @@ export async function resolveExistingStoreAuthScopes(store: string): Promise<Res

return {scopes: remoteScopes, authoritative: true}
} catch (error) {
outputDebug(
outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`,
)
return {scopes: storedSession.scopes, authoritative: false}
if (error instanceof Error) {
outputDebug(
outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`,
)
return {scopes: storedSession.scopes, authoritative: false}
}
throw error
}
}
10 changes: 1 addition & 9 deletions packages/cli/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {authenticateStoreWithApp} from './index.js'
import {setStoredStoreAppSession} from './session-store.js'
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('./session-store.js')
vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)}))
vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')}))

describe('store auth service', () => {
beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
vi.restoreAllMocks()
})

test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => {
const openURL = vi.fn().mockResolvedValue(true)
const presenter = {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {openURL} from '@shopify/cli-kit/node/system'
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {setStoredStoreAppSession} from './session-store.js'
import {exchangeStoreAuthCodeForToken} from './token-client.js'
Expand All @@ -10,6 +6,10 @@ import {createPkceBootstrap} from './pkce.js'
import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js'
import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js'
import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js'
import {openURL} from '@shopify/cli-kit/node/system'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

interface StoreAuthInput {
store: string
Expand Down Expand Up @@ -86,7 +86,7 @@ export async function authenticateStoreWithApp(
refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in
? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString()
: undefined,
hasRefreshToken: !!tokenResponse.refresh_token,
hasRefreshToken: Boolean(tokenResponse.refresh_token),
associatedUser: tokenResponse.associated_user
? {
id: tokenResponse.associated_user.id,
Expand Down
Loading
Loading