@almyty/credentials 1.5.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 +207 -0
- package/dist/exit-codes.d.ts +49 -0
- package/dist/exit-codes.js +90 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +589 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +22 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @almyty/credentials
|
|
2
|
+
|
|
3
|
+
Add a key, a token or a sign-in to almyty once, from your terminal; agents,
|
|
4
|
+
APIs, tools, models, deployments, memory backends, MCP servers, channels and
|
|
5
|
+
registries then use the credential. Model providers, deployment clouds,
|
|
6
|
+
buckets, chat platforms: all of them are services in the same catalog.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx @almyty/auth login
|
|
10
|
+
npx @almyty/credentials services --kind inference
|
|
11
|
+
npx @almyty/credentials add openrouter --open
|
|
12
|
+
npx @almyty/credentials list
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What a credential is
|
|
16
|
+
|
|
17
|
+
A credential is a row with its service, an account label and a health
|
|
18
|
+
status. The secret is stored encrypted in almyty's credential store and
|
|
19
|
+
is **never returned**, not even masked. Every module that needs it holds a
|
|
20
|
+
reference, resolved on each use, so a rotation is live on the next call and
|
|
21
|
+
every resolve is audited.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
b91c… openrouter org ava@northwind valid
|
|
25
|
+
3d70… huggingface user frane failed
|
|
26
|
+
401 invalid credentials
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
Every read command takes `--json` and writes undecorated JSON to stdout.
|
|
32
|
+
|
|
33
|
+
### Read
|
|
34
|
+
|
|
35
|
+
| Command | What it does |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `services [--kind k]` | The catalog: what a credential can be added for, and how. `--kind` is one of `inference`, `deployment`, `memory`, `mcp`, `tool_source`, `channel`, `cloud`, `registry` |
|
|
38
|
+
| `list` | Every credential, with its health |
|
|
39
|
+
| `get <id>` | One credential: service, account label, health and when it was checked, scopes, owner — and what to do when the health is not `valid` |
|
|
40
|
+
| `grants <id>` | Who may use this credential |
|
|
41
|
+
|
|
42
|
+
### Add
|
|
43
|
+
|
|
44
|
+
| Command | What it does |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `add <service> [--method m] [--owner org\|user\|private] [--name n] [--headless] [--open]` | Add a credential |
|
|
47
|
+
| `complete <key> --state s --code c` | Finish a headless sign-in by pasting the code |
|
|
48
|
+
| `validate <id>` | Re-check against the provider; refreshes health and the account label |
|
|
49
|
+
| `rotate <id> [--headless] [--open]` | Replace the secret in place, so everything using the credential keeps working |
|
|
50
|
+
| `delete <id>` | Revoke at the provider where the service declares a revoke endpoint, then delete |
|
|
51
|
+
|
|
52
|
+
Each service offers one or more methods, best first, and `add` picks the best
|
|
53
|
+
unless `--method` names another. `services` lists them.
|
|
54
|
+
|
|
55
|
+
**Sign-in** (OpenRouter and Slack today) is the best path: nothing to paste.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx @almyty/credentials add openrouter --open
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
On a machine with no browser, ask for the headless flow and the provider shows
|
|
62
|
+
a code:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx @almyty/credentials add openrouter --headless
|
|
66
|
+
# -> Open this URL to continue: https://…
|
|
67
|
+
# The provider will show you a code. Paste it with:
|
|
68
|
+
# almyty credentials complete openrouter --state st-… --code <code>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The instructions you get are the ones that will work: a browser flow finishes
|
|
72
|
+
on the callback and says so, rather than pointing you at a `complete` command
|
|
73
|
+
whose state the redirect has already consumed.
|
|
74
|
+
|
|
75
|
+
**A pasted key** prompts for each field, secrets not echoed, with a link to
|
|
76
|
+
the page where the key is created:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npx @almyty/credentials add huggingface --owner user
|
|
80
|
+
# Get a key at: https://huggingface.co/settings/tokens
|
|
81
|
+
# Access token: ······
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The key is checked against the provider before it is saved. If the provider
|
|
85
|
+
says no, the credential is kept with a `failed` health and the provider's
|
|
86
|
+
answer, so you fix the key at the provider and `validate` or `rotate` rather
|
|
87
|
+
than starting over.
|
|
88
|
+
|
|
89
|
+
**Rotate** replaces the secret without touching anything that uses the
|
|
90
|
+
credential. For a pasted key it prompts for the new value; for a sign-in
|
|
91
|
+
service it returns a fresh authorize URL and completing it swaps the key on
|
|
92
|
+
the same credential.
|
|
93
|
+
|
|
94
|
+
### Share
|
|
95
|
+
|
|
96
|
+
An organization credential is usable by the organization; a grant widens or
|
|
97
|
+
narrows that to a principal.
|
|
98
|
+
|
|
99
|
+
| Command | What it does |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `grant <id> --principal user\|team\|role\|agent\|workspace --to <principalId> [--permission use\|manage] [--expires <iso8601>]` | Let a principal use (or manage) a credential |
|
|
102
|
+
| `revoke <id> <grantId>` | Withdraw one grant |
|
|
103
|
+
|
|
104
|
+
## Org or personal
|
|
105
|
+
|
|
106
|
+
`--owner org` (the default) makes the credential the organization's: this is
|
|
107
|
+
what agents and deployments use, and it needs `connections:manage` (admin or
|
|
108
|
+
owner). `--owner user` makes it yours, and you can grant it to others.
|
|
109
|
+
`--owner private` makes it yours alone: nobody else sees or uses it, org admins
|
|
110
|
+
included, and it cannot be granted. Free and personal organizations allow
|
|
111
|
+
personal and private credentials by default; paid organizations start with them off until
|
|
112
|
+
an admin turns them on.
|
|
113
|
+
|
|
114
|
+
## Secrets never travel on argv
|
|
115
|
+
|
|
116
|
+
`ps` shows every process's arguments to every user on the machine, shell
|
|
117
|
+
history keeps them, and most CI runners echo them. So this tool does not take
|
|
118
|
+
a secret as a flag value.
|
|
119
|
+
|
|
120
|
+
In order of preference:
|
|
121
|
+
|
|
122
|
+
1. **A sign-in flow** where the service has one. Nothing is typed at all.
|
|
123
|
+
2. **The prompt**, which is the default: secret fields are read without echo.
|
|
124
|
+
3. **`--input-file <path>`** — the form fields as a JSON object in a file.
|
|
125
|
+
4. **`--input-stdin`** — the same object on stdin.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
npx @almyty/credentials add channel-telegram --input-file bot.json
|
|
129
|
+
pass show telegram/bot | jq -Rn '{bot_token: input}' \
|
|
130
|
+
| npx @almyty/credentials add channel-telegram --input-stdin
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`--input '<json>'` still works for the fields a service does **not** mark
|
|
134
|
+
secret (a region, a bucket, a phone number) and is refused the moment it
|
|
135
|
+
carries one that is, naming the field and the safe alternatives. The refusal
|
|
136
|
+
never repeats the value.
|
|
137
|
+
|
|
138
|
+
Unattended runs are handled rather than hung: without a terminal to prompt on,
|
|
139
|
+
`add` and `rotate` say so and name `--input-file` and `--input-stdin`
|
|
140
|
+
instead of reading end-of-file and submitting an empty form. `--input-stdin`
|
|
141
|
+
with a terminal on stdin is refused for the same reason.
|
|
142
|
+
|
|
143
|
+
## Health, and what to do about it
|
|
144
|
+
|
|
145
|
+
`validate` exits non-zero unless the health comes back `valid`, so it works in
|
|
146
|
+
a check. Each status has a different next step, and `get` and `validate` print
|
|
147
|
+
it:
|
|
148
|
+
|
|
149
|
+
| Health | What it means |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `valid` | The provider accepted the credential |
|
|
152
|
+
| `failed` | The provider rejected it. Fix it at the provider, then `rotate` |
|
|
153
|
+
| `expired` | It has expired. `rotate` |
|
|
154
|
+
| `revoked` | It was revoked at the provider. `rotate` |
|
|
155
|
+
| `quota` | The credential is fine; the account is out of quota or rate limited. Rotating would not help |
|
|
156
|
+
| `unknown` | Never checked. Run `validate` |
|
|
157
|
+
|
|
158
|
+
## Chat channels
|
|
159
|
+
|
|
160
|
+
Every chat channel is a service like any other, so a Slack, Discord or
|
|
161
|
+
Telegram token is added here instead of pasted into a gateway form:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
npx @almyty/credentials services --kind channel
|
|
165
|
+
npx @almyty/credentials add channel-slack --open
|
|
166
|
+
npx @almyty/credentials add channel-telegram
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The service key is `channel-<gateway type>` with underscores dasherized
|
|
170
|
+
(`channel-whatsapp-cloud`), and the form fields are spelled the way the
|
|
171
|
+
channel adapter reads them, so a channel can use a credential you already
|
|
172
|
+
added. `docs/connections.md` has the table of what each channel needs, where
|
|
173
|
+
to get it, and how it is validated.
|
|
174
|
+
|
|
175
|
+
## Environment
|
|
176
|
+
|
|
177
|
+
| Variable | Meaning |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `ALMYTY_TOKEN` | Token override; skips `~/.almyty/credentials.json` |
|
|
180
|
+
| `ALMYTY_URL` | API URL override |
|
|
181
|
+
| `NO_COLOR` | Honoured: this tool never colours its output |
|
|
182
|
+
|
|
183
|
+
## Exit codes
|
|
184
|
+
|
|
185
|
+
| Code | Meaning |
|
|
186
|
+
|---|---|
|
|
187
|
+
| 0 | success |
|
|
188
|
+
| 1 | unexpected error |
|
|
189
|
+
| 2 | usage error (bad flags, missing argument, unknown command, a secret on argv) |
|
|
190
|
+
| 3 | not authenticated — run `npx @almyty/auth login` |
|
|
191
|
+
| 4 | not found |
|
|
192
|
+
| 5 | the operation ran and failed (a `validate` whose health is not `valid`) |
|
|
193
|
+
|
|
194
|
+
The same table in every `@almyty/*` CLI.
|
|
195
|
+
|
|
196
|
+
## About almyty
|
|
197
|
+
|
|
198
|
+
almyty is the platform for AI agents, agnostic by design: any LLM, any API
|
|
199
|
+
turned into tools, served over MCP, A2A, UTCP and Agent Skills.
|
|
200
|
+
|
|
201
|
+
- Website: https://almyty.com
|
|
202
|
+
- Design notes: `docs/connections.md` in the almyty repository
|
|
203
|
+
- Source: https://github.com/almyty-inc/almyty
|
|
204
|
+
|
|
205
|
+
Run `npx @almyty/credentials --help` for the full surface.
|
|
206
|
+
|
|
207
|
+
Apache-2.0 © Almyty Inc.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes shared by every almyty CLI.
|
|
3
|
+
*
|
|
4
|
+
* Scripts need to tell "you are not logged in" apart from "that card does
|
|
5
|
+
* not exist" apart from "the validation run failed" without grepping
|
|
6
|
+
* stderr. Every almyty CLI uses this same table, so
|
|
7
|
+
* `almyty models validate x || case $? in 3) almyty auth login;; esac`
|
|
8
|
+
* behaves the same whichever binary produced the code.
|
|
9
|
+
*
|
|
10
|
+
* Kept as a copy rather than an import: these CLIs are published
|
|
11
|
+
* separately and this table is six numbers that must never drift, which a
|
|
12
|
+
* test in each package pins.
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXIT: {
|
|
15
|
+
/** Success. */
|
|
16
|
+
readonly OK: 0;
|
|
17
|
+
/** Unexpected failure (a thrown error with no better classification). */
|
|
18
|
+
readonly ERROR: 1;
|
|
19
|
+
/** Bad or missing arguments, or an unknown command. */
|
|
20
|
+
readonly USAGE: 2;
|
|
21
|
+
/** No stored credential, or the API rejected the one we had. */
|
|
22
|
+
readonly AUTH: 3;
|
|
23
|
+
/** The named card, deployment, connection or grant does not exist. */
|
|
24
|
+
readonly NOT_FOUND: 4;
|
|
25
|
+
/** The command ran; the operation it asked for failed. */
|
|
26
|
+
readonly FAILED: 5;
|
|
27
|
+
};
|
|
28
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
29
|
+
/** One line per code, for `--help` output and READMEs. */
|
|
30
|
+
export declare const EXIT_CODE_HELP: string;
|
|
31
|
+
/** Errors the CLI raises itself for a bad invocation, so they exit 2. */
|
|
32
|
+
export declare class UsageError extends Error {
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Classify a thrown error into an exit code.
|
|
36
|
+
*
|
|
37
|
+
* The shared client turns a 401 into "Authentication failed. Run: npx
|
|
38
|
+
* @almyty/auth login" and everything else into "API error <status>: <body>",
|
|
39
|
+
* so the status is what there is to go on.
|
|
40
|
+
*/
|
|
41
|
+
export declare function exitCodeFor(err: unknown): ExitCode;
|
|
42
|
+
/**
|
|
43
|
+
* What to print when a command fails.
|
|
44
|
+
*
|
|
45
|
+
* Node's fetch says `fetch failed` and nothing else, which tells the reader
|
|
46
|
+
* neither which host was unreachable nor that the URL is theirs to change.
|
|
47
|
+
* Every message here names the next thing to do.
|
|
48
|
+
*/
|
|
49
|
+
export declare function describeError(err: unknown, apiUrl?: string): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes shared by every almyty CLI.
|
|
3
|
+
*
|
|
4
|
+
* Scripts need to tell "you are not logged in" apart from "that card does
|
|
5
|
+
* not exist" apart from "the validation run failed" without grepping
|
|
6
|
+
* stderr. Every almyty CLI uses this same table, so
|
|
7
|
+
* `almyty models validate x || case $? in 3) almyty auth login;; esac`
|
|
8
|
+
* behaves the same whichever binary produced the code.
|
|
9
|
+
*
|
|
10
|
+
* Kept as a copy rather than an import: these CLIs are published
|
|
11
|
+
* separately and this table is six numbers that must never drift, which a
|
|
12
|
+
* test in each package pins.
|
|
13
|
+
*/
|
|
14
|
+
export const EXIT = {
|
|
15
|
+
/** Success. */
|
|
16
|
+
OK: 0,
|
|
17
|
+
/** Unexpected failure (a thrown error with no better classification). */
|
|
18
|
+
ERROR: 1,
|
|
19
|
+
/** Bad or missing arguments, or an unknown command. */
|
|
20
|
+
USAGE: 2,
|
|
21
|
+
/** No stored credential, or the API rejected the one we had. */
|
|
22
|
+
AUTH: 3,
|
|
23
|
+
/** The named card, deployment, connection or grant does not exist. */
|
|
24
|
+
NOT_FOUND: 4,
|
|
25
|
+
/** The command ran; the operation it asked for failed. */
|
|
26
|
+
FAILED: 5,
|
|
27
|
+
};
|
|
28
|
+
/** One line per code, for `--help` output and READMEs. */
|
|
29
|
+
export const EXIT_CODE_HELP = [
|
|
30
|
+
' 0 success',
|
|
31
|
+
' 1 unexpected error',
|
|
32
|
+
' 2 usage error (bad flags, unknown command)',
|
|
33
|
+
' 3 not authenticated — run `almyty auth login`',
|
|
34
|
+
' 4 not found',
|
|
35
|
+
' 5 the operation ran and failed',
|
|
36
|
+
].join('\n');
|
|
37
|
+
/** Errors the CLI raises itself for a bad invocation, so they exit 2. */
|
|
38
|
+
export class UsageError extends Error {
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Classify a thrown error into an exit code.
|
|
42
|
+
*
|
|
43
|
+
* The shared client turns a 401 into "Authentication failed. Run: npx
|
|
44
|
+
* @almyty/auth login" and everything else into "API error <status>: <body>",
|
|
45
|
+
* so the status is what there is to go on.
|
|
46
|
+
*/
|
|
47
|
+
export function exitCodeFor(err) {
|
|
48
|
+
if (err instanceof UsageError)
|
|
49
|
+
return EXIT.USAGE;
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (/^--|is required|must be|contradict|nothing to set|unknown command/i.test(message))
|
|
52
|
+
return EXIT.USAGE;
|
|
53
|
+
if (/Authentication failed|\bAPI error 401\b|\b403\b/.test(message))
|
|
54
|
+
return EXIT.AUTH;
|
|
55
|
+
if (/\bAPI error 404\b/.test(message))
|
|
56
|
+
return EXIT.NOT_FOUND;
|
|
57
|
+
if (/\bAPI error 4\d\d\b|\bAPI error 5\d\d\b/.test(message))
|
|
58
|
+
return EXIT.FAILED;
|
|
59
|
+
return EXIT.ERROR;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What to print when a command fails.
|
|
63
|
+
*
|
|
64
|
+
* Node's fetch says `fetch failed` and nothing else, which tells the reader
|
|
65
|
+
* neither which host was unreachable nor that the URL is theirs to change.
|
|
66
|
+
* Every message here names the next thing to do.
|
|
67
|
+
*/
|
|
68
|
+
export function describeError(err, apiUrl) {
|
|
69
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
const where = apiUrl ? ` at ${apiUrl}` : '';
|
|
71
|
+
if (/Authentication failed|\bAPI error 401\b/.test(message)) {
|
|
72
|
+
return 'Not authenticated: the stored token is missing or has expired.\n Run: npx @almyty/auth login (or set ALMYTY_TOKEN)';
|
|
73
|
+
}
|
|
74
|
+
if (/\bAPI error 403\b/.test(message)) {
|
|
75
|
+
return `${message}\n The token is valid but lacks permission here. An organization connection needs connections:manage (admin or owner).`;
|
|
76
|
+
}
|
|
77
|
+
if (/\bAPI error 404\b/.test(message)) {
|
|
78
|
+
return `${message}\n No such id, or it belongs to another organization.`;
|
|
79
|
+
}
|
|
80
|
+
if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|other side closed|socket hang up/i.test(message)) {
|
|
81
|
+
return `Could not reach the almyty API${where}: ${message}\n Check ALMYTY_URL and the network, then try again.`;
|
|
82
|
+
}
|
|
83
|
+
if (/certificate|self.signed|SSL/i.test(message)) {
|
|
84
|
+
return `TLS handshake with the almyty API${where} failed: ${message}`;
|
|
85
|
+
}
|
|
86
|
+
if (/ENOENT/.test(message)) {
|
|
87
|
+
return `${message}\n The file named by --input-file or --config-file does not exist.`;
|
|
88
|
+
}
|
|
89
|
+
return message;
|
|
90
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface ParsedArgs {
|
|
3
|
+
command?: string;
|
|
4
|
+
positional: string[];
|
|
5
|
+
flags: Record<string, string | boolean>;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
8
|
+
/**
|
|
9
|
+
* A missing id used to be sent to the API as the literal string "undefined",
|
|
10
|
+
* which came back as an opaque 400. Say what is missing instead.
|
|
11
|
+
*/
|
|
12
|
+
export declare function needArg(positional: string[], index: number, name: string, usage: string): string;
|
|
13
|
+
export declare function connectBody(flags: ParsedArgs['flags'], input?: Record<string, unknown>): Record<string, unknown>;
|
|
14
|
+
export declare function grantBody(flags: ParsedArgs['flags']): Record<string, unknown>;
|
|
15
|
+
/** A JSON object, or a message naming which flag was wrong. */
|
|
16
|
+
export declare function parseInputObject(raw: string, flag: string): Record<string, unknown>;
|
|
17
|
+
export declare function parseInput(flags: ParsedArgs['flags']): Record<string, unknown> | undefined;
|
|
18
|
+
/** Names of the fields a connect form marks `x-secret`. */
|
|
19
|
+
export declare function secretFields(schema: any): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Refuse a secret that arrived on the command line.
|
|
22
|
+
*
|
|
23
|
+
* argv is readable by every process on the machine through `ps`, is written
|
|
24
|
+
* to shell history, and is echoed by most CI runners. A key that travelled
|
|
25
|
+
* that way has to be treated as disclosed, so the tool declines rather than
|
|
26
|
+
* accepting it and storing it as if it were safe.
|
|
27
|
+
*/
|
|
28
|
+
export declare function assertNoArgvSecrets(schema: any, input: Record<string, unknown>): void;
|
|
29
|
+
export declare function isRedirectMethod(type: string): boolean;
|
|
30
|
+
/** browser unless asked for headless; --open only makes sense with a browser. */
|
|
31
|
+
export declare function connectMode(flags: ParsedArgs['flags']): 'browser' | 'headless';
|
|
32
|
+
/**
|
|
33
|
+
* What to tell the user after a sign-in connect. The backend decides how the
|
|
34
|
+
* flow finishes and says so in `completeWith`; printing the paste-a-code
|
|
35
|
+
* instruction for a callback flow sent people to a command that cannot work,
|
|
36
|
+
* because the state is consumed by the redirect.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pendingRedirectMessage(pending: any, connectorKey: string): string;
|
|
39
|
+
export declare function formatConnector(c: any): string;
|
|
40
|
+
export declare function formatConnection(c: any): string;
|
|
41
|
+
/**
|
|
42
|
+
* The detail view. `list` is one line per credential; this answers the
|
|
43
|
+
* question the one-liner cannot: what is wrong with this credential, when
|
|
44
|
+
* was that last checked, and what may it do.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatConnectionDetail(c: any): string;
|
|
47
|
+
/** Every health status other than `valid` has a different next step. */
|
|
48
|
+
export declare function healthAdvice(status: string): string;
|
|
49
|
+
export declare function formatGrant(g: any): string;
|
|
50
|
+
/** Pick the method to use: the one asked for, else the connector's best. */
|
|
51
|
+
export declare function chooseMethod(connector: any, wanted?: string): any;
|
|
52
|
+
/**
|
|
53
|
+
* Reading stdin when stdin is the terminal means waiting for a person to
|
|
54
|
+
* type JSON and press ctrl-D, which looks exactly like a hang. Say so.
|
|
55
|
+
*/
|
|
56
|
+
export declare function assertStdinIsPiped(flag: string): void;
|
|
57
|
+
/** Why `--input` is refused where the form is not known in advance. */
|
|
58
|
+
export declare function unscreenableInputMessage(): string;
|
|
59
|
+
/**
|
|
60
|
+
* Prompting needs a terminal. Without one (a pipe, a CI job, a cron) the
|
|
61
|
+
* prompt used to read end-of-file and submit an empty form, which the
|
|
62
|
+
* provider then rejected for a reason that had nothing to do with the key.
|
|
63
|
+
*/
|
|
64
|
+
export declare function requireTty(what: string, isTty?: boolean): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @almyty/credentials: keys, tokens and accounts from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Credentials are the one place almyty keeps a third-party secret: every
|
|
6
|
+
* credential is a row with its service, an account label and a health
|
|
7
|
+
* status, and everything that uses it (agents, APIs, tools, models,
|
|
8
|
+
* deployments, channels) holds a reference rather than a copy. See
|
|
9
|
+
* docs/connections.md.
|
|
10
|
+
*
|
|
11
|
+
* How a secret reaches the API matters, so this tool never wants one on the
|
|
12
|
+
* command line: argv is visible in `ps` and lands in shell history and CI
|
|
13
|
+
* logs. The paths are, best first: the service's sign-in flow (nothing to
|
|
14
|
+
* paste), a hidden terminal prompt, `--input-file`, or `--input-stdin`.
|
|
15
|
+
* `--input` stays for non-secret fields and is refused for secret ones.
|
|
16
|
+
*/
|
|
17
|
+
import { createInterface } from 'readline';
|
|
18
|
+
import { readFileSync } from 'fs';
|
|
19
|
+
import { AlmytyClient, resolveCredentialsOrExit } from '@almyty/client';
|
|
20
|
+
import { EXIT, EXIT_CODE_HELP, UsageError, describeError, exitCodeFor } from './exit-codes.js';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
22
|
+
/** Flags that never take a value. */
|
|
23
|
+
const BOOLEAN_FLAGS = new Set(['json', 'open', 'headless', 'input-stdin']);
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const result = { positional: [], flags: {} };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg === '-h') {
|
|
29
|
+
result.flags.help = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (arg === '-v') {
|
|
33
|
+
result.flags.version = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (arg === '--') {
|
|
37
|
+
// Everything after `--` is positional, flags included.
|
|
38
|
+
result.positional.push(...argv.slice(i + 1));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (arg.startsWith('--')) {
|
|
42
|
+
const body = arg.slice(2);
|
|
43
|
+
// `--flag=value` as well as `--flag value`. With only the space form,
|
|
44
|
+
// `--input='{"a":1}'` became a flag literally named `input={"a":1}`
|
|
45
|
+
// and the value was dropped without a word.
|
|
46
|
+
const eq = body.indexOf('=');
|
|
47
|
+
if (eq !== -1) {
|
|
48
|
+
result.flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (body === 'help' || body === 'version') {
|
|
52
|
+
result.flags[body] = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (BOOLEAN_FLAGS.has(body)) {
|
|
56
|
+
result.flags[body] = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const next = argv[i + 1];
|
|
60
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
61
|
+
result.flags[body] = next;
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
result.flags[body] = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!result.command)
|
|
69
|
+
result.command = arg;
|
|
70
|
+
else
|
|
71
|
+
result.positional.push(arg);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
function printHelp() {
|
|
76
|
+
console.log(`
|
|
77
|
+
@almyty/credentials v${VERSION}
|
|
78
|
+
|
|
79
|
+
Add a key, a token or a sign-in once; agents, APIs, tools, models,
|
|
80
|
+
deployments and channels use the credential. The secret is stored encrypted
|
|
81
|
+
in almyty and is never returned, not even masked.
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
npx @almyty/credentials <command> [options]
|
|
85
|
+
|
|
86
|
+
Read:
|
|
87
|
+
services [--kind k] The services a credential can be added for, and how
|
|
88
|
+
--kind inference|deployment|memory|mcp|tool_source|channel|cloud|registry
|
|
89
|
+
list Every credential, with whether it works
|
|
90
|
+
get <id> One credential: service, account, health, scopes, owner
|
|
91
|
+
grants <id> Who may use this credential
|
|
92
|
+
|
|
93
|
+
Add:
|
|
94
|
+
add <service> [--method m] [--owner org|user|private] [--name n] [--headless] [--open]
|
|
95
|
+
A sign-in prints an authorize URL (--open launches a
|
|
96
|
+
browser, --headless asks the service for a code to paste).
|
|
97
|
+
A key form prompts for each field, secrets not echoed.
|
|
98
|
+
complete <service> --state s --code c Finish a headless sign-in by pasting the code
|
|
99
|
+
validate <id> Check it with the service again; refreshes health and label.
|
|
100
|
+
Exits non-zero unless the health comes back valid.
|
|
101
|
+
rotate <id> [--headless] [--open] Replace the key in place; everything using the
|
|
102
|
+
credential keeps working. Prompts for the new value.
|
|
103
|
+
delete <id> Revoke at the service where it can be, then delete
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Share:
|
|
107
|
+
grant <id> --principal user|team|role|agent|workspace --to <principalId>
|
|
108
|
+
[--permission use|manage] [--expires <iso8601>]
|
|
109
|
+
revoke <id> <grantId> Withdraw one grant
|
|
110
|
+
|
|
111
|
+
Supplying form fields without a prompt (connect, rotate):
|
|
112
|
+
--input-file <path> Read the fields as a JSON object from a file
|
|
113
|
+
--input-stdin Read the fields as a JSON object from stdin
|
|
114
|
+
--input '<json>' Non-secret fields only. Refused when it carries a field the
|
|
115
|
+
service marks secret, because argv is world-readable.
|
|
116
|
+
|
|
117
|
+
Options:
|
|
118
|
+
--json Undecorated JSON on stdout, for scripts
|
|
119
|
+
-h, --help This help
|
|
120
|
+
-v, --version Print the version
|
|
121
|
+
|
|
122
|
+
Environment:
|
|
123
|
+
ALMYTY_TOKEN Token override (skips ~/.almyty/credentials.json)
|
|
124
|
+
ALMYTY_URL API URL override
|
|
125
|
+
NO_COLOR Honoured: this tool never colours its output
|
|
126
|
+
|
|
127
|
+
Exit codes:
|
|
128
|
+
${EXIT_CODE_HELP}
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
const str = (flags, key) => (typeof flags[key] === 'string' ? flags[key] : undefined);
|
|
132
|
+
const need = (flags, key) => {
|
|
133
|
+
const v = str(flags, key);
|
|
134
|
+
if (!v)
|
|
135
|
+
throw new UsageError(`--${key} is required`);
|
|
136
|
+
return v;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* A missing id used to be sent to the API as the literal string "undefined",
|
|
140
|
+
* which came back as an opaque 400. Say what is missing instead.
|
|
141
|
+
*/
|
|
142
|
+
export function needArg(positional, index, name, usage) {
|
|
143
|
+
const v = positional[index];
|
|
144
|
+
if (!v)
|
|
145
|
+
throw new UsageError(`${name} is required\n usage: almyty credentials ${usage}`);
|
|
146
|
+
return v;
|
|
147
|
+
}
|
|
148
|
+
export function connectBody(flags, input) {
|
|
149
|
+
const body = { owner: str(flags, 'owner') ?? 'org' };
|
|
150
|
+
if (!['org', 'user', 'private'].includes(body.owner))
|
|
151
|
+
throw new UsageError('--owner must be org, user or private');
|
|
152
|
+
if (str(flags, 'method'))
|
|
153
|
+
body.method = str(flags, 'method');
|
|
154
|
+
if (str(flags, 'name'))
|
|
155
|
+
body.name = str(flags, 'name');
|
|
156
|
+
if (input && Object.keys(input).length > 0)
|
|
157
|
+
body.input = input;
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
export function grantBody(flags) {
|
|
161
|
+
const principalType = need(flags, 'principal');
|
|
162
|
+
if (!['user', 'team', 'role', 'agent', 'workspace'].includes(principalType))
|
|
163
|
+
throw new UsageError('--principal must be user, team, role, agent or workspace');
|
|
164
|
+
const permission = str(flags, 'permission') ?? 'use';
|
|
165
|
+
if (!['use', 'manage'].includes(permission))
|
|
166
|
+
throw new UsageError('--permission must be use or manage');
|
|
167
|
+
const body = { principalType, principalId: need(flags, 'to'), permission };
|
|
168
|
+
if (str(flags, 'expires'))
|
|
169
|
+
body.expiresAt = str(flags, 'expires');
|
|
170
|
+
return body;
|
|
171
|
+
}
|
|
172
|
+
/** A JSON object, or a message naming which flag was wrong. */
|
|
173
|
+
export function parseInputObject(raw, flag) {
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(raw);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new UsageError(`${flag} must be valid JSON`);
|
|
180
|
+
}
|
|
181
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
182
|
+
throw new UsageError(`${flag} must be a JSON object`);
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
export function parseInput(flags) {
|
|
186
|
+
const raw = str(flags, 'input');
|
|
187
|
+
if (!raw)
|
|
188
|
+
return undefined;
|
|
189
|
+
try {
|
|
190
|
+
return parseInputObject(raw, '--input');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new UsageError('--input must be a JSON object');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Names of the fields a connect form marks `x-secret`. */
|
|
197
|
+
export function secretFields(schema) {
|
|
198
|
+
const props = schema?.properties ?? {};
|
|
199
|
+
return Object.entries(props).filter(([, def]) => def?.['x-secret'] === true).map(([key]) => key);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Refuse a secret that arrived on the command line.
|
|
203
|
+
*
|
|
204
|
+
* argv is readable by every process on the machine through `ps`, is written
|
|
205
|
+
* to shell history, and is echoed by most CI runners. A key that travelled
|
|
206
|
+
* that way has to be treated as disclosed, so the tool declines rather than
|
|
207
|
+
* accepting it and storing it as if it were safe.
|
|
208
|
+
*/
|
|
209
|
+
export function assertNoArgvSecrets(schema, input) {
|
|
210
|
+
const offending = secretFields(schema).filter((f) => input[f] !== undefined);
|
|
211
|
+
if (offending.length === 0)
|
|
212
|
+
return;
|
|
213
|
+
throw new UsageError(`${offending.join(', ')} ${offending.length === 1 ? 'is a secret' : 'are secrets'} and --input puts it in your shell history and in \`ps\`.\n` +
|
|
214
|
+
' Leave --input off and the field is prompted without echo, or pass the whole object as\n' +
|
|
215
|
+
' --input-file <path> read the JSON object from a file\n' +
|
|
216
|
+
' --input-stdin read the JSON object from stdin');
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The methods that send the user to the provider and finish on the callback,
|
|
220
|
+
* spelled exactly the way the API spells them (`REDIRECT_METHODS`).
|
|
221
|
+
* Guessing from the name prefix was wrong for both of the methods whose name
|
|
222
|
+
* does not match their shape: `oauth2_client_credentials` is a form (a client
|
|
223
|
+
* id and secret you paste, so it must be prompted) and `installation` is a
|
|
224
|
+
* redirect that does not start with `oauth2`.
|
|
225
|
+
*/
|
|
226
|
+
const REDIRECT_METHODS = ['oauth2_pkce', 'oauth2_code', 'installation'];
|
|
227
|
+
export function isRedirectMethod(type) {
|
|
228
|
+
return REDIRECT_METHODS.includes(type);
|
|
229
|
+
}
|
|
230
|
+
/** browser unless asked for headless; --open only makes sense with a browser. */
|
|
231
|
+
export function connectMode(flags) {
|
|
232
|
+
return flags.headless ? 'headless' : 'browser';
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* What to tell the user after a sign-in connect. The backend decides how the
|
|
236
|
+
* flow finishes and says so in `completeWith`; printing the paste-a-code
|
|
237
|
+
* instruction for a callback flow sent people to a command that cannot work,
|
|
238
|
+
* because the state is consumed by the redirect.
|
|
239
|
+
*/
|
|
240
|
+
export function pendingRedirectMessage(pending, connectorKey) {
|
|
241
|
+
const lines = [`Open this URL to continue:`, pending.authorizeUrl, ''];
|
|
242
|
+
if (pending.completeWith === 'code') {
|
|
243
|
+
lines.push('The provider will show you a code. Paste it with:', ` almyty credentials complete ${connectorKey} --state ${pending.state} --code <code>`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
lines.push('Approve in the browser and the connect finishes on its own; then:', ' almyty credentials list', '', 'On a machine with no browser, start again with --headless and the provider', 'shows a code you paste into `almyty credentials complete` instead.');
|
|
247
|
+
}
|
|
248
|
+
if (pending.expiresInSeconds)
|
|
249
|
+
lines.push('', `This link expires in ${Math.round(pending.expiresInSeconds / 60)} minutes.`);
|
|
250
|
+
return lines.join('\n');
|
|
251
|
+
}
|
|
252
|
+
export function formatConnector(c) {
|
|
253
|
+
const methods = (c.connect ?? []).map((m) => m.type).join(', ');
|
|
254
|
+
return `${c.key} ${c.displayName} [${c.kind}] ${methods}${c.custom ? ' (custom)' : ''}`;
|
|
255
|
+
}
|
|
256
|
+
export function formatConnection(c) {
|
|
257
|
+
const health = c.health?.status ?? 'unknown';
|
|
258
|
+
const label = c.accountLabel ? ` ${c.accountLabel}` : '';
|
|
259
|
+
const err = c.health?.error ? `\n ${c.health.error}` : '';
|
|
260
|
+
return `${c.id} ${c.connectorKey} ${c.owner}${label} ${health}${err}`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The detail view. `list` is one line per credential; this answers the
|
|
264
|
+
* question the one-liner cannot: what is wrong with this credential, when
|
|
265
|
+
* was that last checked, and what may it do.
|
|
266
|
+
*/
|
|
267
|
+
export function formatConnectionDetail(c) {
|
|
268
|
+
const lines = [
|
|
269
|
+
`${c.name ?? c.connectorKey}`,
|
|
270
|
+
` id ${c.id}`,
|
|
271
|
+
` service ${c.connectorKey}${c.connectorDisplayName ? ` (${c.connectorDisplayName})` : ''}${c.kind ? ` [${c.kind}]` : ''}`,
|
|
272
|
+
` owner ${c.owner}${c.ownerUserId ? ` (${c.ownerUserId})` : ''}`,
|
|
273
|
+
` method ${c.method ?? 'unknown'}`,
|
|
274
|
+
` account ${c.accountLabel ?? '(the provider named none)'}`,
|
|
275
|
+
` health ${c.health?.status ?? 'unknown'}${c.health?.checkedAt ? `, checked ${c.health.checkedAt}` : ', never checked'}`,
|
|
276
|
+
];
|
|
277
|
+
if (c.health?.error)
|
|
278
|
+
lines.push(` error ${c.health.error}`);
|
|
279
|
+
lines.push(` scopes ${c.scopesGranted?.length ? c.scopesGranted.join(', ') : '(none reported)'}`);
|
|
280
|
+
if (c.expiresAt)
|
|
281
|
+
lines.push(` expires ${c.expiresAt}`);
|
|
282
|
+
if (c.health?.status && c.health.status !== 'valid') {
|
|
283
|
+
lines.push('', healthAdvice(c.health.status), '', 'The stored secret is never returned, so fix it at the provider and then:', ` almyty credentials rotate ${c.id}`);
|
|
284
|
+
}
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
/** Every health status other than `valid` has a different next step. */
|
|
288
|
+
export function healthAdvice(status) {
|
|
289
|
+
switch (status) {
|
|
290
|
+
case 'failed': return 'The provider rejected the stored secret. Re-check it and rotate.';
|
|
291
|
+
case 'expired': return 'The stored secret has expired. Rotate to replace it.';
|
|
292
|
+
case 'revoked': return 'The secret was revoked at the provider. Rotate to replace it.';
|
|
293
|
+
case 'quota': return 'The credential is good but the account is out of quota or over its rate limit at the provider.';
|
|
294
|
+
case 'unknown': return 'Never checked against the provider. Run `almyty credentials validate <id>`.';
|
|
295
|
+
default: return `Health is ${status}.`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
export function formatGrant(g) {
|
|
299
|
+
return `${g.id} ${g.principalType}:${g.principalName ?? g.principalId} ${g.permission}${g.expiresAt ? ` until ${g.expiresAt}` : ''}`;
|
|
300
|
+
}
|
|
301
|
+
/** Pick the method to use: the one asked for, else the connector's best. */
|
|
302
|
+
export function chooseMethod(connector, wanted) {
|
|
303
|
+
const methods = connector?.connect ?? [];
|
|
304
|
+
if (methods.length === 0)
|
|
305
|
+
throw new UsageError(`${connector?.key ?? 'connector'} has no connect method`);
|
|
306
|
+
if (!wanted)
|
|
307
|
+
return methods[0];
|
|
308
|
+
const found = methods.find((m) => m.type === wanted);
|
|
309
|
+
if (!found)
|
|
310
|
+
throw new UsageError(`${connector.key} does not support ${wanted}; available: ${methods.map((m) => m.type).join(', ')}`);
|
|
311
|
+
return found;
|
|
312
|
+
}
|
|
313
|
+
/** Ask for each schema field at the terminal; x-secret fields are read without echo. */
|
|
314
|
+
async function promptForSchema(schema) {
|
|
315
|
+
const out = {};
|
|
316
|
+
const props = schema?.properties ?? {};
|
|
317
|
+
const required = schema?.required ?? [];
|
|
318
|
+
for (const [key, def] of Object.entries(props)) {
|
|
319
|
+
const label = `${def.title ?? key}${required.includes(key) ? '' : ' (optional)'}${def.default !== undefined ? ` [${def.default}]` : ''}: `;
|
|
320
|
+
const value = await ask(label, Boolean(def['x-secret']));
|
|
321
|
+
if (value === '' && def.default !== undefined)
|
|
322
|
+
out[key] = def.default;
|
|
323
|
+
else if (value !== '')
|
|
324
|
+
out[key] = def.type === 'integer' || def.type === 'number' ? Number(value) : def.type === 'boolean' ? /^(y|yes|true|1)$/i.test(value) : value;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function ask(label, secret) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
331
|
+
if (secret) {
|
|
332
|
+
// Hide the typed characters: readline echoes through _writeToOutput.
|
|
333
|
+
const anyRl = rl;
|
|
334
|
+
anyRl._writeToOutput = (s) => {
|
|
335
|
+
if (s.includes(label))
|
|
336
|
+
anyRl.output.write(label);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
rl.question(label, (answer) => {
|
|
340
|
+
rl.close();
|
|
341
|
+
if (secret)
|
|
342
|
+
process.stdout.write('\n');
|
|
343
|
+
resolve(answer.trim());
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Reading stdin when stdin is the terminal means waiting for a person to
|
|
349
|
+
* type JSON and press ctrl-D, which looks exactly like a hang. Say so.
|
|
350
|
+
*/
|
|
351
|
+
export function assertStdinIsPiped(flag) {
|
|
352
|
+
if (!process.stdin.isTTY)
|
|
353
|
+
return;
|
|
354
|
+
throw new UsageError(`${flag} reads stdin, and stdin is your terminal, so it would wait forever.\n` +
|
|
355
|
+
` Pipe the JSON in: cat fields.json | almyty credentials <command> ${flag}\n` +
|
|
356
|
+
' Or use --input-file <path>, or leave both off and be prompted.');
|
|
357
|
+
}
|
|
358
|
+
function readStdin() {
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
let data = '';
|
|
361
|
+
process.stdin.setEncoding('utf8');
|
|
362
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
363
|
+
process.stdin.on('end', () => resolve(data));
|
|
364
|
+
process.stdin.on('error', reject);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Where the form fields come from, in order of how safely the secret travels:
|
|
369
|
+
* a file, stdin, then argv (non-secret fields only), then a hidden prompt.
|
|
370
|
+
* Returns undefined when nothing was supplied and the caller should prompt.
|
|
371
|
+
*/
|
|
372
|
+
async function suppliedInput(flags, schema,
|
|
373
|
+
/**
|
|
374
|
+
* False when the form is not known yet, so which fields are secret is not
|
|
375
|
+
* known either. `rotate` is in that position: the API only answers with
|
|
376
|
+
* the form after the first call, so `--input` cannot be screened and is
|
|
377
|
+
* refused outright rather than accepted blind.
|
|
378
|
+
*/
|
|
379
|
+
schemaKnown = true) {
|
|
380
|
+
const file = str(flags, 'input-file');
|
|
381
|
+
if (file)
|
|
382
|
+
return parseInputObject(readFileSync(file, 'utf8'), `--input-file ${file}`);
|
|
383
|
+
if (flags['input-stdin']) {
|
|
384
|
+
assertStdinIsPiped('--input-stdin');
|
|
385
|
+
return parseInputObject(await readStdin(), '--input-stdin');
|
|
386
|
+
}
|
|
387
|
+
const inline = parseInput(flags);
|
|
388
|
+
if (inline) {
|
|
389
|
+
if (!schemaKnown)
|
|
390
|
+
throw new UsageError(unscreenableInputMessage());
|
|
391
|
+
assertNoArgvSecrets(schema, inline);
|
|
392
|
+
return inline;
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
/** Why `--input` is refused where the form is not known in advance. */
|
|
397
|
+
export function unscreenableInputMessage() {
|
|
398
|
+
return [
|
|
399
|
+
'--input cannot be used here: which of these fields are secret is only known once the API',
|
|
400
|
+
'answers with the form, and argv is in your shell history and in `ps`.',
|
|
401
|
+
' --input-file <path> read the JSON object from a file',
|
|
402
|
+
' --input-stdin read the JSON object from stdin',
|
|
403
|
+
' or leave both off and the fields are prompted, secrets without echo',
|
|
404
|
+
].join('\n');
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Prompting needs a terminal. Without one (a pipe, a CI job, a cron) the
|
|
408
|
+
* prompt used to read end-of-file and submit an empty form, which the
|
|
409
|
+
* provider then rejected for a reason that had nothing to do with the key.
|
|
410
|
+
*/
|
|
411
|
+
export function requireTty(what, isTty = process.stdin.isTTY) {
|
|
412
|
+
if (isTty)
|
|
413
|
+
return;
|
|
414
|
+
throw new UsageError(`${what} needs a terminal to prompt on, and stdin is not one.\n` +
|
|
415
|
+
' Pass the fields instead:\n' +
|
|
416
|
+
' --input-file <path> read the JSON object from a file\n' +
|
|
417
|
+
' --input-stdin read the JSON object from stdin');
|
|
418
|
+
}
|
|
419
|
+
function newClient() {
|
|
420
|
+
const creds = resolveCredentialsOrExit();
|
|
421
|
+
return new AlmytyClient(creds.url, creds.token);
|
|
422
|
+
}
|
|
423
|
+
function out(args, data, pretty) {
|
|
424
|
+
console.log(args.flags.json ? JSON.stringify(data, null, 2) : pretty());
|
|
425
|
+
}
|
|
426
|
+
async function openInBrowser(url) {
|
|
427
|
+
const { spawn } = await import('child_process');
|
|
428
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
429
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
430
|
+
}
|
|
431
|
+
async function main() {
|
|
432
|
+
const args = parseArgs(process.argv.slice(2));
|
|
433
|
+
if (args.flags.version)
|
|
434
|
+
return void console.log(VERSION);
|
|
435
|
+
if (!args.command || args.command === 'help' || args.flags.help)
|
|
436
|
+
return printHelp();
|
|
437
|
+
const client = newClient();
|
|
438
|
+
const q = (path, init) => client.request(path, init);
|
|
439
|
+
const post = (path, body) => q(path, { method: 'POST', body: JSON.stringify(body) });
|
|
440
|
+
switch (args.command) {
|
|
441
|
+
case 'services': {
|
|
442
|
+
// Filtered by the API, so an unknown kind is an error instead of an
|
|
443
|
+
// empty list that looks like "nothing can be connected".
|
|
444
|
+
const kind = str(args.flags, 'kind');
|
|
445
|
+
const res = await q(`/credentials/services${kind ? `?kind=${encodeURIComponent(kind)}` : ''}`);
|
|
446
|
+
out(args, res.data, () => (res.data.length ? res.data.map(formatConnector).join('\n') : `No services${kind ? ` of kind ${kind}` : ''}.`));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
case 'list': {
|
|
450
|
+
const res = await q('/credentials');
|
|
451
|
+
out(args, res.data, () => (res.data.length ? res.data.map(formatConnection).join('\n') : 'No credentials yet. Run: almyty credentials services'));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
case 'get': {
|
|
455
|
+
const id = needArg(args.positional, 0, 'credential id', 'get <id>');
|
|
456
|
+
const res = await q(`/credentials/${id}`);
|
|
457
|
+
out(args, res.data, () => formatConnectionDetail(res.data));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case 'add': {
|
|
461
|
+
const key = needArg(args.positional, 0, 'service key', 'add <service>');
|
|
462
|
+
const catalog = await q('/credentials/services');
|
|
463
|
+
const connector = catalog.data.find((c) => c.key === key);
|
|
464
|
+
if (!connector)
|
|
465
|
+
throw new UsageError(`unknown service ${key}; run: almyty credentials services`);
|
|
466
|
+
const method = chooseMethod(connector, str(args.flags, 'method'));
|
|
467
|
+
const isRedirect = isRedirectMethod(method.type);
|
|
468
|
+
let input = await suppliedInput(args.flags, method.schema);
|
|
469
|
+
if (!isRedirect && !input && method.schema) {
|
|
470
|
+
// `description` is the field the connector catalog actually carries;
|
|
471
|
+
// `instructions` is kept for custom connectors that use that name.
|
|
472
|
+
const guidance = method.description ?? method.instructions;
|
|
473
|
+
if (guidance)
|
|
474
|
+
console.log(`\n${guidance}\n`);
|
|
475
|
+
const keyPage = method.keyPageUrl ?? connector.keyPageUrl;
|
|
476
|
+
if (keyPage)
|
|
477
|
+
console.log(`Get a key at: ${keyPage}`);
|
|
478
|
+
if (method.quickCreateUrl)
|
|
479
|
+
console.log(`Quick create: ${method.quickCreateUrl}`);
|
|
480
|
+
requireTty(`connect ${key} via ${method.type}`);
|
|
481
|
+
input = await promptForSchema(method.schema);
|
|
482
|
+
}
|
|
483
|
+
const body = connectBody({ ...args.flags, method: method.type }, input);
|
|
484
|
+
if (isRedirect)
|
|
485
|
+
body.mode = connectMode(args.flags);
|
|
486
|
+
const res = await post(`/credentials/connect/${key}`, body);
|
|
487
|
+
if (res.data?.authorizeUrl) {
|
|
488
|
+
if (args.flags.json)
|
|
489
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
490
|
+
else
|
|
491
|
+
console.log(pendingRedirectMessage(res.data, key));
|
|
492
|
+
if (args.flags.open)
|
|
493
|
+
await openInBrowser(res.data.authorizeUrl);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
out(args, res.data, () => `Added.\n${formatConnection(res.data.connection ?? res.data)}`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
case 'complete': {
|
|
500
|
+
const key = needArg(args.positional, 0, 'service key', 'complete <service> --state s --code c');
|
|
501
|
+
const res = await post(`/credentials/connect/${key}/complete`, { state: need(args.flags, 'state'), code: need(args.flags, 'code') });
|
|
502
|
+
out(args, res.data, () => `Added.\n${formatConnection(res.data.connection ?? res.data)}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
case 'validate': {
|
|
506
|
+
const id = needArg(args.positional, 0, 'credential id', 'validate <id>');
|
|
507
|
+
const res = await post(`/credentials/${id}/validate`, {});
|
|
508
|
+
const connection = res.data.connection ?? res.data;
|
|
509
|
+
const status = connection.health?.status ?? 'unknown';
|
|
510
|
+
out(args, connection, () => (status === 'valid'
|
|
511
|
+
? `Valid.\n${formatConnection(connection)}`
|
|
512
|
+
: `${status}.\n${formatConnection(connection)}\n\n${healthAdvice(status)}`));
|
|
513
|
+
// A validate that came back failed is a failure. Exiting 0 made this
|
|
514
|
+
// useless in a script and in a health check.
|
|
515
|
+
if (status !== 'valid')
|
|
516
|
+
process.exitCode = EXIT.FAILED;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
case 'rotate': {
|
|
520
|
+
const id = needArg(args.positional, 0, 'credential id', 'rotate <id>');
|
|
521
|
+
const supplied = await suppliedInput(args.flags, undefined, false);
|
|
522
|
+
const body = {};
|
|
523
|
+
if (supplied)
|
|
524
|
+
body.input = supplied;
|
|
525
|
+
if (args.flags.headless)
|
|
526
|
+
body.mode = 'headless';
|
|
527
|
+
let res = await post(`/credentials/${id}/rotate`, body);
|
|
528
|
+
if (res.data?.authorizeUrl) {
|
|
529
|
+
if (args.flags.json)
|
|
530
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
531
|
+
else
|
|
532
|
+
console.log(pendingRedirectMessage(res.data, res.data.connectorKey ?? '<service>'));
|
|
533
|
+
if (args.flags.open)
|
|
534
|
+
await openInBrowser(res.data.authorizeUrl);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// A pasted-key service answers a rotate with the form to fill in.
|
|
538
|
+
// Sending {} and reporting "Rotated." left the old secret in place.
|
|
539
|
+
if (res.data?.pending && res.data.form) {
|
|
540
|
+
const form = res.data.form;
|
|
541
|
+
if (form.keyPageUrl)
|
|
542
|
+
console.log(`Create the replacement key at: ${form.keyPageUrl}`);
|
|
543
|
+
requireTty(`rotate ${id}`);
|
|
544
|
+
const input = await promptForSchema(form.schema);
|
|
545
|
+
if (Object.keys(input).length === 0)
|
|
546
|
+
throw new Error('nothing entered; the credential was left as it was');
|
|
547
|
+
res = await post(`/credentials/${id}/rotate`, { input });
|
|
548
|
+
}
|
|
549
|
+
out(args, res.data, () => `Rotated.\n${formatConnection(res.data.connection ?? res.data)}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
case 'delete': {
|
|
553
|
+
const id = needArg(args.positional, 0, 'credential id', 'delete <id>');
|
|
554
|
+
const res = await q(`/credentials/${id}`, { method: 'DELETE' });
|
|
555
|
+
out(args, res?.data ?? { id, deleted: true }, () => 'Deleted.');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
case 'grants': {
|
|
559
|
+
const id = needArg(args.positional, 0, 'credential id', 'grants <id>');
|
|
560
|
+
const res = await q(`/credentials/${id}/grants`);
|
|
561
|
+
out(args, res.data, () => (res.data.length ? res.data.map(formatGrant).join('\n') : 'No grants: only the owner (and org admins for org credentials) can use it.'));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
case 'grant': {
|
|
565
|
+
const id = needArg(args.positional, 0, 'credential id', 'grant <id> --principal p --to id');
|
|
566
|
+
const res = await post(`/credentials/${id}/grants`, grantBody(args.flags));
|
|
567
|
+
out(args, res.data, () => `Granted.\n${formatGrant(res.data)}`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
case 'revoke': {
|
|
571
|
+
const id = needArg(args.positional, 0, 'credential id', 'revoke <id> <grantId>');
|
|
572
|
+
const grantId = needArg(args.positional, 1, 'grant id', 'revoke <id> <grantId>');
|
|
573
|
+
const res = await q(`/credentials/${id}/grants/${grantId}`, { method: 'DELETE' });
|
|
574
|
+
out(args, res?.data ?? { id: grantId, revoked: true }, () => 'Revoked.');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
default:
|
|
578
|
+
console.error(`Unknown command: ${args.command}\n`);
|
|
579
|
+
printHelp();
|
|
580
|
+
process.exit(EXIT.USAGE);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const invokedDirectly = process.argv[1] && /credentials-cli|almyty-credentials|dist\/index\.js|src\/index\.ts/.test(process.argv[1]) && !process.env.VITEST;
|
|
584
|
+
if (invokedDirectly) {
|
|
585
|
+
main().catch((err) => {
|
|
586
|
+
console.error(describeError(err, process.env.ALMYTY_URL));
|
|
587
|
+
process.exit(exitCodeFor(err));
|
|
588
|
+
});
|
|
589
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's own version, read from its package.json at startup.
|
|
3
|
+
*
|
|
4
|
+
* It used to be a hardcoded string, which drifted: `--version` answered
|
|
5
|
+
* 0.1.0 while the published package was 1.2.0, so a bug report never
|
|
6
|
+
* identified the build it came from. Both `dist/index.js` and `src/index.ts`
|
|
7
|
+
* sit one directory below the package root, so the same relative path
|
|
8
|
+
* resolves for the built bin and for `tsx src/index.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
export function readVersion(fallback = '0.0.0') {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
14
|
+
return typeof pkg.version === 'string' && pkg.version.length > 0
|
|
15
|
+
? pkg.version
|
|
16
|
+
: fallback;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const VERSION = readVersion();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@almyty/credentials",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Keys, tokens and accounts for almyty from your terminal: list services, add by key or sign-in, check, replace, delete, share.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"almyty-credentials": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"almyty",
|
|
25
|
+
"credentials",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"author": "almyty",
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@almyty/client": "^1.2.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^25.4.0",
|
|
35
|
+
"tsx": "^4.7.0",
|
|
36
|
+
"typescript": "^5.3.0",
|
|
37
|
+
"vitest": "^4.1.0"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://almyty.com",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/almyty-inc/almyty.git",
|
|
43
|
+
"directory": "packages/credentials-cli"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/almyty-inc/almyty/issues"
|
|
47
|
+
}
|
|
48
|
+
}
|