troxy-cli 1.21.0 → 1.22.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.
@@ -0,0 +1,60 @@
1
+ // Org policy support end to end: api.js has the routes, policies.js checks
2
+ // --org in every subcommand that has both a personal and an org meaning, and
3
+ // a write subcommand requires admin while list/describe only require
4
+ // membership - matching orgs.py's is_org_admin vs is_org_member split.
5
+ //
6
+ // Source-inspection style, like rotate-key.test.js: driving the real
7
+ // subcommands would need a live JWT and a real org, which this test suite
8
+ // doesn't have. What it can check without either is that the wiring exists
9
+ // and that a write path can't skip the admin check.
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const apiSrc = readFileSync(join(__dirname, '..', 'api.js'), 'utf8');
18
+ const polSrc = readFileSync(join(__dirname, '..', 'policies.js'), 'utf8');
19
+
20
+ test('the org policy routes are wired in the api client', () => {
21
+ for (const path of ["'/orgs/current'", '`/orgs/${orgId}/policies`', '`/orgs/${orgId}/policies/${id}`']) {
22
+ assert.ok(apiSrc.includes(path), `${path} not wired in api.js`);
23
+ }
24
+ });
25
+
26
+ test('every write subcommand resolves org context with admin required', () => {
27
+ // Each of these blocks should call _resolveOrgContext(jwt, true) when
28
+ // flags.org is set, mirroring orgs.py's is_org_admin gate on every
29
+ // POST/PATCH/DELETE to /orgs/:id/policies*.
30
+ const writeSubcommands = ["case 'delete'", "case 'pause'", "case 'set-priority'"];
31
+ for (const marker of writeSubcommands) {
32
+ const start = polSrc.indexOf(marker);
33
+ assert.ok(start !== -1, `${marker} not found in policies.js`);
34
+ const chunk = polSrc.slice(start, start + 1200);
35
+ assert.ok(
36
+ /_resolveOrgContext\(jwt,\s*true\)/.test(chunk),
37
+ `${marker} does not call _resolveOrgContext(jwt, true) - an org write could bypass the admin check`,
38
+ );
39
+ }
40
+ });
41
+
42
+ test('create requires admin for --org but read subcommands do not', () => {
43
+ const createStart = polSrc.indexOf("case 'create'");
44
+ const createChunk = polSrc.slice(createStart, createStart + 4000);
45
+ assert.ok(/_resolveOrgContext\(jwt,\s*true\)/.test(createChunk), 'create --org must require admin');
46
+
47
+ // list/describe are read-only - orgs.py's handle_list_org_policies allows
48
+ // any active member, not just admins. Passing `false` here is what makes
49
+ // that distinction actually reach the CLI instead of over-restricting reads.
50
+ const listStart = polSrc.indexOf("case 'list':");
51
+ const listChunk = polSrc.slice(listStart, listStart + 800);
52
+ assert.ok(/_resolveOrgContext\(jwt,\s*false\)/.test(listChunk), 'list --org should not require admin (read access is for any member)');
53
+ });
54
+
55
+ test('org policy creation sends the domain, unlike the old payment-only path', () => {
56
+ const createStart = polSrc.indexOf("case 'create'");
57
+ const chunk = polSrc.slice(createStart, polSrc.indexOf('createOrgPolicy', createStart) + 50);
58
+ assert.ok(/createOrgPolicy\(jwt,\s*org\.id,\s*body\)/.test(chunk));
59
+ assert.ok(/body\s*=\s*\{\s*name,\s*action,\s*domain,\s*conditions,\s*or_conditions/.test(chunk), 'org policy body must include domain - without it every org policy defaults to payment');
60
+ });
@@ -0,0 +1,127 @@
1
+ // Field-name correctness for the five checkpoint-simulate commands added
2
+ // alongside `troxy pay` (send/signin/action/model-call/scan). A typo'd field
3
+ // name here is invisible until someone reads the JSON: the request still
4
+ // posts, the checkpoint still answers, and it just never matches the policy
5
+ // it was meant to test - the same "saves fine, never fires" failure this
6
+ // whole session has been chasing on the dashboard and backend sides.
7
+ //
8
+ // Body-building is exported separately from the network call specifically so
9
+ // this is checkable without a live login session (see simulate.js's own note
10
+ // on why).
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert';
13
+ import { _sendBody, _signinBody, _actionBody, _modelCallBody, _scanBody, _policyName } from '../simulate.js';
14
+
15
+ function _expectExit(fn) {
16
+ const orig = process.exit;
17
+ process.exit = (code) => { throw new Error(`__EXIT_${code}__`); };
18
+ try {
19
+ fn();
20
+ throw new Error('expected process.exit to be called, but it was not');
21
+ } catch (e) {
22
+ if (!/^__EXIT_\d+__$/.test(e.message)) throw e;
23
+ } finally {
24
+ process.exit = orig;
25
+ }
26
+ }
27
+
28
+ test('send builds the exact fields /evaluate/email expects', () => {
29
+ const b = _sendBody({ recipient: 'jane@acme.com', subject: 'Q3', body: 'attached', channel: 'slack' });
30
+ assert.deepStrictEqual(b.recipient, 'jane@acme.com');
31
+ assert.deepStrictEqual(b.recipients, ['jane@acme.com']);
32
+ assert.strictEqual(b.subject, 'Q3');
33
+ assert.strictEqual(b.body, 'attached');
34
+ assert.strictEqual(b.channel, 'slack');
35
+ });
36
+
37
+ test('send defaults channel to email and merges --recipient with --recipients', () => {
38
+ const b = _sendBody({ recipient: 'a@x.com', recipients: 'b@x.com, c@x.com' });
39
+ assert.strictEqual(b.channel, 'email');
40
+ assert.deepStrictEqual(b.recipients, ['a@x.com', 'b@x.com', 'c@x.com']);
41
+ });
42
+
43
+ test('send requires a recipient', () => {
44
+ _expectExit(() => _sendBody({}));
45
+ });
46
+
47
+ test('signin builds login_action, not a made-up "type" field the backend does not read', () => {
48
+ const b = _signinBody({ site: 'coinbase.com', type: 'signup' });
49
+ assert.strictEqual(b.site, 'coinbase.com');
50
+ assert.strictEqual(b.login_action, 'signup');
51
+ assert.ok(!('type' in b), 'the CLI flag is --type but the wire field is login_action - both must not leak through');
52
+ });
53
+
54
+ test('signin defaults to login and rejects anything else', () => {
55
+ assert.strictEqual(_signinBody({ site: 'x.com' }).login_action, 'login');
56
+ _expectExit(() => _signinBody({ site: 'x.com', type: 'signin' })); // not a real value
57
+ });
58
+
59
+ test('action builds action_verb/item_count/reversible, not verb/items/reversible', () => {
60
+ const b = _actionBody({ verb: 'delete', resource: 'prod users table', items: '500', reversible: 'false' });
61
+ assert.strictEqual(b.action_verb, 'delete');
62
+ assert.strictEqual(b.resource, 'prod users table');
63
+ assert.strictEqual(b.item_count, 500);
64
+ assert.strictEqual(b.reversible, false);
65
+ });
66
+
67
+ test('action defaults item_count to 1 and reversible to false', () => {
68
+ const b = _actionBody({ verb: 'share', resource: 'doc' });
69
+ assert.strictEqual(b.item_count, 1);
70
+ assert.strictEqual(b.reversible, false);
71
+ assert.ok(!('destination' in b), 'destination should be omitted, not sent as undefined, when not given');
72
+ });
73
+
74
+ test('action requires verb and resource', () => {
75
+ _expectExit(() => _actionBody({ resource: 'x' }));
76
+ _expectExit(() => _actionBody({ verb: 'delete' }));
77
+ });
78
+
79
+ test('model-call builds estimated_cost, not "cost"', () => {
80
+ const b = _modelCallBody({ model: 'claude-opus-5', provider: 'anthropic', cost: '4.50', tokens: '900000', effort: 'high' });
81
+ assert.strictEqual(b.estimated_cost, 4.5);
82
+ assert.strictEqual(b.tokens, 900000);
83
+ assert.strictEqual(b.effort, 'high');
84
+ assert.ok(!('cost' in b), 'the CLI flag is --cost but the wire field is estimated_cost');
85
+ });
86
+
87
+ test('model-call defaults estimated_cost to 0 and omits tokens/effort when not given', () => {
88
+ const b = _modelCallBody({ model: 'x', provider: 'y' });
89
+ assert.strictEqual(b.estimated_cost, 0);
90
+ assert.ok(!('tokens' in b));
91
+ assert.ok(!('effort' in b));
92
+ });
93
+
94
+ test('scan builds content_type and action, not type and do', () => {
95
+ const b = _scanBody({ content: 'AWS_KEY=abc', destination: 'public', do: 'push', type: 'code' });
96
+ assert.strictEqual(b.content, 'AWS_KEY=abc');
97
+ assert.strictEqual(b.destination, 'public');
98
+ assert.strictEqual(b.action, 'push');
99
+ assert.strictEqual(b.content_type, 'code');
100
+ assert.ok(!('do' in b) && !('type' in b), 'the CLI flags are --do/--type but the wire fields are action/content_type');
101
+ });
102
+
103
+ test('scan defaults destination to public, action to send, content_type to message', () => {
104
+ const b = _scanBody({ content: 'hi' });
105
+ assert.strictEqual(b.destination, 'public');
106
+ assert.strictEqual(b.action, 'send');
107
+ assert.strictEqual(b.content_type, 'message');
108
+ });
109
+
110
+ test('scan requires content', () => {
111
+ _expectExit(() => _scanBody({}));
112
+ });
113
+
114
+ // ── _policyName: reads `policy` where present, else parses `reason` ────────
115
+
116
+ test('prefers the top-level policy field when present (the payment checkpoint shape)', () => {
117
+ assert.strictEqual(_policyName({ policy: 'Block over $100', reason: 'irrelevant' }), 'Block over $100');
118
+ });
119
+
120
+ test('falls back to parsing the reason sentence (every other checkpoint\'s shape)', () => {
121
+ assert.strictEqual(_policyName({ reason: "Matched policy 'Escalate every signup'." }), 'Escalate every signup');
122
+ });
123
+
124
+ test('returns null rather than a wrong guess when nothing matched', () => {
125
+ assert.strictEqual(_policyName({ reason: 'No secrets policy matched; default for high severity is BLOCK.' }), null);
126
+ assert.strictEqual(_policyName({}), null);
127
+ });
@@ -0,0 +1,46 @@
1
+ // The vendored templates (src/data/templates.json) are only useful if every
2
+ // one of them is a condition the backend will actually accept - a template
3
+ // that fails this is a customer's first "troxy policies create --template"
4
+ // saving a rule that never fires, the exact class of bug this whole spec-
5
+ // vendoring pattern exists to catch before it ships.
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { validateCondition } from '../policy-fields.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const { templates } = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'templates.json'), 'utf8'));
15
+
16
+ test('every vendored template has at least one condition or is a deliberate catch-all', () => {
17
+ assert.ok(templates.length >= 60, `expected ~69 templates, found ${templates.length} - did export-templates.mjs run against a stale/empty index.html?`);
18
+ });
19
+
20
+ test('every condition in every template validates against the field spec', () => {
21
+ const failures = [];
22
+ for (const t of templates) {
23
+ const allConds = [
24
+ ...t.conditions,
25
+ ...t.or_conditions.flatMap(b => b.conditions),
26
+ ];
27
+ for (const c of allConds) {
28
+ const err = validateCondition(t.domain, c.field, c.operator, c.value, c.value2);
29
+ if (err) failures.push(`"${t.label}" (${t.domain}): ${c.field} ${c.operator} ${c.value} -> ${err}`);
30
+ }
31
+ }
32
+ assert.deepStrictEqual(failures, [], failures.join('\n'));
33
+ });
34
+
35
+ test('every template action is one the engine recognizes', () => {
36
+ const VALID = new Set(['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY']);
37
+ const bad = templates.filter(t => !VALID.has(t.action));
38
+ assert.deepStrictEqual(bad.map(t => t.label), []);
39
+ });
40
+
41
+ test('a template can be found by exact label', () => {
42
+ const t = templates.find(t => t.label === 'Block all destructive actions');
43
+ assert.ok(t, 'expected the destructive catch-all template to be present');
44
+ assert.strictEqual(t.domain, 'destructive');
45
+ assert.strictEqual(t.conditions.length, 0);
46
+ });
@@ -0,0 +1,179 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import { execSync } from 'child_process';
6
+ import { api } from './api.js';
7
+
8
+ // AI coding tools we detect + the subscription plans a user can confirm for
9
+ // each. Mirrors the backend registry (troxy-tf-live tool_plans.py TOOL_PLANS)
10
+ // deliberately - six rows that rarely change aren't worth a shared package.
11
+ // Keep the plan slugs identical on both sides; the backend validates against
12
+ // its own copy and resolves the price, so the CLI never sends a price.
13
+ //
14
+ // `detect()` returns true if the tool looks present. Two techniques, same as
15
+ // init.js's detectMcpClients: a `--version` probe for CLI tools, an
16
+ // existence check on the tool's OWN config dir for the rest - never the
17
+ // `.cursor/mcp.json` / windsurf `mcp_config.json` files, since `troxy init`
18
+ // may have created those itself even where the tool was later removed.
19
+
20
+ const home = os.homedir();
21
+
22
+ function binExists(cmd) {
23
+ try { execSync(cmd, { stdio: 'ignore' }); return true; } catch { return false; }
24
+ }
25
+
26
+ function anyPathExists(paths) {
27
+ return paths.some(p => { try { return fs.existsSync(p); } catch { return false; } });
28
+ }
29
+
30
+ export const TOOLS = [
31
+ {
32
+ slug: 'claude_code',
33
+ name: 'Claude Code',
34
+ detect: () => binExists('claude --version'),
35
+ plans: [
36
+ { slug: 'free', label: 'Free' },
37
+ { slug: 'pro', label: 'Pro ($20/mo)' },
38
+ { slug: 'max_5x', label: 'Max 5x ($100/mo)' },
39
+ { slug: 'max_20x', label: 'Max 20x ($200/mo)' },
40
+ { slug: 'api', label: 'API / pay-per-token' },
41
+ { slug: 'not_sure', label: 'Not sure' },
42
+ ],
43
+ },
44
+ {
45
+ slug: 'cursor',
46
+ name: 'Cursor',
47
+ detect: () => anyPathExists([
48
+ path.join(home, 'Library/Application Support/Cursor'),
49
+ path.join(process.env.APPDATA || home, 'Cursor'),
50
+ path.join(home, '.config/Cursor'),
51
+ ]),
52
+ plans: [
53
+ { slug: 'hobby', label: 'Hobby (free)' },
54
+ { slug: 'pro', label: 'Pro ($20/mo)' },
55
+ { slug: 'business', label: 'Business ($40/mo)' },
56
+ { slug: 'not_sure', label: 'Not sure' },
57
+ ],
58
+ },
59
+ {
60
+ slug: 'github_copilot',
61
+ name: 'GitHub Copilot',
62
+ detect: () => anyPathExists([
63
+ path.join(home, '.config/github-copilot/hosts.json'),
64
+ path.join(home, '.config/github-copilot/apps.json'),
65
+ path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'), 'github-copilot/hosts.json'),
66
+ ]),
67
+ plans: [
68
+ { slug: 'individual', label: 'Individual ($10/mo)' },
69
+ { slug: 'business', label: 'Business ($19/mo)' },
70
+ { slug: 'enterprise', label: 'Enterprise ($39/mo)' },
71
+ { slug: 'not_sure', label: 'Not sure' },
72
+ ],
73
+ },
74
+ {
75
+ slug: 'windsurf',
76
+ name: 'Windsurf',
77
+ detect: () => anyPathExists([path.join(home, '.codeium/windsurf')]),
78
+ plans: [
79
+ { slug: 'free', label: 'Free' },
80
+ { slug: 'pro', label: 'Pro ($15/mo)' },
81
+ { slug: 'not_sure', label: 'Not sure' },
82
+ ],
83
+ },
84
+ {
85
+ slug: 'continue',
86
+ name: 'Continue',
87
+ detect: () => anyPathExists([path.join(home, '.continue')]),
88
+ plans: [
89
+ { slug: 'free', label: 'Free' },
90
+ { slug: 'pro', label: 'Pro ($15/mo)' },
91
+ { slug: 'not_sure', label: 'Not sure' },
92
+ ],
93
+ },
94
+ {
95
+ slug: 'aider',
96
+ name: 'Aider',
97
+ detect: () => binExists('aider --version'),
98
+ plans: [
99
+ { slug: 'free', label: 'Free (bring your own API key)' },
100
+ { slug: 'not_sure', label: 'Not sure' },
101
+ ],
102
+ },
103
+ ];
104
+
105
+ export function detectAiTools() {
106
+ return TOOLS.map(t => ({ slug: t.slug, name: t.name, installed: !!t.detect() }));
107
+ }
108
+
109
+ function _prompt(question) {
110
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
111
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
112
+ }
113
+
114
+ // For each detected tool: show a numbered plan menu, read a choice, then ask
115
+ // the renewal day. Returns [{slug, installed:true, plan, cycle_day}]. A tool
116
+ // detected but skipped (blank / invalid choice) is reported as 'not_sure' so
117
+ // the dashboard still shows it's installed. Never throws on bad input - a
118
+ // stray keystroke degrades to 'not_sure', it doesn't abort setup.
119
+ export async function promptForToolPlans(detected) {
120
+ const installed = detected.filter(d => d.installed);
121
+ if (installed.length === 0) return [];
122
+
123
+ console.log('\n Found these AI tools on this machine. Set your plan for each so');
124
+ console.log(' the dashboard shows what you actually pay, not raw token estimates.\n');
125
+
126
+ const results = [];
127
+ for (const d of installed) {
128
+ const tool = TOOLS.find(t => t.slug === d.slug);
129
+ console.log(` ${tool.name}:`);
130
+ tool.plans.forEach((p, i) => console.log(` ${i + 1}) ${p.label}`));
131
+ const ans = await _prompt(` Which plan? [1-${tool.plans.length}, Enter to skip]: `);
132
+ const idx = parseInt(ans, 10) - 1;
133
+ const plan = (idx >= 0 && idx < tool.plans.length) ? tool.plans[idx].slug : 'not_sure';
134
+
135
+ let cycle_day = null;
136
+ const chosen = tool.plans.find(p => p.slug === plan);
137
+ // Only ask the renewal day for a real paid plan - "Free"/"Not sure"/API
138
+ // have no monthly renewal to track a usage window against.
139
+ const isPaid = chosen && !['free', 'hobby', 'not_sure', 'api'].includes(plan);
140
+ if (isPaid) {
141
+ const dayAns = await _prompt(' What day of the month does it renew? [1-31, Enter to skip]: ');
142
+ const day = parseInt(dayAns, 10);
143
+ if (day >= 1 && day <= 31) cycle_day = day;
144
+ }
145
+ results.push({ slug: d.slug, installed: true, plan, cycle_day });
146
+ console.log('');
147
+ }
148
+ return results;
149
+ }
150
+
151
+ // Non-interactive fallback (piped stdin / CI): report what's installed as
152
+ // 'not_sure' with no prompt, so a scripted `troxy init` never hangs. The
153
+ // dashboard still gets the inventory; the plan can be set later via the
154
+ // dashboard or an interactive `troxy tools`.
155
+ export function silentToolReport(detected) {
156
+ return detected.filter(d => d.installed).map(d => ({ slug: d.slug, installed: true, plan: 'not_sure', cycle_day: null }));
157
+ }
158
+
159
+ export async function reportToolPlans(apiKey, results) {
160
+ if (!results || results.length === 0) return;
161
+ try {
162
+ await api.reportToolPlans(apiKey, results);
163
+ } catch (err) {
164
+ // Non-fatal: a failed tool-plan report must never break `troxy init`.
165
+ console.error(`\n Could not save AI tool info: ${err.message} (you can set it later in the dashboard).`);
166
+ }
167
+ }
168
+
169
+ // The full flow, shared by `troxy init` (interactive step) and `troxy tools`.
170
+ export async function runToolDetection(apiKey, { interactive = true } = {}) {
171
+ const detected = detectAiTools();
172
+ const installedCount = detected.filter(d => d.installed).length;
173
+ if (installedCount === 0) return;
174
+
175
+ const results = (interactive && process.stdin.isTTY)
176
+ ? await promptForToolPlans(detected)
177
+ : silentToolReport(detected);
178
+ await reportToolPlans(apiKey, results);
179
+ }