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 +52 -0
- package/bin/notd.js +29 -0
- package/package.json +38 -0
- package/src/commands/delete.js +70 -0
- package/src/commands/edit.js +71 -0
- package/src/commands/list.js +30 -0
- package/src/commands/new.js +54 -0
- package/src/utils/files.js +114 -0
- package/src/utils/format.js +12 -0
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 };
|