Skip to content
Open
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 .changeset/store-output-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/cli': patch
---

Keep tables on one line per row when the output isn't a terminal, and word the store-not-found advice for service account tokens.
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ describe('listBulkOperations', () => {
.map((line) => line.trimEnd())
.join('\n')

// terminal width in test environment is quite narrow, so values in the snapshot get wrapped
// The test environment has no terminal width, and the table takes the width it needs there,
// so every row stays on one line.
expect(outputLinesWithoutTrailingWhitespace).toMatchInlineSnapshot(`
"╭─ info ───────────────────────────────────────────────────────────────────────╮
│ │
Expand All @@ -282,15 +283,10 @@ describe('listBulkOperations', () => {
│ │
╰──────────────────────────────────────────────────────────────────────────────╯

I STATUS COUNT DATE CREATED DATE FINISHED RESULTS

─ ─────── ───── ────────────── ────────────── ──────────────────────────────────
─ ──── ──── ────────────
1 COMPLET 123.5 2025-11-10 2025-11-10 download (
D 12:37:52 16:37:12 https://example.com/results.jsonl
)
2 RUNNING 100 2025-11-11
15:37:52"
ID STATUS COUNT DATE CREATED DATE FINISHED RESULTS
── ───────── ────── ─────────────────── ─────────────────── ──────────────────────────────────────────────
1 COMPLETED 123.5K 2025-11-10 12:37:52 2025-11-10 16:37:12 download ( https://example.com/results.jsonl )
2 RUNNING 100 2025-11-11 15:37:52"
`)
})

Expand Down
24 changes: 22 additions & 2 deletions packages/cli-kit/src/private/node/ui/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Row} from './Row.js'
import ScalarDict from './ScalarDict.js'
import {Column} from './Column.js'
import {unstyled} from '../../../../../public/node/output.js'
import React from 'react'
import {Box} from 'ink'
Expand All @@ -14,8 +15,10 @@ export interface TableProps<T extends ScalarDict> {
columns: TableColumn<T>
}

function Table<T extends ScalarDict>({rows, columns: columnsConfiguration}: TableProps<T>) {
const columns = Object.entries(columnsConfiguration).map(([key, {header, color}]) => {
const COLUMN_GAP = 2

function tableColumns<T extends ScalarDict>({rows, columns: columnsConfiguration}: TableProps<T>): Column<T>[] {
return Object.entries(columnsConfiguration).map(([key, {header, color}]) => {
const headerWidth = String(header ?? key).length
const columnWidths = rows.map((row) => {
const value = row[key]
Expand All @@ -33,6 +36,23 @@ function Table<T extends ScalarDict>({rows, columns: columnsConfiguration}: Tabl
color,
}
})
}

/**
* The width the table needs to show every cell on one line: the widest value or header of each column, plus the
* gap between columns.
*
* @param props - The table's rows and columns.
* @returns The width in characters.
*/
export function tableWidth<T extends ScalarDict>(props: TableProps<T>): number {
const columns = tableColumns(props)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review

Nonblocking: Wide-character store names still get squeezed here. In a local run of the production renderer with stdout piped, a name of '漢'.repeat(32) wrapped the row and clipped neighboring cells—for example, Plus became Plu.

The width calculation uses .length, which counts each 漢 as one, while Ink measures it as two terminal columns. Could we use display width consistently in both the table sizing and Row padding, with a CJK regression test?

This also happens on the base revision, but it leaves part of the display problem this PR addresses unresolved.

const gaps = Math.max(columns.length - 1, 0) * COLUMN_GAP
return columns.reduce((width, column) => width + column.width, gaps)
}

function Table<T extends ScalarDict>({rows, columns: columnsConfiguration}: TableProps<T>) {
const columns = tableColumns({rows, columns: columnsConfiguration})
const headings = Object.entries(columnsConfiguration).reduce(
(headings, [column, {header}]) => ({...headings, [column]: header ?? column}),
{},
Expand Down
62 changes: 62 additions & 0 deletions packages/cli-kit/src/public/node/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
renderAutocompletePrompt,
renderTextPrompt,
renderDangerousConfirmationPrompt,
renderTable,
Task,
} from './ui.js'
import {AbortSignal} from './abort.js'
Expand Down Expand Up @@ -185,6 +186,67 @@ describe('renderInfo', async () => {
})
})

describe('renderTable', async () => {
const rows = [
{
subdomain: 'parimi-photography-dev-test-store',
name: 'Parimi photography dev test store',
type: 'Dev',
plan: 'Plus',
created: 'Apr 01, 2026',
},
]
const columns = {
subdomain: {header: 'Subdomain'},
name: {header: 'Name'},
type: {header: 'Type'},
plan: {header: 'Plan'},
created: {header: 'Created'},
}

test('gives a table wider than 80 columns the room it needs when the output is not a terminal', async () => {
// Given: piped output has no column count.
const mockOutput = mockAndCaptureOutput()
const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns')
Object.defineProperty(process.stdout, 'columns', {value: undefined, configurable: true})

try {
// When
renderTable({rows, columns})
} finally {
if (stdoutColumns) Object.defineProperty(process.stdout, 'columns', stdoutColumns)
else delete (process.stdout as {columns?: number}).columns
}

// Then: one line per row, with every header and cell whole.
expect(unstyled(mockOutput.info())).toMatchInlineSnapshot(`
"Subdomain Name Type Plan Created
───────────────────────────────── ───────────────────────────────── ──── ──── ────────────
parimi-photography-dev-test-store Parimi photography dev test store Dev Plus Apr 01, 2026"
`)
})

test("keeps a terminal's own width", async () => {
// Given: a terminal 80 columns wide.
const mockOutput = mockAndCaptureOutput()
const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns')
Object.defineProperty(process.stdout, 'columns', {value: 80, configurable: true})

try {
// When
renderTable({rows, columns})
} finally {
if (stdoutColumns) Object.defineProperty(process.stdout, 'columns', stdoutColumns)
else delete (process.stdout as {columns?: number}).columns
}

// Then: Ink fits the table to the terminal, as before.
const lines = unstyled(mockOutput.info()).split('\n')
expect(Math.max(...lines.map((line) => line.length))).toBeLessThanOrEqual(80)
expect(lines.length).toBeGreaterThan(3)
})
})

describe('renderSuccess', async () => {
test('renders a success message inside a banner', async () => {
// Given
Expand Down
20 changes: 17 additions & 3 deletions packages/cli-kit/src/public/node/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {
ConcurrentOutputProps,
runConcurrentProcessesForJson,
} from '../../private/node/ui/components/ConcurrentOutput.js'
import {handleCtrlC, render, renderOnce} from '../../private/node/ui.js'
import {handleCtrlC, render, renderOnce, Stdout} from '../../private/node/ui.js'
import {alert, AlertOptions} from '../../private/node/ui/alert.js'
import {CustomSection} from '../../private/node/ui/components/Alert.js'
import {FatalError} from '../../private/node/ui/components/FatalError.js'
import ScalarDict from '../../private/node/ui/components/Table/ScalarDict.js'
import {Table, TableColumn, TableProps} from '../../private/node/ui/components/Table/Table.js'
import {Table, TableColumn, TableProps, tableWidth} from '../../private/node/ui/components/Table/Table.js'
import {
tokenItemToString,
type InlineToken,
Expand Down Expand Up @@ -489,9 +489,23 @@ interface RenderTableOptions<T extends ScalarDict> extends TableProps<T> {
* 3 John Smith jon@smith.com
*/
export function renderTable<T extends ScalarDict>({renderOptions, ...props}: RenderTableOptions<T>) {
return renderOnce(<Table {...props} />, {renderOptions})
return renderOnce(<Table {...props} />, {renderOptions: tableRenderOptions(props, renderOptions)})
}

// Output that isn't a terminal (a pipe, a file, a CI log) has no width, and Ink then lays the table out in 80
// columns, squeezing headers to "Typ" and wrapping cells onto extra lines. Give the table the width it needs
// instead; a terminal keeps its own width.
function tableRenderOptions<T extends ScalarDict>(
props: TableProps<T>,
renderOptions: RenderOptions | undefined,
): RenderOptions | undefined {
if (renderOptions?.stdout || process.stdout.columns !== undefined) return renderOptions
const columns = Math.max(DEFAULT_OUTPUT_COLUMNS, tableWidth(props))
return {...renderOptions, stdout: new Stdout({columns}) as unknown as NodeJS.WriteStream}
}

const DEFAULT_OUTPUT_COLUMNS = 80

interface RenderTasksOptions {
renderOptions?: RenderOptions
noProgressBar?: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {fetchDestinationsContext} from './destinations.js'
import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
import {ensureAuthenticatedBusinessPlatform, isServiceAccountTokenSet} from '@shopify/cli-kit/node/session'
import {AbortError} from '@shopify/cli-kit/node/error'
import {describe, test, expect, vi, beforeEach} from 'vitest'

Expand Down Expand Up @@ -31,6 +31,19 @@ describe('fetchDestinationsContext', () => {
const err = await fetchDestinationsContext({store: SHOP}).catch((error: unknown) => error)
expect(err).toBeInstanceOf(AbortError)
expect((err as AbortError).message).toContain(SHOP)
expect((err as AbortError).tryMessage).toContain("you're logged in to an account with access to the store")
})

test("advises on the service account's access, not a login, when a service account token is set", async () => {
vi.mocked(isServiceAccountTokenSet).mockReturnValue(true)
vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({
currentUserAccount: {destinations: {nodes: []}},
} as never)

const err = await fetchDestinationsContext({store: SHOP}).catch((error: unknown) => error)
expect(err).toBeInstanceOf(AbortError)
expect((err as AbortError).tryMessage).toContain('the service account has access to the store')
expect((err as AbortError).tryMessage).not.toContain('logged in')
})

test('throws AbortError when domain match is missing from results', async () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/store/src/cli/utilities/store-lookup/destinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {StoreInfoDestinations} from '../../api/graphql/business-platform-destina
import {StoreInfoOwningOrg} from '../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
import {ensureAuthenticatedBusinessPlatform, isServiceAccountTokenSet} from '@shopify/cli-kit/node/session'
import {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid'
import {extractHost} from '@shopify/cli-kit/common/url'
import {outputDebug} from '@shopify/cli-kit/node/output'
Expand Down Expand Up @@ -59,7 +59,7 @@ export async function fetchDestinationsContext(options: FetchDestinationsContext
if (!matchedNode) {
throw new StoreLookupStoreNotFoundError(
`Couldn't find a store with domain ${options.store} for the current account.`,
'Verify the domain (must be the canonical `myshopify.com` FQDN) and that you are signed in to an account with access to the store. Inactive shops are not searchable.',
storeNotFoundAdvice(),
)
}

Expand All @@ -68,6 +68,16 @@ export async function fetchDestinationsContext(options: FetchDestinationsContext
return {...(owningOrg ? {owningOrg} : {})}
}

// A token isn't "signed in", and the account behind it is a service account whose store access is set in the
// Shopify admin, so the advice names that instead of a login.
function storeNotFoundAdvice(): string {
const domain = 'Check the domain (it must be the permanent `myshopify.com` domain)'
if (isServiceAccountTokenSet()) {
return `${domain} and that the service account has access to the store: it can open the stores it created, or any store in an organization where it has Dev store management access. Inactive stores aren't searchable.`
}
return `${domain} and that you're logged in to an account with access to the store. Inactive stores aren't searchable.`
}

async function fetchOwningOrg(
destinationPublicId: string,
token: string,
Expand Down
Loading