@nesalia/cli 1.0.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/.turbo/turbo-build.log +4 -0
- package/dist/commands/auth/index.d.ts +4 -0
- package/dist/commands/auth/index.d.ts.map +1 -0
- package/dist/commands/auth/index.js +4 -0
- package/dist/commands/auth/index.js.map +1 -0
- package/dist/commands/auth/login.d.ts +2 -0
- package/dist/commands/auth/login.d.ts.map +1 -0
- package/dist/commands/auth/login.js +19 -0
- package/dist/commands/auth/login.js.map +1 -0
- package/dist/commands/auth/logout.d.ts +2 -0
- package/dist/commands/auth/logout.d.ts.map +1 -0
- package/dist/commands/auth/logout.js +23 -0
- package/dist/commands/auth/logout.js.map +1 -0
- package/dist/commands/auth/status.d.ts +2 -0
- package/dist/commands/auth/status.d.ts.map +1 -0
- package/dist/commands/auth/status.js +33 -0
- package/dist/commands/auth/status.js.map +1 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/post/by-id.d.ts +6 -0
- package/dist/commands/post/by-id.d.ts.map +1 -0
- package/dist/commands/post/by-id.js +32 -0
- package/dist/commands/post/by-id.js.map +1 -0
- package/dist/commands/post/create.d.ts +7 -0
- package/dist/commands/post/create.d.ts.map +1 -0
- package/dist/commands/post/create.js +35 -0
- package/dist/commands/post/create.js.map +1 -0
- package/dist/commands/post/index.d.ts +4 -0
- package/dist/commands/post/index.d.ts.map +1 -0
- package/dist/commands/post/index.js +4 -0
- package/dist/commands/post/index.js.map +1 -0
- package/dist/commands/post/list.d.ts +2 -0
- package/dist/commands/post/list.d.ts.map +1 -0
- package/dist/commands/post/list.js +35 -0
- package/dist/commands/post/list.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api/client.d.ts +17 -0
- package/dist/lib/api/client.d.ts.map +1 -0
- package/dist/lib/api/client.js +18 -0
- package/dist/lib/api/client.js.map +1 -0
- package/dist/lib/auth/client.d.ts +2748 -0
- package/dist/lib/auth/client.d.ts.map +1 -0
- package/dist/lib/auth/client.js +11 -0
- package/dist/lib/auth/client.js.map +1 -0
- package/dist/lib/auth/device-flow/config.d.ts +5 -0
- package/dist/lib/auth/device-flow/config.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/config.js +5 -0
- package/dist/lib/auth/device-flow/config.js.map +1 -0
- package/dist/lib/auth/device-flow/device-code.d.ts +10 -0
- package/dist/lib/auth/device-flow/device-code.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/device-code.js +27 -0
- package/dist/lib/auth/device-flow/device-code.js.map +1 -0
- package/dist/lib/auth/device-flow/errors.d.ts +8 -0
- package/dist/lib/auth/device-flow/errors.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/errors.js +26 -0
- package/dist/lib/auth/device-flow/errors.js.map +1 -0
- package/dist/lib/auth/device-flow/index.d.ts +3 -0
- package/dist/lib/auth/device-flow/index.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/index.js +13 -0
- package/dist/lib/auth/device-flow/index.js.map +1 -0
- package/dist/lib/auth/device-flow/polling.d.ts +4 -0
- package/dist/lib/auth/device-flow/polling.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/polling.js +75 -0
- package/dist/lib/auth/device-flow/polling.js.map +1 -0
- package/dist/lib/auth/device-flow/types.d.ts +10 -0
- package/dist/lib/auth/device-flow/types.d.ts.map +1 -0
- package/dist/lib/auth/device-flow/types.js +2 -0
- package/dist/lib/auth/device-flow/types.js.map +1 -0
- package/dist/lib/auth/index.d.ts +12 -0
- package/dist/lib/auth/index.d.ts.map +1 -0
- package/dist/lib/auth/index.js +24 -0
- package/dist/lib/auth/index.js.map +1 -0
- package/dist/lib/auth/storage.d.ts +20 -0
- package/dist/lib/auth/storage.d.ts.map +1 -0
- package/dist/lib/auth/storage.js +48 -0
- package/dist/lib/auth/storage.js.map +1 -0
- package/package.json +36 -0
- package/src/CLAUDE.md +83 -0
- package/src/commands/auth/index.ts +3 -0
- package/src/commands/auth/login.ts +20 -0
- package/src/commands/auth/logout.ts +25 -0
- package/src/commands/auth/status.ts +37 -0
- package/src/commands/index.ts +1 -0
- package/src/index.ts +32 -0
- package/src/lib/api/client.ts +20 -0
- package/src/lib/auth/client.ts +14 -0
- package/src/lib/auth/device-flow/config.ts +4 -0
- package/src/lib/auth/device-flow/device-code.ts +39 -0
- package/src/lib/auth/device-flow/errors.ts +29 -0
- package/src/lib/auth/device-flow/index.ts +19 -0
- package/src/lib/auth/device-flow/polling.ts +91 -0
- package/src/lib/auth/device-flow/types.ts +9 -0
- package/src/lib/auth/index.ts +41 -0
- package/src/lib/auth/storage.ts +69 -0
- package/tests/auth.test.ts +123 -0
- package/tests/setup.ts +43 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +9 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { login, status, logout } from "./commands/index.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("nesalia")
|
|
10
|
+
.version("1.0.0")
|
|
11
|
+
.description("@nesalia/cli — Manage your account authentication");
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command("auth", { isDefault: false })
|
|
15
|
+
.description("Authentication commands")
|
|
16
|
+
.addCommand(
|
|
17
|
+
new Command("login")
|
|
18
|
+
.description("Login via device authorization")
|
|
19
|
+
.action(login),
|
|
20
|
+
)
|
|
21
|
+
.addCommand(
|
|
22
|
+
new Command("status")
|
|
23
|
+
.description("Check authentication status")
|
|
24
|
+
.action(status),
|
|
25
|
+
)
|
|
26
|
+
.addCommand(
|
|
27
|
+
new Command("logout")
|
|
28
|
+
.description("Logout and clear credentials")
|
|
29
|
+
.action(logout),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
|
2
|
+
import type { AppRouter } from "@complete-web-template/api";
|
|
3
|
+
import { loadCredentials } from "../auth/index.js";
|
|
4
|
+
|
|
5
|
+
const BASE_URL = process.env.CLI_AUTH_API_URL ?? "http://localhost:3000";
|
|
6
|
+
|
|
7
|
+
export const trpcClient = createTRPCClient<AppRouter>({
|
|
8
|
+
links: [
|
|
9
|
+
httpBatchLink({
|
|
10
|
+
url: `${BASE_URL}/api/trpc`,
|
|
11
|
+
headers() {
|
|
12
|
+
const creds = loadCredentials();
|
|
13
|
+
if (creds?.accessToken) {
|
|
14
|
+
return { Authorization: `Bearer ${creds.accessToken}` };
|
|
15
|
+
}
|
|
16
|
+
return {};
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
],
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createAuthClient } from "better-auth/client";
|
|
2
|
+
import { deviceAuthorizationClient, organizationClient } from "better-auth/client/plugins";
|
|
3
|
+
|
|
4
|
+
const BASE_URL = process.env.CLI_AUTH_API_URL ?? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
export const authClient = createAuthClient({
|
|
7
|
+
baseURL: BASE_URL,
|
|
8
|
+
plugins: [
|
|
9
|
+
deviceAuthorizationClient(),
|
|
10
|
+
organizationClient(),
|
|
11
|
+
],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type AuthClient = typeof authClient;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { log } from "@clack/prompts";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import type { AuthClient } from "../client.js";
|
|
4
|
+
import { CLIENT_ID, SCOPE } from "./config.js";
|
|
5
|
+
import { AuthFlowError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
export interface DeviceCodeResult {
|
|
8
|
+
deviceCode: string;
|
|
9
|
+
userCode: string;
|
|
10
|
+
verificationUri: string;
|
|
11
|
+
interval: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const requestDeviceCode = async (client: AuthClient): Promise<DeviceCodeResult> => {
|
|
15
|
+
log.info("Requesting device authorization...");
|
|
16
|
+
|
|
17
|
+
const { data, error } = await client.device.code({
|
|
18
|
+
client_id: CLIENT_ID,
|
|
19
|
+
scope: SCOPE,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (error || !data) {
|
|
23
|
+
const msg = error?.error_description ?? "Failed to get device code";
|
|
24
|
+
throw new AuthFlowError(msg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
deviceCode: data.device_code,
|
|
29
|
+
userCode: data.user_code,
|
|
30
|
+
verificationUri: data.verification_uri_complete,
|
|
31
|
+
interval: data.interval ?? 5,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const openBrowser = async (uri: string): Promise<void> => {
|
|
36
|
+
await open(uri).catch(() => {
|
|
37
|
+
// Non-fatal: browser may fail to open, user can still use the URL
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Transient network error codes from Node.js
|
|
2
|
+
export const TRANSIENT_ERROR_CODES = new Set([
|
|
3
|
+
"ETIMEDOUT",
|
|
4
|
+
"ECONNRESET",
|
|
5
|
+
"ECONNREFUSED",
|
|
6
|
+
"ENOTFOUND",
|
|
7
|
+
"ENETUNREACH",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const isTransientError = (error: unknown): boolean => {
|
|
11
|
+
if (error && typeof error === "object") {
|
|
12
|
+
const code = (error as { code?: string }).code;
|
|
13
|
+
if (code && TRANSIENT_ERROR_CODES.has(code)) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Generic auth flow error (network or oauth)
|
|
19
|
+
export class AuthFlowError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
public readonly isNetwork = false,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "AuthFlowError";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static network = (msg: string): AuthFlowError => new AuthFlowError(msg, true);
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { log } from "@clack/prompts";
|
|
2
|
+
import { authClient } from "../client.js";
|
|
3
|
+
import { requestDeviceCode, openBrowser } from "./device-code.js";
|
|
4
|
+
import { pollForToken } from "./polling.js";
|
|
5
|
+
import type { AuthFlowResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export const startDeviceFlow = async (): Promise<AuthFlowResult> => {
|
|
8
|
+
const { deviceCode, userCode, verificationUri, interval } = await requestDeviceCode(authClient);
|
|
9
|
+
|
|
10
|
+
log.message(
|
|
11
|
+
`Open this URL in your browser:\n ${verificationUri}\n` +
|
|
12
|
+
`Or enter the code: ${userCode}`
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
await openBrowser(verificationUri);
|
|
16
|
+
log.info(`Waiting for authorization... (polling every ${interval}s)`);
|
|
17
|
+
|
|
18
|
+
return pollForToken(authClient, deviceCode, interval);
|
|
19
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { log } from "@clack/prompts";
|
|
2
|
+
import type { AuthClient } from "../client.js";
|
|
3
|
+
import { CLIENT_ID, POLL_TIMEOUT_MS, MAX_NETWORK_RETRIES } from "./config.js";
|
|
4
|
+
import { isTransientError } from "./errors.js";
|
|
5
|
+
import type { AuthFlowResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const sleep = (ms: number): Promise<void> =>
|
|
8
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
|
|
10
|
+
const resolveUser = async (client: AuthClient, accessToken: string): Promise<AuthFlowResult["user"]> => {
|
|
11
|
+
const response = await client.getSession({
|
|
12
|
+
fetchOptions: {
|
|
13
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const user = response?.data?.user;
|
|
18
|
+
if (!user) {
|
|
19
|
+
throw new Error("Could not retrieve user session.");
|
|
20
|
+
}
|
|
21
|
+
return { id: user.id, name: user.name, email: user.email, image: user.image ?? undefined };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const pollForToken = async (
|
|
25
|
+
client: AuthClient,
|
|
26
|
+
deviceCode: string,
|
|
27
|
+
intervalSeconds: number,
|
|
28
|
+
startedAt = Date.now(),
|
|
29
|
+
networkRetries = 0,
|
|
30
|
+
): Promise<AuthFlowResult> => {
|
|
31
|
+
if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
|
|
32
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let data: Awaited<ReturnType<AuthClient["device"]["token"]>>["data"];
|
|
36
|
+
let error: Awaited<ReturnType<AuthClient["device"]["token"]>>["error"];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const result = await client.device.token({
|
|
40
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
41
|
+
device_code: deviceCode,
|
|
42
|
+
client_id: CLIENT_ID,
|
|
43
|
+
});
|
|
44
|
+
data = result.data;
|
|
45
|
+
error = result.error;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (isTransientError(err)) {
|
|
48
|
+
const retries = networkRetries + 1;
|
|
49
|
+
if (retries > MAX_NETWORK_RETRIES) {
|
|
50
|
+
throw new Error(`Network error after ${MAX_NETWORK_RETRIES} retries. Check your connection.`);
|
|
51
|
+
}
|
|
52
|
+
log.warn(`Network error during polling — retry ${retries}/${MAX_NETWORK_RETRIES}.`);
|
|
53
|
+
await sleep(intervalSeconds * 1000);
|
|
54
|
+
return pollForToken(client, deviceCode, intervalSeconds, startedAt, retries);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (data?.access_token) {
|
|
60
|
+
log.success("Authorization successful!");
|
|
61
|
+
const user = await resolveUser(client, data.access_token);
|
|
62
|
+
if (!user.id) {
|
|
63
|
+
throw new Error("Could not retrieve user information. Please try again.");
|
|
64
|
+
}
|
|
65
|
+
log.success(`Connected as ${user.name || user.email || "user"}`);
|
|
66
|
+
return { accessToken: data.access_token, user };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error) {
|
|
70
|
+
switch (error.error) {
|
|
71
|
+
case "authorization_pending":
|
|
72
|
+
await sleep(intervalSeconds * 1000);
|
|
73
|
+
return pollForToken(client, deviceCode, intervalSeconds, startedAt);
|
|
74
|
+
case "slow_down":
|
|
75
|
+
const newInterval = intervalSeconds + 5;
|
|
76
|
+
log.warn(`Slowing down polling to ${newInterval}s`);
|
|
77
|
+
await sleep(newInterval * 1000);
|
|
78
|
+
return pollForToken(client, deviceCode, newInterval, startedAt);
|
|
79
|
+
case "access_denied":
|
|
80
|
+
throw new Error("Authorization was denied.");
|
|
81
|
+
case "expired_token":
|
|
82
|
+
throw new Error("The code expired. Please try again.");
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(error.error_description ?? `Unexpected error: ${error.error}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No data and no error — should not happen, but guard anyway
|
|
89
|
+
await sleep(intervalSeconds * 1000);
|
|
90
|
+
return pollForToken(client, deviceCode, intervalSeconds, startedAt);
|
|
91
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Re-export everything from submodules
|
|
2
|
+
export { authClient } from "./client.js";
|
|
3
|
+
export type { AuthClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export { startDeviceFlow } from "./device-flow/index.js";
|
|
6
|
+
export type { AuthFlowResult } from "./device-flow/types.js";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
saveCredentials,
|
|
10
|
+
loadCredentials,
|
|
11
|
+
clearCredentials,
|
|
12
|
+
isExpired,
|
|
13
|
+
requireAuth,
|
|
14
|
+
type StoredCredentials,
|
|
15
|
+
} from "./storage.js";
|
|
16
|
+
|
|
17
|
+
import { log } from "@clack/prompts";
|
|
18
|
+
import { loadCredentials, clearCredentials, isExpired, type StoredCredentials } from "./storage.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HOF - wraps an async operation requiring authentication.
|
|
22
|
+
* Checks credentials before execution, exits with error message if not authed.
|
|
23
|
+
*/
|
|
24
|
+
export async function withAuth<T>(
|
|
25
|
+
fn: (credentials: StoredCredentials) => Promise<T>,
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
const credentials = loadCredentials();
|
|
28
|
+
|
|
29
|
+
if (!credentials) {
|
|
30
|
+
log.error("Not logged in. Run 'auth login' first.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isExpired(credentials)) {
|
|
35
|
+
log.error("Session expired. Run 'auth login' again.");
|
|
36
|
+
clearCredentials();
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return fn(credentials);
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import { log } from "@clack/prompts";
|
|
3
|
+
|
|
4
|
+
export type StoredCredentials = {
|
|
5
|
+
accessToken: string;
|
|
6
|
+
user: {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
name: string;
|
|
10
|
+
image?: string;
|
|
11
|
+
};
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Support test configuration via environment variable
|
|
16
|
+
const configPath = process.env.CLI_AUTH_CONFIG_PATH;
|
|
17
|
+
|
|
18
|
+
export const storage = new Conf<{ credentials: StoredCredentials | null }>({
|
|
19
|
+
projectName: "complete-web-template",
|
|
20
|
+
configName: "auth",
|
|
21
|
+
cwd: configPath, // Use custom path if set (for tests)
|
|
22
|
+
defaults: {
|
|
23
|
+
credentials: null,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function saveCredentials(credentials: StoredCredentials): void {
|
|
28
|
+
storage.set("credentials", credentials);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadCredentials(): StoredCredentials | null {
|
|
32
|
+
const raw = storage.get("credentials");
|
|
33
|
+
|
|
34
|
+
// Guard against corrupted storage (e.g., old version, partial write)
|
|
35
|
+
if (
|
|
36
|
+
!raw ||
|
|
37
|
+
typeof raw !== "object" ||
|
|
38
|
+
!("accessToken" in raw) ||
|
|
39
|
+
!("user" in raw) ||
|
|
40
|
+
!("expiresAt" in raw)
|
|
41
|
+
) {
|
|
42
|
+
clearCredentials();
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return raw as StoredCredentials;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearCredentials(): void {
|
|
50
|
+
storage.delete("credentials");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isExpired(credentials: StoredCredentials): boolean {
|
|
54
|
+
return Date.now() > credentials.expiresAt;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function requireAuth(): StoredCredentials {
|
|
58
|
+
const credentials = loadCredentials();
|
|
59
|
+
if (!credentials) {
|
|
60
|
+
log.error("Not logged in. Run 'auth login' first.");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (isExpired(credentials)) {
|
|
64
|
+
log.error("Session expired. Run 'auth login' again.");
|
|
65
|
+
clearCredentials();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
return credentials;
|
|
69
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { status, logout } from "../src/commands/auth";
|
|
3
|
+
import { loadCredentials, clearCredentials, saveCredentials } from "../src/lib/auth/storage";
|
|
4
|
+
import { testServer } from "./setup";
|
|
5
|
+
|
|
6
|
+
// Mock @clack/prompts before importing auth commands
|
|
7
|
+
vi.mock("@clack/prompts", () => ({
|
|
8
|
+
log: {
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
success: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
message: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock open before importing device-flow
|
|
18
|
+
vi.mock("../src/lib/auth/device-flow", async () => {
|
|
19
|
+
const actual = await vi.importActual("../src/lib/auth/device-flow");
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
startDeviceFlow: vi.fn().mockImplementation(async () => ({
|
|
23
|
+
accessToken: "test-access-token",
|
|
24
|
+
user: {
|
|
25
|
+
id: "test-user-id",
|
|
26
|
+
name: "Test User",
|
|
27
|
+
email: "test@example.com",
|
|
28
|
+
},
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Import login AFTER mocking
|
|
34
|
+
const { login } = await import("../src/commands/auth");
|
|
35
|
+
|
|
36
|
+
describe("CLI Auth Commands", () => {
|
|
37
|
+
describe("login", () => {
|
|
38
|
+
it("should login successfully with device flow", async () => {
|
|
39
|
+
// Mock open to prevent browser from actually opening
|
|
40
|
+
const openMock = vi.fn();
|
|
41
|
+
vi.stubGlobal("open", Object.assign(openMock, { default: openMock }));
|
|
42
|
+
|
|
43
|
+
// Start login - it uses mocked startDeviceFlow internally
|
|
44
|
+
await login();
|
|
45
|
+
|
|
46
|
+
// Verify credentials were stored
|
|
47
|
+
const creds = loadCredentials();
|
|
48
|
+
expect(creds).toBeDefined();
|
|
49
|
+
expect(creds?.accessToken).toBe("test-access-token");
|
|
50
|
+
expect(creds?.user).toBeDefined();
|
|
51
|
+
expect(creds?.user.email).toBe("test@example.com");
|
|
52
|
+
|
|
53
|
+
vi.unstubAllGlobals();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("status", () => {
|
|
58
|
+
it("should show not logged in message when no credentials", async () => {
|
|
59
|
+
const { log } = await import("@clack/prompts");
|
|
60
|
+
clearCredentials();
|
|
61
|
+
|
|
62
|
+
await status();
|
|
63
|
+
|
|
64
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
65
|
+
"Not logged in. Run 'auth login' to authenticate."
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should show logged in message when credentials exist", async () => {
|
|
70
|
+
const { log } = await import("@clack/prompts");
|
|
71
|
+
|
|
72
|
+
// Simulate logged in state
|
|
73
|
+
saveCredentials({
|
|
74
|
+
accessToken: "test-token",
|
|
75
|
+
user: { id: "1", name: "Test User", email: "test@example.com" },
|
|
76
|
+
expiresAt: Date.now() + 86400000, // 1 day from now
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Mock the fetch call for session verification
|
|
80
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
81
|
+
new Response(JSON.stringify({ user: { name: "Test User", email: "test@example.com" } }), {
|
|
82
|
+
status: 200,
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
87
|
+
|
|
88
|
+
await status();
|
|
89
|
+
|
|
90
|
+
expect(log.success).toHaveBeenCalledWith(
|
|
91
|
+
expect.stringContaining("Test User")
|
|
92
|
+
);
|
|
93
|
+
vi.unstubAllGlobals();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("logout", () => {
|
|
98
|
+
it("should show not logged in when no credentials", async () => {
|
|
99
|
+
const { log } = await import("@clack/prompts");
|
|
100
|
+
clearCredentials();
|
|
101
|
+
|
|
102
|
+
await logout();
|
|
103
|
+
|
|
104
|
+
expect(log.info).toHaveBeenCalledWith("Not logged in.");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should clear credentials and confirm logout", async () => {
|
|
108
|
+
const { log } = await import("@clack/prompts");
|
|
109
|
+
|
|
110
|
+
// Set up credentials
|
|
111
|
+
saveCredentials({
|
|
112
|
+
accessToken: "test-token",
|
|
113
|
+
user: { id: "1", name: "Test User", email: "test@example.com" },
|
|
114
|
+
expiresAt: Date.now() + 86400000,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await logout();
|
|
118
|
+
|
|
119
|
+
expect(log.success).toHaveBeenCalledWith("Successfully logged out.");
|
|
120
|
+
expect(loadCredentials() ?? null).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createTestServerWithAuth } from "@complete-web-template/test-utils";
|
|
2
|
+
import { createTempDir, mockOpen, restoreOpen } from "@complete-web-template/test-utils";
|
|
3
|
+
import { clearCredentials } from "../src/lib/auth/storage";
|
|
4
|
+
import type { TestServer } from "@complete-web-template/test-utils";
|
|
5
|
+
|
|
6
|
+
// Test server instance
|
|
7
|
+
export let testServer: Awaited<ReturnType<typeof createTestServerWithAuth>> | null = null;
|
|
8
|
+
|
|
9
|
+
// Temp directory for config
|
|
10
|
+
export let tempDir: { configPath: string; cleanup: () => void };
|
|
11
|
+
|
|
12
|
+
// Set up test environment before all tests
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
// Create temp directory for config storage
|
|
15
|
+
tempDir = createTempDir();
|
|
16
|
+
process.env.CLI_AUTH_CONFIG_PATH = tempDir.configPath;
|
|
17
|
+
|
|
18
|
+
// Start test server with auth
|
|
19
|
+
testServer = await createTestServerWithAuth();
|
|
20
|
+
|
|
21
|
+
// Set API URL for CLI
|
|
22
|
+
process.env.CLI_AUTH_API_URL = testServer.baseUrl;
|
|
23
|
+
|
|
24
|
+
// Mock open to prevent browser launching
|
|
25
|
+
mockOpen();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Clean up after all tests
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
if (testServer) {
|
|
31
|
+
await testServer.close();
|
|
32
|
+
}
|
|
33
|
+
restoreOpen();
|
|
34
|
+
tempDir.cleanup();
|
|
35
|
+
delete process.env.CLI_AUTH_CONFIG_PATH;
|
|
36
|
+
delete process.env.CLI_AUTH_API_URL;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Clean up before each test (reset credentials)
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// Reset the config storage between tests
|
|
42
|
+
clearCredentials();
|
|
43
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2020"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|