circleinbox 0.1.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 +210 -0
- package/circleinbox-cli.cjs +1626 -0
- package/lib/cli/cli-fixes.test.cjs +242 -0
- package/package.json +39 -0
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "circleinbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for CircleInbox email infrastructure — manage inboxes, send email, store credentials, and more",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "circleinbox-cli.cjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"circleinbox": "./circleinbox-cli.cjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20.0.0"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Dundas",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test lib/**/*.test.cjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib/",
|
|
23
|
+
"circleinbox-cli.cjs",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"circleinbox",
|
|
29
|
+
"email",
|
|
30
|
+
"inbox",
|
|
31
|
+
"cli",
|
|
32
|
+
"credentials",
|
|
33
|
+
"vault",
|
|
34
|
+
"mailbox",
|
|
35
|
+
"agent",
|
|
36
|
+
"developer-tools"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {}
|
|
39
|
+
}
|