@simonfestl/husky-cli 1.2.0 → 1.3.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/dist/commands/agent-msg.d.ts +2 -0
- package/dist/commands/agent-msg.js +252 -0
- package/dist/commands/agent.js +270 -0
- package/dist/commands/biz/gotess.d.ts +3 -0
- package/dist/commands/biz/gotess.js +320 -0
- package/dist/commands/biz.js +5 -1
- package/dist/commands/completion.js +2 -2
- package/dist/commands/config.d.ts +27 -0
- package/dist/commands/config.js +117 -28
- package/dist/commands/interactive/vm-sessions.js +0 -1
- package/dist/commands/llm-context.js +27 -0
- package/dist/commands/preview.js +1 -1
- package/dist/commands/task.js +3 -0
- package/dist/commands/vm.js +7 -2
- package/dist/index.js +2 -0
- package/dist/lib/biz/gotess.d.ts +97 -0
- package/dist/lib/biz/gotess.js +202 -0
- package/dist/lib/permissions.d.ts +78 -0
- package/dist/lib/permissions.js +139 -0
- package/package.json +1 -1
package/dist/commands/vm.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as readline from "readline";
|
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { DEFAULT_AGENT_CONFIGS, generateStartupScript, listDefaultAgentTypes, getDefaultAgentConfig, } from "../lib/agent-templates.js";
|
|
7
|
+
import { requirePermission } from "../lib/permissions.js";
|
|
7
8
|
export const vmCommand = new Command("vm").description("Manage VM sessions");
|
|
8
9
|
// Helper: Ensure API is configured
|
|
9
10
|
function ensureConfig() {
|
|
@@ -80,7 +81,7 @@ vmCommand
|
|
|
80
81
|
.description("List all VM sessions")
|
|
81
82
|
.option("--json", "Output as JSON")
|
|
82
83
|
.option("--status <status>", "Filter by status (pending, starting, running, completed, failed, terminated)")
|
|
83
|
-
.option("--agent <agent>", "Filter by agent type (claude-code, gemini-cli,
|
|
84
|
+
.option("--agent <agent>", "Filter by agent type (claude-code, gemini-cli, custom)")
|
|
84
85
|
.action(async (options) => {
|
|
85
86
|
const config = ensureConfig();
|
|
86
87
|
try {
|
|
@@ -117,7 +118,7 @@ vmCommand
|
|
|
117
118
|
.command("create <name>")
|
|
118
119
|
.description("Create a new VM session")
|
|
119
120
|
.option("-p, --prompt <prompt>", "Initial prompt for the agent")
|
|
120
|
-
.option("--agent <agent>", "Agent type (claude-code, gemini-cli,
|
|
121
|
+
.option("--agent <agent>", "Agent type (claude-code, gemini-cli, custom)", "gemini-cli")
|
|
121
122
|
.option("-t, --type <type>", "Business agent type (support, accounting, marketing, research)")
|
|
122
123
|
.option("--config <configId>", "VM config to use")
|
|
123
124
|
.option("--project <projectId>", "Link to project")
|
|
@@ -128,6 +129,8 @@ vmCommand
|
|
|
128
129
|
.option("--zone <zone>", "GCP zone", "europe-west1-b")
|
|
129
130
|
.option("--json", "Output as JSON")
|
|
130
131
|
.action(async (name, options) => {
|
|
132
|
+
// RBAC: Only supervisor and devops can create VMs
|
|
133
|
+
requirePermission("vm:create");
|
|
131
134
|
const config = ensureConfig();
|
|
132
135
|
const validBusinessTypes = [
|
|
133
136
|
"support",
|
|
@@ -356,6 +359,8 @@ vmCommand
|
|
|
356
359
|
.description("Start/provision the VM")
|
|
357
360
|
.option("--json", "Output as JSON")
|
|
358
361
|
.action(async (id, options) => {
|
|
362
|
+
// RBAC: Only supervisor and devops can start VMs
|
|
363
|
+
requirePermission("vm:manage");
|
|
359
364
|
const config = ensureConfig();
|
|
360
365
|
console.log("Starting VM provisioning...");
|
|
361
366
|
console.log("This may take a few minutes...\n");
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { worktreeCommand } from "./commands/worktree.js";
|
|
|
21
21
|
import { workerCommand } from "./commands/worker.js";
|
|
22
22
|
import { bizCommand } from "./commands/biz.js";
|
|
23
23
|
import { printLLMContext, llmCommand } from "./commands/llm-context.js";
|
|
24
|
+
import { agentMsgCommand } from "./commands/agent-msg.js";
|
|
24
25
|
import { runInteractiveMode } from "./commands/interactive.js";
|
|
25
26
|
import { serviceAccountCommand } from "./commands/service-account.js";
|
|
26
27
|
import { chatCommand } from "./commands/chat.js";
|
|
@@ -59,6 +60,7 @@ program.addCommand(chatCommand);
|
|
|
59
60
|
program.addCommand(previewCommand);
|
|
60
61
|
program.addCommand(llmCommand);
|
|
61
62
|
program.addCommand(initCommand);
|
|
63
|
+
program.addCommand(agentMsgCommand);
|
|
62
64
|
// Handle --llm flag specially
|
|
63
65
|
if (process.argv.includes("--llm")) {
|
|
64
66
|
printLLMContext();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gotess Accounting Client
|
|
3
|
+
*
|
|
4
|
+
* Interfaces with Gotess (Supabase-based) for:
|
|
5
|
+
* - Authentication with SMS 2FA
|
|
6
|
+
* - Transaction management
|
|
7
|
+
* - Invoice matching
|
|
8
|
+
*/
|
|
9
|
+
export interface GotessTransaction {
|
|
10
|
+
id: string;
|
|
11
|
+
value_date: string;
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
purpose?: string;
|
|
15
|
+
counterpart_name?: string;
|
|
16
|
+
transaction_status?: string;
|
|
17
|
+
invoice_id?: string;
|
|
18
|
+
account_id?: number;
|
|
19
|
+
book_id: string;
|
|
20
|
+
}
|
|
21
|
+
export interface GotessInvoice {
|
|
22
|
+
id: string;
|
|
23
|
+
invoice_id?: string;
|
|
24
|
+
invoice_date?: string;
|
|
25
|
+
amount?: number;
|
|
26
|
+
sender_name?: string;
|
|
27
|
+
filename?: string;
|
|
28
|
+
s3_uri?: string;
|
|
29
|
+
book_id: string;
|
|
30
|
+
}
|
|
31
|
+
export interface GotessBook {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
company_name?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface GotessAccount {
|
|
37
|
+
finapi_account_id: number;
|
|
38
|
+
account_name?: string;
|
|
39
|
+
iban?: string;
|
|
40
|
+
account_type?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface LoginResult {
|
|
43
|
+
access_token: string;
|
|
44
|
+
refresh_token: string;
|
|
45
|
+
user: {
|
|
46
|
+
id: string;
|
|
47
|
+
email: string;
|
|
48
|
+
factors?: Array<{
|
|
49
|
+
id: string;
|
|
50
|
+
factor_type: string;
|
|
51
|
+
phone?: string;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export interface MfaChallengeResult {
|
|
56
|
+
id: string;
|
|
57
|
+
type: string;
|
|
58
|
+
expires_at: number;
|
|
59
|
+
}
|
|
60
|
+
export declare class GotessClient {
|
|
61
|
+
private accessToken?;
|
|
62
|
+
private bookId?;
|
|
63
|
+
constructor(accessToken?: string, bookId?: string);
|
|
64
|
+
static fromConfig(): GotessClient;
|
|
65
|
+
private get headers();
|
|
66
|
+
login(email: string, password: string): Promise<LoginResult>;
|
|
67
|
+
startMfaChallenge(factorId: string): Promise<MfaChallengeResult>;
|
|
68
|
+
verifyMfa(factorId: string, challengeId: string, code: string): Promise<LoginResult>;
|
|
69
|
+
listBooks(): Promise<GotessBook[]>;
|
|
70
|
+
listAccounts(): Promise<GotessAccount[]>;
|
|
71
|
+
listTransactions(options?: {
|
|
72
|
+
bookId?: string;
|
|
73
|
+
status?: string;
|
|
74
|
+
limit?: number;
|
|
75
|
+
}): Promise<GotessTransaction[]>;
|
|
76
|
+
getMissingInvoices(bookId?: string): Promise<GotessTransaction[]>;
|
|
77
|
+
listInvoices(options?: {
|
|
78
|
+
bookId?: string;
|
|
79
|
+
limit?: number;
|
|
80
|
+
search?: string;
|
|
81
|
+
}): Promise<GotessInvoice[]>;
|
|
82
|
+
linkInvoice(transactionId: string, invoiceId: string): Promise<void>;
|
|
83
|
+
markProofless(transactionId: string): Promise<void>;
|
|
84
|
+
autoMatch(bookId?: string): Promise<{
|
|
85
|
+
matched: Array<{
|
|
86
|
+
transaction: GotessTransaction;
|
|
87
|
+
invoice: GotessInvoice;
|
|
88
|
+
}>;
|
|
89
|
+
unmatched: GotessTransaction[];
|
|
90
|
+
}>;
|
|
91
|
+
private findMatchingInvoice;
|
|
92
|
+
setAccessToken(token: string): void;
|
|
93
|
+
setBookId(id: string): void;
|
|
94
|
+
getAccessToken(): string | undefined;
|
|
95
|
+
getBookId(): string | undefined;
|
|
96
|
+
}
|
|
97
|
+
export default GotessClient;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gotess Accounting Client
|
|
3
|
+
*
|
|
4
|
+
* Interfaces with Gotess (Supabase-based) for:
|
|
5
|
+
* - Authentication with SMS 2FA
|
|
6
|
+
* - Transaction management
|
|
7
|
+
* - Invoice matching
|
|
8
|
+
*/
|
|
9
|
+
import { getConfig } from '../../commands/config.js';
|
|
10
|
+
const SUPABASE_URL = "https://jysklqhrdqrqedomvwjg.supabase.co";
|
|
11
|
+
const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imp5c2tscWhyZHFycWVkb212d2pnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTQ3MzExNTUsImV4cCI6MjAzMDMwNzE1NX0.eS0x3eYcPMWSm0beZQOqtu0-ENueOdb2ktOM04AjxXY";
|
|
12
|
+
export class GotessClient {
|
|
13
|
+
accessToken;
|
|
14
|
+
bookId;
|
|
15
|
+
constructor(accessToken, bookId) {
|
|
16
|
+
this.accessToken = accessToken;
|
|
17
|
+
this.bookId = bookId;
|
|
18
|
+
}
|
|
19
|
+
static fromConfig() {
|
|
20
|
+
const config = getConfig();
|
|
21
|
+
const accessToken = config.gotessToken;
|
|
22
|
+
const bookId = config.gotessBookId;
|
|
23
|
+
return new GotessClient(accessToken, bookId);
|
|
24
|
+
}
|
|
25
|
+
get headers() {
|
|
26
|
+
return {
|
|
27
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
28
|
+
'Authorization': `Bearer ${this.accessToken || SUPABASE_ANON_KEY}`,
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async login(email, password) {
|
|
33
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ email, password }),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = await res.json();
|
|
40
|
+
throw new Error(err.error_description || err.msg || 'Login failed');
|
|
41
|
+
}
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
this.accessToken = data.access_token;
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
async startMfaChallenge(factorId) {
|
|
47
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/factors/${factorId}/challenge`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: this.headers,
|
|
50
|
+
body: JSON.stringify({}),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const err = await res.json();
|
|
54
|
+
throw new Error(err.msg || 'MFA challenge failed');
|
|
55
|
+
}
|
|
56
|
+
return res.json();
|
|
57
|
+
}
|
|
58
|
+
async verifyMfa(factorId, challengeId, code) {
|
|
59
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/factors/${factorId}/verify`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: this.headers,
|
|
62
|
+
body: JSON.stringify({ challenge_id: challengeId, code }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const err = await res.json();
|
|
66
|
+
throw new Error(err.msg || 'MFA verification failed');
|
|
67
|
+
}
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
this.accessToken = data.access_token;
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
72
|
+
async listBooks() {
|
|
73
|
+
const res = await fetch(`${SUPABASE_URL}/rest/v1/Books?select=id,name,company_name`, {
|
|
74
|
+
headers: this.headers,
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error('Failed to fetch books');
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
async listAccounts() {
|
|
81
|
+
const res = await fetch(`${SUPABASE_URL}/rest/v1/FinapiAccounts?select=finapi_account_id,account_name,iban,account_type`, {
|
|
82
|
+
headers: this.headers,
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new Error('Failed to fetch accounts');
|
|
86
|
+
return res.json();
|
|
87
|
+
}
|
|
88
|
+
async listTransactions(options = {}) {
|
|
89
|
+
const bid = options.bookId || this.bookId;
|
|
90
|
+
if (!bid)
|
|
91
|
+
throw new Error('Book ID required');
|
|
92
|
+
let url = `${SUPABASE_URL}/rest/v1/Transactions?book_id=eq.${bid}&select=id,value_date,amount,currency,purpose,counterpart_name,transaction_status,invoice_id,account_id,book_id&order=value_date.desc&limit=${options.limit || 50}`;
|
|
93
|
+
if (options.status) {
|
|
94
|
+
url += `&transaction_status=eq.${options.status}`;
|
|
95
|
+
}
|
|
96
|
+
const res = await fetch(url, { headers: this.headers });
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
throw new Error('Failed to fetch transactions');
|
|
99
|
+
return res.json();
|
|
100
|
+
}
|
|
101
|
+
async getMissingInvoices(bookId) {
|
|
102
|
+
return this.listTransactions({ bookId, status: 'invoice_missing' });
|
|
103
|
+
}
|
|
104
|
+
async listInvoices(options = {}) {
|
|
105
|
+
const bid = options.bookId || this.bookId;
|
|
106
|
+
if (!bid)
|
|
107
|
+
throw new Error('Book ID required');
|
|
108
|
+
let url = `${SUPABASE_URL}/rest/v1/Invoices?book_id=eq.${bid}&select=id,invoice_id,invoice_date,amount,sender_name,filename,s3_uri,book_id&order=invoice_date.desc&limit=${options.limit || 50}`;
|
|
109
|
+
if (options.search) {
|
|
110
|
+
url += `&or=(sender_name.ilike.*${options.search}*,filename.ilike.*${options.search}*)`;
|
|
111
|
+
}
|
|
112
|
+
const res = await fetch(url, { headers: this.headers });
|
|
113
|
+
if (!res.ok)
|
|
114
|
+
throw new Error('Failed to fetch invoices');
|
|
115
|
+
return res.json();
|
|
116
|
+
}
|
|
117
|
+
async linkInvoice(transactionId, invoiceId) {
|
|
118
|
+
const res = await fetch(`${SUPABASE_URL}/rest/v1/Transactions?id=eq.${transactionId}`, {
|
|
119
|
+
method: 'PATCH',
|
|
120
|
+
headers: { ...this.headers, 'Prefer': 'return=minimal' },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
invoice_id: invoiceId,
|
|
123
|
+
transaction_status: 'invoice_linked',
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok)
|
|
127
|
+
throw new Error('Failed to link invoice');
|
|
128
|
+
}
|
|
129
|
+
async markProofless(transactionId) {
|
|
130
|
+
const res = await fetch(`${SUPABASE_URL}/rest/v1/Transactions?id=eq.${transactionId}`, {
|
|
131
|
+
method: 'PATCH',
|
|
132
|
+
headers: { ...this.headers, 'Prefer': 'return=minimal' },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
transaction_status: 'proofless_auto',
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok)
|
|
138
|
+
throw new Error('Failed to mark proofless');
|
|
139
|
+
}
|
|
140
|
+
async autoMatch(bookId) {
|
|
141
|
+
const bid = bookId || this.bookId;
|
|
142
|
+
if (!bid)
|
|
143
|
+
throw new Error('Book ID required');
|
|
144
|
+
const [missing, invoices] = await Promise.all([
|
|
145
|
+
this.getMissingInvoices(bid),
|
|
146
|
+
this.listInvoices({ bookId: bid, limit: 200 }),
|
|
147
|
+
]);
|
|
148
|
+
const matched = [];
|
|
149
|
+
const unmatched = [];
|
|
150
|
+
for (const tx of missing) {
|
|
151
|
+
const invoice = this.findMatchingInvoice(tx, invoices);
|
|
152
|
+
if (invoice) {
|
|
153
|
+
matched.push({ transaction: tx, invoice });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
unmatched.push(tx);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { matched, unmatched };
|
|
160
|
+
}
|
|
161
|
+
findMatchingInvoice(tx, invoices) {
|
|
162
|
+
const txAmount = Math.abs(tx.amount);
|
|
163
|
+
const txDate = new Date(tx.value_date);
|
|
164
|
+
const txName = (tx.counterpart_name || '').toLowerCase();
|
|
165
|
+
for (const inv of invoices) {
|
|
166
|
+
if (!inv.amount)
|
|
167
|
+
continue;
|
|
168
|
+
const amountMatch = Math.abs(inv.amount - txAmount) < 0.02;
|
|
169
|
+
if (!amountMatch)
|
|
170
|
+
continue;
|
|
171
|
+
if (inv.invoice_date) {
|
|
172
|
+
const invDate = new Date(inv.invoice_date);
|
|
173
|
+
const daysDiff = Math.abs((txDate.getTime() - invDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
174
|
+
if (daysDiff > 14)
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const invName = (inv.sender_name || inv.filename || '').toLowerCase();
|
|
178
|
+
if (txName && invName) {
|
|
179
|
+
const txWords = txName.split(/\s+/).filter(w => w.length > 3);
|
|
180
|
+
const nameMatch = txWords.some(word => invName.includes(word));
|
|
181
|
+
if (nameMatch)
|
|
182
|
+
return inv;
|
|
183
|
+
}
|
|
184
|
+
if (amountMatch)
|
|
185
|
+
return inv;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
setAccessToken(token) {
|
|
190
|
+
this.accessToken = token;
|
|
191
|
+
}
|
|
192
|
+
setBookId(id) {
|
|
193
|
+
this.bookId = id;
|
|
194
|
+
}
|
|
195
|
+
getAccessToken() {
|
|
196
|
+
return this.accessToken;
|
|
197
|
+
}
|
|
198
|
+
getBookId() {
|
|
199
|
+
return this.bookId;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export default GotessClient;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RBAC Permission utilities for Husky CLI
|
|
3
|
+
*
|
|
4
|
+
* This module provides permission checking for CLI commands.
|
|
5
|
+
* Permissions are fetched from the API and cached locally.
|
|
6
|
+
*/
|
|
7
|
+
export type AgentRole = "supervisor" | "worker" | "reviewer" | "e2e_agent" | "pr_agent" | "support";
|
|
8
|
+
/**
|
|
9
|
+
* Check if current user has a specific permission.
|
|
10
|
+
* Uses cached permissions from config.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkPermission(permission: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Require a specific permission, exit with error if not granted.
|
|
15
|
+
* Use this at the start of command handlers to enforce RBAC.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requirePermission(permission: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Require one of several permissions.
|
|
20
|
+
* Useful for commands that can be accessed by multiple permission types.
|
|
21
|
+
*/
|
|
22
|
+
export declare function requireAnyPermission(permissions: string[]): void;
|
|
23
|
+
/**
|
|
24
|
+
* Get the current agent role.
|
|
25
|
+
* Returns undefined if role hasn't been fetched yet.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getCurrentRole(): AgentRole | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Ensure permissions are loaded (fetches from API if needed).
|
|
30
|
+
* Call this at CLI startup or before first permission check.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensurePermissionsLoaded(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Force refresh permissions from the API.
|
|
35
|
+
* Clears the cache and fetches fresh permissions.
|
|
36
|
+
*/
|
|
37
|
+
export declare function refreshPermissions(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Handle API response - refreshes permissions on 403 and shows error.
|
|
40
|
+
* Use this when making API calls that might fail due to permission issues.
|
|
41
|
+
*
|
|
42
|
+
* @returns true if response is ok, false if handled error (exits on 403)
|
|
43
|
+
*/
|
|
44
|
+
export declare function handleApiResponse(res: Response, operation: string): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Role-based command group guards.
|
|
47
|
+
* These can be used with commander's preAction hooks.
|
|
48
|
+
*/
|
|
49
|
+
export declare const guards: {
|
|
50
|
+
/**
|
|
51
|
+
* Guard for task:done permission
|
|
52
|
+
*/
|
|
53
|
+
taskDone: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* Guard for task:start permission
|
|
56
|
+
*/
|
|
57
|
+
taskStart: () => void;
|
|
58
|
+
/**
|
|
59
|
+
* Guard for task:complete permission
|
|
60
|
+
*/
|
|
61
|
+
taskComplete: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Guard for any biz permission
|
|
64
|
+
*/
|
|
65
|
+
bizAccess: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* Guard for VM management
|
|
68
|
+
*/
|
|
69
|
+
vmManage: () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Guard for PR operations
|
|
72
|
+
*/
|
|
73
|
+
prAccess: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Guard for deployment operations
|
|
76
|
+
*/
|
|
77
|
+
deployAccess: () => void;
|
|
78
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RBAC Permission utilities for Husky CLI
|
|
3
|
+
*
|
|
4
|
+
* This module provides permission checking for CLI commands.
|
|
5
|
+
* Permissions are fetched from the API and cached locally.
|
|
6
|
+
*/
|
|
7
|
+
import { getConfig, hasPermission, getRole, fetchAndCacheRole, clearRoleCache } from "../commands/config.js";
|
|
8
|
+
/**
|
|
9
|
+
* Check if current user has a specific permission.
|
|
10
|
+
* Uses cached permissions from config.
|
|
11
|
+
*/
|
|
12
|
+
export function checkPermission(permission) {
|
|
13
|
+
return hasPermission(permission);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Require a specific permission, exit with error if not granted.
|
|
17
|
+
* Use this at the start of command handlers to enforce RBAC.
|
|
18
|
+
*/
|
|
19
|
+
export function requirePermission(permission) {
|
|
20
|
+
const config = getConfig();
|
|
21
|
+
const role = config.role;
|
|
22
|
+
if (!hasPermission(permission)) {
|
|
23
|
+
console.error(`Error: Permission denied (${permission})`);
|
|
24
|
+
if (role) {
|
|
25
|
+
console.error(`Your role (${role}) does not have this permission.`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.error("Run 'husky config test' to refresh your role and permissions.");
|
|
29
|
+
}
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Require one of several permissions.
|
|
35
|
+
* Useful for commands that can be accessed by multiple permission types.
|
|
36
|
+
*/
|
|
37
|
+
export function requireAnyPermission(permissions) {
|
|
38
|
+
const hasAny = permissions.some((p) => hasPermission(p));
|
|
39
|
+
if (!hasAny) {
|
|
40
|
+
const config = getConfig();
|
|
41
|
+
console.error(`Error: Permission denied`);
|
|
42
|
+
console.error(`Required: one of [${permissions.join(", ")}]`);
|
|
43
|
+
if (config.role) {
|
|
44
|
+
console.error(`Your role (${config.role}) does not have these permissions.`);
|
|
45
|
+
}
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get the current agent role.
|
|
51
|
+
* Returns undefined if role hasn't been fetched yet.
|
|
52
|
+
*/
|
|
53
|
+
export function getCurrentRole() {
|
|
54
|
+
return getRole();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ensure permissions are loaded (fetches from API if needed).
|
|
58
|
+
* Call this at CLI startup or before first permission check.
|
|
59
|
+
*/
|
|
60
|
+
export async function ensurePermissionsLoaded() {
|
|
61
|
+
await fetchAndCacheRole();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Force refresh permissions from the API.
|
|
65
|
+
* Clears the cache and fetches fresh permissions.
|
|
66
|
+
*/
|
|
67
|
+
export async function refreshPermissions() {
|
|
68
|
+
// Clear the cache timestamp to force a refresh
|
|
69
|
+
clearRoleCache();
|
|
70
|
+
// fetchAndCacheRole will now fetch fresh data
|
|
71
|
+
await fetchAndCacheRole();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Handle API response - refreshes permissions on 403 and shows error.
|
|
75
|
+
* Use this when making API calls that might fail due to permission issues.
|
|
76
|
+
*
|
|
77
|
+
* @returns true if response is ok, false if handled error (exits on 403)
|
|
78
|
+
*/
|
|
79
|
+
export async function handleApiResponse(res, operation) {
|
|
80
|
+
if (res.ok)
|
|
81
|
+
return true;
|
|
82
|
+
if (res.status === 403) {
|
|
83
|
+
// Refresh permissions to get latest role info
|
|
84
|
+
console.error(`Permission denied for: ${operation}`);
|
|
85
|
+
console.error("Refreshing permissions...");
|
|
86
|
+
await refreshPermissions();
|
|
87
|
+
const config = getConfig();
|
|
88
|
+
if (config.role) {
|
|
89
|
+
console.error(`Your role (${config.role}) does not have permission for this operation.`);
|
|
90
|
+
if (config.permissions) {
|
|
91
|
+
console.error(`Current permissions: ${config.permissions.join(", ")}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.error("Could not determine your role. Check your API key.");
|
|
96
|
+
}
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 401) {
|
|
100
|
+
console.error("Authentication failed. Check your API key with: husky config test");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
// Return false for other errors to let caller handle
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Role-based command group guards.
|
|
108
|
+
* These can be used with commander's preAction hooks.
|
|
109
|
+
*/
|
|
110
|
+
export const guards = {
|
|
111
|
+
/**
|
|
112
|
+
* Guard for task:done permission
|
|
113
|
+
*/
|
|
114
|
+
taskDone: () => requirePermission("task:done"),
|
|
115
|
+
/**
|
|
116
|
+
* Guard for task:start permission
|
|
117
|
+
*/
|
|
118
|
+
taskStart: () => requirePermission("task:start"),
|
|
119
|
+
/**
|
|
120
|
+
* Guard for task:complete permission
|
|
121
|
+
*/
|
|
122
|
+
taskComplete: () => requirePermission("task:complete"),
|
|
123
|
+
/**
|
|
124
|
+
* Guard for any biz permission
|
|
125
|
+
*/
|
|
126
|
+
bizAccess: () => requireAnyPermission(["biz:tickets", "biz:orders", "biz:customers", "biz:*"]),
|
|
127
|
+
/**
|
|
128
|
+
* Guard for VM management
|
|
129
|
+
*/
|
|
130
|
+
vmManage: () => requireAnyPermission(["vm:create", "vm:manage", "vm:*"]),
|
|
131
|
+
/**
|
|
132
|
+
* Guard for PR operations
|
|
133
|
+
*/
|
|
134
|
+
prAccess: () => requireAnyPermission(["pr:create", "pr:merge", "pr:*"]),
|
|
135
|
+
/**
|
|
136
|
+
* Guard for deployment operations
|
|
137
|
+
*/
|
|
138
|
+
deployAccess: () => requireAnyPermission(["deploy:preview", "deploy:sandbox", "deploy:*"]),
|
|
139
|
+
};
|