@jlmarcos99/todos 0.0.0-stage → 1.0.1

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,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # Todos
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 `V` to create another vault. `Tab` switches
83
+ vaults. Press `r` to synchronize; in local-only mode it prompts for a Git remote
84
+ URL and links the current vault store. Changes are saved locally before Git is
85
+ contacted; an offline remote leaves them saved and marked pending.
86
+
87
+ | Key | Action |
88
+ | --- | --- |
89
+ | `j` / `k` or ↓ / ↑ | Move selection; scroll in activity |
90
+ | `gg` / `G` | First / last task or activity line |
91
+ | Tab / Shift+Tab | Next / previous vault |
92
+ | `h` / `l` or ← / → | Previous / next view |
93
+ | `1` / `2` / `3` | Tasks / Daily / History |
94
+ | Space | Complete / reopen selected task |
95
+ | `s` | Start working on selected task |
96
+ | `n` | Add a progress note |
97
+ | Enter | Read task activity |
98
+ | `[` / `]` | Previous / next Daily window |
99
+ | `t` | Today's Daily |
100
+ | `a` / `V` | Add task / create vault |
101
+ | `/` | Search the current view |
102
+ | `r` | Sync, or link a remote in local-only mode |
103
+ | Escape / `?` | Cancel, clear search / help |
104
+ | `q` / Ctrl+C | Quit |
105
+
106
+ Notes and status changes are timestamped activity records. Daily groups them by
107
+ the user's local calendar date; historical entries are not rewritten by later
108
+ task changes.
109
+
110
+ ## Development
111
+
112
+ ```sh
113
+ npm run dev # Build and run the compiled app
114
+ npm run typecheck # TypeScript checks
115
+ npm run build # Compile the CLI into dist/
116
+ npm test # Build, then run tests from app/tests
117
+ npm run start:prod # Run the compiled CLI
118
+ ```
119
+
120
+ ## Releases
121
+
122
+ Run `npm run changeset` to add a Changeset for each user-facing change.
123
+ Changesets opens a version pull
124
+ request against `master`; merging that version pull request publishes the
125
+ package through GitHub Actions. The release workflow publishes through the
126
+ `NPM_TOKEN` GitHub Actions secret, following the setup used by BareWT. Create a
127
+ granular npm access token with package write access and **Bypass 2FA** enabled,
128
+ then add it in the `todos` repository under Settings → Secrets and variables →
129
+ Actions as `NPM_TOKEN`. The package publishes with npm provenance. In GitHub,
130
+ also allow Actions to create pull requests in Settings → Actions → General so
131
+ Changesets can open version pull requests. Merging a version pull request to
132
+ `master` publishes the package; the included initial patch Changeset prepares
133
+ version `1.0.1`.
134
+
135
+ Source is organized under `app/src`: `config` resolves user paths, `storage`
136
+ owns JSON records and atomic writes, `application` owns task operations,
137
+ `sync` wraps system Git, `cli` handles commands, and `tui` handles terminal input
138
+ 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,146 @@
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 addProgressNote(vault, task, note, date) {
59
+ const cleanNote = note.trim();
60
+ if (!cleanNote)
61
+ return;
62
+ await this.runChange(`Added progress note to: ${task.name}`, async () => {
63
+ const currentVault = this.resolveVault(vault);
64
+ const currentTask = this.resolveTask(currentVault, task);
65
+ const oldStatus = currentTask.status;
66
+ if (currentTask.status === 'todo')
67
+ currentTask.status = 'in-progress';
68
+ const activity = {
69
+ id: randomUUID(), taskId: currentTask.id, taskName: currentTask.name, date,
70
+ kind: 'note', status: currentTask.status, note: cleanNote,
71
+ };
72
+ let taskWasSaved = false;
73
+ try {
74
+ if (this.store) {
75
+ await this.store.saveTask(currentVault.id, currentTask);
76
+ taskWasSaved = true;
77
+ await this.store.saveActivity(currentVault.id, activity);
78
+ }
79
+ }
80
+ catch (error) {
81
+ currentTask.status = oldStatus;
82
+ if (taskWasSaved && this.store)
83
+ await this.store.saveTask(currentVault.id, currentTask).catch(() => undefined);
84
+ throw error;
85
+ }
86
+ currentVault.activity.push(activity);
87
+ });
88
+ }
89
+ async applyStatusChange(vault, task, status, kind, date) {
90
+ const previousStatus = task.status;
91
+ const previousCompletedAt = task.completedAt;
92
+ const activity = { id: randomUUID(), taskId: task.id, taskName: task.name, date, kind, status };
93
+ task.status = status;
94
+ if (status === 'done')
95
+ task.completedAt = date;
96
+ else
97
+ delete task.completedAt;
98
+ let taskWasSaved = false;
99
+ try {
100
+ if (this.store) {
101
+ await this.store.saveTask(vault.id, task);
102
+ taskWasSaved = true;
103
+ await this.store.saveActivity(vault.id, activity);
104
+ }
105
+ }
106
+ catch (error) {
107
+ task.status = previousStatus;
108
+ if (previousCompletedAt)
109
+ task.completedAt = previousCompletedAt;
110
+ else
111
+ delete task.completedAt;
112
+ if (taskWasSaved && this.store)
113
+ await this.store.saveTask(vault.id, task).catch(() => undefined);
114
+ throw error;
115
+ }
116
+ vault.activity.push(activity);
117
+ }
118
+ async runChange(description, operation) {
119
+ if (this.transaction) {
120
+ return this.transaction(description, operation, async () => {
121
+ if (!this.store)
122
+ return;
123
+ const vaults = await this.store.loadVaults();
124
+ if (this.onReload)
125
+ this.onReload(vaults);
126
+ else
127
+ this.setVaults(vaults);
128
+ });
129
+ }
130
+ const result = await operation();
131
+ await this.onChange(description);
132
+ return result;
133
+ }
134
+ resolveVault(reference) {
135
+ const vault = this.vaults.find(item => item.id === reference.id);
136
+ if (!vault)
137
+ throw new Error(`Vault ${reference.name} is no longer available. Refresh and try again.`);
138
+ return vault;
139
+ }
140
+ resolveTask(vault, reference) {
141
+ const task = vault.tasks.find(item => item.id === reference.id);
142
+ if (!task)
143
+ throw new Error(`Task ${reference.name} is no longer available. Refresh and try again.`);
144
+ return task;
145
+ }
146
+ }
@@ -0,0 +1,6 @@
1
+ export function formatSyncState(state) {
2
+ const labels = {
3
+ 'local-only': 'Local only', syncing: 'Syncing…', synced: 'No local sync pending', pending: 'Pending sync', error: 'Sync error',
4
+ };
5
+ return labels[state];
6
+ }
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};