@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 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
+ }