@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Use local calendar dates, not UTC dates or rolling 24-hour windows. */
|
|
2
|
+
export function dayKey(date) {
|
|
3
|
+
const year = date.getFullYear();
|
|
4
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
5
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
6
|
+
return `${year}-${month}-${day}`;
|
|
7
|
+
}
|
|
8
|
+
export function getDailyItems(vault, date, dayCount = 2) {
|
|
9
|
+
const includedDays = new Set();
|
|
10
|
+
for (let offset = 0; offset < dayCount; offset++) {
|
|
11
|
+
const day = new Date(date);
|
|
12
|
+
day.setDate(day.getDate() - offset);
|
|
13
|
+
includedDays.add(dayKey(day));
|
|
14
|
+
}
|
|
15
|
+
const activityByTask = new Map();
|
|
16
|
+
for (const entry of vault.activity) {
|
|
17
|
+
if (!includedDays.has(dayKey(entry.date)))
|
|
18
|
+
continue;
|
|
19
|
+
const entries = activityByTask.get(entry.taskId) ?? [];
|
|
20
|
+
entries.push(entry);
|
|
21
|
+
activityByTask.set(entry.taskId, entries);
|
|
22
|
+
}
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const task of vault.tasks) {
|
|
25
|
+
const entries = activityByTask.get(task.id);
|
|
26
|
+
if (!entries?.length)
|
|
27
|
+
continue;
|
|
28
|
+
entries.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
29
|
+
const latest = entries[entries.length - 1];
|
|
30
|
+
result.push({ task, name: latest.taskName, status: latest.status, entries });
|
|
31
|
+
}
|
|
32
|
+
const statusOrder = { done: 0, 'in-progress': 1, todo: 2 };
|
|
33
|
+
return result.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export async function writeJsonAtomically(file, value) {
|
|
5
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
6
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
7
|
+
try {
|
|
8
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
9
|
+
await rename(temporary, file);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const statuses = new Set(['todo', 'in-progress', 'done']);
|
|
2
|
+
const kinds = new Set(['started', 'completed', 'reopened', 'note']);
|
|
3
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
function record(value, label) {
|
|
5
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
6
|
+
throw new Error(`${label} must be an object.`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function string(value, field) {
|
|
10
|
+
if (typeof value !== 'string' || !value)
|
|
11
|
+
throw new Error(`Invalid ${field} in Todos data.`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function id(value, field) {
|
|
15
|
+
const result = string(value, field);
|
|
16
|
+
if (!UUID_PATTERN.test(result))
|
|
17
|
+
throw new Error(`Invalid ${field} in Todos data; expected a UUID.`);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function date(value, field) {
|
|
21
|
+
const result = new Date(string(value, field));
|
|
22
|
+
if (!Number.isFinite(result.getTime()))
|
|
23
|
+
throw new Error(`Invalid ${field} in Todos data.`);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
export function decodeTask(value) {
|
|
27
|
+
const item = record(value, 'Task');
|
|
28
|
+
if (!statuses.has(item.status))
|
|
29
|
+
throw new Error('Invalid task status in Todos data.');
|
|
30
|
+
return {
|
|
31
|
+
id: id(item.id, 'task id'), name: string(item.name, 'task name'), status: item.status,
|
|
32
|
+
date: date(item.date, 'task date'),
|
|
33
|
+
...(item.completedAt ? { completedAt: date(item.completedAt, 'completion date') } : {}),
|
|
34
|
+
...(typeof item.description === 'string' ? { description: item.description } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function encodeTask(task) {
|
|
38
|
+
return { ...task, date: task.date.toISOString(), ...(task.completedAt ? { completedAt: task.completedAt.toISOString() } : {}) };
|
|
39
|
+
}
|
|
40
|
+
export function decodeActivity(value) {
|
|
41
|
+
const item = record(value, 'Activity');
|
|
42
|
+
if (!kinds.has(String(item.kind)) || !statuses.has(item.status))
|
|
43
|
+
throw new Error('Invalid activity in Todos data.');
|
|
44
|
+
return {
|
|
45
|
+
id: id(item.id, 'activity id'), taskId: id(item.taskId, 'activity task id'),
|
|
46
|
+
taskName: string(item.taskName, 'activity task name'), date: date(item.date, 'activity date'),
|
|
47
|
+
kind: item.kind, status: item.status,
|
|
48
|
+
...(typeof item.note === 'string' ? { note: item.note } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function encodeActivity(activity) {
|
|
52
|
+
return { ...activity, date: activity.date.toISOString() };
|
|
53
|
+
}
|
|
54
|
+
export function decodeVault(value) {
|
|
55
|
+
const item = record(value, 'Vault');
|
|
56
|
+
return { id: id(item.id, 'vault id'), name: string(item.name, 'vault name'), description: typeof item.description === 'string' ? item.description : '' };
|
|
57
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writeJsonAtomically } from "./atomic-file.js";
|
|
5
|
+
import { decodeActivity, decodeTask, decodeVault, encodeActivity, encodeTask } from "./record-codec.js";
|
|
6
|
+
const FORMAT_VERSION = 1;
|
|
7
|
+
const OWNER_MARKER = '.jmtodos-store';
|
|
8
|
+
const STORE_FILE = 'store.json';
|
|
9
|
+
const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
export class StoreRepository {
|
|
11
|
+
root;
|
|
12
|
+
constructor(root) {
|
|
13
|
+
this.root = root;
|
|
14
|
+
}
|
|
15
|
+
async initialize() {
|
|
16
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
17
|
+
const entries = await readdir(this.root);
|
|
18
|
+
const partialOwned = entries.length === 1 && entries[0] === OWNER_MARKER;
|
|
19
|
+
const isRecognized = entries.includes(OWNER_MARKER) && entries.includes(STORE_FILE);
|
|
20
|
+
if (entries.length && !isRecognized && !partialOwned)
|
|
21
|
+
throw new Error(`Refusing to initialize a non-empty unrelated folder: ${this.root}`);
|
|
22
|
+
if (partialOwned || isRecognized)
|
|
23
|
+
await this.assertOwnerMarker();
|
|
24
|
+
await chmod(this.root, 0o700).catch(() => undefined);
|
|
25
|
+
if (isRecognized) {
|
|
26
|
+
await this.assertStoreVersion();
|
|
27
|
+
if (!(await this.loadVaults()).length)
|
|
28
|
+
await this.createVault('Personal', 'Your personal tasks and daily progress.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!partialOwned)
|
|
32
|
+
await writeFile(path.join(this.root, OWNER_MARKER), 'Todos managed task store\n', { mode: 0o600, flag: 'wx' });
|
|
33
|
+
await writeJsonAtomically(path.join(this.root, STORE_FILE), { formatVersion: FORMAT_VERSION });
|
|
34
|
+
await this.createVault('Personal', 'Your personal tasks and daily progress.');
|
|
35
|
+
}
|
|
36
|
+
async isInitialized() {
|
|
37
|
+
try {
|
|
38
|
+
await this.assertOwnerMarker();
|
|
39
|
+
await lstat(path.join(this.root, STORE_FILE));
|
|
40
|
+
await this.assertStoreVersion();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async loadVaults() {
|
|
50
|
+
await this.assertStoreVersion();
|
|
51
|
+
const directory = path.join(this.root, 'vaults');
|
|
52
|
+
let ids;
|
|
53
|
+
try {
|
|
54
|
+
ids = (await readdir(directory, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => entry.name);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT')
|
|
58
|
+
return [];
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const vaults = [];
|
|
62
|
+
for (const id of ids.sort()) {
|
|
63
|
+
const base = path.join(directory, id);
|
|
64
|
+
const metadata = decodeVault(await this.readJson(path.join(base, 'vault.json')));
|
|
65
|
+
if (metadata.id !== id)
|
|
66
|
+
throw new Error(`Vault folder ID does not match its metadata: ${id}`);
|
|
67
|
+
const tasks = await this.readRecords(path.join(base, 'tasks'), decodeTask);
|
|
68
|
+
const activity = await this.readRecords(path.join(base, 'activity'), decodeActivity);
|
|
69
|
+
vaults.push({ ...metadata, tasks, activity });
|
|
70
|
+
}
|
|
71
|
+
const vaultIds = new Set();
|
|
72
|
+
const taskIds = new Set();
|
|
73
|
+
const activityIds = new Set();
|
|
74
|
+
for (const vault of vaults) {
|
|
75
|
+
if (vaultIds.has(vault.id))
|
|
76
|
+
throw new Error(`Duplicate vault ID in Todos data: ${vault.id}`);
|
|
77
|
+
vaultIds.add(vault.id);
|
|
78
|
+
for (const task of vault.tasks) {
|
|
79
|
+
if (taskIds.has(task.id))
|
|
80
|
+
throw new Error(`Duplicate task ID in Todos data: ${task.id}`);
|
|
81
|
+
taskIds.add(task.id);
|
|
82
|
+
}
|
|
83
|
+
const thisVaultTaskIds = new Set(vault.tasks.map(task => task.id));
|
|
84
|
+
for (const item of vault.activity) {
|
|
85
|
+
if (activityIds.has(item.id))
|
|
86
|
+
throw new Error(`Duplicate activity ID in Todos data: ${item.id}`);
|
|
87
|
+
activityIds.add(item.id);
|
|
88
|
+
if (!thisVaultTaskIds.has(item.taskId))
|
|
89
|
+
throw new Error(`Activity ${item.id} refers to a missing task ${item.taskId}.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return vaults;
|
|
93
|
+
}
|
|
94
|
+
async createVault(name, description = '') {
|
|
95
|
+
const cleanName = name.trim();
|
|
96
|
+
if (!cleanName)
|
|
97
|
+
throw new Error('Vault name cannot be empty.');
|
|
98
|
+
const vault = { id: randomUUID(), name: cleanName, description: description.trim(), tasks: [], activity: [] };
|
|
99
|
+
await writeJsonAtomically(path.join(this.root, 'vaults', vault.id, 'vault.json'), {
|
|
100
|
+
id: vault.id, name: vault.name, description: vault.description,
|
|
101
|
+
});
|
|
102
|
+
return vault;
|
|
103
|
+
}
|
|
104
|
+
async saveVault(vault) {
|
|
105
|
+
await writeJsonAtomically(path.join(this.root, 'vaults', vault.id, 'vault.json'), {
|
|
106
|
+
id: vault.id, name: vault.name, description: vault.description,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async saveTask(vaultId, task) {
|
|
110
|
+
await writeJsonAtomically(path.join(this.root, 'vaults', vaultId, 'tasks', `${task.id}.json`), encodeTask(task));
|
|
111
|
+
}
|
|
112
|
+
async saveActivity(vaultId, activity) {
|
|
113
|
+
await writeJsonAtomically(path.join(this.root, 'vaults', vaultId, 'activity', `${activity.id}.json`), encodeActivity(activity));
|
|
114
|
+
}
|
|
115
|
+
async deleteTask(vaultId, taskId) {
|
|
116
|
+
await this.deleteRecord(this.recordPath(vaultId, 'tasks', taskId));
|
|
117
|
+
}
|
|
118
|
+
async deleteActivity(vaultId, activityId) {
|
|
119
|
+
await this.deleteRecord(this.recordPath(vaultId, 'activity', activityId));
|
|
120
|
+
}
|
|
121
|
+
async assertStoreVersion() {
|
|
122
|
+
const data = await this.readJson(path.join(this.root, STORE_FILE));
|
|
123
|
+
if (typeof data !== 'object' || data === null || !('formatVersion' in data) || data.formatVersion !== FORMAT_VERSION) {
|
|
124
|
+
throw new Error(`Unsupported Todos store format. Supported version: ${FORMAT_VERSION}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async assertOwnerMarker() {
|
|
128
|
+
const marker = path.join(this.root, OWNER_MARKER);
|
|
129
|
+
const info = await lstat(marker);
|
|
130
|
+
if (!info.isFile() || info.isSymbolicLink() || (await readFile(marker, 'utf8')) !== 'Todos managed task store\n') {
|
|
131
|
+
throw new Error(`Invalid Todos ownership marker at ${marker}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async readJson(file) {
|
|
135
|
+
try {
|
|
136
|
+
const info = await lstat(file);
|
|
137
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
138
|
+
throw new Error('expected a regular file');
|
|
139
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`Could not read Todos data at ${file}: ${error.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
recordPath(vaultId, folder, id) {
|
|
146
|
+
return path.join(this.root, 'vaults', this.assertRecordId(vaultId), folder, `${this.assertRecordId(id)}.json`);
|
|
147
|
+
}
|
|
148
|
+
assertRecordId(value) {
|
|
149
|
+
if (!RECORD_ID.test(value))
|
|
150
|
+
throw new Error('Refusing to delete a record with an invalid id.');
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
async deleteRecord(file) {
|
|
154
|
+
let info;
|
|
155
|
+
try {
|
|
156
|
+
info = await lstat(file);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code === 'ENOENT')
|
|
160
|
+
return;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
164
|
+
throw new Error(`Refusing to delete a non-regular file: ${file}`);
|
|
165
|
+
await rm(file);
|
|
166
|
+
}
|
|
167
|
+
async readRecords(directory, decode) {
|
|
168
|
+
let files;
|
|
169
|
+
try {
|
|
170
|
+
files = await readdir(directory);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code === 'ENOENT')
|
|
174
|
+
return [];
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
const result = [];
|
|
178
|
+
for (const file of files.filter(name => name.endsWith('.json')).sort()) {
|
|
179
|
+
const decoded = decode(await this.readJson(path.join(directory, file)));
|
|
180
|
+
if (typeof decoded === 'object' && decoded !== null && 'id' in decoded && decoded.id !== file.slice(0, -5)) {
|
|
181
|
+
throw new Error(`Record filename does not match its ID: ${path.join(directory, file)}`);
|
|
182
|
+
}
|
|
183
|
+
result.push(decoded);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export class GitCommandError extends Error {
|
|
3
|
+
args;
|
|
4
|
+
exitCode;
|
|
5
|
+
constructor(message, args, exitCode) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.args = args;
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.name = 'GitCommandError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function redact(text) {
|
|
13
|
+
return text.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1[credentials]@');
|
|
14
|
+
}
|
|
15
|
+
/** Runs Git with an argument vector and a fixed working directory; never through a shell. */
|
|
16
|
+
export class GitClient {
|
|
17
|
+
directory;
|
|
18
|
+
constructor(directory) {
|
|
19
|
+
this.directory = directory;
|
|
20
|
+
}
|
|
21
|
+
run(args, allowFailure = false) {
|
|
22
|
+
return this.runOnce(args, allowFailure).catch(async (error) => {
|
|
23
|
+
if (!(error instanceof GitCommandError) || !error.message.toLowerCase().includes('index.lock'))
|
|
24
|
+
throw error;
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
26
|
+
return this.runOnce(args, allowFailure);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
runOnce(args, allowFailure) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn('git', ['-C', this.directory, ...args], {
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: '0' },
|
|
35
|
+
});
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let stderr = '';
|
|
38
|
+
child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
|
|
39
|
+
child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
|
|
40
|
+
child.once('error', error => reject(new GitCommandError(`Could not run Git: ${error.message}`, args, null)));
|
|
41
|
+
child.once('close', code => {
|
|
42
|
+
const result = { stdout: redact(stdout), stderr: redact(stderr), exitCode: code };
|
|
43
|
+
if (code === 0 || allowFailure)
|
|
44
|
+
resolve(result);
|
|
45
|
+
else
|
|
46
|
+
reject(new GitCommandError(result.stderr.trim() || `Git ${args[0]} failed.`, args, code));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { GitClient, GitCommandError } from "./git-client.js";
|
|
6
|
+
import { StoreRepository } from "../storage/store.js";
|
|
7
|
+
export class GitRepository {
|
|
8
|
+
root;
|
|
9
|
+
git;
|
|
10
|
+
constructor(root) {
|
|
11
|
+
this.root = root;
|
|
12
|
+
this.git = new GitClient(root);
|
|
13
|
+
}
|
|
14
|
+
async isRepository() {
|
|
15
|
+
try {
|
|
16
|
+
const root = (await this.git.run(['rev-parse', '--show-toplevel'])).stdout.trim();
|
|
17
|
+
return path.resolve(await realpath(root)) === path.resolve(await realpath(this.root));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof GitCommandError)
|
|
21
|
+
return false;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async hasRemote() {
|
|
26
|
+
if (!(await this.isRepository()))
|
|
27
|
+
return false;
|
|
28
|
+
return (await this.git.run(['remote'], true)).stdout.trim().length > 0;
|
|
29
|
+
}
|
|
30
|
+
async remoteUrl() {
|
|
31
|
+
if (!(await this.isRepository()))
|
|
32
|
+
return undefined;
|
|
33
|
+
return (await this.git.run(['remote', 'get-url', 'origin'], true)).stdout.trim() || undefined;
|
|
34
|
+
}
|
|
35
|
+
async syncState() {
|
|
36
|
+
if (!(await this.hasRemote()))
|
|
37
|
+
return 'local-only';
|
|
38
|
+
if ((await this.git.run(['status', '--porcelain', '--', '.jmtodos-store', 'store.json', 'vaults'])).stdout.trim())
|
|
39
|
+
return 'pending';
|
|
40
|
+
const upstream = await this.git.run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], true);
|
|
41
|
+
if (upstream.exitCode !== 0 || !upstream.stdout.trim())
|
|
42
|
+
return 'pending';
|
|
43
|
+
const counts = await this.git.run(['rev-list', '--left-right', '--count', `HEAD...${upstream.stdout.trim()}`]);
|
|
44
|
+
const [ahead, behind] = counts.stdout.trim().split(/\s+/).map(Number);
|
|
45
|
+
return ahead === 0 && behind === 0 ? 'synced' : 'pending';
|
|
46
|
+
}
|
|
47
|
+
async linkRemote(url) {
|
|
48
|
+
const normalized = url.trim();
|
|
49
|
+
if (!normalized || /\s/.test(normalized) || /https?:\/\/[^/@\s]+@/i.test(normalized)) {
|
|
50
|
+
throw new Error('Enter an SSH or HTTPS Git URL without embedded credentials. Use your SSH agent or Git credential helper.');
|
|
51
|
+
}
|
|
52
|
+
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const applicationRoot = (await new GitClient(moduleDirectory).run(['rev-parse', '--show-toplevel'], true)).stdout.trim();
|
|
54
|
+
const dataRoot = await realpath(this.root);
|
|
55
|
+
const canonicalApplicationRoot = applicationRoot ? await realpath(applicationRoot).catch(() => applicationRoot) : '';
|
|
56
|
+
if (canonicalApplicationRoot && isInside(canonicalApplicationRoot, dataRoot)) {
|
|
57
|
+
throw new Error('The Todos data folder is inside the application checkout. Choose a separate global data folder before linking.');
|
|
58
|
+
}
|
|
59
|
+
const enclosingRoot = (await this.git.run(['rev-parse', '--show-toplevel'], true)).stdout.trim();
|
|
60
|
+
if (enclosingRoot && path.resolve(await realpath(enclosingRoot)) !== path.resolve(dataRoot)) {
|
|
61
|
+
throw new Error('The Todos data folder is inside another Git repository. Choose an isolated data folder before linking.');
|
|
62
|
+
}
|
|
63
|
+
if (!(await this.isRepository()))
|
|
64
|
+
await this.git.run(['init', '--initial-branch=main']);
|
|
65
|
+
await this.secureGitDirectory();
|
|
66
|
+
const existing = await this.remoteUrl();
|
|
67
|
+
const addedRemote = !existing;
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (existing !== normalized)
|
|
70
|
+
throw new Error('A remote is already linked. Run `todos unlink` before linking another remote.');
|
|
71
|
+
}
|
|
72
|
+
else
|
|
73
|
+
await this.git.run(['remote', 'add', 'origin', normalized]);
|
|
74
|
+
await this.commitSnapshot('Initial local task snapshot');
|
|
75
|
+
try {
|
|
76
|
+
const head = await this.remoteHead();
|
|
77
|
+
if (!head) {
|
|
78
|
+
const refs = await this.git.run(['ls-remote', 'origin']);
|
|
79
|
+
if (refs.stdout.trim()) {
|
|
80
|
+
if (addedRemote)
|
|
81
|
+
await this.git.run(['remote', 'remove', 'origin']);
|
|
82
|
+
throw new Error('The remote contains refs but no branch Todos can use. Local data was kept.');
|
|
83
|
+
}
|
|
84
|
+
await this.renameCurrentBranch('main');
|
|
85
|
+
await this.git.run(['push', '-u', 'origin', 'HEAD:refs/heads/main']);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await this.git.run(['fetch', 'origin']);
|
|
89
|
+
const remoteRef = `origin/${head}`;
|
|
90
|
+
if (!(await this.isTodosStore(remoteRef))) {
|
|
91
|
+
if (addedRemote)
|
|
92
|
+
await this.git.run(['remote', 'remove', 'origin']);
|
|
93
|
+
throw new Error('The remote is non-empty and does not contain a recognized Todos store. Local data was kept.');
|
|
94
|
+
}
|
|
95
|
+
let clonePath;
|
|
96
|
+
try {
|
|
97
|
+
clonePath = await this.cloneAndValidateRemote(normalized, head);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (addedRemote)
|
|
101
|
+
await this.git.run(['remote', 'remove', 'origin']);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (await this.isPristineLocalStore()) {
|
|
105
|
+
await this.adoptRemote(clonePath);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await rm(clonePath, { recursive: true, force: true });
|
|
109
|
+
await this.renameCurrentBranch(head);
|
|
110
|
+
await this.mergeRemote(remoteRef);
|
|
111
|
+
await this.git.run(['push', '-u', 'origin', `HEAD:refs/heads/${head}`]);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof GitCommandError)
|
|
115
|
+
throw new Error(`Could not link remote; local tasks remain safe. ${error.message}`);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async syncBeforeRead() {
|
|
120
|
+
if (!(await this.hasRemote()))
|
|
121
|
+
return;
|
|
122
|
+
await this.git.run(['fetch', 'origin']);
|
|
123
|
+
const head = await this.remoteHead();
|
|
124
|
+
if (head) {
|
|
125
|
+
await this.mergeRemote(`origin/${head}`);
|
|
126
|
+
await this.commitAndPush('Synchronize pending task changes');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async commitAndPush(reason) {
|
|
130
|
+
if (!(await this.hasRemote()))
|
|
131
|
+
return;
|
|
132
|
+
await this.commitSnapshot(reason);
|
|
133
|
+
try {
|
|
134
|
+
await this.pushCurrentBranch();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
await this.git.run(['fetch', 'origin']);
|
|
138
|
+
const head = await this.remoteHead();
|
|
139
|
+
if (head)
|
|
140
|
+
await this.mergeRemote(`origin/${head}`);
|
|
141
|
+
await this.pushCurrentBranch();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async unlink() {
|
|
145
|
+
if (await this.hasRemote())
|
|
146
|
+
await this.git.run(['remote', 'remove', 'origin']);
|
|
147
|
+
}
|
|
148
|
+
async commitSnapshot(reason) {
|
|
149
|
+
await this.git.run(['add', '-A', '--', '.jmtodos-store', 'store.json', 'vaults']);
|
|
150
|
+
const changed = await this.git.run(['diff', '--cached', '--quiet'], true);
|
|
151
|
+
if (changed.exitCode === 1) {
|
|
152
|
+
await this.git.run(['-c', 'user.name=Todos', '-c', 'user.email=todos@localhost', 'commit', '-m', `Todos: ${reason}`]);
|
|
153
|
+
}
|
|
154
|
+
else if (changed.exitCode !== 0)
|
|
155
|
+
throw new Error('Could not inspect staged task changes before committing.');
|
|
156
|
+
}
|
|
157
|
+
async remoteHead() {
|
|
158
|
+
const result = await this.git.run(['ls-remote', '--symref', 'origin', 'HEAD']);
|
|
159
|
+
const head = result.stdout.match(/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m)?.[1];
|
|
160
|
+
if (head)
|
|
161
|
+
return head;
|
|
162
|
+
const refs = await this.git.run(['ls-remote', '--heads', 'origin']);
|
|
163
|
+
return refs.stdout.match(/\srefs\/heads\/(.+)$/m)?.[1];
|
|
164
|
+
}
|
|
165
|
+
async isTodosStore(ref) {
|
|
166
|
+
const marker = await this.git.run(['show', `${ref}:.jmtodos-store`], true);
|
|
167
|
+
const store = await this.git.run(['show', `${ref}:store.json`], true);
|
|
168
|
+
if (!marker.stdout || !store.stdout)
|
|
169
|
+
return false;
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(store.stdout).formatVersion === 1;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async isPristineLocalStore() {
|
|
178
|
+
try {
|
|
179
|
+
const entries = (await readdir(this.root)).filter(entry => entry !== '.git').sort();
|
|
180
|
+
if (entries.join('|') !== '.jmtodos-store|store.json|vaults')
|
|
181
|
+
return false;
|
|
182
|
+
const vaultIds = await readdir(path.join(this.root, 'vaults'));
|
|
183
|
+
if (vaultIds.length !== 1)
|
|
184
|
+
return false;
|
|
185
|
+
const vaultDir = path.join(this.root, 'vaults', vaultIds[0]);
|
|
186
|
+
const metadata = JSON.parse(await readFile(path.join(vaultDir, 'vault.json'), 'utf8'));
|
|
187
|
+
const tasks = await entriesOrEmpty(path.join(vaultDir, 'tasks'));
|
|
188
|
+
const activity = await entriesOrEmpty(path.join(vaultDir, 'activity'));
|
|
189
|
+
return metadata.name === 'Personal' && tasks.length === 0 && activity.length === 0;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async cloneAndValidateRemote(url, branch) {
|
|
196
|
+
const parent = path.dirname(this.root);
|
|
197
|
+
const clonePath = path.join(parent, `.jmtodos-clone-${randomUUID()}`);
|
|
198
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
199
|
+
try {
|
|
200
|
+
await new GitClient(parent).run(['clone', '--quiet', '--branch', branch, '--', url, clonePath]);
|
|
201
|
+
await restrictPermissions(clonePath);
|
|
202
|
+
const store = new StoreRepository(clonePath);
|
|
203
|
+
if (!(await store.isInitialized()))
|
|
204
|
+
throw new Error('The remote clone does not contain a valid Todos store.');
|
|
205
|
+
if (!(await store.loadVaults()).length)
|
|
206
|
+
throw new Error('The remote Todos store has no vaults. Local data was kept.');
|
|
207
|
+
return clonePath;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await rm(clonePath, { recursive: true, force: true }).catch(() => undefined);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async adoptRemote(clonePath) {
|
|
215
|
+
const backupPath = path.join(path.dirname(this.root), `.jmtodos-local-${randomUUID()}`);
|
|
216
|
+
await rename(this.root, backupPath);
|
|
217
|
+
try {
|
|
218
|
+
await rename(clonePath, this.root);
|
|
219
|
+
await chmod(this.root, 0o700).catch(() => undefined);
|
|
220
|
+
await this.secureGitDirectory();
|
|
221
|
+
await rm(backupPath, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (await entriesOrEmpty(this.root).then(entries => entries.length > 0).catch(() => false)) {
|
|
225
|
+
await rename(this.root, clonePath).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
await rename(backupPath, this.root).catch(() => undefined);
|
|
228
|
+
await rm(clonePath, { recursive: true, force: true }).catch(() => undefined);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async renameCurrentBranch(name) {
|
|
233
|
+
const current = await this.git.run(['branch', '--show-current']);
|
|
234
|
+
if (current.stdout.trim() !== name)
|
|
235
|
+
await this.git.run(['branch', '-m', name]);
|
|
236
|
+
}
|
|
237
|
+
async pushCurrentBranch() {
|
|
238
|
+
const branch = (await this.git.run(['branch', '--show-current'])).stdout.trim();
|
|
239
|
+
if (!branch)
|
|
240
|
+
throw new Error('Cannot push a detached Todos repository.');
|
|
241
|
+
await this.git.run(['push', 'origin', `HEAD:refs/heads/${branch}`]);
|
|
242
|
+
}
|
|
243
|
+
async secureGitDirectory() {
|
|
244
|
+
const result = await this.git.run(['rev-parse', '--absolute-git-dir']);
|
|
245
|
+
await chmod(result.stdout.trim(), 0o700).catch(() => undefined);
|
|
246
|
+
await chmod(path.join(result.stdout.trim(), 'config'), 0o600).catch(() => undefined);
|
|
247
|
+
}
|
|
248
|
+
async mergeRemote(ref) {
|
|
249
|
+
const localBeforeRemote = await this.git.run(['merge-base', '--is-ancestor', 'HEAD', ref], true);
|
|
250
|
+
if (localBeforeRemote.exitCode === 0) {
|
|
251
|
+
await this.git.run(['merge', '--ff-only', ref]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const remoteBeforeLocal = await this.git.run(['merge-base', '--is-ancestor', ref, 'HEAD'], true);
|
|
255
|
+
if (remoteBeforeLocal.exitCode === 0)
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
await this.git.run(['-c', 'user.name=Todos', '-c', 'user.email=todos@localhost', 'merge', '--no-edit', '--allow-unrelated-histories', ref]);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
await this.git.run(['merge', '--abort'], true);
|
|
262
|
+
throw new Error(`Git sync found conflicting task records. Both commits are preserved; resolve the conflict in ${this.root} and retry. ${error.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function entriesOrEmpty(directory) {
|
|
267
|
+
try {
|
|
268
|
+
return await readdir(directory);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error.code === 'ENOENT')
|
|
272
|
+
return [];
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function restrictPermissions(directory) {
|
|
277
|
+
await chmod(directory, 0o700).catch(() => undefined);
|
|
278
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
279
|
+
const fullPath = path.join(directory, entry.name);
|
|
280
|
+
if (entry.isSymbolicLink())
|
|
281
|
+
continue;
|
|
282
|
+
if (entry.isDirectory())
|
|
283
|
+
await restrictPermissions(fullPath);
|
|
284
|
+
else if (entry.isFile())
|
|
285
|
+
await chmod(fullPath, 0o600).catch(() => undefined);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function isInside(parent, candidate) {
|
|
289
|
+
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
290
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
291
|
+
}
|