@seamapi/cli 0.10.0 → 0.12.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.
- package/README.md +118 -0
- package/bin/cli.js +78 -100
- package/bin/cli.js.map +1 -1
- package/completions/seam.bash +11 -0
- package/completions/seam.fish +11 -0
- package/completions/seam.zsh +13 -0
- package/lib/command-spec.d.ts +53 -0
- package/lib/command-spec.js +290 -0
- package/lib/command-spec.js.map +1 -0
- package/lib/completion/describe.d.ts +10 -0
- package/lib/completion/describe.js +16 -0
- package/lib/completion/describe.js.map +1 -0
- package/lib/completion/index.d.ts +20 -0
- package/lib/completion/index.js +65 -0
- package/lib/completion/index.js.map +1 -0
- package/lib/completion/render-bash.d.ts +2 -0
- package/lib/completion/render-bash.js +98 -0
- package/lib/completion/render-bash.js.map +1 -0
- package/lib/completion/render-fish.d.ts +2 -0
- package/lib/completion/render-fish.js +58 -0
- package/lib/completion/render-fish.js.map +1 -0
- package/lib/completion/render-zsh.d.ts +2 -0
- package/lib/completion/render-zsh.js +121 -0
- package/lib/completion/render-zsh.js.map +1 -0
- package/lib/get-response-key.d.ts +9 -0
- package/lib/get-response-key.js +21 -0
- package/lib/get-response-key.js.map +1 -0
- package/lib/interact-for-action-attempt-poll.js +4 -3
- package/lib/interact-for-action-attempt-poll.js.map +1 -1
- package/lib/interact-for-array.js +9 -7
- package/lib/interact-for-array.js.map +1 -1
- package/lib/interact-for-blueprint-object.js +9 -8
- package/lib/interact-for-blueprint-object.js.map +1 -1
- package/lib/interact-for-command-selection.js +2 -2
- package/lib/interact-for-command-selection.js.map +1 -1
- package/lib/interact-for-custom-metadata.js +10 -8
- package/lib/interact-for-custom-metadata.js.map +1 -1
- package/lib/interact-for-login.js +8 -6
- package/lib/interact-for-login.js.map +1 -1
- package/lib/interact-for-resource.js +2 -2
- package/lib/interact-for-resource.js.map +1 -1
- package/lib/interact-for-server-selection.js +7 -5
- package/lib/interact-for-server-selection.js.map +1 -1
- package/lib/interact-for-timestamp.js +2 -2
- package/lib/interact-for-timestamp.js.map +1 -1
- package/lib/interact-for-use-remote-api-defs.js +4 -3
- package/lib/interact-for-use-remote-api-defs.js.map +1 -1
- package/lib/interact-for-workspace-id.js +2 -2
- package/lib/interact-for-workspace-id.js.map +1 -1
- package/lib/output/create-memory-output.d.ts +13 -0
- package/lib/output/create-memory-output.js +22 -0
- package/lib/output/create-memory-output.js.map +1 -0
- package/lib/output/create-output.d.ts +50 -0
- package/lib/output/create-output.js +34 -0
- package/lib/output/create-output.js.map +1 -0
- package/lib/output/get-output.d.ts +12 -0
- package/lib/output/get-output.js +21 -0
- package/lib/output/get-output.js.map +1 -0
- package/lib/output/resolve-output-format.d.ts +16 -0
- package/lib/output/resolve-output-format.js +23 -0
- package/lib/output/resolve-output-format.js.map +1 -0
- package/lib/output/select-response-payload.d.ts +16 -0
- package/lib/output/select-response-payload.js +33 -0
- package/lib/output/select-response-payload.js.map +1 -0
- package/lib/render-help.d.ts +8 -0
- package/lib/render-help.js +150 -0
- package/lib/render-help.js.map +1 -0
- package/lib/util/cli-args.d.ts +16 -1
- package/lib/util/cli-args.js +18 -2
- package/lib/util/cli-args.js.map +1 -1
- package/lib/util/prompt.d.ts +17 -0
- package/lib/util/prompt.js +25 -0
- package/lib/util/prompt.js.map +1 -0
- package/lib/util/read-stdin-json.d.ts +22 -0
- package/lib/util/read-stdin-json.js +42 -0
- package/lib/util/read-stdin-json.js.map +1 -0
- package/lib/util/request-seam-api.d.ts +5 -2
- package/lib/util/request-seam-api.js +13 -15
- package/lib/util/request-seam-api.js.map +1 -1
- package/lib/util/with-loading.js +6 -1
- package/lib/util/with-loading.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +2 -1
- package/src/bin/cli.ts +100 -106
- package/src/lib/command-spec.ts +400 -0
- package/src/lib/completion/describe.ts +21 -0
- package/src/lib/completion/index.ts +82 -0
- package/src/lib/completion/render-bash.ts +125 -0
- package/src/lib/completion/render-fish.ts +80 -0
- package/src/lib/completion/render-zsh.ts +157 -0
- package/src/lib/get-response-key.ts +25 -0
- package/src/lib/interact-for-action-attempt-poll.ts +4 -3
- package/src/lib/interact-for-array.ts +9 -7
- package/src/lib/interact-for-blueprint-object.ts +9 -8
- package/src/lib/interact-for-command-selection.ts +2 -3
- package/src/lib/interact-for-custom-metadata.ts +10 -8
- package/src/lib/interact-for-login.ts +8 -6
- package/src/lib/interact-for-resource.ts +2 -3
- package/src/lib/interact-for-server-selection.ts +7 -6
- package/src/lib/interact-for-timestamp.ts +2 -2
- package/src/lib/interact-for-use-remote-api-defs.ts +4 -4
- package/src/lib/interact-for-workspace-id.ts +2 -2
- package/src/lib/output/create-memory-output.ts +39 -0
- package/src/lib/output/create-output.ts +104 -0
- package/src/lib/output/get-output.ts +24 -0
- package/src/lib/output/resolve-output-format.ts +33 -0
- package/src/lib/output/select-response-payload.ts +56 -0
- package/src/lib/render-help.ts +197 -0
- package/src/lib/util/cli-args.ts +32 -2
- package/src/lib/util/prompt.ts +38 -0
- package/src/lib/util/read-stdin-json.ts +59 -0
- package/src/lib/util/request-seam-api.ts +24 -20
- package/src/lib/util/with-loading.ts +9 -1
- package/src/lib/version.ts +1 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { Blueprint } from '@seamapi/blueprint'
|
|
2
|
+
|
|
3
|
+
type Endpoint = Blueprint['routes'][number]['endpoints'][number]
|
|
4
|
+
type Parameter = Endpoint['request']['parameters'][number]
|
|
5
|
+
|
|
6
|
+
export interface CommandFlag {
|
|
7
|
+
/** Long form without the leading `--`, or `null` for short-only flags. */
|
|
8
|
+
long: string | null
|
|
9
|
+
/** Short form without the leading `-`, or `null` when there is none. */
|
|
10
|
+
short: string | null
|
|
11
|
+
description: string
|
|
12
|
+
/** Known values for the flag, used to complete and document its argument. */
|
|
13
|
+
values: string[]
|
|
14
|
+
/** Whether the flag is followed by a value. */
|
|
15
|
+
takesValue: boolean
|
|
16
|
+
isRequired: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a command is part of the CLI itself or calls a Seam API endpoint.
|
|
21
|
+
*/
|
|
22
|
+
export type CommandKind = 'cli' | 'api'
|
|
23
|
+
|
|
24
|
+
export interface CommandDefinition {
|
|
25
|
+
path: string[]
|
|
26
|
+
kind: CommandKind
|
|
27
|
+
/** One line naming what the command does. */
|
|
28
|
+
title: string
|
|
29
|
+
/** Longer prose about the command, empty when there is none to add. */
|
|
30
|
+
description: string
|
|
31
|
+
flags: CommandFlag[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Subcommand {
|
|
35
|
+
name: string
|
|
36
|
+
/** 'api' when the name holds any command that calls the Seam API. */
|
|
37
|
+
kind: CommandKind
|
|
38
|
+
description: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandGroup {
|
|
42
|
+
/** Command path completed by this group, empty for `seam` itself. */
|
|
43
|
+
path: string[]
|
|
44
|
+
subcommands: Subcommand[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CommandSpec {
|
|
48
|
+
/** Every invocable command, sorted by command path. */
|
|
49
|
+
commands: CommandDefinition[]
|
|
50
|
+
/** Every incomplete command path, sorted by command path. */
|
|
51
|
+
groups: CommandGroup[]
|
|
52
|
+
/** Flags accepted regardless of the command. */
|
|
53
|
+
globalFlags: CommandFlag[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const globalFlags: CommandFlag[] = [
|
|
57
|
+
{
|
|
58
|
+
long: 'help',
|
|
59
|
+
short: 'h',
|
|
60
|
+
description: 'Display this help guide.',
|
|
61
|
+
values: [],
|
|
62
|
+
takesValue: false,
|
|
63
|
+
isRequired: false,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
long: 'interactive',
|
|
67
|
+
short: 'i',
|
|
68
|
+
description:
|
|
69
|
+
'Always prompt to review and edit properties, prefilled with the given arguments.',
|
|
70
|
+
values: [],
|
|
71
|
+
takesValue: false,
|
|
72
|
+
isRequired: false,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
long: 'json',
|
|
76
|
+
short: null,
|
|
77
|
+
description:
|
|
78
|
+
'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with --no-json.',
|
|
79
|
+
values: [],
|
|
80
|
+
takesValue: false,
|
|
81
|
+
isRequired: false,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
long: 'non-interactive',
|
|
85
|
+
short: 'y',
|
|
86
|
+
description:
|
|
87
|
+
'Never prompt: exit with an error if the command or any required property is missing.',
|
|
88
|
+
values: [],
|
|
89
|
+
takesValue: false,
|
|
90
|
+
isRequired: false,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
long: 'remote-api-defs',
|
|
94
|
+
short: null,
|
|
95
|
+
description: 'Use the API definitions served by the Seam API.',
|
|
96
|
+
values: [],
|
|
97
|
+
takesValue: false,
|
|
98
|
+
isRequired: false,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
long: 'update',
|
|
102
|
+
short: null,
|
|
103
|
+
description: 'Force an update of the cached Seam API definitions.',
|
|
104
|
+
values: [],
|
|
105
|
+
takesValue: false,
|
|
106
|
+
isRequired: false,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
long: 'version',
|
|
110
|
+
short: null,
|
|
111
|
+
description: 'Print the CLI version.',
|
|
112
|
+
values: [],
|
|
113
|
+
takesValue: false,
|
|
114
|
+
isRequired: false,
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
export const flagTokens = (flag: CommandFlag): string[] => {
|
|
119
|
+
const tokens = []
|
|
120
|
+
if (flag.long != null) tokens.push(`--${flag.long}`)
|
|
121
|
+
if (flag.short != null) tokens.push(`-${flag.short}`)
|
|
122
|
+
return tokens
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const getCommandSpec = (blueprint: Blueprint): CommandSpec => {
|
|
126
|
+
const commands = sortByPath(
|
|
127
|
+
dedupeByPath([
|
|
128
|
+
...blueprint.routes
|
|
129
|
+
.flatMap((route) => route.endpoints)
|
|
130
|
+
.map(toCommandDefinition)
|
|
131
|
+
// Command and flag names end up unquoted or single-quoted in shell
|
|
132
|
+
// scripts, so drop any the definitions should never contain rather
|
|
133
|
+
// than emit something a shell could read as syntax.
|
|
134
|
+
.filter((command) => command.path.every(isSafeToken)),
|
|
135
|
+
...localCommands,
|
|
136
|
+
]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return { commands, groups: toCommandGroups(commands), globalFlags }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const findCommand = (
|
|
143
|
+
spec: CommandSpec,
|
|
144
|
+
path: string[],
|
|
145
|
+
): CommandDefinition | undefined =>
|
|
146
|
+
spec.commands.find((command) => isSamePath(command.path, path))
|
|
147
|
+
|
|
148
|
+
export const findGroup = (
|
|
149
|
+
spec: CommandSpec,
|
|
150
|
+
path: string[],
|
|
151
|
+
): CommandGroup | undefined =>
|
|
152
|
+
spec.groups.find((group) => isSamePath(group.path, path))
|
|
153
|
+
|
|
154
|
+
const isSamePath = (a: string[], b: string[]): boolean =>
|
|
155
|
+
a.length === b.length && a.every((word, index) => word === b[index])
|
|
156
|
+
|
|
157
|
+
const stringFlag = (long: string, description: string): CommandFlag => ({
|
|
158
|
+
long,
|
|
159
|
+
short: null,
|
|
160
|
+
description,
|
|
161
|
+
values: [],
|
|
162
|
+
takesValue: true,
|
|
163
|
+
isRequired: false,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Commands handled by the CLI itself, which have no endpoint in the blueprint.
|
|
168
|
+
*
|
|
169
|
+
* Keep in sync with the command handling in `src/bin/cli.ts` and the extra
|
|
170
|
+
* commands offered by `interactForCommandSelection`.
|
|
171
|
+
*/
|
|
172
|
+
const localCommands: CommandDefinition[] = [
|
|
173
|
+
{
|
|
174
|
+
path: ['completion', 'bash'],
|
|
175
|
+
kind: 'cli',
|
|
176
|
+
title: 'Print the bash completion script.',
|
|
177
|
+
description: '',
|
|
178
|
+
flags: [],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
path: ['completion', 'fish'],
|
|
182
|
+
kind: 'cli',
|
|
183
|
+
title: 'Print the fish completion script.',
|
|
184
|
+
description: '',
|
|
185
|
+
flags: [],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
path: ['completion', 'zsh'],
|
|
189
|
+
kind: 'cli',
|
|
190
|
+
title: 'Print the zsh completion script.',
|
|
191
|
+
description: '',
|
|
192
|
+
flags: [],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
path: ['config', 'reveal-location'],
|
|
196
|
+
kind: 'cli',
|
|
197
|
+
title: 'Print the path to the CLI configuration file.',
|
|
198
|
+
description: '',
|
|
199
|
+
flags: [],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
path: ['config', 'use-remote-api-defs'],
|
|
203
|
+
kind: 'cli',
|
|
204
|
+
title: 'Choose whether to use the API definitions served by Seam.',
|
|
205
|
+
description: '',
|
|
206
|
+
flags: [],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
path: ['health', 'get-health'],
|
|
210
|
+
kind: 'api',
|
|
211
|
+
title: 'Report the health of the Seam API.',
|
|
212
|
+
description: '',
|
|
213
|
+
flags: [],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
path: ['login'],
|
|
217
|
+
kind: 'cli',
|
|
218
|
+
title: 'Log in to Seam.',
|
|
219
|
+
description:
|
|
220
|
+
'Prompts for a personal access token unless one is passed with --token.',
|
|
221
|
+
flags: [
|
|
222
|
+
stringFlag('server', 'Seam API server to log in to.'),
|
|
223
|
+
stringFlag('token', 'Personal access token to log in with.'),
|
|
224
|
+
stringFlag('workspace-id', 'Workspace to select after logging in.'),
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
path: ['logout'],
|
|
229
|
+
kind: 'cli',
|
|
230
|
+
title: 'Log out of Seam.',
|
|
231
|
+
description: '',
|
|
232
|
+
flags: [],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
path: ['select', 'server'],
|
|
236
|
+
kind: 'cli',
|
|
237
|
+
title: 'Select the Seam API server.',
|
|
238
|
+
description: '',
|
|
239
|
+
flags: [stringFlag('server', 'Seam API server to select.')],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
path: ['select', 'workspace'],
|
|
243
|
+
kind: 'cli',
|
|
244
|
+
title: 'Select the current workspace.',
|
|
245
|
+
description: '',
|
|
246
|
+
flags: [],
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
path: ['wizard'],
|
|
250
|
+
kind: 'cli',
|
|
251
|
+
title: 'Set up Seam in the current project.',
|
|
252
|
+
description:
|
|
253
|
+
'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.',
|
|
254
|
+
flags: [],
|
|
255
|
+
},
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => {
|
|
259
|
+
const description = toPlainText(endpoint.description)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
path: toCommandPath(endpoint.path),
|
|
263
|
+
kind: 'api',
|
|
264
|
+
title:
|
|
265
|
+
endpoint.title === ''
|
|
266
|
+
? firstSentence(description)
|
|
267
|
+
: toPlainText(endpoint.title),
|
|
268
|
+
description,
|
|
269
|
+
flags: [...endpoint.request.parameters]
|
|
270
|
+
.map(toCommandFlag)
|
|
271
|
+
.filter((flag) => flag.long == null || isSafeToken(flag.long))
|
|
272
|
+
.sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const toCommandFlag = (parameter: Parameter): CommandFlag => ({
|
|
277
|
+
long: toFlagName(parameter.name),
|
|
278
|
+
short: null,
|
|
279
|
+
description: toPlainText(parameter.description),
|
|
280
|
+
values: toFlagValues(parameter),
|
|
281
|
+
takesValue: true,
|
|
282
|
+
isRequired: parameter.isRequired,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const toFlagValues = (parameter: Parameter): string[] => {
|
|
286
|
+
if (parameter.format === 'enum') {
|
|
287
|
+
return parameter.values.map(({ name }) => name).filter(isSafeToken)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (parameter.format === 'list' && parameter.itemFormat === 'enum') {
|
|
291
|
+
return parameter.itemEnumValues.map(({ name }) => name).filter(isSafeToken)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Nothing marks parameters as boolean-only flags, so minimist reads the next
|
|
295
|
+
// argument as the value.
|
|
296
|
+
if (parameter.format === 'boolean') return ['true', 'false']
|
|
297
|
+
|
|
298
|
+
return []
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether a word is safe to write into a shell script. Command, flag, and
|
|
303
|
+
* enum names come from the API definitions and are embedded unquoted or
|
|
304
|
+
* single-quoted in completion scripts, so never emit one that a shell could
|
|
305
|
+
* read as syntax.
|
|
306
|
+
*/
|
|
307
|
+
const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token)
|
|
308
|
+
|
|
309
|
+
interface GroupEntry {
|
|
310
|
+
isCommand: boolean
|
|
311
|
+
kind: CommandKind
|
|
312
|
+
description: string
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => {
|
|
316
|
+
const groups = new Map<string, Map<string, GroupEntry>>()
|
|
317
|
+
|
|
318
|
+
for (const command of commands) {
|
|
319
|
+
for (const [depth, name] of command.path.entries()) {
|
|
320
|
+
const key = command.path.slice(0, depth).join(' ')
|
|
321
|
+
|
|
322
|
+
const entries = groups.get(key) ?? new Map<string, GroupEntry>()
|
|
323
|
+
groups.set(key, entries)
|
|
324
|
+
|
|
325
|
+
// An entry is an API command if any command it holds calls the API.
|
|
326
|
+
const kind =
|
|
327
|
+
command.kind === 'api' ? 'api' : (entries.get(name)?.kind ?? 'cli')
|
|
328
|
+
|
|
329
|
+
// A command and a group may share a name, e.g., a hypothetical
|
|
330
|
+
// `seam devices` alongside `seam devices list`. Prefer the command
|
|
331
|
+
// title, since it describes what running the name does.
|
|
332
|
+
if (depth === command.path.length - 1) {
|
|
333
|
+
entries.set(name, { isCommand: true, kind, description: command.title })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const entry = entries.get(name)
|
|
338
|
+
entries.set(name, {
|
|
339
|
+
isCommand: entry?.isCommand ?? false,
|
|
340
|
+
kind,
|
|
341
|
+
description: entry?.description ?? '',
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Groups have no description of their own in the API definitions, so name
|
|
347
|
+
// the commands they hold instead. Leave the list whole: help wraps it, and
|
|
348
|
+
// completion shortens it to fit a menu column.
|
|
349
|
+
const summarizeGroup = (key: string): string =>
|
|
350
|
+
[...(groups.get(key)?.keys() ?? [])].join(', ')
|
|
351
|
+
|
|
352
|
+
return [...groups]
|
|
353
|
+
.map(([key, entries]) => ({
|
|
354
|
+
path: key === '' ? [] : key.split(' '),
|
|
355
|
+
subcommands: [...entries]
|
|
356
|
+
.map(([name, entry]) => ({
|
|
357
|
+
name,
|
|
358
|
+
kind: entry.kind,
|
|
359
|
+
description: entry.isCommand
|
|
360
|
+
? entry.description
|
|
361
|
+
: summarizeGroup(key === '' ? name : `${key} ${name}`),
|
|
362
|
+
}))
|
|
363
|
+
.sort((a, b) => compare(a.name, b.name)),
|
|
364
|
+
}))
|
|
365
|
+
.sort((a, b) => compare(a.path.join(' '), b.path.join(' ')))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const dedupeByPath = (commands: CommandDefinition[]): CommandDefinition[] => {
|
|
369
|
+
const byPath = new Map<string, CommandDefinition>()
|
|
370
|
+
for (const command of commands) {
|
|
371
|
+
const key = command.path.join(' ')
|
|
372
|
+
if (byPath.has(key)) continue
|
|
373
|
+
byPath.set(key, command)
|
|
374
|
+
}
|
|
375
|
+
return [...byPath.values()]
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const sortByPath = (commands: CommandDefinition[]): CommandDefinition[] =>
|
|
379
|
+
[...commands].sort((a, b) => compare(a.path.join(' '), b.path.join(' ')))
|
|
380
|
+
|
|
381
|
+
const compare = (a: string | null, b: string | null): number =>
|
|
382
|
+
(a ?? '') < (b ?? '') ? -1 : (a ?? '') > (b ?? '') ? 1 : 0
|
|
383
|
+
|
|
384
|
+
const toCommandPath = (path: string): string[] =>
|
|
385
|
+
path.replace(/^\//, '').split('/').map(toFlagName)
|
|
386
|
+
|
|
387
|
+
const toFlagName = (name: string): string => name.replace(/_/g, '-')
|
|
388
|
+
|
|
389
|
+
/** Reduce documentation markdown to a single line of prose. */
|
|
390
|
+
export const toPlainText = (markdown: string): string =>
|
|
391
|
+
markdown
|
|
392
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
393
|
+
.replace(/[`*]/g, '')
|
|
394
|
+
.replace(/\s+/g, ' ')
|
|
395
|
+
.trim()
|
|
396
|
+
|
|
397
|
+
export const firstSentence = (text: string): string => {
|
|
398
|
+
const [sentence] = text.split(/(?<=\.)\s/)
|
|
399
|
+
return sentence ?? text
|
|
400
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { firstSentence } from '../command-spec.js'
|
|
2
|
+
import { ellipsis } from '../util/ellipsis.js'
|
|
3
|
+
|
|
4
|
+
const maxDescriptionLength = 72
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reduce a description to one short line that is safe to embed in a
|
|
8
|
+
* single-quoted shell string.
|
|
9
|
+
*
|
|
10
|
+
* Completion menus give a description a single narrow column, and the shells
|
|
11
|
+
* offer no way to escape a quote inside the generated scripts, so drop any
|
|
12
|
+
* character that would end the string early. A colon goes too, since zsh reads
|
|
13
|
+
* it as the separator in a `_describe` entry.
|
|
14
|
+
*/
|
|
15
|
+
export const describeForShell = (description: string): string =>
|
|
16
|
+
ellipsis(
|
|
17
|
+
firstSentence(description)
|
|
18
|
+
.replace(/['"`$\\:]/g, '')
|
|
19
|
+
.trim(),
|
|
20
|
+
maxDescriptionLength,
|
|
21
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Blueprint } from '@seamapi/blueprint'
|
|
2
|
+
|
|
3
|
+
import { type CommandSpec, getCommandSpec } from '../command-spec.js'
|
|
4
|
+
import { renderBashCompletion } from './render-bash.js'
|
|
5
|
+
import { renderFishCompletion } from './render-fish.js'
|
|
6
|
+
import { renderZshCompletion } from './render-zsh.js'
|
|
7
|
+
|
|
8
|
+
export const completionShells = ['bash', 'fish', 'zsh'] as const
|
|
9
|
+
|
|
10
|
+
export type CompletionShell = (typeof completionShells)[number]
|
|
11
|
+
|
|
12
|
+
export const isCompletionShell = (shell: unknown): shell is CompletionShell =>
|
|
13
|
+
completionShells.includes(shell as CompletionShell)
|
|
14
|
+
|
|
15
|
+
/** File name to install the completion script for each shell as. */
|
|
16
|
+
export const completionFileNames: Record<CompletionShell, string> = {
|
|
17
|
+
bash: 'seam.bash',
|
|
18
|
+
fish: 'seam.fish',
|
|
19
|
+
zsh: 'seam.zsh',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const renderers: Record<CompletionShell, (spec: CommandSpec) => string> = {
|
|
23
|
+
bash: renderBashCompletion,
|
|
24
|
+
fish: renderFishCompletion,
|
|
25
|
+
zsh: renderZshCompletion,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const renderCompletion = (
|
|
29
|
+
shell: CompletionShell,
|
|
30
|
+
blueprint: Blueprint,
|
|
31
|
+
): string => renderers[shell](getCommandSpec(blueprint))
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render the completion loader installed by system packages.
|
|
35
|
+
*
|
|
36
|
+
* The loader runs 'seam completion' the first time the shell completes a seam
|
|
37
|
+
* command, so installed completions always match the CLI's current Seam API
|
|
38
|
+
* definitions instead of the definitions packaged at release time. Each shell
|
|
39
|
+
* loads its completion file on demand, so the CLI runs once per shell session
|
|
40
|
+
* at first completion, never at shell startup.
|
|
41
|
+
*
|
|
42
|
+
* The loader degrades to no completions when the seam command is missing or
|
|
43
|
+
* cannot produce a script, e.g., offline before the definitions are cached.
|
|
44
|
+
*/
|
|
45
|
+
export const renderCompletionStub = (shell: CompletionShell): string =>
|
|
46
|
+
stubs[shell]
|
|
47
|
+
|
|
48
|
+
const stubHeader = (shell: CompletionShell): string =>
|
|
49
|
+
`# ${shell} completion loader for the seam command.
|
|
50
|
+
#
|
|
51
|
+
# Generated by @seamapi/cli. Loads completions from the CLI on first use, so
|
|
52
|
+
# they always match the CLI's current Seam API definitions. Requires the seam
|
|
53
|
+
# command on PATH. Print the underlying script with 'seam completion ${shell}'.`
|
|
54
|
+
|
|
55
|
+
const stubs: Record<CompletionShell, string> = {
|
|
56
|
+
bash: `${stubHeader('bash')}
|
|
57
|
+
#
|
|
58
|
+
# Install to /usr/share/bash-completion/completions/seam
|
|
59
|
+
|
|
60
|
+
if command -v seam > /dev/null 2>&1; then
|
|
61
|
+
eval "$(seam completion bash 2> /dev/null)"
|
|
62
|
+
fi
|
|
63
|
+
`,
|
|
64
|
+
fish: `${stubHeader('fish')}
|
|
65
|
+
#
|
|
66
|
+
# Install to /usr/share/fish/vendor_completions.d/seam.fish
|
|
67
|
+
|
|
68
|
+
if command --query seam
|
|
69
|
+
seam completion fish 2> /dev/null | source
|
|
70
|
+
end
|
|
71
|
+
`,
|
|
72
|
+
zsh: `#compdef seam
|
|
73
|
+
${stubHeader('zsh')}
|
|
74
|
+
#
|
|
75
|
+
# Install to a directory in fpath as _seam
|
|
76
|
+
|
|
77
|
+
# The generated script ends by dispatching on funcstack, so evaluating it
|
|
78
|
+
# while this autoloaded _seam runs both redefines _seam and completes the
|
|
79
|
+
# in-flight request.
|
|
80
|
+
eval "$(seam completion zsh 2> /dev/null)"
|
|
81
|
+
`,
|
|
82
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommandFlag,
|
|
3
|
+
type CommandSpec,
|
|
4
|
+
flagTokens,
|
|
5
|
+
} from '../command-spec.js'
|
|
6
|
+
|
|
7
|
+
export const renderBashCompletion = (spec: CommandSpec): string => {
|
|
8
|
+
const globalTokens = spec.globalFlags.flatMap(flagTokens).sort()
|
|
9
|
+
const valuelessTokens = spec.globalFlags
|
|
10
|
+
.filter(({ takesValue }) => !takesValue)
|
|
11
|
+
.flatMap(flagTokens)
|
|
12
|
+
.sort()
|
|
13
|
+
|
|
14
|
+
return `${[
|
|
15
|
+
header,
|
|
16
|
+
`_seam_global_flags='${globalTokens.join(' ')}'`,
|
|
17
|
+
`_seam_valueless_flags=' ${valuelessTokens.join(' ')} '`,
|
|
18
|
+
renderCase('_seam_subcommands', subcommandBranches(spec)),
|
|
19
|
+
renderCase('_seam_flags', flagBranches(spec)),
|
|
20
|
+
renderCase('_seam_flag_values', flagValueBranches(spec)),
|
|
21
|
+
completionFunction,
|
|
22
|
+
'complete -F _seam_completion seam',
|
|
23
|
+
].join('\n\n')}\n`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const header = `# bash completion for the seam command.
|
|
27
|
+
#
|
|
28
|
+
# Generated by @seamapi/cli from the Seam API definitions.
|
|
29
|
+
# Do not edit: regenerate with 'seam completion bash'.
|
|
30
|
+
#
|
|
31
|
+
# Load it for the current shell with
|
|
32
|
+
#
|
|
33
|
+
# source <(seam completion bash)
|
|
34
|
+
#
|
|
35
|
+
# or install it for every shell with
|
|
36
|
+
#
|
|
37
|
+
# seam completion bash > /usr/share/bash-completion/completions/seam`
|
|
38
|
+
|
|
39
|
+
const completionFunction = `_seam_completion() {
|
|
40
|
+
local current previous word command subcommands
|
|
41
|
+
local -i index
|
|
42
|
+
|
|
43
|
+
current="\${COMP_WORDS[COMP_CWORD]}"
|
|
44
|
+
previous=''
|
|
45
|
+
if (( COMP_CWORD > 0 )); then
|
|
46
|
+
previous="\${COMP_WORDS[COMP_CWORD - 1]}"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# --flag=value splits into three words under the default word breaks.
|
|
50
|
+
if [[ "$previous" == '=' ]] && (( COMP_CWORD > 1 )); then
|
|
51
|
+
previous="\${COMP_WORDS[COMP_CWORD - 2]}"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# The command path is the run of words before the first flag.
|
|
55
|
+
command=''
|
|
56
|
+
for (( index = 1; index < COMP_CWORD; index++ )); do
|
|
57
|
+
word="\${COMP_WORDS[index]}"
|
|
58
|
+
if [[ "$word" == -* ]]; then
|
|
59
|
+
break
|
|
60
|
+
fi
|
|
61
|
+
command="\${command:+$command }$word"
|
|
62
|
+
done
|
|
63
|
+
|
|
64
|
+
# Completing the value of a flag that takes one.
|
|
65
|
+
if [[ "$previous" == -* && "$_seam_valueless_flags" != *" $previous "* ]]; then
|
|
66
|
+
COMPREPLY=( $(compgen -W "$(_seam_flag_values "$command $previous")" -- "$current") )
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [[ "$current" == -* ]]; then
|
|
71
|
+
COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") )
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
subcommands="$(_seam_subcommands "$command")"
|
|
76
|
+
if [[ -z "$subcommands" ]]; then
|
|
77
|
+
COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") )
|
|
78
|
+
return 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
COMPREPLY=( $(compgen -W "$subcommands" -- "$current") )
|
|
82
|
+
return 0
|
|
83
|
+
}`
|
|
84
|
+
|
|
85
|
+
interface Branch {
|
|
86
|
+
pattern: string
|
|
87
|
+
words: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const subcommandBranches = (spec: CommandSpec): Branch[] =>
|
|
91
|
+
spec.groups.map((group) => ({
|
|
92
|
+
pattern: group.path.join(' '),
|
|
93
|
+
words: group.subcommands.map(({ name }) => name),
|
|
94
|
+
}))
|
|
95
|
+
|
|
96
|
+
const flagBranches = (spec: CommandSpec): Branch[] =>
|
|
97
|
+
spec.commands
|
|
98
|
+
.filter(({ flags }) => flags.length > 0)
|
|
99
|
+
.map((command) => ({
|
|
100
|
+
pattern: command.path.join(' '),
|
|
101
|
+
words: command.flags.flatMap(flagTokens),
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
const flagValueBranches = (spec: CommandSpec): Branch[] =>
|
|
105
|
+
spec.commands.flatMap((command) =>
|
|
106
|
+
command.flags.filter(hasValues).flatMap((flag) =>
|
|
107
|
+
flagTokens(flag).map((token) => ({
|
|
108
|
+
pattern: `${command.path.join(' ')} ${token}`,
|
|
109
|
+
words: flag.values,
|
|
110
|
+
})),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const hasValues = (flag: CommandFlag): boolean => flag.values.length > 0
|
|
115
|
+
|
|
116
|
+
const renderCase = (name: string, branches: Branch[]): string =>
|
|
117
|
+
[
|
|
118
|
+
`${name}() {`,
|
|
119
|
+
` case "$1" in`,
|
|
120
|
+
...branches.map(
|
|
121
|
+
({ pattern, words }) => ` '${pattern}') echo '${words.join(' ')}' ;;`,
|
|
122
|
+
),
|
|
123
|
+
` esac`,
|
|
124
|
+
`}`,
|
|
125
|
+
].join('\n')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CommandFlag, CommandSpec } from '../command-spec.js'
|
|
2
|
+
import { describeForShell } from './describe.js'
|
|
3
|
+
|
|
4
|
+
export const renderFishCompletion = (spec: CommandSpec): string =>
|
|
5
|
+
`${[
|
|
6
|
+
header,
|
|
7
|
+
helpers,
|
|
8
|
+
['complete -c seam -f', ...subcommandCompletions(spec)].join('\n'),
|
|
9
|
+
flagCompletions(spec).join('\n'),
|
|
10
|
+
globalFlagCompletions(spec).join('\n'),
|
|
11
|
+
].join('\n\n')}\n`
|
|
12
|
+
|
|
13
|
+
const header = `# fish completion for the seam command.
|
|
14
|
+
#
|
|
15
|
+
# Generated by @seamapi/cli from the Seam API definitions.
|
|
16
|
+
# Do not edit: regenerate with 'seam completion fish'.
|
|
17
|
+
#
|
|
18
|
+
# Install it with
|
|
19
|
+
#
|
|
20
|
+
# seam completion fish > ~/.config/fish/completions/seam.fish`
|
|
21
|
+
|
|
22
|
+
const helpers = `function __seam_command --description 'Print the seam command path on the command line'
|
|
23
|
+
set -l tokens (commandline -opc)
|
|
24
|
+
set -l command
|
|
25
|
+
if test (count $tokens) -gt 1
|
|
26
|
+
# The command path is the run of words before the first flag.
|
|
27
|
+
for token in $tokens[2..-1]
|
|
28
|
+
if string match -q -- '-*' $token
|
|
29
|
+
break
|
|
30
|
+
end
|
|
31
|
+
set -a command $token
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
string join ' ' -- $command
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
function __seam_using --description 'Test whether the command line names the given seam command'
|
|
38
|
+
set -l command (__seam_command)
|
|
39
|
+
test "$command" = "$argv[1]"
|
|
40
|
+
end`
|
|
41
|
+
|
|
42
|
+
const subcommandCompletions = (spec: CommandSpec): string[] =>
|
|
43
|
+
spec.groups.flatMap((group) =>
|
|
44
|
+
group.subcommands.map(({ name, description }) =>
|
|
45
|
+
complete([
|
|
46
|
+
`-n '__seam_using "${group.path.join(' ')}"'`,
|
|
47
|
+
`-a '${name}'`,
|
|
48
|
+
describe(description),
|
|
49
|
+
]),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const flagCompletions = (spec: CommandSpec): string[] =>
|
|
54
|
+
spec.commands.flatMap((command) =>
|
|
55
|
+
command.flags.map((flag) =>
|
|
56
|
+
complete([
|
|
57
|
+
`-n '__seam_using "${command.path.join(' ')}"'`,
|
|
58
|
+
...flagOptions(flag),
|
|
59
|
+
]),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const globalFlagCompletions = (spec: CommandSpec): string[] =>
|
|
64
|
+
spec.globalFlags.map((flag) => complete(flagOptions(flag)))
|
|
65
|
+
|
|
66
|
+
const flagOptions = (flag: CommandFlag): string[] => [
|
|
67
|
+
...(flag.short == null ? [] : [`-s ${flag.short}`]),
|
|
68
|
+
...(flag.long == null ? [] : [`-l ${flag.long}`]),
|
|
69
|
+
...(flag.takesValue ? ['-r'] : []),
|
|
70
|
+
...(flag.values.length === 0 ? [] : [`-a '${flag.values.join(' ')}'`]),
|
|
71
|
+
describe(flag.description),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
const describe = (description: string): string => {
|
|
75
|
+
const summary = describeForShell(description)
|
|
76
|
+
return summary === '' ? '' : `-d '${summary}'`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const complete = (options: string[]): string =>
|
|
80
|
+
['complete -c seam', ...options.filter((option) => option !== '')].join(' ')
|