@kimchi-dev/kimchictl 0.0.2
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/LICENSE +203 -0
- package/README.md +105 -0
- package/bin/kimchictl.mjs +17 -0
- package/dist/api/http.d.ts +7 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +60 -0
- package/dist/api/http.js.map +1 -0
- package/dist/api/keys.d.ts +7 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +28 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/api/me.d.ts +12 -0
- package/dist/api/me.d.ts.map +1 -0
- package/dist/api/me.js +28 -0
- package/dist/api/me.js.map +1 -0
- package/dist/api/resolver.d.ts +26 -0
- package/dist/api/resolver.d.ts.map +1 -0
- package/dist/api/resolver.js +62 -0
- package/dist/api/resolver.js.map +1 -0
- package/dist/api/resources.d.ts +16 -0
- package/dist/api/resources.d.ts.map +1 -0
- package/dist/api/resources.js +96 -0
- package/dist/api/resources.js.map +1 -0
- package/dist/api/templates.d.ts +19 -0
- package/dist/api/templates.d.ts.map +1 -0
- package/dist/api/templates.js +25 -0
- package/dist/api/templates.js.map +1 -0
- package/dist/api/tokens.d.ts +23 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +51 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/api/types.d.ts +34 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/wait.d.ts +17 -0
- package/dist/api/wait.d.ts.map +1 -0
- package/dist/api/wait.js +37 -0
- package/dist/api/wait.js.map +1 -0
- package/dist/api/workspaces.d.ts +33 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +159 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/auth/auth-file.d.ts +37 -0
- package/dist/auth/auth-file.d.ts.map +1 -0
- package/dist/auth/auth-file.js +100 -0
- package/dist/auth/auth-file.js.map +1 -0
- package/dist/auth/browser-login.d.ts +45 -0
- package/dist/auth/browser-login.d.ts.map +1 -0
- package/dist/auth/browser-login.js +195 -0
- package/dist/auth/browser-login.js.map +1 -0
- package/dist/auth/config-file.d.ts +4 -0
- package/dist/auth/config-file.d.ts.map +1 -0
- package/dist/auth/config-file.js +52 -0
- package/dist/auth/config-file.js.map +1 -0
- package/dist/auth/models-providers.d.ts +17 -0
- package/dist/auth/models-providers.d.ts.map +1 -0
- package/dist/auth/models-providers.js +63 -0
- package/dist/auth/models-providers.js.map +1 -0
- package/dist/auth/paths.d.ts +15 -0
- package/dist/auth/paths.d.ts.map +1 -0
- package/dist/auth/paths.js +25 -0
- package/dist/auth/paths.js.map +1 -0
- package/dist/auth/provider-ids.d.ts +4 -0
- package/dist/auth/provider-ids.d.ts.map +1 -0
- package/dist/auth/provider-ids.js +12 -0
- package/dist/auth/provider-ids.js.map +1 -0
- package/dist/auth/resolve.d.ts +19 -0
- package/dist/auth/resolve.d.ts.map +1 -0
- package/dist/auth/resolve.js +37 -0
- package/dist/auth/resolve.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/completion.d.ts +2 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +71 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/flags.d.ts +16 -0
- package/dist/commands/flags.d.ts.map +1 -0
- package/dist/commands/flags.js +23 -0
- package/dist/commands/flags.js.map +1 -0
- package/dist/commands/guard.d.ts +10 -0
- package/dist/commands/guard.d.ts.map +1 -0
- package/dist/commands/guard.js +41 -0
- package/dist/commands/guard.js.map +1 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +19 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/login.d.ts +18 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +80 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +14 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +41 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/prompt.d.ts +9 -0
- package/dist/commands/prompt.d.ts.map +1 -0
- package/dist/commands/prompt.js +21 -0
- package/dist/commands/prompt.js.map +1 -0
- package/dist/commands/registry.d.ts +16 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +35 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.d.ts.map +1 -0
- package/dist/commands/ssh.js +90 -0
- package/dist/commands/ssh.js.map +1 -0
- package/dist/commands/table.d.ts +3 -0
- package/dist/commands/table.d.ts.map +1 -0
- package/dist/commands/table.js +10 -0
- package/dist/commands/table.js.map +1 -0
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +57 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/version.d.ts +2 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +6 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/commands/workspace.d.ts +15 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +206 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +30 -0
- package/dist/run.js.map +1 -0
- package/dist/ssh/bridge.d.ts +25 -0
- package/dist/ssh/bridge.d.ts.map +1 -0
- package/dist/ssh/bridge.js +95 -0
- package/dist/ssh/bridge.js.map +1 -0
- package/dist/ssh/config.d.ts +65 -0
- package/dist/ssh/config.d.ts.map +1 -0
- package/dist/ssh/config.js +198 -0
- package/dist/ssh/config.js.map +1 -0
- package/dist/ssh/connect.d.ts +27 -0
- package/dist/ssh/connect.d.ts.map +1 -0
- package/dist/ssh/connect.js +67 -0
- package/dist/ssh/connect.js.map +1 -0
- package/dist/ssh/tunnel.d.ts +29 -0
- package/dist/ssh/tunnel.d.ts.map +1 -0
- package/dist/ssh/tunnel.js +36 -0
- package/dist/ssh/tunnel.js.map +1 -0
- package/dist/test-support.d.ts +30 -0
- package/dist/test-support.d.ts.map +1 -0
- package/dist/test-support.js +67 -0
- package/dist/test-support.js.map +1 -0
- package/dist/updates/check.d.ts +18 -0
- package/dist/updates/check.d.ts.map +1 -0
- package/dist/updates/check.js +90 -0
- package/dist/updates/check.js.map +1 -0
- package/dist/updates/self.d.ts +36 -0
- package/dist/updates/self.d.ts.map +1 -0
- package/dist/updates/self.js +103 -0
- package/dist/updates/self.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/api/http.ts +70 -0
- package/src/api/keys.ts +40 -0
- package/src/api/me.ts +48 -0
- package/src/api/resolver.test.ts +109 -0
- package/src/api/resolver.ts +72 -0
- package/src/api/resources.test.ts +56 -0
- package/src/api/resources.ts +92 -0
- package/src/api/templates.ts +28 -0
- package/src/api/tokens.ts +80 -0
- package/src/api/types.ts +50 -0
- package/src/api/wait.ts +45 -0
- package/src/api/workspaces.ts +234 -0
- package/src/auth/auth-file.test.ts +176 -0
- package/src/auth/auth-file.ts +103 -0
- package/src/auth/browser-login.test.ts +93 -0
- package/src/auth/browser-login.ts +247 -0
- package/src/auth/config-file.test.ts +68 -0
- package/src/auth/config-file.ts +50 -0
- package/src/auth/models-providers.test.ts +63 -0
- package/src/auth/models-providers.ts +76 -0
- package/src/auth/paths.ts +28 -0
- package/src/auth/provider-ids.ts +15 -0
- package/src/auth/resolve.test.ts +72 -0
- package/src/auth/resolve.ts +46 -0
- package/src/cli.ts +16 -0
- package/src/commands/completion.test.ts +31 -0
- package/src/commands/completion.ts +77 -0
- package/src/commands/flags.ts +29 -0
- package/src/commands/guard.ts +44 -0
- package/src/commands/help.ts +20 -0
- package/src/commands/login.test.ts +159 -0
- package/src/commands/login.ts +94 -0
- package/src/commands/logout.test.ts +89 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/prompt.ts +21 -0
- package/src/commands/registry.ts +50 -0
- package/src/commands/ssh.test.ts +101 -0
- package/src/commands/ssh.ts +112 -0
- package/src/commands/table.test.ts +24 -0
- package/src/commands/table.ts +10 -0
- package/src/commands/update.test.ts +91 -0
- package/src/commands/update.ts +78 -0
- package/src/commands/version.ts +6 -0
- package/src/commands/whoami.test.ts +72 -0
- package/src/commands/whoami.ts +36 -0
- package/src/commands/workspace.test.ts +360 -0
- package/src/commands/workspace.ts +250 -0
- package/src/run.test.ts +53 -0
- package/src/run.ts +34 -0
- package/src/ssh/bridge.test.ts +70 -0
- package/src/ssh/bridge.ts +110 -0
- package/src/ssh/config.test.ts +213 -0
- package/src/ssh/config.ts +233 -0
- package/src/ssh/connect.test.ts +123 -0
- package/src/ssh/connect.ts +98 -0
- package/src/ssh/tunnel.test.ts +119 -0
- package/src/ssh/tunnel.ts +64 -0
- package/src/test-support.ts +77 -0
- package/src/updates/check.test.ts +99 -0
- package/src/updates/check.ts +117 -0
- package/src/updates/self.test.ts +117 -0
- package/src/updates/self.ts +131 -0
- package/src/version.ts +6 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { run } from "./run.js"
|
|
2
|
+
import { maybeNotifyUpdate } from "./updates/check.js"
|
|
3
|
+
|
|
4
|
+
// Executable CLI target: `bun build --compile src/cli.ts`, `bun run dev`,
|
|
5
|
+
// and the published dist/cli.js behind bin/kimchictl.mjs. Self-executes on
|
|
6
|
+
// import; never imported as a library (run.ts is the library surface).
|
|
7
|
+
const argv = process.argv.slice(2)
|
|
8
|
+
const code = await run(argv)
|
|
9
|
+
|
|
10
|
+
// Daily background update notice: prints to stderr, never fails the command.
|
|
11
|
+
// Skipped for `ssh` — its stdio belongs to the SSH session / proxy bridge,
|
|
12
|
+
// where any extra bytes would corrupt the stream.
|
|
13
|
+
if (argv[0] !== "ssh") {
|
|
14
|
+
await maybeNotifyUpdate()
|
|
15
|
+
}
|
|
16
|
+
process.exitCode = code
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { captureConsole } from "../test-support.js"
|
|
3
|
+
import { runCompletion } from "./completion.js"
|
|
4
|
+
import { guardCommand } from "./guard.js"
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const guard = guardCommand(runCompletion)
|
|
11
|
+
|
|
12
|
+
describe("kimchictl completion", () => {
|
|
13
|
+
it.each(["bash", "zsh", "fish"])("prints %s completions with the command surface", async (shell) => {
|
|
14
|
+
const { lines } = captureConsole()
|
|
15
|
+
|
|
16
|
+
const code = await guard([shell])
|
|
17
|
+
|
|
18
|
+
expect(code).toBe(0)
|
|
19
|
+
const script = lines.join("\n")
|
|
20
|
+
for (const name of ["login", "workspace", "ssh", "update"]) {
|
|
21
|
+
expect(script).toContain(name)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("rejects unsupported shells with a usage error", async () => {
|
|
26
|
+
const { errors } = captureConsole()
|
|
27
|
+
|
|
28
|
+
expect(await guard(["powershell"])).toBe(2)
|
|
29
|
+
expect(errors[0]).toContain("bash, zsh, fish")
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseFlags, UsageError } from "./flags.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shell completions — deliberately minimal: complete top-level command names
|
|
5
|
+
* plus workspace subverbs; expand on demand rather than building a full spec.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TOP_LEVEL = ["login", "logout", "whoami", "workspace", "ws", "ssh", "update", "completion", "version", "help"]
|
|
9
|
+
const WORKSPACE_VERBS = ["create", "list", "get", "delete", "--output", "--desc", "--force", "--no-wait", "--help"]
|
|
10
|
+
|
|
11
|
+
const BASH = `# kimchictl bash completion — source this file or place it in /etc/bash_completion.d/
|
|
12
|
+
_kimchictl() {
|
|
13
|
+
local cur prev
|
|
14
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
15
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
16
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
17
|
+
COMPREPLY=( $(compgen -W "${TOP_LEVEL.join(" ")}" -- "$cur") )
|
|
18
|
+
return 0
|
|
19
|
+
fi
|
|
20
|
+
case "\${COMP_WORDS[1]}" in
|
|
21
|
+
workspace|ws)
|
|
22
|
+
[ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "${WORKSPACE_VERBS.join(" ")}" -- "$cur") ) ;;
|
|
23
|
+
completion)
|
|
24
|
+
[ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
|
|
25
|
+
esac
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
complete -F _kimchictl kimchictl
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
const ZSH_COMMANDS = TOP_LEVEL.map((c) => `"${c}"`).join(" ")
|
|
32
|
+
|
|
33
|
+
const ZSH = `#compdef kimchictl
|
|
34
|
+
# kimchictl zsh completion — place in a directory on $fpath as _kimchictl
|
|
35
|
+
_kimchictl() {
|
|
36
|
+
local -a commands=${ZSH_COMMANDS}
|
|
37
|
+
if (( CURRENT == 2 )); then
|
|
38
|
+
_arguments "1:command:(\${commands})"
|
|
39
|
+
return
|
|
40
|
+
fi
|
|
41
|
+
case "$words[2]" in
|
|
42
|
+
workspace|ws)
|
|
43
|
+
(( CURRENT == 3 )) && _arguments "2:verb:(${WORKSPACE_VERBS.join(" ")})" ;;
|
|
44
|
+
completion)
|
|
45
|
+
(( CURRENT == 3 )) && _arguments "2:shell:(bash zsh fish)" ;;
|
|
46
|
+
esac
|
|
47
|
+
}
|
|
48
|
+
_kimchictl "$@"
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
const FISH = `# kimchictl fish completion — place in ~/.config/fish/completions/kimchictl.fish
|
|
52
|
+
complete -c kimchictl -f
|
|
53
|
+
complete -c kimchictl -n '__fish_use_subcommand' -a '${TOP_LEVEL.join(" ")}'
|
|
54
|
+
complete -c kimchictl -n '__fish_seen_subcommand_from workspace ws' -a '${WORKSPACE_VERBS.join(" ")}'
|
|
55
|
+
complete -c kimchictl -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
export async function runCompletion(args: string[]): Promise<number> {
|
|
59
|
+
const { positionals } = parseFlags("completion", args, {})
|
|
60
|
+
const shell = positionals[0]
|
|
61
|
+
if (positionals.length > 1) {
|
|
62
|
+
throw new UsageError("kimchictl completion: expected one shell (bash|zsh|fish)")
|
|
63
|
+
}
|
|
64
|
+
switch (shell) {
|
|
65
|
+
case "bash":
|
|
66
|
+
console.log(BASH)
|
|
67
|
+
return 0
|
|
68
|
+
case "zsh":
|
|
69
|
+
console.log(ZSH)
|
|
70
|
+
return 0
|
|
71
|
+
case "fish":
|
|
72
|
+
console.log(FISH)
|
|
73
|
+
return 0
|
|
74
|
+
default:
|
|
75
|
+
throw new UsageError(`kimchictl completion: unsupported shell '${shell ?? ""}' — one of: bash, zsh, fish`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ParseArgsOptionsConfig, parseArgs } from "node:util"
|
|
2
|
+
|
|
3
|
+
/** Bad CLI input — surfaced with exit code 2, distinct from runtime failures (1). */
|
|
4
|
+
export class UsageError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = "UsageError"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type FlagValues = Record<string, string | boolean | undefined>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Strict flag parsing with a uniform usage-error shape across commands.
|
|
15
|
+
* Values keep their raw type (declared options are TypeScript-blind here —
|
|
16
|
+
* narrow at the call site, relying on the declared option `type`).
|
|
17
|
+
*/
|
|
18
|
+
export function parseFlags(
|
|
19
|
+
command: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
options: ParseArgsOptionsConfig,
|
|
22
|
+
): { values: FlagValues; positionals: string[] } {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = parseArgs({ args, options, strict: true, allowPositionals: true })
|
|
25
|
+
return { values: parsed.values as FlagValues, positionals: parsed.positionals }
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new UsageError(`kimchictl ${command}: ${err instanceof Error ? err.message : String(err)}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AmbiguousWorkspaceError, WorkspaceNotFoundError } from "../api/resolver.js"
|
|
2
|
+
import { TemplatesUnavailableError } from "../api/templates.js"
|
|
3
|
+
import { RemoteAuthError, RemoteNetworkError } from "../api/types.js"
|
|
4
|
+
import { ModelsFetchError } from "../auth/models-providers.js"
|
|
5
|
+
import { NotLoggedInError } from "../auth/resolve.js"
|
|
6
|
+
import { BridgeError } from "../ssh/bridge.js"
|
|
7
|
+
import { UsageError } from "./flags.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert known domain errors into a clean message + exit code instead of a
|
|
11
|
+
* stack trace. Unknown errors rethrow so real bugs stay loud.
|
|
12
|
+
* A wrapped handler still fits CommandDefinition["run"] (deps optional).
|
|
13
|
+
*
|
|
14
|
+
* ✗ <message> → 1 (auth/network/usage-state errors)
|
|
15
|
+
* kimchictl cmd: <usage> → 2 (flag parsing)
|
|
16
|
+
*/
|
|
17
|
+
export function guardCommand<TDeps>(
|
|
18
|
+
handler: (args: string[], deps?: TDeps) => Promise<number>,
|
|
19
|
+
): (args: string[], deps?: TDeps) => Promise<number> {
|
|
20
|
+
return async (args, deps) => {
|
|
21
|
+
try {
|
|
22
|
+
return await handler(args, deps)
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err instanceof UsageError) {
|
|
25
|
+
console.error(err.message)
|
|
26
|
+
return 2
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
err instanceof NotLoggedInError ||
|
|
30
|
+
err instanceof ModelsFetchError ||
|
|
31
|
+
err instanceof TemplatesUnavailableError ||
|
|
32
|
+
err instanceof WorkspaceNotFoundError ||
|
|
33
|
+
err instanceof AmbiguousWorkspaceError ||
|
|
34
|
+
err instanceof BridgeError ||
|
|
35
|
+
err instanceof RemoteAuthError ||
|
|
36
|
+
err instanceof RemoteNetworkError
|
|
37
|
+
) {
|
|
38
|
+
console.error(`✗ ${err.message}`)
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CommandDefinition } from "./registry.js"
|
|
2
|
+
|
|
3
|
+
/** Print top-level usage. Hidden commands are deliberately omitted. */
|
|
4
|
+
export function printHelp(commands: readonly CommandDefinition[], log: (line: string) => void = console.log): void {
|
|
5
|
+
log(`kimchictl — manage kimchi remote workspaces
|
|
6
|
+
`)
|
|
7
|
+
log(`Usage:
|
|
8
|
+
kimchictl <command> [options]
|
|
9
|
+
`)
|
|
10
|
+
log(`Commands:`)
|
|
11
|
+
const visible = commands.filter((c) => !c.hidden)
|
|
12
|
+
const width = Math.max(...visible.map((c) => c.name.length))
|
|
13
|
+
for (const command of visible) {
|
|
14
|
+
log(` ${command.name.padEnd(width)} ${command.summary}`)
|
|
15
|
+
}
|
|
16
|
+
log(`
|
|
17
|
+
Global flags:
|
|
18
|
+
-h, --help Show this help
|
|
19
|
+
-v, --version Print the version`)
|
|
20
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import {
|
|
5
|
+
captureConsole,
|
|
6
|
+
cleanupTempDirs,
|
|
7
|
+
jsonResponse,
|
|
8
|
+
makeTempDir,
|
|
9
|
+
sharedConfigPath,
|
|
10
|
+
stubAgentDirEnv,
|
|
11
|
+
stubFetch,
|
|
12
|
+
} from "../test-support.js"
|
|
13
|
+
import { runLogin } from "./login.js"
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanupTempDirs()
|
|
17
|
+
vi.unstubAllEnvs()
|
|
18
|
+
vi.restoreAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const aiEnablerOnlyFetch = stubFetch(async () => jsonResponse({ models: [{ slug: "m1", provider: "ai-enabler" }] }))
|
|
22
|
+
|
|
23
|
+
describe("kimchictl login --api-key", () => {
|
|
24
|
+
it("stores the key in auth.json and config.json (shared with the harness)", async () => {
|
|
25
|
+
const dir = makeTempDir()
|
|
26
|
+
const { home } = stubAgentDirEnv(dir)
|
|
27
|
+
const { lines, errors } = captureConsole()
|
|
28
|
+
|
|
29
|
+
const fetch = stubFetch(async () =>
|
|
30
|
+
jsonResponse({
|
|
31
|
+
models: [
|
|
32
|
+
{ slug: "m1", provider: "ai-enabler" },
|
|
33
|
+
{ slug: "m2", provider: "anthropic" },
|
|
34
|
+
],
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const code = await runLogin(["--api-key", "test-key"], { fetch })
|
|
39
|
+
|
|
40
|
+
expect(code).toBe(0)
|
|
41
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
|
|
42
|
+
"kimchi-dev": { type: "api_key", key: "test-key" },
|
|
43
|
+
"kimchi-dev/anthropic": { type: "api_key", key: "test-key" },
|
|
44
|
+
})
|
|
45
|
+
expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ apiKey: "test-key" })
|
|
46
|
+
expect(lines.some((l) => l.includes("Logged in to Kimchi"))).toBe(true)
|
|
47
|
+
// First login finishes the SSH integration automatically (with notice).
|
|
48
|
+
expect(errors.some((l) => l.includes("SSH integration configured"))).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("unions provider ids from an existing harness models.json", async () => {
|
|
52
|
+
const dir = makeTempDir()
|
|
53
|
+
stubAgentDirEnv(dir)
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(dir, "models.json"),
|
|
56
|
+
JSON.stringify({ providers: { "kimchi-dev": {}, "kimchi-dev/openai": {}, ollama: {} } }),
|
|
57
|
+
)
|
|
58
|
+
captureConsole()
|
|
59
|
+
|
|
60
|
+
const code = await runLogin(["--api-key", "test-key"], { fetch: aiEnablerOnlyFetch })
|
|
61
|
+
|
|
62
|
+
expect(code).toBe(0)
|
|
63
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
|
|
64
|
+
"kimchi-dev": { type: "api_key", key: "test-key" },
|
|
65
|
+
"kimchi-dev/openai": { type: "api_key", key: "test-key" },
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("rejects an invalid key (401) and writes nothing", async () => {
|
|
70
|
+
const dir = makeTempDir()
|
|
71
|
+
stubAgentDirEnv(dir)
|
|
72
|
+
const { errors } = captureConsole()
|
|
73
|
+
const fetch = stubFetch(async () => new Response("nope", { status: 401, statusText: "Unauthorized" }))
|
|
74
|
+
|
|
75
|
+
const code = await runLogin(["--api-key", "bad-key"], { fetch })
|
|
76
|
+
|
|
77
|
+
expect(code).toBe(1)
|
|
78
|
+
expect(errors[0]).toMatch(/Invalid API key/)
|
|
79
|
+
expect(() => readFileSync(join(dir, "auth.json"))).toThrow()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("fails hard when verification is unreachable for --api-key", async () => {
|
|
83
|
+
const dir = makeTempDir()
|
|
84
|
+
stubAgentDirEnv(dir)
|
|
85
|
+
const { errors } = captureConsole()
|
|
86
|
+
const fetch = stubFetch(async () => new Response("down", { status: 503, statusText: "Unavailable" }))
|
|
87
|
+
|
|
88
|
+
const code = await runLogin(["--api-key", "key"], { fetch })
|
|
89
|
+
|
|
90
|
+
expect(code).toBe(1)
|
|
91
|
+
expect(errors[0]).toMatch(/Could not verify the API key/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("treats an empty --api-key as a usage error", async () => {
|
|
95
|
+
stubAgentDirEnv(makeTempDir())
|
|
96
|
+
const { errors } = captureConsole()
|
|
97
|
+
|
|
98
|
+
expect(await runLogin(["--api-key", " "])).toBe(2)
|
|
99
|
+
expect(errors[0]).toMatch(/--api-key must not be empty/)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("kimchictl login (browser)", () => {
|
|
104
|
+
it("completes the OAuth round trip and saves the token", async () => {
|
|
105
|
+
const dir = makeTempDir()
|
|
106
|
+
stubAgentDirEnv(dir)
|
|
107
|
+
captureConsole()
|
|
108
|
+
|
|
109
|
+
const code = await runLogin([], {
|
|
110
|
+
interactive: true,
|
|
111
|
+
fetch: aiEnablerOnlyFetch,
|
|
112
|
+
open: async (url) => {
|
|
113
|
+
const parsed = new URL(url)
|
|
114
|
+
const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
|
|
115
|
+
callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
|
|
116
|
+
callback.searchParams.set("token", "browser-token")
|
|
117
|
+
await fetch(callback)
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(code).toBe(0)
|
|
122
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
|
|
123
|
+
"kimchi-dev": { type: "api_key", key: "browser-token" },
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("keeps the credential with a warning when the metadata fetch is unreachable", async () => {
|
|
128
|
+
const dir = makeTempDir()
|
|
129
|
+
stubAgentDirEnv(dir)
|
|
130
|
+
const { errors } = captureConsole()
|
|
131
|
+
const downFetch = stubFetch(async () => new Response("down", { status: 503, statusText: "Unavailable" }))
|
|
132
|
+
|
|
133
|
+
const code = await runLogin([], {
|
|
134
|
+
interactive: true,
|
|
135
|
+
fetch: downFetch,
|
|
136
|
+
open: async (url) => {
|
|
137
|
+
const parsed = new URL(url)
|
|
138
|
+
const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
|
|
139
|
+
callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
|
|
140
|
+
callback.searchParams.set("token", "browser-token")
|
|
141
|
+
await fetch(callback)
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(code).toBe(0)
|
|
146
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
|
|
147
|
+
"kimchi-dev": { type: "api_key", key: "browser-token" },
|
|
148
|
+
})
|
|
149
|
+
expect(errors.some((l) => l.includes("Could not refresh the Kimchi model list"))).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("requires a TTY without --api-key", async () => {
|
|
153
|
+
stubAgentDirEnv(makeTempDir())
|
|
154
|
+
const { errors } = captureConsole()
|
|
155
|
+
const code = await runLogin([], { interactive: false })
|
|
156
|
+
expect(code).toBe(1)
|
|
157
|
+
expect(errors[0]).toMatch(/requires a TTY/)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { kimchiProviderIdsFromModelsJson, syncSharedAuth } from "../auth/auth-file.js"
|
|
2
|
+
import { authenticateViaBrowser } from "../auth/browser-login.js"
|
|
3
|
+
import { writeApiKeyToConfig } from "../auth/config-file.js"
|
|
4
|
+
import { fetchKimchiProviderIds, ModelsFetchError } from "../auth/models-providers.js"
|
|
5
|
+
import { resolveAuthJsonPath, resolveConfigJsonPath, resolveModelsJsonPath } from "../auth/paths.js"
|
|
6
|
+
import { ensureSshIntegration } from "../ssh/connect.js"
|
|
7
|
+
import { parseFlags } from "./flags.js"
|
|
8
|
+
import { isInteractive } from "./prompt.js"
|
|
9
|
+
|
|
10
|
+
export interface LoginDeps {
|
|
11
|
+
/** Browser opener override (tests). */
|
|
12
|
+
open?: (url: string) => Promise<unknown>
|
|
13
|
+
fetch?: typeof globalThis.fetch
|
|
14
|
+
env?: NodeJS.ProcessEnv
|
|
15
|
+
/** Override TTY interactivity detection (tests). */
|
|
16
|
+
interactive?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `kimchictl login` — obtain an API key (browser OAuth or --api-key) and
|
|
21
|
+
* store it in the harness's shared credential files:
|
|
22
|
+
* - auth.json: one `{ type: "api_key", key }` per Kimchi provider id
|
|
23
|
+
* (ids from an existing models.json plus a fresh metadata fetch)
|
|
24
|
+
* - config.json: the `apiKey` field
|
|
25
|
+
* …so the harness picks the login up without its own `kimchi login`.
|
|
26
|
+
*/
|
|
27
|
+
export async function runLogin(args: string[], deps: LoginDeps = {}): Promise<number> {
|
|
28
|
+
const { values } = parseFlags("login", args, {
|
|
29
|
+
"api-key": { type: "string" },
|
|
30
|
+
})
|
|
31
|
+
const env = deps.env ?? process.env
|
|
32
|
+
|
|
33
|
+
const authPath = resolveAuthJsonPath(env)
|
|
34
|
+
const configPath = resolveConfigJsonPath(env)
|
|
35
|
+
const modelsPath = resolveModelsJsonPath(env)
|
|
36
|
+
|
|
37
|
+
let apiKey: string
|
|
38
|
+
const apiKeyFlag = values["api-key"]
|
|
39
|
+
const apiKeyProvided = apiKeyFlag !== undefined
|
|
40
|
+
if (apiKeyProvided) {
|
|
41
|
+
// parseArgs guarantees a string here (--api-key is declared type: "string").
|
|
42
|
+
apiKey = typeof apiKeyFlag === "string" ? apiKeyFlag.trim() : ""
|
|
43
|
+
if (!apiKey) {
|
|
44
|
+
console.error("kimchictl login: --api-key must not be empty")
|
|
45
|
+
return 2
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const interactive = deps.interactive ?? isInteractive()
|
|
49
|
+
if (!interactive) {
|
|
50
|
+
console.error("kimchictl login: interactive login requires a TTY; use --api-key for non-interactive mode")
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
const { token } = await authenticateViaBrowser({
|
|
54
|
+
open: deps.open,
|
|
55
|
+
onBrowserUrl: (url) => console.log(`If the browser does not open, visit:\n${url}`),
|
|
56
|
+
})
|
|
57
|
+
apiKey = token
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Validate the key against the models metadata API and derive the full
|
|
61
|
+
// Kimchi provider-id set (kimchi-dev + kimchi-dev/<upstream>) in one call.
|
|
62
|
+
let fetchedIds: string[] = []
|
|
63
|
+
try {
|
|
64
|
+
fetchedIds = await fetchKimchiProviderIds(apiKey, { fetch: deps.fetch })
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ModelsFetchError && err.status === 401) {
|
|
67
|
+
console.error("✗ Invalid API key. Please check the key and try again.")
|
|
68
|
+
return 1
|
|
69
|
+
}
|
|
70
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
71
|
+
if (apiKeyProvided) {
|
|
72
|
+
// Mirror the harness's strict api-key login: an unverifiable key is not saved.
|
|
73
|
+
console.error(`✗ Could not verify the API key (${detail}). Try again later.`)
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
// Browser login: the token came from the web app seconds ago, so keep it and
|
|
77
|
+
// fall back to the ids we know. (Harness: non-strict refresh with cache fallback.)
|
|
78
|
+
console.error(
|
|
79
|
+
`⚠ Could not refresh the Kimchi model list (${detail}); saving the base credential. ` +
|
|
80
|
+
`Run \`kimchictl login\` again when the endpoint is reachable to pick up sub-provider credentials.`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const providerIds = [...new Set([...kimchiProviderIdsFromModelsJson(modelsPath), ...fetchedIds])]
|
|
85
|
+
await syncSharedAuth(authPath, apiKey, providerIds)
|
|
86
|
+
writeApiKeyToConfig(configPath, apiKey)
|
|
87
|
+
|
|
88
|
+
console.log(`✓ Logged in to Kimchi — credentials saved to ${authPath}`)
|
|
89
|
+
console.log(" Shared with the kimchi coding harness; no second login needed there.")
|
|
90
|
+
// First login is the natural moment to finish the native SSH integration
|
|
91
|
+
// (automatic-with-notice; KIMCHICTL_NO_SSH_SETUP opts out).
|
|
92
|
+
await ensureSshIntegration({ env })
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import {
|
|
5
|
+
captureConsole,
|
|
6
|
+
cleanupTempDirs,
|
|
7
|
+
makeTempDir,
|
|
8
|
+
sharedConfigPath,
|
|
9
|
+
stubAgentDirEnv,
|
|
10
|
+
writeSharedConfigJson,
|
|
11
|
+
} from "../test-support.js"
|
|
12
|
+
import { runLogout } from "./logout.js"
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanupTempDirs()
|
|
16
|
+
vi.unstubAllEnvs()
|
|
17
|
+
vi.restoreAllMocks()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function seedCredentials(dir: string, home: string): void {
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(dir, "auth.json"),
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
"kimchi-dev": { type: "api_key", key: "key" },
|
|
25
|
+
"kimchi-dev/openai": { type: "api_key", key: "key" },
|
|
26
|
+
anthropic: { type: "api_key", key: "keep" },
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
writeSharedConfigJson(home, { apiKey: "key", other: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("kimchictl logout", () => {
|
|
33
|
+
it("removes shared credentials with --force (both files, other entries preserved)", async () => {
|
|
34
|
+
const dir = makeTempDir()
|
|
35
|
+
const { home } = stubAgentDirEnv(dir)
|
|
36
|
+
seedCredentials(dir, home)
|
|
37
|
+
const { lines } = captureConsole()
|
|
38
|
+
|
|
39
|
+
expect(await runLogout(["--force"])).toBe(0)
|
|
40
|
+
|
|
41
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))).toEqual({
|
|
42
|
+
anthropic: { type: "api_key", key: "keep" },
|
|
43
|
+
})
|
|
44
|
+
expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ other: true })
|
|
45
|
+
expect(lines.some((l) => l.includes("Logged out"))).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("confirms via the prompt by default (interactive path)", async () => {
|
|
49
|
+
const dir = makeTempDir()
|
|
50
|
+
const { home } = stubAgentDirEnv(dir)
|
|
51
|
+
seedCredentials(dir, home)
|
|
52
|
+
const { errors } = captureConsole()
|
|
53
|
+
|
|
54
|
+
expect(await runLogout([], { confirm: async () => true })).toBe(0)
|
|
55
|
+
expect(errors[0]).toMatch(/also signs out the kimchi coding harness/)
|
|
56
|
+
expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8"))).toEqual({ other: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("leaves everything untouched when the confirmation is declined", async () => {
|
|
60
|
+
const dir = makeTempDir()
|
|
61
|
+
const { home } = stubAgentDirEnv(dir)
|
|
62
|
+
seedCredentials(dir, home)
|
|
63
|
+
const { lines } = captureConsole()
|
|
64
|
+
|
|
65
|
+
expect(await runLogout([], { confirm: async () => false })).toBe(0)
|
|
66
|
+
|
|
67
|
+
expect(lines.some((l) => l.includes("Aborted"))).toBe(true)
|
|
68
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))["kimchi-dev"]).toBeDefined()
|
|
69
|
+
expect(JSON.parse(readFileSync(sharedConfigPath(home), "utf-8")).apiKey).toBe("key")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("requires --force when non-interactive", async () => {
|
|
73
|
+
const dir = makeTempDir()
|
|
74
|
+
const { home } = stubAgentDirEnv(dir)
|
|
75
|
+
seedCredentials(dir, home)
|
|
76
|
+
const { errors } = captureConsole()
|
|
77
|
+
|
|
78
|
+
expect(await runLogout([], { interactive: false })).toBe(1)
|
|
79
|
+
expect(errors.some((l) => l.includes("--force"))).toBe(true)
|
|
80
|
+
expect(JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"))["kimchi-dev"]).toBeDefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("reports when already logged out", async () => {
|
|
84
|
+
stubAgentDirEnv(makeTempDir())
|
|
85
|
+
const { lines } = captureConsole()
|
|
86
|
+
expect(await runLogout([])).toBe(0)
|
|
87
|
+
expect(lines).toEqual(["Not logged in."])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { clearSharedAuth, readSavedApiKey } from "../auth/auth-file.js"
|
|
2
|
+
import { readApiKeyFromConfig, writeApiKeyToConfig } from "../auth/config-file.js"
|
|
3
|
+
import { resolveAuthJsonPath, resolveConfigJsonPath } from "../auth/paths.js"
|
|
4
|
+
import { parseFlags } from "./flags.js"
|
|
5
|
+
import { confirm, isInteractive } from "./prompt.js"
|
|
6
|
+
|
|
7
|
+
export interface LogoutDeps {
|
|
8
|
+
/** Confirmation override (tests). */
|
|
9
|
+
confirm?: () => Promise<boolean>
|
|
10
|
+
env?: NodeJS.ProcessEnv
|
|
11
|
+
/** Override TTY interactivity detection (tests). */
|
|
12
|
+
interactive?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `kimchictl logout` — remove the shared Kimchi credential from BOTH
|
|
17
|
+
* auth.json and config.json. This signs out the kimchi coding harness too
|
|
18
|
+
* (the credential is shared by design), which the user must acknowledge.
|
|
19
|
+
*/
|
|
20
|
+
export async function runLogout(args: string[], deps: LogoutDeps = {}): Promise<number> {
|
|
21
|
+
const { values } = parseFlags("logout", args, {
|
|
22
|
+
force: { type: "boolean", short: "f" },
|
|
23
|
+
})
|
|
24
|
+
const env = deps.env ?? process.env
|
|
25
|
+
|
|
26
|
+
const authPath = resolveAuthJsonPath(env)
|
|
27
|
+
const configPath = resolveConfigJsonPath(env)
|
|
28
|
+
|
|
29
|
+
const hasCredential = readSavedApiKey(authPath) !== undefined || readApiKeyFromConfig(configPath) !== undefined
|
|
30
|
+
if (!hasCredential) {
|
|
31
|
+
console.log("Not logged in.")
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!values.force) {
|
|
36
|
+
console.error("This also signs out the kimchi coding harness (the login is shared).")
|
|
37
|
+
const interactive = deps.interactive ?? isInteractive()
|
|
38
|
+
const ok = await (deps.confirm ? deps.confirm() : interactive ? confirm("Log out everywhere? [y/N] ") : false)
|
|
39
|
+
if (!ok) {
|
|
40
|
+
if (!deps.confirm && !interactive) {
|
|
41
|
+
console.error("kimchictl logout: not interactive — re-run with --force to confirm")
|
|
42
|
+
return 1
|
|
43
|
+
}
|
|
44
|
+
console.log("Aborted.")
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await clearSharedAuth(authPath)
|
|
50
|
+
writeApiKeyToConfig(configPath, undefined)
|
|
51
|
+
console.log("✓ Logged out of Kimchi (kimchictl and the kimchi harness).")
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises"
|
|
2
|
+
|
|
3
|
+
/** True when an interactive prompt can be shown (both streams are terminals). */
|
|
4
|
+
export function isInteractive(): boolean {
|
|
5
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ask a y/N question on the TTY. Anything but exactly "y" or "yes"
|
|
10
|
+
* (case-insensitive) counts as no. Only call when isInteractive() — callers
|
|
11
|
+
* must supply the non-interactive fallback (`--force` hint or a default).
|
|
12
|
+
*/
|
|
13
|
+
export async function confirm(question: string): Promise<boolean> {
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
15
|
+
try {
|
|
16
|
+
const answer = await rl.question(question)
|
|
17
|
+
return /^(y|yes)$/i.test(answer.trim())
|
|
18
|
+
} finally {
|
|
19
|
+
rl.close()
|
|
20
|
+
}
|
|
21
|
+
}
|