Skip to content
Draft
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
28 changes: 28 additions & 0 deletions packages/cli-kit/src/private/node/notifications-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {zod} from '../../public/node/schema.js'

export const NotificationSchema = zod.object({
id: zod.string(),
message: zod.string(),
type: zod.enum(['info', 'warning', 'error']),
frequency: zod.enum(['always', 'once', 'once_a_day', 'once_a_week']),
ownerChannel: zod.string(),
cta: zod
.object({
label: zod.string(),
url: zod.string().url(),
})
.optional(),
title: zod.string().optional(),
minVersion: zod.string().optional(),
maxVersion: zod.string().optional(),
minDate: zod.string().optional(),
maxDate: zod.string().optional(),
commands: zod.array(zod.string()).optional(),
surface: zod.string().optional(),
})

export type Notification = zod.infer<typeof NotificationSchema>

export const NotificationsSchema = zod.object({notifications: zod.array(NotificationSchema)})

export type Notifications = zod.infer<typeof NotificationsSchema>
35 changes: 35 additions & 0 deletions packages/cli-kit/src/private/node/session-alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {setCommandSessionId} from './session/command-session.js'
import * as sessionStore from './session/store.js'
import {AbortError} from '../../public/node/error.js'
import {outputContent, outputToken} from '../../public/node/output.js'

/**
* Finds a stored Shopify account session by alias without changing the current session.
*
* @param alias - The account alias to find.
* @returns The matching session ID, or undefined if no session matches.
*/
export async function findSessionIdByAlias(alias: string): Promise<string | undefined> {
return sessionStore.findSessionByAlias(alias)
}

/**
* Selects a stored Shopify account session by alias for the current command process.
*
* @param alias - The account alias to select. Passing undefined clears the command selection.
*/
export async function setCurrentSessionAlias(alias?: string): Promise<void> {
if (!alias) {
setCommandSessionId(undefined)
return
}

const sessionId = await findSessionIdByAlias(alias)
if (!sessionId) {
throw new AbortError(
outputContent`No authenticated account found for alias ${outputToken.yellow(alias)}.`,
outputContent`Run ${outputToken.genericShellCommand(`shopify auth login`)} first.`,
)
}
setCommandSessionId(sessionId)
}
9 changes: 4 additions & 5 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/
import {isThemeAccessSession} from './api/rest.js'
import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js'
import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js'
import {getCommandSessionId} from './session/command-session.js'
import {outputContent, outputToken, outputDebug, outputCompleted} from '../../public/node/output.js'
import {themeToken} from '../../public/node/context/local.js'
import {AbortError} from '../../public/node/error.js'
Expand All @@ -25,6 +26,8 @@ import {nonRandomUUID} from '../../public/node/crypto.js'
import {isEmpty} from '../../public/common/object.js'
import {businessPlatformRequest} from '../../public/node/api/business-platform.js'

export {setCommandSessionId} from './session/command-session.js'

/**
* Fetches the user's email from the Business Platform API
* @param businessPlatformToken - The business platform token
Expand Down Expand Up @@ -118,7 +121,6 @@ type AuthMethod = 'partners_token' | 'device_auth' | 'theme_access_token' | 'cus

let userId: undefined | string
let authMethod: AuthMethod = 'none'
let commandSessionId: string | undefined

/**
* Retrieves a stable user identifier for analytics, or `'unknown'` if none applies.
Expand Down Expand Up @@ -180,10 +182,6 @@ export function setLastSeenAuthMethod(method: AuthMethod) {
authMethod = method
}

export function setCommandSessionId(sessionId: string | undefined) {
commandSessionId = sessionId
}

export interface EnsureAuthenticatedAdditionalOptions {
noPrompt?: boolean
forceRefresh?: boolean
Expand Down Expand Up @@ -215,6 +213,7 @@ export async function ensureAuthenticated(

const sessions = (await sessionStore.fetch()) ?? {}

const commandSessionId = getCommandSessionId()
let currentSessionId = forceNewSession ? undefined : (commandSessionId ?? getCurrentSessionId())
if (!currentSessionId && !commandSessionId) {
const userIds = Object.keys(sessions[fqdn] ?? {})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
let commandSessionId: string | undefined

export function getCommandSessionId(): string | undefined {
return commandSessionId
}

export function setCommandSessionId(sessionId: string | undefined): void {
commandSessionId = sessionId
}
13 changes: 13 additions & 0 deletions packages/cli-kit/src/private/node/terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {isTruthy} from '../../public/node/context/utilities.js'

/**
* Check if the standard input and output streams support prompting.
*
* @returns True if the standard input and output streams support prompting.
*/
export function terminalSupportsPrompting(): boolean {
if (isTruthy(process.env.CI)) {
return false
}
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
}
10 changes: 2 additions & 8 deletions packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {isDevelopment} from './context/local.js'
import {addPublicMetadata} from './metadata.js'
import {AbortError} from './error.js'
import {outputContent, outputResult, outputToken} from './output.js'
import {setCurrentSessionAlias} from './session.js'
import {terminalSupportsPrompting} from './system.js'
import {hashString} from './crypto.js'
import {isTruthy} from './context/utilities.js'
import {setCurrentCommandId} from './global-context.js'
import {setCurrentSessionAlias} from '../../private/node/session-alias.js'
import {terminalSupportsPrompting} from '../../private/node/terminal.js'
import {JsonMap} from '../../private/common/json.js'
import {underscore} from '../common/string.js'
import {Command, Config, Errors} from '@oclif/core'
Expand Down Expand Up @@ -55,17 +54,12 @@
error.skipOclifErrorHandling = true
const {errorHandler} = await import('./error-handler.js')
await errorHandler(error, this.config)
return Errors.handle(error)

Check failure on line 57 in packages/cli-kit/src/public/node/base-command.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 26.1.0 in windows-latest (shard 1/2)

[@shopify/store] src/cli/commands/store/create/dev.test.ts > store create dev command > prompts for the plan when --plan is omitted in an interactive environment

Error: process.exit unexpectedly called with "1" ❯ Object.exit ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/errors/handle.js:23:17 ❯ Object.handle ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/errors/handle.js:58:22 ❯ StoreCreateDev.catch ../cli-kit/src/public/node/base-command.ts:57:19 ❯ StoreCreateDev._run ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/command.js:186:13 ❯ src/cli/commands/store/create/dev.test.ts:136:5

Check failure on line 57 in packages/cli-kit/src/public/node/base-command.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 26.1.0 in windows-latest (shard 1/2)

[@shopify/store] src/cli/commands/store/create/dev.test.ts > store create dev command > prompts for the name when --name is omitted in an interactive environment

Error: process.exit unexpectedly called with "1" ❯ Object.exit ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/errors/handle.js:23:17 ❯ Object.handle ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/errors/handle.js:58:22 ❯ StoreCreateDev.catch ../cli-kit/src/public/node/base-command.ts:57:19 ❯ StoreCreateDev._run ../../node_modules/.pnpm/@oclif+core@4.8.3/node_modules/@oclif/core/lib/command.js:186:13 ❯ src/cli/commands/store/create/dev.test.ts:127:5
}

protected async init(): Promise<unknown> {
this.exitWithTimestampWhenEnvVariablePresent()
setCurrentCommandId(this.id ?? '')
if (!isDevelopment()) {
// This function runs just prior to `run`
const {registerCleanBugsnagErrorsFromWithinPlugins} = await import('./error-handler.js')
await registerCleanBugsnagErrorsFromWithinPlugins(this.config)
}
await removeDuplicatedPlugins(this.config)
this.showNpmFlagWarning()
const {showNotificationsIfNeeded} = await import('./notifications-system.js')
Expand Down
15 changes: 14 additions & 1 deletion packages/cli-kit/src/public/node/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'

const onNotify = vi.fn()
const capturedEventHandler = vi.fn()
const addOnError = vi.hoisted(() => vi.fn())
let lastBugsnagEvent: {addMetadata: ReturnType<typeof vi.fn>; groupingHash?: string} | undefined

vi.mock('process')
Expand All @@ -34,7 +35,7 @@ vi.mock('@bugsnag/js', () => {
callback(null)
},
isStarted: () => true,
addOnError: vi.fn(),
addOnError,
},
}
})
Expand Down Expand Up @@ -284,6 +285,18 @@ describe('sends errors to Bugsnag', () => {
expect(mockEvent.setUser).toHaveBeenCalledWith(undefined)
})

test('registers plugin stack cleanup once before reporting errors', async () => {
const config = {
plugins: [],
runHook: vi.fn().mockResolvedValue({successes: []}),
} as unknown as NonNullable<Parameters<typeof sendErrorToBugsnag>[2]>

await sendErrorToBugsnag(new Error('first error'), 'unexpected_error', config)
await sendErrorToBugsnag(new Error('second error'), 'unexpected_error', config)

expect(addOnError).toHaveBeenCalledOnce()
})

test('attaches custom metadata with allowed slice_name when startCommand is present', async () => {
await metadata.addSensitiveMetadata(() => ({
commandStartOptions: {startTime: Date.now(), startCommand: 'app dev', startArgs: []},
Expand Down
12 changes: 11 additions & 1 deletion packages/cli-kit/src/public/node/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
cleanSingleStackTracePath,
} from './error.js'
import {outputDebug, outputInfo} from './output.js'
import {isDevelopment} from './context/local.js'
import {getEnvironmentData} from '../../private/node/analytics.js'
import {resolveErrorGrouping} from '../../private/node/analytics/error-grouping.js'
import {isLocalEnvironment} from '../../private/node/context/service.js'
Expand Down Expand Up @@ -66,7 +67,7 @@ const reportError = async (error: unknown, config?: Interfaces.Config): Promise<
// Log an analytics event when there's an error
await reportAnalyticsEvent({config, errorMessage: error instanceof Error ? error.message : undefined, exitMode})
}
await sendErrorToBugsnag(error, exitMode)
await sendErrorToBugsnag(error, exitMode, config)
}

/**
Expand All @@ -77,6 +78,7 @@ const reportError = async (error: unknown, config?: Interfaces.Config): Promise<
export async function sendErrorToBugsnag(
error: unknown,
exitMode: Omit<CommandExitMode, 'ok'>,
config?: Interfaces.Config,
): Promise<{reported: false; error: unknown; unhandled: unknown} | {error: Error; reported: true; unhandled: boolean}> {
try {
if (isLocalEnvironment() || settings.debug) {
Expand Down Expand Up @@ -146,6 +148,7 @@ export async function sendErrorToBugsnag(
// Observe will use the IP when undefined
userId = undefined
}
if (config && !isDevelopment()) await registerCleanBugsnagErrorsFromWithinPlugins(config)
await new Promise((resolve, reject) => {
outputDebug(`Reporting ${unhandled ? 'unhandled' : 'handled'} error to Bugsnag: ${reportableError.message}`)
const eventHandler = (event: Event) => {
Expand Down Expand Up @@ -233,7 +236,14 @@ export function cleanStackFrameFilePath({
* Register a Bugsnag error listener to clean up stack traces for errors within plugin code.
*
*/
let pluginStackCleanupRegistration: Promise<void> | undefined

export async function registerCleanBugsnagErrorsFromWithinPlugins(config: Interfaces.Config): Promise<void> {
pluginStackCleanupRegistration ??= registerPluginStackCleanup(config)
await pluginStackCleanupRegistration
}

async function registerPluginStackCleanup(config: Interfaces.Config): Promise<void> {
// Bugsnag have their own plug-ins that use this private field

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
12 changes: 7 additions & 5 deletions packages/cli-kit/src/public/node/notifications-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,10 +449,12 @@ describe('fetchNotificationsInBackground', () => {
})

// Then
expect(exec).toHaveBeenCalledWith(
'/path/to/node',
['/path/to/shopify', 'notifications', 'list', '--ignore-errors'],
expect.anything(),
)
await vi.waitFor(() => {
expect(exec).toHaveBeenCalledWith(
'/path/to/node',
['/path/to/shopify', 'notifications', 'list', '--ignore-errors'],
expect.anything(),
)
})
})
})
61 changes: 25 additions & 36 deletions packages/cli-kit/src/public/node/notifications-system.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import {versionSatisfies} from './node-package-manager.js'
import {renderError, renderInfo, renderWarning} from './ui.js'
import {getCurrentCommandId} from './global-context.js'
import {outputDebug} from './output.js'
import {zod} from './schema.js'
import {AbortSilentError} from './error.js'
import {isTruthy} from './context/utilities.js'
import {exec} from './system.js'
import {jsonOutputEnabled} from './environment.js'
import {fetch} from './http.js'
import {CLI_KIT_VERSION} from '../common/version.js'
import {NotificationKey, NotificationsKey, cacheRetrieve, cacheStore} from '../../private/node/conf-store.js'
import type {Notification, Notifications} from '../../private/node/notifications-schema.js'

export type {Notification, Notifications} from '../../private/node/notifications-schema.js'

const URL = 'https://cdn.shopify.com/static/cli/notifications.json'
const EMPTY_CACHE_MESSAGE = 'Cache is empty'
Expand All @@ -27,31 +26,6 @@ function url(): string {
return process.env.SHOPIFY_CLI_NOTIFICATIONS_URL ?? URL
}

const NotificationSchema = zod.object({
id: zod.string(),
message: zod.string(),
type: zod.enum(['info', 'warning', 'error']),
frequency: zod.enum(['always', 'once', 'once_a_day', 'once_a_week']),
ownerChannel: zod.string(),
cta: zod
.object({
label: zod.string(),
url: zod.string().url(),
})
.optional(),
title: zod.string().optional(),
minVersion: zod.string().optional(),
maxVersion: zod.string().optional(),
minDate: zod.string().optional(),
maxDate: zod.string().optional(),
commands: zod.array(zod.string()).optional(),
surface: zod.string().optional(),
})
export type Notification = zod.infer<typeof NotificationSchema>

const NotificationsSchema = zod.object({notifications: zod.array(NotificationSchema)})
export type Notifications = zod.infer<typeof NotificationsSchema>

/**
* Shows notifications to the user if they meet the criteria specified in the notifications.json file.
*
Expand Down Expand Up @@ -98,7 +72,11 @@ function skipNotifications(currentCommand: string, environment: NodeJS.ProcessEn
* @param notifications - The notifications to render.
*/
async function renderNotifications(notifications: Notification[]) {
notifications.slice(0, 2).forEach((notification) => {
const notificationsToRender = notifications.slice(0, 2)
if (notificationsToRender.length === 0) return

const {renderError, renderInfo, renderWarning} = await import('./ui.js')
notificationsToRender.forEach((notification) => {
const content = {
headline: notification.title,
body: notification.message.replace(/\\n/g, '\n'),
Expand Down Expand Up @@ -132,6 +110,7 @@ export async function getNotifications(): Promise<Notifications> {
const rawNotifications = cacheRetrieve(cacheKey)?.value as unknown as string
if (!rawNotifications) throw new Error(EMPTY_CACHE_MESSAGE)
const notifications: object = JSON.parse(rawNotifications)
const {NotificationsSchema} = await import('../../private/node/notifications-schema.js')
return NotificationsSchema.parse(notifications)
}

Expand All @@ -142,6 +121,10 @@ export async function getNotifications(): Promise<Notifications> {
*/
export async function fetchNotifications(): Promise<Notifications> {
outputDebug(`Fetching notifications...`)
const [{fetch}, {NotificationsSchema}] = await Promise.all([
import('./http.js'),
import('../../private/node/notifications-schema.js'),
])
const response = await fetch(url(), undefined, {
useNetworkLevelRetry: false,
useAbortSignal: true,
Expand Down Expand Up @@ -187,13 +170,19 @@ export function fetchNotificationsInBackground(
const args = [shopifyBinary, 'notifications', 'list', '--ignore-errors']

// eslint-disable-next-line no-void
void exec(nodeBinary, args, {
background: true,
env: {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'},
externalErrorHandler: async (error: unknown) => {
void import('./system.js')
.then(({exec}) =>
exec(nodeBinary, args, {
background: true,
env: {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'},
externalErrorHandler: async (error: unknown) => {
outputDebug(`Failed to fetch notifications in background: ${(error as Error).message}`)
},
}),
)
.catch((error: unknown) => {
outputDebug(`Failed to fetch notifications in background: ${(error as Error).message}`)
},
})
})
}

/**
Expand Down
9 changes: 3 additions & 6 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@ import {
import {nonRandomUUID} from './crypto.js'
import {getAppAutomationToken} from './environment.js'
import {shopifyFetch} from './http.js'
import {
ensureAuthenticated,
setCommandSessionId,
setLastSeenAuthMethod,
setLastSeenUserIdAfterAuth,
} from '../../private/node/session.js'
import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js'
import {setCommandSessionId} from '../../private/node/session/command-session.js'
import * as sessionStore from '../../private/node/session/store.js'
import {ApplicationToken} from '../../private/node/session/schema.js'
import {
Expand All @@ -39,6 +35,7 @@ const partnersToken: ApplicationToken = {
}

vi.mock('../../private/node/session.js')
vi.mock('../../private/node/session/command-session.js')
vi.mock('../../private/node/session/exchange.js')
vi.mock('../../private/node/session/store.js')
vi.mock('./environment.js')
Expand Down
Loading
Loading