contentrain 0.3.4 → 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 CHANGED
@@ -58,6 +58,7 @@ Requirements:
58
58
  | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports |
59
59
  | `contentrain diff` | Review and merge or reject pending `contentrain/*` branches |
60
60
  | `contentrain serve` | Start the local review UI or the MCP stdio server |
61
+ | `contentrain studio connect` | Connect a repository to a Studio project |
61
62
  | `contentrain studio login` | Authenticate with Contentrain Studio |
62
63
  | `contentrain studio logout` | Log out from Studio |
63
64
  | `contentrain studio whoami` | Show current authentication status |
@@ -172,11 +173,18 @@ to understand:
172
173
 
173
174
  The `studio` command group connects the CLI to [Contentrain Studio](https://studio.contentrain.io) for enterprise workflows.
174
175
 
175
- Authenticate:
176
+ Authenticate and connect:
176
177
 
177
178
  ```bash
178
179
  contentrain studio login
179
180
  contentrain studio whoami
181
+ contentrain studio connect
182
+ ```
183
+
184
+ The `connect` command links your local repository to a Studio project. It detects the git remote, verifies GitHub App installation, scans for `.contentrain/` configuration, and creates the project — all in one interactive flow.
185
+
186
+ ```bash
187
+ contentrain studio connect --workspace ws-123
180
188
  ```
181
189
 
182
190
  Set up CDN for content delivery:
@@ -1,6 +1,6 @@
1
1
  import { i as pc } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/activity.ts
@@ -1,6 +1,6 @@
1
1
  import { i as pc, r as formatTable, t as formatCount } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/branches.ts
@@ -1,6 +1,6 @@
1
1
  import { i as pc, t as formatCount } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/cdn-build.ts
@@ -1,6 +1,6 @@
1
1
  import { i as pc } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { confirm, intro, isCancel, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/cdn-init.ts
@@ -213,6 +213,24 @@ var StudioApiClient = class {
213
213
  async listProjects(workspaceId) {
214
214
  return this.request("GET", `/api/workspaces/${workspaceId}/projects`);
215
215
  }
216
+ async createProject(workspaceId, payload) {
217
+ return this.request("POST", `/api/workspaces/${workspaceId}/projects`, { body: payload });
218
+ }
219
+ async listGitHubInstallations() {
220
+ return this.request("GET", "/api/github/installations");
221
+ }
222
+ async getGitHubSetupUrl() {
223
+ return this.request("GET", "/api/github/setup");
224
+ }
225
+ async listGitHubRepos(installationId) {
226
+ return this.request("GET", "/api/github/repos", { query: { installationId: String(installationId) } });
227
+ }
228
+ async scanRepository(installationId, repoFullName) {
229
+ return this.request("GET", "/api/github/scan", { query: {
230
+ installationId: String(installationId),
231
+ repo: repoFullName
232
+ } });
233
+ }
216
234
  async listBranches(wid, pid) {
217
235
  return this.request("GET", `/api/workspaces/${wid}/projects/${pid}/branches`);
218
236
  }
@@ -0,0 +1,252 @@
1
+ import { i as pc } from "./ui-B5mXontH.mjs";
2
+ import { c as saveDefaults, n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { n as openBrowser, t as startOAuthServer } from "./oauth-server-1p6y1H30.mjs";
4
+ import { defineCommand } from "citty";
5
+ import { confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
6
+ import { simpleGit } from "simple-git";
7
+ //#region src/studio/commands/connect.ts
8
+ var connect_default = defineCommand({
9
+ meta: {
10
+ name: "connect",
11
+ description: "Connect this repository to a Contentrain Studio project"
12
+ },
13
+ args: {
14
+ workspace: {
15
+ type: "string",
16
+ description: "Workspace ID",
17
+ required: false
18
+ },
19
+ json: {
20
+ type: "boolean",
21
+ description: "JSON output",
22
+ required: false
23
+ }
24
+ },
25
+ async run({ args }) {
26
+ if (!args.json) intro(pc.bold("contentrain studio connect"));
27
+ try {
28
+ const client = await resolveStudioClient();
29
+ const s1 = args.json ? null : spinner();
30
+ s1?.start("Loading workspaces...");
31
+ const workspaces = await client.listWorkspaces();
32
+ s1?.stop(`${workspaces.length} workspace(s) found`);
33
+ if (workspaces.length === 0) {
34
+ log.warning("No workspaces found. Create one at studio.contentrain.io first.");
35
+ outro("");
36
+ return;
37
+ }
38
+ let workspaceId = args.workspace;
39
+ if (!workspaceId) if (workspaces.length === 1) {
40
+ workspaceId = workspaces[0].id;
41
+ if (!args.json) log.info(`Using workspace: ${pc.cyan(workspaces[0].name)}`);
42
+ } else {
43
+ const choice = await select({
44
+ message: "Select workspace",
45
+ options: workspaces.map((w) => ({
46
+ value: w.id,
47
+ label: w.name,
48
+ hint: w.plan
49
+ }))
50
+ });
51
+ if (isCancel(choice)) {
52
+ outro(pc.dim("Cancelled"));
53
+ return;
54
+ }
55
+ workspaceId = choice;
56
+ }
57
+ const workspace = workspaces.find((w) => w.id === workspaceId);
58
+ const s2 = args.json ? null : spinner();
59
+ s2?.start("Checking GitHub App installation...");
60
+ let installations = await client.listGitHubInstallations();
61
+ s2?.stop(installations.length > 0 ? `${installations.length} GitHub installation(s) found` : "No GitHub App installed");
62
+ if (installations.length === 0) {
63
+ const shouldInstall = await confirm({ message: "The Contentrain GitHub App is required. Install it now?" });
64
+ if (isCancel(shouldInstall) || !shouldInstall) {
65
+ outro(pc.dim("Cancelled"));
66
+ return;
67
+ }
68
+ const setupData = await client.getGitHubSetupUrl();
69
+ const oauth = await startOAuthServer();
70
+ const installUrl = `${setupData.url}&redirect_uri=${encodeURIComponent(oauth.callbackUrl)}&state=${oauth.state}`;
71
+ if (!args.json) log.info(`If the browser doesn't open, visit:\n ${pc.cyan(installUrl)}`);
72
+ await openBrowser(installUrl);
73
+ const s2b = args.json ? null : spinner();
74
+ s2b?.start("Waiting for GitHub App installation...");
75
+ try {
76
+ if ((await oauth.waitForCallback()).state !== oauth.state) {
77
+ s2b?.stop("Failed");
78
+ log.error("State mismatch. Please try again.");
79
+ process.exitCode = 1;
80
+ outro("");
81
+ return;
82
+ }
83
+ s2b?.stop("GitHub App installed");
84
+ } catch (error) {
85
+ s2b?.stop("Failed");
86
+ oauth.close();
87
+ log.error(error instanceof Error ? error.message : String(error));
88
+ process.exitCode = 1;
89
+ outro("");
90
+ return;
91
+ }
92
+ installations = await client.listGitHubInstallations();
93
+ if (installations.length === 0) {
94
+ log.error("GitHub App installation not detected. Please try again.");
95
+ process.exitCode = 1;
96
+ outro("");
97
+ return;
98
+ }
99
+ }
100
+ let installation;
101
+ if (installations.length === 1) {
102
+ installation = installations[0];
103
+ if (!args.json) log.info(`Using GitHub account: ${pc.cyan(installation.accountLogin)}`);
104
+ } else {
105
+ const choice = await select({
106
+ message: "Select GitHub account",
107
+ options: installations.map((inst) => ({
108
+ value: String(inst.id),
109
+ label: inst.accountLogin,
110
+ hint: inst.accountType
111
+ }))
112
+ });
113
+ if (isCancel(choice)) {
114
+ outro(pc.dim("Cancelled"));
115
+ return;
116
+ }
117
+ installation = installations.find((i) => String(i.id) === choice);
118
+ }
119
+ const s3 = args.json ? null : spinner();
120
+ s3?.start("Detecting git remote...");
121
+ let detectedRepoFullName = null;
122
+ try {
123
+ const origin = (await simpleGit(process.cwd()).getRemotes(true)).find((r) => r.name === "origin");
124
+ if (origin?.refs?.fetch) detectedRepoFullName = parseGitHubRepoFromUrl(origin.refs.fetch);
125
+ } catch {}
126
+ const repos = await client.listGitHubRepos(installation.id);
127
+ s3?.stop(detectedRepoFullName ? `Detected: ${pc.cyan(detectedRepoFullName)}` : `${repos.length} accessible repo(s)`);
128
+ let selectedRepo = null;
129
+ if (detectedRepoFullName) {
130
+ const match = repos.find((r) => r.fullName === detectedRepoFullName);
131
+ if (match) {
132
+ const useDetected = await confirm({ message: `Connect to ${pc.cyan(match.fullName)}?` });
133
+ if (isCancel(useDetected)) {
134
+ outro(pc.dim("Cancelled"));
135
+ return;
136
+ }
137
+ selectedRepo = useDetected ? match : await pickRepo(repos);
138
+ } else {
139
+ if (!args.json) log.warning(`Detected remote "${detectedRepoFullName}" is not accessible to the GitHub App.`);
140
+ selectedRepo = await pickRepo(repos);
141
+ }
142
+ } else {
143
+ if (repos.length === 0) {
144
+ log.error("No repositories accessible. Grant the GitHub App access to your repos.");
145
+ process.exitCode = 1;
146
+ outro("");
147
+ return;
148
+ }
149
+ selectedRepo = await pickRepo(repos);
150
+ }
151
+ if (!selectedRepo) {
152
+ outro(pc.dim("Cancelled"));
153
+ return;
154
+ }
155
+ const s4 = args.json ? null : spinner();
156
+ s4?.start("Scanning repository...");
157
+ const scan = await client.scanRepository(installation.id, selectedRepo.fullName);
158
+ s4?.stop(scan.hasContentrain ? "Contentrain configuration found" : "No Contentrain configuration found");
159
+ if (!scan.hasContentrain) {
160
+ if (!args.json) {
161
+ log.warning("No .contentrain/ directory found in the repository.");
162
+ log.info(`Run ${pc.cyan("contentrain init")} in your project first, then push to ${selectedRepo.defaultBranch}.`);
163
+ }
164
+ const proceed = await confirm({ message: "Continue anyway? (You can initialize later)" });
165
+ if (isCancel(proceed) || !proceed) {
166
+ outro(pc.dim("Cancelled"));
167
+ return;
168
+ }
169
+ } else if (!args.json) log.info(`Found ${scan.models.length} model(s), ${scan.locales.length} locale(s)`);
170
+ const projectName = await text({
171
+ message: "Project name",
172
+ initialValue: selectedRepo.fullName.split("/")[1] ?? selectedRepo.fullName,
173
+ validate: (v) => {
174
+ if (!v.trim()) return "Name is required";
175
+ }
176
+ });
177
+ if (isCancel(projectName)) {
178
+ outro(pc.dim("Cancelled"));
179
+ return;
180
+ }
181
+ const s5 = args.json ? null : spinner();
182
+ s5?.start("Creating project...");
183
+ const project = await client.createProject(workspaceId, {
184
+ name: projectName,
185
+ installationId: installation.id,
186
+ repositoryFullName: selectedRepo.fullName,
187
+ defaultBranch: selectedRepo.defaultBranch
188
+ });
189
+ s5?.stop("Project created");
190
+ await saveDefaults(workspaceId, project.id);
191
+ if (args.json) {
192
+ const output = {
193
+ workspace: {
194
+ id: workspace.id,
195
+ name: workspace.name
196
+ },
197
+ project: {
198
+ id: project.id,
199
+ name: project.name
200
+ },
201
+ repository: selectedRepo.fullName,
202
+ scan
203
+ };
204
+ process.stdout.write(JSON.stringify(output, null, 2));
205
+ return;
206
+ }
207
+ log.success(pc.bold("Project connected!"));
208
+ log.message("");
209
+ log.message(` Workspace: ${pc.cyan(workspace.name)}`);
210
+ log.message(` Project: ${pc.cyan(project.name)}`);
211
+ log.message(` Repository: ${pc.cyan(selectedRepo.fullName)}`);
212
+ log.message(` Branch: ${selectedRepo.defaultBranch}`);
213
+ log.message("");
214
+ log.info("Next steps:");
215
+ log.message(` ${pc.cyan("contentrain studio status")} — view project overview`);
216
+ log.message(` ${pc.cyan("contentrain studio cdn-init")} — set up content delivery`);
217
+ log.message(` ${pc.cyan("contentrain studio webhooks")} — configure webhooks`);
218
+ } catch (error) {
219
+ log.error(error instanceof Error ? error.message : String(error));
220
+ process.exitCode = 1;
221
+ }
222
+ if (!args.json) outro("");
223
+ }
224
+ });
225
+ /**
226
+ * Parse owner/repo from a GitHub remote URL.
227
+ * Handles both SSH (git@github.com:owner/repo.git) and HTTPS formats.
228
+ */
229
+ function parseGitHubRepoFromUrl(url) {
230
+ const sshMatch = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
231
+ if (sshMatch) return sshMatch[1];
232
+ try {
233
+ const parsed = new URL(url);
234
+ if (parsed.hostname === "github.com") return parsed.pathname.replace(/^\//, "").replace(/\.git$/, "");
235
+ } catch {}
236
+ return null;
237
+ }
238
+ async function pickRepo(repos) {
239
+ if (repos.length === 0) return null;
240
+ const choice = await select({
241
+ message: "Select repository",
242
+ options: repos.map((r) => ({
243
+ value: r.fullName,
244
+ label: r.fullName,
245
+ hint: r.private ? "private" : "public"
246
+ }))
247
+ });
248
+ if (isCancel(choice)) return null;
249
+ return repos.find((r) => r.fullName === choice) ?? null;
250
+ }
251
+ //#endregion
252
+ export { connect_default as default };
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { defineCommand, runMain } from "citty";
5
5
  runMain(defineCommand({
6
6
  meta: {
7
7
  name: "contentrain",
8
- version: "0.3.4",
8
+ version: "0.4.0",
9
9
  description: "Contentrain CLI — AI content governance infrastructure"
10
10
  },
11
11
  subCommands: {
@@ -16,7 +16,7 @@ runMain(defineCommand({
16
16
  serve: () => import("./serve-DL4LhWxl.mjs").then((m) => m.default),
17
17
  generate: () => import("./generate-DDEDRLdy.mjs").then((m) => m.default),
18
18
  diff: () => import("./diff-Cq6iSnaD.mjs").then((m) => m.default),
19
- studio: () => import("./studio-BmXS_bCq.mjs").then((m) => m.default)
19
+ studio: () => import("./studio-B8lF0fQx.mjs").then((m) => m.default)
20
20
  }
21
21
  }));
22
22
  //#endregion
@@ -0,0 +1,125 @@
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-CLV4XoJz.mjs";
3
+ import { n as openBrowser, t as startOAuthServer } from "./oauth-server-1p6y1H30.mjs";
4
+ import { defineCommand } from "citty";
5
+ import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
6
+ //#region src/studio/commands/login.ts
7
+ const DEFAULT_STUDIO_URL = "https://studio.contentrain.io";
8
+ var login_default = defineCommand({
9
+ meta: {
10
+ name: "login",
11
+ description: "Authenticate with Contentrain Studio"
12
+ },
13
+ args: {
14
+ url: {
15
+ type: "string",
16
+ description: "Studio instance URL",
17
+ required: false
18
+ },
19
+ provider: {
20
+ type: "string",
21
+ description: "OAuth provider (github or google)",
22
+ required: false
23
+ }
24
+ },
25
+ async run({ args }) {
26
+ intro(pc.bold("contentrain studio login"));
27
+ try {
28
+ const studioUrl = (args.url ?? process.env["CONTENTRAIN_STUDIO_URL"] ?? DEFAULT_STUDIO_URL).replace(/\/+$/, "");
29
+ if (await loadCredentials()) {
30
+ const reAuth = await confirm({ message: "You are already logged in. Re-authenticate?" });
31
+ if (isCancel(reAuth) || !reAuth) {
32
+ outro(pc.dim("Cancelled"));
33
+ return;
34
+ }
35
+ }
36
+ let provider = args.provider;
37
+ if (!provider) {
38
+ const choice = await select({
39
+ message: "Sign in with",
40
+ options: [{
41
+ value: "github",
42
+ label: "GitHub"
43
+ }, {
44
+ value: "google",
45
+ label: "Google"
46
+ }]
47
+ });
48
+ if (isCancel(choice)) {
49
+ outro(pc.dim("Cancelled"));
50
+ return;
51
+ }
52
+ provider = choice;
53
+ }
54
+ const s = spinner();
55
+ s.start("Starting authentication...");
56
+ const oauth = await startOAuthServer();
57
+ const loginUrl = `${studioUrl}/api/auth/login?provider=${provider}&redirect_uri=${encodeURIComponent(oauth.callbackUrl)}&state=${oauth.state}`;
58
+ s.stop("Opening browser...");
59
+ log.info(`If the browser doesn't open, visit:\n ${pc.cyan(loginUrl)}`);
60
+ await openBrowser(loginUrl);
61
+ const s2 = spinner();
62
+ s2.start("Waiting for authentication...");
63
+ let result;
64
+ try {
65
+ result = await oauth.waitForCallback();
66
+ } catch (error) {
67
+ s2.stop("Failed");
68
+ oauth.close();
69
+ log.error(error instanceof Error ? error.message : String(error));
70
+ process.exitCode = 1;
71
+ outro("");
72
+ return;
73
+ }
74
+ if (result.state !== oauth.state) {
75
+ s2.stop("Failed");
76
+ log.error("OAuth state mismatch. This could be a CSRF attack. Please try again.");
77
+ process.exitCode = 1;
78
+ outro("");
79
+ return;
80
+ }
81
+ s2.stop("Exchanging token...");
82
+ const s3 = spinner();
83
+ s3.start("Completing authentication...");
84
+ const verifyRes = await globalThis.fetch(`${studioUrl}/api/auth/verify`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({
88
+ code: result.code,
89
+ state: result.state,
90
+ redirectUri: oauth.callbackUrl
91
+ })
92
+ });
93
+ if (!verifyRes.ok) {
94
+ s3.stop("Failed");
95
+ log.error("Token exchange failed. Please try again.");
96
+ process.exitCode = 1;
97
+ outro("");
98
+ return;
99
+ }
100
+ const tokenData = await verifyRes.json();
101
+ await saveCredentials({
102
+ studioUrl,
103
+ accessToken: tokenData.accessToken,
104
+ refreshToken: tokenData.refreshToken,
105
+ expiresAt: tokenData.expiresAt
106
+ });
107
+ const user = await new StudioApiClient({
108
+ studioUrl,
109
+ accessToken: tokenData.accessToken,
110
+ refreshToken: tokenData.refreshToken,
111
+ expiresAt: tokenData.expiresAt
112
+ }).me();
113
+ s3.stop("Authenticated");
114
+ log.success(`Logged in as ${pc.bold(user.email)}`);
115
+ const permWarning = await checkPermissions();
116
+ if (permWarning) log.warning(permWarning);
117
+ } catch (error) {
118
+ log.error(error instanceof Error ? error.message : String(error));
119
+ process.exitCode = 1;
120
+ }
121
+ outro("");
122
+ }
123
+ });
124
+ //#endregion
125
+ export { login_default as default };
@@ -1,5 +1,5 @@
1
1
  import { i as pc } from "./ui-B5mXontH.mjs";
2
- import { a as clearCredentials, o as loadCredentials, t as StudioApiClient } from "./client-D4xW8aLz.mjs";
2
+ import { a as clearCredentials, o as loadCredentials, t as StudioApiClient } from "./client-CLV4XoJz.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { intro, log, outro } from "@clack/prompts";
5
5
  //#region src/studio/commands/logout.ts
@@ -1,9 +1,19 @@
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
1
  import { randomUUID } from "node:crypto";
6
2
  import { createServer } from "node:http";
3
+ //#region src/utils/browser.ts
4
+ /**
5
+ * Open a URL in the user's default browser (best-effort, platform-aware).
6
+ */
7
+ async function openBrowser(url) {
8
+ const { exec } = await import("node:child_process");
9
+ const command = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
10
+ return new Promise((resolve) => {
11
+ exec(command, () => {
12
+ resolve();
13
+ });
14
+ });
15
+ }
16
+ //#endregion
7
17
  //#region src/studio/auth/oauth-server.ts
8
18
  const PORT_RANGE_START = 9876;
9
19
  const PORT_RANGE_END = 9899;
@@ -112,132 +122,4 @@ function closeServer(server) {
112
122
  } catch {}
113
123
  }
114
124
  //#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 };
125
+ export { openBrowser as n, startOAuthServer as t };
@@ -1,4 +1,4 @@
1
- import { c as saveDefaults, o as loadCredentials } from "./client-D4xW8aLz.mjs";
1
+ import { c as saveDefaults, o as loadCredentials } from "./client-CLV4XoJz.mjs";
2
2
  import { confirm, isCancel, select } from "@clack/prompts";
3
3
  //#region src/studio/resolve-context.ts
4
4
  /**
@@ -1,6 +1,6 @@
1
1
  import { i as pc, r as formatTable, t as formatCount } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/status.ts
@@ -0,0 +1,24 @@
1
+ import { defineCommand } from "citty";
2
+ //#region src/studio/index.ts
3
+ var studio_default = defineCommand({
4
+ meta: {
5
+ name: "studio",
6
+ description: "Interact with Contentrain Studio"
7
+ },
8
+ subCommands: {
9
+ login: () => import("./login-wRBvHDCi.mjs").then((m) => m.default),
10
+ logout: () => import("./logout-xPTGu6vx.mjs").then((m) => m.default),
11
+ whoami: () => import("./whoami-CWvTYzum.mjs").then((m) => m.default),
12
+ connect: () => import("./connect-DiSYnxFd.mjs").then((m) => m.default),
13
+ status: () => import("./status-UvjbO4eS.mjs").then((m) => m.default),
14
+ activity: () => import("./activity-DMQsYlzz.mjs").then((m) => m.default),
15
+ usage: () => import("./usage-BqriRRqE.mjs").then((m) => m.default),
16
+ branches: () => import("./branches-D330IxDO.mjs").then((m) => m.default),
17
+ "cdn-init": () => import("./cdn-init-Bhnil0F8.mjs").then((m) => m.default),
18
+ "cdn-build": () => import("./cdn-build-D_d3p4OZ.mjs").then((m) => m.default),
19
+ webhooks: () => import("./webhooks-B1OhcCtZ.mjs").then((m) => m.default),
20
+ submissions: () => import("./submissions-Dw0ChFDi.mjs").then((m) => m.default)
21
+ }
22
+ });
23
+ //#endregion
24
+ export { studio_default as default };
@@ -1,6 +1,6 @@
1
1
  import { i as pc, r as formatTable, t as formatCount } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/submissions.ts
@@ -1,6 +1,6 @@
1
1
  import { i as pc, n as formatPercent, r as formatTable } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { intro, log, outro, spinner } from "@clack/prompts";
6
6
  //#region src/studio/commands/usage.ts
@@ -1,6 +1,6 @@
1
1
  import { i as pc, o as statusIcon, r as formatTable } from "./ui-B5mXontH.mjs";
2
- import { n as resolveStudioClient } from "./client-D4xW8aLz.mjs";
3
- import { t as resolveStudioContext } from "./resolve-context-BMV7JwCw.mjs";
2
+ import { n as resolveStudioClient } from "./client-CLV4XoJz.mjs";
3
+ import { t as resolveStudioContext } from "./resolve-context-BwcikcF0.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { confirm, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts";
6
6
  //#region src/studio/commands/webhooks.ts
@@ -1,5 +1,5 @@
1
1
  import { i as pc } from "./ui-B5mXontH.mjs";
2
- import { i as checkPermissions, n as resolveStudioClient, r as AuthExpiredError } from "./client-D4xW8aLz.mjs";
2
+ import { i as checkPermissions, n as resolveStudioClient, r as AuthExpiredError } from "./client-CLV4XoJz.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { intro, log, outro } from "@clack/prompts";
5
5
  //#region src/studio/commands/whoami.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contentrain",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "CLI for Contentrain — AI content governance infrastructure",
6
6
  "type": "module",
@@ -47,9 +47,9 @@
47
47
  "simple-git": "^3.27.0",
48
48
  "ws": "^8.18.0",
49
49
  "@contentrain/rules": "0.3.1",
50
+ "@contentrain/mcp": "1.1.2",
50
51
  "@contentrain/query": "5.1.3",
51
- "@contentrain/types": "0.4.1",
52
- "@contentrain/mcp": "1.1.2"
52
+ "@contentrain/types": "0.4.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/ws": "^8.5.0",
@@ -1,23 +0,0 @@
1
- import { defineCommand } from "citty";
2
- //#region src/studio/index.ts
3
- var studio_default = defineCommand({
4
- meta: {
5
- name: "studio",
6
- description: "Interact with Contentrain Studio"
7
- },
8
- subCommands: {
9
- login: () => import("./login-CO0hxICh.mjs").then((m) => m.default),
10
- logout: () => import("./logout-CRM4fCSH.mjs").then((m) => m.default),
11
- whoami: () => import("./whoami-D8NyFKs_.mjs").then((m) => m.default),
12
- status: () => import("./status-ts5FvDEZ.mjs").then((m) => m.default),
13
- activity: () => import("./activity-CtOcAA4X.mjs").then((m) => m.default),
14
- usage: () => import("./usage-BAb4q7Pz.mjs").then((m) => m.default),
15
- branches: () => import("./branches-C_kgqSv9.mjs").then((m) => m.default),
16
- "cdn-init": () => import("./cdn-init-DXQ-3V_c.mjs").then((m) => m.default),
17
- "cdn-build": () => import("./cdn-build-CDv_yM15.mjs").then((m) => m.default),
18
- webhooks: () => import("./webhooks-C39IItU1.mjs").then((m) => m.default),
19
- submissions: () => import("./submissions-6_OCrUNF.mjs").then((m) => m.default)
20
- }
21
- });
22
- //#endregion
23
- export { studio_default as default };