@moloodev/cli 0.2.2 → 0.4.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 +3 -0
- package/package.json +14 -15
- package/src/auth.ts +158 -0
- package/src/cli.ts +88 -34
- package/src/config.ts +36 -0
- package/src/credentials.ts +54 -0
- package/src/dev-server.ts +40 -141
- package/src/editor-dev.ts +38 -0
- package/src/editor-views.ts +23 -43
- package/src/index.ts +0 -2
- package/src/oauth.ts +190 -0
- package/src/publish.ts +6 -5
- package/src/workspace.ts +68 -0
- package/src/dev-host-app.tsx +0 -688
- package/src/dev-host-client.ts +0 -940
- package/src/dev-host-entry.tsx +0 -12
- package/src/dev-host.css +0 -19
- package/src/view-page.ts +0 -57
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
`moloo editor dev` does not support the local app host because the parent app origin is fixed to production (`https://app.moloo.dev`).
|
|
2
|
+
|
|
3
|
+
The view server defaults to `http://127.0.0.1:4310`. Use `moloo editor dev --view-port 4451` to select another port. Click **로컬 뷰 연결** in the app and allow browser local network access when prompted. If a connection fails, the app displays the browser error or manifest HTTP status; check the CLI and the app's browser site permissions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moloodev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"moloo": "./bin/moloo.mjs"
|
|
@@ -13,27 +13,24 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@babel/parser": "7.29.8",
|
|
16
|
+
"@napi-rs/keyring": "2.1.2",
|
|
16
17
|
"@tailwindcss/vite": "^4.3.3",
|
|
17
18
|
"esbuild": "^0.28.2",
|
|
19
|
+
"open": "11.0.2",
|
|
18
20
|
"shadcn": "^4.21.0",
|
|
19
21
|
"tailwindcss": "^4.3.3",
|
|
20
22
|
"tw-animate-css": "^1.4.0",
|
|
21
|
-
"vite": "^8.2.2",
|
|
22
23
|
"tsx": "^4.23.13",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"@moloodev/editor-store": "0.
|
|
27
|
-
"@moloodev/
|
|
28
|
-
"@moloodev/platform": "0.2.2",
|
|
29
|
-
"@moloodev/ui": "0.2.2"
|
|
24
|
+
"vite": "^8.2.2",
|
|
25
|
+
"@moloodev/editor-sdk": "0.4.0",
|
|
26
|
+
"@moloodev/platform": "0.4.0",
|
|
27
|
+
"@moloodev/editor-store": "0.4.0",
|
|
28
|
+
"@moloodev/ui": "0.4.0"
|
|
30
29
|
},
|
|
31
30
|
"devDependencies": {
|
|
32
31
|
"@types/node": "^24.13.3",
|
|
33
32
|
"typescript": "^7.0.2",
|
|
34
|
-
"@
|
|
35
|
-
"@types/react-dom": "^19.2.7",
|
|
36
|
-
"@moloodev/typescript-config": "0.2.2"
|
|
33
|
+
"@moloodev/typescript-config": "0.4.0"
|
|
37
34
|
},
|
|
38
35
|
"publishConfig": {
|
|
39
36
|
"access": "public"
|
|
@@ -41,11 +38,13 @@
|
|
|
41
38
|
"files": [
|
|
42
39
|
"bin",
|
|
43
40
|
"src",
|
|
44
|
-
"!src/**/*.test.ts"
|
|
45
|
-
"!src/**/*.test.tsx"
|
|
41
|
+
"!src/**/*.test.ts"
|
|
46
42
|
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24"
|
|
45
|
+
},
|
|
47
46
|
"scripts": {
|
|
48
47
|
"typecheck": "tsc --noEmit",
|
|
49
|
-
"test": "tsx --test 'src/**/*.test.ts'"
|
|
48
|
+
"test": "node --import tsx --test 'src/**/*.test.ts'"
|
|
50
49
|
}
|
|
51
50
|
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { getIdOrigin, getApiOrigin } from "@moloodev/platform";
|
|
2
|
+
import { credentialStore, type CredentialStore, type Tokens } from "./credentials";
|
|
3
|
+
import { BrowserUnavailable, browserLogin, deviceLogin, oauthPost, readTokens, SCOPE } from "./oauth";
|
|
4
|
+
|
|
5
|
+
export const ISSUER = `${getIdOrigin("production")}/api/auth`;
|
|
6
|
+
|
|
7
|
+
export function canOpenBrowser(
|
|
8
|
+
env = process.env,
|
|
9
|
+
platform = process.platform,
|
|
10
|
+
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
11
|
+
): boolean {
|
|
12
|
+
return Boolean(
|
|
13
|
+
interactive &&
|
|
14
|
+
!env.CI &&
|
|
15
|
+
!env.SSH_CONNECTION &&
|
|
16
|
+
(platform !== "linux" || env.DISPLAY || env.WAYLAND_DISPLAY),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function authorizeLogin(issuer: string, scope: string, device = false): Promise<Tokens> {
|
|
21
|
+
try {
|
|
22
|
+
return await (
|
|
23
|
+
device || !canOpenBrowser()
|
|
24
|
+
? deviceLogin(issuer, scope)
|
|
25
|
+
: browserLogin(issuer, async (url) => {
|
|
26
|
+
const { default: open } = await import("open");
|
|
27
|
+
const launcher = await open(url, { wait: false });
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
const exited = (code: number | null) =>
|
|
30
|
+
code === 0 ? resolve() : reject(new Error("Browser launch failed"));
|
|
31
|
+
if (launcher.exitCode !== null) exited(launcher.exitCode);
|
|
32
|
+
else {
|
|
33
|
+
launcher.once("error", reject);
|
|
34
|
+
launcher.once("close", exited);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}, scope)
|
|
38
|
+
);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!(error instanceof BrowserUnavailable)) throw error;
|
|
41
|
+
return deviceLogin(issuer, scope);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function replaceTokens(
|
|
46
|
+
store: CredentialStore,
|
|
47
|
+
issuer: string,
|
|
48
|
+
previous: Tokens | null,
|
|
49
|
+
tokens: Tokens,
|
|
50
|
+
) {
|
|
51
|
+
try {
|
|
52
|
+
await store.write(tokens);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await revokeTokens(issuer, tokens).catch(() => {});
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
if (previous) await revokeTokens(issuer, previous).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function login(
|
|
61
|
+
device: boolean,
|
|
62
|
+
store = credentialStore(ISSUER),
|
|
63
|
+
issuer = ISSUER,
|
|
64
|
+
authorize = authorizeLogin,
|
|
65
|
+
) {
|
|
66
|
+
// Check keychain availability before starting an authorization that cannot be saved.
|
|
67
|
+
const previous = await store.read();
|
|
68
|
+
const tokens = await authorize(issuer, SCOPE, device);
|
|
69
|
+
await replaceTokens(store, issuer, previous, tokens);
|
|
70
|
+
console.log("Logged in.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loginToken(store = credentialStore(ISSUER), issuer = ISSUER) {
|
|
74
|
+
const tokens = await store.read();
|
|
75
|
+
if (!tokens) throw new Error("Not logged in. Run moloo login or set MOLOO_SERVICE_TOKEN for CI.");
|
|
76
|
+
if (tokens.expiresAt > Date.now() + 60_000) return tokens.accessToken;
|
|
77
|
+
const updated = await readTokens(
|
|
78
|
+
await oauthPost(issuer, "/oauth2/token", {
|
|
79
|
+
grant_type: "refresh_token",
|
|
80
|
+
refresh_token: tokens.refreshToken,
|
|
81
|
+
}),
|
|
82
|
+
tokens.refreshToken,
|
|
83
|
+
);
|
|
84
|
+
await store.write(updated);
|
|
85
|
+
return updated.accessToken;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function revokeTokens(issuer: string, tokens: { accessToken: string; refreshToken: string }) {
|
|
89
|
+
// Revoke both credentials; the provider also removes access tokens linked to the refresh token.
|
|
90
|
+
for (const [token, hint] of [
|
|
91
|
+
[tokens.accessToken, "access_token"],
|
|
92
|
+
[tokens.refreshToken, "refresh_token"],
|
|
93
|
+
] as const) {
|
|
94
|
+
const response = await oauthPost(issuer, "/oauth2/revoke", { token, token_type_hint: hint });
|
|
95
|
+
if (!response.ok) throw new Error("Server revocation failed. Retry moloo logout when online.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function logout(store: CredentialStore = credentialStore(ISSUER), issuer = ISSUER) {
|
|
100
|
+
const tokens = await store.read();
|
|
101
|
+
if (tokens) await revokeTokens(issuer, tokens);
|
|
102
|
+
await store.delete();
|
|
103
|
+
console.log(tokens ? "Logged out." : "Local credentials cleared; no parseable token, server revocation skipped.");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function publishCredentials(
|
|
107
|
+
serviceToken: string | undefined,
|
|
108
|
+
store = credentialStore(ISSUER),
|
|
109
|
+
issuer = ISSUER,
|
|
110
|
+
authorize = authorizeLogin,
|
|
111
|
+
) {
|
|
112
|
+
if (serviceToken !== undefined) {
|
|
113
|
+
const token = serviceToken.trim();
|
|
114
|
+
if (!token) throw new Error("MOLOO_SERVICE_TOKEN is empty");
|
|
115
|
+
return { token, bearer: false };
|
|
116
|
+
}
|
|
117
|
+
const previous = await store.read();
|
|
118
|
+
if (!previous) throw new Error("Not logged in. Run moloo login or set MOLOO_SERVICE_TOKEN for CI.");
|
|
119
|
+
if (!previous.scopes.includes("editor:publish")) {
|
|
120
|
+
const scopes = [...new Set([...previous.scopes, "editor:publish"])];
|
|
121
|
+
const tokens = await authorize(issuer, scopes.join(" "));
|
|
122
|
+
if (!scopes.every((scope) => tokens.scopes.includes(scope))) {
|
|
123
|
+
await revokeTokens(issuer, tokens).catch(() => {});
|
|
124
|
+
throw new Error("OAuth authorization did not grant the requested publish scopes.");
|
|
125
|
+
}
|
|
126
|
+
await replaceTokens(store, issuer, previous, tokens);
|
|
127
|
+
return { token: tokens.accessToken, bearer: true };
|
|
128
|
+
}
|
|
129
|
+
return { token: await loginToken(store, issuer), bearer: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function cliIdentity(
|
|
133
|
+
store = credentialStore(ISSUER),
|
|
134
|
+
apiOrigin = getApiOrigin("production"),
|
|
135
|
+
) {
|
|
136
|
+
const token = await loginToken(store);
|
|
137
|
+
const response = await fetch(`${apiOrigin}/v1/cli/me`, {
|
|
138
|
+
headers: { authorization: `Bearer ${token}` },
|
|
139
|
+
redirect: "error",
|
|
140
|
+
signal: AbortSignal.timeout(30_000),
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) throw new Error(`CLI identity request failed (${response.status}). Run moloo login.`);
|
|
143
|
+
const { data } = (await response.json()) as {
|
|
144
|
+
data: { user: { id: string; name: string; email: string }; workspaces: { id: string; name: string; slug: string }[] };
|
|
145
|
+
};
|
|
146
|
+
return data;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function whoami(
|
|
150
|
+
store = credentialStore(ISSUER),
|
|
151
|
+
apiOrigin = getApiOrigin("production"),
|
|
152
|
+
) {
|
|
153
|
+
const data = await cliIdentity(store, apiOrigin);
|
|
154
|
+
console.log(`${data.user.name} <${data.user.email}>`);
|
|
155
|
+
for (const workspace of data.workspaces)
|
|
156
|
+
console.log(`Workspace: ${workspace.name} (${workspace.slug})`);
|
|
157
|
+
if (!data.workspaces.length) console.log("Workspace: none");
|
|
158
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
+
import { login, logout, whoami } from "./auth";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
|
-
import {
|
|
3
|
+
import { runEditorDev } from "./editor-dev";
|
|
3
4
|
import { buildEditorPackage } from "./editor-views";
|
|
4
5
|
import { publishEditorPackage } from "./publish";
|
|
6
|
+
import { listWorkspaces, useWorkspace, type WorkspaceOptions } from "./workspace";
|
|
5
7
|
|
|
6
8
|
const EDITOR_USAGE =
|
|
7
|
-
"Usage: moloo editor <dev|build|publish> [--dir <path>] [--out-dir <path>] [--port <port>] [--
|
|
8
|
-
const
|
|
9
|
+
"Usage: moloo editor <dev|build|publish> [--dir <path>] [--out-dir <path>] [--view-port <port>] [--workspace <slug>] [--api-url <https-origin>]\nDev view port: 4310 (default)";
|
|
10
|
+
const WORKSPACE_USAGE = "Usage: moloo workspace list | use <slug>";
|
|
11
|
+
const USAGE = `Usage: moloo <command>\n\nCommands:\n login [--device]\n logout\n whoami\n workspace list | use <slug>\n editor dev | build | publish\n\n${WORKSPACE_USAGE}\n${EDITOR_USAGE}`;
|
|
9
12
|
|
|
10
|
-
type Command = (args: readonly string[]) => Promise<number>;
|
|
13
|
+
type Command = (args: readonly string[], options: WorkspaceOptions) => Promise<number>;
|
|
11
14
|
|
|
12
15
|
const editorCommands: Record<string, Command> = {
|
|
13
|
-
async dev(args) {
|
|
14
|
-
|
|
16
|
+
async dev(args, options) {
|
|
17
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
18
|
+
if (
|
|
19
|
+
!["--dir", "--view-port", "--workspace"].includes(args[index] ?? "") ||
|
|
20
|
+
!args[index + 1] ||
|
|
21
|
+
args[index + 1]?.startsWith("--")
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(EDITOR_USAGE);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
const viewPort = readPortFlag(args, "--view-port");
|
|
16
|
-
|
|
27
|
+
return runEditorDev({
|
|
28
|
+
...options,
|
|
17
29
|
packageRoot: packageRootFlag(args),
|
|
18
|
-
|
|
30
|
+
workspace: readFlag(args, "--workspace"),
|
|
19
31
|
...(viewPort !== undefined ? { viewPort } : {}),
|
|
20
32
|
});
|
|
21
|
-
console.log(`${server.editorId} dev host -> ${server.hostOrigin}`);
|
|
22
|
-
console.log(`${server.editorId} view origin -> ${server.viewOrigin}`);
|
|
23
|
-
await new Promise<void>((resolveStop) => {
|
|
24
|
-
const stop = () => {
|
|
25
|
-
process.off("SIGINT", stop);
|
|
26
|
-
process.off("SIGTERM", stop);
|
|
27
|
-
void server.close().finally(resolveStop);
|
|
28
|
-
};
|
|
29
|
-
process.on("SIGINT", stop);
|
|
30
|
-
process.on("SIGTERM", stop);
|
|
31
|
-
});
|
|
32
|
-
return 0;
|
|
33
33
|
},
|
|
34
34
|
async build(args) {
|
|
35
35
|
const packageRoot = packageRootFlag(args);
|
|
@@ -41,30 +41,84 @@ const editorCommands: Record<string, Command> = {
|
|
|
41
41
|
return 0;
|
|
42
42
|
},
|
|
43
43
|
async publish(args) {
|
|
44
|
-
const result = await publishEditorPackage(packageRootFlag(args), {
|
|
44
|
+
const result = await publishEditorPackage(packageRootFlag(args), {
|
|
45
|
+
apiUrl: readFlag(args, "--api-url"),
|
|
46
|
+
});
|
|
45
47
|
console.log(`${result.id} ${String(result.status)} ${result.body}`);
|
|
46
48
|
return result.status === 200 || result.status === 201 ? 0 : 1;
|
|
47
49
|
},
|
|
48
50
|
};
|
|
49
51
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
+
const commands: Record<string, {
|
|
53
|
+
usage: string;
|
|
54
|
+
run: (args: readonly string[], options: WorkspaceOptions) => Promise<number>;
|
|
55
|
+
}> = {
|
|
56
|
+
login: {
|
|
57
|
+
usage: "Usage: moloo login [--device]",
|
|
58
|
+
async run(args) {
|
|
59
|
+
if (args.some((arg) => arg !== "--device")) throw new Error("Usage: moloo login [--device]");
|
|
60
|
+
await login(args.includes("--device"));
|
|
61
|
+
return 0;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
logout: {
|
|
65
|
+
usage: "Usage: moloo logout",
|
|
66
|
+
async run(args) {
|
|
67
|
+
if (args.length) throw new Error("Usage: moloo logout");
|
|
68
|
+
await logout();
|
|
69
|
+
return 0;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
whoami: {
|
|
73
|
+
usage: "Usage: moloo whoami",
|
|
74
|
+
async run(args) {
|
|
75
|
+
if (args.length) throw new Error("Usage: moloo whoami");
|
|
76
|
+
await whoami();
|
|
77
|
+
return 0;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
workspace: {
|
|
81
|
+
usage: WORKSPACE_USAGE,
|
|
82
|
+
async run([name, ...args], options) {
|
|
83
|
+
if (name === "list" && args.length === 0) await listWorkspaces(options);
|
|
84
|
+
else if (name === "use" && args.length === 1 && args[0] && !args[0].startsWith("-")) {
|
|
85
|
+
await useWorkspace(args[0], options);
|
|
86
|
+
} else throw new Error(WORKSPACE_USAGE);
|
|
87
|
+
return 0;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
editor: {
|
|
91
|
+
usage: EDITOR_USAGE,
|
|
92
|
+
async run([name, ...args], options) {
|
|
93
|
+
const command =
|
|
94
|
+
name === undefined
|
|
95
|
+
? undefined
|
|
96
|
+
: Object.hasOwn(editorCommands, name)
|
|
97
|
+
? editorCommands[name]
|
|
98
|
+
: undefined;
|
|
99
|
+
if (!command) {
|
|
100
|
+
console.error(EDITOR_USAGE);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
return command(args, options);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
52
106
|
};
|
|
53
107
|
|
|
54
|
-
export async function runCli(argv: readonly string[]): Promise<number> {
|
|
55
|
-
const [
|
|
56
|
-
const
|
|
108
|
+
export async function runCli(argv: readonly string[], options: WorkspaceOptions = {}): Promise<number> {
|
|
109
|
+
const [name, ...args] = argv;
|
|
110
|
+
const command =
|
|
111
|
+
name === undefined ? undefined : Object.hasOwn(commands, name) ? commands[name] : undefined;
|
|
57
112
|
const wantsHelp = argv.includes("--help") || argv.includes("-h");
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
return
|
|
113
|
+
if (wantsHelp) {
|
|
114
|
+
console.log(command?.usage ?? USAGE);
|
|
115
|
+
return 0;
|
|
61
116
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return wantsHelp ? 0 : 1;
|
|
117
|
+
if (!command) {
|
|
118
|
+
console.error(USAGE);
|
|
119
|
+
return 1;
|
|
66
120
|
}
|
|
67
|
-
return command(
|
|
121
|
+
return command.run(args, options);
|
|
68
122
|
}
|
|
69
123
|
|
|
70
124
|
function packageRootFlag(args: readonly string[]): string {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
type Config = { workspace?: string };
|
|
6
|
+
|
|
7
|
+
export function configPath(env = process.env, home = homedir()): string {
|
|
8
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "moloo", "config.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readConfig(path = configPath()): Promise<Config> {
|
|
12
|
+
let raw: string;
|
|
13
|
+
try {
|
|
14
|
+
raw = await readFile(path, "utf8");
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const value: unknown = JSON.parse(raw);
|
|
21
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
22
|
+
if (!("workspace" in value)) return {};
|
|
23
|
+
if (typeof value.workspace === "string" && value.workspace.length > 0) {
|
|
24
|
+
return { workspace: value.workspace };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Report the path, never the file contents.
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Invalid CLI config at ${path}. Remove it and run moloo workspace use <slug>.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function writeConfig(config: Config, path = configPath()): Promise<void> {
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
await writeFile(path, `${JSON.stringify({ workspace: config.workspace }, null, 2)}\n`, { mode: 0o600 });
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const SERVICE = "dev.moloo.cli";
|
|
2
|
+
|
|
3
|
+
export type Tokens = { accessToken: string; refreshToken: string; expiresAt: number; scopes: string[] };
|
|
4
|
+
export type CredentialStore = {
|
|
5
|
+
/** Missing or unparseable credentials are absent; store access errors propagate. */
|
|
6
|
+
read(): Promise<Tokens | null>;
|
|
7
|
+
write(tokens: Tokens): Promise<void>;
|
|
8
|
+
delete(): Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type KeychainEntry = {
|
|
12
|
+
getPassword(): string | null;
|
|
13
|
+
setPassword(value: string): void;
|
|
14
|
+
deletePassword(): boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Never fall back to a file when the OS credential store is unavailable. */
|
|
18
|
+
export function credentialStore(issuer: string, entry?: KeychainEntry): CredentialStore {
|
|
19
|
+
async function keychain() {
|
|
20
|
+
if (entry) return entry;
|
|
21
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
22
|
+
entry = new Entry(SERVICE, issuer, { linux: { store: "secret-service" } });
|
|
23
|
+
return entry;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async read() {
|
|
27
|
+
const value = (await keychain()).getPassword();
|
|
28
|
+
if (value === null) return null;
|
|
29
|
+
try {
|
|
30
|
+
const tokens = JSON.parse(value) as Tokens;
|
|
31
|
+
if (
|
|
32
|
+
typeof tokens.accessToken === "string" &&
|
|
33
|
+
tokens.accessToken.length &&
|
|
34
|
+
typeof tokens.refreshToken === "string" &&
|
|
35
|
+
tokens.refreshToken.length &&
|
|
36
|
+
Number.isFinite(tokens.expiresAt) &&
|
|
37
|
+
Array.isArray(tokens.scopes) &&
|
|
38
|
+
tokens.scopes.every((scope) => typeof scope === "string" && scope.length > 0)
|
|
39
|
+
)
|
|
40
|
+
return tokens;
|
|
41
|
+
} catch {
|
|
42
|
+
/* Never include a credential's contents in parse errors. */
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
},
|
|
46
|
+
async write(tokens) {
|
|
47
|
+
(await keychain()).setPassword(JSON.stringify(tokens));
|
|
48
|
+
},
|
|
49
|
+
async delete() {
|
|
50
|
+
const key = await keychain();
|
|
51
|
+
if (key.getPassword() !== null) key.deletePassword();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|