@seamapi/cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +83 -6
  2. package/bin/cli.js +106 -31
  3. package/bin/cli.js.map +1 -1
  4. package/lib/get-response-key.d.ts +9 -0
  5. package/lib/get-response-key.js +21 -0
  6. package/lib/get-response-key.js.map +1 -0
  7. package/lib/interact-for-action-attempt-poll.js +4 -3
  8. package/lib/interact-for-action-attempt-poll.js.map +1 -1
  9. package/lib/interact-for-array.js +9 -7
  10. package/lib/interact-for-array.js.map +1 -1
  11. package/lib/interact-for-blueprint-object.js +21 -10
  12. package/lib/interact-for-blueprint-object.js.map +1 -1
  13. package/lib/interact-for-command-selection.js +17 -2
  14. package/lib/interact-for-command-selection.js.map +1 -1
  15. package/lib/interact-for-custom-metadata.js +10 -8
  16. package/lib/interact-for-custom-metadata.js.map +1 -1
  17. package/lib/interact-for-login.js +8 -6
  18. package/lib/interact-for-login.js.map +1 -1
  19. package/lib/interact-for-resource.js +2 -2
  20. package/lib/interact-for-resource.js.map +1 -1
  21. package/lib/interact-for-server-selection.js +7 -5
  22. package/lib/interact-for-server-selection.js.map +1 -1
  23. package/lib/interact-for-timestamp.js +2 -2
  24. package/lib/interact-for-timestamp.js.map +1 -1
  25. package/lib/interact-for-use-remote-api-defs.js +4 -3
  26. package/lib/interact-for-use-remote-api-defs.js.map +1 -1
  27. package/lib/interact-for-workspace-id.js +2 -2
  28. package/lib/interact-for-workspace-id.js.map +1 -1
  29. package/lib/output/create-memory-output.d.ts +13 -0
  30. package/lib/output/create-memory-output.js +22 -0
  31. package/lib/output/create-memory-output.js.map +1 -0
  32. package/lib/output/create-output.d.ts +50 -0
  33. package/lib/output/create-output.js +34 -0
  34. package/lib/output/create-output.js.map +1 -0
  35. package/lib/output/get-output.d.ts +12 -0
  36. package/lib/output/get-output.js +21 -0
  37. package/lib/output/get-output.js.map +1 -0
  38. package/lib/output/resolve-output-format.d.ts +16 -0
  39. package/lib/output/resolve-output-format.js +23 -0
  40. package/lib/output/resolve-output-format.js.map +1 -0
  41. package/lib/output/select-response-payload.d.ts +16 -0
  42. package/lib/output/select-response-payload.js +33 -0
  43. package/lib/output/select-response-payload.js.map +1 -0
  44. package/lib/types.d.ts +2 -1
  45. package/lib/util/cli-args.d.ts +45 -0
  46. package/lib/util/cli-args.js +58 -0
  47. package/lib/util/cli-args.js.map +1 -0
  48. package/lib/util/prompt.d.ts +17 -0
  49. package/lib/util/prompt.js +25 -0
  50. package/lib/util/prompt.js.map +1 -0
  51. package/lib/util/read-stdin-json.d.ts +22 -0
  52. package/lib/util/read-stdin-json.js +42 -0
  53. package/lib/util/read-stdin-json.js.map +1 -0
  54. package/lib/util/request-seam-api.d.ts +5 -2
  55. package/lib/util/request-seam-api.js +13 -15
  56. package/lib/util/request-seam-api.js.map +1 -1
  57. package/lib/util/with-loading.js +6 -1
  58. package/lib/util/with-loading.js.map +1 -1
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/lib/version.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/bin/cli.ts +139 -33
  64. package/src/lib/get-response-key.ts +25 -0
  65. package/src/lib/interact-for-action-attempt-poll.ts +4 -3
  66. package/src/lib/interact-for-array.ts +9 -7
  67. package/src/lib/interact-for-blueprint-object.ts +27 -10
  68. package/src/lib/interact-for-command-selection.ts +23 -3
  69. package/src/lib/interact-for-custom-metadata.ts +10 -8
  70. package/src/lib/interact-for-login.ts +8 -6
  71. package/src/lib/interact-for-resource.ts +2 -3
  72. package/src/lib/interact-for-server-selection.ts +7 -6
  73. package/src/lib/interact-for-timestamp.ts +2 -2
  74. package/src/lib/interact-for-use-remote-api-defs.ts +4 -4
  75. package/src/lib/interact-for-workspace-id.ts +2 -2
  76. package/src/lib/output/create-memory-output.ts +39 -0
  77. package/src/lib/output/create-output.ts +104 -0
  78. package/src/lib/output/get-output.ts +24 -0
  79. package/src/lib/output/resolve-output-format.ts +33 -0
  80. package/src/lib/output/select-response-payload.ts +56 -0
  81. package/src/lib/types.ts +2 -1
  82. package/src/lib/util/cli-args.ts +92 -0
  83. package/src/lib/util/prompt.ts +38 -0
  84. package/src/lib/util/read-stdin-json.ts +59 -0
  85. package/src/lib/util/request-seam-api.ts +24 -20
  86. package/src/lib/util/with-loading.ts +9 -1
  87. package/src/lib/version.ts +1 -1
@@ -1,5 +1,4 @@
1
- import prompts from 'prompts'
2
-
1
+ import { prompt } from './util/prompt.js'
3
2
  import { withLoading } from './util/with-loading.js'
4
3
 
5
4
  export interface ResourceChoice {
@@ -23,7 +22,7 @@ export const interactForResource = async <Resource>({
23
22
  `Fetching ${resourceName.replace(/_/g, ' ')}s...`,
24
23
  fetchResources,
25
24
  )
26
- const { resourceId } = await prompts({
25
+ const { resourceId } = await prompt({
27
26
  name: 'resourceId',
28
27
  type: 'autocomplete',
29
28
  message,
@@ -1,9 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
- import prompts from 'prompts'
4
-
5
3
  import { getConfigStore } from './config/index.js'
6
4
  import { getServer } from './get-server.js'
5
+ import { getOutput } from './output/get-output.js'
6
+ import { prompt } from './util/prompt.js'
7
7
 
8
8
  export async function interactForServerSelection() {
9
9
  const servers = [
@@ -12,7 +12,7 @@ export async function interactForServerSelection() {
12
12
  'https://fakeseamconnect.seam.vc',
13
13
  ]
14
14
 
15
- const { server } = await prompts([
15
+ const { server } = await prompt([
16
16
  {
17
17
  type: 'select',
18
18
  name: 'server',
@@ -22,8 +22,9 @@ export async function interactForServerSelection() {
22
22
  ])
23
23
 
24
24
  const config = getConfigStore()
25
+ const output = getOutput()
25
26
  if (server === servers[2]) {
26
- let { userUrlSeed } = await prompts([
27
+ let { userUrlSeed } = await prompt([
27
28
  {
28
29
  type: 'text',
29
30
  name: 'userUrlSeed',
@@ -37,10 +38,10 @@ export async function interactForServerSelection() {
37
38
  }
38
39
  config.set('server', `https://${userUrlSeed}.fakeseamconnect.seam.vc`)
39
40
  config.set(`${getServer()}.pat`, `seam_apikey1_token`)
40
- console.log(`PAT set to use fakeseamconnect with "seam_apikey1_token"`)
41
+ output.info(`PAT set to use fakeseamconnect with "seam_apikey1_token"`)
41
42
  } else {
42
43
  config.set('server', server)
43
44
  }
44
45
  config.delete('current_workspace_id')
45
- console.log(`Server set to ${server}`)
46
+ output.info(`Server set to ${server}`)
46
47
  }
@@ -1,6 +1,6 @@
1
- import prompts from 'prompts'
1
+ import { prompt } from './util/prompt.js'
2
2
  export const interactForTimestamp = async () => {
3
- const { timestamp } = await prompts({
3
+ const { timestamp } = await prompt({
4
4
  name: 'timestamp',
5
5
  type: 'date',
6
6
  message: 'Enter a timestamp:',
@@ -1,9 +1,9 @@
1
- import prompts from 'prompts'
2
-
3
1
  import { getConfigStore } from './config/index.js'
2
+ import { getOutput } from './output/get-output.js'
3
+ import { prompt } from './util/prompt.js'
4
4
 
5
5
  export async function interactForUseRemoteApiDefs() {
6
- const { use_remote_api_defs } = await prompts([
6
+ const { use_remote_api_defs } = await prompt([
7
7
  {
8
8
  type: 'select',
9
9
  name: 'use_remote_api_defs',
@@ -23,5 +23,5 @@ export async function interactForUseRemoteApiDefs() {
23
23
 
24
24
  const config = getConfigStore()
25
25
  config.set('use_remote_api_defs', use_remote_api_defs)
26
- console.log(`Use remote API Definitions: ${use_remote_api_defs}`)
26
+ getOutput().info(`Use remote API Definitions: ${use_remote_api_defs}`)
27
27
  }
@@ -1,9 +1,9 @@
1
1
  import { SeamHttpWithoutWorkspace } from '@seamapi/http/connect'
2
- import prompts from 'prompts'
3
2
 
4
3
  import { getConfigStore } from './config/index.js'
5
4
  import { getSeamMultiWorkspace } from './get-seam.js'
6
5
  import { getServer } from './get-server.js'
6
+ import { prompt } from './util/prompt.js'
7
7
  import { withLoading } from './util/with-loading.js'
8
8
 
9
9
  export const interactForWorkspaceId = async (personalAccessToken?: string) => {
@@ -17,7 +17,7 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => {
17
17
  const workspaces = await withLoading('Fetching workspaces...', () =>
18
18
  seam.workspaces.list(),
19
19
  )
20
- const { workspaceId } = await prompts({
20
+ const { workspaceId } = await prompt({
21
21
  name: 'workspaceId',
22
22
  type: 'select',
23
23
  message: 'Select a workspace:',
@@ -0,0 +1,39 @@
1
+ import {
2
+ createOutput,
3
+ type CreateOutputOptions,
4
+ type Output,
5
+ type OutputStream,
6
+ } from './create-output.js'
7
+
8
+ export interface MemoryOutput {
9
+ output: Output
10
+ /** Everything written to stdout so far. */
11
+ stdout: () => string
12
+ /** Everything written to stderr so far. */
13
+ stderr: () => string
14
+ }
15
+
16
+ const createMemoryStream = (): OutputStream & { read: () => string } => {
17
+ const chunks: string[] = []
18
+ return {
19
+ write: (chunk: string) => chunks.push(chunk),
20
+ read: () => chunks.join(''),
21
+ }
22
+ }
23
+
24
+ /**
25
+ * An {@link Output} that captures writes in memory instead of
26
+ * touching the process streams, for asserting on CLI output.
27
+ */
28
+ export const createMemoryOutput = (
29
+ options: Omit<CreateOutputOptions, 'stdout' | 'stderr'> = {},
30
+ ): MemoryOutput => {
31
+ const stdout = createMemoryStream()
32
+ const stderr = createMemoryStream()
33
+
34
+ return {
35
+ output: createOutput({ ...options, stdout, stderr }),
36
+ stdout: stdout.read,
37
+ stderr: stderr.read,
38
+ }
39
+ }
@@ -0,0 +1,104 @@
1
+ import { inspect } from 'node:util'
2
+
3
+ /**
4
+ * Minimal writable surface needed by {@link Output}.
5
+ *
6
+ * Both `process.stdout` and an in-memory buffer satisfy this,
7
+ * which is what makes output testable.
8
+ */
9
+ export interface OutputStream {
10
+ write: (chunk: string) => unknown
11
+ }
12
+
13
+ /**
14
+ * `json` is machine readable: only {@link Output.data} produces output.
15
+ * `text` is human readable: data is pretty printed and may be colorized.
16
+ */
17
+ export type OutputFormat = 'json' | 'text'
18
+
19
+ export interface Output {
20
+ readonly format: OutputFormat
21
+
22
+ /**
23
+ * The result of a command, e.g., an API response.
24
+ *
25
+ * This is the only thing ever written to stdout,
26
+ * so it is safe to pipe into another program.
27
+ */
28
+ data: (value: unknown) => void
29
+
30
+ /**
31
+ * A plain text command result, e.g., the version or the help guide.
32
+ *
33
+ * Written to stdout verbatim in every format: these results are already
34
+ * a single value, so encoding them as JSON would only make them harder
35
+ * to consume from a pipe.
36
+ */
37
+ text: (value: string) => void
38
+
39
+ /**
40
+ * Human facing progress, context, and confirmations.
41
+ *
42
+ * Written to stderr and suppressed entirely in the json format.
43
+ */
44
+ info: (message?: string) => void
45
+
46
+ /** Human facing warning. Written to stderr in every format. */
47
+ warn: (message: string) => void
48
+
49
+ /** Human facing error. Written to stderr in every format. */
50
+ error: (message: string) => void
51
+ }
52
+
53
+ export interface CreateOutputOptions {
54
+ format?: OutputFormat
55
+ stdout?: OutputStream
56
+ stderr?: OutputStream
57
+ /** Colorize pretty printed data. Never applied to the json format. */
58
+ colors?: boolean
59
+ }
60
+
61
+ export const createOutput = ({
62
+ format = 'text',
63
+ stdout = process.stdout,
64
+ stderr = process.stderr,
65
+ colors = false,
66
+ }: CreateOutputOptions = {}): Output => {
67
+ const isJson = format === 'json'
68
+
69
+ return {
70
+ format,
71
+
72
+ data: (value: unknown): void => {
73
+ if (value === undefined) return
74
+ stdout.write(`${formatData(value, format, colors)}\n`)
75
+ },
76
+
77
+ text: (value: string): void => {
78
+ stdout.write(`${value}\n`)
79
+ },
80
+
81
+ info: (message = ''): void => {
82
+ if (isJson) return
83
+ stderr.write(`${message}\n`)
84
+ },
85
+
86
+ warn: (message: string): void => {
87
+ stderr.write(`${message}\n`)
88
+ },
89
+
90
+ error: (message: string): void => {
91
+ stderr.write(`${message}\n`)
92
+ },
93
+ }
94
+ }
95
+
96
+ const formatData = (
97
+ value: unknown,
98
+ format: OutputFormat,
99
+ colors: boolean,
100
+ ): string => {
101
+ if (format === 'json') return JSON.stringify(value, null, 2)
102
+ if (typeof value === 'string') return value
103
+ return inspect(value, { depth: null, colors })
104
+ }
@@ -0,0 +1,24 @@
1
+ import { createOutput, type Output } from './create-output.js'
2
+
3
+ let output: Output | null = null
4
+
5
+ /**
6
+ * The output used by the CLI.
7
+ *
8
+ * Defaults to a `text` output bound to the real process streams,
9
+ * so importing modules never write to stdout by accident.
10
+ */
11
+ export const getOutput = (): Output => {
12
+ output ??= createOutput()
13
+ return output
14
+ }
15
+
16
+ /** Replace the output, e.g., once flags are parsed, or from a test. */
17
+ export const setOutput = (nextOutput: Output): void => {
18
+ output = nextOutput
19
+ }
20
+
21
+ /** Restore the default output. Intended for tests. */
22
+ export const resetOutput = (): void => {
23
+ output = null
24
+ }
@@ -0,0 +1,33 @@
1
+ import type { OutputFormat } from './create-output.js'
2
+
3
+ export interface ResolveOutputFormatOptions {
4
+ /** Whether stdout is a terminal. */
5
+ isTty?: boolean
6
+ }
7
+
8
+ /**
9
+ * Whether to write machine readable output.
10
+ *
11
+ * An explicit `--json` or `--no-json` wins, otherwise the CLI writes JSON
12
+ * whenever stdout is piped or redirected, and pretty output at a terminal.
13
+ *
14
+ * This reads the arguments rather than the parsed args because `--json` is
15
+ * declared as a boolean flag, so that it never consumes the argument after
16
+ * it, and a boolean cannot tell `--no-json` apart from not passing anything.
17
+ */
18
+ export const resolveOutputFormat = (
19
+ argv: string[],
20
+ { isTty = false }: ResolveOutputFormatOptions = {},
21
+ ): OutputFormat => {
22
+ const flag = argv.filter((arg) => arg === '--json' || arg === '--no-json')
23
+
24
+ // The last one wins, matching how the argument parser resolves repeats.
25
+ switch (flag[flag.length - 1]) {
26
+ case '--json':
27
+ return 'json'
28
+ case '--no-json':
29
+ return 'text'
30
+ default:
31
+ return isTty ? 'text' : 'json'
32
+ }
33
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Top level response fields that are transport details,
3
+ * not part of the result the CLI reports.
4
+ */
5
+ const metaKeys = new Set(['ok'])
6
+
7
+ const paginationKey = 'pagination'
8
+ const errorKey = 'error'
9
+
10
+ export interface SelectResponsePayloadOptions {
11
+ /**
12
+ * The response key for the endpoint, e.g., `devices` for `/devices/list`,
13
+ * usually taken from the API blueprint.
14
+ *
15
+ * When omitted, every top level field except {@link metaKeys} is kept.
16
+ */
17
+ responseKey?: string | null | undefined
18
+ }
19
+
20
+ /**
21
+ * Reduce an API response body to the response key and pagination.
22
+ *
23
+ * The CLI never reports other top level fields: they are details of the
24
+ * transport, so including them would leak into anything parsing stdout.
25
+ */
26
+ export const selectResponsePayload = (
27
+ data: unknown,
28
+ { responseKey }: SelectResponsePayloadOptions = {},
29
+ ): unknown => {
30
+ if (!isRecord(data)) return data
31
+
32
+ if (errorKey in data) {
33
+ return { [errorKey]: data[errorKey] }
34
+ }
35
+
36
+ const keys =
37
+ responseKey != null && responseKey in data
38
+ ? [responseKey]
39
+ : Object.keys(data).filter(
40
+ (key) => !metaKeys.has(key) && key !== paginationKey,
41
+ )
42
+
43
+ const payload: Record<string, unknown> = {}
44
+ for (const key of keys) {
45
+ payload[key] = data[key]
46
+ }
47
+
48
+ if (paginationKey in data) {
49
+ payload[paginationKey] = data[paginationKey]
50
+ }
51
+
52
+ return payload
53
+ }
54
+
55
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
56
+ typeof value === 'object' && value !== null && !Array.isArray(value)
package/src/lib/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ApiBlueprint } from './get-api-blueprint.js'
2
+ import type { Interactivity } from './util/cli-args.js'
2
3
 
3
4
  export interface ContextHelpers {
4
5
  blueprint: ApiBlueprint
5
- is_interactive: boolean
6
+ interactivity: Interactivity
6
7
  }
@@ -0,0 +1,92 @@
1
+ import parseArgs, { type ParsedArgs } from 'minimist'
2
+
3
+ /**
4
+ * How the CLI should behave when properties are not given as arguments.
5
+ *
6
+ * - `auto`: make the request as soon as every required property is given,
7
+ * otherwise prompt for what is missing. This is the default.
8
+ * - `interactive`: always prompt to review and edit properties, prefilled
9
+ * with the given arguments. Selected with `--interactive` or `-i`.
10
+ * - `non-interactive`: never prompt: anything missing is an error.
11
+ * Selected with `--non-interactive` or `-y`.
12
+ */
13
+ export type Interactivity = 'auto' | 'interactive' | 'non-interactive'
14
+
15
+ /**
16
+ * Argument keys that select the interactivity
17
+ * and are therefore not command parameters.
18
+ */
19
+ export const interactivityFlags: string[] = [
20
+ 'non_interactive',
21
+ 'y',
22
+ 'interactive',
23
+ 'i',
24
+ ]
25
+
26
+ /**
27
+ * Argument keys that configure the CLI itself
28
+ * and are therefore never sent as command parameters.
29
+ */
30
+ export const cliFlags: string[] = [
31
+ ...interactivityFlags,
32
+ 'h',
33
+ 'help',
34
+ 'json',
35
+ 'remote_api_defs',
36
+ 'update',
37
+ 'version',
38
+ ]
39
+
40
+ /**
41
+ * Thrown when the CLI needs input it cannot prompt for.
42
+ */
43
+ export class NonInteractiveError extends Error {
44
+ override name = 'NonInteractiveError'
45
+ }
46
+
47
+ export const parseCliArgs = (argv: string[]): ParsedArgs =>
48
+ parseArgs(argv, {
49
+ string: ['code'],
50
+ boolean: ['non-interactive', 'interactive', 'json'],
51
+ // Deliberately not aliased to -n, which is reserved for a future
52
+ // --dry-run flag.
53
+ alias: { 'non-interactive': 'y', interactive: 'i' },
54
+ })
55
+
56
+ export interface GetInteractivityOptions {
57
+ /**
58
+ * Whether there is a terminal to prompt on.
59
+ *
60
+ * When there is not, the CLI cannot ask for anything, so it behaves as
61
+ * though `--non-interactive` was given rather than waiting on a prompt
62
+ * nobody can answer.
63
+ */
64
+ canPrompt?: boolean
65
+ }
66
+
67
+ export const getInteractivity = (
68
+ args: ParsedArgs,
69
+ { canPrompt = true }: GetInteractivityOptions = {},
70
+ ): Interactivity => {
71
+ const isNonInteractive =
72
+ args['non_interactive'] === true || args['y'] === true
73
+ const isInteractive = args['interactive'] === true || args['i'] === true
74
+
75
+ if (isNonInteractive && isInteractive) {
76
+ throw new Error(
77
+ 'The --interactive and --non-interactive flags cannot be used together',
78
+ )
79
+ }
80
+ if (isNonInteractive) return 'non-interactive'
81
+ // An explicit --interactive still asks, and fails loudly if it cannot.
82
+ if (isInteractive) return 'interactive'
83
+ if (!canPrompt) return 'non-interactive'
84
+ return 'auto'
85
+ }
86
+
87
+ /**
88
+ * Render a parameter name as the argument used to set it,
89
+ * e.g., `device_id` as `--device-id`.
90
+ */
91
+ export const toArgName = (parameterName: string): string =>
92
+ `--${parameterName.replace(/_/g, '-')}`
@@ -0,0 +1,38 @@
1
+ import prompts, { type Answers, type Options, type PromptObject } from 'prompts'
2
+
3
+ import { NonInteractiveError } from './cli-args.js'
4
+
5
+ /**
6
+ * Whether the CLI can ask the user a question.
7
+ *
8
+ * Prompts read raw keypresses and render an interface, so they need a
9
+ * terminal on both ends: when stdin is a pipe or a file it holds request
10
+ * params, not answers, and when stderr is redirected nobody sees the
11
+ * question.
12
+ */
13
+ export const canPrompt = (): boolean =>
14
+ process.stdin.isTTY === true && process.stderr.isTTY === true
15
+
16
+ /**
17
+ * Ask the user a question.
18
+ *
19
+ * Prompts are rendered to stderr: a selection is not a command result,
20
+ * so it must not end up in stdout when the CLI is piped.
21
+ */
22
+ export const prompt = async <T extends string = string>(
23
+ questions: PromptObject<T> | Array<PromptObject<T>>,
24
+ options?: Options,
25
+ ): Promise<Answers<T>> => {
26
+ if (!canPrompt()) {
27
+ throw new NonInteractiveError(
28
+ 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON',
29
+ )
30
+ }
31
+
32
+ const questionList = Array.isArray(questions) ? questions : [questions]
33
+
34
+ return await prompts(
35
+ questionList.map((question) => ({ ...question, stdout: process.stderr })),
36
+ options,
37
+ )
38
+ }
@@ -0,0 +1,59 @@
1
+ import type { Readable } from 'node:stream'
2
+
3
+ export interface StdinLike extends AsyncIterable<string | Buffer> {
4
+ isTTY?: boolean | undefined
5
+ }
6
+
7
+ /**
8
+ * Read request params piped into the CLI, e.g.,
9
+ *
10
+ * ```
11
+ * $ echo '{"device_id": "..."}' | seam locks unlock-door --json
12
+ * $ seam locks unlock-door --json < params.json
13
+ * ```
14
+ *
15
+ * Returns null when stdin is a terminal or is empty,
16
+ * so an interactive session is never blocked waiting for input.
17
+ */
18
+ export const readStdinJson = async (
19
+ stdin: StdinLike | Readable = process.stdin,
20
+ ): Promise<Record<string, unknown> | null> => {
21
+ if ((stdin as StdinLike).isTTY ?? false) return null
22
+
23
+ let raw = ''
24
+ for await (const chunk of stdin) {
25
+ raw += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
26
+ }
27
+
28
+ return parseJsonParams(raw, 'stdin')
29
+ }
30
+
31
+ /**
32
+ * Parse JSON request params, e.g., from stdin or `--json '{"limit": 2}'`.
33
+ *
34
+ * Returns null when there is nothing to parse.
35
+ */
36
+ export const parseJsonParams = (
37
+ raw: string,
38
+ source: string,
39
+ ): Record<string, unknown> | null => {
40
+ const trimmed = raw.trim()
41
+ if (trimmed === '') return null
42
+
43
+ let parsed: unknown
44
+ try {
45
+ parsed = JSON.parse(trimmed)
46
+ } catch (error) {
47
+ throw new Error(
48
+ `Could not parse JSON from ${source}: ${(error as Error).message}`,
49
+ )
50
+ }
51
+
52
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
53
+ throw new Error(
54
+ `Expected a JSON object of request params from ${source}, got ${Array.isArray(parsed) ? 'an array' : typeof parsed}`,
55
+ )
56
+ }
57
+
58
+ return parsed as Record<string, unknown>
59
+ }
@@ -1,19 +1,29 @@
1
1
  import chalk from 'chalk'
2
2
 
3
3
  import { getSeam } from 'lib/get-seam.js'
4
+ import { getOutput } from 'lib/output/get-output.js'
5
+ import { selectResponsePayload } from 'lib/output/select-response-payload.js'
4
6
 
5
7
  import { withLoading } from './with-loading.js'
6
8
 
9
+ export interface RequestSeamApiOptions {
10
+ path: string
11
+ params: Record<string, any>
12
+ /** Response key for the endpoint, used to trim the reported payload. */
13
+ responseKey?: string | null | undefined
14
+ }
15
+
7
16
  export const RequestSeamApi = async ({
8
17
  path,
9
18
  params,
10
- }: {
11
- path: string
12
- params: Record<string, any>
13
- }) => {
19
+ responseKey,
20
+ }: RequestSeamApiOptions) => {
14
21
  const seam = await getSeam()
22
+ const output = getOutput()
15
23
 
16
- logRequest(path, params)
24
+ output.info(`\n${chalk.green(path)}`)
25
+ output.info(`Request Params:`)
26
+ output.info(formatParams(params))
17
27
 
18
28
  const response = await withLoading('Making request...', () =>
19
29
  seam.client.post(path, params, {
@@ -21,23 +31,17 @@ export const RequestSeamApi = async ({
21
31
  }),
22
32
  )
23
33
 
24
- logResponse(response)
25
-
26
- return response
27
- }
28
-
29
- const logResponse = (response: { status: number; data: unknown }) => {
30
34
  if (response.status >= 400) {
31
- console.log(chalk.red(`\n\n[${response.status}]\n`))
35
+ output.warn(chalk.red(`[${response.status}]`))
36
+ process.exitCode = 1
32
37
  } else {
33
- console.log(chalk.green(`\n\n[${response.status}]`))
38
+ output.info(chalk.green(`[${response.status}]`))
34
39
  }
35
- console.dir(response.data, { depth: null })
36
- console.log('\n')
37
- }
38
40
 
39
- const logRequest = (apiPath: string, params: Record<string, any>) => {
40
- console.log(`\n\n${chalk.green(apiPath)}`)
41
- console.log(`Request Params:`)
42
- console.log(params)
41
+ output.data(selectResponsePayload(response.data, { responseKey }))
42
+
43
+ return response
43
44
  }
45
+
46
+ const formatParams = (params: Record<string, any>): string =>
47
+ JSON.stringify(params, null, 2)
@@ -1,10 +1,15 @@
1
1
  import { createSpinner } from 'nanospinner'
2
2
 
3
+ import { getOutput } from 'lib/output/get-output.js'
4
+
3
5
  export const withLoading = async <T>(
4
6
  message: string,
5
7
  fn: () => Promise<T>,
6
8
  ): Promise<T> => {
7
- const spinner = createSpinner(message).start()
9
+ if (!shouldSpin()) return await fn()
10
+
11
+ // Progress is not a command result, so it is rendered to stderr.
12
+ const spinner = createSpinner(message, { stream: process.stderr }).start()
8
13
  try {
9
14
  const result = await fn()
10
15
  spinner.success()
@@ -14,3 +19,6 @@ export const withLoading = async <T>(
14
19
  throw error
15
20
  }
16
21
  }
22
+
23
+ const shouldSpin = (): boolean =>
24
+ getOutput().format === 'text' && process.stderr.isTTY === true
@@ -1,5 +1,5 @@
1
1
  // Versions are replaced with generated values when the package is packed.
2
- const seamapiCliVersion = '0.9.0'
2
+ const seamapiCliVersion = '0.11.0'
3
3
  const seamapiBlueprintVersion = '1.2.0'
4
4
 
5
5
  export { seamapiBlueprintVersion }