notd-cli 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 ADDED
@@ -0,0 +1,52 @@
1
+ # notd
2
+
3
+ Lightweight notes for your repo. Leave a thought, not a document.
4
+
5
+ `notd` stores notes as Markdown files in a `.notd/` folder at the root of your repo. Notes are committed to git alongside your code — no external services, no databases, just files.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g notd-cli
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ notd new # create a new note
17
+ notd list # list all notes
18
+ notd edit [id] # edit a note's tags
19
+ notd delete [id] # delete a note
20
+ ```
21
+
22
+ If you omit the `[id]`, you'll be shown a list to pick from.
23
+
24
+ ## Note format
25
+
26
+ Notes are stored in `.notd/` as Markdown files named after their first line:
27
+
28
+ ```
29
+ .notd/
30
+ └── fix-the-auth-bug.md
31
+ ```
32
+
33
+ Each file includes frontmatter with timestamps and tags:
34
+
35
+ ```markdown
36
+ ---
37
+ created: 2025-02-24T10:30:00.000Z
38
+ updated: 2025-02-24T10:30:00.000Z
39
+ tags:
40
+ - auth
41
+ - api
42
+ ---
43
+
44
+ Fix the auth bug
45
+
46
+ The token expiry check is using the wrong timezone. Need to normalise
47
+ to UTC before comparing. See the discussion in PR #42.
48
+ ```
49
+
50
+ ## Requirements
51
+
52
+ Node.js v18 or later.
package/bin/notd.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // This shebang line tells the OS to run this file with Node.js.
3
+ // It's required for any npm CLI tool so the command works without typing "node" first.
4
+
5
+ const command = process.argv[2];
6
+
7
+ switch (command) {
8
+ case 'new':
9
+ require('../src/commands/new');
10
+ break;
11
+ case 'list':
12
+ require('../src/commands/list');
13
+ break;
14
+ case 'edit':
15
+ require('../src/commands/edit');
16
+ break;
17
+ case 'delete':
18
+ require('../src/commands/delete');
19
+ break;
20
+ default:
21
+ console.log('Usage: notd <command>');
22
+ console.log('');
23
+ console.log('Commands:');
24
+ console.log(' new Create a new note');
25
+ console.log(' list List all notes');
26
+ console.log(' edit Edit a note\'s tags');
27
+ console.log(' delete Delete a note');
28
+ process.exit(1);
29
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "notd-cli",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight notes for your repo. Leave a thought, not a document.",
5
+ "keywords": [
6
+ "notes",
7
+ "repo",
8
+ "cli",
9
+ "markdown",
10
+ "developer-notes",
11
+ "documentation"
12
+ ],
13
+ "homepage": "https://github.com/niccoates/notd#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/niccoates/notd/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/niccoates/notd.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Nic Coates",
23
+ "type": "commonjs",
24
+ "main": "index.js",
25
+ "bin": {
26
+ "notd": "bin/notd.js"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "src/"
31
+ ],
32
+ "scripts": {
33
+ "test": "echo \"Error: no test specified\" && exit 1"
34
+ },
35
+ "dependencies": {
36
+ "gray-matter": "^4.0.3"
37
+ }
38
+ }
@@ -0,0 +1,70 @@
1
+ const fs = require('fs');
2
+ const readline = require('readline');
3
+ const { readNotes, deleteNote } = require('../utils/files');
4
+ const { formatDate } = require('../utils/format');
5
+
6
+ function askViaTTY(question) {
7
+ return new Promise(resolve => {
8
+ const ttyInput = fs.createReadStream('/dev/tty');
9
+ const rl = readline.createInterface({
10
+ input: ttyInput,
11
+ output: process.stdout,
12
+ });
13
+ rl.question(question, answer => {
14
+ rl.close();
15
+ ttyInput.destroy();
16
+ resolve(answer);
17
+ });
18
+ });
19
+ }
20
+
21
+ function firstLine(content) {
22
+ const line = content.split('\n').find(l => l.trim() !== '') || '';
23
+ return line.replace(/^#+\s*/, '').trim();
24
+ }
25
+
26
+ async function run() {
27
+ const notes = readNotes();
28
+
29
+ if (notes.length === 0) {
30
+ console.log('No notes yet. Run `notd new` to create one.');
31
+ process.exit(0);
32
+ }
33
+
34
+ notes.sort((a, b) => new Date(b.created) - new Date(a.created));
35
+
36
+ let note;
37
+ const idArg = process.argv[3];
38
+
39
+ if (idArg) {
40
+ const index = parseInt(idArg, 10) - 1;
41
+ note = notes[index];
42
+ if (!note) {
43
+ console.log(`No note at index ${idArg}. Run \`notd list\` to see available notes.`);
44
+ process.exit(1);
45
+ }
46
+ } else {
47
+ notes.forEach((n, i) => {
48
+ console.log(`${String(i + 1).padStart(2)} ${formatDate(n.created)} ${firstLine(n.content)}`);
49
+ });
50
+ const choice = await askViaTTY('\nWhich note to delete? Enter a number: ');
51
+ const index = parseInt(choice, 10) - 1;
52
+ note = notes[index];
53
+ if (!note) {
54
+ console.log('Invalid selection.');
55
+ process.exit(1);
56
+ }
57
+ }
58
+
59
+ const confirm = await askViaTTY(`Delete "${firstLine(note.content)}"? (y/n): `);
60
+
61
+ if (confirm.trim().toLowerCase() !== 'y') {
62
+ console.log('Cancelled.');
63
+ process.exit(0);
64
+ }
65
+
66
+ deleteNote(note.filepath);
67
+ console.log('Note deleted.');
68
+ }
69
+
70
+ run();
@@ -0,0 +1,71 @@
1
+ const fs = require('fs');
2
+ const readline = require('readline');
3
+ const { readNotes, updateNote } = require('../utils/files');
4
+ const { formatDate } = require('../utils/format');
5
+
6
+ function askViaTTY(question) {
7
+ return new Promise(resolve => {
8
+ const ttyInput = fs.createReadStream('/dev/tty');
9
+ const rl = readline.createInterface({
10
+ input: ttyInput,
11
+ output: process.stdout,
12
+ });
13
+ rl.question(question, answer => {
14
+ rl.close();
15
+ ttyInput.destroy(); // close the stream so the process can exit cleanly
16
+ resolve(answer);
17
+ });
18
+ });
19
+ }
20
+
21
+ function firstLine(content) {
22
+ const line = content.split('\n').find(l => l.trim() !== '') || '';
23
+ return line.replace(/^#+\s*/, '').trim();
24
+ }
25
+
26
+ async function run() {
27
+ const notes = readNotes();
28
+
29
+ if (notes.length === 0) {
30
+ console.log('No notes yet. Run `notd new` to create one.');
31
+ process.exit(0);
32
+ }
33
+
34
+ notes.sort((a, b) => new Date(b.created) - new Date(a.created));
35
+
36
+ let note;
37
+ const idArg = process.argv[3];
38
+
39
+ if (idArg) {
40
+ const index = parseInt(idArg, 10) - 1;
41
+ note = notes[index];
42
+ if (!note) {
43
+ console.log(`No note at index ${idArg}. Run \`notd list\` to see available notes.`);
44
+ process.exit(1);
45
+ }
46
+ } else {
47
+ notes.forEach((n, i) => {
48
+ console.log(`${String(i + 1).padStart(2)} ${formatDate(n.created)} ${firstLine(n.content)}`);
49
+ });
50
+ const choice = await askViaTTY('\nWhich note to edit? Enter a number: ');
51
+ const index = parseInt(choice, 10) - 1;
52
+ note = notes[index];
53
+ if (!note) {
54
+ console.log('Invalid selection.');
55
+ process.exit(1);
56
+ }
57
+ }
58
+
59
+ const currentTags = note.tags && note.tags.length > 0 ? note.tags.join(', ') : 'none';
60
+ const tagsInput = await askViaTTY(`Tags (current: ${currentTags}, Enter to keep): `);
61
+
62
+ // Empty input = keep existing tags; otherwise parse the new value
63
+ const tags = tagsInput.trim() === ''
64
+ ? (note.tags || [])
65
+ : tagsInput.split(',').map(t => t.trim()).filter(Boolean);
66
+
67
+ updateNote(note.filepath, note.content, tags);
68
+ console.log('Note updated.');
69
+ }
70
+
71
+ run();
@@ -0,0 +1,30 @@
1
+ const { readNotes } = require('../utils/files');
2
+ const { formatDate } = require('../utils/format');
3
+
4
+ const notes = readNotes();
5
+
6
+ if (notes.length === 0) {
7
+ console.log('No notes yet. Run `notd new` to create one.');
8
+ process.exit(0);
9
+ }
10
+
11
+ // Sort newest first using the created timestamp in frontmatter
12
+ notes.sort((a, b) => new Date(b.created) - new Date(a.created));
13
+
14
+ // Find the longest first line so we can pad everything to align nicely
15
+ const maxTitleLength = Math.max(...notes.map(n => firstLine(n.content).length));
16
+
17
+ notes.forEach((note, i) => {
18
+ const index = String(i + 1).padStart(2);
19
+ const date = formatDate(note.created);
20
+ const title = firstLine(note.content).padEnd(maxTitleLength);
21
+ const tags = note.tags && note.tags.length > 0 ? `[${note.tags.join(', ')}]` : '';
22
+
23
+ console.log(`${index} ${date} ${title} ${tags}`);
24
+ });
25
+
26
+ // Returns the first non-empty line of a note, stripping any markdown heading markers
27
+ function firstLine(content) {
28
+ const line = content.split('\n').find(l => l.trim() !== '') || '';
29
+ return line.replace(/^#+\s*/, '').trim();
30
+ }
@@ -0,0 +1,54 @@
1
+ const fs = require('fs');
2
+ const readline = require('readline');
3
+ const { writeNote } = require('../utils/files');
4
+
5
+ // Read raw stdin until Ctrl+D (EOF) — no readline, no prompt characters bleeding in
6
+ function collectMultilineInput(prompt) {
7
+ return new Promise(resolve => {
8
+ console.log(prompt);
9
+ let data = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', chunk => { data += chunk; });
12
+ process.stdin.on('end', () => resolve(data));
13
+ });
14
+ }
15
+
16
+ // After stdin closes with Ctrl+D, we can't reuse it — so open /dev/tty directly.
17
+ // /dev/tty is the controlling terminal device and works even after stdin has ended.
18
+ function askViaTTY(question) {
19
+ return new Promise(resolve => {
20
+ const ttyInput = fs.createReadStream('/dev/tty');
21
+ const rl = readline.createInterface({
22
+ input: ttyInput,
23
+ output: process.stdout,
24
+ });
25
+ rl.question(question, answer => {
26
+ rl.close();
27
+ ttyInput.destroy(); // close the stream so the process can exit cleanly
28
+ resolve(answer);
29
+ });
30
+ });
31
+ }
32
+
33
+ async function run() {
34
+ const content = await collectMultilineInput(
35
+ 'Enter your note (press Ctrl+D on a new line when done):\n'
36
+ );
37
+
38
+ if (!content.trim()) {
39
+ console.log('No content entered. Note not saved.');
40
+ process.exit(0);
41
+ }
42
+
43
+ const tagsInput = await askViaTTY('\nTags (comma-separated, or press Enter to skip): ');
44
+
45
+ const tags = tagsInput
46
+ .split(',')
47
+ .map(t => t.trim())
48
+ .filter(Boolean);
49
+
50
+ const filepath = writeNote(content, tags);
51
+ console.log(`\nNote saved: ${filepath}`);
52
+ }
53
+
54
+ run();
@@ -0,0 +1,114 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const matter = require('gray-matter');
4
+
5
+ // Walk up from the current directory to find the nearest .git folder.
6
+ // This lets `notd` work from any subdirectory within a repo.
7
+ function findRepoRoot() {
8
+ let dir = process.cwd();
9
+ while (true) {
10
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
11
+ const parent = path.dirname(dir);
12
+ // If we've reached the filesystem root without finding .git, give up and use cwd
13
+ if (parent === dir) return process.cwd();
14
+ dir = parent;
15
+ }
16
+ }
17
+
18
+ // Returns the path to the .notd/ directory, creating it if it doesn't exist
19
+ function getNotdDir() {
20
+ const root = findRepoRoot();
21
+ const notdDir = path.join(root, '.notd');
22
+ if (!fs.existsSync(notdDir)) {
23
+ fs.mkdirSync(notdDir);
24
+ }
25
+ return notdDir;
26
+ }
27
+
28
+ // Turns the first line of a note into a filename-safe slug
29
+ // e.g. "Fix the auth bug!" → "fix-the-auth-bug.md"
30
+ function slugify(text) {
31
+ return text
32
+ .toLowerCase()
33
+ .trim()
34
+ .replace(/[^a-z0-9\s-]/g, '') // strip anything that's not a letter, number, space, or dash
35
+ .replace(/\s+/g, '-') // spaces become dashes
36
+ .replace(/-+/g, '-') // collapse multiple dashes
37
+ .slice(0, 60); // cap length so filenames stay readable
38
+ }
39
+
40
+ // Writes a new note file and returns the full path
41
+ function writeNote(content, tags) {
42
+ const notdDir = getNotdDir();
43
+ const now = new Date().toISOString();
44
+
45
+ // Use the first line of the note as the filename
46
+ const firstLine = content.trim().split('\n')[0];
47
+ const filename = `${slugify(firstLine)}.md`;
48
+ const filepath = path.join(notdDir, filename);
49
+
50
+ // gray-matter's stringify builds the ---frontmatter--- block for us
51
+ const fileContent = matter.stringify(content.trim(), {
52
+ created: now,
53
+ updated: now,
54
+ tags: tags,
55
+ });
56
+
57
+ fs.writeFileSync(filepath, fileContent, 'utf8');
58
+ return filepath;
59
+ }
60
+
61
+ // Reads all notes from .notd/, returns them as an array of parsed objects
62
+ function readNotes() {
63
+ const notdDir = getNotdDir();
64
+ const files = fs.readdirSync(notdDir).filter(f => f.endsWith('.md'));
65
+
66
+ return files.map(filename => {
67
+ const filepath = path.join(notdDir, filename);
68
+ const raw = fs.readFileSync(filepath, 'utf8');
69
+ const parsed = matter(raw);
70
+ return {
71
+ filename,
72
+ filepath,
73
+ // The frontmatter fields (created, updated, tags)
74
+ ...parsed.data,
75
+ // The note body (everything after the ---)
76
+ content: parsed.content.trim(),
77
+ };
78
+ });
79
+ }
80
+
81
+ // Updates an existing note, preserving its created timestamp.
82
+ // If the first line changed, the file is renamed to match the new slug.
83
+ function updateNote(filepath, content, tags) {
84
+ const notdDir = getNotdDir();
85
+
86
+ // Read the existing file so we can preserve the original created date
87
+ const raw = fs.readFileSync(filepath, 'utf8');
88
+ const parsed = matter(raw);
89
+
90
+ const fileContent = matter.stringify(content.trim(), {
91
+ created: parsed.data.created,
92
+ updated: new Date().toISOString(),
93
+ tags,
94
+ });
95
+
96
+ // Recalculate the slug in case the first line changed
97
+ const firstLine = content.trim().split('\n')[0];
98
+ const newFilepath = path.join(notdDir, `${slugify(firstLine)}.md`);
99
+
100
+ fs.writeFileSync(newFilepath, fileContent, 'utf8');
101
+
102
+ // Remove the old file if it was renamed
103
+ if (newFilepath !== filepath) {
104
+ fs.unlinkSync(filepath);
105
+ }
106
+
107
+ return newFilepath;
108
+ }
109
+
110
+ function deleteNote(filepath) {
111
+ fs.unlinkSync(filepath);
112
+ }
113
+
114
+ module.exports = { writeNote, updateNote, deleteNote, readNotes, getNotdDir };
@@ -0,0 +1,12 @@
1
+ // Formats an ISO date string (e.g. "2025-02-24T10:30:00.000Z") into a short readable date
2
+ // We only need the date portion for list display — the time isn't useful there
3
+ function formatDate(isoString) {
4
+ const date = new Date(isoString);
5
+ return date.toLocaleDateString('en-GB', {
6
+ day: '2-digit',
7
+ month: 'short',
8
+ year: 'numeric',
9
+ });
10
+ }
11
+
12
+ module.exports = { formatDate };