@jlmarcos99/todos 0.0.0-stage → 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 +139 -2
- package/dist/app/src/app.js +39 -0
- package/dist/app/src/application/operation-queue.js +9 -0
- package/dist/app/src/application/task-service.js +172 -0
- package/dist/app/src/cli/format.js +6 -0
- package/dist/app/src/cli/prompts.js +15 -0
- package/dist/app/src/cli.js +174 -0
- package/dist/app/src/config/config.js +25 -0
- package/dist/app/src/config/paths.js +95 -0
- package/dist/app/src/model/activity.js +1 -0
- package/dist/app/src/model/daily.js +34 -0
- package/dist/app/src/model/task.js +1 -0
- package/dist/app/src/model/vault.js +1 -0
- package/dist/app/src/storage/atomic-file.js +15 -0
- package/dist/app/src/storage/record-codec.js +57 -0
- package/dist/app/src/storage/store.js +187 -0
- package/dist/app/src/sync/git-client.js +50 -0
- package/dist/app/src/sync/git-repository.js +291 -0
- package/dist/app/src/sync/sync-service.js +97 -0
- package/dist/app/src/tui/demo.js +93 -0
- package/dist/app/src/tui/screen.js +325 -0
- package/dist/app/src/tui/session.js +167 -0
- package/dist/app/src/tui/theme.js +17 -0
- package/dist/app/src/tui/view.js +279 -0
- package/dist/index.js +3 -0
- package/package.json +55 -4
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Todos
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A keyboard-first task manager and daily work log for the terminal, built with
|
|
4
|
+
TypeScript and [@earendil-works/pi-tui](https://github.com/earendil-works/pi/tree/main/packages/tui).
|
|
5
|
+
Task data lives in a global per-user folder and can optionally sync to a Git remote.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 22.19 or newer
|
|
10
|
+
- Git installed and configured only when you choose remote synchronization
|
|
11
|
+
- An interactive terminal for the TUI
|
|
12
|
+
|
|
13
|
+
## Install and initialize
|
|
14
|
+
|
|
15
|
+
Install the published CLI globally (Node.js 22.19 or newer):
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install --global @jlmarcos99/todos
|
|
19
|
+
todos
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The first launch creates a local vault automatically. Use `todos init` when you
|
|
23
|
+
want to choose a data folder or configure a Git remote.
|
|
24
|
+
|
|
25
|
+
To run from a checkout, install dependencies and compile the CLI:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
npm link
|
|
31
|
+
todos
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
On first launch, `todos` creates a global data folder with a starter vault and
|
|
35
|
+
opens the TUI in local-only mode. `todos init` is optional; run it when you want
|
|
36
|
+
to choose a data folder or link a remote during setup. You can also link a
|
|
37
|
+
remote later with `todos link <url>`. Use SSH authentication or a Git credential
|
|
38
|
+
helper; URLs containing embedded credentials are rejected.
|
|
39
|
+
|
|
40
|
+
To choose a custom folder:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
todos init --data-dir /path/to/todos
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Without the global `todos` command, use `npm run init` to configure the store
|
|
47
|
+
from this checkout.
|
|
48
|
+
|
|
49
|
+
The folder can also be selected with the `TODOS_DATA_DIR` environment variable.
|
|
50
|
+
The initialized path is saved in the user's configuration directory and is used
|
|
51
|
+
regardless of the current working directory.
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
todos Create the first vault if needed, then open the TUI
|
|
57
|
+
todos init [--local|--remote URL] Configure the global store
|
|
58
|
+
todos link <url> Link or synchronize a Git remote
|
|
59
|
+
todos unlink [--yes] Remove the remote, keeping local history
|
|
60
|
+
todos status Show the data path, vault count and remote
|
|
61
|
+
todos vault create <name> [about] Create an additional vault
|
|
62
|
+
todos --help Show command help
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--data-dir <path>` can also be used with `todos` to open a specific store.
|
|
66
|
+
The default data paths are:
|
|
67
|
+
|
|
68
|
+
- Linux: `$XDG_DATA_HOME/jmtodos` or `~/.local/share/jmtodos`
|
|
69
|
+
- macOS: `~/Library/Application Support/JMTodos`
|
|
70
|
+
- Windows: `%LOCALAPPDATA%/JMTodos`
|
|
71
|
+
|
|
72
|
+
Configuration is kept separately using each platform's config directory.
|
|
73
|
+
|
|
74
|
+
## TUI workflow
|
|
75
|
+
|
|
76
|
+
- **Tasks** shows open tasks and tasks completed within the last 24 hours.
|
|
77
|
+
- **Daily** shows tasks with activity on the selected local date and the previous
|
|
78
|
+
date, with progress notes directly below each task.
|
|
79
|
+
- **History** shows tasks completed more than 24 hours ago.
|
|
80
|
+
|
|
81
|
+
Press `a` to add a task, `s` to start it, `n` to add a progress note, and Space
|
|
82
|
+
to complete or reopen it. Press `d` twice to delete the selected task and its
|
|
83
|
+
activity. Press `V` to create another vault. `Tab` switches
|
|
84
|
+
vaults. Press `r` to synchronize; in local-only mode it prompts for a Git remote
|
|
85
|
+
URL and links the current vault store. Changes are saved locally before Git is
|
|
86
|
+
contacted; an offline remote leaves them saved and marked pending.
|
|
87
|
+
|
|
88
|
+
| Key | Action |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `j` / `k` or ↓ / ↑ | Move selection; scroll in activity |
|
|
91
|
+
| `gg` / `G` | First / last task or activity line |
|
|
92
|
+
| Tab / Shift+Tab | Next / previous vault |
|
|
93
|
+
| `h` / `l` or ← / → | Previous / next view |
|
|
94
|
+
| `1` / `2` / `3` | Tasks / Daily / History |
|
|
95
|
+
| Space | Complete / reopen selected task |
|
|
96
|
+
| `s` | Start working on selected task |
|
|
97
|
+
| `n` | Add a progress note |
|
|
98
|
+
| Enter | Read task activity |
|
|
99
|
+
| `[` / `]` | Previous / next Daily window |
|
|
100
|
+
| `t` | Today's Daily |
|
|
101
|
+
| `d` `d` | Delete selected task; the second `d` confirms |
|
|
102
|
+
| `a` / `V` | Add task / create vault |
|
|
103
|
+
| `/` | Search the current view |
|
|
104
|
+
| `r` | Sync, or link a remote in local-only mode |
|
|
105
|
+
| Escape / `?` | Cancel, clear search / help |
|
|
106
|
+
| `q` / Ctrl+C | Quit |
|
|
107
|
+
|
|
108
|
+
Notes and status changes are timestamped activity records. Daily groups them by
|
|
109
|
+
the user's local calendar date; historical entries are not rewritten by later
|
|
110
|
+
task changes.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npm run dev # Build and run the compiled app
|
|
116
|
+
npm run typecheck # TypeScript checks
|
|
117
|
+
npm run build # Compile the CLI into dist/
|
|
118
|
+
npm test # Build, then run tests from app/tests
|
|
119
|
+
npm run start:prod # Run the compiled CLI
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Releases
|
|
123
|
+
|
|
124
|
+
Run `npm run changeset` to add a Changeset for each user-facing change.
|
|
125
|
+
Changesets opens a version pull
|
|
126
|
+
request against `master`; merging that version pull request publishes the
|
|
127
|
+
package through GitHub Actions. The release workflow publishes through the
|
|
128
|
+
`NPM_TOKEN` GitHub Actions secret, following the setup used by BareWT. Create a
|
|
129
|
+
granular npm access token with package write access and **Bypass 2FA** enabled,
|
|
130
|
+
then add it in the `todos` repository under Settings → Secrets and variables →
|
|
131
|
+
Actions as `NPM_TOKEN`. The package publishes with npm provenance. In GitHub,
|
|
132
|
+
also allow Actions to create pull requests in Settings → Actions → General so
|
|
133
|
+
Changesets can open version pull requests. Merging a version pull request to
|
|
134
|
+
`master` publishes the package; the included initial patch Changeset prepares
|
|
135
|
+
version `1.0.1`.
|
|
136
|
+
|
|
137
|
+
Source is organized under `app/src`: `config` resolves user paths, `storage`
|
|
138
|
+
owns JSON records and atomic writes, `application` owns task operations,
|
|
139
|
+
`sync` wraps system Git, `cli` handles commands, and `tui` handles terminal input
|
|
140
|
+
and rendering. Existing UI tests are kept under `app/tests`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ProcessTerminal, TUI } from '@earendil-works/pi-tui';
|
|
2
|
+
import { TodoScreen } from "./tui/screen.js";
|
|
3
|
+
export const App = {
|
|
4
|
+
async init(options) {
|
|
5
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
6
|
+
console.error('Open Todos in an interactive terminal: npm start');
|
|
7
|
+
process.exitCode = 1;
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const terminal = new ProcessTerminal();
|
|
11
|
+
const tui = new TUI(terminal);
|
|
12
|
+
let stopped = false;
|
|
13
|
+
let timer;
|
|
14
|
+
const quit = () => {
|
|
15
|
+
if (stopped)
|
|
16
|
+
return;
|
|
17
|
+
stopped = true;
|
|
18
|
+
clearInterval(timer);
|
|
19
|
+
screen?.dispose();
|
|
20
|
+
tui.stop();
|
|
21
|
+
terminal.write('\x1b[?1049l');
|
|
22
|
+
process.off('SIGINT', quit);
|
|
23
|
+
process.off('SIGTERM', quit);
|
|
24
|
+
};
|
|
25
|
+
const screen = new TodoScreen({
|
|
26
|
+
now: () => new Date(), rows: () => terminal.rows,
|
|
27
|
+
redraw: () => tui.requestRender(), quit,
|
|
28
|
+
vaults: options.vaults, tasks: options.tasks, sync: options.sync, refresh: options.refresh,
|
|
29
|
+
});
|
|
30
|
+
terminal.write('\x1b[?1049h\x1b[H');
|
|
31
|
+
tui.addChild(screen);
|
|
32
|
+
tui.setFocus(screen);
|
|
33
|
+
process.on('SIGINT', quit);
|
|
34
|
+
process.on('SIGTERM', quit);
|
|
35
|
+
tui.start();
|
|
36
|
+
// Re-evaluate the 24-hour boundary even while the keyboard is idle.
|
|
37
|
+
timer = setInterval(() => tui.requestRender(), 1_000);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Serializes refresh and mutation work within this process. */
|
|
2
|
+
export class OperationQueue {
|
|
3
|
+
tail = Promise.resolve();
|
|
4
|
+
run(operation) {
|
|
5
|
+
const result = this.tail.then(operation, operation);
|
|
6
|
+
this.tail = result.catch(() => undefined);
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/** Owns task mutations and persists each record before notifying sync. */
|
|
3
|
+
export class TaskService {
|
|
4
|
+
store;
|
|
5
|
+
onChange;
|
|
6
|
+
transaction;
|
|
7
|
+
vaults = [];
|
|
8
|
+
onReload;
|
|
9
|
+
constructor(store, onChange = () => undefined, transaction) {
|
|
10
|
+
this.store = store;
|
|
11
|
+
this.onChange = onChange;
|
|
12
|
+
this.transaction = transaction;
|
|
13
|
+
}
|
|
14
|
+
setVaults(vaults) { this.vaults = vaults; }
|
|
15
|
+
setOnReload(callback) { this.onReload = callback; }
|
|
16
|
+
async createVault(name, description = '') {
|
|
17
|
+
if (!this.store)
|
|
18
|
+
throw new Error('Vault creation needs a persistent store.');
|
|
19
|
+
return this.runChange(`Created vault: ${name}`, () => this.store.createVault(name, description));
|
|
20
|
+
}
|
|
21
|
+
async addTask(vault, name, date) {
|
|
22
|
+
const cleanName = name.trim();
|
|
23
|
+
if (!cleanName)
|
|
24
|
+
return undefined;
|
|
25
|
+
const task = { id: randomUUID(), name: cleanName, status: 'todo', date };
|
|
26
|
+
await this.runChange(`Added task: ${cleanName}`, async () => {
|
|
27
|
+
const currentVault = this.resolveVault(vault);
|
|
28
|
+
currentVault.tasks.push(task);
|
|
29
|
+
try {
|
|
30
|
+
if (this.store)
|
|
31
|
+
await this.store.saveTask(currentVault.id, task);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
currentVault.tasks.splice(currentVault.tasks.indexOf(task), 1);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
async startTask(vault, task, date) {
|
|
41
|
+
await this.runChange(`Started task: ${task.name}`, async () => {
|
|
42
|
+
const currentVault = this.resolveVault(vault);
|
|
43
|
+
const currentTask = this.resolveTask(currentVault, task);
|
|
44
|
+
if (currentTask.status !== 'in-progress')
|
|
45
|
+
await this.applyStatusChange(currentVault, currentTask, 'in-progress', 'started', date);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async toggleTask(vault, task, date) {
|
|
49
|
+
await this.runChange(`Updated task: ${task.name}`, async () => {
|
|
50
|
+
const currentVault = this.resolveVault(vault);
|
|
51
|
+
const currentTask = this.resolveTask(currentVault, task);
|
|
52
|
+
if (currentTask.status === 'done')
|
|
53
|
+
await this.applyStatusChange(currentVault, currentTask, 'todo', 'reopened', date);
|
|
54
|
+
else
|
|
55
|
+
await this.applyStatusChange(currentVault, currentTask, 'done', 'completed', date);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async deleteTask(vault, task) {
|
|
59
|
+
await this.runChange(`Deleted task: ${task.name}`, async () => {
|
|
60
|
+
const currentVault = this.resolveVault(vault);
|
|
61
|
+
const currentTask = this.resolveTask(currentVault, task);
|
|
62
|
+
const related = currentVault.activity.filter(item => item.taskId === currentTask.id);
|
|
63
|
+
const deletedActivity = [];
|
|
64
|
+
try {
|
|
65
|
+
if (this.store) {
|
|
66
|
+
for (const item of related) {
|
|
67
|
+
await this.store.deleteActivity(currentVault.id, item.id);
|
|
68
|
+
deletedActivity.push(item);
|
|
69
|
+
}
|
|
70
|
+
await this.store.deleteTask(currentVault.id, currentTask.id);
|
|
71
|
+
}
|
|
72
|
+
currentVault.tasks = currentVault.tasks.filter(item => item.id !== currentTask.id);
|
|
73
|
+
currentVault.activity = currentVault.activity.filter(item => item.taskId !== currentTask.id);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (this.store) {
|
|
77
|
+
for (const item of deletedActivity)
|
|
78
|
+
await this.store.saveActivity(currentVault.id, item).catch(() => undefined);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async addProgressNote(vault, task, note, date) {
|
|
85
|
+
const cleanNote = note.trim();
|
|
86
|
+
if (!cleanNote)
|
|
87
|
+
return;
|
|
88
|
+
await this.runChange(`Added progress note to: ${task.name}`, async () => {
|
|
89
|
+
const currentVault = this.resolveVault(vault);
|
|
90
|
+
const currentTask = this.resolveTask(currentVault, task);
|
|
91
|
+
const oldStatus = currentTask.status;
|
|
92
|
+
if (currentTask.status === 'todo')
|
|
93
|
+
currentTask.status = 'in-progress';
|
|
94
|
+
const activity = {
|
|
95
|
+
id: randomUUID(), taskId: currentTask.id, taskName: currentTask.name, date,
|
|
96
|
+
kind: 'note', status: currentTask.status, note: cleanNote,
|
|
97
|
+
};
|
|
98
|
+
let taskWasSaved = false;
|
|
99
|
+
try {
|
|
100
|
+
if (this.store) {
|
|
101
|
+
await this.store.saveTask(currentVault.id, currentTask);
|
|
102
|
+
taskWasSaved = true;
|
|
103
|
+
await this.store.saveActivity(currentVault.id, activity);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
currentTask.status = oldStatus;
|
|
108
|
+
if (taskWasSaved && this.store)
|
|
109
|
+
await this.store.saveTask(currentVault.id, currentTask).catch(() => undefined);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
currentVault.activity.push(activity);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async applyStatusChange(vault, task, status, kind, date) {
|
|
116
|
+
const previousStatus = task.status;
|
|
117
|
+
const previousCompletedAt = task.completedAt;
|
|
118
|
+
const activity = { id: randomUUID(), taskId: task.id, taskName: task.name, date, kind, status };
|
|
119
|
+
task.status = status;
|
|
120
|
+
if (status === 'done')
|
|
121
|
+
task.completedAt = date;
|
|
122
|
+
else
|
|
123
|
+
delete task.completedAt;
|
|
124
|
+
let taskWasSaved = false;
|
|
125
|
+
try {
|
|
126
|
+
if (this.store) {
|
|
127
|
+
await this.store.saveTask(vault.id, task);
|
|
128
|
+
taskWasSaved = true;
|
|
129
|
+
await this.store.saveActivity(vault.id, activity);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
task.status = previousStatus;
|
|
134
|
+
if (previousCompletedAt)
|
|
135
|
+
task.completedAt = previousCompletedAt;
|
|
136
|
+
else
|
|
137
|
+
delete task.completedAt;
|
|
138
|
+
if (taskWasSaved && this.store)
|
|
139
|
+
await this.store.saveTask(vault.id, task).catch(() => undefined);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
vault.activity.push(activity);
|
|
143
|
+
}
|
|
144
|
+
async runChange(description, operation) {
|
|
145
|
+
if (this.transaction) {
|
|
146
|
+
return this.transaction(description, operation, async () => {
|
|
147
|
+
if (!this.store)
|
|
148
|
+
return;
|
|
149
|
+
const vaults = await this.store.loadVaults();
|
|
150
|
+
if (this.onReload)
|
|
151
|
+
this.onReload(vaults);
|
|
152
|
+
else
|
|
153
|
+
this.setVaults(vaults);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const result = await operation();
|
|
157
|
+
await this.onChange(description);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
resolveVault(reference) {
|
|
161
|
+
const vault = this.vaults.find(item => item.id === reference.id);
|
|
162
|
+
if (!vault)
|
|
163
|
+
throw new Error(`Vault ${reference.name} is no longer available. Refresh and try again.`);
|
|
164
|
+
return vault;
|
|
165
|
+
}
|
|
166
|
+
resolveTask(vault, reference) {
|
|
167
|
+
const task = vault.tasks.find(item => item.id === reference.id);
|
|
168
|
+
if (!task)
|
|
169
|
+
throw new Error(`Task ${reference.name} is no longer available. Refresh and try again.`);
|
|
170
|
+
return task;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
export async function ask(question, defaultValue = '') {
|
|
4
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
5
|
+
try {
|
|
6
|
+
return (await terminal.question(`${question}${defaultValue ? ` [${defaultValue}]` : ''}: `)).trim() || defaultValue;
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
terminal.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function confirm(question, defaultYes = false) {
|
|
13
|
+
const answer = (await ask(`${question} (${defaultYes ? 'Y/n' : 'y/N'})`)).toLowerCase();
|
|
14
|
+
return answer ? answer === 'y' || answer === 'yes' : defaultYes;
|
|
15
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadConfig, saveConfig } from "./config/config.js";
|
|
3
|
+
import { assertDataDirectoryIsolated, currentDataDirectory } from "./config/paths.js";
|
|
4
|
+
import { ask, confirm } from "./cli/prompts.js";
|
|
5
|
+
import { StoreRepository } from "./storage/store.js";
|
|
6
|
+
import { GitRepository } from "./sync/git-repository.js";
|
|
7
|
+
import { SyncService } from "./sync/sync-service.js";
|
|
8
|
+
import { OperationQueue } from "./application/operation-queue.js";
|
|
9
|
+
import { TaskService } from "./application/task-service.js";
|
|
10
|
+
import { App } from "./app.js";
|
|
11
|
+
import { formatSyncState } from "./cli/format.js";
|
|
12
|
+
const HELP = `Todos — keyboard-first task manager
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
todos [--data-dir <path>] Open the task manager
|
|
16
|
+
todos init [--local | --remote URL] [--data-dir <path>] Configure the store
|
|
17
|
+
todos link <url> Link the local store to a Git remote
|
|
18
|
+
todos unlink [--yes] Remove the remote and keep local history
|
|
19
|
+
todos status Show the store and sync configuration
|
|
20
|
+
todos vault create <name> [about] Create another vault
|
|
21
|
+
todos --help
|
|
22
|
+
`;
|
|
23
|
+
export async function main(args = process.argv.slice(2)) {
|
|
24
|
+
try {
|
|
25
|
+
await dispatch(args);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error(`Todos: ${error.message}`);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function dispatch(args) {
|
|
33
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
34
|
+
console.log(HELP);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const command = args[0];
|
|
38
|
+
if (command === 'init')
|
|
39
|
+
return initialize(args.slice(1));
|
|
40
|
+
if (command === 'link')
|
|
41
|
+
return link(args[1]);
|
|
42
|
+
if (command === 'unlink')
|
|
43
|
+
return unlink(args.includes('--yes'));
|
|
44
|
+
if (command === 'status')
|
|
45
|
+
return status();
|
|
46
|
+
if (command === 'vault' && args[1] === 'create')
|
|
47
|
+
return createVault(args.slice(2));
|
|
48
|
+
if (command && !command.startsWith('-'))
|
|
49
|
+
throw new Error(`Unknown command: ${command}\nRun todos --help for usage.`);
|
|
50
|
+
const override = optionValue(args, '--data-dir');
|
|
51
|
+
await openApp(override);
|
|
52
|
+
}
|
|
53
|
+
async function initialize(args) {
|
|
54
|
+
if (args.includes('--local') && optionValue(args, '--remote'))
|
|
55
|
+
throw new Error('Choose either --local or --remote, not both.');
|
|
56
|
+
const override = optionValue(args, '--data-dir');
|
|
57
|
+
const config = await loadConfig();
|
|
58
|
+
const dataDirectory = path.resolve(override || process.env.TODOS_DATA_DIR || config.dataDirectory || currentDataDirectory());
|
|
59
|
+
await assertDataDirectoryIsolated(dataDirectory);
|
|
60
|
+
const store = new StoreRepository(dataDirectory);
|
|
61
|
+
const wasInitialized = await store.isInitialized();
|
|
62
|
+
if (!wasInitialized) {
|
|
63
|
+
await store.initialize();
|
|
64
|
+
console.log(`Created your Todos store at ${dataDirectory}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log(`Todos is already initialized at ${dataDirectory}`);
|
|
68
|
+
}
|
|
69
|
+
await saveConfig({ dataDirectory });
|
|
70
|
+
let remote = optionValue(args, '--remote');
|
|
71
|
+
const localOnly = args.includes('--local');
|
|
72
|
+
const git = new GitRepository(dataDirectory);
|
|
73
|
+
const linkedRemote = await git.remoteUrl();
|
|
74
|
+
if (!remote && !linkedRemote && !localOnly && process.stdin.isTTY && process.stdout.isTTY) {
|
|
75
|
+
if (await confirm('Link this store to a Git remote now?', false))
|
|
76
|
+
remote = await ask('Git remote URL');
|
|
77
|
+
}
|
|
78
|
+
if (remote) {
|
|
79
|
+
console.log(`Git remote: ${redactRemote(remote)}`);
|
|
80
|
+
await new SyncService(git, new OperationQueue()).linkRemote(remote);
|
|
81
|
+
console.log('Remote linked. Run `todos` to open the TUI.');
|
|
82
|
+
}
|
|
83
|
+
else if (linkedRemote)
|
|
84
|
+
console.log(`Git remote already linked: ${redactRemote(linkedRemote)}`);
|
|
85
|
+
else
|
|
86
|
+
console.log('Local-only mode enabled. Link a remote later with `todos link <url>`.');
|
|
87
|
+
}
|
|
88
|
+
async function link(url) {
|
|
89
|
+
if (!url)
|
|
90
|
+
throw new Error('Usage: todos link <url>');
|
|
91
|
+
const { store, sync, dataDirectory } = await context();
|
|
92
|
+
if (!(await store.isInitialized()))
|
|
93
|
+
throw new Error('Run `todos init` before linking a remote.');
|
|
94
|
+
await store.loadVaults();
|
|
95
|
+
console.log(`Linking data in ${dataDirectory}`);
|
|
96
|
+
console.log(`Git remote: ${redactRemote(url)}`);
|
|
97
|
+
await sync.linkRemote(url);
|
|
98
|
+
console.log('Remote linked and task data synchronized.');
|
|
99
|
+
}
|
|
100
|
+
async function unlink(yes) {
|
|
101
|
+
const { sync, dataDirectory, git } = await context();
|
|
102
|
+
if (!(await git.hasRemote())) {
|
|
103
|
+
console.log(`No remote is linked. Data remains at ${dataDirectory}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!yes) {
|
|
107
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
108
|
+
throw new Error('Use `todos unlink --yes` in a non-interactive terminal.');
|
|
109
|
+
if (!(await confirm(`Remove the Git remote from ${dataDirectory}? Local files and history remain.`, false)))
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await sync.unlink();
|
|
113
|
+
console.log('Remote unlinked. Local files and Git history remain.');
|
|
114
|
+
}
|
|
115
|
+
async function status() {
|
|
116
|
+
const { store, git, dataDirectory } = await context();
|
|
117
|
+
const initialized = await store.isInitialized();
|
|
118
|
+
const vaults = initialized ? await store.loadVaults() : [];
|
|
119
|
+
const remote = await git.remoteUrl();
|
|
120
|
+
const syncState = await git.syncState();
|
|
121
|
+
console.log(`Data directory: ${dataDirectory}`);
|
|
122
|
+
console.log(`Initialized: ${initialized ? 'yes' : 'no (run todos init)'}`);
|
|
123
|
+
console.log(`Vaults: ${vaults.length}`);
|
|
124
|
+
console.log(`Git remote: ${remote ? redactRemote(remote) : 'none (local only)'}`);
|
|
125
|
+
console.log(`Sync: ${formatSyncState(syncState)}`);
|
|
126
|
+
}
|
|
127
|
+
async function createVault(args) {
|
|
128
|
+
const name = args[0];
|
|
129
|
+
if (!name)
|
|
130
|
+
throw new Error('Usage: todos vault create <name> [about]');
|
|
131
|
+
const { store, sync } = await context();
|
|
132
|
+
if (!(await store.isInitialized()))
|
|
133
|
+
throw new Error('Run `todos init` first.');
|
|
134
|
+
const vault = await store.createVault(name, args.slice(1).join(' '));
|
|
135
|
+
await sync.commitAndPush(`Created vault ${vault.name}`);
|
|
136
|
+
console.log(`Created vault ${vault.name}.`);
|
|
137
|
+
}
|
|
138
|
+
async function openApp(override) {
|
|
139
|
+
const { store, git, sync, dataDirectory } = await context(override);
|
|
140
|
+
if (!(await store.isInitialized())) {
|
|
141
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
142
|
+
throw new Error('Open Todos in an interactive terminal; `todos` will create the first local vault automatically.');
|
|
143
|
+
}
|
|
144
|
+
await store.initialize();
|
|
145
|
+
await saveConfig({ dataDirectory });
|
|
146
|
+
console.log(`Created your first Todos vault at ${dataDirectory} (local-only).`);
|
|
147
|
+
}
|
|
148
|
+
await sync.syncBeforeRead();
|
|
149
|
+
const vaults = await store.loadVaults();
|
|
150
|
+
const tasks = new TaskService(store, undefined, (description, operation, afterSync) => sync.mutateAndSync(description, operation, afterSync));
|
|
151
|
+
await App.init({ vaults, tasks, refresh: async () => {
|
|
152
|
+
return sync.refresh(async () => {
|
|
153
|
+
const current = await store.loadVaults();
|
|
154
|
+
tasks.setVaults(current);
|
|
155
|
+
return current;
|
|
156
|
+
});
|
|
157
|
+
}, sync });
|
|
158
|
+
}
|
|
159
|
+
async function context(override) {
|
|
160
|
+
const config = await loadConfig();
|
|
161
|
+
const dataDirectory = path.resolve(override || process.env.TODOS_DATA_DIR || config.dataDirectory || currentDataDirectory());
|
|
162
|
+
await assertDataDirectoryIsolated(dataDirectory);
|
|
163
|
+
const store = new StoreRepository(dataDirectory);
|
|
164
|
+
const git = new GitRepository(dataDirectory);
|
|
165
|
+
const queue = new OperationQueue();
|
|
166
|
+
return { store, git, sync: new SyncService(git, queue), dataDirectory };
|
|
167
|
+
}
|
|
168
|
+
function optionValue(args, name) {
|
|
169
|
+
const index = args.indexOf(name);
|
|
170
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
171
|
+
}
|
|
172
|
+
function redactRemote(remote) {
|
|
173
|
+
return remote.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1[credentials]@');
|
|
174
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeJsonAtomically } from "../storage/atomic-file.js";
|
|
4
|
+
import { currentConfigDirectory, currentDataDirectory } from "./paths.js";
|
|
5
|
+
export function configFilePath() {
|
|
6
|
+
return path.join(currentConfigDirectory(), 'config.json');
|
|
7
|
+
}
|
|
8
|
+
export async function loadConfig() {
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(await readFile(configFilePath(), 'utf8'));
|
|
11
|
+
if (typeof parsed === 'object' && parsed !== null && 'dataDirectory' in parsed && typeof parsed.dataDirectory === 'string') {
|
|
12
|
+
return { dataDirectory: parsed.dataDirectory };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code !== 'ENOENT')
|
|
17
|
+
throw new Error(`Could not read Todos configuration: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
return { dataDirectory: currentDataDirectory() };
|
|
20
|
+
}
|
|
21
|
+
export async function saveConfig(config) {
|
|
22
|
+
const file = configFilePath();
|
|
23
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
24
|
+
await writeJsonAtomically(file, config);
|
|
25
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
export function resolveDataDirectory(platform, env, home, override) {
|
|
6
|
+
if (override) {
|
|
7
|
+
const api = platform === 'win32' ? path.win32 : path.posix;
|
|
8
|
+
return api.isAbsolute(override) ? api.normalize(override) : api.resolve(home, override);
|
|
9
|
+
}
|
|
10
|
+
if (platform === 'win32')
|
|
11
|
+
return path.win32.join(absoluteWinPath(env.LOCALAPPDATA) || path.win32.join(home, 'AppData', 'Local'), 'JMTodos');
|
|
12
|
+
if (platform === 'darwin')
|
|
13
|
+
return path.posix.join(home, 'Library', 'Application Support', 'JMTodos');
|
|
14
|
+
return path.posix.join(absolutePosixPath(env.XDG_DATA_HOME) || path.posix.join(home, '.local', 'share'), 'jmtodos');
|
|
15
|
+
}
|
|
16
|
+
export function resolveConfigDirectory(platform, env, home) {
|
|
17
|
+
if (platform === 'win32')
|
|
18
|
+
return path.win32.join(absoluteWinPath(env.APPDATA) || path.win32.join(home, 'AppData', 'Roaming'), 'JMTodos');
|
|
19
|
+
if (platform === 'darwin')
|
|
20
|
+
return path.posix.join(home, 'Library', 'Preferences', 'JMTodos');
|
|
21
|
+
return path.posix.join(absolutePosixPath(env.XDG_CONFIG_HOME) || path.posix.join(home, '.config'), 'jmtodos');
|
|
22
|
+
}
|
|
23
|
+
export function currentDataDirectory(env = process.env) {
|
|
24
|
+
const home = process.platform === 'win32' ? env.USERPROFILE || env.HOME : env.HOME || env.USERPROFILE;
|
|
25
|
+
return resolveDataDirectory(process.platform, env, home || homedir(), env.TODOS_DATA_DIR);
|
|
26
|
+
}
|
|
27
|
+
export function currentConfigDirectory(env = process.env) {
|
|
28
|
+
const home = process.platform === 'win32' ? env.USERPROFILE || env.HOME : env.HOME || env.USERPROFILE;
|
|
29
|
+
return resolveConfigDirectory(process.platform, env, home || homedir());
|
|
30
|
+
}
|
|
31
|
+
export async function assertDataDirectoryIsolated(directory) {
|
|
32
|
+
let current = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
let checkoutRoot;
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
await lstat(path.join(current, '.git'));
|
|
37
|
+
checkoutRoot = await realpath(current);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch { /* Keep walking toward the filesystem root. */ }
|
|
41
|
+
const parent = path.dirname(current);
|
|
42
|
+
if (parent === current)
|
|
43
|
+
break;
|
|
44
|
+
current = parent;
|
|
45
|
+
}
|
|
46
|
+
if (!checkoutRoot)
|
|
47
|
+
return;
|
|
48
|
+
let unresolved = path.resolve(directory);
|
|
49
|
+
const suffix = [];
|
|
50
|
+
while (true) {
|
|
51
|
+
let existing;
|
|
52
|
+
try {
|
|
53
|
+
existing = await realpath(unresolved);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
const parent = path.dirname(unresolved);
|
|
57
|
+
if (parent === unresolved)
|
|
58
|
+
return;
|
|
59
|
+
suffix.unshift(path.basename(unresolved));
|
|
60
|
+
unresolved = parent;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const target = path.resolve(existing, ...suffix);
|
|
64
|
+
const relative = path.relative(checkoutRoot, target);
|
|
65
|
+
if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
|
|
66
|
+
throw new Error('The Todos data folder cannot be inside the application checkout. Choose a global or separate data folder.');
|
|
67
|
+
}
|
|
68
|
+
let ancestor = existing;
|
|
69
|
+
while (true) {
|
|
70
|
+
let hasGitDirectory = false;
|
|
71
|
+
try {
|
|
72
|
+
await lstat(path.join(ancestor, '.git'));
|
|
73
|
+
hasGitDirectory = true;
|
|
74
|
+
}
|
|
75
|
+
catch { /* Not a Git worktree root. */ }
|
|
76
|
+
if (hasGitDirectory) {
|
|
77
|
+
if (path.resolve(ancestor) !== path.resolve(target)) {
|
|
78
|
+
throw new Error('The Todos data folder cannot be inside a Git worktree. Choose an isolated data folder.');
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
const parent = path.dirname(ancestor);
|
|
83
|
+
if (parent === ancestor)
|
|
84
|
+
break;
|
|
85
|
+
ancestor = parent;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function absoluteWinPath(value) {
|
|
91
|
+
return value && path.win32.isAbsolute(value) ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
function absolutePosixPath(value) {
|
|
94
|
+
return value && path.posix.isAbsolute(value) ? value : undefined;
|
|
95
|
+
}
|