gt 2.21.5 → 2.22.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/CHANGELOG.md +32 -0
- package/dist/api/collectUserEditDiffs.js +1 -1
- package/dist/auth/callbackPage.d.ts +1 -0
- package/dist/auth/callbackPage.js +77 -0
- package/dist/auth/callbackPage.js.map +1 -0
- package/dist/auth/credentialStore.d.ts +15 -0
- package/dist/auth/credentialStore.js +107 -0
- package/dist/auth/credentialStore.js.map +1 -0
- package/dist/auth/errors.d.ts +7 -0
- package/dist/auth/errors.js +23 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth/loopback.d.ts +9 -0
- package/dist/auth/loopback.js +73 -0
- package/dist/auth/loopback.js.map +1 -0
- package/dist/auth/loopback.test.d.ts +1 -0
- package/dist/auth/oauth.d.ts +39 -0
- package/dist/auth/oauth.js +260 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/bin/bin-entry.js +2 -10
- package/dist/bin/bin-entry.js.map +1 -1
- package/dist/cli/base.d.ts +4 -8
- package/dist/cli/base.js +119 -60
- package/dist/cli/base.js.map +1 -1
- package/dist/cli/commands/api.js +6 -2
- package/dist/cli/commands/api.js.map +1 -1
- package/dist/cli/commands/upload.js +2 -2
- package/dist/cli/commands/upload.js.map +1 -1
- package/dist/cli/commands/utils/validation.d.ts +7 -7
- package/dist/cli/commands/utils/validation.js +7 -20
- package/dist/cli/commands/utils/validation.js.map +1 -1
- package/dist/config/generateSettings.js +4 -9
- package/dist/config/generateSettings.js.map +1 -1
- package/dist/config/optionPresets.js +16 -6
- package/dist/config/optionPresets.js.map +1 -1
- package/dist/config/validateSettings.d.ts +4 -2
- package/dist/config/validateSettings.js +9 -0
- package/dist/config/validateSettings.js.map +1 -1
- package/dist/console/index.d.ts +0 -2
- package/dist/console/index.js +2 -4
- package/dist/console/index.js.map +1 -1
- package/dist/console/inkPrompts.js +8 -6
- package/dist/console/inkPrompts.js.map +1 -1
- package/dist/console/inkTypes.d.ts +3 -0
- package/dist/console/logger.d.ts +13 -0
- package/dist/console/logger.js +60 -20
- package/dist/console/logger.js.map +1 -1
- package/dist/console/logging.d.ts +5 -3
- package/dist/console/logging.js +8 -6
- package/dist/console/logging.js.map +1 -1
- package/dist/console/promptParsing.d.ts +4 -3
- package/dist/console/promptParsing.js +7 -7
- package/dist/console/promptParsing.js.map +1 -1
- package/dist/formats/files/aggregateFiles.js +4 -4
- package/dist/formats/files/aggregateFiles.js.map +1 -1
- package/dist/formats/files/localeContent.js +2 -2
- package/dist/formats/files/localeContent.js.map +1 -1
- package/dist/formats/files/transformFormat.d.ts +2 -0
- package/dist/formats/files/transformFormat.js +8 -2
- package/dist/formats/files/transformFormat.js.map +1 -1
- package/dist/formats/json/extractJson.js +3 -3
- package/dist/formats/json/extractJson.js.map +1 -1
- package/dist/formats/json/mergeJson.js +6 -6
- package/dist/formats/json/mergeJson.js.map +1 -1
- package/dist/formats/xcstrings/mergeXcstrings.js +2 -2
- package/dist/formats/xcstrings/mergeXcstrings.js.map +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/generated/version.js.map +1 -1
- package/dist/git/setupMergeDrivers.js +1 -1
- package/dist/git/setupMergeDrivers.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -10
- package/dist/main.js.map +1 -1
- package/dist/react/jsx/utils/jsxParsing/addGTIdentifierToSyntaxTree.js +1 -1
- package/dist/react/jsx/utils/jsxParsing/parseJsx.js +1 -1
- package/dist/react/jsx/utils/parseString.js +1 -1
- package/dist/react/jsx/utils/stringParsing/derivation/index.js +1 -1
- package/dist/setup/developmentCredentials.d.ts +8 -0
- package/dist/setup/developmentCredentials.js +82 -0
- package/dist/setup/developmentCredentials.js.map +1 -0
- package/dist/setup/setupViteSPA.js +1 -1
- package/dist/setup/setupViteSPA.js.map +1 -1
- package/dist/setup/userInput.d.ts +1 -0
- package/dist/setup/userInput.js +7 -2
- package/dist/setup/userInput.js.map +1 -1
- package/dist/translation/extractInline.js +1 -1
- package/dist/translation/extractInline.js.map +1 -1
- package/dist/types/index.d.ts +4 -1
- package/dist/utils/api.d.ts +146 -67
- package/dist/utils/api.js +5 -94
- package/dist/utils/api.js.map +1 -1
- package/dist/utils/credentials.d.ts +37 -10
- package/dist/utils/credentials.js +267 -58
- package/dist/utils/credentials.js.map +1 -1
- package/dist/utils/loadEnv.d.ts +2 -0
- package/dist/utils/loadEnv.js +24 -0
- package/dist/utils/loadEnv.js.map +1 -0
- package/dist/utils/reactPackageCompatibility.js +1 -1
- package/dist/utils/reactPackageCompatibility.js.map +1 -1
- package/dist/workflows/steps/PollJobsStep.js +40 -53
- package/dist/workflows/steps/PollJobsStep.js.map +1 -1
- package/dist/workflows/utils/syncFonts.js +2 -2
- package/dist/workflows/utils/syncFonts.js.map +1 -1
- package/package.json +8 -7
- package/dist/next/config/parseNextConfig.d.ts +0 -10
- package/dist/next/config/parseNextConfig.js +0 -34
- package/dist/next/config/parseNextConfig.js.map +0 -1
- package/dist/utils/fetch.d.ts +0 -11
- package/dist/utils/fetch.js +0 -24
- package/dist/utils/fetch.js.map +0 -1
- package/dist/utils/gt.d.ts +0 -2
- package/dist/utils/gt.js +0 -7
- package/dist/utils/gt.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# gtx-cli
|
|
2
2
|
|
|
3
|
+
## 2.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`5382de5`](https://github.com/generaltranslation/gt/commit/5382de503a626beb394e01cad92882b78c2a0aa8) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Add `gt api-key create --name <name> --permission <permissions...>`. It creates an API key for the configured project (`--project-id`, `gt.config.json`, or `GT_PROJECT_ID`) as the signed-in user or with an explicit `--api-key`/`GT_API_KEY`, and prints the new key to stdout exactly once while all other output (project ID, warnings, errors) goes to stderr, so `gt api-key create ... > key.txt` captures only the key; it never writes `.env` files. Both the name and at least one permission are required, permissions must be from the project API key permission set (for example `project:translations:generate`), and the server grants the requested set all-or-nothing.
|
|
8
|
+
|
|
9
|
+
Remove `gt auth`, which opened the dashboard wizard to save a project ID and development key to `.env.local`. Use `gt init` for that instead: it signs you in, picks or creates the project, and saves the same variables without the browser wizard. The CLI no longer calls the deprecated wizard session routes; `gt api` can still reach every route.
|
|
10
|
+
|
|
11
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`87fa5b1`](https://github.com/generaltranslation/gt/commit/87fa5b17cc2dcd265bc96ab77259e4bc4a6c987f) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - `gt auth` and the `gt init` wizard no longer ask for a key type; `-t/--key-type` is removed. There is one kind of project API key, and its permissions are checked by the server. The wizard now saves only the project ID and the framework-prefixed `GT_DEV_API_KEY` that enables hot reload and runtime translation in development; it no longer writes `GT_API_KEY`, which belongs in your hosting environment (production runtime translation) or CI secrets, and both commands print that guidance. `gt init` signs you in (`gt login`) first when no API key is configured, so the CLI translates as you. On the `GT` and `GTRuntime` constructors, `devApiKey` is deprecated in favor of `apiKey`.
|
|
12
|
+
|
|
13
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`c734b08`](https://github.com/generaltranslation/gt/commit/c734b084f0a50d1b2787c86ec790403f88803650) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Add `gt login`, `gt logout`, and `gt whoami`. Login uses openid-client with the seeded public `gt-cli` client, S256 PKCE and a loopback callback against the General Translation dashboard. When `--no-browser` is set or the loopback listener cannot bind, login uses device authorization with a displayed verification code. Device polling and retries follow openid-client policy (not blanket network/all-5xx retries). Signed identity tokens are validated before saving per-server credentials in `$XDG_STATE_HOME/gt/credentials.json` (default `~/.local/state/gt/credentials.json`, 0600); refresh preserves the validated account identity. Obsolete pre-release logins require `gt login` again without resetting other servers' entries. Login requests a token for the project's configured `baseUrl` (`GT_API_URL` overrides), records that resource with the credentials, and the CLI refuses to send a login issued for a different API instead of letting the server reject it. The API client and core request path accept a refreshable user-token provider, used only when no API key is configured — an explicit API key always wins. The former development-key rejection is gone; every `gtx-` key is just a key with its own permissions.
|
|
14
|
+
|
|
15
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`052d499`](https://github.com/generaltranslation/gt/commit/052d499556f3dcf7c6801d2ba91399783608e70b) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - `gt init` sets up development credentials without the dashboard wizard. After signing in (or with an explicit `GT_API_KEY`), it lists the projects you can access and lets you pick one, or creates a new project in an organization where you can create projects. It then mints a development key limited to `project:translations:generate` and saves the project ID and that key to `.env.local` under the names your framework expects (for example `NEXT_PUBLIC_`, `VITE_`, `GATSBY_`, `REACT_APP_`, `REDWOOD_ENV_`). Existing `.env.local` lines, comments, and any `GT_API_KEY` are preserved; rerunning replaces only those two variables. A project ID already in `gt.config.json` or the environment is used directly, and setup is skipped when a runtime key for it is already configured. `--config` and `--src` now reach the config written by `gt init`. The shared API adapter gains paginated `listProjects` and `listOrgs` plus `createProjectApiKey`.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`78e3a57`](https://github.com/generaltranslation/gt/commit/78e3a57c19caffe8fbce2e9bc74e573071d662e8) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Expose diagnostics through the public `generaltranslation/diagnostics` subpath and fail fast with a diagnostic when an unsupported model provider would otherwise be forwarded and rejected by the server. Migrate the CLI to the diagnostics subpath and API version export, and report invalid model-provider settings with the validation diagnostic.
|
|
20
|
+
|
|
21
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`370c31f`](https://github.com/generaltranslation/gt/commit/370c31f975cd1d2bfa9609ea1827c2900f812665) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Adopt the enriched OpenAPI contract types for runtime translations, download metadata, and action types.
|
|
22
|
+
|
|
23
|
+
- [#2331](https://github.com/generaltranslation/gt/pull/2331) [`f6d1bae`](https://github.com/generaltranslation/gt/commit/f6d1bae8489ef37377bd45ec27f60e05e1f152d3) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Mintlify presets now translate footer and navbar text (`header`, `label`) in per-language navigation entries, and the `mintlify` preset localizes group `root` pages.
|
|
24
|
+
|
|
25
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`08e6d82`](https://github.com/generaltranslation/gt/commit/08e6d82df8f02045f43bfcc87b35e9a4ae4a9f69) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Expose successful job-poll responses through an `onPoll` hook. Route CLI font and wizard requests through the generated SDK, use the shared sequential job poller, and remove the retired hand-written HTTP client implementations.
|
|
26
|
+
|
|
27
|
+
- [#2205](https://github.com/generaltranslation/gt/pull/2205) [`abc7fea`](https://github.com/generaltranslation/gt/commit/abc7feaeb8be41709178d34db550d7af36c809af) Thanks [@chenxin-yan](https://github.com/chenxin-yan)! - Consolidate CLI and Sanity request construction on the shared core API adapter, which now owns `createProject` and exposes raw project-info and job-status loaders for compatibility facades.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [[`87fa5b1`](https://github.com/generaltranslation/gt/commit/87fa5b17cc2dcd265bc96ab77259e4bc4a6c987f), [`78e3a57`](https://github.com/generaltranslation/gt/commit/78e3a57c19caffe8fbce2e9bc74e573071d662e8), [`0e87e42`](https://github.com/generaltranslation/gt/commit/0e87e42ac5d93a1c9ee1f5993d074d4c9a4ae387), [`370c31f`](https://github.com/generaltranslation/gt/commit/370c31f975cd1d2bfa9609ea1827c2900f812665), [`c734b08`](https://github.com/generaltranslation/gt/commit/c734b084f0a50d1b2787c86ec790403f88803650), [`322e927`](https://github.com/generaltranslation/gt/commit/322e9279b7efb17faf220e626a5a3696efc5d863), [`052d499`](https://github.com/generaltranslation/gt/commit/052d499556f3dcf7c6801d2ba91399783608e70b), [`d5649a4`](https://github.com/generaltranslation/gt/commit/d5649a4d9711c3f752a1706348398935bf44e8e1), [`abc7fea`](https://github.com/generaltranslation/gt/commit/abc7feaeb8be41709178d34db550d7af36c809af)]:
|
|
30
|
+
- generaltranslation@9.5.0
|
|
31
|
+
- @generaltranslation/python-extractor@0.2.54
|
|
32
|
+
- @generaltranslation/supported-locales@2.1.34
|
|
33
|
+
- @generaltranslation/vue-extractor@0.1.14
|
|
34
|
+
|
|
3
35
|
## 2.21.5
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
|
@@ -13,8 +13,8 @@ import { extractYaml } from "../formats/yaml/extractYaml.js";
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import * as path$1 from "node:path";
|
|
15
15
|
import * as fs$1 from "node:fs";
|
|
16
|
-
import { isBinaryFileFormat } from "generaltranslation/types";
|
|
17
16
|
import os from "node:os";
|
|
17
|
+
import { isBinaryFileFormat } from "generaltranslation/types";
|
|
18
18
|
//#region src/api/collectUserEditDiffs.ts
|
|
19
19
|
/**
|
|
20
20
|
* The bytes a file's pipeline content stands for: binary formats carry base64,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderCallbackPage(): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region src/auth/callbackPage.ts
|
|
2
|
+
const STYLES = `
|
|
3
|
+
:root {
|
|
4
|
+
color-scheme: light dark;
|
|
5
|
+
--background: #fff;
|
|
6
|
+
--foreground: #09090b;
|
|
7
|
+
--muted-foreground: #71717a;
|
|
8
|
+
--success: #1cca5b;
|
|
9
|
+
}
|
|
10
|
+
@media (prefers-color-scheme: dark) {
|
|
11
|
+
:root {
|
|
12
|
+
--background: #09090b;
|
|
13
|
+
--foreground: #fafafa;
|
|
14
|
+
--muted-foreground: #a1a1aa;
|
|
15
|
+
--success: #387836;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; margin: 0; }
|
|
19
|
+
html, body { height: 100%; }
|
|
20
|
+
body {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
padding: 48px 24px;
|
|
25
|
+
background: var(--background);
|
|
26
|
+
color: var(--foreground);
|
|
27
|
+
font: 14px/20px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
28
|
+
-webkit-font-smoothing: antialiased;
|
|
29
|
+
}
|
|
30
|
+
main {
|
|
31
|
+
width: 100%;
|
|
32
|
+
max-width: 400px;
|
|
33
|
+
text-align: center;
|
|
34
|
+
}
|
|
35
|
+
svg {
|
|
36
|
+
display: block;
|
|
37
|
+
width: 48px;
|
|
38
|
+
height: 48px;
|
|
39
|
+
margin: 0 auto 16px;
|
|
40
|
+
}
|
|
41
|
+
.success { color: var(--success); }
|
|
42
|
+
h1 {
|
|
43
|
+
font-size: 18px;
|
|
44
|
+
line-height: 28px;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
letter-spacing: -0.01em;
|
|
47
|
+
margin-bottom: 8px;
|
|
48
|
+
}
|
|
49
|
+
p { color: var(--muted-foreground); }
|
|
50
|
+
`;
|
|
51
|
+
const page = {
|
|
52
|
+
title: "Sign-in response received",
|
|
53
|
+
icon: "<svg class=\"success\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"m9 12 2 2 4-4\"/></svg>",
|
|
54
|
+
body: "Return to your terminal to check whether sign in completed. You can close this window."
|
|
55
|
+
};
|
|
56
|
+
function renderCallbackPage() {
|
|
57
|
+
return `<!doctype html>
|
|
58
|
+
<html lang="en">
|
|
59
|
+
<head>
|
|
60
|
+
<meta charset="utf-8">
|
|
61
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
62
|
+
<title>${page.title}</title>
|
|
63
|
+
<style>${STYLES}</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<main>
|
|
67
|
+
${page.icon}
|
|
68
|
+
<h1>${page.title}</h1>
|
|
69
|
+
<p>${page.body}</p>
|
|
70
|
+
</main>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { renderCallbackPage };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=callbackPage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"callbackPage.js","names":[],"sources":["../../src/auth/callbackPage.ts"],"sourcesContent":["// Mirrors the terminal state of the dashboard's device-approval screen\n// (gt-cloud apps/dashboard/.../signin/device/_components/DeviceApproval.tsx):\n// a 48px lucide status icon, 18px semibold heading, 14px muted body, centered\n// in a 400px column. Tokens are copied from gt-cloud packages/ui shared.css\n// because this page is served from 127.0.0.1 with no external assets.\nconst STYLES = `\n :root {\n color-scheme: light dark;\n --background: #fff;\n --foreground: #09090b;\n --muted-foreground: #71717a;\n --success: #1cca5b;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --background: #09090b;\n --foreground: #fafafa;\n --muted-foreground: #a1a1aa;\n --success: #387836;\n }\n }\n * { box-sizing: border-box; margin: 0; }\n html, body { height: 100%; }\n body {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 48px 24px;\n background: var(--background);\n color: var(--foreground);\n font: 14px/20px Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n -webkit-font-smoothing: antialiased;\n }\n main {\n width: 100%;\n max-width: 400px;\n text-align: center;\n }\n svg {\n display: block;\n width: 48px;\n height: 48px;\n margin: 0 auto 16px;\n }\n .success { color: var(--success); }\n h1 {\n font-size: 18px;\n line-height: 28px;\n font-weight: 600;\n letter-spacing: -0.01em;\n margin-bottom: 8px;\n }\n p { color: var(--muted-foreground); }\n`;\n\nconst CIRCLE_CHECK =\n '<svg class=\"success\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"m9 12 2 2 4-4\"/></svg>';\nconst page = {\n title: 'Sign-in response received',\n icon: CIRCLE_CHECK,\n body: 'Return to your terminal to check whether sign in completed. You can close this window.',\n};\n\nexport function renderCallbackPage(): string {\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>${page.title}</title>\n<style>${STYLES}</style>\n</head>\n<body>\n<main>\n${page.icon}\n<h1>${page.title}</h1>\n<p>${page.body}</p>\n</main>\n</body>\n</html>`;\n}\n"],"mappings":";AAKA,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDf,MAAM,OAAO;CACX,OAAO;CACP,MAAM;CACN,MAAM;CACP;AAED,SAAgB,qBAA6B;AAC3C,QAAO;;;;;SAKA,KAAK,MAAM;SACX,OAAO;;;;EAId,KAAK,KAAK;MACN,KAAK,MAAM;KACZ,KAAK,KAAK"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type OAuthTokens = {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
refreshToken: string;
|
|
5
|
+
/** API resource (origin href) the tokens were issued for. */
|
|
6
|
+
resource: string;
|
|
7
|
+
scope: string;
|
|
8
|
+
tokenType: string;
|
|
9
|
+
/** Subject from a validated, signature-checked ID token. */
|
|
10
|
+
subject: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function getCredentialsPath(): string;
|
|
13
|
+
export declare function readOAuthTokens(authBaseUrl: string): Promise<OAuthTokens | undefined>;
|
|
14
|
+
export declare function writeOAuthTokens(tokens: OAuthTokens, authBaseUrl: string): Promise<void>;
|
|
15
|
+
export declare function deleteOAuthTokens(authBaseUrl: string): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { logger } from "../console/logger.js";
|
|
2
|
+
import { UserAuthError } from "./errors.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createDiagnosticMessage } from "generaltranslation/diagnostics";
|
|
6
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
//#region src/auth/credentialStore.ts
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function parseStoredTokens(value) {
|
|
13
|
+
if (!isRecord(value) || typeof value.accessToken !== "string" || !value.accessToken || typeof value.subject !== "string" || !value.subject || typeof value.resource !== "string" || !value.resource || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt) || typeof value.refreshToken !== "string" || typeof value.scope !== "string" || typeof value.tokenType !== "string" || value.tokenType.toLowerCase() !== "bearer") throw new UserAuthError("obsolete_credentials", "This stored login is obsolete or invalid and cannot be used safely", "Run `gt login` again");
|
|
14
|
+
return {
|
|
15
|
+
accessToken: value.accessToken,
|
|
16
|
+
subject: value.subject,
|
|
17
|
+
resource: value.resource,
|
|
18
|
+
expiresAt: value.expiresAt,
|
|
19
|
+
refreshToken: value.refreshToken,
|
|
20
|
+
scope: value.scope,
|
|
21
|
+
tokenType: value.tokenType
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function getCredentialsPath() {
|
|
25
|
+
const stateHome = process.env.XDG_STATE_HOME;
|
|
26
|
+
return path.join(stateHome && path.isAbsolute(stateHome) ? stateHome : path.join(homedir(), ".local", "state"), "gt", "credentials.json");
|
|
27
|
+
}
|
|
28
|
+
async function readCredentialsFile() {
|
|
29
|
+
const credentialsPath = getCredentialsPath();
|
|
30
|
+
let contents;
|
|
31
|
+
try {
|
|
32
|
+
contents = await readFile(credentialsPath, "utf8");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isRecord(error) && error.code === "ENOENT") return {
|
|
35
|
+
version: 2,
|
|
36
|
+
servers: {}
|
|
37
|
+
};
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(contents);
|
|
42
|
+
if (!isRecord(parsed) || parsed.version !== 2 || !isRecord(parsed.servers)) throw new Error();
|
|
43
|
+
return {
|
|
44
|
+
version: 2,
|
|
45
|
+
servers: parsed.servers
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
const backupPath = `${credentialsPath}.corrupt-${Date.now()}`;
|
|
49
|
+
await rename(credentialsPath, backupPath);
|
|
50
|
+
logger.warn(createDiagnosticMessage({
|
|
51
|
+
source: "gt",
|
|
52
|
+
severity: "Warning",
|
|
53
|
+
whatHappened: "Stored OAuth credentials were invalid and have been reset",
|
|
54
|
+
reassurance: "The unreadable file was preserved",
|
|
55
|
+
details: backupPath,
|
|
56
|
+
fix: "Run `gt login` again"
|
|
57
|
+
}));
|
|
58
|
+
return {
|
|
59
|
+
version: 2,
|
|
60
|
+
servers: {}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function writeCredentialsFile(credentials) {
|
|
65
|
+
const credentialsPath = getCredentialsPath();
|
|
66
|
+
const directory = path.dirname(credentialsPath);
|
|
67
|
+
const temporaryPath = path.join(directory, `.credentials-${randomUUID()}.tmp`);
|
|
68
|
+
await mkdir(directory, {
|
|
69
|
+
recursive: true,
|
|
70
|
+
mode: 448
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(temporaryPath, `${JSON.stringify(credentials, null, 2)}\n`, {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
mode: 384
|
|
76
|
+
});
|
|
77
|
+
await rename(temporaryPath, credentialsPath);
|
|
78
|
+
await chmod(credentialsPath, 384);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await rm(temporaryPath, { force: true });
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function setServerTokens(authBaseUrl, tokens) {
|
|
85
|
+
const credentials = await readCredentialsFile();
|
|
86
|
+
if (tokens) credentials.servers[authBaseUrl] = tokens;
|
|
87
|
+
else delete credentials.servers[authBaseUrl];
|
|
88
|
+
if (Object.keys(credentials.servers).length === 0) {
|
|
89
|
+
await rm(getCredentialsPath(), { force: true });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await writeCredentialsFile(credentials);
|
|
93
|
+
}
|
|
94
|
+
async function readOAuthTokens(authBaseUrl) {
|
|
95
|
+
const value = (await readCredentialsFile()).servers[authBaseUrl];
|
|
96
|
+
return value === void 0 ? void 0 : parseStoredTokens(value);
|
|
97
|
+
}
|
|
98
|
+
async function writeOAuthTokens(tokens, authBaseUrl) {
|
|
99
|
+
await setServerTokens(authBaseUrl, tokens);
|
|
100
|
+
}
|
|
101
|
+
async function deleteOAuthTokens(authBaseUrl) {
|
|
102
|
+
await setServerTokens(authBaseUrl, void 0);
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { deleteOAuthTokens, getCredentialsPath, readOAuthTokens, writeOAuthTokens };
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=credentialStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentialStore.js","names":[],"sources":["../../src/auth/credentialStore.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport {\n chmod,\n mkdir,\n readFile,\n rename,\n rm,\n writeFile,\n} from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport path from 'node:path';\nimport { createDiagnosticMessage } from 'generaltranslation/diagnostics';\nimport { logger } from '../console/logger.js';\nimport { UserAuthError } from './errors.js';\n\nexport type OAuthTokens = {\n accessToken: string;\n expiresAt: number;\n refreshToken: string;\n /** API resource (origin href) the tokens were issued for. */\n resource: string;\n scope: string;\n tokenType: string;\n /** Subject from a validated, signature-checked ID token. */\n subject: string;\n};\n\ntype StoredCredentials = {\n version: 2;\n // Validate only the selected entry; obsolete sibling sessions must survive.\n servers: Record<string, unknown>;\n};\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction parseStoredTokens(value: unknown): OAuthTokens {\n if (\n !isRecord(value) ||\n typeof value.accessToken !== 'string' ||\n !value.accessToken ||\n typeof value.subject !== 'string' ||\n !value.subject ||\n typeof value.resource !== 'string' ||\n !value.resource ||\n typeof value.expiresAt !== 'number' ||\n !Number.isFinite(value.expiresAt) ||\n typeof value.refreshToken !== 'string' ||\n typeof value.scope !== 'string' ||\n typeof value.tokenType !== 'string' ||\n value.tokenType.toLowerCase() !== 'bearer'\n ) {\n throw new UserAuthError(\n 'obsolete_credentials',\n 'This stored login is obsolete or invalid and cannot be used safely',\n 'Run `gt login` again'\n );\n }\n return {\n accessToken: value.accessToken,\n subject: value.subject,\n resource: value.resource,\n expiresAt: value.expiresAt,\n refreshToken: value.refreshToken,\n scope: value.scope,\n tokenType: value.tokenType,\n };\n}\n\nexport function getCredentialsPath(): string {\n // XDG Base Directory spec: relative XDG_* values are invalid and ignored.\n const stateHome = process.env.XDG_STATE_HOME;\n return path.join(\n stateHome && path.isAbsolute(stateHome)\n ? stateHome\n : path.join(homedir(), '.local', 'state'),\n 'gt',\n 'credentials.json'\n );\n}\n\nasync function readCredentialsFile(): Promise<StoredCredentials> {\n const credentialsPath = getCredentialsPath();\n let contents: string;\n try {\n contents = await readFile(credentialsPath, 'utf8');\n } catch (error) {\n if (isRecord(error) && error.code === 'ENOENT')\n return { version: 2, servers: {} };\n throw error;\n }\n try {\n const parsed: unknown = JSON.parse(contents);\n if (!isRecord(parsed) || parsed.version !== 2 || !isRecord(parsed.servers))\n throw new Error();\n return { version: 2, servers: parsed.servers };\n } catch {\n const backupPath = `${credentialsPath}.corrupt-${Date.now()}`;\n await rename(credentialsPath, backupPath);\n logger.warn(\n createDiagnosticMessage({\n source: 'gt',\n severity: 'Warning',\n whatHappened:\n 'Stored OAuth credentials were invalid and have been reset',\n reassurance: 'The unreadable file was preserved',\n details: backupPath,\n fix: 'Run `gt login` again',\n })\n );\n return { version: 2, servers: {} };\n }\n}\n\nasync function writeCredentialsFile(\n credentials: StoredCredentials\n): Promise<void> {\n const credentialsPath = getCredentialsPath();\n const directory = path.dirname(credentialsPath);\n const temporaryPath = path.join(\n directory,\n `.credentials-${randomUUID()}.tmp`\n );\n await mkdir(directory, { recursive: true, mode: 0o700 });\n try {\n await writeFile(\n temporaryPath,\n `${JSON.stringify(credentials, null, 2)}\\n`,\n { encoding: 'utf8', mode: 0o600 }\n );\n await rename(temporaryPath, credentialsPath);\n await chmod(credentialsPath, 0o600);\n } catch (error) {\n await rm(temporaryPath, { force: true });\n throw error;\n }\n}\n\nasync function setServerTokens(\n authBaseUrl: string,\n tokens: OAuthTokens | undefined\n): Promise<void> {\n const credentials = await readCredentialsFile();\n if (tokens) credentials.servers[authBaseUrl] = tokens;\n else delete credentials.servers[authBaseUrl];\n if (Object.keys(credentials.servers).length === 0) {\n await rm(getCredentialsPath(), { force: true });\n return;\n }\n await writeCredentialsFile(credentials);\n}\n\nexport async function readOAuthTokens(\n authBaseUrl: string\n): Promise<OAuthTokens | undefined> {\n const value = (await readCredentialsFile()).servers[authBaseUrl];\n return value === undefined ? undefined : parseStoredTokens(value);\n}\nexport async function writeOAuthTokens(\n tokens: OAuthTokens,\n authBaseUrl: string\n): Promise<void> {\n await setServerTokens(authBaseUrl, tokens);\n}\nexport async function deleteOAuthTokens(authBaseUrl: string): Promise<void> {\n await setServerTokens(authBaseUrl, undefined);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type UserAuthErrorCode = 'login_required' | 'obsolete_credentials' | 'resource_mismatch' | 'oauth';
|
|
2
|
+
/** `message` is a complete `gt Error:` diagnostic; commands print it as-is. */
|
|
3
|
+
export declare class UserAuthError extends Error {
|
|
4
|
+
readonly code: UserAuthErrorCode;
|
|
5
|
+
constructor(code: UserAuthErrorCode, whatHappened: string, fix?: string, details?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function loginRequiredError(): UserAuthError;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createDiagnosticMessage } from "generaltranslation/diagnostics";
|
|
2
|
+
//#region src/auth/errors.ts
|
|
3
|
+
/** `message` is a complete `gt Error:` diagnostic; commands print it as-is. */
|
|
4
|
+
var UserAuthError = class extends Error {
|
|
5
|
+
constructor(code, whatHappened, fix, details) {
|
|
6
|
+
super(createDiagnosticMessage({
|
|
7
|
+
source: "gt",
|
|
8
|
+
severity: "Error",
|
|
9
|
+
whatHappened,
|
|
10
|
+
fix,
|
|
11
|
+
details
|
|
12
|
+
}));
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "UserAuthError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function loginRequiredError() {
|
|
18
|
+
return new UserAuthError("login_required", "You are not signed in", "Run `gt login` to sign in");
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { UserAuthError, loginRequiredError };
|
|
22
|
+
|
|
23
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../src/auth/errors.ts"],"sourcesContent":["import { createDiagnosticMessage } from 'generaltranslation/diagnostics';\n\nexport type UserAuthErrorCode =\n | 'login_required'\n | 'obsolete_credentials'\n | 'resource_mismatch'\n | 'oauth';\n\n/** `message` is a complete `gt Error:` diagnostic; commands print it as-is. */\nexport class UserAuthError extends Error {\n constructor(\n readonly code: UserAuthErrorCode,\n whatHappened: string,\n fix?: string,\n details?: string\n ) {\n super(\n createDiagnosticMessage({\n source: 'gt',\n severity: 'Error',\n whatHappened,\n fix,\n details,\n })\n );\n this.name = 'UserAuthError';\n }\n}\n\nexport function loginRequiredError(): UserAuthError {\n return new UserAuthError(\n 'login_required',\n 'You are not signed in',\n 'Run `gt login` to sign in'\n );\n}\n"],"mappings":";;;AASA,IAAa,gBAAb,cAAmC,MAAM;CACvC,YACE,MACA,cACA,KACA,SACA;AACA,QACE,wBAAwB;GACtB,QAAQ;GACR,UAAU;GACV;GACA;GACA;GACD,CAAC,CACH;AAbQ,OAAA,OAAA;AAcT,OAAK,OAAO;;;AAIhB,SAAgB,qBAAoC;AAClD,QAAO,IAAI,cACT,kBACA,yBACA,4BACD"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const LOOPBACK_CALLBACK_PATH = "/callback";
|
|
2
|
+
export type LoopbackServer = {
|
|
3
|
+
/** Loopback redirect URI bound to an ephemeral port, e.g. http://127.0.0.1:53211/callback */
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
/** Resolves with the raw callback parameters of the first request to the callback path. */
|
|
6
|
+
waitForCallback: (timeoutMs?: number) => Promise<URL>;
|
|
7
|
+
close: () => void;
|
|
8
|
+
};
|
|
9
|
+
export declare function startLoopbackServer(): Promise<LoopbackServer>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { renderCallbackPage } from "./callbackPage.js";
|
|
2
|
+
import { createDiagnosticMessage } from "generaltranslation/diagnostics";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
//#region src/auth/loopback.ts
|
|
5
|
+
const LOOPBACK_CALLBACK_PATH = "/callback";
|
|
6
|
+
const DEFAULT_CALLBACK_TIMEOUT_MS = 300 * 1e3;
|
|
7
|
+
function listen(server) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
server.once("error", reject);
|
|
10
|
+
server.listen(0, "127.0.0.1", () => {
|
|
11
|
+
server.off("error", reject);
|
|
12
|
+
const address = server.address();
|
|
13
|
+
if (!address || typeof address === "string") {
|
|
14
|
+
server.close();
|
|
15
|
+
reject(new Error(createDiagnosticMessage({ whatHappened: "Could not resolve the loopback callback address" })));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
resolve(address.port);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async function startLoopbackServer() {
|
|
23
|
+
const server = createServer();
|
|
24
|
+
const origin = `http://127.0.0.1:${await listen(server)}`;
|
|
25
|
+
let timeout;
|
|
26
|
+
return {
|
|
27
|
+
redirectUri: `${origin}${LOOPBACK_CALLBACK_PATH}`,
|
|
28
|
+
waitForCallback(timeoutMs = DEFAULT_CALLBACK_TIMEOUT_MS) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
timeout = setTimeout(() => {
|
|
32
|
+
settled = true;
|
|
33
|
+
server.close();
|
|
34
|
+
reject(new Error(createDiagnosticMessage({
|
|
35
|
+
whatHappened: "Timed out waiting for the browser to sign in",
|
|
36
|
+
fix: "Run `gt login` again"
|
|
37
|
+
})));
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
server.on("request", (request, response) => {
|
|
40
|
+
const target = request.url ?? "/";
|
|
41
|
+
const url = URL.canParse(target, origin) ? new URL(target, origin) : void 0;
|
|
42
|
+
if (!url || settled || request.method !== "GET" || url.origin !== origin || url.pathname !== "/callback") {
|
|
43
|
+
response.writeHead(404, {
|
|
44
|
+
"cache-control": "no-store",
|
|
45
|
+
"content-type": "text/plain; charset=utf-8"
|
|
46
|
+
});
|
|
47
|
+
response.end("Not found");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
settled = true;
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
response.writeHead(200, {
|
|
53
|
+
"cache-control": "no-store",
|
|
54
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
55
|
+
"content-type": "text/html; charset=utf-8",
|
|
56
|
+
"x-content-type-options": "nosniff"
|
|
57
|
+
});
|
|
58
|
+
response.end(renderCallbackPage());
|
|
59
|
+
server.close();
|
|
60
|
+
resolve(url);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
close() {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
server.close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { LOOPBACK_CALLBACK_PATH, startLoopbackServer };
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=loopback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loopback.js","names":[],"sources":["../../src/auth/loopback.ts"],"sourcesContent":["import { createServer, type Server } from 'node:http';\nimport { createDiagnosticMessage } from 'generaltranslation/diagnostics';\nimport { renderCallbackPage } from './callbackPage.js';\n\nexport const LOOPBACK_CALLBACK_PATH = '/callback';\nconst DEFAULT_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;\n\nexport type LoopbackServer = {\n /** Loopback redirect URI bound to an ephemeral port, e.g. http://127.0.0.1:53211/callback */\n redirectUri: string;\n /** Resolves with the raw callback parameters of the first request to the callback path. */\n waitForCallback: (timeoutMs?: number) => Promise<URL>;\n close: () => void;\n};\n\nfunction listen(server: Server): Promise<number> {\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n // RFC 8252 §7.3: bind the loopback interface on an ephemeral port; the\n // authorization server matches loopback redirect URIs ignoring the port.\n server.listen(0, '127.0.0.1', () => {\n server.off('error', reject);\n const address = server.address();\n if (!address || typeof address === 'string') {\n server.close();\n reject(\n new Error(\n createDiagnosticMessage({\n whatHappened: 'Could not resolve the loopback callback address',\n })\n )\n );\n return;\n }\n resolve(address.port);\n });\n });\n}\n\nexport async function startLoopbackServer(): Promise<LoopbackServer> {\n const server = createServer();\n const port = await listen(server);\n const origin = `http://127.0.0.1:${port}`;\n let timeout: NodeJS.Timeout | undefined;\n\n return {\n redirectUri: `${origin}${LOOPBACK_CALLBACK_PATH}`,\n waitForCallback(timeoutMs = DEFAULT_CALLBACK_TIMEOUT_MS) {\n return new Promise((resolve, reject) => {\n let settled = false;\n timeout = setTimeout(() => {\n settled = true;\n server.close();\n reject(\n new Error(\n createDiagnosticMessage({\n whatHappened: 'Timed out waiting for the browser to sign in',\n fix: 'Run `gt login` again',\n })\n )\n );\n }, timeoutMs);\n\n server.on('request', (request, response) => {\n const target = request.url ?? '/';\n const url = URL.canParse(target, origin)\n ? new URL(target, origin)\n : undefined;\n if (\n !url ||\n settled ||\n request.method !== 'GET' ||\n url.origin !== origin ||\n url.pathname !== LOOPBACK_CALLBACK_PATH\n ) {\n response.writeHead(404, {\n 'cache-control': 'no-store',\n 'content-type': 'text/plain; charset=utf-8',\n });\n response.end('Not found');\n return;\n }\n\n settled = true;\n clearTimeout(timeout);\n response.writeHead(200, {\n 'cache-control': 'no-store',\n 'content-security-policy':\n \"default-src 'none'; style-src 'unsafe-inline'\",\n 'content-type': 'text/html; charset=utf-8',\n 'x-content-type-options': 'nosniff',\n });\n response.end(renderCallbackPage());\n server.close();\n resolve(url);\n });\n });\n },\n close() {\n clearTimeout(timeout);\n server.close();\n },\n };\n}\n"],"mappings":";;;;AAIA,MAAa,yBAAyB;AACtC,MAAM,8BAA8B,MAAS;AAU7C,SAAS,OAAO,QAAiC;AAC/C,QAAO,IAAI,SAAS,SAAS,WAAW;AACtC,SAAO,KAAK,SAAS,OAAO;AAG5B,SAAO,OAAO,GAAG,mBAAmB;AAClC,UAAO,IAAI,SAAS,OAAO;GAC3B,MAAM,UAAU,OAAO,SAAS;AAChC,OAAI,CAAC,WAAW,OAAO,YAAY,UAAU;AAC3C,WAAO,OAAO;AACd,WACE,IAAI,MACF,wBAAwB,EACtB,cAAc,mDACf,CAAC,CACH,CACF;AACD;;AAEF,WAAQ,QAAQ,KAAK;IACrB;GACF;;AAGJ,eAAsB,sBAA+C;CACnE,MAAM,SAAS,cAAc;CAE7B,MAAM,SAAS,oBAAoB,MADhB,OAAO,OAAO;CAEjC,IAAI;AAEJ,QAAO;EACL,aAAa,GAAG,SAAS;EACzB,gBAAgB,YAAY,6BAA6B;AACvD,UAAO,IAAI,SAAS,SAAS,WAAW;IACtC,IAAI,UAAU;AACd,cAAU,iBAAiB;AACzB,eAAU;AACV,YAAO,OAAO;AACd,YACE,IAAI,MACF,wBAAwB;MACtB,cAAc;MACd,KAAK;MACN,CAAC,CACH,CACF;OACA,UAAU;AAEb,WAAO,GAAG,YAAY,SAAS,aAAa;KAC1C,MAAM,SAAS,QAAQ,OAAO;KAC9B,MAAM,MAAM,IAAI,SAAS,QAAQ,OAAO,GACpC,IAAI,IAAI,QAAQ,OAAO,GACvB,KAAA;AACJ,SACE,CAAC,OACD,WACA,QAAQ,WAAW,SACnB,IAAI,WAAW,UACf,IAAI,aAAA,aACJ;AACA,eAAS,UAAU,KAAK;OACtB,iBAAiB;OACjB,gBAAgB;OACjB,CAAC;AACF,eAAS,IAAI,YAAY;AACzB;;AAGF,eAAU;AACV,kBAAa,QAAQ;AACrB,cAAS,UAAU,KAAK;MACtB,iBAAiB;MACjB,2BACE;MACF,gBAAgB;MAChB,0BAA0B;MAC3B,CAAC;AACF,cAAS,IAAI,oBAAoB,CAAC;AAClC,YAAO,OAAO;AACd,aAAQ,IAAI;MACZ;KACF;;EAEJ,QAAQ;AACN,gBAAa,QAAQ;AACrB,UAAO,OAAO;;EAEjB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { UserTokenProvider } from 'generaltranslation/api';
|
|
2
|
+
import type { OAuthTokens } from './credentialStore.js';
|
|
3
|
+
export type DeviceCode = {
|
|
4
|
+
userCode: string;
|
|
5
|
+
verificationUri: string;
|
|
6
|
+
verificationUriComplete?: string;
|
|
7
|
+
};
|
|
8
|
+
export type UserInfo = {
|
|
9
|
+
email?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
sub: string;
|
|
12
|
+
};
|
|
13
|
+
type OAuthRequestOptions = {
|
|
14
|
+
authBaseUrl?: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
};
|
|
17
|
+
export type UserTokenProviderOptions = OAuthRequestOptions & {
|
|
18
|
+
/** API the requests go to; must match the resource the login was issued for. */
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
};
|
|
21
|
+
export type LoginOptions = OAuthRequestOptions & {
|
|
22
|
+
/** API the token is issued for; GT_API_URL overrides, then the public API. */
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
noBrowser?: boolean;
|
|
25
|
+
onDeviceCode?: (deviceCode: DeviceCode) => void;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
onAuthorizationUrl?: (url: string) => void;
|
|
28
|
+
openBrowser?: (url: string) => Promise<unknown>;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
};
|
|
31
|
+
/** Browser S256/loopback, or device login for --no-browser and bind failure. */
|
|
32
|
+
export declare function login(options?: LoginOptions): Promise<OAuthTokens>;
|
|
33
|
+
/** Whether a stored login exists for this API; does not refresh or contact the server. */
|
|
34
|
+
export declare function hasLogin(options: UserTokenProviderOptions): Promise<boolean>;
|
|
35
|
+
/** Construction is deliberately free of auth/storage/discovery I/O. */
|
|
36
|
+
export declare function createUserTokenProvider(options: UserTokenProviderOptions): UserTokenProvider;
|
|
37
|
+
export declare function logout(options?: OAuthRequestOptions): Promise<void>;
|
|
38
|
+
export declare function whoAmI(options?: OAuthRequestOptions): Promise<UserInfo>;
|
|
39
|
+
export {};
|