circleinbox 0.1.0 → 0.2.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 CHANGED
@@ -17,8 +17,8 @@ npx @circleinbox/cli help
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- # 1. Login with your API key
21
- circleinbox login --key ci_live_YOUR_KEY
20
+ # 1. Login with your API key (interactive hidden prompt)
21
+ circleinbox login
22
22
 
23
23
  # 2. Check connection
24
24
  circleinbox status
@@ -37,11 +37,15 @@ circleinbox send --from hello@example.com --to user@gmail.com --subject "Hello"
37
37
 
38
38
  API keys are encrypted with AES-256-GCM and stored at `~/.circleinbox/credentials`. On macOS, the encryption key is stored in the system keychain.
39
39
 
40
+ The API key is never accepted as a command-line argument (that would leak it via shell history and `ps aux`). Provide it through one of these safe channels:
41
+
40
42
  ```bash
41
- circleinbox login # Interactive prompt
42
- circleinbox login --key ci_live_... # Direct key
43
- CIRCLEINBOX_API_KEY=ci_live_... circleinbox status # Env var fallback
44
- circleinbox logout # Remove credentials
43
+ circleinbox login # Interactive hidden prompt
44
+ circleinbox login --key-file ./key.txt # Read from a file (chmod 600)
45
+ echo "$KEY" | circleinbox login --from-stdin # Pipe via stdin
46
+ CIRCLEINBOX_API_KEY=ci_live_... circleinbox login # From the environment
47
+ CIRCLEINBOX_API_KEY=ci_live_... circleinbox status # Env var fallback for any command
48
+ circleinbox logout # Remove credentials
45
49
  ```
46
50
 
47
51
  ## Commands
@@ -50,7 +54,7 @@ circleinbox logout # Remove credentials
50
54
 
51
55
  | Command | Description |
52
56
  |---------|-------------|
53
- | `login [--key <key>]` | Store API key (encrypted) |
57
+ | `login [--key-file <path> \| --from-stdin]` | Store API key (encrypted) |
54
58
  | `logout` | Remove stored credentials |
55
59
  | `status` | Show connection status and org info |
56
60
  | `version` | Show CLI version |
@@ -85,6 +85,30 @@ function success(msg) { log(c.green('✓'), msg); }
85
85
  function warn(msg) { log(c.yellow('!'), msg); }
86
86
  function error(msg) { console.error(c.red('✗'), msg); }
87
87
 
88
+ // Read a line from the TTY without echoing it (for secret entry).
89
+ function promptHidden(question) {
90
+ const readline = require('readline');
91
+ const { Writable } = require('stream');
92
+ return new Promise((resolve) => {
93
+ const masked = new Writable({
94
+ write(chunk, enc, cb) {
95
+ if (!masked.isMuted) process.stdout.write(chunk, enc);
96
+ cb();
97
+ },
98
+ });
99
+ masked.isMuted = false;
100
+ const rl = readline.createInterface({ input: process.stdin, output: masked, terminal: true });
101
+ // Intentional sequencing: rl.question() writes the prompt synchronously
102
+ // (while unmuted), then we mute so the user's keystrokes are not echoed.
103
+ rl.question(question, (answer) => {
104
+ rl.close();
105
+ process.stdout.write('\n');
106
+ resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
107
+ });
108
+ masked.isMuted = true;
109
+ });
110
+ }
111
+
88
112
  // ============================================================================
89
113
  // Table formatter
90
114
  // ============================================================================
@@ -199,7 +223,7 @@ function getEncryptionKey() {
199
223
  function encryptCredentials(data) {
200
224
  const key = getEncryptionKey();
201
225
  const iv = crypto.randomBytes(IV_LENGTH);
202
- const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
226
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
203
227
 
204
228
  const plaintext = JSON.stringify(data);
205
229
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
@@ -221,7 +245,8 @@ function decryptCredentials() {
221
245
  const decipher = crypto.createDecipheriv(
222
246
  'aes-256-gcm',
223
247
  key,
224
- Buffer.from(iv, 'hex')
248
+ Buffer.from(iv, 'hex'),
249
+ { authTagLength: 16 }
225
250
  );
226
251
  decipher.setAuthTag(Buffer.from(tag, 'hex'));
227
252
 
@@ -403,7 +428,7 @@ ${c.bold('USAGE')}
403
428
  circleinbox <command> [options]
404
429
 
405
430
  ${c.bold('GENERAL')}
406
- login [--key <api-key>] Store API key (encrypted)
431
+ login [--key-file <path> | --from-stdin] Store API key (encrypted)
407
432
  logout Remove stored credentials
408
433
  status Show connection status and org info
409
434
  version Show CLI version
@@ -456,18 +481,59 @@ ${c.bold('GLOBAL FLAGS')}
456
481
 
457
482
  async function commandLogin(args) {
458
483
  const { flags, positional } = parseFlags(args);
459
- let apiKey = flags.key || positional[0];
460
484
 
461
- if (!apiKey) {
462
- // Read from stdin if available
463
- const readline = require('readline');
464
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
465
- apiKey = await new Promise((resolve) => {
466
- rl.question('API Key (ci_live_...): ', (answer) => {
467
- rl.close();
468
- resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
469
- });
470
- });
485
+ // Secrets must never be passed on the command line — they leak via shell
486
+ // history and `ps aux`. Reject every leak-prone form (`--key <v>`,
487
+ // `--key=<v>`, and a bare positional key) and route to safe channels.
488
+ // Three leak-prone forms, each caught explicitly because parseFlags treats
489
+ // them differently: `--key <v>` -> flags.key='<v>'; `--key=<v>` -> a flag
490
+ // literally named "key=<v>" set to true (parseFlags does not split on "=");
491
+ // and a bare positional. (The middle check guards parseFlags' current "="
492
+ // behavior — test/login-security.test.cjs exercises `--key=...` end-to-end to catch drift.)
493
+ const leakedKeyOnCmdline =
494
+ typeof flags.key === 'string' ||
495
+ Object.keys(flags).some((k) => k.startsWith('key=')) ||
496
+ positional.length > 0;
497
+ if (leakedKeyOnCmdline) {
498
+ error('Passing the API key on the command line is insecure (it leaks via shell history and `ps aux`).');
499
+ info('Use one of these instead:');
500
+ info(' circleinbox login --key-file <path> Read the key from a file');
501
+ info(' echo "$KEY" | circleinbox login --from-stdin Pipe the key via stdin');
502
+ info(' CIRCLEINBOX_API_KEY=<key> circleinbox login Read from the environment');
503
+ info(' circleinbox login Interactive hidden prompt');
504
+ info('If you just ran this with a real key, rotate it — it is now in your shell history.');
505
+ process.exit(1);
506
+ }
507
+
508
+ let apiKey;
509
+ if (flags['key-file']) {
510
+ const keyPath = flags['key-file'];
511
+ try {
512
+ const st = fs.statSync(keyPath);
513
+ if (process.platform !== 'win32' && (st.mode & 0o077)) {
514
+ warn(`Key file ${keyPath} is group/world-readable (mode ${(st.mode & 0o777).toString(8)}). Run: chmod 600 ${keyPath}`);
515
+ }
516
+ apiKey = fs.readFileSync(keyPath, 'utf8').replace(/[\x00-\x1F\x7F]/g, '').trim();
517
+ } catch (err) {
518
+ error(`Could not read key file: ${err.message}`);
519
+ process.exit(1);
520
+ }
521
+ } else if (flags['from-stdin']) {
522
+ if (process.stdin.isTTY) {
523
+ info('Reading API key from stdin (press Ctrl-D when done)…');
524
+ }
525
+ const chunks = [];
526
+ for await (const chunk of process.stdin) chunks.push(chunk);
527
+ apiKey = Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x1F\x7F]/g, '').trim();
528
+ } else if (process.env.CIRCLEINBOX_API_KEY) {
529
+ info('Using key from CIRCLEINBOX_API_KEY');
530
+ apiKey = process.env.CIRCLEINBOX_API_KEY.replace(/[\x00-\x1F\x7F]/g, '').trim();
531
+ } else if (process.stdin.isTTY) {
532
+ apiKey = await promptHidden('API Key (ci_live_...): ');
533
+ } else {
534
+ error('No API key provided.');
535
+ info('Use --key-file <path>, --from-stdin, the CIRCLEINBOX_API_KEY env var, or run interactively.');
536
+ process.exit(1);
471
537
  }
472
538
 
473
539
  if (!apiKey) {
@@ -480,10 +546,10 @@ async function commandLogin(args) {
480
546
  process.exit(1);
481
547
  }
482
548
 
483
- // Test the key
549
+ // Test the key (honor --api-url override, matching getClient resolution)
484
550
  info('Verifying API key...');
485
- const config = loadConfig();
486
- const client = new ApiClient(config.apiUrl || DEFAULT_API_URL, apiKey);
551
+ const baseUrl = globalFlags.apiUrlOverride || loadConfig().apiUrl || DEFAULT_API_URL;
552
+ const client = new ApiClient(baseUrl, apiKey);
487
553
 
488
554
  try {
489
555
  const result = await client.get('/domains');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circleinbox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for CircleInbox email infrastructure — manage inboxes, send email, store credentials, and more",
5
5
  "type": "module",
6
6
  "main": "circleinbox-cli.cjs",
@@ -16,10 +16,9 @@
16
16
  "access": "public"
17
17
  },
18
18
  "scripts": {
19
- "test": "node --test lib/**/*.test.cjs"
19
+ "test": "node --test test/*.test.cjs"
20
20
  },
21
21
  "files": [
22
- "lib/",
23
22
  "circleinbox-cli.cjs",
24
23
  "README.md",
25
24
  "LICENSE"
@@ -1,242 +0,0 @@
1
- // Tests for the 6 critical/major fixes in circleinbox-cli.cjs
2
- // Run with: node --test cli/lib/cli/cli-fixes.test.cjs
3
-
4
- const { describe, it } = require('node:test');
5
- const assert = require('node:assert/strict');
6
- const fs = require('node:fs');
7
- const path = require('node:path');
8
-
9
- // Read the CLI source to verify structural fixes
10
- const cliSource = fs.readFileSync(
11
- path.join(__dirname, '..', '..', 'circleinbox-cli.cjs'),
12
- 'utf8'
13
- );
14
-
15
- describe('Fix 1: --api-url must not persist to disk', () => {
16
- it('should NOT call saveConfig when --api-url is set', () => {
17
- // The old code had: saveConfig(config); after setting api-url
18
- // The new code should set globalFlags.apiUrlOverride instead
19
- const apiUrlBlock = cliSource.match(
20
- /if \(gf\['api-url'\]\)[\s\S]*?}/
21
- )?.[0];
22
- assert.ok(apiUrlBlock, 'api-url handling block exists');
23
- assert.ok(
24
- !apiUrlBlock.includes('saveConfig'),
25
- 'saveConfig must not be called in api-url block'
26
- );
27
- assert.ok(
28
- apiUrlBlock.includes('apiUrlOverride'),
29
- 'should set apiUrlOverride in memory'
30
- );
31
- });
32
-
33
- it('getClient should use apiUrlOverride', () => {
34
- const getClientBlock = cliSource.match(
35
- /function getClient\(\)[\s\S]*?^}/m
36
- )?.[0];
37
- assert.ok(getClientBlock, 'getClient function exists');
38
- assert.ok(
39
- getClientBlock.includes('apiUrlOverride'),
40
- 'getClient must check globalFlags.apiUrlOverride'
41
- );
42
- });
43
- });
44
-
45
- describe('Fix 2: readline rl.close() ordering', () => {
46
- it('inboxDelete should close rl inside the question callback', () => {
47
- const inboxDeleteBlock = cliSource.match(
48
- /async function inboxDelete[\s\S]*?^}/m
49
- )?.[0];
50
- assert.ok(inboxDeleteBlock, 'inboxDelete function exists');
51
-
52
- // rl.close() must appear INSIDE the callback, not after rl.question
53
- // The pattern should be: rl.question('...', (answer) => { rl.close(); resolve(...) })
54
- // NOT: rl.question('...', resolve); rl.close();
55
- assert.ok(
56
- !inboxDeleteBlock.match(/rl\.question\([^)]+,\s*resolve\s*\)\s*;\s*\n\s*rl\.close/),
57
- 'rl.close() must NOT be on the line after rl.question with bare resolve'
58
- );
59
- // Positive check: rl.close() appears inside a callback with resolve
60
- assert.ok(
61
- inboxDeleteBlock.includes('rl.close();\n resolve('),
62
- 'rl.close() should be inside the callback before resolve'
63
- );
64
- });
65
-
66
- it('commandReset password prompt should close rl inside the callback', () => {
67
- // Find the commandReset function's readline usage
68
- const resetBlock = cliSource.match(
69
- /async function commandReset[\s\S]*?^}/m
70
- )?.[0];
71
- assert.ok(resetBlock, 'commandReset function exists');
72
-
73
- // Should NOT have the pattern: rl.question('...', resolve); rl.close();
74
- assert.ok(
75
- !resetBlock.match(/rl\.question\([^)]+,\s*resolve\s*\)\s*;\s*\n\s*rl\.close/),
76
- 'rl.close() must NOT be after rl.question with bare resolve in commandReset'
77
- );
78
- });
79
- });
80
-
81
- describe('Fix 3: response.json() crash on non-JSON', () => {
82
- it('should use response.text() + JSON.parse instead of response.json()', () => {
83
- const requestBlock = cliSource.match(
84
- /async request\(method[\s\S]*?^\s{2}}/m
85
- )?.[0];
86
- assert.ok(requestBlock, 'request method exists');
87
-
88
- assert.ok(
89
- !requestBlock.includes('response.json()'),
90
- 'must NOT use response.json() directly'
91
- );
92
- assert.ok(
93
- requestBlock.includes('response.text()'),
94
- 'must use response.text() first'
95
- );
96
- assert.ok(
97
- requestBlock.includes('JSON.parse(text)'),
98
- 'must use JSON.parse on text'
99
- );
100
- });
101
-
102
- it('should handle parse failures gracefully', () => {
103
- const requestBlock = cliSource.match(
104
- /async request\(method[\s\S]*?^\s{2}}/m
105
- )?.[0];
106
- // Should have error handling around JSON.parse
107
- assert.ok(
108
- requestBlock.includes("catch") && requestBlock.includes("INVALID_RESPONSE"),
109
- 'should catch parse errors and throw with INVALID_RESPONSE code'
110
- );
111
- });
112
- });
113
-
114
- describe('Fix 4: fetch timeout with AbortController', () => {
115
- it('ApiClient.request should use AbortController with 30s timeout', () => {
116
- const requestBlock = cliSource.match(
117
- /async request\(method[\s\S]*?^\s{2}}/m
118
- )?.[0];
119
- assert.ok(requestBlock, 'request method exists');
120
-
121
- assert.ok(
122
- requestBlock.includes('AbortController'),
123
- 'must create AbortController'
124
- );
125
- assert.ok(
126
- requestBlock.includes('30000'),
127
- 'must set 30s timeout'
128
- );
129
- assert.ok(
130
- requestBlock.includes('signal: controller.signal'),
131
- 'must pass signal to fetch'
132
- );
133
- assert.ok(
134
- requestBlock.includes('clearTimeout'),
135
- 'must clear timeout after fetch'
136
- );
137
- });
138
-
139
- it('resetClearAuth fetch calls should also have timeouts', () => {
140
- const resetBlock = cliSource.match(
141
- /async function resetClearAuth[\s\S]*?^}/m
142
- )?.[0];
143
- assert.ok(resetBlock, 'resetClearAuth function exists');
144
-
145
- // Should have AbortController for both fetch calls
146
- const controllerMatches = resetBlock.match(/new AbortController/g);
147
- assert.ok(
148
- controllerMatches && controllerMatches.length >= 2,
149
- 'resetClearAuth must have at least 2 AbortControllers (one per fetch)'
150
- );
151
- });
152
- });
153
-
154
- describe('Fix 5: subArgs no longer uses fragile indexOf', () => {
155
- it('should not use allArgs.indexOf(command) for subArgs', () => {
156
- const mainBlock = cliSource.match(
157
- /\/\/ Main[\s\S]*$/
158
- )?.[0];
159
- assert.ok(mainBlock, 'main block exists');
160
-
161
- assert.ok(
162
- !mainBlock.includes('allArgs.indexOf(command)'),
163
- 'must NOT use allArgs.indexOf(command)'
164
- );
165
- });
166
-
167
- it('should track command position by iterating and skipping global flags', () => {
168
- const mainBlock = cliSource.match(
169
- /\/\/ Main[\s\S]*$/
170
- )?.[0];
171
- assert.ok(mainBlock, 'main block exists');
172
-
173
- assert.ok(
174
- mainBlock.includes('commandIndex'),
175
- 'should use a commandIndex variable'
176
- );
177
- assert.ok(
178
- mainBlock.includes('GLOBAL_FLAGS'),
179
- 'should define known global flags to skip'
180
- );
181
- });
182
- });
183
-
184
- describe('Fix 6: HTTPS validation for credentials', () => {
185
- it('resetClearAuth should refuse HTTP URLs', () => {
186
- const resetBlock = cliSource.match(
187
- /async function resetClearAuth[\s\S]*?^}/m
188
- )?.[0];
189
- assert.ok(resetBlock, 'resetClearAuth function exists');
190
-
191
- assert.ok(
192
- resetBlock.includes("startsWith('https://')"),
193
- 'must check for https:// protocol'
194
- );
195
- assert.ok(
196
- resetBlock.includes('insecure HTTP') || resetBlock.includes('Refusing'),
197
- 'must show error about insecure connection'
198
- );
199
- });
200
- });
201
-
202
- // ============================================================================
203
- // PR #22 Review Fixes
204
- // ============================================================================
205
-
206
- describe('Fix 7: Generic password reset flow must also validate HTTPS', () => {
207
- it('commandReset generic flow should check for HTTPS before proceeding', () => {
208
- const resetBlock = cliSource.match(
209
- /async function commandReset[\s\S]*?^}/m
210
- )?.[0];
211
- assert.ok(resetBlock, 'commandReset function exists');
212
-
213
- // The generic flow starts after the ClearAuth branch (if flags.clearauth)
214
- // It should have its own HTTPS check before the generic flow logic
215
- const genericFlowSection = resetBlock.split('flags.clearauth')[1];
216
- assert.ok(genericFlowSection, 'generic flow section exists after clearauth branch');
217
-
218
- assert.ok(
219
- genericFlowSection.includes("startsWith('https://')") ||
220
- genericFlowSection.includes('startsWith("https://")'),
221
- 'generic flow must check for https:// protocol on serviceUrl'
222
- );
223
- assert.ok(
224
- genericFlowSection.includes('insecure') || genericFlowSection.includes('HTTPS'),
225
- 'generic flow must warn about insecure connections'
226
- );
227
- });
228
- });
229
-
230
- describe('Fix 8: Retry jitter to prevent thundering herd', () => {
231
- it('ApiClient.request should add jitter to retry delays', () => {
232
- const requestBlock = cliSource.match(
233
- /async request\(method[\s\S]*?^\s{2}}/m
234
- )?.[0];
235
- assert.ok(requestBlock, 'request method exists');
236
-
237
- assert.ok(
238
- requestBlock.includes('Math.random()'),
239
- 'retry delay must include random jitter via Math.random()'
240
- );
241
- });
242
- });