@ysicing/plane-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.
@@ -0,0 +1,181 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ function normalizeBaseUrl(baseUrl) {
4
+ return baseUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "").replace(/\/api$/, "");
5
+ }
6
+
7
+ function parseSetCookie(setCookie) {
8
+ const [pair] = setCookie.split(";", 1);
9
+ const index = pair.indexOf("=");
10
+ if (index === -1) return null;
11
+
12
+ const name = pair.slice(0, index).trim();
13
+ const value = pair.slice(index + 1).trim();
14
+ if (!name) return null;
15
+
16
+ return { name, value };
17
+ }
18
+
19
+ export class CookieJar {
20
+ constructor() {
21
+ this.cookies = new Map();
22
+ }
23
+
24
+ set(name, value) {
25
+ this.cookies.set(name, value);
26
+ }
27
+
28
+ get(name) {
29
+ return this.cookies.get(name);
30
+ }
31
+
32
+ entries() {
33
+ return [...this.cookies.entries()];
34
+ }
35
+
36
+ setFromResponse(response) {
37
+ const setCookies =
38
+ typeof response.headers.getSetCookie === "function"
39
+ ? response.headers.getSetCookie()
40
+ : response.headers.get("set-cookie")
41
+ ? [response.headers.get("set-cookie")]
42
+ : [];
43
+
44
+ for (const item of setCookies) {
45
+ const parsed = parseSetCookie(item);
46
+ if (!parsed) continue;
47
+ this.set(parsed.name, parsed.value);
48
+ }
49
+ }
50
+
51
+ toHeader() {
52
+ return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
53
+ }
54
+ }
55
+
56
+ function buildUrl(baseUrl, path) {
57
+ return new URL(path, `${normalizeBaseUrl(baseUrl)}/`);
58
+ }
59
+
60
+ function extractLoginError(locationHeader) {
61
+ if (!locationHeader) return undefined;
62
+
63
+ try {
64
+ const url = new URL(locationHeader, "https://plane.invalid");
65
+ const errorCode = url.searchParams.get("error_code");
66
+ const error = url.searchParams.get("error");
67
+ const errorMessage = url.searchParams.get("error_message");
68
+ const parts = [errorCode, errorMessage, error].filter(Boolean);
69
+ return parts.length ? parts.join(": ") : undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export class PlaneSessionClient {
76
+ constructor(baseUrl) {
77
+ this.baseUrl = normalizeBaseUrl(baseUrl);
78
+ this.cookies = new CookieJar();
79
+ this.csrfToken = undefined;
80
+ }
81
+
82
+ async fetch(path, options = {}) {
83
+ const headers = new Headers(options.headers || {});
84
+ const cookieHeader = this.cookies.toHeader();
85
+ if (cookieHeader) {
86
+ headers.set("Cookie", cookieHeader);
87
+ }
88
+
89
+ const response = await fetch(buildUrl(this.baseUrl, path), {
90
+ ...options,
91
+ headers,
92
+ });
93
+
94
+ this.cookies.setFromResponse(response);
95
+ return response;
96
+ }
97
+
98
+ async initializeCsrf() {
99
+ const response = await this.fetch("/auth/get-csrf-token/");
100
+ const payload = await response.json();
101
+
102
+ if (!response.ok || !payload?.csrf_token) {
103
+ throw new CliError("Failed to initialize CSRF token.", {
104
+ details: payload,
105
+ });
106
+ }
107
+
108
+ this.csrfToken = payload.csrf_token;
109
+ return payload.csrf_token;
110
+ }
111
+
112
+ async login({ username, password, ldap = false }) {
113
+ const csrfToken = this.csrfToken || (await this.initializeCsrf());
114
+ const params = new URLSearchParams();
115
+ params.set("email", username);
116
+ params.set("password", password);
117
+ params.set("csrfmiddlewaretoken", csrfToken);
118
+ params.set("next_path", "/");
119
+
120
+ const response = await this.fetch(ldap ? "/auth/ldap/sign-in/" : "/auth/sign-in/", {
121
+ method: "POST",
122
+ redirect: "manual",
123
+ headers: {
124
+ "Content-Type": "application/x-www-form-urlencoded",
125
+ "X-CSRFTOKEN": csrfToken,
126
+ Referer: this.baseUrl,
127
+ },
128
+ body: params.toString(),
129
+ });
130
+
131
+ const hasSessionCookie = this.cookies.entries().some(([name]) => name !== "csrftoken");
132
+ if (!hasSessionCookie) {
133
+ throw new CliError("Login failed.", {
134
+ details: {
135
+ location: response.headers.get("location"),
136
+ error: extractLoginError(response.headers.get("location")),
137
+ },
138
+ });
139
+ }
140
+
141
+ return {
142
+ location: response.headers.get("location"),
143
+ ldap,
144
+ };
145
+ }
146
+
147
+ async request(method, path, body) {
148
+ const headers = {
149
+ Accept: "application/json",
150
+ };
151
+
152
+ if (body !== undefined) {
153
+ headers["Content-Type"] = "application/json";
154
+ }
155
+
156
+ const response = await this.fetch(path, {
157
+ method,
158
+ headers,
159
+ body: body === undefined ? undefined : JSON.stringify(body),
160
+ });
161
+
162
+ const contentType = response.headers.get("content-type") || "";
163
+ const payload = contentType.includes("application/json") ? await response.json() : await response.text();
164
+
165
+ if (!response.ok) {
166
+ throw new CliError(`Plane session request failed: ${response.status} ${response.statusText}`, {
167
+ details: payload,
168
+ });
169
+ }
170
+
171
+ return payload;
172
+ }
173
+
174
+ createApiToken(body) {
175
+ return this.request("POST", "/api/users/api-tokens/", body);
176
+ }
177
+
178
+ listUserWorkspaces() {
179
+ return this.request("GET", "/api/users/me/workspaces/");
180
+ }
181
+ }