@perceo/perceo 0.1.1 → 0.3.1
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/dist/chunk-Y65GRRTT.js +197 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1795 -0
- package/dist/login-47R62VE3.js +6 -0
- package/package.json +8 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1795 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
clearStoredAuth,
|
|
4
|
+
getEffectiveAuth,
|
|
5
|
+
getStoredAuth,
|
|
6
|
+
isLoggedIn,
|
|
7
|
+
loginCommand
|
|
8
|
+
} from "./chunk-Y65GRRTT.js";
|
|
9
|
+
|
|
10
|
+
// src/index.ts
|
|
11
|
+
import { Command as Command6 } from "commander";
|
|
12
|
+
|
|
13
|
+
// src/commands/init.ts
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
import chalk3 from "chalk";
|
|
16
|
+
import ora2 from "ora";
|
|
17
|
+
import fs2 from "fs/promises";
|
|
18
|
+
import path3 from "path";
|
|
19
|
+
import { execSync as execSync2 } from "child_process";
|
|
20
|
+
|
|
21
|
+
// src/projectAccess.ts
|
|
22
|
+
import path2 from "path";
|
|
23
|
+
import chalk from "chalk";
|
|
24
|
+
import { PerceoDataClient, getSupabaseAnonKey } from "@perceo/supabase";
|
|
25
|
+
|
|
26
|
+
// src/config.ts
|
|
27
|
+
import fs from "fs/promises";
|
|
28
|
+
import path from "path";
|
|
29
|
+
var CONFIG_DIR = ".perceo";
|
|
30
|
+
var BASE_CONFIG_FILE = "config.json";
|
|
31
|
+
var LOCAL_CONFIG_FILE = "config.local.json";
|
|
32
|
+
async function loadConfig(options = {}) {
|
|
33
|
+
const projectDir = path.resolve(options.projectDir ?? process.cwd());
|
|
34
|
+
const envPath = process.env.PERCEO_CONFIG_PATH;
|
|
35
|
+
const baseConfigPath = envPath ? resolveMaybeRelativePath(envPath, projectDir) : path.join(projectDir, CONFIG_DIR, BASE_CONFIG_FILE);
|
|
36
|
+
let baseConfig = await readJsonFile(baseConfigPath);
|
|
37
|
+
const isLocalEnv = (process.env.PERCEO_ENV || "").toLowerCase() === "local" || process.env.NODE_ENV === "development";
|
|
38
|
+
if (isLocalEnv) {
|
|
39
|
+
const localConfigPath = path.join(projectDir, CONFIG_DIR, LOCAL_CONFIG_FILE);
|
|
40
|
+
const localExists = await fileExists(localConfigPath);
|
|
41
|
+
if (localExists) {
|
|
42
|
+
const localConfig = await readJsonFile(localConfigPath);
|
|
43
|
+
baseConfig = deepMerge(baseConfig, localConfig);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (process.env.PERCEO_TEMPORAL_ENABLED === "true") {
|
|
47
|
+
baseConfig.temporal = {
|
|
48
|
+
enabled: true,
|
|
49
|
+
address: process.env.PERCEO_TEMPORAL_ADDRESS || "localhost:7233",
|
|
50
|
+
namespace: process.env.PERCEO_TEMPORAL_NAMESPACE || "perceo",
|
|
51
|
+
taskQueue: process.env.PERCEO_TEMPORAL_TASK_QUEUE || "observer-engine"
|
|
52
|
+
};
|
|
53
|
+
if (process.env.PERCEO_TEMPORAL_TLS_CERT_PATH) {
|
|
54
|
+
baseConfig.temporal.tls = {
|
|
55
|
+
certPath: process.env.PERCEO_TEMPORAL_TLS_CERT_PATH,
|
|
56
|
+
keyPath: process.env.PERCEO_TEMPORAL_TLS_KEY_PATH || ""
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return baseConfig;
|
|
61
|
+
}
|
|
62
|
+
async function readJsonFile(filePath) {
|
|
63
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
async function fileExists(p) {
|
|
67
|
+
try {
|
|
68
|
+
await fs.access(p);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function resolveMaybeRelativePath(p, projectDir) {
|
|
75
|
+
if (path.isAbsolute(p)) return p;
|
|
76
|
+
return path.join(projectDir, p);
|
|
77
|
+
}
|
|
78
|
+
function deepMerge(target, source) {
|
|
79
|
+
if (Array.isArray(target) && Array.isArray(source)) {
|
|
80
|
+
return source;
|
|
81
|
+
}
|
|
82
|
+
if (isPlainObject(target) && isPlainObject(source)) {
|
|
83
|
+
const result = { ...target };
|
|
84
|
+
for (const key of Object.keys(source)) {
|
|
85
|
+
if (key in target) {
|
|
86
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
87
|
+
} else {
|
|
88
|
+
result[key] = source[key];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
return source;
|
|
94
|
+
}
|
|
95
|
+
function isPlainObject(value) {
|
|
96
|
+
return typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/projectAccess.ts
|
|
100
|
+
var ADMIN_ROLES = ["owner", "admin"];
|
|
101
|
+
async function ensureProjectAccess(options = {}) {
|
|
102
|
+
const projectDir = path2.resolve(options.projectDir ?? process.cwd());
|
|
103
|
+
const requireAdmin = options.requireAdmin ?? false;
|
|
104
|
+
const auth = await getEffectiveAuth(projectDir);
|
|
105
|
+
if (!auth?.access_token) {
|
|
106
|
+
console.error(chalk.red("You must log in first. Run ") + chalk.cyan("perceo login"));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const config = await loadConfig({ projectDir });
|
|
110
|
+
const projectId = config?.project?.id;
|
|
111
|
+
const projectName = config?.project?.name ?? path2.basename(projectDir);
|
|
112
|
+
if (!projectId) {
|
|
113
|
+
console.error(chalk.red("No project linked. Run ") + chalk.cyan("perceo init") + chalk.red(" in this directory first."));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const supabaseUrl = auth.supabaseUrl;
|
|
117
|
+
const supabaseKey = getSupabaseAnonKey();
|
|
118
|
+
const client = await PerceoDataClient.fromUserSession({
|
|
119
|
+
supabaseUrl,
|
|
120
|
+
supabaseKey,
|
|
121
|
+
accessToken: auth.access_token,
|
|
122
|
+
refreshToken: auth.refresh_token,
|
|
123
|
+
projectId
|
|
124
|
+
});
|
|
125
|
+
const role = await client.getProjectMemberRole(projectId);
|
|
126
|
+
if (!role) {
|
|
127
|
+
console.error(chalk.red("You don't have access to this project."));
|
|
128
|
+
console.error(chalk.gray("Only users added to the project can view or change it. Ask a project owner or admin to add you."));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
if (requireAdmin && !ADMIN_ROLES.includes(role)) {
|
|
132
|
+
console.error(chalk.red("This action requires project owner or admin role."));
|
|
133
|
+
console.error(chalk.gray(`Your role: ${role}. Ask a project owner or admin to perform this action or change your role.`));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
return { client, projectId, projectName, role, config };
|
|
137
|
+
}
|
|
138
|
+
async function checkProjectAccess(client, projectId, options = {}) {
|
|
139
|
+
const role = await client.getProjectMemberRole(projectId);
|
|
140
|
+
if (!role) return null;
|
|
141
|
+
if (options.requireAdmin && !ADMIN_ROLES.includes(role)) return null;
|
|
142
|
+
return role;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/commands/init.ts
|
|
146
|
+
import { PerceoDataClient as PerceoDataClient2, getSupabaseAnonKey as getSupabaseAnonKey2 } from "@perceo/supabase";
|
|
147
|
+
|
|
148
|
+
// src/github.ts
|
|
149
|
+
import { execSync } from "child_process";
|
|
150
|
+
import chalk2 from "chalk";
|
|
151
|
+
import ora from "ora";
|
|
152
|
+
var GITHUB_CLIENT_ID = process.env.PERCEO_GITHUB_CLIENT_ID || "";
|
|
153
|
+
async function authorizeGitHub() {
|
|
154
|
+
if (!GITHUB_CLIENT_ID) {
|
|
155
|
+
throw new Error("GitHub OAuth client ID not configured. Set PERCEO_GITHUB_CLIENT_ID environment variable.");
|
|
156
|
+
}
|
|
157
|
+
const spinner = ora("Requesting device authorization...").start();
|
|
158
|
+
let deviceResponse;
|
|
159
|
+
try {
|
|
160
|
+
deviceResponse = await fetch("https://github.com/login/device/code", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
Accept: "application/json"
|
|
165
|
+
},
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
client_id: GITHUB_CLIENT_ID,
|
|
168
|
+
scope: "repo"
|
|
169
|
+
})
|
|
170
|
+
});
|
|
171
|
+
} catch (fetchError) {
|
|
172
|
+
spinner.fail("Failed to connect to GitHub");
|
|
173
|
+
throw new Error(`Network error connecting to GitHub: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
|
174
|
+
}
|
|
175
|
+
if (!deviceResponse.ok) {
|
|
176
|
+
spinner.fail("Failed to request device code");
|
|
177
|
+
throw new Error(`GitHub API error: ${deviceResponse.statusText}`);
|
|
178
|
+
}
|
|
179
|
+
const deviceData = await deviceResponse.json();
|
|
180
|
+
const { device_code, user_code, verification_uri, expires_in, interval = 5 } = deviceData;
|
|
181
|
+
spinner.succeed("Device code received");
|
|
182
|
+
console.log("\n" + chalk2.bold.yellow("GitHub Authorization Required"));
|
|
183
|
+
console.log(chalk2.gray("\u2500".repeat(50)));
|
|
184
|
+
console.log("\n 1. Visit: " + chalk2.cyan.underline(verification_uri));
|
|
185
|
+
console.log(" 2. Enter code: " + chalk2.bold.green(user_code));
|
|
186
|
+
console.log("\n" + chalk2.gray(" Waiting for you to authorize in your browser..."));
|
|
187
|
+
console.log(chalk2.gray("\u2500".repeat(50)) + "\n");
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
const expiresAt = startTime + expires_in * 1e3;
|
|
190
|
+
while (Date.now() < expiresAt) {
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
|
|
192
|
+
let tokenResponse;
|
|
193
|
+
try {
|
|
194
|
+
tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
Accept: "application/json"
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
client_id: GITHUB_CLIENT_ID,
|
|
202
|
+
device_code,
|
|
203
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
204
|
+
})
|
|
205
|
+
});
|
|
206
|
+
} catch (fetchError) {
|
|
207
|
+
console.log(chalk2.yellow(`
|
|
208
|
+
Warning: Network error polling GitHub, retrying...`));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const tokenData = await tokenResponse.json();
|
|
212
|
+
if (tokenData.access_token) {
|
|
213
|
+
console.log(chalk2.green("\u2713 GitHub authorization successful!\n"));
|
|
214
|
+
return {
|
|
215
|
+
accessToken: tokenData.access_token,
|
|
216
|
+
tokenType: tokenData.token_type || "bearer"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (tokenData.error === "authorization_pending") {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (tokenData.error === "slow_down") {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (tokenData.error) {
|
|
227
|
+
throw new Error(`GitHub authorization failed: ${tokenData.error_description || tokenData.error}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw new Error("GitHub authorization timed out. Please try again.");
|
|
231
|
+
}
|
|
232
|
+
async function createRepositorySecret(token, owner, repo, secretName, secretValue) {
|
|
233
|
+
let keyResponse;
|
|
234
|
+
try {
|
|
235
|
+
keyResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`, {
|
|
236
|
+
headers: {
|
|
237
|
+
Authorization: `Bearer ${token}`,
|
|
238
|
+
Accept: "application/vnd.github+json",
|
|
239
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
} catch (fetchError) {
|
|
243
|
+
throw new Error(`Network error connecting to GitHub API: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
|
244
|
+
}
|
|
245
|
+
if (!keyResponse.ok) {
|
|
246
|
+
const error = await keyResponse.text();
|
|
247
|
+
throw new Error(`Failed to get repository public key: ${error}`);
|
|
248
|
+
}
|
|
249
|
+
const { key: publicKey, key_id: keyId } = await keyResponse.json();
|
|
250
|
+
const encryptedValue = await encryptSecret(secretValue, publicKey);
|
|
251
|
+
let secretResponse;
|
|
252
|
+
try {
|
|
253
|
+
secretResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/secrets/${secretName}`, {
|
|
254
|
+
method: "PUT",
|
|
255
|
+
headers: {
|
|
256
|
+
Authorization: `Bearer ${token}`,
|
|
257
|
+
Accept: "application/vnd.github+json",
|
|
258
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
259
|
+
"Content-Type": "application/json"
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
encrypted_value: encryptedValue,
|
|
263
|
+
key_id: keyId
|
|
264
|
+
})
|
|
265
|
+
});
|
|
266
|
+
} catch (fetchError) {
|
|
267
|
+
throw new Error(`Network error creating repository secret: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
|
268
|
+
}
|
|
269
|
+
if (!secretResponse.ok) {
|
|
270
|
+
const error = await secretResponse.text();
|
|
271
|
+
throw new Error(`Failed to create repository secret: ${error}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function encryptSecret(secretValue, publicKeyBase64) {
|
|
275
|
+
try {
|
|
276
|
+
const publicKeyBuffer = Buffer.from(publicKeyBase64, "base64");
|
|
277
|
+
const sodium = await import("tweetnacl");
|
|
278
|
+
const { box, randomBytes } = sodium.default;
|
|
279
|
+
const messageBytes = new TextEncoder().encode(secretValue);
|
|
280
|
+
const publicKeyBytes = new Uint8Array(publicKeyBuffer);
|
|
281
|
+
const ephemeralKeyPair = box.keyPair();
|
|
282
|
+
const nonce = randomBytes(box.nonceLength);
|
|
283
|
+
const encrypted = box(messageBytes, nonce, publicKeyBytes, ephemeralKeyPair.secretKey);
|
|
284
|
+
const combined = new Uint8Array(ephemeralKeyPair.publicKey.length + nonce.length + encrypted.length);
|
|
285
|
+
combined.set(ephemeralKeyPair.publicKey);
|
|
286
|
+
combined.set(nonce, ephemeralKeyPair.publicKey.length);
|
|
287
|
+
combined.set(encrypted, ephemeralKeyPair.publicKey.length + nonce.length);
|
|
288
|
+
return Buffer.from(combined).toString("base64");
|
|
289
|
+
} catch (error) {
|
|
290
|
+
throw new Error(`Failed to encrypt secret. Install tweetnacl: npm install tweetnacl
|
|
291
|
+
Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function isGitRepository(projectDir) {
|
|
295
|
+
try {
|
|
296
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
297
|
+
cwd: projectDir,
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function parseGitHubRemoteUrl(url) {
|
|
307
|
+
const trimmed = url.trim();
|
|
308
|
+
let match = trimmed.match(/github\.com[/:]([\w-]+)\/([\w.-]+?)(\.git)?$/);
|
|
309
|
+
if (match && match[1] && match[2]) {
|
|
310
|
+
return { owner: match[1], repo: match[2] };
|
|
311
|
+
}
|
|
312
|
+
match = trimmed.match(/^git@([^:]+):([\w-]+)\/([\w.-]+?)(\.git)?$/);
|
|
313
|
+
if (match && match[1] && match[2] && match[3] && /github/i.test(match[1])) {
|
|
314
|
+
return { owner: match[2], repo: match[3] };
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
function detectGitHubRemote(projectDir) {
|
|
319
|
+
if (!isGitRepository(projectDir)) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const origin = execSync("git remote get-url origin", {
|
|
324
|
+
cwd: projectDir,
|
|
325
|
+
encoding: "utf8",
|
|
326
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
327
|
+
}).trim();
|
|
328
|
+
const parsed = parseGitHubRemoteUrl(origin);
|
|
329
|
+
if (parsed) return parsed;
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const out = execSync("git remote -v", {
|
|
334
|
+
cwd: projectDir,
|
|
335
|
+
encoding: "utf8",
|
|
336
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
337
|
+
});
|
|
338
|
+
for (const line of out.split("\n")) {
|
|
339
|
+
const tab = line.indexOf(" ");
|
|
340
|
+
if (tab === -1) continue;
|
|
341
|
+
const url = line.slice(tab + 1).replace(/\s*\((?:fetch|push)\)\s*$/, "").trim();
|
|
342
|
+
const parsed = parseGitHubRemoteUrl(url);
|
|
343
|
+
if (parsed) return parsed;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
async function checkRepositoryPermissions(token, owner, repo) {
|
|
350
|
+
try {
|
|
351
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
352
|
+
headers: {
|
|
353
|
+
Authorization: `Bearer ${token}`,
|
|
354
|
+
Accept: "application/vnd.github+json",
|
|
355
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
console.log(chalk2.yellow(` Warning: Failed to check repository permissions (HTTP ${response.status})`));
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
const data = await response.json();
|
|
363
|
+
return data.permissions?.admin || data.permissions?.push || false;
|
|
364
|
+
} catch (fetchError) {
|
|
365
|
+
console.log(chalk2.yellow(` Warning: Network error checking repository permissions: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`));
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/commands/init.ts
|
|
371
|
+
import { createInterface } from "readline";
|
|
372
|
+
import os from "os";
|
|
373
|
+
var CONFIG_DIR2 = ".perceo";
|
|
374
|
+
var CONFIG_FILE = "config.json";
|
|
375
|
+
var SUPPORTED_FRAMEWORKS = ["nextjs", "react", "remix"];
|
|
376
|
+
var initCommand = new Command("init").description("Initialize Perceo in your project and discover flows").option("-d, --dir <directory>", "Project directory", process.cwd()).option("--skip-github", "Skip GitHub Actions setup", false).option("-y, --yes", "Skip confirmation prompt (e.g. for CI)", false).option("-b, --branch <branch>", "Main branch name (default: auto-detect)").option("--configure-personas", "Configure custom user personas instead of auto-generating them", false).action(async (options) => {
|
|
377
|
+
const projectDir = path3.resolve(options.dir || process.cwd());
|
|
378
|
+
const loggedIn = await isLoggedIn(projectDir);
|
|
379
|
+
if (!loggedIn) {
|
|
380
|
+
console.error(chalk3.red("You must log in first. Run ") + chalk3.cyan("perceo login") + chalk3.red(", then run ") + chalk3.cyan("perceo init") + chalk3.red(" again."));
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
if (!options.yes) {
|
|
384
|
+
const isGit = isGitRepository(projectDir);
|
|
385
|
+
const remote = detectGitHubRemote(projectDir);
|
|
386
|
+
const isHomeDir = projectDir === os.homedir();
|
|
387
|
+
console.log(chalk3.bold("Initialize Perceo in this directory?"));
|
|
388
|
+
console.log(chalk3.gray(" Directory: ") + chalk3.cyan(projectDir));
|
|
389
|
+
if (remote) {
|
|
390
|
+
console.log(chalk3.gray(" Repository: ") + chalk3.cyan(`${remote.owner}/${remote.repo}`));
|
|
391
|
+
} else if (isGit) {
|
|
392
|
+
console.log(chalk3.gray(" Repository: ") + chalk3.yellow("Git repository (no GitHub remote)"));
|
|
393
|
+
} else {
|
|
394
|
+
console.log(chalk3.gray(" Repository: ") + chalk3.yellow("Not a git repository"));
|
|
395
|
+
}
|
|
396
|
+
if (isHomeDir) {
|
|
397
|
+
console.log(chalk3.yellow(" Warning: This is your home directory. Prefer running from a project directory."));
|
|
398
|
+
}
|
|
399
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
400
|
+
const answer = await new Promise((resolve) => {
|
|
401
|
+
rl.question(chalk3.bold("Continue? [y/N]: "), (ans) => {
|
|
402
|
+
rl.close();
|
|
403
|
+
resolve((ans || "n").trim().toLowerCase());
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
if (answer !== "y" && answer !== "yes") {
|
|
407
|
+
console.log(chalk3.gray("Init cancelled."));
|
|
408
|
+
process.exit(0);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const spinner = ora2(`Initializing Perceo in ${chalk3.cyan(projectDir)}...`).start();
|
|
412
|
+
try {
|
|
413
|
+
const pkg = await readPackageJson(projectDir);
|
|
414
|
+
const projectName = pkg?.name || path3.basename(projectDir);
|
|
415
|
+
const framework = await detectFramework(projectDir, pkg);
|
|
416
|
+
let branch = options.branch;
|
|
417
|
+
if (!branch) {
|
|
418
|
+
branch = detectDefaultBranch(projectDir);
|
|
419
|
+
console.log(chalk3.gray(` Auto-detected default branch: ${branch}`));
|
|
420
|
+
} else {
|
|
421
|
+
console.log(chalk3.gray(` Using specified branch: ${branch}`));
|
|
422
|
+
}
|
|
423
|
+
if (!SUPPORTED_FRAMEWORKS.includes(framework)) {
|
|
424
|
+
spinner.fail("Unsupported project type");
|
|
425
|
+
const detected = framework === "unknown" ? "No React/Next.js/Remix project detected" : `Detected: ${framework}`;
|
|
426
|
+
console.error(chalk3.red(detected + "."));
|
|
427
|
+
console.error(chalk3.yellow("Perceo currently supports React, Next.js, and Remix projects. Support for more frameworks is coming soon."));
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
const perceoDir = path3.join(projectDir, CONFIG_DIR2);
|
|
431
|
+
const perceoConfigPath = path3.join(perceoDir, CONFIG_FILE);
|
|
432
|
+
await fs2.mkdir(perceoDir, { recursive: true });
|
|
433
|
+
spinner.text = "Bootstrapping project via Perceo Observer...";
|
|
434
|
+
const storedAuth = await getEffectiveAuth(projectDir);
|
|
435
|
+
if (!storedAuth) {
|
|
436
|
+
spinner.fail("Authentication required");
|
|
437
|
+
throw new Error("Please run 'perceo login' first");
|
|
438
|
+
}
|
|
439
|
+
const supabaseUrl = storedAuth.supabaseUrl;
|
|
440
|
+
const supabaseKey = getSupabaseAnonKey2();
|
|
441
|
+
let tempClient;
|
|
442
|
+
try {
|
|
443
|
+
console.log(chalk3.gray(`
|
|
444
|
+
Connecting to Perceo Cloud: ${supabaseUrl}`));
|
|
445
|
+
tempClient = await PerceoDataClient2.fromUserSession({
|
|
446
|
+
supabaseUrl: storedAuth.supabaseUrl,
|
|
447
|
+
supabaseKey,
|
|
448
|
+
accessToken: storedAuth.access_token,
|
|
449
|
+
refreshToken: storedAuth.refresh_token
|
|
450
|
+
});
|
|
451
|
+
} catch (authError) {
|
|
452
|
+
spinner.fail("Failed to connect to Perceo Cloud");
|
|
453
|
+
console.error(chalk3.red("\nAuthentication error details:"));
|
|
454
|
+
console.error(chalk3.gray(` Supabase URL: ${supabaseUrl}`));
|
|
455
|
+
console.error(chalk3.gray(` Error: ${authError instanceof Error ? authError.message : String(authError)}`));
|
|
456
|
+
if (authError instanceof Error && authError.stack) {
|
|
457
|
+
console.error(chalk3.gray(` Stack: ${authError.stack}`));
|
|
458
|
+
}
|
|
459
|
+
throw new Error(`Failed to authenticate with Perceo Cloud. Try running 'perceo logout' followed by 'perceo login'.`);
|
|
460
|
+
}
|
|
461
|
+
let tempProject;
|
|
462
|
+
const remote = detectGitHubRemote(projectDir);
|
|
463
|
+
const gitRemoteUrl = remote ? `https://github.com/${remote.owner}/${remote.repo}` : null;
|
|
464
|
+
try {
|
|
465
|
+
spinner.text = "Looking up project...";
|
|
466
|
+
tempProject = await tempClient.getProjectByName(projectName);
|
|
467
|
+
} catch (dbError) {
|
|
468
|
+
spinner.fail("Failed to query project");
|
|
469
|
+
console.error(chalk3.red("\nDatabase error details:"));
|
|
470
|
+
console.error(chalk3.gray(` Error: ${dbError instanceof Error ? dbError.message : String(dbError)}`));
|
|
471
|
+
throw new Error("Failed to query project from database");
|
|
472
|
+
}
|
|
473
|
+
if (!tempProject) {
|
|
474
|
+
try {
|
|
475
|
+
spinner.text = "Creating project...";
|
|
476
|
+
tempProject = await tempClient.createProject({
|
|
477
|
+
name: projectName,
|
|
478
|
+
framework,
|
|
479
|
+
config: { source: "cli-init" },
|
|
480
|
+
git_remote_url: gitRemoteUrl
|
|
481
|
+
});
|
|
482
|
+
} catch (createError) {
|
|
483
|
+
spinner.fail("Failed to create project");
|
|
484
|
+
console.error(chalk3.red("\nDatabase error details:"));
|
|
485
|
+
console.error(chalk3.gray(` Error: ${createError instanceof Error ? createError.message : String(createError)}`));
|
|
486
|
+
throw new Error("Failed to create project in database");
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
const role = await checkProjectAccess(tempClient, tempProject.id);
|
|
490
|
+
if (!role) {
|
|
491
|
+
spinner.fail("Access denied");
|
|
492
|
+
console.error(chalk3.red(`
|
|
493
|
+
You don't have access to the project "${tempProject.name}".`));
|
|
494
|
+
console.error(chalk3.gray("Only users added to the project can run init. Ask a project owner or admin to add you."));
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
spinner.text = "Generating workflow authorization key...";
|
|
499
|
+
let workflowApiKey;
|
|
500
|
+
try {
|
|
501
|
+
const {
|
|
502
|
+
data: { user }
|
|
503
|
+
} = await tempClient.getSupabaseClient().auth.getUser();
|
|
504
|
+
const userId = user?.id;
|
|
505
|
+
try {
|
|
506
|
+
const existingKeys = await tempClient.getApiKeys(tempProject.id);
|
|
507
|
+
const existingWorkflowKey = existingKeys.find((k) => k.name === "temporal-workflow-auth");
|
|
508
|
+
if (existingWorkflowKey) {
|
|
509
|
+
console.log(chalk3.gray("\n \u2713 Found existing workflow key, deleting it"));
|
|
510
|
+
await tempClient.deleteApiKey(existingWorkflowKey.id);
|
|
511
|
+
}
|
|
512
|
+
} catch (cleanupError) {
|
|
513
|
+
console.log(chalk3.gray(` Note: Could not check for existing keys: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`));
|
|
514
|
+
}
|
|
515
|
+
const { key } = await tempClient.createApiKey(tempProject.id, {
|
|
516
|
+
name: "temporal-workflow-auth",
|
|
517
|
+
scopes: ["workflows:start"],
|
|
518
|
+
createdBy: userId
|
|
519
|
+
});
|
|
520
|
+
workflowApiKey = key;
|
|
521
|
+
console.log(chalk3.gray("\n \u2713 Workflow authorization key generated"));
|
|
522
|
+
console.log(chalk3.gray(` Key prefix: ${key.substring(0, 12)}...`));
|
|
523
|
+
} catch (keyError) {
|
|
524
|
+
spinner.fail("Database key generation failed");
|
|
525
|
+
console.error(chalk3.red("\n \u2717 CRITICAL: Cannot generate workflow authorization key in database"));
|
|
526
|
+
if (keyError && typeof keyError === "object") {
|
|
527
|
+
const err = keyError;
|
|
528
|
+
console.error(chalk3.gray(` Error code: ${err.code || "N/A"}`));
|
|
529
|
+
console.error(chalk3.gray(` Error message: ${err.message || "Unknown error"}`));
|
|
530
|
+
if (err.details) {
|
|
531
|
+
console.error(chalk3.gray(` Details: ${err.details}`));
|
|
532
|
+
}
|
|
533
|
+
if (err.hint) {
|
|
534
|
+
console.error(chalk3.gray(` Hint: ${err.hint}`));
|
|
535
|
+
}
|
|
536
|
+
} else if (keyError instanceof Error) {
|
|
537
|
+
console.error(chalk3.gray(` Error: ${keyError.message}`));
|
|
538
|
+
if (keyError.stack) {
|
|
539
|
+
console.error(chalk3.gray(` Stack: ${keyError.stack}`));
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
console.error(chalk3.gray(` Error: ${String(keyError)}`));
|
|
543
|
+
}
|
|
544
|
+
const crypto = await import("crypto");
|
|
545
|
+
const keyBytes = crypto.randomBytes(32);
|
|
546
|
+
workflowApiKey = `prc_${keyBytes.toString("base64url")}`;
|
|
547
|
+
console.log(chalk3.yellow("\n \u26A0 Using temporary local key as fallback"));
|
|
548
|
+
console.log(chalk3.gray(` Key prefix: ${workflowApiKey.substring(0, 12)}...`));
|
|
549
|
+
console.log(chalk3.red(" \u2717 WARNING: Temporal workflows will NOT work with this key"));
|
|
550
|
+
console.log(chalk3.red(" \u2717 You must fix the database issue before workflows can run"));
|
|
551
|
+
throw new Error("Failed to generate workflow authorization key in database");
|
|
552
|
+
}
|
|
553
|
+
const workerApiUrl = process.env.PERCEO_WORKER_API_URL || "https://perceo-temporal-worker-331577200018.us-west1.run.app/";
|
|
554
|
+
const workerApiKey = process.env.PERCEO_WORKER_API_KEY;
|
|
555
|
+
const headers = {
|
|
556
|
+
"Content-Type": "application/json"
|
|
557
|
+
};
|
|
558
|
+
if (workerApiKey) {
|
|
559
|
+
headers["x-api-key"] = workerApiKey;
|
|
560
|
+
}
|
|
561
|
+
spinner.stop();
|
|
562
|
+
console.log(chalk3.gray("\n \u2139\uFE0F LLM API key will be fetched from secure storage during bootstrap"));
|
|
563
|
+
console.log(chalk3.gray(" (configured by admin in Supabase project_secrets table)"));
|
|
564
|
+
let userConfiguredPersonas = [];
|
|
565
|
+
let useCustomPersonas = false;
|
|
566
|
+
if (options.configurePersonas) {
|
|
567
|
+
spinner.stop();
|
|
568
|
+
console.log(chalk3.bold("\n\u{1F3AD} Configure User Personas"));
|
|
569
|
+
console.log(chalk3.gray("Define custom user personas for your application, or let Perceo auto-generate them from your codebase."));
|
|
570
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
571
|
+
const configurePersonasAnswer = await new Promise((resolve) => {
|
|
572
|
+
rl.question(chalk3.cyan("Do you want to configure custom personas? [y/N]: "), (ans) => {
|
|
573
|
+
resolve((ans || "n").trim().toLowerCase());
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
if (configurePersonasAnswer === "y" || configurePersonasAnswer === "yes") {
|
|
577
|
+
useCustomPersonas = true;
|
|
578
|
+
console.log(chalk3.green("\n\u2713 Configuring custom personas"));
|
|
579
|
+
console.log(chalk3.gray("Enter persona details (press Enter with empty name to finish):"));
|
|
580
|
+
let personaIndex = 1;
|
|
581
|
+
while (true) {
|
|
582
|
+
console.log(chalk3.bold(`
|
|
583
|
+
Persona ${personaIndex}:`));
|
|
584
|
+
const name = await new Promise((resolve) => {
|
|
585
|
+
rl.question(chalk3.cyan(" Name: "), resolve);
|
|
586
|
+
});
|
|
587
|
+
if (!name.trim()) {
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
const description = await new Promise((resolve) => {
|
|
591
|
+
rl.question(chalk3.cyan(" Description: "), resolve);
|
|
592
|
+
});
|
|
593
|
+
const behaviors = await new Promise((resolve) => {
|
|
594
|
+
rl.question(chalk3.cyan(" Key behaviors (comma-separated): "), resolve);
|
|
595
|
+
});
|
|
596
|
+
const behaviorList = behaviors.split(",").map((b) => b.trim()).filter((b) => b.length > 0);
|
|
597
|
+
const behaviorObj = {};
|
|
598
|
+
behaviorList.forEach((behavior, idx) => {
|
|
599
|
+
behaviorObj[`behavior_${idx + 1}`] = behavior;
|
|
600
|
+
});
|
|
601
|
+
userConfiguredPersonas.push({
|
|
602
|
+
name: name.trim(),
|
|
603
|
+
description: description.trim() || null,
|
|
604
|
+
behaviors: behaviorObj
|
|
605
|
+
});
|
|
606
|
+
console.log(chalk3.green(` \u2713 Added persona: ${name}`));
|
|
607
|
+
personaIndex++;
|
|
608
|
+
}
|
|
609
|
+
if (userConfiguredPersonas.length === 0) {
|
|
610
|
+
console.log(chalk3.yellow(" No personas configured, will use auto-generation"));
|
|
611
|
+
useCustomPersonas = false;
|
|
612
|
+
} else {
|
|
613
|
+
console.log(chalk3.green(`
|
|
614
|
+
\u2713 Configured ${userConfiguredPersonas.length} custom personas`));
|
|
615
|
+
try {
|
|
616
|
+
console.log(chalk3.gray(" Saving personas to database..."));
|
|
617
|
+
await tempClient.createUserConfiguredPersonas(userConfiguredPersonas, tempProject.id);
|
|
618
|
+
console.log(chalk3.green(" \u2713 Personas saved successfully"));
|
|
619
|
+
} catch (personaError) {
|
|
620
|
+
console.error(chalk3.red(" \u2717 Failed to save personas:"), personaError);
|
|
621
|
+
throw new Error("Failed to save custom personas to database");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
rl.close();
|
|
626
|
+
spinner.start("Starting bootstrap workflow...");
|
|
627
|
+
} else {
|
|
628
|
+
spinner.start("Starting bootstrap workflow...");
|
|
629
|
+
}
|
|
630
|
+
let bootstrapResponse;
|
|
631
|
+
try {
|
|
632
|
+
console.log(chalk3.gray(`
|
|
633
|
+
Connecting to worker API: ${workerApiUrl}`));
|
|
634
|
+
console.log(chalk3.gray(` Project ID: ${tempProject.id}`));
|
|
635
|
+
console.log(chalk3.gray(` Git Remote URL: ${gitRemoteUrl || "Not detected"}`));
|
|
636
|
+
console.log(chalk3.gray(` Workflow API Key: ${workflowApiKey.substring(0, 12)}...`));
|
|
637
|
+
if (!gitRemoteUrl) {
|
|
638
|
+
spinner.fail("Cannot start bootstrap workflow");
|
|
639
|
+
throw new Error("Git remote URL not detected. Please ensure this is a Git repository with a GitHub remote configured.");
|
|
640
|
+
}
|
|
641
|
+
bootstrapResponse = await fetch(`${workerApiUrl}/api/workflows/bootstrap`, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers,
|
|
644
|
+
body: JSON.stringify({
|
|
645
|
+
projectId: tempProject.id,
|
|
646
|
+
gitRemoteUrl,
|
|
647
|
+
projectName,
|
|
648
|
+
framework,
|
|
649
|
+
branch,
|
|
650
|
+
workflowApiKey,
|
|
651
|
+
useCustomPersonas
|
|
652
|
+
})
|
|
653
|
+
});
|
|
654
|
+
} catch (fetchError) {
|
|
655
|
+
spinner.fail("Failed to connect to worker API");
|
|
656
|
+
console.error(chalk3.red("\nNetwork error details:"));
|
|
657
|
+
console.error(chalk3.gray(` URL: ${workerApiUrl}/api/workflows/bootstrap`));
|
|
658
|
+
console.error(chalk3.gray(` Error: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`));
|
|
659
|
+
if (fetchError instanceof Error && fetchError.cause) {
|
|
660
|
+
console.error(chalk3.gray(` Cause: ${JSON.stringify(fetchError.cause, null, 2)}`));
|
|
661
|
+
}
|
|
662
|
+
throw new Error(`Failed to connect to worker API at ${workerApiUrl}. Check that PERCEO_WORKER_API_URL is correct and the service is running.`);
|
|
663
|
+
}
|
|
664
|
+
if (!bootstrapResponse.ok) {
|
|
665
|
+
const errorData = await bootstrapResponse.json().catch(() => ({}));
|
|
666
|
+
spinner.fail("Failed to start bootstrap workflow");
|
|
667
|
+
console.error(chalk3.red(` HTTP ${bootstrapResponse.status}: ${bootstrapResponse.statusText}`));
|
|
668
|
+
throw new Error(`Bootstrap start failed: ${errorData.error || bootstrapResponse.statusText}`);
|
|
669
|
+
}
|
|
670
|
+
const { workflowId } = await bootstrapResponse.json();
|
|
671
|
+
console.log(chalk3.gray(`
|
|
672
|
+
Workflow ID: ${workflowId}`));
|
|
673
|
+
spinner.text = "Initializing workflow...";
|
|
674
|
+
let temporalResult;
|
|
675
|
+
for (; ; ) {
|
|
676
|
+
let queryResponse;
|
|
677
|
+
try {
|
|
678
|
+
queryResponse = await fetch(`${workerApiUrl}/api/workflows/${workflowId}`, {
|
|
679
|
+
headers
|
|
680
|
+
});
|
|
681
|
+
} catch (fetchError) {
|
|
682
|
+
console.log(chalk3.yellow("\n Warning: Failed to query workflow progress (network error), retrying..."));
|
|
683
|
+
console.log(chalk3.gray(` Error: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`));
|
|
684
|
+
await sleep(2e3);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (!queryResponse.ok) {
|
|
688
|
+
console.log(chalk3.yellow(`
|
|
689
|
+
Warning: Failed to query workflow progress (HTTP ${queryResponse.status}), retrying...`));
|
|
690
|
+
await sleep(2e3);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const queryResult = await queryResponse.json();
|
|
694
|
+
if (queryResult.progress?.message) {
|
|
695
|
+
spinner.prefixText = chalk3.gray(`[${queryResult.progress.stage}]`);
|
|
696
|
+
spinner.text = `${queryResult.progress.message} (${queryResult.progress.percentage}%)`;
|
|
697
|
+
}
|
|
698
|
+
if (queryResult.completed) {
|
|
699
|
+
if (queryResult.error) {
|
|
700
|
+
spinner.fail("Bootstrap workflow failed");
|
|
701
|
+
throw new Error(queryResult.error);
|
|
702
|
+
}
|
|
703
|
+
if (!queryResult.result) {
|
|
704
|
+
spinner.fail("Bootstrap workflow completed but no result returned");
|
|
705
|
+
throw new Error("No result from workflow");
|
|
706
|
+
}
|
|
707
|
+
temporalResult = queryResult.result;
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
if (queryResult.progress?.stage === "error") {
|
|
711
|
+
spinner.fail("Bootstrap workflow failed");
|
|
712
|
+
throw new Error(queryResult.progress.error || "Unknown workflow error");
|
|
713
|
+
}
|
|
714
|
+
await sleep(1e3);
|
|
715
|
+
}
|
|
716
|
+
spinner.succeed("Bootstrap complete!");
|
|
717
|
+
console.log(chalk3.green("\n\u2713 Bootstrap successful:"));
|
|
718
|
+
console.log(chalk3.gray(` Personas: ${temporalResult.personasExtracted}`));
|
|
719
|
+
console.log(chalk3.gray(` Flows: ${temporalResult.flowsExtracted}`));
|
|
720
|
+
console.log(chalk3.gray(` Steps: ${temporalResult.stepsExtracted}`));
|
|
721
|
+
console.log(chalk3.gray(` Commits: ${temporalResult.totalCommitsProcessed}`));
|
|
722
|
+
console.log();
|
|
723
|
+
spinner.start("Finishing initialization...");
|
|
724
|
+
spinner.text = "Connecting to Perceo Cloud...";
|
|
725
|
+
let client;
|
|
726
|
+
if (storedAuth) {
|
|
727
|
+
try {
|
|
728
|
+
client = await PerceoDataClient2.fromUserSession({
|
|
729
|
+
supabaseUrl: storedAuth.supabaseUrl,
|
|
730
|
+
supabaseKey,
|
|
731
|
+
accessToken: storedAuth.access_token,
|
|
732
|
+
refreshToken: storedAuth.refresh_token
|
|
733
|
+
});
|
|
734
|
+
} catch (authError) {
|
|
735
|
+
spinner.warn("Failed to attach user session, falling back to anonymous access");
|
|
736
|
+
console.log(chalk3.gray(` ${authError instanceof Error ? authError.message : typeof authError === "string" ? authError : "Unknown error while restoring session"}`));
|
|
737
|
+
spinner.start("Connecting to Perceo Cloud...");
|
|
738
|
+
client = new PerceoDataClient2({ supabaseUrl, supabaseKey });
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
client = new PerceoDataClient2({ supabaseUrl, supabaseKey });
|
|
742
|
+
}
|
|
743
|
+
let project;
|
|
744
|
+
try {
|
|
745
|
+
spinner.text = "Syncing project...";
|
|
746
|
+
project = await client.getProjectByName(projectName);
|
|
747
|
+
} catch (dbError) {
|
|
748
|
+
spinner.fail("Failed to query project");
|
|
749
|
+
console.error(chalk3.red("\nDatabase error details:"));
|
|
750
|
+
console.error(chalk3.gray(` Error: ${dbError instanceof Error ? dbError.message : String(dbError)}`));
|
|
751
|
+
throw new Error("Failed to query project from database");
|
|
752
|
+
}
|
|
753
|
+
if (!project) {
|
|
754
|
+
try {
|
|
755
|
+
spinner.text = "Creating project...";
|
|
756
|
+
project = await client.createProject({
|
|
757
|
+
name: projectName,
|
|
758
|
+
framework,
|
|
759
|
+
config: { source: "cli-init" },
|
|
760
|
+
git_remote_url: gitRemoteUrl
|
|
761
|
+
});
|
|
762
|
+
} catch (createError) {
|
|
763
|
+
spinner.fail("Failed to create project");
|
|
764
|
+
console.error(chalk3.red("\nDatabase error details:"));
|
|
765
|
+
console.error(chalk3.gray(` Error: ${createError instanceof Error ? createError.message : String(createError)}`));
|
|
766
|
+
throw new Error("Failed to create project in database");
|
|
767
|
+
}
|
|
768
|
+
} else if (gitRemoteUrl && project.git_remote_url !== gitRemoteUrl) {
|
|
769
|
+
try {
|
|
770
|
+
await client.updateProject(project.id, { git_remote_url: gitRemoteUrl });
|
|
771
|
+
} catch (updateError) {
|
|
772
|
+
console.log(chalk3.yellow(` Warning: Failed to update git remote URL: ${updateError instanceof Error ? updateError.message : String(updateError)}`));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const projectId = project.id;
|
|
776
|
+
const flowsDiscovered = temporalResult.flowsExtracted;
|
|
777
|
+
const flowsNew = temporalResult.flowsExtracted;
|
|
778
|
+
let apiKey = null;
|
|
779
|
+
let workflowCreated = false;
|
|
780
|
+
let githubAutoConfigured = false;
|
|
781
|
+
if (projectId && !options.skipGithub) {
|
|
782
|
+
spinner.text = "Generating CI API key...";
|
|
783
|
+
const scopes = ["ci:analyze", "ci:test", "flows:read", "insights:read", "events:publish"];
|
|
784
|
+
try {
|
|
785
|
+
const {
|
|
786
|
+
data: { user }
|
|
787
|
+
} = await client.getSupabaseClient().auth.getUser();
|
|
788
|
+
const userId = user?.id;
|
|
789
|
+
try {
|
|
790
|
+
const existingKeys = await client.getApiKeys(projectId);
|
|
791
|
+
const existingGhKey = existingKeys.find((k) => k.name === "github-actions");
|
|
792
|
+
if (existingGhKey) {
|
|
793
|
+
console.log(chalk3.gray("\n \u2713 Found existing GitHub Actions key, deleting it"));
|
|
794
|
+
await client.deleteApiKey(existingGhKey.id);
|
|
795
|
+
}
|
|
796
|
+
} catch (cleanupError) {
|
|
797
|
+
console.log(chalk3.gray(` Note: Could not check for existing keys: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`));
|
|
798
|
+
}
|
|
799
|
+
const { key } = await client.createApiKey(projectId, {
|
|
800
|
+
name: "github-actions",
|
|
801
|
+
scopes,
|
|
802
|
+
createdBy: userId
|
|
803
|
+
});
|
|
804
|
+
apiKey = key;
|
|
805
|
+
const remote2 = detectGitHubRemote(projectDir);
|
|
806
|
+
if (remote2) {
|
|
807
|
+
spinner.stop();
|
|
808
|
+
console.log(chalk3.cyan(`
|
|
809
|
+
\u{1F4E6} Detected GitHub repository: ${remote2.owner}/${remote2.repo}`));
|
|
810
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
811
|
+
const answer = await new Promise((resolve) => {
|
|
812
|
+
rl.question(chalk3.bold("Auto-configure GitHub Actions? (Y/n): "), (ans) => {
|
|
813
|
+
rl.close();
|
|
814
|
+
resolve((ans || "y").toLowerCase());
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
if (answer === "y" || answer === "yes" || answer === "") {
|
|
818
|
+
try {
|
|
819
|
+
spinner.start("Authorizing with GitHub...");
|
|
820
|
+
const ghAuth = await authorizeGitHub();
|
|
821
|
+
spinner.text = "Checking repository permissions...";
|
|
822
|
+
const hasPermission = await checkRepositoryPermissions(ghAuth.accessToken, remote2.owner, remote2.repo);
|
|
823
|
+
if (!hasPermission) {
|
|
824
|
+
spinner.warn("Insufficient permissions to write to repository");
|
|
825
|
+
console.log(chalk3.yellow(" You need admin or push access to configure secrets automatically."));
|
|
826
|
+
} else {
|
|
827
|
+
spinner.text = "Creating PERCEO_API_KEY secret...";
|
|
828
|
+
if (!apiKey) {
|
|
829
|
+
throw new Error("API key not found after creation");
|
|
830
|
+
}
|
|
831
|
+
await createRepositorySecret(ghAuth.accessToken, remote2.owner, remote2.repo, "PERCEO_API_KEY", apiKey);
|
|
832
|
+
githubAutoConfigured = true;
|
|
833
|
+
spinner.text = "Creating GitHub Actions workflow...";
|
|
834
|
+
}
|
|
835
|
+
} catch (error) {
|
|
836
|
+
spinner.warn("GitHub authorization failed");
|
|
837
|
+
console.log(chalk3.yellow(` ${error instanceof Error ? error.message : "Unknown error"}`));
|
|
838
|
+
console.log(chalk3.gray(" Continuing with manual setup instructions..."));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (!githubAutoConfigured) {
|
|
842
|
+
spinner.start("Creating GitHub Actions workflow...");
|
|
843
|
+
}
|
|
844
|
+
} else {
|
|
845
|
+
spinner.text = "Creating GitHub Actions workflow...";
|
|
846
|
+
}
|
|
847
|
+
const workflowDir = path3.join(projectDir, ".github", "workflows");
|
|
848
|
+
const workflowPath = path3.join(workflowDir, "perceo.yml");
|
|
849
|
+
await fs2.mkdir(workflowDir, { recursive: true });
|
|
850
|
+
if (!await fileExists2(workflowPath)) {
|
|
851
|
+
const workflowContent = generateGitHubWorkflow(branch);
|
|
852
|
+
await fs2.writeFile(workflowPath, workflowContent, "utf8");
|
|
853
|
+
workflowCreated = true;
|
|
854
|
+
}
|
|
855
|
+
} catch (error) {
|
|
856
|
+
console.log(chalk3.yellow("\n \u26A0 Database key generation failed, using local fallback"));
|
|
857
|
+
if (error && typeof error === "object") {
|
|
858
|
+
const err = error;
|
|
859
|
+
console.error(chalk3.gray(` Error code: ${err.code || "N/A"}`));
|
|
860
|
+
console.error(chalk3.gray(` Error message: ${err.message || "Unknown error"}`));
|
|
861
|
+
if (err.details) {
|
|
862
|
+
console.error(chalk3.gray(` Details: ${err.details}`));
|
|
863
|
+
}
|
|
864
|
+
if (err.hint) {
|
|
865
|
+
console.error(chalk3.gray(` Hint: ${err.hint}`));
|
|
866
|
+
}
|
|
867
|
+
} else if (error instanceof Error) {
|
|
868
|
+
console.error(chalk3.gray(` Error: ${error.message}`));
|
|
869
|
+
} else {
|
|
870
|
+
console.error(chalk3.gray(` Error: ${String(error)}`));
|
|
871
|
+
}
|
|
872
|
+
const crypto = await import("crypto");
|
|
873
|
+
const keyBytes = crypto.randomBytes(32);
|
|
874
|
+
apiKey = `prc_${keyBytes.toString("base64url")}`;
|
|
875
|
+
console.log(chalk3.gray(" \u2713 Local CI API key generated"));
|
|
876
|
+
console.log(chalk3.gray(` Key prefix: ${apiKey.substring(0, 12)}...`));
|
|
877
|
+
console.log(chalk3.yellow(" \u26A0 This key is not stored in the database and is local-only"));
|
|
878
|
+
try {
|
|
879
|
+
const workflowDir = path3.join(projectDir, ".github", "workflows");
|
|
880
|
+
const workflowPath = path3.join(workflowDir, "perceo.yml");
|
|
881
|
+
await fs2.mkdir(workflowDir, { recursive: true });
|
|
882
|
+
if (!await fileExists2(workflowPath)) {
|
|
883
|
+
const workflowContent = generateGitHubWorkflow(branch);
|
|
884
|
+
await fs2.writeFile(workflowPath, workflowContent, "utf8");
|
|
885
|
+
workflowCreated = true;
|
|
886
|
+
}
|
|
887
|
+
} catch (workflowError) {
|
|
888
|
+
console.log(chalk3.yellow(" \u26A0 Could not create workflow file"));
|
|
889
|
+
console.log(chalk3.gray(` ${workflowError instanceof Error ? workflowError.message : "Unknown error"}`));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (await fileExists2(perceoConfigPath)) {
|
|
894
|
+
spinner.stop();
|
|
895
|
+
console.log(chalk3.yellow(`
|
|
896
|
+
.perceo/${CONFIG_FILE} already exists. Skipping config generation.`));
|
|
897
|
+
} else {
|
|
898
|
+
const config = createDefaultConfig(projectName, framework, projectId, branch);
|
|
899
|
+
await fs2.writeFile(perceoConfigPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
900
|
+
}
|
|
901
|
+
const readmePath = path3.join(perceoDir, "README.md");
|
|
902
|
+
if (!await fileExists2(readmePath)) {
|
|
903
|
+
await fs2.writeFile(readmePath, createPerceoReadme(projectName), "utf8");
|
|
904
|
+
}
|
|
905
|
+
spinner.succeed("Perceo initialized successfully!");
|
|
906
|
+
console.log("\n" + chalk3.bold("Project: ") + projectName);
|
|
907
|
+
console.log(chalk3.bold("Framework: ") + framework);
|
|
908
|
+
console.log(chalk3.bold("Branch: ") + branch);
|
|
909
|
+
console.log(chalk3.bold("Config: ") + path3.relative(projectDir, perceoConfigPath));
|
|
910
|
+
console.log(chalk3.bold("Project ID: ") + projectId);
|
|
911
|
+
console.log(chalk3.bold("Flows discovered: ") + `${flowsDiscovered} (${flowsNew} new)`);
|
|
912
|
+
if (githubAutoConfigured && workflowCreated) {
|
|
913
|
+
console.log("\n" + chalk3.bold.green("\u2713 GitHub Actions configured automatically!"));
|
|
914
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
915
|
+
console.log("\n Workflow: " + chalk3.cyan(".github/workflows/perceo.yml"));
|
|
916
|
+
console.log(" Secret: " + chalk3.green("PERCEO_API_KEY") + " " + chalk3.gray("(already added)"));
|
|
917
|
+
console.log("\n" + chalk3.gray(" CI will run on pull requests automatically."));
|
|
918
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
919
|
+
} else if (apiKey && workflowCreated) {
|
|
920
|
+
console.log("\n" + chalk3.bold.yellow("GitHub Actions Setup (Manual):"));
|
|
921
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
922
|
+
console.log("\n Workflow created at: " + chalk3.cyan(".github/workflows/perceo.yml"));
|
|
923
|
+
console.log("\n " + chalk3.bold("Add this secret to your repository:"));
|
|
924
|
+
console.log(" " + chalk3.gray("Settings \u2192 Secrets and variables \u2192 Actions \u2192 New repository secret"));
|
|
925
|
+
console.log("\n Name: " + chalk3.yellow("PERCEO_API_KEY"));
|
|
926
|
+
console.log(" Value: " + chalk3.green(apiKey));
|
|
927
|
+
console.log("\n" + chalk3.gray(" \u26A0\uFE0F This key is shown only once. Store it securely."));
|
|
928
|
+
console.log(chalk3.gray(" \u26A0\uFE0F Manage keys with: perceo keys list"));
|
|
929
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
930
|
+
} else if (apiKey && !workflowCreated) {
|
|
931
|
+
console.log("\n" + chalk3.bold.green("CI API Key Generated:"));
|
|
932
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
933
|
+
console.log("\n " + chalk3.bold("Add this secret to your CI:"));
|
|
934
|
+
console.log("\n Name: " + chalk3.yellow("PERCEO_API_KEY"));
|
|
935
|
+
console.log(" Value: " + chalk3.green(apiKey));
|
|
936
|
+
console.log("\n Workflow already exists at .github/workflows/perceo.yml");
|
|
937
|
+
console.log(chalk3.gray("\u2500".repeat(50)));
|
|
938
|
+
} else if (!options.skipGithub && projectId) {
|
|
939
|
+
console.log("\n" + chalk3.yellow("\u26A0\uFE0F GitHub Actions setup skipped."));
|
|
940
|
+
}
|
|
941
|
+
console.log("\n" + chalk3.bold("Next steps:"));
|
|
942
|
+
if (githubAutoConfigured) {
|
|
943
|
+
console.log(" 1. Review flows: " + chalk3.cyan("perceo flows list"));
|
|
944
|
+
console.log(" 2. Commit and push: " + chalk3.cyan("git add . && git commit -m 'Add Perceo' && git push"));
|
|
945
|
+
console.log(" 3. Open a PR to see Perceo analyze your changes!");
|
|
946
|
+
} else if (apiKey) {
|
|
947
|
+
console.log(" 1. " + chalk3.bold("Add the PERCEO_API_KEY secret to GitHub") + " (see above)");
|
|
948
|
+
console.log(" 2. Review flows: " + chalk3.cyan("perceo flows list"));
|
|
949
|
+
console.log(" 3. Commit and push to trigger CI: " + chalk3.cyan("git add . && git commit -m 'Add Perceo'"));
|
|
950
|
+
} else {
|
|
951
|
+
console.log(" 1. Review flows: " + chalk3.cyan("perceo flows list"));
|
|
952
|
+
console.log(" 2. Analyze PR changes: " + chalk3.cyan(`perceo analyze --base ${branch}`));
|
|
953
|
+
}
|
|
954
|
+
console.log("\n" + chalk3.gray("Run `perceo logout` to sign out if needed."));
|
|
955
|
+
} catch (error) {
|
|
956
|
+
spinner.fail("Failed to initialize Perceo");
|
|
957
|
+
if (error instanceof Error) {
|
|
958
|
+
console.error(chalk3.red(error.message));
|
|
959
|
+
if (process.env.PERCEO_DEBUG === "1" || process.env.NODE_ENV === "development") {
|
|
960
|
+
if (error.stack) {
|
|
961
|
+
console.error(chalk3.gray(error.stack));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} else {
|
|
965
|
+
try {
|
|
966
|
+
console.error(chalk3.red(`Unexpected error: ${JSON.stringify(error, null, 2)}`));
|
|
967
|
+
} catch {
|
|
968
|
+
console.error(chalk3.red(`Unexpected error: ${String(error)}`));
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
process.exit(1);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
async function readPackageJson(projectDir) {
|
|
975
|
+
const pkgPath = path3.join(projectDir, "package.json");
|
|
976
|
+
if (!await fileExists2(pkgPath)) return null;
|
|
977
|
+
try {
|
|
978
|
+
const raw = await fs2.readFile(pkgPath, "utf8");
|
|
979
|
+
return JSON.parse(raw);
|
|
980
|
+
} catch {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function detectFramework(projectDir, pkg) {
|
|
985
|
+
const nextConfig = await fileExists2(path3.join(projectDir, "next.config.js"));
|
|
986
|
+
const nextConfigTs = await fileExists2(path3.join(projectDir, "next.config.ts"));
|
|
987
|
+
if (nextConfig || nextConfigTs) return "nextjs";
|
|
988
|
+
const remixConfig = await fileExists2(path3.join(projectDir, "remix.config.js"));
|
|
989
|
+
if (remixConfig) return "remix";
|
|
990
|
+
const deps = {
|
|
991
|
+
...pkg?.dependencies || {},
|
|
992
|
+
...pkg?.devDependencies || {}
|
|
993
|
+
};
|
|
994
|
+
if (deps["next"]) return "nextjs";
|
|
995
|
+
if (deps["react"] || deps["react-dom"]) return "react";
|
|
996
|
+
if (deps["@remix-run/react"]) return "remix";
|
|
997
|
+
if (deps["@angular/core"]) return "angular";
|
|
998
|
+
if (deps["vue"]) return "vue";
|
|
999
|
+
return "unknown";
|
|
1000
|
+
}
|
|
1001
|
+
async function fileExists2(p) {
|
|
1002
|
+
try {
|
|
1003
|
+
await fs2.access(p);
|
|
1004
|
+
return true;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function detectDefaultBranch(projectDir) {
|
|
1010
|
+
try {
|
|
1011
|
+
const result = execSync2("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'", {
|
|
1012
|
+
cwd: projectDir,
|
|
1013
|
+
encoding: "utf-8"
|
|
1014
|
+
}).trim();
|
|
1015
|
+
if (result) return result;
|
|
1016
|
+
} catch {
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
const currentBranch = execSync2("git branch --show-current", {
|
|
1020
|
+
cwd: projectDir,
|
|
1021
|
+
encoding: "utf-8"
|
|
1022
|
+
}).trim();
|
|
1023
|
+
if (currentBranch) return currentBranch;
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
execSync2("git rev-parse --verify main", {
|
|
1028
|
+
cwd: projectDir,
|
|
1029
|
+
encoding: "utf-8",
|
|
1030
|
+
stdio: "pipe"
|
|
1031
|
+
});
|
|
1032
|
+
return "main";
|
|
1033
|
+
} catch {
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
execSync2("git rev-parse --verify master", {
|
|
1037
|
+
cwd: projectDir,
|
|
1038
|
+
encoding: "utf-8",
|
|
1039
|
+
stdio: "pipe"
|
|
1040
|
+
});
|
|
1041
|
+
return "master";
|
|
1042
|
+
} catch {
|
|
1043
|
+
}
|
|
1044
|
+
return "main";
|
|
1045
|
+
}
|
|
1046
|
+
function createDefaultConfig(projectName, framework, projectId, branch) {
|
|
1047
|
+
return {
|
|
1048
|
+
version: "1.0",
|
|
1049
|
+
project: {
|
|
1050
|
+
id: projectId,
|
|
1051
|
+
name: projectName,
|
|
1052
|
+
framework,
|
|
1053
|
+
branch
|
|
1054
|
+
},
|
|
1055
|
+
observer: {
|
|
1056
|
+
watch: {
|
|
1057
|
+
paths: inferDefaultWatchPaths(framework),
|
|
1058
|
+
ignore: ["node_modules/", ".next/", "dist/", "build/"],
|
|
1059
|
+
debounceMs: 500
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function inferDefaultWatchPaths(framework) {
|
|
1065
|
+
switch (framework) {
|
|
1066
|
+
case "nextjs":
|
|
1067
|
+
return ["app/", "src/"];
|
|
1068
|
+
case "react":
|
|
1069
|
+
return ["src/"];
|
|
1070
|
+
case "remix":
|
|
1071
|
+
return ["app/"];
|
|
1072
|
+
case "angular":
|
|
1073
|
+
return ["src/"];
|
|
1074
|
+
case "vue":
|
|
1075
|
+
return ["src/"];
|
|
1076
|
+
default:
|
|
1077
|
+
return ["src/"];
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function sleep(ms) {
|
|
1081
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1082
|
+
}
|
|
1083
|
+
function createPerceoReadme(projectName) {
|
|
1084
|
+
return `# Perceo configuration for ${projectName}
|
|
1085
|
+
|
|
1086
|
+
This folder was generated by \`perceo init\`.
|
|
1087
|
+
|
|
1088
|
+
## Files
|
|
1089
|
+
|
|
1090
|
+
- \`.perceo/config.json\` \u2014 project configuration (project id, name, branch). Access to this project is controlled in Perceo Cloud; only users added as members can view or change project data.
|
|
1091
|
+
|
|
1092
|
+
## Commands
|
|
1093
|
+
|
|
1094
|
+
- \`perceo analyze --base <branch>\` \u2014 analyze PR changes and find affected flows
|
|
1095
|
+
- \`perceo flows list\` \u2014 list all discovered flows
|
|
1096
|
+
|
|
1097
|
+
## Environment Variables
|
|
1098
|
+
|
|
1099
|
+
Set these to enable Supabase sync:
|
|
1100
|
+
|
|
1101
|
+
\`\`\`bash
|
|
1102
|
+
PERCEO_SUPABASE_URL=https://your-project.supabase.co
|
|
1103
|
+
PERCEO_SUPABASE_ANON_KEY=your-anon-key
|
|
1104
|
+
\`\`\`
|
|
1105
|
+
`;
|
|
1106
|
+
}
|
|
1107
|
+
function generateGitHubWorkflow(branch) {
|
|
1108
|
+
const branches = Array.from(/* @__PURE__ */ new Set([branch, "main", "master"])).join(", ");
|
|
1109
|
+
return `# Perceo CI - Automated regression impact analysis
|
|
1110
|
+
# Generated by perceo init
|
|
1111
|
+
# Docs: https://perceo.dev/docs/ci
|
|
1112
|
+
|
|
1113
|
+
name: Perceo CI
|
|
1114
|
+
|
|
1115
|
+
on:
|
|
1116
|
+
pull_request:
|
|
1117
|
+
branches: [${branches}]
|
|
1118
|
+
push:
|
|
1119
|
+
branches: [${branches}]
|
|
1120
|
+
|
|
1121
|
+
permissions:
|
|
1122
|
+
contents: read
|
|
1123
|
+
pull-requests: write # For PR comments
|
|
1124
|
+
|
|
1125
|
+
jobs:
|
|
1126
|
+
analyze:
|
|
1127
|
+
name: Analyze Changes
|
|
1128
|
+
runs-on: ubuntu-latest
|
|
1129
|
+
|
|
1130
|
+
steps:
|
|
1131
|
+
- name: Checkout
|
|
1132
|
+
uses: actions/checkout@v4
|
|
1133
|
+
with:
|
|
1134
|
+
fetch-depth: 0 # Full history for accurate diff
|
|
1135
|
+
|
|
1136
|
+
- name: Setup Node
|
|
1137
|
+
uses: actions/setup-node@v4
|
|
1138
|
+
with:
|
|
1139
|
+
node-version: '20'
|
|
1140
|
+
|
|
1141
|
+
- name: Install Perceo CLI
|
|
1142
|
+
run: npm install -g @perceo/perceo
|
|
1143
|
+
|
|
1144
|
+
- name: Analyze PR Changes
|
|
1145
|
+
if: github.event_name == 'pull_request'
|
|
1146
|
+
env:
|
|
1147
|
+
PERCEO_API_KEY: \${{ secrets.PERCEO_API_KEY }}
|
|
1148
|
+
run: |
|
|
1149
|
+
perceo ci analyze \\
|
|
1150
|
+
--base \${{ github.event.pull_request.base.sha }} \\
|
|
1151
|
+
--head \${{ github.sha }} \\
|
|
1152
|
+
--pr \${{ github.event.pull_request.number }}
|
|
1153
|
+
|
|
1154
|
+
- name: Analyze Push Changes
|
|
1155
|
+
if: github.event_name == 'push'
|
|
1156
|
+
env:
|
|
1157
|
+
PERCEO_API_KEY: \${{ secrets.PERCEO_API_KEY }}
|
|
1158
|
+
run: |
|
|
1159
|
+
perceo ci analyze \\
|
|
1160
|
+
--base \${{ github.event.before }} \\
|
|
1161
|
+
--head \${{ github.sha }}
|
|
1162
|
+
`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/commands/logout.ts
|
|
1166
|
+
import { Command as Command2 } from "commander";
|
|
1167
|
+
import chalk4 from "chalk";
|
|
1168
|
+
import path4 from "path";
|
|
1169
|
+
var logoutCommand = new Command2("logout").description("Log out from Perceo (remove stored auth for the given scope)").option("-s, --scope <scope>", "Which login to remove: 'project' or 'global'", "global").option("-d, --dir <directory>", "Project directory (for project scope)", process.cwd()).action(async (options) => {
|
|
1170
|
+
const scope = options.scope?.toLowerCase() === "project" ? "project" : "global";
|
|
1171
|
+
const projectDir = path4.resolve(options.dir || process.cwd());
|
|
1172
|
+
const existing = await getStoredAuth(scope, scope === "project" ? projectDir : void 0);
|
|
1173
|
+
if (!existing?.access_token) {
|
|
1174
|
+
console.log(chalk4.yellow(`Not logged in (${scope} scope). Nothing to do.`));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
await clearStoredAuth(scope, scope === "project" ? projectDir : void 0);
|
|
1178
|
+
console.log(chalk4.green(`Logged out successfully (${scope} scope).`));
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// src/commands/del.ts
|
|
1182
|
+
import { Command as Command3 } from "commander";
|
|
1183
|
+
import chalk5 from "chalk";
|
|
1184
|
+
import fs3 from "fs/promises";
|
|
1185
|
+
import path5 from "path";
|
|
1186
|
+
import { createInterface as createInterface2 } from "readline";
|
|
1187
|
+
var CONFIG_DIR3 = ".perceo";
|
|
1188
|
+
var CONFIG_FILE2 = "config.json";
|
|
1189
|
+
var GITHUB_WORKFLOW_PATH = ".github/workflows/perceo.yml";
|
|
1190
|
+
async function fileExists3(p) {
|
|
1191
|
+
try {
|
|
1192
|
+
await fs3.access(p);
|
|
1193
|
+
return true;
|
|
1194
|
+
} catch {
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function readProjectFromConfig(projectDir) {
|
|
1199
|
+
const configPath = path5.join(projectDir, CONFIG_DIR3, CONFIG_FILE2);
|
|
1200
|
+
if (!await fileExists3(configPath)) return { id: null, name: null };
|
|
1201
|
+
try {
|
|
1202
|
+
const raw = await fs3.readFile(configPath, "utf8");
|
|
1203
|
+
const config = JSON.parse(raw);
|
|
1204
|
+
const project = config?.project;
|
|
1205
|
+
return {
|
|
1206
|
+
id: project?.id ?? null,
|
|
1207
|
+
name: project?.name ?? null
|
|
1208
|
+
};
|
|
1209
|
+
} catch {
|
|
1210
|
+
return { id: null, name: null };
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async function fetchProjectData(client, projectId) {
|
|
1214
|
+
try {
|
|
1215
|
+
const project = await client.getProject(projectId);
|
|
1216
|
+
if (!project) return null;
|
|
1217
|
+
const [flows, personas, apiKeys, recentTestRuns, insights] = await Promise.all([
|
|
1218
|
+
client.getFlows(projectId).catch(() => []),
|
|
1219
|
+
client.getPersonas(projectId).catch(() => []),
|
|
1220
|
+
client.getApiKeys(projectId).catch(() => []),
|
|
1221
|
+
client.getRecentTestRuns(projectId, 1e3).catch(() => []),
|
|
1222
|
+
client.getInsights(projectId).catch(() => [])
|
|
1223
|
+
]);
|
|
1224
|
+
return {
|
|
1225
|
+
id: project.id,
|
|
1226
|
+
name: project.name,
|
|
1227
|
+
flows: flows.map((f) => ({ id: f.id, name: f.name })),
|
|
1228
|
+
personas: personas.map((p) => ({ id: p.id, name: p.name })),
|
|
1229
|
+
apiKeys: apiKeys.map((k) => ({ id: k.id, name: k.name, key_prefix: k.key_prefix })),
|
|
1230
|
+
testRunsCount: recentTestRuns.length,
|
|
1231
|
+
insightsCount: insights.length
|
|
1232
|
+
};
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
console.warn(chalk5.yellow(`Warning: Could not fetch complete project data: ${error instanceof Error ? error.message : String(error)}`));
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
async function requireReauth(projectDir) {
|
|
1239
|
+
console.log(chalk5.yellow.bold("\n\u{1F512} Re-authentication Required"));
|
|
1240
|
+
console.log(chalk5.gray("For security, you must re-authenticate before deleting project data."));
|
|
1241
|
+
console.log(chalk5.gray("Your current session will be cleared and you'll need to log in again.\n"));
|
|
1242
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1243
|
+
const answer = await new Promise((resolve) => {
|
|
1244
|
+
rl.question(chalk5.bold("Continue with re-authentication? [y/N]: "), (ans) => {
|
|
1245
|
+
rl.close();
|
|
1246
|
+
resolve((ans || "n").trim().toLowerCase());
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1250
|
+
console.log(chalk5.gray("Cancelled."));
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
await clearStoredAuth("project", projectDir);
|
|
1255
|
+
await clearStoredAuth("global");
|
|
1256
|
+
console.log(chalk5.gray("Existing authentication cleared."));
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
console.warn(chalk5.yellow(`Warning: Could not clear existing auth: ${error instanceof Error ? error.message : String(error)}`));
|
|
1259
|
+
}
|
|
1260
|
+
console.log(chalk5.cyan("\nPlease log in again:"));
|
|
1261
|
+
try {
|
|
1262
|
+
const { loginCommand: loginCommand2 } = await import("./login-47R62VE3.js");
|
|
1263
|
+
await loginCommand2.parseAsync(["login", "--scope", "global"], { from: "user" });
|
|
1264
|
+
return true;
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
console.error(chalk5.red(`Re-authentication failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
var delCommand = new Command3("del").description("Remove Perceo from this project - PERMANENTLY deletes ALL project data including flows, personas, test runs, and API keys").alias("rm").option("-d, --dir <directory>", "Project directory", process.cwd()).option("-y, --yes", "Skip confirmation prompt (NOT recommended)", false).option("--skip-server", "Only remove local files; do not delete the project on the server", false).option("--keep-github", "Do not remove .github/workflows/perceo.yml", false).option("--skip-reauth", "Skip re-authentication requirement (DANGEROUS - for development only)", false).action(async (options) => {
|
|
1271
|
+
const projectDir = path5.resolve(options.dir || process.cwd());
|
|
1272
|
+
const perceoDir = path5.join(projectDir, CONFIG_DIR3);
|
|
1273
|
+
const workflowPath = path5.join(projectDir, GITHUB_WORKFLOW_PATH);
|
|
1274
|
+
const hasPerceoDir = await fileExists3(perceoDir);
|
|
1275
|
+
const hasWorkflow = await fileExists3(workflowPath);
|
|
1276
|
+
const { id: projectId, name: projectName } = await readProjectFromConfig(projectDir);
|
|
1277
|
+
const willDeleteServer = !options["skip-server"] && projectId;
|
|
1278
|
+
if (!hasPerceoDir && !hasWorkflow && !willDeleteServer) {
|
|
1279
|
+
console.log(chalk5.yellow("No Perceo setup found in this directory. Nothing to remove."));
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (willDeleteServer && !options["skip-reauth"]) {
|
|
1283
|
+
const reauthSuccess = await requireReauth(projectDir);
|
|
1284
|
+
if (!reauthSuccess) {
|
|
1285
|
+
console.log(chalk5.red("Re-authentication failed. Deletion cancelled for security."));
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
let projectData = null;
|
|
1290
|
+
let deleteClient = null;
|
|
1291
|
+
let resolvedProjectId = projectId;
|
|
1292
|
+
if (willDeleteServer && (projectId || projectName)) {
|
|
1293
|
+
try {
|
|
1294
|
+
if (projectId) {
|
|
1295
|
+
const access = await ensureProjectAccess({ projectDir, requireAdmin: true });
|
|
1296
|
+
deleteClient = access.client;
|
|
1297
|
+
resolvedProjectId = access.projectId;
|
|
1298
|
+
projectData = await fetchProjectData(deleteClient, access.projectId);
|
|
1299
|
+
} else {
|
|
1300
|
+
const access = await ensureProjectAccess({ projectDir, requireAdmin: false });
|
|
1301
|
+
const project = await access.client.getProjectByName(projectName);
|
|
1302
|
+
if (!project) {
|
|
1303
|
+
console.error(chalk5.red("Project not found or you don't have access."));
|
|
1304
|
+
process.exit(1);
|
|
1305
|
+
}
|
|
1306
|
+
const role = await checkProjectAccess(access.client, project.id, { requireAdmin: true });
|
|
1307
|
+
if (!role) {
|
|
1308
|
+
console.error(chalk5.red("Deleting the project requires owner or admin role."));
|
|
1309
|
+
process.exit(1);
|
|
1310
|
+
}
|
|
1311
|
+
deleteClient = access.client;
|
|
1312
|
+
resolvedProjectId = project.id;
|
|
1313
|
+
projectData = await fetchProjectData(deleteClient, project.id);
|
|
1314
|
+
}
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
console.warn(chalk5.yellow(`Warning: Could not fetch project data for confirmation: ${err instanceof Error ? err.message : String(err)}`));
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (!options.yes) {
|
|
1320
|
+
console.log(chalk5.red.bold("\n\u26A0\uFE0F DESTRUCTIVE OPERATION - PERMANENT DATA DELETION"));
|
|
1321
|
+
console.log(chalk5.red("This will permanently delete ALL Perceo data for this project."));
|
|
1322
|
+
console.log(chalk5.red("This action CANNOT be undone.\n"));
|
|
1323
|
+
console.log(chalk5.bold("\u{1F4C1} Local files to be removed:"));
|
|
1324
|
+
console.log(chalk5.gray(" Directory: ") + chalk5.cyan(projectDir));
|
|
1325
|
+
if (hasPerceoDir) console.log(chalk5.gray(" \u2022 ") + chalk5.cyan(".perceo/ (config, auth, etc.)"));
|
|
1326
|
+
if (hasWorkflow && !options["keep-github"]) console.log(chalk5.gray(" \u2022 ") + chalk5.cyan(GITHUB_WORKFLOW_PATH));
|
|
1327
|
+
if (willDeleteServer) {
|
|
1328
|
+
console.log(chalk5.bold("\n\u{1F5C4}\uFE0F Server data to be PERMANENTLY DELETED:"));
|
|
1329
|
+
if (projectData) {
|
|
1330
|
+
console.log(chalk5.gray(" Project: ") + chalk5.cyan(`${projectData.name} (${projectData.id})`));
|
|
1331
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red(`${projectData.flows.length} flows`) + chalk5.gray(" (including all steps)"));
|
|
1332
|
+
if (projectData.flows.length > 0) {
|
|
1333
|
+
projectData.flows.slice(0, 5).forEach((flow) => {
|
|
1334
|
+
console.log(chalk5.gray(" - ") + chalk5.cyan(flow.name));
|
|
1335
|
+
});
|
|
1336
|
+
if (projectData.flows.length > 5) {
|
|
1337
|
+
console.log(chalk5.gray(` ... and ${projectData.flows.length - 5} more`));
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red(`${projectData.personas.length} personas`));
|
|
1341
|
+
if (projectData.personas.length > 0) {
|
|
1342
|
+
projectData.personas.slice(0, 3).forEach((persona) => {
|
|
1343
|
+
console.log(chalk5.gray(" - ") + chalk5.cyan(persona.name));
|
|
1344
|
+
});
|
|
1345
|
+
if (projectData.personas.length > 3) {
|
|
1346
|
+
console.log(chalk5.gray(` ... and ${projectData.personas.length - 3} more`));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red(`${projectData.apiKeys.length} API keys`));
|
|
1350
|
+
if (projectData.apiKeys.length > 0) {
|
|
1351
|
+
projectData.apiKeys.forEach((key) => {
|
|
1352
|
+
console.log(chalk5.gray(" - ") + chalk5.cyan(`${key.name} (${key.key_prefix}...)`));
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red(`${projectData.testRunsCount} test runs`));
|
|
1356
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red(`${projectData.insightsCount} insights`));
|
|
1357
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red("All project secrets"));
|
|
1358
|
+
} else {
|
|
1359
|
+
console.log(chalk5.gray(" Project: ") + chalk5.cyan(projectId ?? projectName ?? "Unknown"));
|
|
1360
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red("All flows, personas, and related data"));
|
|
1361
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red("All API keys and project secrets"));
|
|
1362
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.red("All test runs and insights"));
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
console.log(chalk5.red.bold("\n\u{1F480} This deletion is PERMANENT and IRREVERSIBLE."));
|
|
1366
|
+
console.log(chalk5.gray("Type the project name to confirm deletion:"));
|
|
1367
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1368
|
+
const expectedName = projectData?.name ?? projectName ?? "CONFIRM";
|
|
1369
|
+
const answer = await new Promise((resolve) => {
|
|
1370
|
+
rl.question(chalk5.bold(`Enter "${expectedName}" to confirm: `), (ans) => {
|
|
1371
|
+
rl.close();
|
|
1372
|
+
resolve((ans || "").trim());
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
if (answer !== expectedName) {
|
|
1376
|
+
console.log(chalk5.gray("Project name does not match. Deletion cancelled."));
|
|
1377
|
+
process.exit(0);
|
|
1378
|
+
}
|
|
1379
|
+
const finalAnswer = await new Promise((resolve) => {
|
|
1380
|
+
const rl2 = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1381
|
+
rl2.question(chalk5.red.bold("Are you absolutely sure? This cannot be undone. [type 'DELETE']: "), (ans) => {
|
|
1382
|
+
rl2.close();
|
|
1383
|
+
resolve((ans || "").trim());
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
if (finalAnswer !== "DELETE") {
|
|
1387
|
+
console.log(chalk5.gray("Final confirmation failed. Deletion cancelled."));
|
|
1388
|
+
process.exit(0);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
console.log(chalk5.yellow("\n\u{1F5D1}\uFE0F Starting deletion process..."));
|
|
1392
|
+
const deletionSummary = {
|
|
1393
|
+
localFiles: [],
|
|
1394
|
+
serverData: {
|
|
1395
|
+
project: null,
|
|
1396
|
+
flows: 0,
|
|
1397
|
+
personas: 0,
|
|
1398
|
+
apiKeys: 0,
|
|
1399
|
+
testRuns: 0,
|
|
1400
|
+
insights: 0,
|
|
1401
|
+
secrets: 0
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
if (willDeleteServer && deleteClient && resolvedProjectId) {
|
|
1405
|
+
try {
|
|
1406
|
+
if (projectData) {
|
|
1407
|
+
deletionSummary.serverData = {
|
|
1408
|
+
project: { id: projectData.id, name: projectData.name },
|
|
1409
|
+
flows: projectData.flows.length,
|
|
1410
|
+
personas: projectData.personas.length,
|
|
1411
|
+
apiKeys: projectData.apiKeys.length,
|
|
1412
|
+
testRuns: projectData.testRunsCount,
|
|
1413
|
+
insights: projectData.insightsCount,
|
|
1414
|
+
secrets: 0
|
|
1415
|
+
// We can't easily count secrets, but they'll be deleted by cascade
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
await deleteClient.deleteProject(resolvedProjectId);
|
|
1419
|
+
console.log(chalk5.green("\u2705 Deleted project and all related data on server"));
|
|
1420
|
+
} catch (err) {
|
|
1421
|
+
console.error(chalk5.red("\u274C Failed to delete project on server: " + (err instanceof Error ? err.message : String(err))));
|
|
1422
|
+
process.exit(1);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (hasWorkflow && !options["keep-github"]) {
|
|
1426
|
+
await fs3.rm(workflowPath, { force: true });
|
|
1427
|
+
deletionSummary.localFiles.push(GITHUB_WORKFLOW_PATH);
|
|
1428
|
+
console.log(chalk5.green("\u2705 Removed ") + chalk5.cyan(GITHUB_WORKFLOW_PATH));
|
|
1429
|
+
}
|
|
1430
|
+
if (hasPerceoDir) {
|
|
1431
|
+
await fs3.rm(perceoDir, { recursive: true, force: true });
|
|
1432
|
+
deletionSummary.localFiles.push(".perceo/");
|
|
1433
|
+
console.log(chalk5.green("\u2705 Removed ") + chalk5.cyan(".perceo/"));
|
|
1434
|
+
}
|
|
1435
|
+
console.log(chalk5.bold.green("\n\u{1F389} Perceo successfully removed from this project"));
|
|
1436
|
+
console.log(chalk5.bold("\u{1F4CA} Deletion Summary:"));
|
|
1437
|
+
if (deletionSummary.localFiles.length > 0) {
|
|
1438
|
+
console.log(chalk5.gray(" Local files removed:"));
|
|
1439
|
+
deletionSummary.localFiles.forEach((file) => {
|
|
1440
|
+
console.log(chalk5.gray(" \u2022 ") + chalk5.cyan(file));
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
if (deletionSummary.serverData.project) {
|
|
1444
|
+
console.log(chalk5.gray(" Server data deleted:"));
|
|
1445
|
+
console.log(chalk5.gray(" \u2022 Project: ") + chalk5.cyan(deletionSummary.serverData.project.name));
|
|
1446
|
+
console.log(chalk5.gray(" \u2022 Flows: ") + chalk5.red(deletionSummary.serverData.flows.toString()));
|
|
1447
|
+
console.log(chalk5.gray(" \u2022 Personas: ") + chalk5.red(deletionSummary.serverData.personas.toString()));
|
|
1448
|
+
console.log(chalk5.gray(" \u2022 API Keys: ") + chalk5.red(deletionSummary.serverData.apiKeys.toString()));
|
|
1449
|
+
console.log(chalk5.gray(" \u2022 Test Runs: ") + chalk5.red(deletionSummary.serverData.testRuns.toString()));
|
|
1450
|
+
console.log(chalk5.gray(" \u2022 Insights: ") + chalk5.red(deletionSummary.serverData.insights.toString()));
|
|
1451
|
+
console.log(chalk5.gray(" \u2022 Project secrets and all related data"));
|
|
1452
|
+
}
|
|
1453
|
+
console.log(chalk5.gray("\nTo set up Perceo again, run: ") + chalk5.cyan("perceo init"));
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// src/commands/analyze.ts
|
|
1457
|
+
import { Command as Command4 } from "commander";
|
|
1458
|
+
import chalk6 from "chalk";
|
|
1459
|
+
import ora3 from "ora";
|
|
1460
|
+
import path6 from "path";
|
|
1461
|
+
import { computeChangeAnalysis } from "@perceo/observer-engine";
|
|
1462
|
+
var analyzeCommand = new Command4("analyze").description("Analyze git diff to find affected flows").requiredOption("--base <sha>", "Base Git ref (e.g., main, origin/main, commit SHA)").option("--head <sha>", "Head Git ref (default: HEAD)", "HEAD").option("--project-dir <dir>", "Project directory", process.cwd()).option("--json", "Output JSON for CI integration", false).action(async (options) => {
|
|
1463
|
+
const projectRoot = path6.resolve(options.projectDir ?? process.cwd());
|
|
1464
|
+
const spinner = ora3("Analyzing changes...").start();
|
|
1465
|
+
try {
|
|
1466
|
+
const { client, projectId, projectName } = await ensureProjectAccess({ projectDir: projectRoot });
|
|
1467
|
+
spinner.text = "Computing git diff...";
|
|
1468
|
+
const baseSha = options.base;
|
|
1469
|
+
const headSha = options.head ?? "HEAD";
|
|
1470
|
+
const analysis = await computeChangeAnalysis(projectRoot, baseSha, headSha);
|
|
1471
|
+
if (analysis.files.length === 0) {
|
|
1472
|
+
spinner.succeed("No changes found");
|
|
1473
|
+
if (options.json) {
|
|
1474
|
+
console.log(JSON.stringify({ flows: [], changes: [], riskLevel: "low", riskScore: 0 }, null, 2));
|
|
1475
|
+
} else {
|
|
1476
|
+
console.log(chalk6.green("\n\u2713 No files changed between the commits"));
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
spinner.text = "Matching against flows...";
|
|
1481
|
+
const flows = await client.getFlows(projectId);
|
|
1482
|
+
if (flows.length === 0) {
|
|
1483
|
+
spinner.warn("No flows found");
|
|
1484
|
+
console.log(chalk6.yellow("\nNo flows have been discovered yet. Run `perceo init` to discover flows."));
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const affectedFlows = matchAffectedFlows(flows, analysis.files);
|
|
1488
|
+
spinner.text = "Storing analysis...";
|
|
1489
|
+
const codeChange = await client.createCodeChange({
|
|
1490
|
+
project_id: projectId,
|
|
1491
|
+
base_sha: baseSha,
|
|
1492
|
+
head_sha: headSha,
|
|
1493
|
+
files: analysis.files.map((f) => ({
|
|
1494
|
+
path: f.path,
|
|
1495
|
+
status: f.status
|
|
1496
|
+
}))
|
|
1497
|
+
});
|
|
1498
|
+
const riskScore = calculateOverallRisk(affectedFlows);
|
|
1499
|
+
const riskLevel = getRiskLevel(riskScore);
|
|
1500
|
+
await client.updateCodeChangeAnalysis(codeChange.id, {
|
|
1501
|
+
risk_level: riskLevel,
|
|
1502
|
+
risk_score: riskScore,
|
|
1503
|
+
affected_flow_ids: affectedFlows.map((f) => f.flow.id)
|
|
1504
|
+
});
|
|
1505
|
+
if (affectedFlows.length > 0) {
|
|
1506
|
+
await client.markFlowsAffected(
|
|
1507
|
+
affectedFlows.map((f) => f.flow.id),
|
|
1508
|
+
codeChange.id,
|
|
1509
|
+
riskScore * 0.2
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
spinner.succeed("Analysis complete");
|
|
1513
|
+
const result = {
|
|
1514
|
+
projectId,
|
|
1515
|
+
projectName,
|
|
1516
|
+
baseSha,
|
|
1517
|
+
headSha,
|
|
1518
|
+
flows: affectedFlows.map((f) => ({
|
|
1519
|
+
id: f.flow.id,
|
|
1520
|
+
name: f.flow.name,
|
|
1521
|
+
priority: f.flow.priority,
|
|
1522
|
+
riskScore: f.riskScore,
|
|
1523
|
+
confidence: f.confidence,
|
|
1524
|
+
matchedFiles: f.matchedFiles
|
|
1525
|
+
})),
|
|
1526
|
+
changes: analysis.files,
|
|
1527
|
+
riskLevel,
|
|
1528
|
+
riskScore,
|
|
1529
|
+
createdAt: Date.now()
|
|
1530
|
+
};
|
|
1531
|
+
if (options.json) {
|
|
1532
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1533
|
+
} else {
|
|
1534
|
+
printResults(result);
|
|
1535
|
+
}
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
spinner.fail("Analysis failed");
|
|
1538
|
+
console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
function matchAffectedFlows(flows, changedFiles) {
|
|
1543
|
+
const affected = [];
|
|
1544
|
+
for (const flow of flows) {
|
|
1545
|
+
const matchedFiles = [];
|
|
1546
|
+
let maxConfidence = 0;
|
|
1547
|
+
for (const file of changedFiles) {
|
|
1548
|
+
const confidence = calculateMatchConfidence(flow, file.path);
|
|
1549
|
+
if (confidence > 0.3) {
|
|
1550
|
+
matchedFiles.push(file.path);
|
|
1551
|
+
maxConfidence = Math.max(maxConfidence, confidence);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (matchedFiles.length > 0) {
|
|
1555
|
+
affected.push({
|
|
1556
|
+
flow,
|
|
1557
|
+
riskScore: calculateFlowRisk(flow, matchedFiles, changedFiles),
|
|
1558
|
+
confidence: maxConfidence,
|
|
1559
|
+
matchedFiles
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return affected.sort((a, b) => b.riskScore - a.riskScore);
|
|
1564
|
+
}
|
|
1565
|
+
function calculateMatchConfidence(flow, filePath) {
|
|
1566
|
+
const normalizedPath = filePath.toLowerCase();
|
|
1567
|
+
const flowName = flow.name.toLowerCase();
|
|
1568
|
+
if (flow.entry_point) {
|
|
1569
|
+
const entryNormalized = flow.entry_point.toLowerCase().replace(/\//g, "");
|
|
1570
|
+
if (normalizedPath.includes(entryNormalized)) {
|
|
1571
|
+
return 0.9;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const keywords = flowName.split(/[\s-_]+/).filter((k) => k.length > 2);
|
|
1575
|
+
for (const keyword of keywords) {
|
|
1576
|
+
if (normalizedPath.includes(keyword)) {
|
|
1577
|
+
return 0.7;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const graphData = flow.graph_data;
|
|
1581
|
+
if (graphData) {
|
|
1582
|
+
const allRefs = [...graphData.components ?? [], ...graphData.pages ?? []];
|
|
1583
|
+
for (const ref of allRefs) {
|
|
1584
|
+
if (normalizedPath.includes(ref.toLowerCase())) {
|
|
1585
|
+
return 0.8;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return 0;
|
|
1590
|
+
}
|
|
1591
|
+
function calculateFlowRisk(flow, matchedFiles, allChanges) {
|
|
1592
|
+
const priorityWeight = {
|
|
1593
|
+
critical: 1,
|
|
1594
|
+
high: 0.75,
|
|
1595
|
+
medium: 0.5,
|
|
1596
|
+
low: 0.25
|
|
1597
|
+
};
|
|
1598
|
+
const baseRisk = priorityWeight[flow.priority] ?? 0.5;
|
|
1599
|
+
const fileRatio = matchedFiles.length / Math.max(allChanges.length, 1);
|
|
1600
|
+
return Math.min(1, baseRisk + fileRatio * 0.3);
|
|
1601
|
+
}
|
|
1602
|
+
function calculateOverallRisk(affected) {
|
|
1603
|
+
if (affected.length === 0) return 0;
|
|
1604
|
+
let totalWeight = 0;
|
|
1605
|
+
let weightedRisk = 0;
|
|
1606
|
+
for (const a of affected) {
|
|
1607
|
+
const weight = a.flow.priority === "critical" ? 2 : a.flow.priority === "high" ? 1.5 : 1;
|
|
1608
|
+
totalWeight += weight;
|
|
1609
|
+
weightedRisk += a.riskScore * weight;
|
|
1610
|
+
}
|
|
1611
|
+
return Math.min(1, weightedRisk / totalWeight);
|
|
1612
|
+
}
|
|
1613
|
+
function getRiskLevel(score) {
|
|
1614
|
+
if (score >= 0.8) return "critical";
|
|
1615
|
+
if (score >= 0.6) return "high";
|
|
1616
|
+
if (score >= 0.3) return "medium";
|
|
1617
|
+
return "low";
|
|
1618
|
+
}
|
|
1619
|
+
function printResults(result) {
|
|
1620
|
+
console.log();
|
|
1621
|
+
console.log(chalk6.bold("Project: ") + result.projectName);
|
|
1622
|
+
console.log(chalk6.bold("Diff: ") + `${result.baseSha}...${result.headSha}`);
|
|
1623
|
+
console.log();
|
|
1624
|
+
const riskColor = result.riskLevel === "critical" ? chalk6.red : result.riskLevel === "high" ? chalk6.yellow : result.riskLevel === "medium" ? chalk6.blue : chalk6.green;
|
|
1625
|
+
console.log(chalk6.bold("Overall Risk: ") + riskColor(`${result.riskLevel.toUpperCase()} (${(result.riskScore * 100).toFixed(0)}%)`));
|
|
1626
|
+
console.log();
|
|
1627
|
+
if (result.flows.length === 0) {
|
|
1628
|
+
console.log(chalk6.green("\u2713 No flows affected by these changes"));
|
|
1629
|
+
} else {
|
|
1630
|
+
console.log(chalk6.bold(`${result.flows.length} flow(s) affected:`));
|
|
1631
|
+
console.log();
|
|
1632
|
+
for (const flow of result.flows) {
|
|
1633
|
+
const riskColor2 = flow.riskScore > 0.7 ? chalk6.red : flow.riskScore > 0.4 ? chalk6.yellow : chalk6.green;
|
|
1634
|
+
const priorityColor = flow.priority === "critical" ? chalk6.red : flow.priority === "high" ? chalk6.yellow : chalk6.gray;
|
|
1635
|
+
const risk = (flow.riskScore * 100).toFixed(0);
|
|
1636
|
+
const confidence = (flow.confidence * 100).toFixed(0);
|
|
1637
|
+
console.log(` ${chalk6.cyan(flow.name)} ${priorityColor(`[${flow.priority}]`)}`);
|
|
1638
|
+
console.log(` Risk: ${riskColor2(`${risk}%`)} Confidence: ${confidence}%`);
|
|
1639
|
+
console.log(` Matched: ${chalk6.gray(flow.matchedFiles.slice(0, 3).join(", "))}${flow.matchedFiles.length > 3 ? ` (+${flow.matchedFiles.length - 3})` : ""}`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
console.log();
|
|
1643
|
+
console.log(chalk6.bold(`${result.changes.length} file(s) changed:`));
|
|
1644
|
+
const maxShow = 10;
|
|
1645
|
+
for (const file of result.changes.slice(0, maxShow)) {
|
|
1646
|
+
const icon = file.status === "added" ? chalk6.green("+") : file.status === "deleted" ? chalk6.red("-") : chalk6.yellow("~");
|
|
1647
|
+
console.log(` ${icon} ${file.path}`);
|
|
1648
|
+
}
|
|
1649
|
+
if (result.changes.length > maxShow) {
|
|
1650
|
+
console.log(chalk6.gray(` ... and ${result.changes.length - maxShow} more`));
|
|
1651
|
+
}
|
|
1652
|
+
console.log();
|
|
1653
|
+
if (result.riskLevel === "critical" || result.riskLevel === "high") {
|
|
1654
|
+
console.log(chalk6.red(`\u26A0\uFE0F High-risk changes detected. Recommend running tests for affected flows.`));
|
|
1655
|
+
} else if (result.flows.length > 0) {
|
|
1656
|
+
console.log(chalk6.yellow(`\u2139\uFE0F ${result.flows.length} flow(s) may be affected. Consider testing them.`));
|
|
1657
|
+
} else {
|
|
1658
|
+
console.log(chalk6.green(`\u2713 Low risk - no critical flows affected.`));
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/commands/keys.ts
|
|
1663
|
+
import { Command as Command5 } from "commander";
|
|
1664
|
+
import chalk7 from "chalk";
|
|
1665
|
+
import ora4 from "ora";
|
|
1666
|
+
var keysCommand = new Command5("keys").description("Manage project API keys for CI/CD authentication");
|
|
1667
|
+
keysCommand.command("list").description("List all API keys for the current project").option("-a, --all", "Include revoked and expired keys", false).action(async (options) => {
|
|
1668
|
+
const spinner = ora4("Loading API keys...").start();
|
|
1669
|
+
try {
|
|
1670
|
+
const { client, projectId } = await ensureProjectAccess();
|
|
1671
|
+
const keys = options.all ? await client.getApiKeys(projectId) : await client.getActiveApiKeys(projectId);
|
|
1672
|
+
spinner.stop();
|
|
1673
|
+
if (keys.length === 0) {
|
|
1674
|
+
console.log(chalk7.yellow("No API keys found."));
|
|
1675
|
+
console.log(chalk7.gray("Create one with: perceo keys create --name <name>"));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
console.log(chalk7.bold("\nAPI Keys:\n"));
|
|
1679
|
+
console.log(chalk7.gray("\u2500".repeat(80)));
|
|
1680
|
+
for (const key of keys) {
|
|
1681
|
+
const status = getKeyStatus(key);
|
|
1682
|
+
const statusColor = status === "active" ? chalk7.green : status === "expired" ? chalk7.yellow : chalk7.red;
|
|
1683
|
+
console.log(` ${chalk7.bold(key.name)}`);
|
|
1684
|
+
console.log(` Prefix: ${chalk7.cyan(key.key_prefix)}...`);
|
|
1685
|
+
console.log(` Status: ${statusColor(status)}`);
|
|
1686
|
+
console.log(` Scopes: ${chalk7.gray(key.scopes.join(", "))}`);
|
|
1687
|
+
console.log(` Created: ${chalk7.gray(formatDate(key.created_at))}`);
|
|
1688
|
+
if (key.last_used_at) {
|
|
1689
|
+
console.log(` Last used: ${chalk7.gray(formatDate(key.last_used_at))}`);
|
|
1690
|
+
}
|
|
1691
|
+
if (key.expires_at) {
|
|
1692
|
+
console.log(` Expires: ${chalk7.gray(formatDate(key.expires_at))}`);
|
|
1693
|
+
}
|
|
1694
|
+
console.log(chalk7.gray("\u2500".repeat(80)));
|
|
1695
|
+
}
|
|
1696
|
+
console.log(chalk7.gray(`
|
|
1697
|
+
Total: ${keys.length} key(s)`));
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
spinner.fail("Failed to load API keys");
|
|
1700
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1701
|
+
process.exit(1);
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
keysCommand.command("create").description("Create a new API key").requiredOption("-n, --name <name>", "Name for the API key (e.g., 'github-actions', 'jenkins')").option("-s, --scopes <scopes>", "Comma-separated list of scopes", "ci:analyze,ci:test,flows:read,insights:read,events:publish").option("-e, --expires <days>", "Number of days until expiration (default: never)").action(async (options) => {
|
|
1705
|
+
const spinner = ora4("Creating API key...").start();
|
|
1706
|
+
try {
|
|
1707
|
+
const { client, projectId } = await ensureProjectAccess({ requireAdmin: true });
|
|
1708
|
+
const scopes = options.scopes.split(",").map((s) => s.trim());
|
|
1709
|
+
const expiresAt = options.expires ? new Date(Date.now() + parseInt(options.expires, 10) * 24 * 60 * 60 * 1e3) : void 0;
|
|
1710
|
+
const { key, keyRecord } = await client.createApiKey(projectId, {
|
|
1711
|
+
name: options.name,
|
|
1712
|
+
scopes,
|
|
1713
|
+
expiresAt
|
|
1714
|
+
});
|
|
1715
|
+
spinner.succeed("API key created successfully!");
|
|
1716
|
+
console.log("\n" + chalk7.bold.green("New API Key:"));
|
|
1717
|
+
console.log(chalk7.gray("\u2500".repeat(60)));
|
|
1718
|
+
console.log(` Name: ${chalk7.bold(keyRecord.name)}`);
|
|
1719
|
+
console.log(` Prefix: ${chalk7.cyan(keyRecord.key_prefix)}...`);
|
|
1720
|
+
console.log(` Scopes: ${chalk7.gray(scopes.join(", "))}`);
|
|
1721
|
+
if (expiresAt) {
|
|
1722
|
+
console.log(` Expires: ${chalk7.gray(formatDate(expiresAt.toISOString()))}`);
|
|
1723
|
+
}
|
|
1724
|
+
console.log(chalk7.gray("\u2500".repeat(60)));
|
|
1725
|
+
console.log("\n" + chalk7.bold.yellow(" API Key (copy this now - shown only once):"));
|
|
1726
|
+
console.log("\n " + chalk7.green(key));
|
|
1727
|
+
console.log("\n" + chalk7.gray("\u2500".repeat(60)));
|
|
1728
|
+
console.log(chalk7.gray("\n Add this as a secret in your CI environment:"));
|
|
1729
|
+
console.log(chalk7.gray(" - GitHub: Settings \u2192 Secrets \u2192 PERCEO_API_KEY"));
|
|
1730
|
+
console.log(chalk7.gray(" - GitLab: Settings \u2192 CI/CD \u2192 Variables"));
|
|
1731
|
+
console.log(chalk7.gray(" - Other: Set PERCEO_API_KEY environment variable\n"));
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
spinner.fail("Failed to create API key");
|
|
1734
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1735
|
+
process.exit(1);
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
keysCommand.command("revoke").description("Revoke an API key").argument("<prefix>", "Key prefix to revoke (e.g., prc_abc12345)").option("-r, --reason <reason>", "Reason for revocation").action(async (prefix, options) => {
|
|
1739
|
+
const spinner = ora4("Revoking API key...").start();
|
|
1740
|
+
try {
|
|
1741
|
+
const { client, projectId } = await ensureProjectAccess({ requireAdmin: true });
|
|
1742
|
+
const keys = await client.getApiKeys(projectId);
|
|
1743
|
+
const keyToRevoke = keys.find((k) => k.key_prefix === prefix || k.key_prefix.startsWith(prefix));
|
|
1744
|
+
if (!keyToRevoke) {
|
|
1745
|
+
spinner.fail(`No key found with prefix: ${prefix}`);
|
|
1746
|
+
console.log(chalk7.gray("Run 'perceo keys list' to see available keys."));
|
|
1747
|
+
process.exit(1);
|
|
1748
|
+
}
|
|
1749
|
+
if (keyToRevoke.revoked_at) {
|
|
1750
|
+
spinner.fail(`Key ${keyToRevoke.name} is already revoked.`);
|
|
1751
|
+
process.exit(1);
|
|
1752
|
+
}
|
|
1753
|
+
await client.revokeApiKey(keyToRevoke.id, { reason: options.reason });
|
|
1754
|
+
spinner.succeed(`API key "${keyToRevoke.name}" revoked successfully!`);
|
|
1755
|
+
console.log(chalk7.gray("\nThe key can no longer be used for authentication."));
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
spinner.fail("Failed to revoke API key");
|
|
1758
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
function getKeyStatus(key) {
|
|
1763
|
+
if (key.revoked_at) return "revoked";
|
|
1764
|
+
if (key.expires_at && new Date(key.expires_at) < /* @__PURE__ */ new Date()) return "expired";
|
|
1765
|
+
return "active";
|
|
1766
|
+
}
|
|
1767
|
+
function formatDate(dateStr) {
|
|
1768
|
+
const date = new Date(dateStr);
|
|
1769
|
+
return date.toLocaleDateString("en-US", {
|
|
1770
|
+
year: "numeric",
|
|
1771
|
+
month: "short",
|
|
1772
|
+
day: "numeric",
|
|
1773
|
+
hour: "2-digit",
|
|
1774
|
+
minute: "2-digit"
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/index.ts
|
|
1779
|
+
var originalEmit = process.emit.bind(process);
|
|
1780
|
+
process.emit = function(event, ...args) {
|
|
1781
|
+
const warning = args[0];
|
|
1782
|
+
if (event === "warning" && warning && typeof warning === "object" && "name" in warning && warning.name === "DeprecationWarning" && "message" in warning && typeof warning.message === "string" && (warning.message.includes("url.parse()") || warning.code === "DEP0169")) {
|
|
1783
|
+
return true;
|
|
1784
|
+
}
|
|
1785
|
+
return originalEmit.apply(process, [event, ...args]);
|
|
1786
|
+
};
|
|
1787
|
+
var program = new Command6();
|
|
1788
|
+
program.name("perceo").description("Intelligent regression testing through multi-agent simulation").version("0.1.0");
|
|
1789
|
+
program.addCommand(loginCommand);
|
|
1790
|
+
program.addCommand(logoutCommand);
|
|
1791
|
+
program.addCommand(initCommand);
|
|
1792
|
+
program.addCommand(delCommand);
|
|
1793
|
+
program.addCommand(analyzeCommand);
|
|
1794
|
+
program.addCommand(keysCommand);
|
|
1795
|
+
program.parse();
|