contentrain 0.3.1 → 0.3.3

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,275 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { chmod, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
4
+ //#region src/studio/auth/credential-store.ts
5
+ const DIR_NAME = ".contentrain";
6
+ const FILE_NAME = "credentials.json";
7
+ const FILE_MODE = 384;
8
+ const DIR_MODE = 448;
9
+ const DEFAULT_STUDIO_URL = "https://studio.contentrain.io";
10
+ /** Absolute path to the credentials file. */
11
+ function getCredentialsPath() {
12
+ return join(homedir(), DIR_NAME, FILE_NAME);
13
+ }
14
+ /** Absolute path to the credentials directory. */
15
+ function getCredentialsDir() {
16
+ return join(homedir(), DIR_NAME);
17
+ }
18
+ /**
19
+ * Load Studio credentials.
20
+ *
21
+ * Resolution order:
22
+ * 1. CONTENTRAIN_STUDIO_TOKEN env var (CI/CD — no file I/O)
23
+ * 2. ~/.contentrain/credentials.json (developer workstation)
24
+ * 3. null (not authenticated)
25
+ */
26
+ async function loadCredentials() {
27
+ const envToken = process.env["CONTENTRAIN_STUDIO_TOKEN"];
28
+ if (envToken) return {
29
+ studioUrl: process.env["CONTENTRAIN_STUDIO_URL"] ?? DEFAULT_STUDIO_URL,
30
+ accessToken: envToken,
31
+ refreshToken: "",
32
+ expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1e3).toISOString()
33
+ };
34
+ const filePath = getCredentialsPath();
35
+ try {
36
+ const raw = await readFile(filePath, "utf-8");
37
+ const config = JSON.parse(raw);
38
+ if (config.version !== 1 || !config.credentials?.accessToken) return null;
39
+ return config.credentials;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Persist credentials to ~/.contentrain/credentials.json.
46
+ *
47
+ * Always enforces 0o600 file + 0o700 directory permissions on POSIX.
48
+ */
49
+ async function saveCredentials(credentials) {
50
+ const dirPath = getCredentialsDir();
51
+ const filePath = getCredentialsPath();
52
+ await mkdir(dirPath, { recursive: true });
53
+ const config = {
54
+ version: 1,
55
+ credentials
56
+ };
57
+ await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", {
58
+ encoding: "utf-8",
59
+ mode: FILE_MODE
60
+ });
61
+ await enforcePermissions(dirPath, filePath);
62
+ }
63
+ /**
64
+ * Securely remove stored credentials.
65
+ *
66
+ * Defence in depth: overwrites file content with zeros before unlinking
67
+ * to reduce the risk of recovery from filesystem snapshots.
68
+ */
69
+ async function clearCredentials() {
70
+ const filePath = getCredentialsPath();
71
+ try {
72
+ const info = await stat(filePath);
73
+ await writeFile(filePath, Buffer.alloc(info.size), { encoding: "utf-8" });
74
+ await unlink(filePath);
75
+ } catch {}
76
+ }
77
+ /**
78
+ * Update only the default workspace/project IDs without touching tokens.
79
+ */
80
+ async function saveDefaults(workspaceId, projectId) {
81
+ const credentials = await loadCredentials();
82
+ if (!credentials) return;
83
+ credentials.defaultWorkspaceId = workspaceId;
84
+ credentials.defaultProjectId = projectId;
85
+ await saveCredentials(credentials);
86
+ }
87
+ /**
88
+ * Check whether the credentials file has secure permissions.
89
+ *
90
+ * Returns a warning message if permissions are too open, or null if secure.
91
+ * On Windows this always returns null (chmod is a no-op there).
92
+ */
93
+ async function checkPermissions() {
94
+ if (process.platform === "win32") return null;
95
+ const filePath = getCredentialsPath();
96
+ try {
97
+ const mode = (await stat(filePath)).mode & 511;
98
+ if (mode !== FILE_MODE) return `Credentials file has insecure permissions (${modeToString(mode)}). Expected ${modeToString(FILE_MODE)}. Run: chmod 600 ${filePath}`;
99
+ return null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Check whether the stored token is expired.
106
+ */
107
+ function isTokenExpired(credentials) {
108
+ if (!credentials.expiresAt) return false;
109
+ return Date.now() >= new Date(credentials.expiresAt).getTime();
110
+ }
111
+ async function enforcePermissions(dirPath, filePath) {
112
+ if (process.platform === "win32") return;
113
+ try {
114
+ await chmod(dirPath, DIR_MODE);
115
+ await chmod(filePath, FILE_MODE);
116
+ } catch {}
117
+ }
118
+ function modeToString(mode) {
119
+ return `0o${mode.toString(8)}`;
120
+ }
121
+ //#endregion
122
+ //#region src/studio/types.ts
123
+ var StudioApiError = class extends Error {
124
+ constructor(statusCode, statusText, message) {
125
+ super(message);
126
+ this.statusCode = statusCode;
127
+ this.statusText = statusText;
128
+ this.name = "StudioApiError";
129
+ }
130
+ };
131
+ var AuthExpiredError = class extends StudioApiError {
132
+ constructor() {
133
+ super(401, "Unauthorized", "Session expired. Run `contentrain studio login` to re-authenticate.");
134
+ this.name = "AuthExpiredError";
135
+ }
136
+ };
137
+ //#endregion
138
+ //#region src/studio/client.ts
139
+ var StudioApiClient = class {
140
+ baseUrl;
141
+ credentials;
142
+ constructor(credentials) {
143
+ this.baseUrl = credentials.studioUrl.replace(/\/+$/, "");
144
+ this.credentials = credentials;
145
+ }
146
+ async request(method, path, options) {
147
+ if (isTokenExpired(this.credentials) && this.credentials.refreshToken) await this.refreshToken();
148
+ const url = new URL(`${this.baseUrl}${path}`);
149
+ if (options?.query) for (const [key, value] of Object.entries(options.query)) url.searchParams.set(key, value);
150
+ const headers = {
151
+ "Authorization": `Bearer ${this.credentials.accessToken}`,
152
+ "Accept": "application/json"
153
+ };
154
+ if (options?.body !== void 0) headers["Content-Type"] = "application/json";
155
+ const res = await globalThis.fetch(url.toString(), {
156
+ method,
157
+ headers,
158
+ body: options?.body !== void 0 ? JSON.stringify(options.body) : void 0
159
+ });
160
+ if (res.status === 401) {
161
+ if (this.credentials.refreshToken) try {
162
+ await this.refreshToken();
163
+ const retryHeaders = {
164
+ ...headers,
165
+ "Authorization": `Bearer ${this.credentials.accessToken}`
166
+ };
167
+ const retry = await globalThis.fetch(url.toString(), {
168
+ method,
169
+ headers: retryHeaders,
170
+ body: options?.body !== void 0 ? JSON.stringify(options.body) : void 0
171
+ });
172
+ if (retry.ok) return retry.status === 204 ? void 0 : await retry.json();
173
+ } catch {}
174
+ throw new AuthExpiredError();
175
+ }
176
+ if (!res.ok) {
177
+ let message;
178
+ try {
179
+ message = (await res.json()).message ?? res.statusText;
180
+ } catch {
181
+ message = res.statusText;
182
+ }
183
+ throw new StudioApiError(res.status, res.statusText, message);
184
+ }
185
+ if (res.status === 204) return void 0;
186
+ return await res.json();
187
+ }
188
+ async refreshToken() {
189
+ const res = await globalThis.fetch(`${this.baseUrl}/api/auth/refresh`, {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify({ refreshToken: this.credentials.refreshToken })
193
+ });
194
+ if (!res.ok) throw new AuthExpiredError();
195
+ const data = await res.json();
196
+ this.credentials.accessToken = data.accessToken;
197
+ this.credentials.refreshToken = data.refreshToken;
198
+ this.credentials.expiresAt = data.expiresAt;
199
+ await saveCredentials(this.credentials);
200
+ }
201
+ async me() {
202
+ return this.request("GET", "/api/auth/me");
203
+ }
204
+ async logout() {
205
+ await this.request("POST", "/api/auth/logout");
206
+ }
207
+ async listWorkspaces() {
208
+ return this.request("GET", "/api/workspaces");
209
+ }
210
+ async getWorkspaceUsage(workspaceId) {
211
+ return this.request("GET", `/api/workspaces/${workspaceId}/usage`);
212
+ }
213
+ async listProjects(workspaceId) {
214
+ return this.request("GET", `/api/workspaces/${workspaceId}/projects`);
215
+ }
216
+ async listBranches(wid, pid) {
217
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/branches`);
218
+ }
219
+ async mergeBranch(wid, pid, branch) {
220
+ await this.request("POST", `/api/workspaces/${wid}/projects/${pid}/branches/${encodeURIComponent(branch)}/merge`);
221
+ }
222
+ async rejectBranch(wid, pid, branch) {
223
+ await this.request("POST", `/api/workspaces/${wid}/projects/${pid}/branches/${encodeURIComponent(branch)}/reject`);
224
+ }
225
+ async listCdnKeys(wid, pid) {
226
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/cdn/keys`);
227
+ }
228
+ async createCdnKey(wid, pid, name) {
229
+ return this.request("POST", `/api/workspaces/${wid}/projects/${pid}/cdn/keys`, { body: { name } });
230
+ }
231
+ async getCdnSettings(wid, pid) {
232
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/cdn/settings`);
233
+ }
234
+ async triggerCdnBuild(wid, pid) {
235
+ return this.request("POST", `/api/workspaces/${wid}/projects/${pid}/cdn/builds/trigger`);
236
+ }
237
+ async listCdnBuilds(wid, pid, query) {
238
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/cdn/builds`, { query });
239
+ }
240
+ async listWebhooks(wid, pid) {
241
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/webhooks`);
242
+ }
243
+ async createWebhook(wid, pid, config) {
244
+ return this.request("POST", `/api/workspaces/${wid}/projects/${pid}/webhooks`, { body: config });
245
+ }
246
+ async deleteWebhook(wid, pid, webhookId) {
247
+ await this.request("DELETE", `/api/workspaces/${wid}/projects/${pid}/webhooks/${webhookId}`);
248
+ }
249
+ async testWebhook(wid, pid, webhookId) {
250
+ return this.request("POST", `/api/workspaces/${wid}/projects/${pid}/webhooks/${webhookId}/test`);
251
+ }
252
+ async listWebhookDeliveries(wid, pid, webhookId) {
253
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/webhooks/${webhookId}/deliveries`);
254
+ }
255
+ async listSubmissions(wid, pid, modelId, query) {
256
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/forms/${modelId}/submissions`, { query });
257
+ }
258
+ async updateSubmissionStatus(wid, pid, modelId, submissionId, status) {
259
+ await this.request("PATCH", `/api/workspaces/${wid}/projects/${pid}/forms/${modelId}/submissions/${submissionId}`, { body: { status } });
260
+ }
261
+ async getActivity(wid, pid, query) {
262
+ return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/activity`, { query });
263
+ }
264
+ };
265
+ /**
266
+ * Load credentials and return an authenticated StudioApiClient.
267
+ * Throws if the user is not logged in.
268
+ */
269
+ async function resolveStudioClient() {
270
+ const credentials = await loadCredentials();
271
+ if (!credentials) throw new Error("Not logged in. Run `contentrain studio login` first.");
272
+ return new StudioApiClient(credentials);
273
+ }
274
+ //#endregion
275
+ export { clearCredentials as a, saveDefaults as c, checkPermissions as i, resolveStudioClient as n, loadCredentials as o, AuthExpiredError as r, saveCredentials as s, StudioApiClient as t };
@@ -4,6 +4,10 @@ import { defineCommand } from "citty";
4
4
  import { confirm, intro, isCancel, log, outro, select } from "@clack/prompts";
5
5
  import { simpleGit } from "simple-git";
6
6
  import { readConfig } from "@contentrain/mcp/core/config";
7
+ import { CONTENTRAIN_BRANCH } from "@contentrain/types";
8
+ import { tmpdir } from "node:os";
9
+ import { randomUUID } from "node:crypto";
10
+ import { join } from "node:path";
7
11
  //#region src/commands/diff.ts
8
12
  var diff_default = defineCommand({
9
13
  meta: {
@@ -19,17 +23,16 @@ var diff_default = defineCommand({
19
23
  const projectRoot = await resolveProjectRoot(args.root);
20
24
  const git = simpleGit(projectRoot);
21
25
  intro(pc.bold("contentrain diff"));
22
- const branches = await git.branch(["--list", "contentrain/*"]);
23
- if (branches.all.length === 0) {
26
+ const featureBranches = (await git.branch(["--list", "cr/*"])).all.filter((b) => b !== CONTENTRAIN_BRANCH);
27
+ if (featureBranches.length === 0) {
24
28
  log.message("No pending contentrain branches.");
25
29
  outro("");
26
30
  return;
27
31
  }
28
- log.info(pc.bold(`Pending branches (${branches.all.length})`));
29
- const config = await readConfig(projectRoot);
30
- const baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
32
+ log.info(pc.bold(`Pending branches (${featureBranches.length})`));
33
+ const baseBranch = (await readConfig(projectRoot))?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
31
34
  const branchInfos = [];
32
- for (const branch of branches.all) try {
35
+ for (const branch of featureBranches) try {
33
36
  const diffStat = await git.diffSummary([`${baseBranch}...${branch}`]);
34
37
  branchInfos.push({
35
38
  name: branch,
@@ -92,13 +95,42 @@ var diff_default = defineCommand({
92
95
  }
93
96
  if (action === "merge") {
94
97
  const confirmMerge = await confirm({ message: `Merge ${selectedBranch} into ${baseBranch}?` });
95
- if (!isCancel(confirmMerge) && confirmMerge) try {
96
- await git.checkout(baseBranch);
97
- await git.merge([selectedBranch, "--no-edit"]);
98
- await git.deleteLocalBranch(selectedBranch, true);
99
- log.success(`Merged and deleted ${selectedBranch}`);
100
- } catch (error) {
101
- log.error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`);
98
+ if (!isCancel(confirmMerge) && confirmMerge) {
99
+ const mergePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
100
+ try {
101
+ if (!(await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
102
+ await git.raw([
103
+ "worktree",
104
+ "add",
105
+ mergePath,
106
+ CONTENTRAIN_BRANCH
107
+ ]);
108
+ const mergeGit = simpleGit(mergePath);
109
+ await mergeGit.merge([baseBranch, "--no-edit"]).catch(() => {});
110
+ await mergeGit.merge([selectedBranch, "--no-edit"]);
111
+ const tip = (await mergeGit.raw(["rev-parse", "HEAD"])).trim();
112
+ await git.raw([
113
+ "update-ref",
114
+ `refs/heads/${baseBranch}`,
115
+ tip
116
+ ]);
117
+ if ((await git.raw(["branch", "--show-current"])).trim() === baseBranch) await git.checkout([
118
+ tip,
119
+ "--",
120
+ ".contentrain/"
121
+ ]);
122
+ await git.deleteLocalBranch(selectedBranch, true);
123
+ log.success(`Merged and deleted ${selectedBranch}`);
124
+ } catch (error) {
125
+ log.error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`);
126
+ } finally {
127
+ await git.raw([
128
+ "worktree",
129
+ "remove",
130
+ mergePath,
131
+ "--force"
132
+ ]).catch(() => {});
133
+ }
102
134
  }
103
135
  } else if (action === "delete") {
104
136
  const confirmDelete = await confirm({ message: `Delete ${selectedBranch}? This cannot be undone.` });
package/dist/index.mjs CHANGED
@@ -5,17 +5,18 @@ import { defineCommand, runMain } from "citty";
5
5
  runMain(defineCommand({
6
6
  meta: {
7
7
  name: "contentrain",
8
- version: "0.3.1",
8
+ version: "0.3.3",
9
9
  description: "Contentrain CLI — AI content governance infrastructure"
10
10
  },
11
11
  subCommands: {
12
12
  init: () => import("./init-BARgfYUV.mjs").then((m) => m.default),
13
- status: () => import("./status-kF5miVty.mjs").then((m) => m.default),
13
+ status: () => import("./status-Bi9HYgcA.mjs").then((m) => m.default),
14
14
  doctor: () => import("./doctor-DyKjAIKH.mjs").then((m) => m.default),
15
15
  validate: () => import("./validate-DnVamp3p.mjs").then((m) => m.default),
16
- serve: () => import("./serve-CaCZMeHF.mjs").then((m) => m.default),
16
+ serve: () => import("./serve-DL4LhWxl.mjs").then((m) => m.default),
17
17
  generate: () => import("./generate-DDEDRLdy.mjs").then((m) => m.default),
18
- diff: () => import("./diff-otz0kGqT.mjs").then((m) => m.default)
18
+ diff: () => import("./diff-Cq6iSnaD.mjs").then((m) => m.default),
19
+ studio: () => import("./studio-BmXS_bCq.mjs").then((m) => m.default)
19
20
  }
20
21
  }));
21
22
  //#endregion
@@ -0,0 +1,243 @@
1
+ import { i as pc } from "./ui-B5mXontH.mjs";
2
+ import { i as checkPermissions, o as loadCredentials, s as saveCredentials, t as StudioApiClient } from "./client-D4xW8aLz.mjs";
3
+ import { defineCommand } from "citty";
4
+ import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
5
+ import { randomUUID } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ //#region src/studio/auth/oauth-server.ts
8
+ const PORT_RANGE_START = 9876;
9
+ const PORT_RANGE_END = 9899;
10
+ const DEFAULT_TIMEOUT_MS = 12e4;
11
+ const SUCCESS_HTML = `<!DOCTYPE html>
12
+ <html><head><meta charset="utf-8"><title>Contentrain CLI</title>
13
+ <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#fafafa}
14
+ .box{text-align:center;padding:2rem;border-radius:12px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.08)}
15
+ h1{font-size:1.5rem;margin:0 0 .5rem}p{color:#666;margin:0}</style></head>
16
+ <body><div class="box"><h1>Authenticated</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
17
+ /**
18
+ * Start an ephemeral OAuth callback server.
19
+ *
20
+ * Tries ports in the 9876-9899 range until one is available.
21
+ * The server shuts down automatically after a callback or timeout.
22
+ */
23
+ async function startOAuthServer(timeoutMs = DEFAULT_TIMEOUT_MS) {
24
+ const state = randomUUID();
25
+ const server = createServer();
26
+ const port = await listenOnAvailablePort(server);
27
+ const callbackUrl = `http://127.0.0.1:${port}/callback`;
28
+ let settled = false;
29
+ let resolveCallback;
30
+ let rejectCallback;
31
+ const callbackPromise = new Promise((resolve, reject) => {
32
+ resolveCallback = resolve;
33
+ rejectCallback = reject;
34
+ });
35
+ callbackPromise.catch(() => {});
36
+ const timer = setTimeout(() => {
37
+ if (!settled) {
38
+ settled = true;
39
+ closeServer(server);
40
+ rejectCallback(/* @__PURE__ */ new Error(`OAuth callback timed out after ${Math.round(timeoutMs / 1e3)}s. Please try again.`));
41
+ }
42
+ }, timeoutMs);
43
+ server.on("request", (req, res) => {
44
+ if (!req.url?.startsWith("/callback")) {
45
+ res.writeHead(404, { "Content-Type": "text/plain" });
46
+ res.end("Not found");
47
+ return;
48
+ }
49
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
50
+ const code = url.searchParams.get("code");
51
+ const returnedState = url.searchParams.get("state");
52
+ const error = url.searchParams.get("error");
53
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
54
+ res.end(SUCCESS_HTML);
55
+ if (settled) return;
56
+ settled = true;
57
+ clearTimeout(timer);
58
+ setTimeout(() => closeServer(server), 500);
59
+ if (error) {
60
+ rejectCallback(/* @__PURE__ */ new Error(`OAuth provider returned an error: ${error}`));
61
+ return;
62
+ }
63
+ if (!code || !returnedState) {
64
+ rejectCallback(/* @__PURE__ */ new Error("OAuth callback missing code or state parameter."));
65
+ return;
66
+ }
67
+ resolveCallback({
68
+ code,
69
+ state: returnedState
70
+ });
71
+ });
72
+ const close = () => {
73
+ if (!settled) {
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ rejectCallback(/* @__PURE__ */ new Error("OAuth flow cancelled."));
77
+ }
78
+ closeServer(server);
79
+ };
80
+ return {
81
+ callbackUrl,
82
+ state,
83
+ waitForCallback: () => callbackPromise,
84
+ close
85
+ };
86
+ }
87
+ function listenOnAvailablePort(server) {
88
+ return new Promise((resolve, reject) => {
89
+ let port = PORT_RANGE_START;
90
+ const tryPort = () => {
91
+ if (port > PORT_RANGE_END) {
92
+ reject(/* @__PURE__ */ new Error(`Could not find an available port in range ${PORT_RANGE_START}-${PORT_RANGE_END}. Close other processes or try again.`));
93
+ return;
94
+ }
95
+ server.once("error", (err) => {
96
+ if (err.code === "EADDRINUSE") {
97
+ port++;
98
+ tryPort();
99
+ } else reject(err);
100
+ });
101
+ server.listen(port, "127.0.0.1", () => {
102
+ resolve(port);
103
+ });
104
+ };
105
+ tryPort();
106
+ });
107
+ }
108
+ function closeServer(server) {
109
+ try {
110
+ server.close();
111
+ server.closeAllConnections();
112
+ } catch {}
113
+ }
114
+ //#endregion
115
+ //#region src/studio/commands/login.ts
116
+ const DEFAULT_STUDIO_URL = "https://studio.contentrain.io";
117
+ var login_default = defineCommand({
118
+ meta: {
119
+ name: "login",
120
+ description: "Authenticate with Contentrain Studio"
121
+ },
122
+ args: {
123
+ url: {
124
+ type: "string",
125
+ description: "Studio instance URL",
126
+ required: false
127
+ },
128
+ provider: {
129
+ type: "string",
130
+ description: "OAuth provider (github or google)",
131
+ required: false
132
+ }
133
+ },
134
+ async run({ args }) {
135
+ intro(pc.bold("contentrain studio login"));
136
+ try {
137
+ const studioUrl = (args.url ?? process.env["CONTENTRAIN_STUDIO_URL"] ?? DEFAULT_STUDIO_URL).replace(/\/+$/, "");
138
+ if (await loadCredentials()) {
139
+ const reAuth = await confirm({ message: "You are already logged in. Re-authenticate?" });
140
+ if (isCancel(reAuth) || !reAuth) {
141
+ outro(pc.dim("Cancelled"));
142
+ return;
143
+ }
144
+ }
145
+ let provider = args.provider;
146
+ if (!provider) {
147
+ const choice = await select({
148
+ message: "Sign in with",
149
+ options: [{
150
+ value: "github",
151
+ label: "GitHub"
152
+ }, {
153
+ value: "google",
154
+ label: "Google"
155
+ }]
156
+ });
157
+ if (isCancel(choice)) {
158
+ outro(pc.dim("Cancelled"));
159
+ return;
160
+ }
161
+ provider = choice;
162
+ }
163
+ const s = spinner();
164
+ s.start("Starting authentication...");
165
+ const oauth = await startOAuthServer();
166
+ const loginUrl = `${studioUrl}/api/auth/login?provider=${provider}&redirect_uri=${encodeURIComponent(oauth.callbackUrl)}&state=${oauth.state}`;
167
+ s.stop("Opening browser...");
168
+ log.info(`If the browser doesn't open, visit:\n ${pc.cyan(loginUrl)}`);
169
+ await openBrowser(loginUrl);
170
+ const s2 = spinner();
171
+ s2.start("Waiting for authentication...");
172
+ let result;
173
+ try {
174
+ result = await oauth.waitForCallback();
175
+ } catch (error) {
176
+ s2.stop("Failed");
177
+ oauth.close();
178
+ log.error(error instanceof Error ? error.message : String(error));
179
+ process.exitCode = 1;
180
+ outro("");
181
+ return;
182
+ }
183
+ if (result.state !== oauth.state) {
184
+ s2.stop("Failed");
185
+ log.error("OAuth state mismatch. This could be a CSRF attack. Please try again.");
186
+ process.exitCode = 1;
187
+ outro("");
188
+ return;
189
+ }
190
+ s2.stop("Exchanging token...");
191
+ const s3 = spinner();
192
+ s3.start("Completing authentication...");
193
+ const verifyRes = await globalThis.fetch(`${studioUrl}/api/auth/verify`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({
197
+ code: result.code,
198
+ state: result.state,
199
+ redirectUri: oauth.callbackUrl
200
+ })
201
+ });
202
+ if (!verifyRes.ok) {
203
+ s3.stop("Failed");
204
+ log.error("Token exchange failed. Please try again.");
205
+ process.exitCode = 1;
206
+ outro("");
207
+ return;
208
+ }
209
+ const tokenData = await verifyRes.json();
210
+ await saveCredentials({
211
+ studioUrl,
212
+ accessToken: tokenData.accessToken,
213
+ refreshToken: tokenData.refreshToken,
214
+ expiresAt: tokenData.expiresAt
215
+ });
216
+ const user = await new StudioApiClient({
217
+ studioUrl,
218
+ accessToken: tokenData.accessToken,
219
+ refreshToken: tokenData.refreshToken,
220
+ expiresAt: tokenData.expiresAt
221
+ }).me();
222
+ s3.stop("Authenticated");
223
+ log.success(`Logged in as ${pc.bold(user.email)}`);
224
+ const permWarning = await checkPermissions();
225
+ if (permWarning) log.warning(permWarning);
226
+ } catch (error) {
227
+ log.error(error instanceof Error ? error.message : String(error));
228
+ process.exitCode = 1;
229
+ }
230
+ outro("");
231
+ }
232
+ });
233
+ async function openBrowser(url) {
234
+ const { exec } = await import("node:child_process");
235
+ const command = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
236
+ return new Promise((resolve) => {
237
+ exec(command, () => {
238
+ resolve();
239
+ });
240
+ });
241
+ }
242
+ //#endregion
243
+ export { login_default as default };
@@ -0,0 +1,29 @@
1
+ import { i as pc } from "./ui-B5mXontH.mjs";
2
+ import { a as clearCredentials, o as loadCredentials, t as StudioApiClient } from "./client-D4xW8aLz.mjs";
3
+ import { defineCommand } from "citty";
4
+ import { intro, log, outro } from "@clack/prompts";
5
+ //#region src/studio/commands/logout.ts
6
+ var logout_default = defineCommand({
7
+ meta: {
8
+ name: "logout",
9
+ description: "Log out from Contentrain Studio"
10
+ },
11
+ args: {},
12
+ async run() {
13
+ intro(pc.bold("contentrain studio logout"));
14
+ const credentials = await loadCredentials();
15
+ if (!credentials) {
16
+ log.info("Not currently logged in.");
17
+ outro("");
18
+ return;
19
+ }
20
+ try {
21
+ await new StudioApiClient(credentials).logout();
22
+ } catch {}
23
+ await clearCredentials();
24
+ log.success("Logged out successfully.");
25
+ outro("");
26
+ }
27
+ });
28
+ //#endregion
29
+ export { logout_default as default };