pruff-skills 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/cli.mjs +1022 -307
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import "./_chunks/libs/@kwsites/promise-deferred.mjs";
|
|
|
7
7
|
import { t as esm_default } from "./_chunks/libs/simple-git.mjs";
|
|
8
8
|
import { t as xdgConfig } from "./_chunks/libs/xdg-basedir.mjs";
|
|
9
9
|
import { t as require_dist } from "./_chunks/libs/@vercel/detect-agent.mjs";
|
|
10
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
|
|
11
|
-
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
|
|
10
|
+
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
11
|
+
import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
|
|
12
12
|
import { fileURLToPath } from "url";
|
|
13
13
|
import { stripVTControlCharacters } from "node:util";
|
|
14
14
|
import { homedir, platform, tmpdir } from "os";
|
|
@@ -941,7 +941,7 @@ function filterSkills(skills, inputNames) {
|
|
|
941
941
|
const home$1 = homedir();
|
|
942
942
|
const configHome = xdgConfig ?? join(home$1, ".config");
|
|
943
943
|
const codexHome = process.env.CODEX_HOME?.trim() || join(home$1, ".codex");
|
|
944
|
-
const claudeHome$
|
|
944
|
+
const claudeHome$2 = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home$1, ".claude");
|
|
945
945
|
const vibeHome = process.env.VIBE_HOME?.trim() || join(home$1, ".vibe");
|
|
946
946
|
const hermesHome = process.env.HERMES_HOME?.trim() || join(home$1, ".hermes");
|
|
947
947
|
const autohandHome = process.env.AUTOHAND_HOME?.trim() || join(home$1, ".autohand");
|
|
@@ -1030,9 +1030,9 @@ const agents = {
|
|
|
1030
1030
|
name: "claude-code",
|
|
1031
1031
|
displayName: "Claude Code",
|
|
1032
1032
|
skillsDir: ".claude/skills",
|
|
1033
|
-
globalSkillsDir: join(claudeHome$
|
|
1033
|
+
globalSkillsDir: join(claudeHome$2, "skills"),
|
|
1034
1034
|
detectInstalled: async () => {
|
|
1035
|
-
return existsSync(claudeHome$
|
|
1035
|
+
return existsSync(claudeHome$2);
|
|
1036
1036
|
}
|
|
1037
1037
|
},
|
|
1038
1038
|
openclaw: {
|
|
@@ -4032,23 +4032,24 @@ function parseAddOptions(args) {
|
|
|
4032
4032
|
};
|
|
4033
4033
|
}
|
|
4034
4034
|
const execFileAsync$4 = promisify(execFile);
|
|
4035
|
-
const
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4035
|
+
const isLocalSource = (source) => source.startsWith(".") || isAbsolute(source);
|
|
4036
|
+
const isGitHubSource = (source) => /^[^/]+\/[^/]+$/.test(source);
|
|
4037
|
+
const ghEnv = () => ({
|
|
4038
|
+
...process.env,
|
|
4039
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
4040
|
+
});
|
|
4041
|
+
async function fetchCatalogFile(source, rel) {
|
|
4042
|
+
if (isLocalSource(source)) {
|
|
4039
4043
|
const file = join(source, rel);
|
|
4040
4044
|
return existsSync(file) ? readFile(file, "utf8") : null;
|
|
4041
4045
|
}
|
|
4042
|
-
if (
|
|
4046
|
+
if (isGitHubSource(source)) try {
|
|
4043
4047
|
const { stdout } = await execFileAsync$4("gh", [
|
|
4044
4048
|
"api",
|
|
4045
4049
|
`repos/${source}/contents/${rel}`,
|
|
4046
4050
|
"--jq",
|
|
4047
4051
|
".content"
|
|
4048
|
-
], { env:
|
|
4049
|
-
...process.env,
|
|
4050
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
4051
|
-
} });
|
|
4052
|
+
], { env: ghEnv() });
|
|
4052
4053
|
return Buffer.from(stdout.trim(), "base64").toString("utf8");
|
|
4053
4054
|
} catch {
|
|
4054
4055
|
return null;
|
|
@@ -4060,33 +4061,35 @@ async function fetchAgentFile(source, name) {
|
|
|
4060
4061
|
return null;
|
|
4061
4062
|
}
|
|
4062
4063
|
}
|
|
4063
|
-
async function
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
return toNames(await readdir(join(source, "agents")));
|
|
4064
|
+
async function listCatalogDir(source, dir) {
|
|
4065
|
+
if (isLocalSource(source)) try {
|
|
4066
|
+
return await readdir(join(source, dir));
|
|
4067
4067
|
} catch {
|
|
4068
4068
|
return null;
|
|
4069
4069
|
}
|
|
4070
|
-
if (
|
|
4070
|
+
if (isGitHubSource(source)) try {
|
|
4071
4071
|
const { stdout } = await execFileAsync$4("gh", [
|
|
4072
4072
|
"api",
|
|
4073
|
-
`repos/${source}/contents
|
|
4073
|
+
`repos/${source}/contents/${dir}`,
|
|
4074
4074
|
"--jq",
|
|
4075
4075
|
".[].name"
|
|
4076
|
-
], { env:
|
|
4077
|
-
|
|
4078
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
4079
|
-
} });
|
|
4080
|
-
return toNames(stdout.trim().split("\n").filter(Boolean));
|
|
4076
|
+
], { env: ghEnv() });
|
|
4077
|
+
return stdout.trim().split("\n").filter(Boolean);
|
|
4081
4078
|
} catch {
|
|
4082
4079
|
return null;
|
|
4083
4080
|
}
|
|
4084
4081
|
try {
|
|
4085
|
-
return
|
|
4082
|
+
return await readdir(join(await cloneRepo$1(source), dir));
|
|
4086
4083
|
} catch {
|
|
4087
4084
|
return null;
|
|
4088
4085
|
}
|
|
4089
4086
|
}
|
|
4087
|
+
const claudeAgentsDir = (global) => global ? join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "agents") : join(process.cwd(), ".claude", "agents");
|
|
4088
|
+
const fetchAgentFile = (source, name) => fetchCatalogFile(source, `agents/${name}.md`);
|
|
4089
|
+
async function listCatalogAgents(source) {
|
|
4090
|
+
const files = await listCatalogDir(source, "agents");
|
|
4091
|
+
return files ? files.filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3)) : null;
|
|
4092
|
+
}
|
|
4090
4093
|
async function listInstalledAgents(targetDir) {
|
|
4091
4094
|
try {
|
|
4092
4095
|
return (await readdir(targetDir, { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.slice(0, -3));
|
|
@@ -6276,6 +6279,641 @@ function fail(message) {
|
|
|
6276
6279
|
process.exit(1);
|
|
6277
6280
|
}
|
|
6278
6281
|
var UseCommandError = class extends Error {};
|
|
6282
|
+
const claudeHome$1 = () => process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
|
|
6283
|
+
const claudeSettingsPath$1 = () => join(claudeHome$1(), "settings.json");
|
|
6284
|
+
const pruffHooksDir = () => join(claudeHome$1(), "hooks", "pruff");
|
|
6285
|
+
const statePath = () => join(claudeHome$1(), "pruff-skills", "state.json");
|
|
6286
|
+
function readClaudeSettings$1() {
|
|
6287
|
+
const path = claudeSettingsPath$1();
|
|
6288
|
+
if (!existsSync(path)) return {};
|
|
6289
|
+
let parsed;
|
|
6290
|
+
try {
|
|
6291
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
6292
|
+
} catch {
|
|
6293
|
+
throw new Error(`${path} is not valid JSON. Fix it (or move it aside) before running this command.`);
|
|
6294
|
+
}
|
|
6295
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
6296
|
+
}
|
|
6297
|
+
function writeClaudeSettings$1(settings) {
|
|
6298
|
+
const path = claudeSettingsPath$1();
|
|
6299
|
+
const next = `${JSON.stringify(settings, null, 2)}\n`;
|
|
6300
|
+
const current = existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
6301
|
+
if (current === next) return null;
|
|
6302
|
+
mkdirSync(claudeHome$1(), { recursive: true });
|
|
6303
|
+
let backup = null;
|
|
6304
|
+
if (current !== null) {
|
|
6305
|
+
backup = `${path}.pruff-bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
6306
|
+
copyFileSync(path, backup);
|
|
6307
|
+
}
|
|
6308
|
+
writeFileSync(path, next, "utf-8");
|
|
6309
|
+
return backup;
|
|
6310
|
+
}
|
|
6311
|
+
function readState() {
|
|
6312
|
+
try {
|
|
6313
|
+
const parsed = JSON.parse(readFileSync(statePath(), "utf-8"));
|
|
6314
|
+
return {
|
|
6315
|
+
hooks: parsed.hooks ?? {},
|
|
6316
|
+
env: parsed.env ?? [],
|
|
6317
|
+
files: parsed.files ?? {}
|
|
6318
|
+
};
|
|
6319
|
+
} catch {
|
|
6320
|
+
return {
|
|
6321
|
+
hooks: {},
|
|
6322
|
+
env: [],
|
|
6323
|
+
files: {}
|
|
6324
|
+
};
|
|
6325
|
+
}
|
|
6326
|
+
}
|
|
6327
|
+
function writeState(state) {
|
|
6328
|
+
mkdirSync(join(claudeHome$1(), "pruff-skills"), { recursive: true });
|
|
6329
|
+
writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
6330
|
+
}
|
|
6331
|
+
function upsertHook(settings, event, matcher, command) {
|
|
6332
|
+
const hooks = { ...settings.hooks ?? {} };
|
|
6333
|
+
const groups = (hooks[event] ?? []).map((g) => ({
|
|
6334
|
+
...g,
|
|
6335
|
+
hooks: [...g.hooks]
|
|
6336
|
+
}));
|
|
6337
|
+
const existing = groups.find((g) => g.matcher === matcher && g.hooks.some((h) => h.command === command.command));
|
|
6338
|
+
if (existing) existing.hooks = existing.hooks.map((h) => h.command === command.command ? command : h);
|
|
6339
|
+
else groups.push(matcher === void 0 ? { hooks: [command] } : {
|
|
6340
|
+
matcher,
|
|
6341
|
+
hooks: [command]
|
|
6342
|
+
});
|
|
6343
|
+
hooks[event] = groups;
|
|
6344
|
+
return {
|
|
6345
|
+
...settings,
|
|
6346
|
+
hooks
|
|
6347
|
+
};
|
|
6348
|
+
}
|
|
6349
|
+
function removeHooks(settings, isOwned) {
|
|
6350
|
+
if (!settings.hooks) return settings;
|
|
6351
|
+
const hooks = {};
|
|
6352
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
6353
|
+
const kept = groups.map((g) => ({
|
|
6354
|
+
...g,
|
|
6355
|
+
hooks: g.hooks.filter((h) => !isOwned(h.command))
|
|
6356
|
+
})).filter((g) => g.hooks.length > 0);
|
|
6357
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
6358
|
+
}
|
|
6359
|
+
const next = { ...settings };
|
|
6360
|
+
if (Object.keys(hooks).length === 0) delete next.hooks;
|
|
6361
|
+
else next.hooks = hooks;
|
|
6362
|
+
return next;
|
|
6363
|
+
}
|
|
6364
|
+
function applyOwnedEnv(settings, desired, previouslyOwned) {
|
|
6365
|
+
const env = { ...settings.env ?? {} };
|
|
6366
|
+
const owned = [];
|
|
6367
|
+
const keptUserValues = [];
|
|
6368
|
+
for (const key of previouslyOwned) if (!(key in desired)) delete env[key];
|
|
6369
|
+
for (const [key, value] of Object.entries(desired)) {
|
|
6370
|
+
const isOurs = previouslyOwned.includes(key);
|
|
6371
|
+
if (key in env && env[key] !== value && !isOurs) {
|
|
6372
|
+
keptUserValues.push(key);
|
|
6373
|
+
continue;
|
|
6374
|
+
}
|
|
6375
|
+
env[key] = value;
|
|
6376
|
+
owned.push(key);
|
|
6377
|
+
}
|
|
6378
|
+
const next = { ...settings };
|
|
6379
|
+
if (Object.keys(env).length === 0) delete next.env;
|
|
6380
|
+
else next.env = env;
|
|
6381
|
+
return {
|
|
6382
|
+
settings: next,
|
|
6383
|
+
owned,
|
|
6384
|
+
keptUserValues
|
|
6385
|
+
};
|
|
6386
|
+
}
|
|
6387
|
+
const HOOK_EVENTS = [
|
|
6388
|
+
"PreToolUse",
|
|
6389
|
+
"PostToolUse",
|
|
6390
|
+
"UserPromptSubmit",
|
|
6391
|
+
"Notification",
|
|
6392
|
+
"Stop",
|
|
6393
|
+
"SubagentStop",
|
|
6394
|
+
"SessionStart",
|
|
6395
|
+
"SessionEnd",
|
|
6396
|
+
"PreCompact"
|
|
6397
|
+
];
|
|
6398
|
+
const sha256$1 = (content) => createHash("sha256").update(content).digest("hex");
|
|
6399
|
+
const hookDir = (name) => join(pruffHooksDir(), name);
|
|
6400
|
+
const commandFor = (name, script) => `"${join(hookDir(name), script)}"`;
|
|
6401
|
+
const ownedBy = (name) => (command) => command.includes(`${join(pruffHooksDir(), name)}/`);
|
|
6402
|
+
function parseHookManifest(raw, expectedName) {
|
|
6403
|
+
let data;
|
|
6404
|
+
try {
|
|
6405
|
+
data = JSON.parse(raw);
|
|
6406
|
+
} catch {
|
|
6407
|
+
throw new Error(`hooks/${expectedName}/hook.json is not valid JSON`);
|
|
6408
|
+
}
|
|
6409
|
+
if (data.name !== expectedName) throw new Error(`hooks/${expectedName}/hook.json: "name" must be "${expectedName}"`);
|
|
6410
|
+
if (!data.script || !/^[\w.-]+$/.test(data.script)) throw new Error(`hooks/${expectedName}/hook.json: "script" must be a bare file name`);
|
|
6411
|
+
if (!Array.isArray(data.registrations) || data.registrations.length === 0) throw new Error(`hooks/${expectedName}/hook.json: needs at least one registration`);
|
|
6412
|
+
for (const r of data.registrations) if (!HOOK_EVENTS.includes(r.event)) throw new Error(`hooks/${expectedName}/hook.json: unknown event "${String(r.event)}"`);
|
|
6413
|
+
return data;
|
|
6414
|
+
}
|
|
6415
|
+
async function listCatalogHooks(source = PRUFF_CATALOG) {
|
|
6416
|
+
const entries = await listCatalogDir(source, "hooks");
|
|
6417
|
+
return entries ? entries.filter(isBareSkillName).sort() : null;
|
|
6418
|
+
}
|
|
6419
|
+
function applyHook(settings, manifest) {
|
|
6420
|
+
const command = commandFor(manifest.name, manifest.script);
|
|
6421
|
+
const registrations = manifest.registrations.map((r) => ({
|
|
6422
|
+
event: r.event,
|
|
6423
|
+
...r.matcher ? { matcher: r.matcher } : {},
|
|
6424
|
+
command
|
|
6425
|
+
}));
|
|
6426
|
+
const hookFor = (r) => ({
|
|
6427
|
+
type: "command",
|
|
6428
|
+
command,
|
|
6429
|
+
...r.timeout ? { timeout: r.timeout } : {}
|
|
6430
|
+
});
|
|
6431
|
+
const owned = ownedBy(manifest.name);
|
|
6432
|
+
const current = Object.entries(settings.hooks ?? {}).flatMap(([event, groups]) => groups.flatMap((g) => g.hooks.filter((h) => owned(h.command)).map((h) => ({
|
|
6433
|
+
event,
|
|
6434
|
+
matcher: g.matcher,
|
|
6435
|
+
hook: h
|
|
6436
|
+
}))));
|
|
6437
|
+
if (current.length === manifest.registrations.length && manifest.registrations.every((r) => current.some((c) => c.event === r.event && c.matcher === r.matcher && JSON.stringify(c.hook) === JSON.stringify(hookFor(r))))) return {
|
|
6438
|
+
settings,
|
|
6439
|
+
registrations
|
|
6440
|
+
};
|
|
6441
|
+
let next = removeHooks(settings, owned);
|
|
6442
|
+
for (const r of manifest.registrations) next = upsertHook(next, r.event, r.matcher, hookFor(r));
|
|
6443
|
+
return {
|
|
6444
|
+
settings: next,
|
|
6445
|
+
registrations
|
|
6446
|
+
};
|
|
6447
|
+
}
|
|
6448
|
+
async function installHooks(names, options = {}) {
|
|
6449
|
+
const result = {
|
|
6450
|
+
installed: [],
|
|
6451
|
+
unchanged: [],
|
|
6452
|
+
skippedLocalEdits: [],
|
|
6453
|
+
failed: [],
|
|
6454
|
+
backup: null
|
|
6455
|
+
};
|
|
6456
|
+
const state = readState();
|
|
6457
|
+
let settings = readClaudeSettings$1();
|
|
6458
|
+
const writes = [];
|
|
6459
|
+
for (const name of names) {
|
|
6460
|
+
if (!isBareSkillName(name)) {
|
|
6461
|
+
result.failed.push({
|
|
6462
|
+
name,
|
|
6463
|
+
reason: "invalid hook name"
|
|
6464
|
+
});
|
|
6465
|
+
continue;
|
|
6466
|
+
}
|
|
6467
|
+
const rawManifest = await fetchCatalogFile(PRUFF_CATALOG, `hooks/${name}/hook.json`);
|
|
6468
|
+
if (rawManifest === null) {
|
|
6469
|
+
result.failed.push({
|
|
6470
|
+
name,
|
|
6471
|
+
reason: `not found in the catalog (${PRUFF_CATALOG})`
|
|
6472
|
+
});
|
|
6473
|
+
continue;
|
|
6474
|
+
}
|
|
6475
|
+
let manifest;
|
|
6476
|
+
try {
|
|
6477
|
+
manifest = parseHookManifest(rawManifest, name);
|
|
6478
|
+
} catch (error) {
|
|
6479
|
+
result.failed.push({
|
|
6480
|
+
name,
|
|
6481
|
+
reason: error.message
|
|
6482
|
+
});
|
|
6483
|
+
continue;
|
|
6484
|
+
}
|
|
6485
|
+
const script = await fetchCatalogFile(PRUFF_CATALOG, `hooks/${name}/${manifest.script}`);
|
|
6486
|
+
if (script === null) {
|
|
6487
|
+
result.failed.push({
|
|
6488
|
+
name,
|
|
6489
|
+
reason: `script ${manifest.script} missing from the catalog`
|
|
6490
|
+
});
|
|
6491
|
+
continue;
|
|
6492
|
+
}
|
|
6493
|
+
const target = join(hookDir(name), manifest.script);
|
|
6494
|
+
const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
6495
|
+
const recorded = state.hooks[name]?.files[manifest.script];
|
|
6496
|
+
if (current !== null && sha256$1(current) !== recorded && current !== script && !options.force) {
|
|
6497
|
+
result.skippedLocalEdits.push(name);
|
|
6498
|
+
continue;
|
|
6499
|
+
}
|
|
6500
|
+
const applied = applyHook(settings, manifest);
|
|
6501
|
+
const settingsChanged = JSON.stringify(applied.settings) !== JSON.stringify(settings);
|
|
6502
|
+
settings = applied.settings;
|
|
6503
|
+
if (current === script && !settingsChanged && recorded === sha256$1(script)) {
|
|
6504
|
+
result.unchanged.push(name);
|
|
6505
|
+
continue;
|
|
6506
|
+
}
|
|
6507
|
+
if (current !== script) writes.push({
|
|
6508
|
+
path: target,
|
|
6509
|
+
content: script
|
|
6510
|
+
});
|
|
6511
|
+
state.hooks[name] = {
|
|
6512
|
+
files: { [manifest.script]: sha256$1(script) },
|
|
6513
|
+
registrations: applied.registrations
|
|
6514
|
+
};
|
|
6515
|
+
result.installed.push(name);
|
|
6516
|
+
}
|
|
6517
|
+
if (options.dryRun || result.installed.length === 0) return result;
|
|
6518
|
+
for (const w of writes) {
|
|
6519
|
+
mkdirSync(join(w.path, ".."), { recursive: true });
|
|
6520
|
+
writeFileSync(w.path, w.content, "utf-8");
|
|
6521
|
+
chmodSync(w.path, 493);
|
|
6522
|
+
}
|
|
6523
|
+
result.backup = writeClaudeSettings$1(settings);
|
|
6524
|
+
writeState(state);
|
|
6525
|
+
return result;
|
|
6526
|
+
}
|
|
6527
|
+
function uninstallHooks(names, options = {}) {
|
|
6528
|
+
const state = readState();
|
|
6529
|
+
let settings = readClaudeSettings$1();
|
|
6530
|
+
const removed = [];
|
|
6531
|
+
for (const name of names) {
|
|
6532
|
+
if (!isBareSkillName(name)) continue;
|
|
6533
|
+
const next = removeHooks(settings, ownedBy(name));
|
|
6534
|
+
const hadFiles = existsSync(hookDir(name));
|
|
6535
|
+
if (!state.hooks[name] && !hadFiles && JSON.stringify(next) === JSON.stringify(settings)) continue;
|
|
6536
|
+
settings = next;
|
|
6537
|
+
delete state.hooks[name];
|
|
6538
|
+
removed.push(name);
|
|
6539
|
+
if (!options.dryRun) rmSync(hookDir(name), {
|
|
6540
|
+
recursive: true,
|
|
6541
|
+
force: true
|
|
6542
|
+
});
|
|
6543
|
+
}
|
|
6544
|
+
if (!options.dryRun && removed.length > 0) {
|
|
6545
|
+
writeClaudeSettings$1(settings);
|
|
6546
|
+
writeState(state);
|
|
6547
|
+
}
|
|
6548
|
+
return removed;
|
|
6549
|
+
}
|
|
6550
|
+
function parseHooksOptions(args) {
|
|
6551
|
+
const names = [];
|
|
6552
|
+
const options = {};
|
|
6553
|
+
for (const arg of args) if (arg === "--dry-run") options.dryRun = true;
|
|
6554
|
+
else if (arg === "--force" || arg === "-f") options.force = true;
|
|
6555
|
+
else if (!arg.startsWith("-")) names.push(arg);
|
|
6556
|
+
return {
|
|
6557
|
+
names,
|
|
6558
|
+
options
|
|
6559
|
+
};
|
|
6560
|
+
}
|
|
6561
|
+
async function addCommand(names, options) {
|
|
6562
|
+
Ie(import_picocolors.default.cyan(`pruff-skills hooks${options.dryRun ? " (dry run)" : ""}`));
|
|
6563
|
+
if (names.length === 0) {
|
|
6564
|
+
M.error("Specify at least one hook name, e.g. `pruff-skills hooks add <name>`.");
|
|
6565
|
+
M.message(import_picocolors.default.dim("List them with `npx pruff-skills hooks list`."));
|
|
6566
|
+
process.exit(1);
|
|
6567
|
+
}
|
|
6568
|
+
let result;
|
|
6569
|
+
try {
|
|
6570
|
+
result = await installHooks(names, options);
|
|
6571
|
+
} catch (error) {
|
|
6572
|
+
M.error(error.message);
|
|
6573
|
+
process.exit(1);
|
|
6574
|
+
}
|
|
6575
|
+
const verb = options.dryRun ? "would install" : "installed";
|
|
6576
|
+
for (const name of result.installed) M.success(`${import_picocolors.default.green(name)} ${verb}`);
|
|
6577
|
+
for (const name of result.unchanged) M.info(`${name} already up to date`);
|
|
6578
|
+
for (const name of result.skippedLocalEdits) M.warn(`${import_picocolors.default.yellow(name)} skipped — the installed script was edited locally. Re-run with --force to overwrite it.`);
|
|
6579
|
+
for (const f of result.failed) M.error(`${import_picocolors.default.yellow(f.name)}: ${f.reason}`);
|
|
6580
|
+
if (result.backup) M.message(import_picocolors.default.dim(`Previous settings saved to ${result.backup}`));
|
|
6581
|
+
if (result.installed.length + result.unchanged.length === 0) {
|
|
6582
|
+
Se(import_picocolors.default.red("No hooks installed."));
|
|
6583
|
+
process.exit(1);
|
|
6584
|
+
}
|
|
6585
|
+
Se(options.dryRun ? "Dry run — nothing was written." : import_picocolors.default.dim("Hooks apply to new Claude Code sessions. They run with full agent permissions."));
|
|
6586
|
+
}
|
|
6587
|
+
async function listCommand() {
|
|
6588
|
+
const state = readState();
|
|
6589
|
+
const catalog = await listCatalogHooks();
|
|
6590
|
+
const installed = Object.keys(state.hooks).sort();
|
|
6591
|
+
console.log(import_picocolors.default.bold("Installed"));
|
|
6592
|
+
if (installed.length === 0) console.log(import_picocolors.default.dim(" none"));
|
|
6593
|
+
for (const name of installed) {
|
|
6594
|
+
const events = state.hooks[name].registrations.map((r) => `${r.event}${r.matcher ? ` [${r.matcher}]` : ""}`);
|
|
6595
|
+
console.log(` ${import_picocolors.default.green(name)} ${import_picocolors.default.dim(events.join(", "))}`);
|
|
6596
|
+
}
|
|
6597
|
+
if (catalog === null) {
|
|
6598
|
+
console.log(import_picocolors.default.yellow(`\nCould not read the catalog (${PRUFF_CATALOG}).`));
|
|
6599
|
+
return;
|
|
6600
|
+
}
|
|
6601
|
+
const available = catalog.filter((n) => !state.hooks[n]);
|
|
6602
|
+
console.log(import_picocolors.default.bold("\nAvailable"));
|
|
6603
|
+
if (available.length === 0) console.log(import_picocolors.default.dim(" none"));
|
|
6604
|
+
for (const name of available) console.log(` ${name}`);
|
|
6605
|
+
}
|
|
6606
|
+
const HOOKS_HELP = `${import_picocolors.default.bold("Usage:")} pruff-skills hooks <command> [options]
|
|
6607
|
+
|
|
6608
|
+
${import_picocolors.default.bold("Commands:")}
|
|
6609
|
+
add <name...> Install hooks into ~/.claude/hooks/pruff/ and register them in ~/.claude/settings.json
|
|
6610
|
+
list Show installed and available hooks
|
|
6611
|
+
remove <name...> Unregister hooks and delete their scripts
|
|
6612
|
+
|
|
6613
|
+
${import_picocolors.default.bold("Options:")}
|
|
6614
|
+
--dry-run Show what would change without writing anything
|
|
6615
|
+
-f, --force Overwrite a hook script that was edited locally
|
|
6616
|
+
|
|
6617
|
+
Hooks always install at user scope: Claude Code only reads a project settings file
|
|
6618
|
+
in the directory a session starts from, so a user-level hook is the only one that
|
|
6619
|
+
reaches every repo and worktree. Your own settings and hooks are never modified;
|
|
6620
|
+
~/.claude/settings.json is backed up before every change.`;
|
|
6621
|
+
async function runHooks(args) {
|
|
6622
|
+
const sub = args[0];
|
|
6623
|
+
const { names, options } = parseHooksOptions(args.slice(1));
|
|
6624
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
6625
|
+
console.log(HOOKS_HELP);
|
|
6626
|
+
return;
|
|
6627
|
+
}
|
|
6628
|
+
if (sub === "add" || sub === "a" || sub === "install" || sub === "i") {
|
|
6629
|
+
await addCommand(names, options);
|
|
6630
|
+
return;
|
|
6631
|
+
}
|
|
6632
|
+
if (sub === "list" || sub === "ls") {
|
|
6633
|
+
await listCommand();
|
|
6634
|
+
return;
|
|
6635
|
+
}
|
|
6636
|
+
if (sub === "remove" || sub === "rm") {
|
|
6637
|
+
const removed = uninstallHooks(names, options);
|
|
6638
|
+
if (removed.length === 0) console.log(import_picocolors.default.dim("Nothing to remove."));
|
|
6639
|
+
for (const name of removed) console.log(`${import_picocolors.default.green("✓")} ${name} ${options.dryRun ? "would be removed" : "removed"}`);
|
|
6640
|
+
return;
|
|
6641
|
+
}
|
|
6642
|
+
console.log(`Unknown hooks command: ${sub}`);
|
|
6643
|
+
console.log(HOOKS_HELP);
|
|
6644
|
+
}
|
|
6645
|
+
const DB_READER_REGION = "us-east-1";
|
|
6646
|
+
const DB_READER_SERVICE = "pruff-db-reader";
|
|
6647
|
+
const DB_READER_SECRET_PREFIX = "pruff/db-reader/";
|
|
6648
|
+
const DB_READER_ALLOW_ENTRY = "Bash(pruff-db-query:*)";
|
|
6649
|
+
const DB_READER_VERIFY_DATABASE = "pruff-sii";
|
|
6650
|
+
const DB_READER_SKILL = "prod-db-query";
|
|
6651
|
+
const DB_READER_SCRIPTS = ["pruff-db-query", "pruff-db-verify"];
|
|
6652
|
+
const RDS_CA_URL = `https://truststore.pki.rds.amazonaws.com/${DB_READER_REGION}/${DB_READER_REGION}-bundle.pem`;
|
|
6653
|
+
const MAX_SUGGESTED_NAME_LENGTH = 23;
|
|
6654
|
+
const dbReaderConfigPath = () => join(homedir(), ".config", "pruff", "db-reader.json");
|
|
6655
|
+
const dbReaderCaPath = () => join(homedir(), ".config", "pruff", `rds-${DB_READER_REGION}-bundle.pem`);
|
|
6656
|
+
const dbReaderBinDir = () => join(homedir(), ".local", "bin");
|
|
6657
|
+
function aws(args) {
|
|
6658
|
+
const r = spawnSync("aws", args, {
|
|
6659
|
+
encoding: "utf-8",
|
|
6660
|
+
stdio: "pipe"
|
|
6661
|
+
});
|
|
6662
|
+
return {
|
|
6663
|
+
ok: r.status === 0,
|
|
6664
|
+
out: (r.stdout ?? "").trim(),
|
|
6665
|
+
err: (r.stderr ?? "").trim()
|
|
6666
|
+
};
|
|
6667
|
+
}
|
|
6668
|
+
function iamUserFromArn(arn) {
|
|
6669
|
+
const match = /^arn:[^:]*:iam::\d+:user\/(?:.*\/)?([^/\s]+)$/.exec(arn.trim());
|
|
6670
|
+
return match ? match[1] : null;
|
|
6671
|
+
}
|
|
6672
|
+
function suggestMysqlName(email, iamUser) {
|
|
6673
|
+
const clean = (value) => value.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, MAX_SUGGESTED_NAME_LENGTH);
|
|
6674
|
+
return clean(email.split("@")[0] ?? "") || clean(iamUser) || "engineer";
|
|
6675
|
+
}
|
|
6676
|
+
function parseReaderSecret(raw) {
|
|
6677
|
+
try {
|
|
6678
|
+
const data = JSON.parse(raw);
|
|
6679
|
+
if (typeof data.username !== "string" || typeof data.password !== "string") return null;
|
|
6680
|
+
if (!/^[A-Za-z0-9_]+$/.test(data.username)) return null;
|
|
6681
|
+
if (data.password.length === 0 || /[\u0000-\u001f\u007f]/.test(data.password)) return null;
|
|
6682
|
+
return {
|
|
6683
|
+
username: data.username,
|
|
6684
|
+
password: data.password
|
|
6685
|
+
};
|
|
6686
|
+
} catch {
|
|
6687
|
+
return null;
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
function quoteForSecurityShell(value) {
|
|
6691
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
6692
|
+
}
|
|
6693
|
+
function storeInKeychain(username, password) {
|
|
6694
|
+
if (spawnSync("security", ["-i"], {
|
|
6695
|
+
input: `add-generic-password -U -s ${DB_READER_SERVICE} -a ${quoteForSecurityShell(username)} -w ${quoteForSecurityShell(password)}\n`,
|
|
6696
|
+
encoding: "utf-8",
|
|
6697
|
+
stdio: "pipe"
|
|
6698
|
+
}).status !== 0) return false;
|
|
6699
|
+
return spawnSync("security", [
|
|
6700
|
+
"find-generic-password",
|
|
6701
|
+
"-s",
|
|
6702
|
+
DB_READER_SERVICE,
|
|
6703
|
+
"-a",
|
|
6704
|
+
username
|
|
6705
|
+
], { stdio: "ignore" }).status === 0;
|
|
6706
|
+
}
|
|
6707
|
+
function writeConfig(username) {
|
|
6708
|
+
const path = dbReaderConfigPath();
|
|
6709
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6710
|
+
writeFileSync(path, `${JSON.stringify({ username }, null, 2)}\n`, "utf-8");
|
|
6711
|
+
}
|
|
6712
|
+
function downloadCaBundle() {
|
|
6713
|
+
const target = dbReaderCaPath();
|
|
6714
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
6715
|
+
return spawnSync("curl", [
|
|
6716
|
+
"-sSfo",
|
|
6717
|
+
target,
|
|
6718
|
+
RDS_CA_URL
|
|
6719
|
+
], { stdio: "ignore" }).status === 0;
|
|
6720
|
+
}
|
|
6721
|
+
async function installScripts() {
|
|
6722
|
+
const dir = dbReaderBinDir();
|
|
6723
|
+
mkdirSync(dir, { recursive: true });
|
|
6724
|
+
for (const name of DB_READER_SCRIPTS) {
|
|
6725
|
+
const content = await fetchCatalogFile(PRUFF_CATALOG, `db-reader/${name}`);
|
|
6726
|
+
if (content === null) return `${name} could not be read from the catalog`;
|
|
6727
|
+
const target = join(dir, name);
|
|
6728
|
+
if ((existsSync(target) ? readFileSync(target, "utf-8") : null) !== content) writeFileSync(target, content, "utf-8");
|
|
6729
|
+
chmodSync(target, 493);
|
|
6730
|
+
}
|
|
6731
|
+
return null;
|
|
6732
|
+
}
|
|
6733
|
+
function runVerify$1() {
|
|
6734
|
+
const dir = dbReaderBinDir();
|
|
6735
|
+
const r = spawnSync(join(dir, "pruff-db-verify"), [DB_READER_VERIFY_DATABASE], {
|
|
6736
|
+
encoding: "utf-8",
|
|
6737
|
+
stdio: "pipe",
|
|
6738
|
+
env: {
|
|
6739
|
+
...process.env,
|
|
6740
|
+
PRUFF_DB_QUERY_CMD: join(dir, "pruff-db-query")
|
|
6741
|
+
}
|
|
6742
|
+
});
|
|
6743
|
+
return {
|
|
6744
|
+
ok: r.status === 0,
|
|
6745
|
+
out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
|
|
6746
|
+
};
|
|
6747
|
+
}
|
|
6748
|
+
function mergeAllowlist(workspace) {
|
|
6749
|
+
const path = join(workspace, ".claude", "settings.json");
|
|
6750
|
+
let settings = {};
|
|
6751
|
+
let current = null;
|
|
6752
|
+
if (existsSync(path)) {
|
|
6753
|
+
current = readFileSync(path, "utf-8");
|
|
6754
|
+
try {
|
|
6755
|
+
const parsed = JSON.parse(current);
|
|
6756
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "invalid";
|
|
6757
|
+
settings = parsed;
|
|
6758
|
+
} catch {
|
|
6759
|
+
return "invalid";
|
|
6760
|
+
}
|
|
6761
|
+
}
|
|
6762
|
+
const permissions = settings.permissions && typeof settings.permissions === "object" ? settings.permissions : {};
|
|
6763
|
+
const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
6764
|
+
if (allow.includes(DB_READER_ALLOW_ENTRY)) return "present";
|
|
6765
|
+
const next = {
|
|
6766
|
+
...settings,
|
|
6767
|
+
permissions: {
|
|
6768
|
+
...permissions,
|
|
6769
|
+
allow: [...allow, DB_READER_ALLOW_ENTRY]
|
|
6770
|
+
}
|
|
6771
|
+
};
|
|
6772
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6773
|
+
if (current !== null) copyFileSync(path, `${path}.pruff-bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
|
|
6774
|
+
const tmp = `${path}.tmp`;
|
|
6775
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
|
|
6776
|
+
renameSync(tmp, path);
|
|
6777
|
+
return "added";
|
|
6778
|
+
}
|
|
6779
|
+
async function installReaderSkill(workspace, note) {
|
|
6780
|
+
if (existsSync(join(workspace, ".claude", "skills", DB_READER_SKILL, "SKILL.md"))) return;
|
|
6781
|
+
const prevCwd = process.cwd();
|
|
6782
|
+
try {
|
|
6783
|
+
process.chdir(workspace);
|
|
6784
|
+
await runAdd([PRUFF_CATALOG], {
|
|
6785
|
+
skill: [DB_READER_SKILL],
|
|
6786
|
+
agent: ["claude-code"],
|
|
6787
|
+
yes: true
|
|
6788
|
+
});
|
|
6789
|
+
} catch {} finally {
|
|
6790
|
+
process.chdir(prevCwd);
|
|
6791
|
+
}
|
|
6792
|
+
if (existsSync(join(workspace, ".claude", "skills", DB_READER_SKILL, "SKILL.md"))) M.success(`Skill ${DB_READER_SKILL} installed`);
|
|
6793
|
+
else note(`Skill ${DB_READER_SKILL} not installed — run ${import_picocolors.default.cyan(`npx pruff-skills add ${PRUFF_CATALOG} --skill ${DB_READER_SKILL}`)} from ${workspace}.`);
|
|
6794
|
+
}
|
|
6795
|
+
async function setupDbReader({ workspace, note }) {
|
|
6796
|
+
M.step("Setting up read-only prod database access…");
|
|
6797
|
+
const identity = aws([
|
|
6798
|
+
"sts",
|
|
6799
|
+
"get-caller-identity",
|
|
6800
|
+
"--query",
|
|
6801
|
+
"Arn",
|
|
6802
|
+
"--output",
|
|
6803
|
+
"text"
|
|
6804
|
+
]);
|
|
6805
|
+
if (!identity.ok) {
|
|
6806
|
+
note(`Prod DB access not set up — no AWS credentials. Ask your team for AWS access, run ${import_picocolors.default.cyan("aws configure")}, then ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
|
|
6807
|
+
return;
|
|
6808
|
+
}
|
|
6809
|
+
const iamUser = iamUserFromArn(identity.out);
|
|
6810
|
+
if (!iamUser) {
|
|
6811
|
+
note(`Prod DB access not set up — the AWS identity is not an IAM user. Use your personal IAM user credentials and re-run ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
|
|
6812
|
+
return;
|
|
6813
|
+
}
|
|
6814
|
+
const secretId = `${DB_READER_SECRET_PREFIX}${iamUser}`;
|
|
6815
|
+
const fetched = aws([
|
|
6816
|
+
"secretsmanager",
|
|
6817
|
+
"get-secret-value",
|
|
6818
|
+
"--secret-id",
|
|
6819
|
+
secretId,
|
|
6820
|
+
"--region",
|
|
6821
|
+
DB_READER_REGION,
|
|
6822
|
+
"--query",
|
|
6823
|
+
"SecretString",
|
|
6824
|
+
"--output",
|
|
6825
|
+
"text"
|
|
6826
|
+
]);
|
|
6827
|
+
if (!fetched.ok) {
|
|
6828
|
+
if (/ResourceNotFound/.test(fetched.err)) {
|
|
6829
|
+
const name = suggestMysqlName((spawnSync("git", [
|
|
6830
|
+
"config",
|
|
6831
|
+
"--global",
|
|
6832
|
+
"user.email"
|
|
6833
|
+
], {
|
|
6834
|
+
encoding: "utf-8",
|
|
6835
|
+
stdio: "pipe"
|
|
6836
|
+
}).stdout ?? "").trim(), iamUser);
|
|
6837
|
+
note(`Prod DB access pending — ask the infra owner to run: ${import_picocolors.default.cyan(`stacks/buzz-db-reader/scripts/create-reader ${name} ${iamUser}`)}, then re-run ${import_picocolors.default.cyan("npx pruff-skills onboarding --db-access")}.`);
|
|
6838
|
+
} else note(`Prod DB access not set up — could not read ${secretId}. Check it manually: ${import_picocolors.default.cyan(`aws secretsmanager describe-secret --secret-id ${secretId} --region ${DB_READER_REGION}`)}`);
|
|
6839
|
+
return;
|
|
6840
|
+
}
|
|
6841
|
+
const secret = parseReaderSecret(fetched.out);
|
|
6842
|
+
if (!secret) {
|
|
6843
|
+
note(`Prod DB access not set up — ${secretId} does not hold a valid username and password.`);
|
|
6844
|
+
return;
|
|
6845
|
+
}
|
|
6846
|
+
if (!storeInKeychain(secret.username, secret.password)) {
|
|
6847
|
+
note(`Prod DB access not set up — the Keychain write failed. Add it by hand: ${import_picocolors.default.cyan(`security add-generic-password -U -s ${DB_READER_SERVICE} -a ${secret.username} -w`)} (prompts for the password).`);
|
|
6848
|
+
return;
|
|
6849
|
+
}
|
|
6850
|
+
writeConfig(secret.username);
|
|
6851
|
+
M.success(`Keychain entry ${DB_READER_SERVICE} stored for ${secret.username}`);
|
|
6852
|
+
if (!downloadCaBundle()) {
|
|
6853
|
+
note(`Prod DB access not set up — the RDS CA bundle download failed. Run: ${import_picocolors.default.cyan(`curl -sSfo ${dbReaderCaPath()} ${RDS_CA_URL}`)}`);
|
|
6854
|
+
return;
|
|
6855
|
+
}
|
|
6856
|
+
const installError = await installScripts();
|
|
6857
|
+
if (installError) {
|
|
6858
|
+
note(`Prod DB access not set up — ${installError}. Re-run once the catalog is reachable.`);
|
|
6859
|
+
return;
|
|
6860
|
+
}
|
|
6861
|
+
M.success(`Installed ${DB_READER_SCRIPTS.join(" and ")} in ${dbReaderBinDir()}`);
|
|
6862
|
+
if (!(process.env.PATH ?? "").split(delimiter).includes(dbReaderBinDir())) M.warn(`${dbReaderBinDir()} is not on your PATH. Add ${import_picocolors.default.cyan("export PATH=\"$HOME/.local/bin:$PATH\"")} to ~/.zshrc.`);
|
|
6863
|
+
const verify = runVerify$1();
|
|
6864
|
+
if (!verify.ok) {
|
|
6865
|
+
note(`Prod DB access not enabled — ${import_picocolors.default.cyan("pruff-db-verify")} failed. Run ${import_picocolors.default.cyan(`${join(dbReaderBinDir(), "pruff-db-verify")} ${DB_READER_VERIFY_DATABASE}`)} and share the output with the infra owner.`);
|
|
6866
|
+
M.warn(verify.out);
|
|
6867
|
+
return;
|
|
6868
|
+
}
|
|
6869
|
+
M.success("Read-only access verified");
|
|
6870
|
+
if (mergeAllowlist(workspace) === "invalid") note(`Add ${import_picocolors.default.cyan(DB_READER_ALLOW_ENTRY)} to permissions.allow in ${join(workspace, ".claude", "settings.json")} by hand — the file is not valid JSON.`);
|
|
6871
|
+
const hooks = await installHooks(["prod-db-guard"]);
|
|
6872
|
+
for (const failure of hooks.failed) note(`Hook ${failure.name} not installed: ${failure.reason}`);
|
|
6873
|
+
await installReaderSkill(workspace, note);
|
|
6874
|
+
if (!existsSync(join(workspace, ".claude", "rules", "prod-db-reads.md"))) note(`The prod-db-reads rule is missing from the workspace — run ${import_picocolors.default.cyan("npx pruff-skills rules add --all")} from ${workspace}.`);
|
|
6875
|
+
}
|
|
6876
|
+
const WORKSPACE_DIRS = [".claude/scripts"];
|
|
6877
|
+
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
|
6878
|
+
const isExecutable = (name) => /\.(sh|zsh|bash)$/.test(name);
|
|
6879
|
+
async function installWorkspaceFiles(workspace, source = PRUFF_CATALOG) {
|
|
6880
|
+
const result = {
|
|
6881
|
+
written: [],
|
|
6882
|
+
unchanged: [],
|
|
6883
|
+
skippedLocalEdits: [],
|
|
6884
|
+
catalogUnavailable: false
|
|
6885
|
+
};
|
|
6886
|
+
const state = readState();
|
|
6887
|
+
for (const dir of WORKSPACE_DIRS) {
|
|
6888
|
+
const names = await listCatalogDir(source, `workspace/${dir}`);
|
|
6889
|
+
if (names === null) {
|
|
6890
|
+
result.catalogUnavailable = true;
|
|
6891
|
+
continue;
|
|
6892
|
+
}
|
|
6893
|
+
for (const name of names.sort()) {
|
|
6894
|
+
const content = await fetchCatalogFile(source, `workspace/${dir}/${name}`);
|
|
6895
|
+
if (content === null) continue;
|
|
6896
|
+
const target = join(workspace, dir, name);
|
|
6897
|
+
const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
6898
|
+
if (current === content) {
|
|
6899
|
+
state.files[target] = sha256(content);
|
|
6900
|
+
result.unchanged.push(target);
|
|
6901
|
+
continue;
|
|
6902
|
+
}
|
|
6903
|
+
if (current !== null && sha256(current) !== state.files[target]) {
|
|
6904
|
+
result.skippedLocalEdits.push(target);
|
|
6905
|
+
continue;
|
|
6906
|
+
}
|
|
6907
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
6908
|
+
writeFileSync(target, content, "utf-8");
|
|
6909
|
+
if (isExecutable(name)) chmodSync(target, 493);
|
|
6910
|
+
state.files[target] = sha256(content);
|
|
6911
|
+
result.written.push(target);
|
|
6912
|
+
}
|
|
6913
|
+
}
|
|
6914
|
+
writeState(state);
|
|
6915
|
+
return result;
|
|
6916
|
+
}
|
|
6279
6917
|
const RESET = "\x1B[0m";
|
|
6280
6918
|
const DIM = "\x1B[38;5;102m";
|
|
6281
6919
|
const BLUES = [
|
|
@@ -6395,9 +7033,10 @@ async function showBlockLogo(lines, letterCuts, { slow = false, palette = BLUES
|
|
|
6395
7033
|
}
|
|
6396
7034
|
const WORKSPACE_CLAUDE_TEMPLATE = `# Pruff — Working Directory
|
|
6397
7035
|
|
|
6398
|
-
This folder is the working directory for Pruff's frontend and backend. The
|
|
7036
|
+
This folder is the working directory for Pruff's frontend and backend. The three main git repos are:
|
|
6399
7037
|
|
|
6400
|
-
- **\`Pruff-Client/\`** — React frontend
|
|
7038
|
+
- **\`Pruff-Client/\`** — React frontend (customer-facing, tenant-scoped)
|
|
7039
|
+
- **\`Pruff-Backoffice/\`** — React frontend (internal staff, cross-tenant)
|
|
6401
7040
|
- **\`Pruff-server/\`** — Node.js/Express backend
|
|
6402
7041
|
|
|
6403
7042
|
Other directories (\`sls-*/\`, \`slsl-*/\`, \`AWS-Tools/\`, etc.) are serverless microservices and tooling —
|
|
@@ -6408,14 +7047,33 @@ workspace-level context only.
|
|
|
6408
7047
|
|
|
6409
7048
|
---
|
|
6410
7049
|
|
|
7050
|
+
## Read the repo's own rules before touching its files
|
|
7051
|
+
|
|
7052
|
+
When the session is rooted at the workspace folder, a repo's \`CLAUDE.md\` and \`.claude/rules/\` are **not**
|
|
7053
|
+
loaded automatically — only this file is. The repo-level rules are the authoritative, most current
|
|
7054
|
+
source for that codebase; this file is a workspace-level summary. Where they disagree, the repo rule wins.
|
|
7055
|
+
|
|
7056
|
+
Before your first edit to any file inside a repo subdirectory, read that repo's rules (once per session):
|
|
7057
|
+
|
|
7058
|
+
\`\`\`bash
|
|
7059
|
+
cat Pruff-server/CLAUDE.md Pruff-server/.claude/rules/*.md # backend work
|
|
7060
|
+
cat Pruff-Client/CLAUDE.md Pruff-Client/.claude/rules/*.md # frontend work
|
|
7061
|
+
cat Pruff-Backoffice/CLAUDE.md Pruff-Backoffice/.claude/rules/*.md
|
|
7062
|
+
\`\`\`
|
|
7063
|
+
|
|
7064
|
+
If the session is already rooted inside the repo (or one of its worktrees), they load automatically.
|
|
7065
|
+
|
|
7066
|
+
---
|
|
7067
|
+
|
|
6411
7068
|
## Workspace Layout
|
|
6412
7069
|
|
|
6413
7070
|
### Main applications (modify freely when asked)
|
|
6414
7071
|
|
|
6415
7072
|
| Folder | What it is |
|
|
6416
7073
|
|---|---|
|
|
6417
|
-
| \`Pruff-Client/\` | React 18 + Vite + MUI frontend. React Query + Redux Toolkit. |
|
|
6418
|
-
| \`Pruff-
|
|
7074
|
+
| \`Pruff-Client/\` | React 18 + Vite + MUI frontend. Many feature pages, React Query + Redux Toolkit. |
|
|
7075
|
+
| \`Pruff-Backoffice/\` | React 19 + Vite + MUI v7 internal staff frontend. Cross-tenant views, tenant config, roles/permissions. React Query + Zustand. Consumes \`/api/backoffice\`. Branches from \`main\` (no \`dev\`); no test suite. |
|
|
7076
|
+
| \`Pruff-server/\` | Node.js 22 / Express / Prisma backend. Multi-tenant (DB-per-tenant), many domains and routes. |
|
|
6419
7077
|
|
|
6420
7078
|
### Serverless microservices (\`sls-*/\`, \`slsl-*/\` — clone as needed, don't modify unless asked)
|
|
6421
7079
|
|
|
@@ -6434,27 +7092,57 @@ over HTTPS with an \`x-api-key\`.
|
|
|
6434
7092
|
| \`sls-Notification-Service/\` | **Outbound notifications** (email, in-app, collections). |
|
|
6435
7093
|
| \`sls-Pruff-Webhooks/\` | **Outbound webhook delivery** to client integrations. |
|
|
6436
7094
|
| \`sls-Pruff-Workers/\` | **Async background jobs for Pruff-server** (PDF rendering, tenant document/summary sync crons). |
|
|
7095
|
+
| \`sls-Judiciary-Extraction/\` | **PJUD (Poder Judicial) scraper.** Given a RUT, returns every *causa* across the jurisdictional competences; no database, writes the aggregate to S3. Build on \`extractCourtCases\`; \`judiciaryOrchestrator\` + \`judiciaryScrapper\` is the legacy rollback path — don't add features to it. |
|
|
7096
|
+
| \`sls-external-databases/\` | **External compliance/risk data sources behind API Gateway** (Bankruptcy, Rutificator, DepartmentOfLabor): scheduled bulk extractors plus query endpoints. Also read synchronously by Pruff-server via \`externalDatabasesModule\`, so it sits on the request path. |
|
|
7097
|
+
| \`sls-server-scheduler/\` | **Cron triggers for Pruff-server's jobs.** EventBridge-scheduled Lambdas, each a thin \`POST\` to an \`/api/external/jobs/*\` endpoint with an \`x-api-key\`. Schedules are UTC only. Jobs are migrating to \`Pruff-infra/stacks/pruff-job-runner\`. |
|
|
7098
|
+
|
|
7099
|
+
#### Deprecated / being retired — don't invest in these
|
|
7100
|
+
|
|
7101
|
+
| Folder | Status |
|
|
7102
|
+
|---|---|
|
|
7103
|
+
| \`sls-financial-statements/\` | **Deprecated.** Leave it out of workspace-wide sweeps (lockfiles, Dependabot, OIDC, CODEOWNERS). |
|
|
7104
|
+
| \`sls-TGR-Extraction/\` | **Slated for deprecation.** Still live and deployed, so it stays in the table above; check the tracking issue before starting non-trivial work in it. |
|
|
7105
|
+
|
|
7106
|
+
If you add a new \`sls-*\` repo, add it to the table above — workspace-wide sweeps are derived from this list.
|
|
7107
|
+
|
|
7108
|
+
**Not every Lambda is an \`sls-*\` repo.** Several live in \`Pruff-infra/stacks/\` and deploy with
|
|
7109
|
+
\`terraform apply\` instead (e.g. \`newsletter-automation\`, \`github-release-automation\`,
|
|
7110
|
+
\`intelligence-recompute\`, \`reconcile-credit-note-links\`, \`posthog-error-digest\`, \`buzz-relay\`).
|
|
7111
|
+
|
|
7112
|
+
**Scheduled Pruff-server jobs are a third list.** \`Pruff-infra/stacks/pruff-job-runner\` runs them as
|
|
7113
|
+
one-shot Fargate tasks (EventBridge Scheduler → \`ecs:RunTask\`, \`America/Santiago\`), one task definition
|
|
7114
|
+
and one schedule per job. They are not Lambdas and do not appear in \`aws lambda list-functions\`.
|
|
6437
7115
|
|
|
6438
7116
|
### Tooling & utilities
|
|
6439
7117
|
|
|
6440
7118
|
| Folder | What it is |
|
|
6441
7119
|
|---|---|
|
|
6442
7120
|
| \`AWS-Tools/\` | Standalone Node/TypeScript scripts for AWS ops — not deployed; run locally with AWS creds. |
|
|
7121
|
+
| \`dte-signer/\` | \`@pruff/dte-signer\` — TypeScript library to emit and cede DTEs before the SII. Published as a package, not deployed. Prototype: most of the public surface is not implemented yet. Single \`main\` branch. |
|
|
7122
|
+
|
|
7123
|
+
### Worktrees & scratch (do not touch)
|
|
7124
|
+
|
|
7125
|
+
| Folder | What it is |
|
|
7126
|
+
|---|---|
|
|
7127
|
+
| \`.worktrees/\`, \`worktrees/\` | Git worktrees created by the harness for isolated agent runs. |
|
|
7128
|
+
| \`Pruff-Client-boneyard/\` | Archived/deleted frontend components kept for reference. |
|
|
7129
|
+
| \`Pruff-Client-pru-*/\`, \`Pruff-server-pru-*/\` | Per-ticket worktrees for in-progress PRs. Work in \`Pruff-Client/\` and \`Pruff-server/\` unless pointed at one of these. |
|
|
6443
7130
|
|
|
6444
7131
|
### Cross-service data flow (mental model)
|
|
6445
7132
|
|
|
6446
7133
|
\`\`\`
|
|
6447
|
-
sls-SII-Services
|
|
6448
|
-
sls-TGR-Extraction
|
|
6449
|
-
sls-Public-Market
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
7134
|
+
sls-SII-Services ─┐
|
|
7135
|
+
sls-TGR-Extraction ├─► slsl-Data-Injection-Syncronizer ─► Pruff-server (per-tenant DBs)
|
|
7136
|
+
sls-Public-Market │ │ ▲
|
|
7137
|
+
sls-external-databases ┘ ▼ │
|
|
7138
|
+
sls-Decision-Engine ──► Pruff-server
|
|
7139
|
+
│ (pull, via API Gateway:
|
|
7140
|
+
▼ externalDatabasesModule)
|
|
7141
|
+
sls-Notification-Service / sls-Pruff-Webhooks (outbound)
|
|
6455
7142
|
\`\`\`
|
|
6456
7143
|
|
|
6457
7144
|
\`sls-Pruff-Workers\` sits beside \`Pruff-server\` handling async/scheduled jobs that would block the API.
|
|
7145
|
+
\`sls-external-databases\` is also read synchronously (Pruff-server calls its \`/consult\` endpoints on demand).
|
|
6458
7146
|
|
|
6459
7147
|
---
|
|
6460
7148
|
|
|
@@ -6534,7 +7222,7 @@ src/
|
|
|
6534
7222
|
internal/ # Staff endpoints (JWT auth)
|
|
6535
7223
|
clients/ # Customer-facing endpoints (API key)
|
|
6536
7224
|
external/ # Scraper/lambda endpoints (API key)
|
|
6537
|
-
public/ # No auth
|
|
7225
|
+
public/ # No auth at tier level
|
|
6538
7226
|
modules/ # Infrastructure wrappers (S3, SQS, SII, GoFirmex, etc.)
|
|
6539
7227
|
middlewares/ # Auth, tenant resolution, rate limiting, error handling
|
|
6540
7228
|
jobs/ # Cron jobs (iterate over all tenants)
|
|
@@ -6544,33 +7232,43 @@ src/
|
|
|
6544
7232
|
|
|
6545
7233
|
### API Tiers
|
|
6546
7234
|
|
|
7235
|
+
Six tiers, all mounted in \`src/routes/api.ts\`:
|
|
7236
|
+
|
|
6547
7237
|
| Tier | Base Route | Auth | Middleware |
|
|
6548
7238
|
|------|-----------|------|-----------|
|
|
6549
|
-
| Internal | \`/api/internal\` | JWT | \`
|
|
6550
|
-
| External | \`/api/external\` | API Key | \`authenticateWithApiKey\` |
|
|
6551
|
-
| Clients | \`/api/clients\` | Client API Key | \`
|
|
6552
|
-
| Public | \`/api/public\` | None | — |
|
|
7239
|
+
| Internal | \`/api/internal\` | JWT | \`tenantMiddleware(JWTTenantResolver)\` + \`loadUser\` + \`attachUserPermissions\` |
|
|
7240
|
+
| External | \`/api/external\` | API Key (one shared key) | \`authenticateWithApiKey\` |
|
|
7241
|
+
| Clients | \`/api/clients\` | Client API Key | \`tenantMiddleware(ClientApiKeyTenantResolver)\` + \`logApiKeyRequest\` |
|
|
7242
|
+
| Public | \`/api/public\` | None at tier level — each endpoint self-guards | — |
|
|
7243
|
+
| Backoffice | \`/api/backoffice\` | None at tier level — validated per sub-router | — |
|
|
7244
|
+
| Service | \`/api/service\` | Pruff-ai service token | \`requirePruffAiServiceToken\` |
|
|
6553
7245
|
|
|
6554
7246
|
### Service Pattern — Result\\<T\\>
|
|
6555
7247
|
|
|
6556
|
-
All services return \`Result<T
|
|
7248
|
+
All services return \`Result<T>\` (where \`T extends Record<string, unknown>\`). Never throw from services.
|
|
7249
|
+
Services extend \`BaseService\` so \`serviceName\` is inferred — never pass a string literal.
|
|
6557
7250
|
|
|
6558
7251
|
\`\`\`typescript
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
return {
|
|
6562
|
-
return {
|
|
7252
|
+
class CreateFeatureService extends BaseService {
|
|
7253
|
+
async execute(prisma: PrismaClient, dto: CreateFeatureDto): Promise<Result<Feature>> {
|
|
7254
|
+
return Result.success({ feature });
|
|
7255
|
+
return Result.failure({ service: this.serviceName, status: APP_CODES.NOT_FOUND, message: 'NOT_FOUND', displayMessage: 'No encontrado' });
|
|
7256
|
+
}
|
|
7257
|
+
}
|
|
7258
|
+
export default new CreateFeatureService();
|
|
6563
7259
|
\`\`\`
|
|
6564
7260
|
|
|
6565
7261
|
### Controller Pattern — handleApiOutput / handleApiError
|
|
6566
7262
|
|
|
7263
|
+
Pass the **service instance**, not a string.
|
|
7264
|
+
|
|
6567
7265
|
\`\`\`typescript
|
|
6568
7266
|
async someAction(req: UserRequest, res: Response) {
|
|
6569
7267
|
try {
|
|
6570
7268
|
const result = await SomeService.execute(req.prisma, req.body);
|
|
6571
|
-
return handleApiOutput(
|
|
7269
|
+
return handleApiOutput(SomeService, req, res, result);
|
|
6572
7270
|
} catch (error) {
|
|
6573
|
-
handleApiError(
|
|
7271
|
+
handleApiError(SomeService, req, res, error);
|
|
6574
7272
|
}
|
|
6575
7273
|
}
|
|
6576
7274
|
\`\`\`
|
|
@@ -6580,7 +7278,7 @@ async someAction(req: UserRequest, res: Response) {
|
|
|
6580
7278
|
- **Services:** \`PascalCase\` + \`Service\` suffix → \`CreateFactoringRequestService.ts\`
|
|
6581
7279
|
- **DTOs/interfaces:** \`PascalCase\` + \`Dto\` suffix → \`CreateFactoringRequestDto\`
|
|
6582
7280
|
- **Controllers:** \`camelCase\` + \`Controller\` suffix → \`invoicesController.ts\`
|
|
6583
|
-
- **Services are singletons
|
|
7281
|
+
- **Services are singletons extending \`BaseService\`:** \`export default new MyService();\`
|
|
6584
7282
|
- **\`prisma\` is always the first parameter** when the service needs DB access
|
|
6585
7283
|
|
|
6586
7284
|
### Accounting Rules
|
|
@@ -6718,10 +7416,17 @@ Notes are frontmatter/filenames in English (\`kebab-case\`), body in Spanish. Se
|
|
|
6718
7416
|
|
|
6719
7417
|
- **Always use \`/commit\`** — invoke the \`commit\` skill for all git commits. Never run \`git commit\` manually.
|
|
6720
7418
|
- **Always use \`/create-pr\`** — invoke the \`create-pr\` skill for all pull requests. Never run \`gh pr create\` manually.
|
|
6721
|
-
- **
|
|
6722
|
-
- **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** —
|
|
6723
|
-
\`dev\`
|
|
6724
|
-
\`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`).
|
|
7419
|
+
- **You, the human, are the sole author of every commit** — never set \`--author\` to Claude and never add Claude as a co-author.
|
|
7420
|
+
- **In \`sls-*/\` and \`slsl-*/\` repos, branch from \`origin/main\`** — these repos deploy from \`main\` and
|
|
7421
|
+
their \`dev\` branches are stale/abandoned. PR target = branch base = \`main\`. Exception: \`Pruff-server\`
|
|
7422
|
+
and \`Pruff-Client\` branch from \`dev\` (their deploy flow promotes \`dev → main\`). \`Pruff-Backoffice\`
|
|
7423
|
+
and \`dte-signer\` have no \`dev\` — branch and target \`main\`.
|
|
7424
|
+
- **Create worktrees with \`.claude/scripts/wt-new.sh\`** (or \`wt new <repo> <branch>\` after sourcing
|
|
7425
|
+
\`.claude/scripts/wt.zsh\`) — never by hand. It picks the correct base (\`dev\` for
|
|
7426
|
+
Pruff-server/Pruff-Client/Pruff-ai, \`main\` for everything else), fetches it first, clones
|
|
7427
|
+
\`node_modules\` copy-on-write and symlinks \`test.env\`/\`local.env\`/\`dev.env\` from the main checkout.
|
|
7428
|
+
For a hotfix pass \`--hotfix\` so the branch comes off \`origin/main\`, or \`--base <branch>\` for any
|
|
7429
|
+
other base. Remove one with \`.claude/scripts/wt-rm.sh\`; diagnose with \`.claude/scripts/wt-doctor.sh\`.
|
|
6725
7430
|
- **Branch names embed the Linear issue code; PRs map 1:1 with Linear issues** — name branches so they
|
|
6726
7431
|
contain the issue code (e.g. \`pru-1355-...\`) for Linear's git automation. When splitting work, plan
|
|
6727
7432
|
the PR grouping first, create one Linear issue per PR (1:1), and stack dependent PRs
|
|
@@ -6802,56 +7507,6 @@ async function main() {
|
|
|
6802
7507
|
console.log('RUT_HOLDER=' + (company ? company.secretId : ''));
|
|
6803
7508
|
}
|
|
6804
7509
|
|
|
6805
|
-
main()
|
|
6806
|
-
.catch((e) => {
|
|
6807
|
-
console.error(e);
|
|
6808
|
-
process.exit(1);
|
|
6809
|
-
})
|
|
6810
|
-
.finally(() => process.exit(0));
|
|
6811
|
-
`;
|
|
6812
|
-
const TENANT_USER_BACKFILL_SCRIPT = `import PrismaClientManager from './src/modules/prismaClient';
|
|
6813
|
-
import EncryptionModule from './src/modules/encryptionModule';
|
|
6814
|
-
|
|
6815
|
-
const SUPER_ADMIN_ROLE_ID = 4;
|
|
6816
|
-
|
|
6817
|
-
async function main() {
|
|
6818
|
-
const email = process.argv[2];
|
|
6819
|
-
const password = process.argv[3];
|
|
6820
|
-
const clientId = process.env.CLIENT_ID;
|
|
6821
|
-
if (!clientId) throw new Error('CLIENT_ID is required');
|
|
6822
|
-
|
|
6823
|
-
const tenantPrisma = PrismaClientManager.getGlobalClient();
|
|
6824
|
-
const existing = await tenantPrisma.user.findFirst({ where: { email } });
|
|
6825
|
-
|
|
6826
|
-
if (existing) {
|
|
6827
|
-
// create-intel-tenant-local resets tenant roles to Administrador only;
|
|
6828
|
-
// reconnect Super Administrador so the local admin keeps full access.
|
|
6829
|
-
await tenantPrisma.user.update({
|
|
6830
|
-
where: { id: existing.id },
|
|
6831
|
-
data: { roles: { connect: [{ id: SUPER_ADMIN_ROLE_ID }] } },
|
|
6832
|
-
});
|
|
6833
|
-
console.log('tenant user present: ' + email + ' (super-admin role ensured)');
|
|
6834
|
-
return;
|
|
6835
|
-
}
|
|
6836
|
-
|
|
6837
|
-
const globalPrisma = PrismaClientManager.getGlobalClientFromTenantBase(clientId);
|
|
6838
|
-
const globalUser = await globalPrisma.user.findFirstOrThrow({ where: { email } });
|
|
6839
|
-
const encryptedPassword = await EncryptionModule.encrypt(password);
|
|
6840
|
-
|
|
6841
|
-
await tenantPrisma.user.create({
|
|
6842
|
-
data: {
|
|
6843
|
-
id: globalUser.id,
|
|
6844
|
-
email,
|
|
6845
|
-
name: email.split('@')[0],
|
|
6846
|
-
password: encryptedPassword,
|
|
6847
|
-
active: true,
|
|
6848
|
-
companyId: globalUser.companyId,
|
|
6849
|
-
roles: { connect: [{ id: SUPER_ADMIN_ROLE_ID }] },
|
|
6850
|
-
},
|
|
6851
|
-
});
|
|
6852
|
-
console.log('tenant user created: ' + email + ' (id=' + globalUser.id + ')');
|
|
6853
|
-
}
|
|
6854
|
-
|
|
6855
7510
|
main()
|
|
6856
7511
|
.catch((e) => {
|
|
6857
7512
|
console.error(e);
|
|
@@ -6888,72 +7543,28 @@ main()
|
|
|
6888
7543
|
})
|
|
6889
7544
|
.finally(() => process.exit(0));
|
|
6890
7545
|
`;
|
|
6891
|
-
const
|
|
6892
|
-
|
|
6893
|
-
import
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
}
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
const command = \`DATABASE_URL="\${databaseUrl}" npx prisma migrate deploy\`;
|
|
6910
|
-
console.log(\`Running command: \${command}\`);
|
|
6911
|
-
exec(command, (error, stdout, stderr) => {
|
|
6912
|
-
if (error) {
|
|
6913
|
-
console.error(\`Error migrating \${label}:\`, error);
|
|
6914
|
-
reject(error);
|
|
6915
|
-
return;
|
|
6916
|
-
}
|
|
6917
|
-
if (stderr) {
|
|
6918
|
-
console.error(\`Stderr for \${label}:\`, stderr);
|
|
6919
|
-
reject(new Error(stderr));
|
|
6920
|
-
return;
|
|
6921
|
-
}
|
|
6922
|
-
console.log(\`Migration output for \${label}:\`, stdout);
|
|
6923
|
-
resolve(stdout);
|
|
6924
|
-
});
|
|
6925
|
-
});
|
|
6926
|
-
}
|
|
6927
|
-
|
|
6928
|
-
async function migrateTenants() {
|
|
6929
|
-
console.log('Migrating main database');
|
|
6930
|
-
try {
|
|
6931
|
-
await migrate('local database', process.env.DATABASE_URL!);
|
|
6932
|
-
} catch (error) {
|
|
6933
|
-
console.error('Failed to migrate local database:', error);
|
|
6934
|
-
}
|
|
6935
|
-
|
|
6936
|
-
try {
|
|
6937
|
-
const tenantIds = await getActiveTenants();
|
|
6938
|
-
for (const tenantId of tenantIds) {
|
|
6939
|
-
console.log(\`Migrating tenant: \${tenantId}\`);
|
|
6940
|
-
const databaseUrl = process.env.DATABASE_URL!.replace(
|
|
6941
|
-
'/${CONTROL_PLANE_DB}',
|
|
6942
|
-
\`/tenant-\${tenantId}\`,
|
|
6943
|
-
);
|
|
6944
|
-
try {
|
|
6945
|
-
await migrate(\`tenant \${tenantId}\`, databaseUrl);
|
|
6946
|
-
} catch (error) {
|
|
6947
|
-
console.error(\`Failed to migrate tenant \${tenantId}:\`, error);
|
|
6948
|
-
}
|
|
6949
|
-
}
|
|
6950
|
-
} catch (error) {
|
|
6951
|
-
console.error('Error fetching tenants or migrating:', error);
|
|
6952
|
-
}
|
|
6953
|
-
}
|
|
7546
|
+
const TENANT_CONFIG_PATH = join("scripts", "tenant", "tenant.config.ts");
|
|
7547
|
+
function renderTenantConfig(v) {
|
|
7548
|
+
return `import { CompanyType } from './consts';
|
|
7549
|
+
import { TenantScriptConfig } from './types';
|
|
7550
|
+
|
|
7551
|
+
const config: TenantScriptConfig = {
|
|
7552
|
+
secretId: ${JSON.stringify(v.secretId)},
|
|
7553
|
+
taxIdentifier: ${JSON.stringify(v.taxIdentifier)},
|
|
7554
|
+
companyName: ${JSON.stringify(v.companyName)},
|
|
7555
|
+
companyTypes: [CompanyType.Intelligence],
|
|
7556
|
+
admin: {
|
|
7557
|
+
email: ${JSON.stringify(v.adminEmail)},
|
|
7558
|
+
password: ${JSON.stringify(v.adminPassword)},
|
|
7559
|
+
superAdmin: true,
|
|
7560
|
+
},
|
|
7561
|
+
additionalUsers: [],
|
|
7562
|
+
configurationFlags: [],
|
|
7563
|
+
};
|
|
6954
7564
|
|
|
6955
|
-
|
|
7565
|
+
export default config;
|
|
6956
7566
|
`;
|
|
7567
|
+
}
|
|
6957
7568
|
function mysqlUrl(dbPassword, db, query = "") {
|
|
6958
7569
|
return `mysql://${dbPassword ? `root:${encodeURIComponent(dbPassword)}` : "root"}@localhost:3306/${db}${query}`;
|
|
6959
7570
|
}
|
|
@@ -7090,11 +7701,17 @@ const WORKSPACE_SETTINGS_JSON = `${JSON.stringify({ permissions: { allow: [
|
|
|
7090
7701
|
"mcp__linear-server__get_project"
|
|
7091
7702
|
] } }, null, 2)}\n`;
|
|
7092
7703
|
const CLAUDE_CODE_PKG = "@anthropic-ai/claude-code";
|
|
7093
|
-
const
|
|
7094
|
-
|
|
7704
|
+
const SERVER_SCRIPT = {
|
|
7705
|
+
migrate: "db-migrate-local",
|
|
7706
|
+
seedControlPlane: "db-seed-control-plane-local",
|
|
7707
|
+
createTenant: "create-tenant-local",
|
|
7708
|
+
openAccountingPeriod: "open-accounting-period-local",
|
|
7709
|
+
intelMigrate: "db-intel-migrate-local",
|
|
7710
|
+
intelGenerate: "db-intel-generate",
|
|
7711
|
+
intelSeedGlobal: "db-seed-intel-global-local",
|
|
7712
|
+
intelCreateTenant: "create-intel-tenant-local"
|
|
7713
|
+
};
|
|
7095
7714
|
const PRISMA_BIN = join("node_modules", ".bin", "prisma");
|
|
7096
|
-
const INTELLIGENCE_GLOBAL_SEED = "prisma/intelligence/seeds/seedIntelligenceGlobal.ts";
|
|
7097
|
-
const INTELLIGENCE_TENANT_SCRIPT = "scripts/intelligence/createIntelligenceTenant.ts";
|
|
7098
7715
|
const KNOWLEDGE_REPO = "https://github.com/Pruff-Tech/pruff-knowledge";
|
|
7099
7716
|
const KNOWLEDGE_DIR = "Pruff Knowledge";
|
|
7100
7717
|
const DEFAULT_WORKSPACE = join(homedir(), "pruff");
|
|
@@ -7129,6 +7746,27 @@ const CORE_AGENTS = [
|
|
|
7129
7746
|
"db-query-reviewer",
|
|
7130
7747
|
"pruff-logs-investigator"
|
|
7131
7748
|
];
|
|
7749
|
+
const TEAM_HOOKS = [
|
|
7750
|
+
"sweep-nudge",
|
|
7751
|
+
"prod-db-guard",
|
|
7752
|
+
"handoff-nudge",
|
|
7753
|
+
"save-session"
|
|
7754
|
+
];
|
|
7755
|
+
const TEAM_USER_ENV = { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" };
|
|
7756
|
+
function catalogInstallNames(rawIndex) {
|
|
7757
|
+
if (!rawIndex) return {
|
|
7758
|
+
skills: CORE_SKILLS,
|
|
7759
|
+
agents: CORE_AGENTS
|
|
7760
|
+
};
|
|
7761
|
+
const index = parseCatalogIndex(rawIndex, PRUFF_CATALOG);
|
|
7762
|
+
const names = (entries) => Array.isArray(entries) ? entries.map((e) => e.name).filter((n) => typeof n === "string") : [];
|
|
7763
|
+
const skills = names(index.skills);
|
|
7764
|
+
const agents = names(index.agents);
|
|
7765
|
+
return {
|
|
7766
|
+
skills: skills.length > 0 ? [...new Set([...skills, "find-skills"])] : CORE_SKILLS,
|
|
7767
|
+
agents: agents.length > 0 ? agents : CORE_AGENTS
|
|
7768
|
+
};
|
|
7769
|
+
}
|
|
7132
7770
|
const issues = [];
|
|
7133
7771
|
function note(issue) {
|
|
7134
7772
|
issues.push(issue);
|
|
@@ -7138,8 +7776,21 @@ async function runOnboarding(args) {
|
|
|
7138
7776
|
M.error(`${import_picocolors.default.bold("onboarding")} currently supports macOS only (detected ${import_picocolors.default.cyan(process.platform)}).`);
|
|
7139
7777
|
return;
|
|
7140
7778
|
}
|
|
7779
|
+
if (args.includes("--print-claude-md")) {
|
|
7780
|
+
process.stdout.write(WORKSPACE_CLAUDE_TEMPLATE);
|
|
7781
|
+
return;
|
|
7782
|
+
}
|
|
7141
7783
|
Ie(import_picocolors.default.bold("Pruff onboarding"));
|
|
7142
7784
|
issues.length = 0;
|
|
7785
|
+
if (args.includes("--db-access")) {
|
|
7786
|
+
await setupDbReader({
|
|
7787
|
+
workspace: await resolveWorkspace(args),
|
|
7788
|
+
note
|
|
7789
|
+
});
|
|
7790
|
+
showIssues();
|
|
7791
|
+
Se(issues.length === 0 ? import_picocolors.default.green("Done.") : import_picocolors.default.yellow("Prod DB access is not complete — fix the items above and re-run."));
|
|
7792
|
+
return;
|
|
7793
|
+
}
|
|
7143
7794
|
if (!await preflight()) {
|
|
7144
7795
|
Se(import_picocolors.default.yellow("Onboarding stopped — fix the items above and re-run."));
|
|
7145
7796
|
return;
|
|
@@ -7156,10 +7807,16 @@ async function runOnboarding(args) {
|
|
|
7156
7807
|
writeWorkspaceClaudeMd(workspace);
|
|
7157
7808
|
writeHarnessConfig(workspace);
|
|
7158
7809
|
await installCatalog(workspace);
|
|
7810
|
+
await installHarness(workspace);
|
|
7159
7811
|
if (await shouldSetupDatabase(args)) await setupLocalEnvironment(join(workspace, "Pruff-server"));
|
|
7812
|
+
if (await shouldSetupDbAccess(args)) await setupDbReader({
|
|
7813
|
+
workspace,
|
|
7814
|
+
note
|
|
7815
|
+
});
|
|
7160
7816
|
await showGuidance(workspace);
|
|
7161
7817
|
showTeamCredentialsAsk(workspace);
|
|
7162
7818
|
showIssues();
|
|
7819
|
+
showLocalLogin();
|
|
7163
7820
|
if (issues.length === 0) Se(`${import_picocolors.default.green("Done.")} Welcome to Pruff — happy building.`);
|
|
7164
7821
|
else Se(`${import_picocolors.default.yellow(`Finished with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`)} Fix them and re-run — every step is idempotent.`);
|
|
7165
7822
|
}
|
|
@@ -7425,12 +8082,19 @@ function installDeps(repo, workspace) {
|
|
|
7425
8082
|
}
|
|
7426
8083
|
function writeWorkspaceClaudeMd(workspace) {
|
|
7427
8084
|
const target = join(workspace, "CLAUDE.md");
|
|
7428
|
-
|
|
7429
|
-
|
|
8085
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
8086
|
+
const state = readState();
|
|
8087
|
+
const current = existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
8088
|
+
if (current === WORKSPACE_CLAUDE_TEMPLATE) M.success("Workspace CLAUDE.md is up to date");
|
|
8089
|
+
else if (current !== null && hash(current) !== state.files[target]) {
|
|
8090
|
+
M.warn(`Workspace CLAUDE.md has your own edits — left untouched. The team template changed; compare with ${import_picocolors.default.cyan("npx pruff-skills onboarding --print-claude-md")}.`);
|
|
7430
8091
|
return;
|
|
8092
|
+
} else {
|
|
8093
|
+
writeFileSync(target, WORKSPACE_CLAUDE_TEMPLATE, "utf-8");
|
|
8094
|
+
M.success(current === null ? "Wrote workspace CLAUDE.md" : "Updated workspace CLAUDE.md");
|
|
7431
8095
|
}
|
|
7432
|
-
|
|
7433
|
-
|
|
8096
|
+
state.files[target] = hash(WORKSPACE_CLAUDE_TEMPLATE);
|
|
8097
|
+
writeState(state);
|
|
7434
8098
|
}
|
|
7435
8099
|
function writeHarnessConfig(workspace) {
|
|
7436
8100
|
writeIfAbsent(join(workspace, ".mcp.json"), WORKSPACE_MCP_JSON, "MCP servers (.mcp.json)");
|
|
@@ -7452,9 +8116,10 @@ async function installCatalog(workspace) {
|
|
|
7452
8116
|
try {
|
|
7453
8117
|
process.chdir(workspace);
|
|
7454
8118
|
await runRules(["add", "--all"]);
|
|
7455
|
-
await
|
|
8119
|
+
const { skills, agents } = catalogInstallNames(await fetchCatalogFile(PRUFF_CATALOG, ".well-known/agent-skills/index.json"));
|
|
8120
|
+
await runAgents(["add", ...agents]);
|
|
7456
8121
|
await runAdd([PRUFF_CATALOG], {
|
|
7457
|
-
skill:
|
|
8122
|
+
skill: skills,
|
|
7458
8123
|
agent: ["claude-code"],
|
|
7459
8124
|
yes: true
|
|
7460
8125
|
});
|
|
@@ -7465,6 +8130,53 @@ async function installCatalog(workspace) {
|
|
|
7465
8130
|
process.chdir(prevCwd);
|
|
7466
8131
|
}
|
|
7467
8132
|
}
|
|
8133
|
+
async function installHarness(workspace) {
|
|
8134
|
+
M.step("Installing the team harness (hooks, settings, scripts)…");
|
|
8135
|
+
try {
|
|
8136
|
+
const state = readState();
|
|
8137
|
+
const env = applyOwnedEnv(readClaudeSettings$1(), TEAM_USER_ENV, state.env);
|
|
8138
|
+
writeClaudeSettings$1(env.settings);
|
|
8139
|
+
writeState({
|
|
8140
|
+
...readState(),
|
|
8141
|
+
env: env.owned
|
|
8142
|
+
});
|
|
8143
|
+
for (const key of env.keptUserValues) M.info(`${key} already set in ~/.claude/settings.json — kept your value`);
|
|
8144
|
+
const hooks = await installHooks(TEAM_HOOKS);
|
|
8145
|
+
const ready = [...hooks.installed, ...hooks.unchanged];
|
|
8146
|
+
if (ready.length > 0) M.success(`Hooks: ${ready.join(", ")}`);
|
|
8147
|
+
for (const name of hooks.skippedLocalEdits) M.warn(`Hook ${name} was edited locally — kept. Overwrite with ${import_picocolors.default.cyan(`npx pruff-skills hooks add ${name} --force`)}`);
|
|
8148
|
+
for (const f of hooks.failed) note(`Hook ${f.name} not installed: ${f.reason}`);
|
|
8149
|
+
const files = await installWorkspaceFiles(workspace);
|
|
8150
|
+
if (files.catalogUnavailable) note("Workspace scripts could not be read from the catalog.");
|
|
8151
|
+
for (const path of files.skippedLocalEdits) M.warn(`${path} has local edits — kept`);
|
|
8152
|
+
M.message(import_picocolors.default.dim(`Worktree helper: add ${import_picocolors.default.cyan(`source ${join(workspace, ".claude", "scripts", "wt.zsh")}`)} to ~/.zshrc`));
|
|
8153
|
+
} catch (error) {
|
|
8154
|
+
note("Team harness did not finish installing.");
|
|
8155
|
+
M.warn(error instanceof Error ? error.message : String(error));
|
|
8156
|
+
}
|
|
8157
|
+
ensureRtk();
|
|
8158
|
+
}
|
|
8159
|
+
function ensureRtk() {
|
|
8160
|
+
if (!has("rtk")) {
|
|
8161
|
+
const s = Y();
|
|
8162
|
+
s.start("Installing RTK…");
|
|
8163
|
+
const ok = run("brew", ["install", "rtk"]);
|
|
8164
|
+
s.stop(ok ? "RTK installed" : import_picocolors.default.yellow("RTK install failed"));
|
|
8165
|
+
if (!ok) {
|
|
8166
|
+
note(`RTK not installed — run ${import_picocolors.default.cyan("brew install rtk")}.`);
|
|
8167
|
+
return;
|
|
8168
|
+
}
|
|
8169
|
+
}
|
|
8170
|
+
if (spawnSync("rtk", ["gain"], { stdio: "ignore" }).status !== 0) {
|
|
8171
|
+
note(`The ${import_picocolors.default.cyan("rtk")} on PATH is not RTK (name collision with Rust Type Kit).`);
|
|
8172
|
+
return;
|
|
8173
|
+
}
|
|
8174
|
+
if (!run("rtk", [
|
|
8175
|
+
"init",
|
|
8176
|
+
"-g",
|
|
8177
|
+
"--auto-patch"
|
|
8178
|
+
])) note("`rtk init -g` failed.");
|
|
8179
|
+
}
|
|
7468
8180
|
async function setupLocalEnvironment(serverPath) {
|
|
7469
8181
|
if (!existsSync(join(serverPath, ".git"))) {
|
|
7470
8182
|
note("Local database not set up — Pruff-server was not cloned.");
|
|
@@ -7481,7 +8193,6 @@ async function setupLocalEnvironment(serverPath) {
|
|
|
7481
8193
|
const dbPassword = await resolveRootPassword();
|
|
7482
8194
|
if (dbPassword === null) return;
|
|
7483
8195
|
generateLocalEnv(serverPath, dbPassword);
|
|
7484
|
-
writeMigrateTenantsLocal(serverPath);
|
|
7485
8196
|
const env = parseEnvFile(join(serverPath, "local.env"));
|
|
7486
8197
|
if (!env.DATABASE_URL || !env.CLIENT_ID) {
|
|
7487
8198
|
note("Local database not set up — local.env is missing DATABASE_URL / CLIENT_ID.");
|
|
@@ -7490,6 +8201,15 @@ async function setupLocalEnvironment(serverPath) {
|
|
|
7490
8201
|
}
|
|
7491
8202
|
setupLocalDatabase(serverPath, env);
|
|
7492
8203
|
}
|
|
8204
|
+
async function shouldSetupDbAccess(args) {
|
|
8205
|
+
if (args.includes("--yes") || args.includes("-y") || !process.stdout.isTTY) return true;
|
|
8206
|
+
const answer = await ye({
|
|
8207
|
+
message: "Set up read-only production database access now? (needs your AWS credentials; stores your personal reader password in the macOS Keychain)",
|
|
8208
|
+
initialValue: true
|
|
8209
|
+
});
|
|
8210
|
+
if (pD(answer)) return false;
|
|
8211
|
+
return answer === true;
|
|
8212
|
+
}
|
|
7493
8213
|
async function shouldSetupDatabase(args) {
|
|
7494
8214
|
if (args.includes("--no-db")) return false;
|
|
7495
8215
|
if (args.includes("--db")) return true;
|
|
@@ -7627,11 +8347,6 @@ function generateLocalEnv(serverPath, dbPassword) {
|
|
|
7627
8347
|
}), "utf-8");
|
|
7628
8348
|
M.success("Wrote local.env (minimal bootable — fill third-party keys when needed)");
|
|
7629
8349
|
}
|
|
7630
|
-
function writeMigrateTenantsLocal(serverPath) {
|
|
7631
|
-
const target = join(serverPath, "sincronizations", "migrateTenantsLocal.ts");
|
|
7632
|
-
if (!existsSync(join(serverPath, "sincronizations"))) return;
|
|
7633
|
-
writeIfAbsent(target, MIGRATE_TENANTS_LOCAL_SCRIPT, "sincronizations/migrateTenantsLocal.ts");
|
|
7634
|
-
}
|
|
7635
8350
|
function parseEnvFile(path) {
|
|
7636
8351
|
if (!existsSync(path)) return {};
|
|
7637
8352
|
const out = {};
|
|
@@ -7647,11 +8362,6 @@ function parseEnvFile(path) {
|
|
|
7647
8362
|
}
|
|
7648
8363
|
return out;
|
|
7649
8364
|
}
|
|
7650
|
-
function decideTenantAction(tenantDbExists, adminUserExists) {
|
|
7651
|
-
if (tenantDbExists && adminUserExists) return "already-provisioned";
|
|
7652
|
-
if (tenantDbExists) return "reset-and-seed";
|
|
7653
|
-
return "create-and-seed";
|
|
7654
|
-
}
|
|
7655
8365
|
function parseMysqlUrl(url) {
|
|
7656
8366
|
const u = new URL(url);
|
|
7657
8367
|
return {
|
|
@@ -7681,15 +8391,6 @@ function mysqlArgs(conn) {
|
|
|
7681
8391
|
if (conn.password) args.push(`-p${conn.password}`);
|
|
7682
8392
|
return args;
|
|
7683
8393
|
}
|
|
7684
|
-
function mysqlQuery(conn, sql) {
|
|
7685
|
-
return capture("mysql", [
|
|
7686
|
-
...mysqlArgs(conn),
|
|
7687
|
-
"-N",
|
|
7688
|
-
"-B",
|
|
7689
|
-
"-e",
|
|
7690
|
-
sql
|
|
7691
|
-
]);
|
|
7692
|
-
}
|
|
7693
8394
|
function mysqlExec(conn, sql) {
|
|
7694
8395
|
return run("mysql", [
|
|
7695
8396
|
...mysqlArgs(conn),
|
|
@@ -7697,33 +8398,12 @@ function mysqlExec(conn, sql) {
|
|
|
7697
8398
|
sql
|
|
7698
8399
|
]);
|
|
7699
8400
|
}
|
|
7700
|
-
function dbExists(conn, name) {
|
|
7701
|
-
const { out } = mysqlQuery(conn, `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${name}'`);
|
|
7702
|
-
return out.length > 0;
|
|
7703
|
-
}
|
|
7704
|
-
function tableExists(conn, db, table) {
|
|
7705
|
-
const { out } = mysqlQuery(conn, `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${db}' AND TABLE_NAME = '${table}'`);
|
|
7706
|
-
return out.length > 0;
|
|
7707
|
-
}
|
|
7708
|
-
function adminUserExists(conn, db, email) {
|
|
7709
|
-
if (!tableExists(conn, db, "User")) return false;
|
|
7710
|
-
const { ok, out } = mysqlQuery(conn, `SELECT COUNT(*) FROM \`${db}\`.\`User\` WHERE email = '${email}'`);
|
|
7711
|
-
return ok && parseInt(out, 10) > 0;
|
|
7712
|
-
}
|
|
7713
8401
|
function prismaBin(serverPath) {
|
|
7714
8402
|
return join(serverPath, PRISMA_BIN);
|
|
7715
8403
|
}
|
|
7716
8404
|
function prismaInstalled(serverPath) {
|
|
7717
8405
|
return existsSync(prismaBin(serverPath));
|
|
7718
8406
|
}
|
|
7719
|
-
function migrateDeploy(serverPath, url) {
|
|
7720
|
-
return run(prismaBin(serverPath), [
|
|
7721
|
-
"migrate",
|
|
7722
|
-
"deploy",
|
|
7723
|
-
"--schema",
|
|
7724
|
-
SERVER_SCHEMA
|
|
7725
|
-
], serverPath, { DATABASE_URL: url });
|
|
7726
|
-
}
|
|
7727
8407
|
function rutConflict(holderSecretId, clientId) {
|
|
7728
8408
|
return holderSecretId !== "" && holderSecretId !== clientId;
|
|
7729
8409
|
}
|
|
@@ -7763,6 +8443,37 @@ function runDisposableScript(serverPath, fileName, contents, env, extraArgs = []
|
|
|
7763
8443
|
rmSync(scriptPath, { force: true });
|
|
7764
8444
|
}
|
|
7765
8445
|
}
|
|
8446
|
+
function readServerScripts(serverPath) {
|
|
8447
|
+
try {
|
|
8448
|
+
return JSON.parse(readFileSync(join(serverPath, "package.json"), "utf-8")).scripts ?? {};
|
|
8449
|
+
} catch {
|
|
8450
|
+
return {};
|
|
8451
|
+
}
|
|
8452
|
+
}
|
|
8453
|
+
function makeServerRunner(serverPath, env) {
|
|
8454
|
+
const pm = has("bun") ? "bun" : "npm";
|
|
8455
|
+
return {
|
|
8456
|
+
exec: (script, extraEnv) => run(pm, ["run", script], serverPath, {
|
|
8457
|
+
...env,
|
|
8458
|
+
...extraEnv
|
|
8459
|
+
}),
|
|
8460
|
+
rerun: (script) => `cd Pruff-server && ${pm} run ${script}`
|
|
8461
|
+
};
|
|
8462
|
+
}
|
|
8463
|
+
const ONBOARDING_RERUN = "npx pruff-skills onboarding --db";
|
|
8464
|
+
function withTenantConfig(serverPath, contents, fn) {
|
|
8465
|
+
const target = join(serverPath, TENANT_CONFIG_PATH);
|
|
8466
|
+
const previous = existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
8467
|
+
writeFileSync(target, contents, "utf-8");
|
|
8468
|
+
let ok = false;
|
|
8469
|
+
try {
|
|
8470
|
+
ok = fn();
|
|
8471
|
+
} finally {
|
|
8472
|
+
if (previous !== null) writeFileSync(target, previous, "utf-8");
|
|
8473
|
+
else if (ok) rmSync(target, { force: true });
|
|
8474
|
+
}
|
|
8475
|
+
return ok;
|
|
8476
|
+
}
|
|
7766
8477
|
function setupLocalDatabase(serverPath, env) {
|
|
7767
8478
|
const controlPlaneUrl = env.DATABASE_URL;
|
|
7768
8479
|
const clientId = env.CLIENT_ID;
|
|
@@ -7774,134 +8485,125 @@ function setupLocalDatabase(serverPath, env) {
|
|
|
7774
8485
|
const conn = parseMysqlUrl(controlPlaneUrl);
|
|
7775
8486
|
const controlPlaneName = dbNameFromUrl(controlPlaneUrl);
|
|
7776
8487
|
const tenantName = `tenant-${clientId}`;
|
|
7777
|
-
const tenantUrl = swapDbName(controlPlaneUrl, tenantName);
|
|
7778
8488
|
const adminEmail = `admin@${clientId}.com`;
|
|
7779
8489
|
if (controlPlaneName !== CONTROL_PLANE_DB) {
|
|
7780
8490
|
note(`Local database not set up — DATABASE_URL must point at "${CONTROL_PLANE_DB}".`);
|
|
7781
8491
|
M.warn(`DATABASE_URL points at ${import_picocolors.default.cyan(controlPlaneName)}, but Pruff-server hardcodes the local control plane as ${import_picocolors.default.cyan(CONTROL_PLANE_DB)}.\n Company/user seeds would silently write to the wrong database — fix local.env and re-run with --db.`);
|
|
7782
8492
|
return;
|
|
7783
8493
|
}
|
|
8494
|
+
const scripts = readServerScripts(serverPath);
|
|
8495
|
+
const missing = [SERVER_SCRIPT.migrate, SERVER_SCRIPT.createTenant].filter((name) => !scripts[name]);
|
|
8496
|
+
if (missing.length > 0) {
|
|
8497
|
+
note(`Local database not set up — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
|
|
8498
|
+
M.warn(`Pruff-server's package.json has no ${import_picocolors.default.cyan(missing.join(", "))}.\n Pull the latest ${import_picocolors.default.dim("dev")} in Pruff-server, then re-run with ${import_picocolors.default.cyan("--db")}.`);
|
|
8499
|
+
return;
|
|
8500
|
+
}
|
|
8501
|
+
const server = makeServerRunner(serverPath, env);
|
|
7784
8502
|
M.step(`Setting up local databases (${controlPlaneName} + ${tenantName})…`);
|
|
7785
8503
|
mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${controlPlaneName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
7786
|
-
if (!
|
|
7787
|
-
note(
|
|
8504
|
+
if (!server.exec(SERVER_SCRIPT.migrate)) {
|
|
8505
|
+
note(`Local database not set up — control-plane migration failed. Rerun: ${server.rerun(SERVER_SCRIPT.migrate)}`);
|
|
7788
8506
|
M.warn("Control-plane migration failed — skipping the rest of database setup.");
|
|
7789
8507
|
return;
|
|
7790
8508
|
}
|
|
7791
|
-
|
|
8509
|
+
const hasControlPlaneSeed = Boolean(scripts[SERVER_SCRIPT.seedControlPlane]);
|
|
8510
|
+
if (!(hasControlPlaneSeed ? server.exec(SERVER_SCRIPT.seedControlPlane) : runDisposableScript(serverPath, ".pruff-onboarding-preseed.ts", CONTROL_PLANE_PRESEED_SCRIPT, {
|
|
7792
8511
|
...env,
|
|
7793
8512
|
DATABASE_URL: controlPlaneUrl
|
|
7794
|
-
})
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
const holder = findRutHolder(serverPath, env, controlPlaneUrl);
|
|
7798
|
-
if (rutConflict(holder, clientId)) {
|
|
7799
|
-
note(`Tenant "${clientId}" not seeded — CLIENT_RUT is already used by "${holder}".`);
|
|
7800
|
-
M.warn(`CLIENT_RUT ${import_picocolors.default.cyan(env.CLIENT_RUT ?? "")} already belongs to tenant ${import_picocolors.default.cyan(holder)} in ${controlPlaneName}.\n Seeding would reuse that company and never register "${clientId}" — the tenant could not be logged into.\n Change CLIENT_RUT (or CLIENT_ID) in local.env and re-run with --db.`);
|
|
7801
|
-
return;
|
|
7802
|
-
}
|
|
7803
|
-
if (action === "reset-and-seed") {
|
|
7804
|
-
M.warn(`Tenant "${clientId}" partially provisioned — resetting (Pruff-server seed is not idempotent).`);
|
|
7805
|
-
mysqlExec(conn, `DROP DATABASE IF EXISTS \`${tenantName}\``);
|
|
7806
|
-
}
|
|
7807
|
-
mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${tenantName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
7808
|
-
if (!migrateDeploy(serverPath, tenantUrl)) {
|
|
7809
|
-
note("Local database not set up — tenant migration failed.");
|
|
7810
|
-
M.warn("Tenant migration failed — skipping seed.");
|
|
7811
|
-
return;
|
|
7812
|
-
}
|
|
7813
|
-
const s = Y();
|
|
7814
|
-
s.start(`Seeding tenant "${clientId}"…`);
|
|
7815
|
-
const seeded = run(prismaBin(serverPath), ["db", "seed"], serverPath, {
|
|
7816
|
-
...env,
|
|
7817
|
-
DATABASE_URL: tenantUrl
|
|
7818
|
-
});
|
|
7819
|
-
s.stop(seeded ? `Tenant "${clientId}" seeded` : import_picocolors.default.yellow("Tenant seed failed"));
|
|
7820
|
-
if (!seeded) {
|
|
7821
|
-
note(`Tenant "${clientId}" seed failed.`);
|
|
7822
|
-
return;
|
|
7823
|
-
}
|
|
7824
|
-
} else M.success(`Tenant "${clientId}" already provisioned — skipping seed.`);
|
|
7825
|
-
setupIntelligence(serverPath, env, conn, controlPlaneUrl, adminEmail, adminPassword);
|
|
7826
|
-
if (!runDisposableScript(serverPath, ".pruff-onboarding-tenant-user.ts", TENANT_USER_BACKFILL_SCRIPT, {
|
|
7827
|
-
...env,
|
|
7828
|
-
DATABASE_URL: tenantUrl
|
|
7829
|
-
}, [adminEmail, adminPassword])) {
|
|
7830
|
-
note("Tenant admin user may be missing — the backfill script failed.");
|
|
7831
|
-
M.warn("Tenant admin-user backfill failed — you may need to create the user manually.");
|
|
8513
|
+
}))) {
|
|
8514
|
+
note(`Local database not set up — control-plane seed failed. Rerun: ${hasControlPlaneSeed ? server.rerun(SERVER_SCRIPT.seedControlPlane) : ONBOARDING_RERUN}`);
|
|
8515
|
+
M.warn("Control-plane seed failed — skipping the rest of database setup.");
|
|
7832
8516
|
return;
|
|
7833
8517
|
}
|
|
7834
|
-
|
|
8518
|
+
const holder = findRutHolder(serverPath, env, controlPlaneUrl);
|
|
8519
|
+
if (rutConflict(holder, clientId)) {
|
|
8520
|
+
note(`Tenant "${clientId}" not seeded — CLIENT_RUT is already used by "${holder}".`);
|
|
8521
|
+
M.warn(`CLIENT_RUT ${import_picocolors.default.cyan(env.CLIENT_RUT ?? "")} already belongs to tenant ${import_picocolors.default.cyan(holder)} in ${controlPlaneName}.\n Seeding would reuse that company and never register "${clientId}" — the tenant could not be logged into.\n Change CLIENT_RUT (or CLIENT_ID) in local.env and re-run with --db.`);
|
|
8522
|
+
return;
|
|
8523
|
+
}
|
|
8524
|
+
const s = Y();
|
|
8525
|
+
s.start(`Provisioning tenant "${clientId}"…`);
|
|
8526
|
+
const tenantCreated = withTenantConfig(serverPath, renderTenantConfig({
|
|
8527
|
+
secretId: clientId,
|
|
8528
|
+
taxIdentifier: env.CLIENT_RUT ?? "",
|
|
8529
|
+
companyName: env.CLIENT_NAME ?? clientId,
|
|
8530
|
+
adminEmail,
|
|
8531
|
+
adminPassword
|
|
8532
|
+
}), () => server.exec(SERVER_SCRIPT.createTenant));
|
|
8533
|
+
s.stop(tenantCreated ? `Tenant "${clientId}" provisioned` : import_picocolors.default.yellow("Tenant provisioning failed"));
|
|
8534
|
+
if (!tenantCreated) {
|
|
8535
|
+
note(`Tenant "${clientId}" not provisioned — ${SERVER_SCRIPT.createTenant} failed. Rerun: ${server.rerun(SERVER_SCRIPT.createTenant)} (reads ${TENANT_CONFIG_PATH})`);
|
|
8536
|
+
return;
|
|
8537
|
+
}
|
|
8538
|
+
const hasPeriodScript = Boolean(scripts[SERVER_SCRIPT.openAccountingPeriod]);
|
|
8539
|
+
if (!(hasPeriodScript ? server.exec(SERVER_SCRIPT.openAccountingPeriod) : runDisposableScript(serverPath, ".pruff-onboarding-accounting-period.ts", ACCOUNTING_PERIOD_SCRIPT, {
|
|
7835
8540
|
...env,
|
|
7836
|
-
DATABASE_URL:
|
|
7837
|
-
}, [String((/* @__PURE__ */ new Date()).getFullYear())])) {
|
|
7838
|
-
note(
|
|
8541
|
+
DATABASE_URL: swapDbName(controlPlaneUrl, tenantName)
|
|
8542
|
+
}, [String((/* @__PURE__ */ new Date()).getFullYear())]))) {
|
|
8543
|
+
note(`No open AccountingPeriod — accounting entries will be rejected until one exists. Rerun: ${hasPeriodScript ? server.rerun(SERVER_SCRIPT.openAccountingPeriod) : ONBOARDING_RERUN}`);
|
|
7839
8544
|
M.warn("Could not open the current-year accounting period — create one under Contabilidad → Períodos.");
|
|
7840
8545
|
}
|
|
8546
|
+
setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword);
|
|
7841
8547
|
M.success(`Local tenant "${clientId}" ready.`);
|
|
7842
8548
|
printLoginHint(clientId, adminEmail, adminPassword);
|
|
7843
8549
|
}
|
|
7844
|
-
function setupIntelligence(
|
|
8550
|
+
function setupIntelligence(env, conn, scripts, server, adminEmail, adminPassword) {
|
|
7845
8551
|
const intelligenceUrl = env.INTELLIGENCE_DATABASE_URL;
|
|
7846
8552
|
if (!intelligenceUrl) {
|
|
7847
8553
|
note("Intelligence module unavailable — INTELLIGENCE_DATABASE_URL is not set in local.env.");
|
|
7848
8554
|
M.warn(`local.env has no ${import_picocolors.default.cyan("INTELLIGENCE_DATABASE_URL")} — skipping Intelligence setup.`);
|
|
7849
8555
|
return;
|
|
7850
8556
|
}
|
|
8557
|
+
const steps = [
|
|
8558
|
+
SERVER_SCRIPT.intelMigrate,
|
|
8559
|
+
SERVER_SCRIPT.intelGenerate,
|
|
8560
|
+
SERVER_SCRIPT.intelSeedGlobal,
|
|
8561
|
+
SERVER_SCRIPT.intelCreateTenant
|
|
8562
|
+
];
|
|
8563
|
+
const missing = steps.filter((name) => !scripts[name]);
|
|
8564
|
+
if (missing.length > 0) {
|
|
8565
|
+
note(`Intelligence module unavailable — Pruff-server has no ${missing.join(" / ")} script (update it to the latest dev).`);
|
|
8566
|
+
M.warn(`Pruff-server's package.json has no ${import_picocolors.default.cyan(missing.join(", "))} — skipping Intelligence setup.`);
|
|
8567
|
+
return;
|
|
8568
|
+
}
|
|
7851
8569
|
const s = Y();
|
|
7852
8570
|
s.start("Setting up Intelligence…");
|
|
7853
8571
|
mysqlExec(conn, `CREATE DATABASE IF NOT EXISTS \`${dbNameFromUrl(intelligenceUrl)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
7854
|
-
const
|
|
7855
|
-
...env,
|
|
7856
|
-
INTELLIGENCE_DATABASE_URL: intelligenceUrl
|
|
7857
|
-
};
|
|
7858
|
-
if (!(run(prismaBin(serverPath), [
|
|
7859
|
-
"migrate",
|
|
7860
|
-
"deploy",
|
|
7861
|
-
"--schema",
|
|
7862
|
-
INTELLIGENCE_SCHEMA
|
|
7863
|
-
], serverPath, intelligenceEnv) && run(prismaBin(serverPath), [
|
|
7864
|
-
"generate",
|
|
7865
|
-
"--schema",
|
|
7866
|
-
INTELLIGENCE_SCHEMA
|
|
7867
|
-
], serverPath, intelligenceEnv))) {
|
|
7868
|
-
s.stop(import_picocolors.default.yellow("Intelligence database setup failed"));
|
|
7869
|
-
note(`Intelligence module unavailable — could not migrate/generate ${INTELLIGENCE_DB}.`);
|
|
7870
|
-
return;
|
|
7871
|
-
}
|
|
7872
|
-
if (!(run("npx", [
|
|
7873
|
-
"ts-node",
|
|
7874
|
-
"--transpile-only",
|
|
7875
|
-
"-r",
|
|
7876
|
-
"tsconfig-paths/register",
|
|
7877
|
-
INTELLIGENCE_GLOBAL_SEED
|
|
7878
|
-
], serverPath, {
|
|
7879
|
-
...env,
|
|
7880
|
-
DATABASE_URL: controlPlaneUrl
|
|
7881
|
-
}) && run("npx", [
|
|
7882
|
-
"ts-node",
|
|
7883
|
-
"--transpile-only",
|
|
7884
|
-
"-r",
|
|
7885
|
-
"tsconfig-paths/register",
|
|
7886
|
-
INTELLIGENCE_TENANT_SCRIPT
|
|
7887
|
-
], serverPath, {
|
|
7888
|
-
...env,
|
|
7889
|
-
DATABASE_URL: controlPlaneUrl,
|
|
8572
|
+
const enrolment = {
|
|
7890
8573
|
TENANT_SECRET_ID: env.CLIENT_ID,
|
|
7891
8574
|
TENANT_RUT: env.CLIENT_RUT,
|
|
7892
8575
|
TENANT_NAME: env.CLIENT_NAME,
|
|
7893
8576
|
USER_EMAIL: adminEmail,
|
|
7894
8577
|
USER_PASSWORD: adminPassword,
|
|
7895
8578
|
USER_NAME: "Pruff Local Admin"
|
|
7896
|
-
}
|
|
7897
|
-
|
|
7898
|
-
|
|
8579
|
+
};
|
|
8580
|
+
for (const script of steps) {
|
|
8581
|
+
if (server.exec(script, script === SERVER_SCRIPT.intelCreateTenant ? enrolment : void 0)) continue;
|
|
8582
|
+
s.stop(import_picocolors.default.yellow(`Intelligence setup failed at ${script}`));
|
|
8583
|
+
note(`Intelligence module unavailable — ${script} failed. Rerun: ${server.rerun(script)}`);
|
|
7899
8584
|
return;
|
|
7900
8585
|
}
|
|
7901
8586
|
s.stop("Intelligence ready");
|
|
7902
8587
|
}
|
|
8588
|
+
let localLogin = null;
|
|
7903
8589
|
function printLoginHint(clientId, email, password) {
|
|
7904
|
-
|
|
8590
|
+
localLogin = {
|
|
8591
|
+
clientId,
|
|
8592
|
+
email,
|
|
8593
|
+
password
|
|
8594
|
+
};
|
|
8595
|
+
}
|
|
8596
|
+
function showLocalLogin() {
|
|
8597
|
+
if (!localLogin) return;
|
|
8598
|
+
const line = (s) => console.log(` ${s}`);
|
|
8599
|
+
console.log();
|
|
8600
|
+
line(import_picocolors.default.bold("Your local Pruff login") + import_picocolors.default.dim(" (Super Administrador)"));
|
|
8601
|
+
line(`${import_picocolors.default.dim("url: ")} ${import_picocolors.default.cyan(FRONTEND_URL)}`);
|
|
8602
|
+
line(`${import_picocolors.default.dim("tenant: ")} ${import_picocolors.default.cyan(localLogin.clientId)}`);
|
|
8603
|
+
line(`${import_picocolors.default.dim("email: ")} ${import_picocolors.default.cyan(localLogin.email)}`);
|
|
8604
|
+
line(`${import_picocolors.default.dim("password:")} ${import_picocolors.default.cyan(localLogin.password)}`);
|
|
8605
|
+
line(import_picocolors.default.dim("Local only: this user exists in your local databases, never in dev or prod."));
|
|
8606
|
+
console.log();
|
|
7905
8607
|
}
|
|
7906
8608
|
async function showGuidance(workspace) {
|
|
7907
8609
|
console.log();
|
|
@@ -7931,6 +8633,8 @@ async function showGuidance(workspace) {
|
|
|
7931
8633
|
line(`${import_picocolors.default.dim("3.")} Dependencies are installed — ${import_picocolors.default.dim("cd Pruff-server && npm run local")} / ${import_picocolors.default.dim("cd Pruff-Client && yarn start-local")}.`);
|
|
7932
8634
|
line(`${import_picocolors.default.dim("4.")} Run ${import_picocolors.default.dim("/mcp")} inside Claude Code to authenticate Linear + PostHog (already configured in ${import_picocolors.default.dim(".mcp.json")}).`);
|
|
7933
8635
|
line(`${import_picocolors.default.dim("5.")} Clone any serverless service into this folder with ${import_picocolors.default.dim("git clone <repo>")}.`);
|
|
8636
|
+
line(`${import_picocolors.default.dim("6.")} Install the ${import_picocolors.default.bold("Claude in Chrome")} extension and run ${import_picocolors.default.dim("claude --chrome")} — UI checks and PR screenshots drive the browser through it.`);
|
|
8637
|
+
line(`${import_picocolors.default.dim("7.")} Prod data is read-only: ${import_picocolors.default.dim("pruff-db-query <database> -e \"SELECT …\"")} — your own reader user, password in the Keychain, verified TLS. Finish a pending request with ${import_picocolors.default.dim("npx pruff-skills onboarding --db-access")}.`);
|
|
7934
8638
|
console.log();
|
|
7935
8639
|
}
|
|
7936
8640
|
function showTeamCredentialsAsk(workspace) {
|
|
@@ -8913,6 +9617,11 @@ ${BOLD}Rules:${RESET}
|
|
|
8913
9617
|
rules add --scope <client|server|common>
|
|
8914
9618
|
Install a whole repo's rule set (client/server also pull in common)
|
|
8915
9619
|
|
|
9620
|
+
${BOLD}Hooks:${RESET}
|
|
9621
|
+
hooks add <name> Install a Claude Code hook into ~/.claude/hooks/pruff/ and register it
|
|
9622
|
+
hooks list Show installed and available hooks
|
|
9623
|
+
hooks remove <name> Unregister a hook and delete its script
|
|
9624
|
+
|
|
8916
9625
|
${BOLD}Usage analytics:${RESET}
|
|
8917
9626
|
analytics status Is Claude Code reporting usage to Pruff's PostHog? (alias: telemetry)
|
|
8918
9627
|
analytics enable Turn it on in ~/.claude/settings.json
|
|
@@ -8941,6 +9650,7 @@ ${BOLD}Onboarding:${RESET}
|
|
|
8941
9650
|
${BOLD}Onboarding Options:${RESET}
|
|
8942
9651
|
--db Force the local database step (non-interactive shells)
|
|
8943
9652
|
--no-db Skip the local database step
|
|
9653
|
+
--db-access Only set up read-only prod database access (needs AWS credentials)
|
|
8944
9654
|
|
|
8945
9655
|
${BOLD}Updates:${RESET}
|
|
8946
9656
|
update [skills...] Update skills to latest versions (alias: upgrade)
|
|
@@ -9151,6 +9861,11 @@ async function main() {
|
|
|
9151
9861
|
if (!inAgent) await showLogo();
|
|
9152
9862
|
await runRules(restArgs);
|
|
9153
9863
|
break;
|
|
9864
|
+
case "hooks":
|
|
9865
|
+
case "hook":
|
|
9866
|
+
if (!inAgent) await showLogo();
|
|
9867
|
+
await runHooks(restArgs);
|
|
9868
|
+
break;
|
|
9154
9869
|
case "buzz":
|
|
9155
9870
|
if (!inAgent) await showBuzzLogo();
|
|
9156
9871
|
await runBuzz(restArgs);
|