@devbrevity/note-cli 1.0.0 → 1.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 CHANGED
@@ -1,15 +1,21 @@
1
1
  # @devbrevity/note-cli
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@devbrevity/note-cli)](https://www.npmjs.com/package/@devbrevity/note-cli)
4
+
3
5
  A terminal-based CLI for [LifeBrevity](https://www.lifebrevity.com) journal entries. Create, list, search, and manage your notes from the command line.
4
6
 
7
+ ## Requirements
8
+
9
+ - [Node.js](https://nodejs.org/) 18 or later
10
+ - An active [LifeBrevity](https://www.lifebrevity.com) subscription
11
+ - Register for an account at [lifebrevity.com/register](https://www.lifebrevity.com/register)
12
+
5
13
  ## Install
6
14
 
7
15
  ```bash
8
16
  npm install -g @devbrevity/note-cli
9
17
  ```
10
18
 
11
- Requires Node.js 18+ and an active [LifeBrevity](https://www.lifebrevity.com) subscription.
12
-
13
19
  ## Quick Start
14
20
 
15
21
  ```bash
@@ -93,6 +99,14 @@ Show current user, token expiry, and config location.
93
99
 
94
100
  Clear all stored credentials.
95
101
 
102
+ ### `note --help`
103
+
104
+ Show all available commands and options.
105
+
106
+ ### `note --version`
107
+
108
+ Show the installed version.
109
+
96
110
  ## Authentication
97
111
 
98
112
  The CLI uses browser-based OAuth (same as the LifeBrevity web app). When you run `note login`, your browser opens to the LifeBrevity sign-in page. After authenticating, you're redirected back and the CLI stores your session locally.
@@ -101,6 +115,17 @@ The CLI uses browser-based OAuth (same as the LifeBrevity web app). When you run
101
115
  - **Session duration:** 7 days
102
116
  - **API key:** Provided automatically at login
103
117
 
118
+ ## Uninstall
119
+
120
+ ```bash
121
+ npm uninstall -g @devbrevity/note-cli
122
+ ```
123
+
124
+ ## Links
125
+
126
+ - [npm package](https://www.npmjs.com/package/@devbrevity/note-cli)
127
+ - [LifeBrevity](https://www.lifebrevity.com)
128
+
104
129
  ## License
105
130
 
106
131
  MIT
package/bin/note.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
4
  import { loginCommand } from '../src/commands/login.mjs';
@@ -8,6 +8,7 @@ import { getCommand } from '../src/commands/get.mjs';
8
8
  import { searchCommand } from '../src/commands/search.mjs';
9
9
  import { logoutCommand } from '../src/commands/logout.mjs';
10
10
  import { whoamiCommand } from '../src/commands/whoami.mjs';
11
+ import { importCommand } from '../src/commands/import.mjs';
11
12
 
12
13
  program
13
14
  .name('note')
@@ -21,5 +22,6 @@ program.addCommand(getCommand);
21
22
  program.addCommand(searchCommand);
22
23
  program.addCommand(logoutCommand);
23
24
  program.addCommand(whoamiCommand);
25
+ program.addCommand(importCommand);
24
26
 
25
27
  program.parse();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@devbrevity/note-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for notesBrevity journal entries",
5
5
  "type": "module",
6
6
  "bin": {
7
- "note": "./bin/note.mjs"
7
+ "note": "bin/note.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
@@ -1,170 +1,210 @@
1
- import { createServer } from 'http';
2
- import { randomBytes } from 'crypto';
3
- import { URL } from 'url';
4
- import open from 'open';
5
- import { CONFIG } from '../config.mjs';
6
- import { saveAuth } from './tokenStore.mjs';
7
-
8
- const TIMEOUT_MS = 120_000; // 2 minutes
9
-
10
- /**
11
- * Browser-based Cognito login flow.
12
- * Opens the Cognito Hosted UI, catches the callback on a local server,
13
- * exchanges the auth code via LifeBrevity backend for a local JWT.
14
- */
15
- export async function cognitoLogin() {
16
- const state = randomBytes(32).toString('hex');
17
- const redirectUri = `http://localhost:${CONFIG.CALLBACK_PORT}${CONFIG.CALLBACK_PATH}`;
18
-
19
- const authorizeUrl = new URL(`https://${CONFIG.COGNITO_DOMAIN}/oauth2/authorize`);
20
- authorizeUrl.searchParams.set('response_type', 'code');
21
- authorizeUrl.searchParams.set('client_id', CONFIG.COGNITO_CLIENT_ID);
22
- authorizeUrl.searchParams.set('redirect_uri', redirectUri);
23
- authorizeUrl.searchParams.set('scope', CONFIG.COGNITO_SCOPES);
24
- authorizeUrl.searchParams.set('state', state);
25
-
26
- return new Promise((resolve, reject) => {
27
- let timeout;
28
-
29
- const server = createServer(async (req, res) => {
30
- try {
31
- const url = new URL(req.url, `http://localhost:${CONFIG.CALLBACK_PORT}`);
32
-
33
- if (url.pathname !== CONFIG.CALLBACK_PATH) {
34
- res.writeHead(404);
35
- res.end('Not found');
36
- return;
37
- }
38
-
39
- const code = url.searchParams.get('code');
40
- const returnedState = url.searchParams.get('state');
41
- const error = url.searchParams.get('error');
42
-
43
- if (error) {
44
- const errorDesc = url.searchParams.get('error_description') || error;
45
- res.writeHead(200, { 'Content-Type': 'text/html' });
46
- res.end(errorPage(errorDesc));
47
- cleanup();
48
- reject(new Error(`Login failed: ${errorDesc}`));
49
- return;
50
- }
51
-
52
- if (returnedState !== state) {
53
- res.writeHead(200, { 'Content-Type': 'text/html' });
54
- res.end(errorPage('Security validation failed (state mismatch). Please try again.'));
55
- cleanup();
56
- reject(new Error('State mismatch - possible CSRF attack'));
57
- return;
58
- }
59
-
60
- if (!code) {
61
- res.writeHead(200, { 'Content-Type': 'text/html' });
62
- res.end(errorPage('No authorization code received.'));
63
- cleanup();
64
- reject(new Error('No authorization code in callback'));
65
- return;
66
- }
67
-
68
- // Exchange the code for a local JWT via LifeBrevity backend
69
- const tokenResult = await exchangeCodeForToken(code, redirectUri);
70
-
71
- // Save the token and API key (if returned by backend)
72
- saveAuth({
73
- token: tokenResult.token,
74
- email: tokenResult.user.email,
75
- apiKey: tokenResult.apiKeys?.notesBrevity,
76
- });
77
-
78
- res.writeHead(200, { 'Content-Type': 'text/html' });
79
- res.end(successPage(tokenResult.user.email));
80
- cleanup();
81
- resolve(tokenResult);
82
- } catch (err) {
83
- res.writeHead(200, { 'Content-Type': 'text/html' });
84
- res.end(errorPage(err.message));
85
- cleanup();
86
- reject(err);
87
- }
88
- });
89
-
90
- function cleanup() {
91
- clearTimeout(timeout);
92
- server.close();
93
- }
94
-
95
- server.on('error', (err) => {
96
- if (err.code === 'EADDRINUSE') {
97
- reject(new Error(
98
- `Port ${CONFIG.CALLBACK_PORT} is already in use. ` +
99
- `Close the process using it and try again.`
100
- ));
101
- } else {
102
- reject(err);
103
- }
104
- });
105
-
106
- server.listen(CONFIG.CALLBACK_PORT, async () => {
107
- // Open the browser
108
- await open(authorizeUrl.toString());
109
-
110
- timeout = setTimeout(() => {
111
- cleanup();
112
- reject(new Error('Login timed out after 2 minutes. Please try again.'));
113
- }, TIMEOUT_MS);
114
- });
115
- });
116
- }
117
-
118
- /**
119
- * Exchange the Cognito authorization code for a local JWT
120
- * via the LifeBrevity backend's /api/oidc/callback endpoint.
121
- */
122
- async function exchangeCodeForToken(code, redirectUri) {
123
- const response = await fetch(`${CONFIG.LIFEBREVITY_BACKEND}/api/oidc/callback`, {
124
- method: 'POST',
125
- headers: { 'Content-Type': 'application/json' },
126
- body: JSON.stringify({ code, redirectUri }),
127
- });
128
-
129
- const data = await response.json();
130
-
131
- if (!response.ok || !data.success) {
132
- if (data.code === 'SubscriptionRequired') {
133
- throw new Error(
134
- 'Active LifeBrevity subscription required.\n' +
135
- 'Subscribe at: https://www.lifebrevity.com/subscribe'
136
- );
137
- }
138
- throw new Error(data.message || `Authentication failed (HTTP ${response.status})`);
139
- }
140
-
141
- return data;
142
- }
143
-
144
- function successPage(email) {
145
- return `<!DOCTYPE html>
146
- <html>
147
- <head><title>Login Successful</title></head>
148
- <body style="font-family: -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5;">
149
- <div style="text-align: center; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
150
- <h1 style="color: #2e7d32; margin-bottom: 8px;">Login Successful</h1>
151
- <p style="color: #666; font-size: 18px;">Signed in as <strong>${email}</strong></p>
152
- <p style="color: #999; margin-top: 20px;">You can close this window and return to your terminal.</p>
153
- </div>
154
- </body>
155
- </html>`;
156
- }
157
-
158
- function errorPage(message) {
159
- return `<!DOCTYPE html>
160
- <html>
161
- <head><title>Login Failed</title></head>
162
- <body style="font-family: -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5;">
163
- <div style="text-align: center; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
164
- <h1 style="color: #c62828;">Login Failed</h1>
165
- <p style="color: #666; font-size: 16px;">${message}</p>
166
- <p style="color: #999; margin-top: 20px;">Close this window and try <code>note login</code> again.</p>
167
- </div>
168
- </body>
169
- </html>`;
170
- }
1
+ import { createServer } from 'http';
2
+ import { randomBytes } from 'crypto';
3
+ import { URL } from 'url';
4
+ import open from 'open';
5
+ import { CONFIG } from '../config.mjs';
6
+ import { saveAuth } from './tokenStore.mjs';
7
+
8
+ const TIMEOUT_MS = 120_000; // 2 minutes
9
+
10
+ /**
11
+ * Browser-based Cognito login flow.
12
+ * Opens the Cognito Hosted UI, catches the callback on a local server,
13
+ * exchanges the auth code via LifeBrevity backend for a local JWT.
14
+ */
15
+ export async function cognitoLogin() {
16
+ const state = randomBytes(32).toString('hex');
17
+ const redirectUri = `http://localhost:${CONFIG.CALLBACK_PORT}${CONFIG.CALLBACK_PATH}`;
18
+
19
+ const authorizeUrl = new URL(`https://${CONFIG.COGNITO_DOMAIN}/oauth2/authorize`);
20
+ authorizeUrl.searchParams.set('response_type', 'code');
21
+ authorizeUrl.searchParams.set('client_id', CONFIG.COGNITO_CLIENT_ID);
22
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
23
+ authorizeUrl.searchParams.set('scope', CONFIG.COGNITO_SCOPES);
24
+ authorizeUrl.searchParams.set('state', state);
25
+
26
+ return new Promise((resolve, reject) => {
27
+ let timeout;
28
+
29
+ const server = createServer(async (req, res) => {
30
+ try {
31
+ const url = new URL(req.url, `http://localhost:${CONFIG.CALLBACK_PORT}`);
32
+
33
+ if (url.pathname !== CONFIG.CALLBACK_PATH) {
34
+ res.writeHead(404);
35
+ res.end('Not found');
36
+ return;
37
+ }
38
+
39
+ const code = url.searchParams.get('code');
40
+ const returnedState = url.searchParams.get('state');
41
+ const error = url.searchParams.get('error');
42
+
43
+ if (error) {
44
+ const errorDesc = url.searchParams.get('error_description') || error;
45
+ res.writeHead(200, { 'Content-Type': 'text/html' });
46
+ res.end(errorPage(errorDesc));
47
+ cleanup();
48
+ reject(new Error(`Login failed: ${errorDesc}`));
49
+ return;
50
+ }
51
+
52
+ if (returnedState !== state) {
53
+ res.writeHead(200, { 'Content-Type': 'text/html' });
54
+ res.end(errorPage('Security validation failed (state mismatch). Please try again.'));
55
+ cleanup();
56
+ reject(new Error('State mismatch - possible CSRF attack'));
57
+ return;
58
+ }
59
+
60
+ if (!code) {
61
+ res.writeHead(200, { 'Content-Type': 'text/html' });
62
+ res.end(errorPage('No authorization code received.'));
63
+ cleanup();
64
+ reject(new Error('No authorization code in callback'));
65
+ return;
66
+ }
67
+
68
+ // Exchange the code for a local JWT via LifeBrevity backend
69
+ const tokenResult = await exchangeCodeForToken(code, redirectUri);
70
+
71
+ // Save the token and API key (if returned by backend)
72
+ saveAuth({
73
+ token: tokenResult.token,
74
+ email: tokenResult.user.email,
75
+ apiKey: tokenResult.apiKeys?.notesBrevity,
76
+ });
77
+
78
+ res.writeHead(200, { 'Content-Type': 'text/html' });
79
+ res.end(successPage(tokenResult.user.email));
80
+ cleanup();
81
+ resolve(tokenResult);
82
+ } catch (err) {
83
+ res.writeHead(200, { 'Content-Type': 'text/html' });
84
+ res.end(errorPage(err.message));
85
+ cleanup();
86
+ reject(err);
87
+ }
88
+ });
89
+
90
+ function cleanup() {
91
+ clearTimeout(timeout);
92
+ server.close();
93
+ }
94
+
95
+ server.on('error', (err) => {
96
+ if (err.code === 'EADDRINUSE') {
97
+ reject(new Error(
98
+ `Port ${CONFIG.CALLBACK_PORT} is already in use. ` +
99
+ `Close the process using it and try again.`
100
+ ));
101
+ } else {
102
+ reject(err);
103
+ }
104
+ });
105
+
106
+ server.listen(CONFIG.CALLBACK_PORT, async () => {
107
+ // Open the browser
108
+ await open(authorizeUrl.toString());
109
+
110
+ timeout = setTimeout(() => {
111
+ cleanup();
112
+ reject(new Error('Login timed out after 2 minutes. Please try again.'));
113
+ }, TIMEOUT_MS);
114
+ });
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Exchange the Cognito authorization code for a local JWT
120
+ * via the LifeBrevity backend's /api/oidc/callback endpoint.
121
+ */
122
+ async function exchangeCodeForToken(code, redirectUri) {
123
+ const response = await fetch(`${CONFIG.LIFEBREVITY_BACKEND}/api/oidc/callback`, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify({ code, redirectUri }),
127
+ });
128
+
129
+ const data = await response.json();
130
+
131
+ if (!response.ok || !data.success) {
132
+ if (data.code === 'SubscriptionRequired') {
133
+ throw new Error(
134
+ 'Active LifeBrevity subscription required.\n' +
135
+ 'Subscribe at: https://www.lifebrevity.com/subscribe'
136
+ );
137
+ }
138
+ throw new Error(data.message || `Authentication failed (HTTP ${response.status})`);
139
+ }
140
+
141
+ return data;
142
+ }
143
+
144
+ const PAGE_STYLES = `
145
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
146
+ <style>
147
+ * { margin: 0; padding: 0; box-sizing: border-box; }
148
+ body {
149
+ font-family: 'Roboto', 'Segoe UI', -apple-system, sans-serif;
150
+ display: flex; justify-content: center; align-items: center;
151
+ height: 100vh; background: #f5f5f5;
152
+ }
153
+ .card {
154
+ text-align: center; padding: 48px 40px; background: white;
155
+ border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
156
+ max-width: 420px; width: 90%;
157
+ animation: fadeInUp 0.8s ease-out;
158
+ }
159
+ .subtitle { color: #999; font-size: 12px; font-weight: 300; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 24px; }
160
+ .divider { width: 40px; height: 2px; background: #2A6A88; margin: 0 auto 24px; }
161
+ .status { font-size: 22px; font-weight: 500; margin-bottom: 8px; }
162
+ .status.success { color: #2A6A88; }
163
+ .status.error { color: #FF5252; }
164
+ .detail { color: #666; font-size: 16px; margin-bottom: 4px; }
165
+ .hint { color: #999; font-size: 14px; margin-top: 20px; }
166
+ .hint code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
167
+ .countdown { color: #bbb; font-size: 12px; margin-top: 12px; }
168
+ @keyframes fadeInUp {
169
+ from { opacity: 0; transform: translateY(20px); }
170
+ to { opacity: 1; transform: translateY(0); }
171
+ }
172
+ </style>`;
173
+
174
+ function successPage(email) {
175
+ return `<!DOCTYPE html>
176
+ <html>
177
+ <head><title>LifeBrevity - Login Successful</title><link rel="icon" href="data:,">${PAGE_STYLES}</head>
178
+ <body>
179
+ <div class="card">
180
+ <div class="subtitle">note CLI</div>
181
+ <div class="divider"></div>
182
+ <div class="status success">Login Successful</div>
183
+ <p class="detail">Signed in as <strong>${email}</strong></p>
184
+ <p class="hint">You can close this window and return to your terminal.</p>
185
+ <p class="countdown" id="countdown">This window will close in 3 seconds...</p>
186
+ </div>
187
+ <script>
188
+ let s = 3;
189
+ const el = document.getElementById('countdown');
190
+ const t = setInterval(() => { if (--s <= 0) { clearInterval(t); window.close(); } el.textContent = 'This window will close in ' + s + ' seconds...'; }, 1000);
191
+ </script>
192
+ </body>
193
+ </html>`;
194
+ }
195
+
196
+ function errorPage(message) {
197
+ return `<!DOCTYPE html>
198
+ <html>
199
+ <head><title>LifeBrevity - Login Failed</title><link rel="icon" href="data:,">${PAGE_STYLES}</head>
200
+ <body>
201
+ <div class="card">
202
+ <div class="subtitle">note CLI</div>
203
+ <div class="divider"></div>
204
+ <div class="status error">Login Failed</div>
205
+ <p class="detail">${message}</p>
206
+ <p class="hint">Close this window and try <code>note login</code> again.</p>
207
+ </div>
208
+ </body>
209
+ </html>`;
210
+ }
@@ -0,0 +1,80 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve, basename, extname } from 'path';
6
+ import { apiRequest } from '../auth/apiClient.mjs';
7
+
8
+ const SUPPORTED_EXTENSIONS = ['.md', '.txt'];
9
+
10
+ export const importCommand = new Command('import')
11
+ .description('Import a markdown or text file as a journal entry')
12
+ .argument('<file>', 'Path to a .md or .txt file')
13
+ .option('-t, --title <title>', 'Entry title (defaults to filename)')
14
+ .option('-s, --summarize', 'Generate AI summary')
15
+ .option('--tags <tags>', 'Comma-separated tags (e.g. "work,ideas")')
16
+ .action(async (file, options) => {
17
+ const filePath = resolve(file);
18
+ const ext = extname(filePath).toLowerCase();
19
+
20
+ if (!existsSync(filePath)) {
21
+ console.error(chalk.red(`File not found: ${filePath}`));
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
26
+ console.error(chalk.red(`Unsupported file type "${ext}". Use .md or .txt files.`));
27
+ process.exit(1);
28
+ }
29
+
30
+ let body;
31
+ try {
32
+ body = readFileSync(filePath, 'utf8');
33
+ } catch (err) {
34
+ console.error(chalk.red(`Could not read file: ${err.message}`));
35
+ process.exit(1);
36
+ }
37
+
38
+ if (!body.trim()) {
39
+ console.error(chalk.red('File is empty.'));
40
+ process.exit(1);
41
+ }
42
+
43
+ const title = options.title || basename(filePath, ext);
44
+
45
+ const spinner = ora('Importing entry...').start();
46
+
47
+ try {
48
+ const payload = {
49
+ title,
50
+ body,
51
+ generateSummary: !!options.summarize,
52
+ };
53
+
54
+ if (options.tags) {
55
+ payload.tags = options.tags.split(',').map(t => t.trim());
56
+ }
57
+
58
+ const data = await apiRequest('POST', '/entries', payload);
59
+ const entry = data.entry;
60
+
61
+ spinner.succeed(chalk.green('Entry imported!'));
62
+ console.log('');
63
+ console.log(` ${chalk.dim('ID:')} ${entry.entryId}`);
64
+ console.log(` ${chalk.dim('Title:')} ${entry.title}`);
65
+ console.log(` ${chalk.dim('Created:')} ${new Date(entry.createdAt).toLocaleString()}`);
66
+
67
+ if (entry.tags?.length > 0) {
68
+ console.log(` ${chalk.dim('Tags:')} ${entry.tags.join(', ')}`);
69
+ }
70
+
71
+ if (entry.summary) {
72
+ console.log('');
73
+ console.log(` ${chalk.dim('Summary:')}`);
74
+ console.log(` ${entry.summary}`);
75
+ }
76
+ } catch (err) {
77
+ spinner.fail(chalk.red(err.message));
78
+ process.exit(1);
79
+ }
80
+ });
@@ -32,7 +32,7 @@ export const listCommand = new Command('list')
32
32
  for (const entry of data.entries) {
33
33
  const date = new Date(entry.createdAt).toLocaleDateString();
34
34
  const tags = entry.tags?.length ? chalk.dim(` [${entry.tags.join(', ')}]`) : '';
35
- console.log(` ${chalk.cyan(entry.entryId.slice(0, 8))} ${date} ${chalk.bold(entry.title)}${tags}`);
35
+ console.log(` ${chalk.cyan(entry.entryId)} ${date} ${chalk.bold(entry.title)}${tags}`);
36
36
  if (entry.summary) {
37
37
  console.log(` ${chalk.dim(entry.summary.slice(0, 80))}${entry.summary.length > 80 ? '...' : ''}`);
38
38
  }