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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type CreateSourceScanMutationVariables = Types.Exact<{
appId: Types.Scalars['ID']['input']
sourceScanUrl: Types.Scalars['URL']['input']
}>

export type CreateSourceScanMutation = {
appSourceScanCreate: {accepted: boolean; userErrors: {field?: string[] | null; message: string}[]}
}

export const CreateSourceScan = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: {kind: 'Name', value: 'CreateSourceScan'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'sourceScanUrl'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'URL'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'appSourceScanCreate'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'appId'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'sourceScanUrl'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'sourceScanUrl'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'accepted'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<CreateSourceScanMutation, CreateSourceScanMutationVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type RequestSourceScanUploadUrlMutationVariables = Types.Exact<{
appId: Types.Scalars['ID']['input']
}>

export type RequestSourceScanUploadUrlMutation = {
appRequestSourceScanUploadUrl: {
sourceScanUploadUrl?: string | null
userErrors: {field?: string[] | null; message: string}[]
}
}

export const RequestSourceScanUploadUrl = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: {kind: 'Name', value: 'RequestSourceScanUploadUrl'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'appRequestSourceScanUploadUrl'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'appId'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'sourceScanUploadUrl'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<RequestSourceScanUploadUrlMutation, RequestSourceScanUploadUrlMutationVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mutation CreateSourceScan($appId: ID!, $sourceScanUrl: URL!) {
appSourceScanCreate(appId: $appId, sourceScanUrl: $sourceScanUrl) {
accepted
userErrors {
field
message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mutation RequestSourceScanUploadUrl($appId: ID!) {
appRequestSourceScanUploadUrl(appId: $appId) {
sourceScanUploadUrl
userErrors {
field
message
}
}
}
115 changes: 115 additions & 0 deletions packages/app/src/cli/commands/app/doctor/submit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import DoctorSubmit from './submit.js'
import {appFlags} from '../../../flags.js'
import doctorSubmit from '../../../services/doctor-submit.js'
import AppLinkedCommand from '../../../utilities/app-linked-command.js'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {cwd, resolvePath} from '@shopify/cli-kit/node/path'
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../../../services/doctor-submit.js')
vi.mock('@shopify/cli-kit/node/system')

describe('app doctor submit command', () => {
beforeEach(() => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
})

test('is hidden and lets the service link only after trace validation', () => {
expect(DoctorSubmit.hidden).toBe(true)
expect(DoctorSubmit.prototype).toBeInstanceOf(BaseCommand)
expect(DoctorSubmit.prototype).not.toBeInstanceOf(AppLinkedCommand)
expect(DoctorSubmit.flags.path).toBe(appFlags.path)
expect(DoctorSubmit.flags.config).toBe(appFlags.config)
expect(DoctorSubmit.flags['client-id']).toBe(appFlags['client-id'])
expect(DoctorSubmit.args).not.toHaveProperty('directory')
expect(DoctorSubmit.descriptionWithMarkdown).toContain(
'No source code, file paths, snippets, or commit identifiers are sent',
)
expect(DoctorSubmit.descriptionWithMarkdown).toContain('--version')
expect(DoctorSubmit.descriptionWithMarkdown).toContain('--source-control-url')
})

test('forwards defaults from the current directory', async () => {
await DoctorSubmit.run([], import.meta.url)

expect(doctorSubmit).toHaveBeenCalledWith({
directory: cwd(),
json: false,
force: false,
dryRun: false,
clientId: undefined,
configName: undefined,
versionTag: undefined,
sourceControlUrl: undefined,
})
})

test('forwards submit flags with --client-id', async () => {
await DoctorSubmit.run(
[
'--path',
'./fixtures/app',
'--client-id',
'client-id',
'--json',
'--force',
'--dry-run',
'--version',
'v1.2.3',
'--source-control-url',
'https://github.com/example/app/tree/v1.2.3',
],
import.meta.url,
)

expect(doctorSubmit).toHaveBeenCalledWith({
directory: resolvePath('./fixtures/app'),
json: true,
force: true,
dryRun: true,
clientId: 'client-id',
configName: undefined,
versionTag: 'v1.2.3',
sourceControlUrl: 'https://github.com/example/app/tree/v1.2.3',
})
})

test('forwards --config separately because --config and --client-id are exclusive', async () => {
await DoctorSubmit.run(['--config', 'staging'], import.meta.url)

expect(doctorSubmit).toHaveBeenCalledWith({
directory: cwd(),
json: false,
force: false,
dryRun: false,
clientId: undefined,
configName: 'staging',
versionTag: undefined,
sourceControlUrl: undefined,
})
})

test('fails at parse time in a non-interactive terminal without --force', async () => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(false)

await expect(DoctorSubmit.run([], import.meta.url)).rejects.toThrow()
expect(doctorSubmit).not.toHaveBeenCalled()
})

test('allows --dry-run in a non-interactive terminal without --force', async () => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(false)

await DoctorSubmit.run(['--json', '--dry-run'], import.meta.url)

expect(doctorSubmit).toHaveBeenCalledWith(expect.objectContaining({json: true, dryRun: true, force: false}))
})

test('uses the established flag aliases and environment variables', () => {
expect(DoctorSubmit.flags.force.char).toBe('f')
expect(DoctorSubmit.flags.force.env).toBe('SHOPIFY_FLAG_FORCE')
expect(DoctorSubmit.flags['dry-run'].env).toBe('SHOPIFY_FLAG_APP_DOCTOR_DRY_RUN')
expect(DoctorSubmit.flags.version.env).toBe('SHOPIFY_FLAG_VERSION')
expect(DoctorSubmit.flags['source-control-url'].env).toBe('SHOPIFY_FLAG_SOURCE_CONTROL_URL')
})
})
67 changes: 67 additions & 0 deletions packages/app/src/cli/commands/app/doctor/submit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {appFlags} from '../../../flags.js'
import doctorSubmit from '../../../services/doctor-submit.js'
import {Flags} from '@oclif/core'
import BaseCommand, {type NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

export default class DoctorSubmit extends BaseCommand {
static hidden = true

static summary = 'Submit App Doctor results to Shopify.'

static descriptionWithMarkdown = `Reads the most recent App Doctor trace, writes a redacted \`.shopify/app-doctor/submission.json\` file for inspection, asks for confirmation, and uploads the result to Shopify.

No source code, file paths, snippets, or commit identifiers are sent. Optional \`--version\` and \`--source-control-url\` metadata is included only when supplied. Use \`--dry-run\` to write and inspect the exact payload without uploading it.`

static description = this.descriptionWithoutMarkdown()

static flags = {
...globalFlags,
path: appFlags.path,
config: appFlags.config,
'client-id': appFlags['client-id'],
...jsonFlag,
version: Flags.string({
hidden: false,
description:
'Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.',
env: 'SHOPIFY_FLAG_VERSION',
}),
'source-control-url': Flags.string({
hidden: false,
description: 'URL associated with the new app version.',
env: 'SHOPIFY_FLAG_SOURCE_CONTROL_URL',
}),
force: Flags.boolean({
char: 'f',
description: 'Skip confirmation. Required if non interactive.',
env: 'SHOPIFY_FLAG_FORCE',
default: false,
}),
'dry-run': Flags.boolean({
description: 'Write the submission payload without uploading it.',
env: 'SHOPIFY_FLAG_APP_DOCTOR_DRY_RUN',
default: false,
}),
}

static nonTTYFlagRequirements(): NonTTYFlagRequirement[] {
// Dry runs never upload, so they may run non-interactively without --force.
return [{flags: ['force'], when: (flags) => !flags['dry-run']}]
}

public async run(): Promise<void> {
const {flags} = await this.parse(DoctorSubmit)

await doctorSubmit({
directory: flags.path,
json: flags.json,
force: flags.force,
dryRun: flags['dry-run'],
clientId: flags['client-id'],
configName: flags.config,
versionTag: flags.version,
sourceControlUrl: flags['source-control-url'],
})
}
}
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import DemoWatcher from './commands/app/demo/watcher.js'
import Deploy from './commands/app/deploy.js'
import Dev from './commands/app/dev.js'
import DoctorInstructions from './commands/app/doctor/instructions.js'
import DoctorSubmit from './commands/app/doctor/submit.js'
import Doctor from './commands/app/doctor.js'
import Logs from './commands/app/logs.js'
import Sources from './commands/app/app-logs/sources.js'
Expand Down Expand Up @@ -55,6 +56,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin
'app:dev': Dev,
'app:dev:clean': DevClean,
'app:doctor:instructions': DoctorInstructions,
'app:doctor:submit': DoctorSubmit,
'app:doctor': Doctor,
'app:logs': Logs,
'app:logs:sources': Sources,
Expand Down
Loading
Loading