Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/app/src/cli/commands/app/config/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export default class ConfigLink extends AppLinkedCommand {
env: 'SHOPIFY_FLAG_ORGANIZATION_ID',
exclusive: ['client-id'],
}),
'new-app-name': Flags.string({
hidden: true,
env: 'SHOPIFY_FLAG_NEW_APP_NAME',
exclusive: ['client-id'],
}),
}

public async run(): Promise<AppLinkedCommandOutput> {
Expand All @@ -32,6 +37,7 @@ export default class ConfigLink extends AppLinkedCommand {
directory: flags.path,
apiKey: flags['client-id'],
organizationId: flags['organization-id'],
newAppName: flags['new-app-name'],
configName: flags.config,
}

Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/cli/services/app/config/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ describe('link', () => {
})
})

test('passes hidden organization and app name flags when creating a new linked app', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
const developerPlatformClient = buildDeveloperPlatformClient()
const options: LinkOptions = {
directory: tmp,
organizationId: 'org-id',
newAppName: 'Secondary App',
developerPlatformClient,
}
await mockLoadOpaqueAppWithApp(tmp)
vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient}))

// When
await link(options)

// Then
expect(fetchOrCreateOrganizationApp).toHaveBeenLastCalledWith(
expect.objectContaining({
organizationId: 'org-id',
name: 'Secondary App',
nameProvidedAsFlag: true,
}),
)
})
})

test('does not ask for a name when the selected app is already linked', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/cli/services/app/config/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface LinkOptions {
apiKey?: string
appId?: string
organizationId?: string
newAppName?: string
configName?: string
developerPlatformClient?: DeveloperPlatformClient
isNewApp?: boolean
Expand Down Expand Up @@ -124,6 +125,7 @@ async function selectOrCreateRemoteAppToLinkTo(options: LinkOptions): Promise<{

const remoteApp = await fetchOrCreateOrganizationApp({
...creationOptions,
...(options.newAppName ? {name: options.newAppName, nameProvidedAsFlag: true} : {}),
directory: appDirectory,
organizationId: options.organizationId,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ function includeConfigOnDeployPrompt(configPath: string): Promise<boolean> {
}

export async function fetchOrCreateOrganizationApp(
options: CreateAppOptions & {organizationId?: string},
options: CreateAppOptions & {organizationId?: string; nameProvidedAsFlag?: boolean},
): Promise<OrganizationApp> {
const org = options.organizationId
? await fetchOrgFromId(options.organizationId, selectDeveloperPlatformClient())
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/cli/services/dev/select-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ describe('selectOrCreateApp', () => {
expect(developerPlatformClient.createApp).toHaveBeenCalledWith(ORG1, {name: 'app-name'})
})

test('creates a new app without prompts when the name was provided as a flag', async () => {
// When
const {developerPlatformClient} = mockDeveloperPlatformClient()
const got = await selectOrCreateApp(APPS, false, ORG1, developerPlatformClient, {
name: 'flag-app-name',
nameProvidedAsFlag: true,
})

// Then
expect(got).toEqual({...APP1, newApp: true})
expect(createAsNewAppPrompt).not.toHaveBeenCalled()
expect(appNamePrompt).not.toHaveBeenCalled()
expect(developerPlatformClient.createApp).toHaveBeenCalledWith(ORG1, {name: 'flag-app-name'})
})

test('retries when selectAppPrompt returns undefined and succeeds on next attempt', async () => {
// Given
vi.mocked(selectAppPrompt).mockResolvedValueOnce(undefined).mockResolvedValueOnce(APPS[0])
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/cli/services/dev/select-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,16 @@ export async function selectOrCreateApp(
hasMorePages: boolean,
org: Organization,
developerPlatformClient: DeveloperPlatformClient,
options: CreateAppOptions,
options: CreateAppOptions & {nameProvidedAsFlag?: boolean},
): Promise<OrganizationApp> {
let createNewApp = apps.length === 0
let createNewApp = apps.length === 0 || Boolean(options.nameProvidedAsFlag)
if (!createNewApp) {
createNewApp = await createAsNewAppPrompt()
}
if (createNewApp) {
const name = await appNamePrompt(options.name)
return developerPlatformClient.createApp(org, {...options, name})
const {nameProvidedAsFlag: _nameProvidedAsFlag, ...createAppOptions} = options
const name = options.nameProvidedAsFlag ? options.name : await appNamePrompt(options.name)
return developerPlatformClient.createApp(org, {...createAppOptions, name})
} else {
// Capture app selection context
const cachedData = getCachedCommandInfo()
Expand Down
94 changes: 10 additions & 84 deletions packages/e2e/setup/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,6 @@ import * as fs from 'fs'
import type {CLIContext, CLIProcess, ExecResult} from './cli.js'
import type {Page} from '@playwright/test'

/**
* Race the given promise builders. When the winner resolves, losers are
* cancelled via `AbortController.abort()` so their timers and `outputWaiters`
* entries inside `waitForOutput` are freed immediately rather than lingering
* until they hit their own timeout. Loser rejections are swallowed so they
* don't surface as unhandled promise rejections.
*/
async function raceWaiters<T>(build: (signal: AbortSignal) => Promise<T>[]): Promise<T> {
const ctrl = new AbortController()
const promises = build(ctrl.signal)
promises.forEach((promise) => {
promise.catch(() => {})
})
try {
return await Promise.race(promises)
} finally {
ctrl.abort()
}
}

// ---------------------------------------------------------------------------
// CLI helpers — thin wrappers around cli.exec()
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -231,24 +211,14 @@ export async function versionsList(
* Run `app config link` to create a brand-new app on Shopify interactively.
* Answers the prompts:
* --organization-id flag skips the organization picker
* "Create this project as a new app on Shopify?" → Yes (default)
* "App name" → appName
* --new-app-name flag skips the create/select and app-name prompts
* "Configuration file name" → skipped via `--config` flag
*
* Env overrides (via PTY spawn):
* CI=undefined — drop the key so prompts render.
* Fixture default is CI=1; Ink's `is-in-ci`
* treats `'CI' in env` as CI even when ''.
* In CI mode Ink suppresses prompt frames
* (only emitted on unmount), so waitForOutput
* hangs until the process is killed.
* SHOPIFY_CLI_NEVER_USE_PARTNERS_API=1 — skip Partners client in fetchOrganizations.
* Without this, fetchOrganizations iterates
* AppManagement AND Partners sequentially.
* Env overrides:
* SHOPIFY_CLI_NEVER_USE_PARTNERS_API=1 — keep the command on AppManagement.
* Partners requires SHOPIFY_CLI_PARTNERS_TOKEN
* (not set in OAuth-auth'd tests) and hangs
* for minutes trying to authenticate. The e2e
* test org (161686155) lives in AppManagement.
* (not set in OAuth-auth'd tests) and can hang
* for minutes trying to authenticate.
*/
export async function configLink(
ctx: CLIContext & {
Expand All @@ -257,60 +227,16 @@ export async function configLink(
configName?: string
},
): Promise<ExecResult> {
const args = ['app', 'config', 'link', '--organization-id', ctx.orgId]
const args = ['app', 'config', 'link', '--organization-id', ctx.orgId, '--new-app-name', ctx.appName]
// Pass configName as --config flag. link.ts → loadConfigurationFileName skips
// the "Configuration file name" prompt when options.configName is set, which
// also side-steps a painful interactive quirk: that prompt uses
// `initialAnswer = remoteApp.title`, so any text we write would be appended
// to the app name rather than replacing it.
// the "Configuration file name" prompt when options.configName is set.
if (ctx.configName) args.push('--config', ctx.configName)
args.push('--path', ctx.appDir)

const proc = await ctx.cli.spawn(args, {
env: {
CI: undefined,
SHOPIFY_CLI_NEVER_USE_PARTNERS_API: '1',
},
return ctx.cli.exec(args, {
env: {SHOPIFY_CLI_NEVER_USE_PARTNERS_API: '1'},
timeout: CLI_TIMEOUT.long,
})

// Short sleep so Ink's useInput hooks attach before we start writing.
// Without this, an Enter press arrives mid-mount and a subsequent render can
// flip the prompt state unexpectedly (e.g. turning a select into search mode).
const settle = (ms = 50) => new Promise<void>((resolve) => setTimeout(resolve, ms))

try {
// With --organization-id, the first prompt is either "Create this project"
// when the org has existing apps, or "App name" when it does not. Race both;
// the loser waitForOutput call is cancelled via AbortSignal so its timer and
// outputWaiter entry are freed immediately when the winner resolves.
const firstPrompt = await raceWaiters((signal) => [
proc
.waitForOutput('Create this project as a new app', {timeoutMs: CLI_TIMEOUT.medium, signal})
.then(() => 'create' as const),
proc.waitForOutput('App name', {timeoutMs: CLI_TIMEOUT.medium, signal}).then(() => 'appName' as const),
])

if (firstPrompt === 'create') {
await settle()
proc.sendKey('\r')
}

// Wait for "App name" text prompt and submit the desired name.
// Important: Ink parses each PTY data event as ONE keypress. If we write
// "name\r" in one call, parseKeypress sees the whole string and treats
// it as text (not Enter), so the prompt never submits. We must write the
// text, wait for it to be consumed, then write \r separately.
await proc.waitForOutput('App name', CLI_TIMEOUT.medium)
await settle()
proc.ptyProcess.write(ctx.appName)
await settle()
proc.sendKey('\r')

const exitCode = await proc.waitForExit(CLI_TIMEOUT.long)
return {exitCode, stdout: proc.getOutput(), stderr: ''}
} finally {
proc.kill()
}
}

// ---------------------------------------------------------------------------
Expand Down
Loading