@throng/cli 0.1.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 +120 -0
- package/dist/commands/api.js +80 -0
- package/dist/commands/auth.js +96 -0
- package/dist/commands/org.js +52 -0
- package/dist/commands/token.js +14 -0
- package/dist/config.js +37 -0
- package/dist/credentials.js +211 -0
- package/dist/errors.js +6 -0
- package/dist/graphql.js +129 -0
- package/dist/index.js +111 -0
- package/dist/oauth/login.js +135 -0
- package/dist/oauth/pkce.js +14 -0
- package/dist/oauth/refresh.js +24 -0
- package/dist/oauth/revoke.js +33 -0
- package/dist/oauth/session.js +117 -0
- package/dist/oauth/token-request.js +87 -0
- package/dist/output.js +46 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# throng
|
|
2
|
+
|
|
3
|
+
Command-line client for [Throng](https://app.throng.dev).
|
|
4
|
+
|
|
5
|
+
Requires Node 20 or later.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
throng auth login Sign in through your browser
|
|
11
|
+
throng auth logout Revoke the session on the server and forget the stored credentials
|
|
12
|
+
throng auth status Show host, user, access token expiry and organisation (exits 3 when not signed in)
|
|
13
|
+
throng token Print a valid access token on stdout
|
|
14
|
+
throng org list List your organisations
|
|
15
|
+
throng org use <slug-or-id>
|
|
16
|
+
Choose the organisation later commands act in
|
|
17
|
+
throng api [-f file] Run a GraphQL document and print the data as JSON
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`api` reads the document from `-f <file>`, or from stdin when `-f` is absent
|
|
21
|
+
(`echo '{ me { email } }' | throng api`). With no file and a terminal on stdin
|
|
22
|
+
it exits with a usage error rather than wait for input. Organisation-scoped
|
|
23
|
+
queries need an organisation: choose one with `org use`, or pass `--org <id>` for
|
|
24
|
+
a single call.
|
|
25
|
+
|
|
26
|
+
`auth logout` revokes the grant on the server (RFC 7009) as well as forgetting it
|
|
27
|
+
on this machine, so a refresh token copied or backed up elsewhere stops working.
|
|
28
|
+
Revocation is best-effort: if the server cannot be reached, logout warns on stderr
|
|
29
|
+
and still forgets the credentials locally. `auth status` asks the server who you
|
|
30
|
+
are; if it cannot, it reports what is stored locally and says the user is unknown.
|
|
31
|
+
|
|
32
|
+
### Global options
|
|
33
|
+
|
|
34
|
+
| Option | Meaning |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `--host <url>` | The Throng host. Falls back to `THRONG_HOST`, then `https://app.throng.dev`. |
|
|
37
|
+
| `--org <id>` | Organisation id for this call, overriding the one chosen with `org use`. |
|
|
38
|
+
| `--json` | Print JSON instead of a table (`org list`). |
|
|
39
|
+
|
|
40
|
+
### Output and exit codes
|
|
41
|
+
|
|
42
|
+
Stdout carries only data: the token from `token`, the JSON from `api`, the table
|
|
43
|
+
from `org list`. Every message meant for a person goes to stderr, so
|
|
44
|
+
`throng token | pbcopy` copies the token and nothing else.
|
|
45
|
+
|
|
46
|
+
| Code | Meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| 0 | Success |
|
|
49
|
+
| 1 | API error, refused mutation, or any other failure (network, lock timeout) |
|
|
50
|
+
| 2 | Usage error |
|
|
51
|
+
| 3 | Authentication required: run `throng auth login` |
|
|
52
|
+
|
|
53
|
+
If the server says you are no longer a member of the stored organisation, `api`
|
|
54
|
+
forgets it and asks you to run `throng org use`.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
npm test # unit tests
|
|
60
|
+
npm run typecheck
|
|
61
|
+
npm run dev -- --host http://localhost:4000 auth status
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Releasing
|
|
65
|
+
|
|
66
|
+
Releases are published by `.github/workflows/cli-release.yml` when a `cli-v*` tag
|
|
67
|
+
is pushed. **There is no npm token**: the workflow authenticates to npm over OIDC
|
|
68
|
+
as a trusted publisher, so nothing long-lived has to be stored in the repository,
|
|
69
|
+
and each release gets a provenance attestation for free.
|
|
70
|
+
|
|
71
|
+
To cut a release, bump `version` in `package.json`, merge, then tag. The workflow
|
|
72
|
+
refuses to publish if the tag and `package.json` disagree, so the two cannot drift:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
git tag cli-v0.1.1 && git push origin cli-v0.1.1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### One-time setup
|
|
79
|
+
|
|
80
|
+
Trusted publishing is configured per package, at
|
|
81
|
+
`npmjs.com/package/@throng/cli/access` — which only exists once the package does.
|
|
82
|
+
So the first version has to be published by hand, and only the first:
|
|
83
|
+
|
|
84
|
+
1. Create the `throng` organisation on npm. A free org is enough; it allows
|
|
85
|
+
unlimited public packages, and this one is public.
|
|
86
|
+
2. Publish once from a checkout: `npm login`, then `npm run build && npm publish
|
|
87
|
+
--access public`.
|
|
88
|
+
3. At `npmjs.com/package/@throng/cli/access`, add a GitHub Actions trusted
|
|
89
|
+
publisher — organisation `col`, repository `throngx`, workflow filename
|
|
90
|
+
`cli-release.yml`. Leave the environment blank. **Every field is
|
|
91
|
+
case-sensitive, and npm does not validate them when you save**, so a typo
|
|
92
|
+
surfaces only as a failed publish.
|
|
93
|
+
|
|
94
|
+
After that every release is tokenless. Two things to know if a publish ever fails
|
|
95
|
+
with an authentication error: `package.json`'s `repository.url` must keep matching
|
|
96
|
+
the trusted publisher exactly, and the publish job needs npm 11.5.1+ on Node
|
|
97
|
+
22.14+, which is why it installs its own npm rather than using Node's bundled one.
|
|
98
|
+
|
|
99
|
+
### Integration test
|
|
100
|
+
|
|
101
|
+
`test/integration.test.ts` runs against a real server and is skipped unless
|
|
102
|
+
`THRONG_INTEGRATION_HOST` is set:
|
|
103
|
+
|
|
104
|
+
| Variable | Meaning |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `THRONG_INTEGRATION_HOST` | Origin of the server, for example `http://localhost:4000`. |
|
|
107
|
+
| `THRONG_INTEGRATION_REFRESH_TOKEN` | A refresh token for that host. Run `throng auth login --host <host>` by hand once and copy `refresh_token` from `~/.config/throng/credentials.json`. |
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
THRONG_INTEGRATION_HOST=http://localhost:4000 \
|
|
111
|
+
THRONG_INTEGRATION_REFRESH_TOKEN=... \
|
|
112
|
+
npx vitest run test/integration.test.ts
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
It exercises the library calls `token` and `api` are built on (refreshing against
|
|
116
|
+
the live `/oauth/token`, a real `me { email }`, and the page shape of
|
|
117
|
+
`myOrganisations`), not the commands themselves, which `commands.test.ts` covers
|
|
118
|
+
against stubs. It does not cover `login`, because completing a browser sign-in
|
|
119
|
+
and consent cannot be automated. Refresh tokens rotate, so a run spends the one
|
|
120
|
+
you supplied; sign in again before the next.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { OrgAccessDeniedError, graphql } from "../graphql.js";
|
|
3
|
+
import { clearOrgId, getOrgId } from "../oauth/session.js";
|
|
4
|
+
import { printJson } from "../output.js";
|
|
5
|
+
async function readStream(stream) {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for await (const chunk of stream)
|
|
8
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
9
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `throng api [-f file]`: run a GraphQL document and print its `data` as JSON.
|
|
13
|
+
*
|
|
14
|
+
* With no `-f` the document is read from stdin. If stdin is a terminal there is
|
|
15
|
+
* nothing to read and waiting would just hang the command, so that is a usage
|
|
16
|
+
* error instead.
|
|
17
|
+
*/
|
|
18
|
+
export function registerApi(program, ctx) {
|
|
19
|
+
program
|
|
20
|
+
.command("api")
|
|
21
|
+
.description("Run a GraphQL document against the API and print the data as JSON")
|
|
22
|
+
.option("-f, --file <path>", "read the document from a file instead of stdin")
|
|
23
|
+
.action(async (opts, command) => {
|
|
24
|
+
const { host, org, stdin } = ctx();
|
|
25
|
+
let document;
|
|
26
|
+
if (opts.file !== undefined) {
|
|
27
|
+
try {
|
|
28
|
+
document = await readFile(opts.file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
const code = cause.code ?? "unknown error";
|
|
32
|
+
return command.error(`error: cannot read ${opts.file} (${code})`, { exitCode: 2 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (stdin.isTTY) {
|
|
36
|
+
return command.error("error: no GraphQL document: pass -f <file> or pipe one to stdin", { exitCode: 2 });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
document = await readStream(stdin);
|
|
40
|
+
}
|
|
41
|
+
if (document.trim() === "") {
|
|
42
|
+
return command.error("error: the GraphQL document is empty", { exitCode: 2 });
|
|
43
|
+
}
|
|
44
|
+
// --org wins; otherwise the organisation `throng org use` stored.
|
|
45
|
+
const storedOrg = org === undefined ? await getOrgId(host) : undefined;
|
|
46
|
+
const orgId = org ?? storedOrg;
|
|
47
|
+
try {
|
|
48
|
+
printJson(await graphql(host, document, { orgId }));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!(error instanceof OrgAccessDeniedError))
|
|
52
|
+
throw error;
|
|
53
|
+
throw await orgDenied(error, host, org, storedOrg);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The documented recovery from a 403 `ORGANISATION_ACCESS_DENIED`: drop the
|
|
59
|
+
* stored organisation so the next command stops sending it, and point the user
|
|
60
|
+
* at `throng org use`. An organisation given with `--org` is the user's explicit
|
|
61
|
+
* choice for this one call; the stored one did not cause the refusal, so it stays.
|
|
62
|
+
*/
|
|
63
|
+
async function orgDenied(error, host, flagOrg, storedOrg) {
|
|
64
|
+
if (flagOrg !== undefined) {
|
|
65
|
+
return new OrgAccessDeniedError(`You are not a member of organisation ${flagOrg}. Run \`throng org list\` to see yours.`, error.code);
|
|
66
|
+
}
|
|
67
|
+
if (storedOrg === undefined)
|
|
68
|
+
return error;
|
|
69
|
+
let cleared = true;
|
|
70
|
+
try {
|
|
71
|
+
await clearOrgId(host);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
cleared = false;
|
|
75
|
+
}
|
|
76
|
+
const action = cleared
|
|
77
|
+
? "The stored organisation was cleared."
|
|
78
|
+
: "The stored organisation could not be cleared.";
|
|
79
|
+
return new OrgAccessDeniedError(`You are no longer a member of the selected organisation. ${action} Run \`throng org use <slug-or-id>\` to choose another.`, error.code);
|
|
80
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import open from "open";
|
|
2
|
+
import { AuthRequiredError } from "../errors.js";
|
|
3
|
+
import { graphql } from "../graphql.js";
|
|
4
|
+
import { login } from "../oauth/login.js";
|
|
5
|
+
import { getAccessToken, getExpiresAt, getOrgId, logout, saveLogin } from "../oauth/session.js";
|
|
6
|
+
import { describeError, info } from "../output.js";
|
|
7
|
+
export function registerAuth(program, ctx) {
|
|
8
|
+
const auth = program.command("auth").description("Sign in, sign out, and check your session");
|
|
9
|
+
auth
|
|
10
|
+
.command("login")
|
|
11
|
+
.description("Sign in through your browser")
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const { host, openBrowser } = ctx();
|
|
14
|
+
const launch = openBrowser ??
|
|
15
|
+
(async (url) => {
|
|
16
|
+
await open(url);
|
|
17
|
+
});
|
|
18
|
+
const pair = await login(host, {
|
|
19
|
+
// `login` has no output channel of its own. The URL is printed before the
|
|
20
|
+
// launcher runs because a launcher can succeed without showing anything
|
|
21
|
+
// (headless box, SSH), and the user would otherwise wait out the timeout
|
|
22
|
+
// with nothing to act on.
|
|
23
|
+
openBrowser: async (url) => {
|
|
24
|
+
info(`Opening your browser to sign in to ${host}.`);
|
|
25
|
+
info("If it does not open, visit this URL:");
|
|
26
|
+
info(url);
|
|
27
|
+
await launch(url);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
await saveLogin(host, pair);
|
|
31
|
+
info(`Signed in to ${host}.`);
|
|
32
|
+
});
|
|
33
|
+
auth
|
|
34
|
+
.command("logout")
|
|
35
|
+
.description("Revoke this host's session and forget the stored credentials")
|
|
36
|
+
.action(async () => {
|
|
37
|
+
const { host } = ctx();
|
|
38
|
+
const { revokeFailure } = await logout(host);
|
|
39
|
+
if (revokeFailure) {
|
|
40
|
+
info(`Could not revoke your session on ${host} (${revokeFailure}). It was forgotten on this machine only, and stays valid on the server until it expires.`);
|
|
41
|
+
}
|
|
42
|
+
info(`Signed out of ${host}.`);
|
|
43
|
+
});
|
|
44
|
+
auth
|
|
45
|
+
.command("status")
|
|
46
|
+
.description("Show your host, user, organisation and token expiry (exits 3 when you are not signed in)")
|
|
47
|
+
.action(async () => {
|
|
48
|
+
const context = ctx();
|
|
49
|
+
const { host } = context;
|
|
50
|
+
// The one problem that makes the rest of the report unverifiable, if there is one.
|
|
51
|
+
let problem;
|
|
52
|
+
try {
|
|
53
|
+
// Not just "is a record on disk": this refreshes a lapsed session, so a
|
|
54
|
+
// dead one is reported as signed out. The token itself is never shown.
|
|
55
|
+
await getAccessToken(host);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!(error instanceof AuthRequiredError)) {
|
|
59
|
+
// Could not refresh (offline, lock timeout): fall through to the local facts.
|
|
60
|
+
problem = describeError(error);
|
|
61
|
+
context.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Reported, not thrown: a status check answering "no" is not a failure of the command.
|
|
65
|
+
info(error.message);
|
|
66
|
+
context.exitCode = 3;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
info(`Signed in to ${host}.`);
|
|
71
|
+
if (problem === undefined) {
|
|
72
|
+
try {
|
|
73
|
+
// No organisation header: `me` does not need one, and a stale stored one would be refused.
|
|
74
|
+
const data = await graphql(host, "{ me { email } }");
|
|
75
|
+
const email = data.me?.email;
|
|
76
|
+
info(typeof email === "string" ? `User: ${email}` : "User: unknown");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// The local facts below are still true, so report them rather than fail the command.
|
|
80
|
+
info(`User: unknown (could not ask ${host}: ${describeError(error)})`);
|
|
81
|
+
// The server turning our access token away means the session is not usable.
|
|
82
|
+
if (error instanceof AuthRequiredError)
|
|
83
|
+
context.exitCode = 3;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
info(`User: unknown (could not refresh your session: ${problem})`);
|
|
88
|
+
}
|
|
89
|
+
const expiresAt = await getExpiresAt(host);
|
|
90
|
+
if (expiresAt !== undefined) {
|
|
91
|
+
info(`Access token expires: ${new Date(expiresAt * 1000).toISOString()}`);
|
|
92
|
+
}
|
|
93
|
+
const orgId = await getOrgId(host);
|
|
94
|
+
info(orgId ? `Organisation: ${orgId}` : "No organisation selected. Run `throng org use <slug-or-id>`.");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { graphql } from "../graphql.js";
|
|
2
|
+
import { setOrgId } from "../oauth/session.js";
|
|
3
|
+
import { info, printJson, printTable } from "../output.js";
|
|
4
|
+
// The server's default page is 10; 250 is the most it serves per page. Asking for the
|
|
5
|
+
// maximum is what lets `org use` reach any organisation without paginating.
|
|
6
|
+
const MY_ORGANISATIONS = "{ myOrganisations(limit: 250) { hasNextPage results { id name slug } } }";
|
|
7
|
+
/**
|
|
8
|
+
* Deliberately sent without `x-throng-organisation`. This query is how a user
|
|
9
|
+
* recovers from a stale stored organisation, and a header naming an
|
|
10
|
+
* organisation they have left would be refused for this query too.
|
|
11
|
+
*/
|
|
12
|
+
async function myOrganisations(host) {
|
|
13
|
+
const data = await graphql(host, MY_ORGANISATIONS);
|
|
14
|
+
return data.myOrganisations;
|
|
15
|
+
}
|
|
16
|
+
export function registerOrg(program, ctx) {
|
|
17
|
+
const org = program.command("org").description("List and select organisations");
|
|
18
|
+
org
|
|
19
|
+
.command("list")
|
|
20
|
+
.description("List the organisations you belong to")
|
|
21
|
+
.action(async () => {
|
|
22
|
+
const { host, json } = ctx();
|
|
23
|
+
const page = await myOrganisations(host);
|
|
24
|
+
if (json) {
|
|
25
|
+
printJson(page.results);
|
|
26
|
+
}
|
|
27
|
+
else if (page.results.length === 0) {
|
|
28
|
+
info("No organisations.");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
printTable(page.results, ["id", "name", "slug"]);
|
|
32
|
+
}
|
|
33
|
+
if (page.hasNextPage)
|
|
34
|
+
info("More organisations exist than are listed here.");
|
|
35
|
+
});
|
|
36
|
+
org
|
|
37
|
+
.command("use")
|
|
38
|
+
.argument("<slug-or-id>", "organisation slug or id")
|
|
39
|
+
.description("Select the organisation later commands act in")
|
|
40
|
+
.action(async (slugOrId) => {
|
|
41
|
+
const { host } = ctx();
|
|
42
|
+
const page = await myOrganisations(host);
|
|
43
|
+
// An id match wins over a slug match, whatever the list order.
|
|
44
|
+
const match = page.results.find((o) => o.id === slugOrId) ?? page.results.find((o) => o.slug === slugOrId);
|
|
45
|
+
if (!match) {
|
|
46
|
+
const more = page.hasNextPage ? " (only the first page of your organisations was searched)" : "";
|
|
47
|
+
throw new Error(`No organisation with slug or id "${slugOrId}" among yours${more}. Run \`throng org list\` to see them.`);
|
|
48
|
+
}
|
|
49
|
+
await setOrgId(host, match.id);
|
|
50
|
+
info(`Using organisation ${match.name} (${match.slug}).`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getAccessToken } from "../oauth/session.js";
|
|
2
|
+
/**
|
|
3
|
+
* `throng token`: print a valid access token. Stdout carries the token and a
|
|
4
|
+
* newline and nothing else, ever. It is meant for `$(throng token)` and pipes.
|
|
5
|
+
*/
|
|
6
|
+
export function registerToken(program, ctx) {
|
|
7
|
+
program
|
|
8
|
+
.command("token")
|
|
9
|
+
.description("Print a valid access token on stdout (refreshing it if needed)")
|
|
10
|
+
.action(async () => {
|
|
11
|
+
const token = await getAccessToken(ctx().host);
|
|
12
|
+
process.stdout.write(`${token}\n`);
|
|
13
|
+
});
|
|
14
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const DEFAULT_HOST = "https://app.throng.dev";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the Throng host: `--host` flag, then `THRONG_HOST`, then the default.
|
|
4
|
+
*
|
|
5
|
+
* Returns `origin + path` with no trailing slash, so callers can append paths
|
|
6
|
+
* (`/api/graphql`) and one host maps to exactly one credential record. A
|
|
7
|
+
* subpath is kept (reverse-proxy mounts); a query, fragment or userinfo is
|
|
8
|
+
* rejected rather than silently dropped.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveHost(flag, env) {
|
|
11
|
+
// `??` for the flag: an explicitly empty `--host ""` is a mistake (an unset shell
|
|
12
|
+
// variable, say) and must fail validation, never quietly fall through to production.
|
|
13
|
+
// `||` for the environment: an empty variable conventionally means unset.
|
|
14
|
+
const raw = flag ?? (env.THRONG_HOST || DEFAULT_HOST);
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Never echo the host back: even a malformed one may contain a password.
|
|
21
|
+
throw new Error(`Invalid host: expected an absolute http(s) URL such as ${DEFAULT_HOST}`);
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
24
|
+
throw new Error(`Invalid host: expected an absolute http(s) URL such as ${DEFAULT_HOST}, got protocol "${url.protocol}"`);
|
|
25
|
+
}
|
|
26
|
+
// As above, nothing from the host is echoed in any of these messages.
|
|
27
|
+
if (url.username || url.password) {
|
|
28
|
+
throw new Error("Invalid host: it must not contain embedded credentials (user:password@)");
|
|
29
|
+
}
|
|
30
|
+
if (url.search) {
|
|
31
|
+
throw new Error("Invalid host: it must not contain a query string (?...)");
|
|
32
|
+
}
|
|
33
|
+
if (url.hash) {
|
|
34
|
+
throw new Error("Invalid host: it must not contain a fragment (#...)");
|
|
35
|
+
}
|
|
36
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
37
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const LOCK_RETRY_MS = 50;
|
|
6
|
+
/**
|
|
7
|
+
* How long to wait for the lock. This must comfortably exceed the token
|
|
8
|
+
* request timeout plus a persist: a waiter that gives up while a refresh is
|
|
9
|
+
* still in flight invites the user to delete the lock, and the next process
|
|
10
|
+
* would then replay the not-yet-persisted refresh token.
|
|
11
|
+
*/
|
|
12
|
+
export const LOCK_TIMEOUT_MS = 30_000;
|
|
13
|
+
/** Another process held the credentials lock for the whole wait. */
|
|
14
|
+
export class LockTimeoutError extends Error {
|
|
15
|
+
name = "LockTimeoutError";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `${XDG_CONFIG_HOME:-~/.config}/throng/credentials.json`.
|
|
19
|
+
*
|
|
20
|
+
* Every function here resolves the path from `process.env` when it is called,
|
|
21
|
+
* never at module load, so a changed environment is always honoured.
|
|
22
|
+
*/
|
|
23
|
+
export function credentialsPath(env = process.env) {
|
|
24
|
+
const configHome = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
25
|
+
return join(configHome, "throng", "credentials.json");
|
|
26
|
+
}
|
|
27
|
+
function lockPathFor(file) {
|
|
28
|
+
return `${file}.lock`;
|
|
29
|
+
}
|
|
30
|
+
function errorCode(err) {
|
|
31
|
+
return err.code;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whole-file read. Only a missing file (ENOENT) or unparseable JSON counts as
|
|
35
|
+
* "nothing stored"; any other failure (EACCES, EISDIR, EIO, ...) is rethrown.
|
|
36
|
+
* Swallowing those would let a read-modify-write replace a file that still
|
|
37
|
+
* holds valid refresh tokens with one that holds only the new record.
|
|
38
|
+
*/
|
|
39
|
+
async function readAll(file) {
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
text = await readFile(file, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (errorCode(err) === "ENOENT")
|
|
46
|
+
return {};
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err instanceof SyntaxError)
|
|
55
|
+
return {};
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
59
|
+
? parsed
|
|
60
|
+
: {};
|
|
61
|
+
}
|
|
62
|
+
function isHostCredentials(value) {
|
|
63
|
+
if (typeof value !== "object" || value === null)
|
|
64
|
+
return false;
|
|
65
|
+
const v = value;
|
|
66
|
+
return (typeof v.refresh_token === "string" &&
|
|
67
|
+
typeof v.access_token === "string" &&
|
|
68
|
+
typeof v.expires_at === "number" &&
|
|
69
|
+
(v.org_id === undefined || typeof v.org_id === "string"));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Write to a private temp file, then rename over the target. Rename is atomic
|
|
73
|
+
* within a filesystem, so a crash leaves either the old file or the new one,
|
|
74
|
+
* never a truncated one. The temp file is created 0600 so the secret is never
|
|
75
|
+
* briefly readable by others.
|
|
76
|
+
*/
|
|
77
|
+
async function writeAll(file, data) {
|
|
78
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
79
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
80
|
+
try {
|
|
81
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
82
|
+
await rename(tmp, file);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
await unlink(tmp).catch(() => { });
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function readCredentials(host) {
|
|
90
|
+
const all = await readAll(credentialsPath(process.env));
|
|
91
|
+
if (!Object.hasOwn(all, host))
|
|
92
|
+
return null;
|
|
93
|
+
const record = all[host];
|
|
94
|
+
// A hand-edited or damaged record is the same as being signed out.
|
|
95
|
+
return isHostCredentials(record) ? record : null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Merge one host's record into the file, leaving other hosts untouched. This
|
|
99
|
+
* is a read-modify-write: callers that can run concurrently must hold
|
|
100
|
+
* `withLock`.
|
|
101
|
+
*/
|
|
102
|
+
export async function writeCredentials(host, creds) {
|
|
103
|
+
const file = credentialsPath(process.env);
|
|
104
|
+
const all = await readAll(file);
|
|
105
|
+
all[host] = creds;
|
|
106
|
+
await writeAll(file, all);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Remove one host's record. A no-op when that host has nothing stored. Like
|
|
110
|
+
* `writeCredentials`, a read-modify-write: callers that can run concurrently
|
|
111
|
+
* must hold `withLock`.
|
|
112
|
+
*/
|
|
113
|
+
export async function deleteCredentials(host) {
|
|
114
|
+
const file = credentialsPath(process.env);
|
|
115
|
+
const all = await readAll(file);
|
|
116
|
+
if (!Object.hasOwn(all, host))
|
|
117
|
+
return;
|
|
118
|
+
delete all[host];
|
|
119
|
+
await writeAll(file, all);
|
|
120
|
+
}
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Run `fn` while holding an exclusive lock on the credentials file.
|
|
126
|
+
*
|
|
127
|
+
* Refresh tokens rotate on every use and the server treats a replayed one as
|
|
128
|
+
* theft, so two concurrent invocations must never both refresh. One lock
|
|
129
|
+
* guards the whole file. Created with `open(..., "wx")`, which fails with
|
|
130
|
+
* EEXIST if another process holds it, and stamped with a per-call token.
|
|
131
|
+
*/
|
|
132
|
+
export async function withLock(fn) {
|
|
133
|
+
const lockPath = lockPathFor(credentialsPath(process.env));
|
|
134
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
135
|
+
// Identifies this holder, so release never removes a lock someone else
|
|
136
|
+
// took after ours was deleted (e.g. by a user following the timeout error).
|
|
137
|
+
const token = randomUUID();
|
|
138
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
139
|
+
for (;;) {
|
|
140
|
+
try {
|
|
141
|
+
const handle = await open(lockPath, "wx", 0o600);
|
|
142
|
+
try {
|
|
143
|
+
await handle.writeFile(token);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
await unlink(lockPath).catch(() => { });
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (errorCode(err) !== "EEXIST")
|
|
156
|
+
throw err;
|
|
157
|
+
// A holder that died without releasing (Ctrl-C does not run `finally`) leaves a
|
|
158
|
+
// lock nothing will ever remove. Past the point where we would tell the user to
|
|
159
|
+
// delete it by hand, delete it ourselves and try again straight away.
|
|
160
|
+
if (await removeIfAbandoned(lockPath))
|
|
161
|
+
continue;
|
|
162
|
+
if (Date.now() >= deadline) {
|
|
163
|
+
throw new LockTimeoutError(`Another throng command is probably refreshing your session. Wait a moment and try again. If no other throng command is running, delete ${lockPath} and retry.`);
|
|
164
|
+
}
|
|
165
|
+
await sleep(LOCK_RETRY_MS);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return await fn();
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
await releaseLock(lockPath, token);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Remove a lock file whose mtime is older than `LOCK_TIMEOUT_MS`, and report
|
|
177
|
+
* whether it did. Safe for a live holder: no refresh outlives that age, and
|
|
178
|
+
* `releaseLock` only unlinks a lock that still carries its own token, so a
|
|
179
|
+
* holder whose lock was stolen cannot remove the thief's.
|
|
180
|
+
*/
|
|
181
|
+
async function removeIfAbandoned(lockPath) {
|
|
182
|
+
try {
|
|
183
|
+
const { mtimeMs } = await stat(lockPath);
|
|
184
|
+
if (Date.now() - mtimeMs <= LOCK_TIMEOUT_MS)
|
|
185
|
+
return false;
|
|
186
|
+
await unlink(lockPath);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Released between our failed create and this look: nothing left to wait for.
|
|
191
|
+
if (errorCode(err) === "ENOENT")
|
|
192
|
+
return true;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Remove the lock only if it is still ours. A lock that is already gone is
|
|
198
|
+
* fine; any other failure is surfaced, since swallowing it would leave a lock
|
|
199
|
+
* that nothing can release.
|
|
200
|
+
*/
|
|
201
|
+
async function releaseLock(lockPath, token) {
|
|
202
|
+
try {
|
|
203
|
+
if ((await readFile(lockPath, "utf8")) !== token)
|
|
204
|
+
return;
|
|
205
|
+
await unlink(lockPath);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (errorCode(err) !== "ENOENT")
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The command that starts a sign-in, quoted in every message that tells the user to sign in. */
|
|
2
|
+
export const SIGN_IN_COMMAND = "throng auth login";
|
|
3
|
+
/** The user has no usable session for the host and must sign in again. */
|
|
4
|
+
export class AuthRequiredError extends Error {
|
|
5
|
+
name = "AuthRequiredError";
|
|
6
|
+
}
|