not-manage 0.1.17

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,465 @@
1
+ const crypto = require("node:crypto");
2
+ const {
3
+ CLIO_APP_CREATION_GUIDE_URL,
4
+ CLIO_AUTHORIZATION_GUIDE_URL,
5
+ CLIO_DEVELOPER_ACCOUNT_GUIDE_URL,
6
+ DEFAULT_REDIRECT_URI,
7
+ DEFAULT_REGION,
8
+ REGIONS,
9
+ } = require("./constants");
10
+ const {
11
+ authorizeUrl,
12
+ deauthorize,
13
+ exchangeAuthorizationCode,
14
+ fetchWhoAmI,
15
+ fetchUser,
16
+ getValidAccessToken,
17
+ } = require("./clio-api");
18
+ const { openBrowser } = require("./open-browser");
19
+ const { waitForOAuthCallback } = require("./oauth-callback");
20
+ const { ask, askSecret, withPrompt } = require("./prompt");
21
+ const {
22
+ clearTokenSet,
23
+ findConfig,
24
+ getConfig,
25
+ getTokenSet,
26
+ normalizeRegion,
27
+ parseRedirectUri,
28
+ saveConfig,
29
+ saveTokenSet,
30
+ } = require("./store");
31
+
32
+ function formatUserSummary(payload) {
33
+ const data = payload?.data || {};
34
+ const user = data?.user || data;
35
+ const id = user?.id || "unknown";
36
+ const name =
37
+ user?.name ||
38
+ [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
39
+ "unknown";
40
+ const email = user?.email || user?.email_address || "unknown";
41
+ return { id, name, email };
42
+ }
43
+
44
+ async function hydrateUserSummary(config, accessToken, user) {
45
+ if (!user || user.email !== "unknown" || user.id === "unknown") {
46
+ return user;
47
+ }
48
+
49
+ try {
50
+ const detailPayload = await fetchUser(config, accessToken, user.id, {
51
+ fields: "id,name,first_name,last_name,email",
52
+ });
53
+ const detailedUser = formatUserSummary(detailPayload);
54
+ return {
55
+ ...user,
56
+ ...detailedUser,
57
+ email: detailedUser.email || user.email,
58
+ };
59
+ } catch (_error) {
60
+ return user;
61
+ }
62
+ }
63
+
64
+ async function fetchCurrentUserSummary(config, accessToken) {
65
+ const payload = await fetchWhoAmI(config, accessToken);
66
+ const user = await hydrateUserSummary(config, accessToken, formatUserSummary(payload));
67
+ return { payload, user };
68
+ }
69
+
70
+ function maskCredential(value) {
71
+ const text = String(value || "");
72
+ if (!text) {
73
+ return "not set";
74
+ }
75
+
76
+ if (text.length <= 8) {
77
+ return `${"*".repeat(Math.max(text.length - 2, 0))}${text.slice(-2)}`;
78
+ }
79
+
80
+ return `${text.slice(0, 4)}...${text.slice(-4)}`;
81
+ }
82
+
83
+ function formatConfigSummary(config) {
84
+ return [
85
+ `Config source: ${config.source}`,
86
+ `Region: ${config.region} (${config.regionLabel})`,
87
+ `Host: ${config.host}`,
88
+ `Redirect URI: ${config.redirectUri}`,
89
+ `App Key: ${maskCredential(config.clientId)}`,
90
+ ];
91
+ }
92
+
93
+ function rewriteOAuthError(error, config) {
94
+ const message = error && error.message ? error.message : String(error);
95
+
96
+ if (message.includes("invalid_client")) {
97
+ return new Error(
98
+ [
99
+ "Clio rejected the app credentials during OAuth token exchange (`invalid_client`).",
100
+ ...formatConfigSummary(config),
101
+ "Most likely causes:",
102
+ "- The App Secret is wrong or was copied from a different Clio app.",
103
+ "- The App ID was entered instead of the App Key.",
104
+ "- The App Key/App Secret pair does not belong to the selected Clio region.",
105
+ "- The redirect URI registered in the Clio app does not exactly match the value above.",
106
+ "Run `not-manage auth setup` again and copy the App Key and App Secret from the same Clio developer app.",
107
+ `Original error: ${message}`,
108
+ ].join("\n")
109
+ );
110
+ }
111
+
112
+ return error;
113
+ }
114
+
115
+ function collectSecurityWarnings(config, tokenSet) {
116
+ return [];
117
+ }
118
+
119
+ function printSetupBanner() {
120
+ console.log("+===========================================+");
121
+ console.log("| WELCOME TO NOT MANAGE |");
122
+ console.log("+===========================================+");
123
+ console.log("| Local OAuth setup for this CLI |");
124
+ console.log("+===========================================+");
125
+ }
126
+
127
+ function printSetupSteps() {
128
+ console.log("Setup flow:");
129
+ console.log(" [1] Choose your Clio region");
130
+ console.log(" [2] Open the Clio developer portal for that region and sign in");
131
+ console.log(" [3] Open your Clio developer app, or create one if you do not have one yet");
132
+ console.log(" [4] Choose the Clio Manage permissions this CLI should access");
133
+ console.log(" [5] Add the local callback URL to Redirect URIs, then copy the App Key and App Secret back here");
134
+ }
135
+
136
+ async function maybeOpenDeveloperPortal(rl, region) {
137
+ const regionInfo = REGIONS[region];
138
+ const promptLabel = "Press Enter to open the developer portal now, or type skip to continue here";
139
+ const answer = String(await ask(rl, promptLabel, "")).trim().toLowerCase();
140
+
141
+ if (answer === "skip") {
142
+ console.log("Continuing without opening the browser.");
143
+ return;
144
+ }
145
+
146
+ try {
147
+ await openBrowser(regionInfo.developerPortalUrl);
148
+ console.log(`Opened the ${regionInfo.label} Clio developer portal in your browser.`);
149
+ } catch (_error) {
150
+ console.log("Could not open the Clio developer portal automatically.");
151
+ console.log(`Open this URL manually: ${regionInfo.developerPortalUrl}`);
152
+ }
153
+ }
154
+
155
+ function printSetupLinks(region, redirectUri) {
156
+ const regionInfo = REGIONS[region];
157
+ console.log("Clio setup links:");
158
+ console.log(`- Developer portal: ${regionInfo.developerPortalUrl}`);
159
+ console.log(`- Developer account guide: ${CLIO_DEVELOPER_ACCOUNT_GUIDE_URL}`);
160
+ console.log(`- App creation guide: ${CLIO_APP_CREATION_GUIDE_URL}`);
161
+ console.log(`- Authorization guide: ${CLIO_AUTHORIZATION_GUIDE_URL}`);
162
+ console.log(`- Add this redirect URI in your Clio app: ${redirectUri}`);
163
+ }
164
+
165
+ function printClioAppFieldGuide(redirectUri) {
166
+ console.log("Clio app form guide:");
167
+ console.log("");
168
+ console.log(" Required:");
169
+ console.log(" - Website URL: use your firm website, company site, or GitHub repo.");
170
+ console.log(" - Do not put the local callback URL in Website URL.");
171
+ console.log(" - Clio Manage permissions / scopes: select only the permissions this CLI will actually use.");
172
+ console.log(" - Redirect URIs: copy this exact URL on its own line:");
173
+ console.log(` ${redirectUri}`);
174
+ console.log("");
175
+ console.log(" Optional:");
176
+ console.log(" - Support URL and Deauthorization callback URL can stay blank unless you already use them.");
177
+ }
178
+
179
+ function printDeveloperPortalReminder(redirectUri) {
180
+ console.log("In the developer portal:");
181
+ console.log("");
182
+ console.log(" First:");
183
+ console.log(" - Sign in, then open the Clio developer app you want this CLI to use.");
184
+ console.log(" - Use an existing app in this region, or create a new one.");
185
+ console.log("");
186
+ console.log(" Permissions:");
187
+ console.log(" - Select only the Clio Manage permissions (OAuth scopes) this CLI should access.");
188
+ console.log("");
189
+ console.log(" Redirect URI:");
190
+ console.log(" - Register this exact URL in your Clio developer app:");
191
+ console.log(` ${redirectUri}`);
192
+ console.log("");
193
+ console.log(" Then:");
194
+ console.log(" - Copy the App Key and App Secret from that same app back here.");
195
+ }
196
+
197
+ function printConfidentialityNotice() {
198
+ console.log("Confidentiality notice:");
199
+ console.log(" not-manage can display client-identifying, confidential, or privileged matter data.");
200
+ console.log(" `--redacted` is best-effort only and may miss identifiers in labels, custom fields, or free text.");
201
+ console.log(" Review all output before sharing it with AI tools, tickets, chats, or other third parties.");
202
+ console.log(" Use only with workflows and vendors your firm has approved.");
203
+ }
204
+
205
+ function printSetupIntro(redirectUri) {
206
+ printSetupBanner();
207
+ console.log("");
208
+ console.log("This setup is for developers who are connecting the CLI to their own Clio app.");
209
+ console.log("If this is your first time doing that, this guide will walk you through it.");
210
+ console.log("");
211
+ printConfidentialityNotice();
212
+ console.log("");
213
+ printSetupSteps();
214
+ console.log("");
215
+ console.log("Useful links:");
216
+ console.log(`- Developer account guide: ${CLIO_DEVELOPER_ACCOUNT_GUIDE_URL}`);
217
+ console.log(`- App creation guide: ${CLIO_APP_CREATION_GUIDE_URL}`);
218
+ console.log(`- OAuth guide: ${CLIO_AUTHORIZATION_GUIDE_URL}`);
219
+ console.log("");
220
+ printClioAppFieldGuide(redirectUri);
221
+ console.log("");
222
+ console.log("You do not need to paste the redirect URI back into this CLI unless you want to override it.");
223
+ console.log("");
224
+ console.log("Region options:");
225
+ Object.values(REGIONS).forEach((region) => {
226
+ console.log(`- ${region.code}: ${region.label} (${region.host})`);
227
+ });
228
+ }
229
+
230
+ async function confirmConfidentialityNotice(rl) {
231
+ const answer = String(
232
+ await ask(
233
+ rl,
234
+ "Type yes to confirm you will review output before sharing it outside your firm"
235
+ )
236
+ )
237
+ .trim()
238
+ .toLowerCase();
239
+
240
+ if (answer !== "yes") {
241
+ throw new Error(
242
+ "Setup aborted. Review your confidentiality and client-sharing requirements, then rerun `not-manage auth setup`."
243
+ );
244
+ }
245
+ }
246
+
247
+ async function authSetup(options = {}) {
248
+ printSetupIntro(DEFAULT_REDIRECT_URI);
249
+ console.log("");
250
+
251
+ const configInput = await withPrompt(async (rl) => {
252
+ await confirmConfidentialityNotice(rl);
253
+ const regionRaw = await ask(rl, "Region", DEFAULT_REGION);
254
+ const region = normalizeRegion(regionRaw);
255
+ const regionInfo = REGIONS[region];
256
+
257
+ console.log(`Using ${regionInfo.label} (${regionInfo.host}).`);
258
+ console.log(`Developer portal: ${regionInfo.developerPortalUrl}`);
259
+ console.log("If you already have a Clio developer app in this region, you can use it.");
260
+ console.log("If not, create one there first, then come back here.");
261
+ await maybeOpenDeveloperPortal(rl, region);
262
+ printDeveloperPortalReminder(DEFAULT_REDIRECT_URI);
263
+
264
+ const clientId = await ask(rl, "App Key / Client ID (from your Clio developer app)");
265
+ if (!clientId) {
266
+ throw new Error("App Key / Client ID is required.");
267
+ }
268
+
269
+ const clientSecret = await askSecret(
270
+ rl,
271
+ "App Secret / Client Secret (from the same Clio app)"
272
+ );
273
+ if (!clientSecret) {
274
+ throw new Error("App Secret / Client Secret is required.");
275
+ }
276
+
277
+ const redirectUriOverride = await ask(
278
+ rl,
279
+ "Custom redirect URI override (optional; press Enter to keep the default)"
280
+ );
281
+ const redirectUri = parseRedirectUri(redirectUriOverride || DEFAULT_REDIRECT_URI);
282
+ return {
283
+ region,
284
+ clientId,
285
+ clientSecret,
286
+ redirectUri,
287
+ };
288
+ });
289
+
290
+ const saved = await saveConfig(configInput);
291
+ await clearTokenSet();
292
+
293
+ console.log("");
294
+ console.log("Saved credentials to secure keychain.");
295
+ console.log(`Region: ${saved.region} (${REGIONS[saved.region].label})`);
296
+ printSetupLinks(saved.region, saved.redirectUri);
297
+ printClioAppFieldGuide(saved.redirectUri);
298
+
299
+ if (!options.skipNextStepHint) {
300
+ console.log("");
301
+ console.log("Next step: run `not-manage auth login`");
302
+ }
303
+
304
+ return saved;
305
+ }
306
+
307
+ async function authLogin(options = {}) {
308
+ const config = options.config || (await getConfig());
309
+ const state = crypto.randomBytes(16).toString("hex");
310
+ const authUrl = authorizeUrl(config, state);
311
+
312
+ console.log(`Config source: ${config.source}`);
313
+ console.log(`Starting OAuth for region ${config.region} (${config.regionLabel}).`);
314
+ console.log(`Using host ${config.host}`);
315
+ console.log(`Waiting for callback on ${config.redirectUri}`);
316
+
317
+ const callbackPromise = waitForOAuthCallback(config.redirectUri, state);
318
+
319
+ try {
320
+ await openBrowser(authUrl);
321
+ console.log("Opened browser for Clio authorization.");
322
+ } catch (_error) {
323
+ console.log("Could not open browser automatically. Open this URL manually:");
324
+ console.log(authUrl);
325
+ }
326
+
327
+ const callback = await callbackPromise;
328
+ let tokenPayload;
329
+
330
+ try {
331
+ tokenPayload = await exchangeAuthorizationCode(config, callback.code);
332
+ } catch (error) {
333
+ throw rewriteOAuthError(error, config);
334
+ }
335
+
336
+ const tokenSet = await saveTokenSet(tokenPayload);
337
+ const accessToken = await getValidAccessToken(config, tokenSet);
338
+ const { user } = await fetchCurrentUserSummary(config, accessToken);
339
+
340
+ console.log("");
341
+ console.log("Clio login complete.");
342
+ console.log(`Connected user: ${user.name} <${user.email}> (id: ${user.id})`);
343
+ }
344
+
345
+ async function authStatus(options = {}) {
346
+ const config = await getConfig();
347
+ const tokenSet = await getTokenSet();
348
+
349
+ if (!tokenSet || !tokenSet.accessToken) {
350
+ console.log(`Config source: ${config.source}`);
351
+ console.log(`Region: ${config.region} (${config.regionLabel})`);
352
+ console.log("Login status: not logged in");
353
+ console.log("Run `not-manage auth login`.");
354
+ return;
355
+ }
356
+
357
+ const accessToken = await getValidAccessToken(config, tokenSet);
358
+ const { user } = await fetchCurrentUserSummary(config, accessToken);
359
+ const warnings = collectSecurityWarnings(config, tokenSet);
360
+
361
+ if (options.json) {
362
+ console.log(
363
+ JSON.stringify(
364
+ {
365
+ configSource: config.source,
366
+ tokenSource: tokenSet.source,
367
+ region: config.region,
368
+ host: config.host,
369
+ user,
370
+ },
371
+ null,
372
+ 2
373
+ )
374
+ );
375
+ return;
376
+ }
377
+
378
+ console.log(`Config source: ${config.source}`);
379
+ console.log(`Token source: ${tokenSet.source}`);
380
+ console.log(`Region: ${config.region} (${config.regionLabel})`);
381
+ console.log(`Host: ${config.host}`);
382
+ console.log(`Login status: connected`);
383
+ console.log(`Connected user: ${user.name} <${user.email}> (id: ${user.id})`);
384
+ warnings.forEach((warning) => {
385
+ console.log(`Security warning: ${warning}`);
386
+ });
387
+ }
388
+
389
+ async function authRevoke() {
390
+ const config = await getConfig();
391
+ const tokenSet = await getTokenSet();
392
+
393
+ if (!tokenSet || !tokenSet.accessToken) {
394
+ console.log("No local token found. Nothing to revoke.");
395
+ return;
396
+ }
397
+
398
+ try {
399
+ const accessToken = await getValidAccessToken(config, tokenSet);
400
+ await deauthorize(config, accessToken);
401
+ console.log("Revoked token in Clio.");
402
+ } catch (error) {
403
+ console.log(`Clio deauthorize call failed: ${error.message}`);
404
+ console.log("Clearing local token anyway.");
405
+ }
406
+
407
+ await clearTokenSet();
408
+ console.log("Local keychain token cleared.");
409
+ }
410
+
411
+ async function whoAmI(options = {}) {
412
+ const config = await getConfig();
413
+ const tokenSet = await getTokenSet();
414
+ const accessToken = await getValidAccessToken(config, tokenSet);
415
+ const { payload, user } = await fetchCurrentUserSummary(config, accessToken);
416
+
417
+ if (options.json) {
418
+ console.log(JSON.stringify(payload, null, 2));
419
+ return;
420
+ }
421
+
422
+ console.log(`User: ${user.name}`);
423
+ console.log(`Email: ${user.email}`);
424
+ console.log(`ID: ${user.id}`);
425
+ }
426
+
427
+ async function setupWizard() {
428
+ const config = await authSetup({ skipNextStepHint: true });
429
+ console.log("");
430
+ console.log("Continuing with OAuth login...");
431
+ await authLogin({ config });
432
+ }
433
+
434
+ async function maybeRunSetupOnFirstUse() {
435
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
436
+ return false;
437
+ }
438
+
439
+ const config = await findConfig();
440
+ if (config) {
441
+ return false;
442
+ }
443
+
444
+ console.log("No Clio app credentials are configured yet.");
445
+ console.log("Starting guided setup...");
446
+ console.log("");
447
+ await setupWizard();
448
+ return true;
449
+ }
450
+
451
+ module.exports = {
452
+ authLogin,
453
+ authRevoke,
454
+ authSetup,
455
+ authStatus,
456
+ maybeRunSetupOnFirstUse,
457
+ setupWizard,
458
+ whoAmI,
459
+ __private: {
460
+ collectSecurityWarnings,
461
+ fetchCurrentUserSummary,
462
+ formatUserSummary,
463
+ hydrateUserSummary,
464
+ },
465
+ };
@@ -0,0 +1,152 @@
1
+ const {
2
+ fetchBillableClientsPage,
3
+ getValidAccessToken,
4
+ } = require("./clio-api");
5
+ const { getConfig, getTokenSet } = require("./store");
6
+ const {
7
+ clip,
8
+ compactQuery,
9
+ fetchPages,
10
+ formatMoney,
11
+ parseLimit,
12
+ printKeyValueRows,
13
+ } = require("./resource-utils");
14
+ const { maybeRedactData, maybeRedactPayload } = require("./redaction");
15
+
16
+ const DEFAULT_LIST_FIELDS =
17
+ "id,name,unbilled_hours,unbilled_amount,amount_in_trust,billable_matters_count";
18
+
19
+ function buildBillableClientQuery(options) {
20
+ return compactQuery({
21
+ client_id: options.clientId || undefined,
22
+ end_date: options.endDate || undefined,
23
+ fields: options.fields || DEFAULT_LIST_FIELDS,
24
+ limit: parseLimit(options.limit, 25),
25
+ matter_id: options.matterId || undefined,
26
+ originating_attorney_id: options.originatingAttorneyId || undefined,
27
+ page_token: options.pageToken || undefined,
28
+ query: options.query || undefined,
29
+ responsible_attorney_id: options.responsibleAttorneyId || undefined,
30
+ start_date: options.startDate || undefined,
31
+ });
32
+ }
33
+
34
+ function formatBillableClientRow(record) {
35
+ return {
36
+ id: String(record.id || "-"),
37
+ name: String(record.name || "-"),
38
+ hours:
39
+ record.unbilled_hours === undefined || record.unbilled_hours === null
40
+ ? "-"
41
+ : Number(record.unbilled_hours).toFixed(2),
42
+ amount: formatMoney(record.unbilled_amount),
43
+ trust: formatMoney(record.amount_in_trust),
44
+ matters: String(record.billable_matters_count ?? "-"),
45
+ };
46
+ }
47
+
48
+ function printBillableClientList(rows, options) {
49
+ if (rows.length === 0) {
50
+ console.log("No billable clients found for the selected filters.");
51
+ return;
52
+ }
53
+
54
+ const visibleRows = rows.slice(0, 50);
55
+ console.log("ID NAME HOURS AMOUNT TRUST MATTERS");
56
+ console.log("-------- ---------------------------- ----- ---------- ---------- -------");
57
+
58
+ visibleRows.forEach((row) => {
59
+ const line = [
60
+ clip(row.id, 8).padEnd(8, " "),
61
+ clip(row.name, 28).padEnd(28, " "),
62
+ clip(row.hours, 5).padEnd(5, " "),
63
+ clip(row.amount, 10).padEnd(10, " "),
64
+ clip(row.trust, 10).padEnd(10, " "),
65
+ clip(row.matters, 7),
66
+ ].join(" ");
67
+
68
+ console.log(line);
69
+ });
70
+
71
+ if (rows.length > visibleRows.length) {
72
+ console.log(`Showing ${visibleRows.length} of ${rows.length} billable clients. Use --json for full output.`);
73
+ }
74
+
75
+ if (!options.all && options.nextPageUrl) {
76
+ console.log("");
77
+ console.log("More results are available.");
78
+ console.log("Run again with `--all` or pass `--page-token` from `--json` output.");
79
+ }
80
+ }
81
+
82
+ function printBillableClient(record) {
83
+ printKeyValueRows([
84
+ ["ID", record.id],
85
+ ["Name", record.name],
86
+ ["Unbilled Hours", record.unbilled_hours],
87
+ ["Unbilled Amount", formatMoney(record.unbilled_amount)],
88
+ ["Amount In Trust", formatMoney(record.amount_in_trust)],
89
+ ["Billable Matters", record.billable_matters_count],
90
+ ]);
91
+ }
92
+
93
+ async function getAuthContext() {
94
+ const config = await getConfig();
95
+ const tokenSet = await getTokenSet();
96
+ const accessToken = await getValidAccessToken(config, tokenSet);
97
+ return { config, accessToken };
98
+ }
99
+
100
+ async function billableClientsList(options = {}) {
101
+ const query = buildBillableClientQuery(options);
102
+ const { config, accessToken } = await getAuthContext();
103
+ const result = await fetchPages(
104
+ (pageQuery, nextPageUrl) =>
105
+ fetchBillableClientsPage(config, accessToken, pageQuery, nextPageUrl),
106
+ query,
107
+ Boolean(options.all)
108
+ );
109
+
110
+ if (options.json) {
111
+ const firstPage = maybeRedactPayload(result.firstPage, options, "billable-client");
112
+ if (!options.all) {
113
+ console.log(JSON.stringify(firstPage, null, 2));
114
+ return;
115
+ }
116
+
117
+ const data = maybeRedactData(result.data, options, "billable-client");
118
+ console.log(
119
+ JSON.stringify(
120
+ {
121
+ data,
122
+ meta: {
123
+ pages_fetched: result.pagesFetched,
124
+ returned_count: data.length,
125
+ },
126
+ },
127
+ null,
128
+ 2
129
+ )
130
+ );
131
+ return;
132
+ }
133
+
134
+ const rows = maybeRedactData(result.data, options, "billable-client").map(
135
+ formatBillableClientRow
136
+ );
137
+ printBillableClientList(rows, { all: Boolean(options.all), nextPageUrl: result.nextPageUrl });
138
+ console.log("");
139
+ console.log(
140
+ `Returned ${rows.length} billable client${rows.length === 1 ? "" : "s"} across ${result.pagesFetched} page${result.pagesFetched === 1 ? "" : "s"}.`
141
+ );
142
+ }
143
+
144
+ module.exports = {
145
+ billableClientsList,
146
+ __private: {
147
+ buildBillableClientQuery,
148
+ formatBillableClientRow,
149
+ printBillableClient,
150
+ printBillableClientList,
151
+ },
152
+ };