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.
- package/README.md +56 -2
- package/dist/activity-CtOcAA4X.mjs +88 -0
- package/dist/branches-C_kgqSv9.mjs +142 -0
- package/dist/cdn-build-CDv_yM15.mjs +106 -0
- package/dist/cdn-init-DXQ-3V_c.mjs +90 -0
- package/dist/client-D4xW8aLz.mjs +275 -0
- package/dist/{diff-otz0kGqT.mjs → diff-Cq6iSnaD.mjs} +45 -13
- package/dist/index.mjs +5 -4
- package/dist/login-CO0hxICh.mjs +243 -0
- package/dist/logout-CRM4fCSH.mjs +29 -0
- package/dist/resolve-context-BMV7JwCw.mjs +58 -0
- package/dist/{serve-CaCZMeHF.mjs → serve-DL4LhWxl.mjs} +1 -1
- package/dist/{server-DW0KXB0T.mjs → server-WLDOAZa4.mjs} +94 -36
- package/dist/{status-kF5miVty.mjs → status-Bi9HYgcA.mjs} +38 -5
- package/dist/status-ts5FvDEZ.mjs +128 -0
- package/dist/studio-BmXS_bCq.mjs +23 -0
- package/dist/submissions-6_OCrUNF.mjs +169 -0
- package/dist/usage-BAb4q7Pz.mjs +81 -0
- package/dist/webhooks-C39IItU1.mjs +235 -0
- package/dist/whoami-D8NyFKs_.mjs +65 -0
- package/package.json +6 -6
|
@@ -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
|
|
23
|
-
if (
|
|
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 (${
|
|
29
|
-
const
|
|
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
|
|
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)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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 };
|