aethel 0.3.8 → 1.0.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/CHANGELOG.md +8 -0
- package/README.md +51 -43
- package/package.json +12 -1
- package/scripts/demo.js +416 -0
- package/scripts/render-demo-gif.py +90 -0
- package/scripts/render-demo-screenshot.js +65 -0
- package/src/cli.js +46 -9
- package/src/core/repository.js +5 -0
- package/src/core/staging.js +20 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
Aethel brings a `snapshot → diff → stage → commit` workflow to Google Drive. Track changes on both sides, resolve conflicts explicitly, and keep a full sync history — all without leaving the command line. It also ships with a dual-pane TUI for hands-on file management.
|
|
11
11
|
|
|
12
|
-
---
|
|
13
|
-
|
|
14
12
|
## Install
|
|
15
13
|
|
|
16
14
|
```bash
|
|
@@ -33,6 +31,8 @@ npm run install:cli # symlinks `aethel` into ~/.local/bin
|
|
|
33
31
|
|
|
34
32
|
## Setup
|
|
35
33
|
|
|
34
|
+

|
|
35
|
+
|
|
36
36
|
### 1. Get Google OAuth Credentials
|
|
37
37
|
|
|
38
38
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
@@ -62,15 +62,20 @@ aethel auth # opens browser, saves token.json
|
|
|
62
62
|
|
|
63
63
|
### 4. Initialize a Workspace
|
|
64
64
|
|
|
65
|
+

|
|
66
|
+
|
|
65
67
|
```bash
|
|
66
68
|
aethel init --local-path ./my-drive # sync entire My Drive
|
|
67
69
|
aethel init --local-path ./workspace --drive-folder <folder-id> # sync specific folder
|
|
70
|
+
aethel pull --all -m "initial pull" # hydrate local files from the current remote tree
|
|
68
71
|
```
|
|
69
72
|
|
|
70
73
|
> `credentials.json` and `token.json` are local secrets — never commit them.
|
|
71
74
|
|
|
72
75
|
## Usage
|
|
73
76
|
|
|
77
|
+

|
|
78
|
+
|
|
74
79
|
```bash
|
|
75
80
|
aethel status # local vs remote changes at a glance
|
|
76
81
|
aethel diff --side all # detailed file-level diff
|
|
@@ -78,9 +83,12 @@ aethel add --all # stage default suggested actions
|
|
|
78
83
|
aethel commit -m "sync" # execute staged operations
|
|
79
84
|
|
|
80
85
|
aethel pull -m "pull" # fetch remote changes and apply
|
|
86
|
+
aethel pull --all # download the full remote tree to local
|
|
81
87
|
aethel push -m "push" # push local changes to Drive
|
|
82
88
|
```
|
|
83
89
|
|
|
90
|
+
`pull` applies remote changes relative to the latest snapshot. Use `pull --all` for the first full download or to rehydrate a local workspace from the current remote tree.
|
|
91
|
+
|
|
84
92
|
### Conflict Resolution
|
|
85
93
|
|
|
86
94
|
When both local and remote change the same path:
|
|
@@ -104,28 +112,28 @@ Processes deepest-first for single-pass convergence, caches child state to minim
|
|
|
104
112
|
|
|
105
113
|
## Commands
|
|
106
114
|
|
|
107
|
-
| Command
|
|
108
|
-
|
|
109
|
-
| `auth`
|
|
110
|
-
| `init`
|
|
111
|
-
| `status`
|
|
112
|
-
| `diff`
|
|
113
|
-
| `add`
|
|
114
|
-
| `reset`
|
|
115
|
-
| `commit`
|
|
116
|
-
| `pull`
|
|
117
|
-
| `push`
|
|
118
|
-
| `log`
|
|
119
|
-
| `fetch`
|
|
120
|
-
| `resolve`
|
|
121
|
-
| `ignore`
|
|
122
|
-
| `show`
|
|
123
|
-
| `restore`
|
|
124
|
-
| `rm`
|
|
125
|
-
| `mv`
|
|
126
|
-
| `clean`
|
|
127
|
-
| `dedupe-folders` | Detect and merge duplicate remote folders
|
|
128
|
-
| `tui`
|
|
115
|
+
| Command | Description |
|
|
116
|
+
| ------------------ | ------------------------------------------------------------------- |
|
|
117
|
+
| `auth` | OAuth flow — creates `token.json`, verifies Drive access |
|
|
118
|
+
| `init` | Initialize a local sync workspace |
|
|
119
|
+
| `status` | Show local vs remote changes |
|
|
120
|
+
| `diff` | Detailed file differences |
|
|
121
|
+
| `add` | Stage changes |
|
|
122
|
+
| `reset` | Unstage changes |
|
|
123
|
+
| `commit` | Execute staged sync operations |
|
|
124
|
+
| `pull` | Fetch and apply remote changes (`--all` for full remote download) |
|
|
125
|
+
| `push` | Push local changes to Drive |
|
|
126
|
+
| `log` | Sync history |
|
|
127
|
+
| `fetch` | Refresh remote state without applying |
|
|
128
|
+
| `resolve` | Resolve conflicts (local / remote / both) |
|
|
129
|
+
| `ignore` | Manage `.aethelignore` patterns |
|
|
130
|
+
| `show` | Inspect a saved snapshot |
|
|
131
|
+
| `restore` | Restore files from the last snapshot |
|
|
132
|
+
| `rm` | Remove local files and stage remote deletion |
|
|
133
|
+
| `mv` | Move or rename local files |
|
|
134
|
+
| `clean` | List and optionally trash/delete Drive files |
|
|
135
|
+
| `dedupe-folders` | Detect and merge duplicate remote folders |
|
|
136
|
+
| `tui` | Launch interactive terminal UI |
|
|
129
137
|
|
|
130
138
|
## TUI
|
|
131
139
|
|
|
@@ -135,20 +143,20 @@ aethel tui
|
|
|
135
143
|
|
|
136
144
|
Dual-pane file browser — local filesystem on the left, Google Drive on the right.
|
|
137
145
|
|
|
138
|
-
| Key
|
|
139
|
-
|
|
140
|
-
| `Tab`
|
|
141
|
-
| `Left` / `Right` | Navigate up / into directories
|
|
142
|
-
| `u`
|
|
143
|
-
| `s`
|
|
144
|
-
| `U`
|
|
145
|
-
| `n`
|
|
146
|
-
| `x`
|
|
147
|
-
| `Space`
|
|
148
|
-
| `t` / `d`
|
|
149
|
-
| `/`
|
|
150
|
-
| `f`
|
|
151
|
-
| `:`
|
|
146
|
+
| Key | Action |
|
|
147
|
+
| -------------------- | -------------------------------------------------- |
|
|
148
|
+
| `Tab` | Switch panes |
|
|
149
|
+
| `Left` / `Right` | Navigate up / into directories |
|
|
150
|
+
| `u` | Upload selected local file or folder to Drive |
|
|
151
|
+
| `s` | Batch sync local folder to current Drive directory |
|
|
152
|
+
| `U` | Upload from a manually entered path |
|
|
153
|
+
| `n` | Rename selected local item |
|
|
154
|
+
| `x` | Delete selected local item |
|
|
155
|
+
| `Space` | Toggle selection in Drive pane |
|
|
156
|
+
| `t` / `d` | Trash / permanently delete selected Drive items |
|
|
157
|
+
| `/` | Filter by name |
|
|
158
|
+
| `f` | Open the commands page and choose a TUI action |
|
|
159
|
+
| `:` | Run any Aethel CLI command inside the TUI |
|
|
152
160
|
|
|
153
161
|
## Ignore Patterns
|
|
154
162
|
|
|
@@ -165,11 +173,11 @@ build/
|
|
|
165
173
|
|
|
166
174
|
## Environment Variables
|
|
167
175
|
|
|
168
|
-
| Variable
|
|
169
|
-
|
|
170
|
-
| `GOOGLE_DRIVE_CREDENTIALS_PATH` | `~/.config/aethel/credentials.json` | Path to OAuth credentials
|
|
171
|
-
| `GOOGLE_DRIVE_TOKEN_PATH`
|
|
172
|
-
| `AETHEL_DRIVE_CONCURRENCY`
|
|
176
|
+
| Variable | Default | Description |
|
|
177
|
+
| --------------------------------- | ------------------------------------- | --------------------------------- |
|
|
178
|
+
| `GOOGLE_DRIVE_CREDENTIALS_PATH` | `~/.config/aethel/credentials.json` | Path to OAuth credentials |
|
|
179
|
+
| `GOOGLE_DRIVE_TOKEN_PATH` | `~/.config/aethel/token.json` | Path to cached OAuth token |
|
|
180
|
+
| `AETHEL_DRIVE_CONCURRENCY` | `40` | Max concurrent Drive API requests |
|
|
173
181
|
|
|
174
182
|
## Architecture
|
|
175
183
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aethel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Git-style Google Drive sync CLI with interactive TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"src/",
|
|
29
|
+
"scripts/demo.js",
|
|
30
|
+
"scripts/render-demo-gif.py",
|
|
31
|
+
"scripts/render-demo-screenshot.js",
|
|
32
|
+
"docs/demo.gif",
|
|
33
|
+
"docs/demo-screenshot.svg",
|
|
29
34
|
"LICENSE",
|
|
30
35
|
"README.md",
|
|
31
36
|
"CHANGELOG.md",
|
|
@@ -33,6 +38,12 @@
|
|
|
33
38
|
".env.example"
|
|
34
39
|
],
|
|
35
40
|
"scripts": {
|
|
41
|
+
"demo": "node scripts/demo.js",
|
|
42
|
+
"demo:gif": "python3 scripts/render-demo-gif.py",
|
|
43
|
+
"demo:screenshot": "node scripts/render-demo-screenshot.js",
|
|
44
|
+
"demo:setup": "python3 scripts/generate-cast.py && agg docs/setup.cast docs/setup.gif --font-size 32 --theme dracula",
|
|
45
|
+
"demo:init": "python3 scripts/generate-init-cast.py && agg docs/init.cast docs/init.gif --font-size 32 --theme dracula",
|
|
46
|
+
"demo:usage": "python3 scripts/generate-usage-cast.py && agg docs/usage.cast docs/usage.gif --font-size 32 --theme dracula",
|
|
36
47
|
"start": "node src/cli.js",
|
|
37
48
|
"auth": "node src/cli.js auth",
|
|
38
49
|
"clean": "node src/cli.js clean",
|
package/scripts/demo.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { initWorkspace } from "../src/core/config.js";
|
|
10
|
+
import { ensureFolder, resetFolderLookupCache } from "../src/core/drive-api.js";
|
|
11
|
+
import { Repository } from "../src/core/repository.js";
|
|
12
|
+
|
|
13
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
14
|
+
|
|
15
|
+
function md5(buffer) {
|
|
16
|
+
return createHash("md5").update(buffer).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clone(value) {
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function folder(id, name, parentId, createdTime) {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
mimeType: FOLDER_MIME,
|
|
28
|
+
parents: parentId ? [parentId] : [],
|
|
29
|
+
createdTime,
|
|
30
|
+
modifiedTime: createdTime,
|
|
31
|
+
md5Checksum: null,
|
|
32
|
+
size: null,
|
|
33
|
+
trashed: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function file(id, name, parentId, createdTime, content) {
|
|
38
|
+
const body = Buffer.from(content);
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
name,
|
|
42
|
+
mimeType: "application/octet-stream",
|
|
43
|
+
parents: [parentId],
|
|
44
|
+
createdTime,
|
|
45
|
+
modifiedTime: createdTime,
|
|
46
|
+
md5Checksum: md5(body),
|
|
47
|
+
size: body.length,
|
|
48
|
+
trashed: false,
|
|
49
|
+
body,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createFakeDrive(initialItems = []) {
|
|
54
|
+
const items = new Map();
|
|
55
|
+
const contentById = new Map();
|
|
56
|
+
let sequence = 0;
|
|
57
|
+
let idCounter = 1000;
|
|
58
|
+
|
|
59
|
+
for (const item of initialItems) {
|
|
60
|
+
const next = clone(item);
|
|
61
|
+
delete next.body;
|
|
62
|
+
items.set(next.id, next);
|
|
63
|
+
if (item.body) {
|
|
64
|
+
contentById.set(next.id, Buffer.from(item.body));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeQueryValue(value) {
|
|
69
|
+
return value.replace(/\\\\/g, "\\").replace(/\\'/g, "'");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matches(item, query) {
|
|
73
|
+
if (!query) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return query.split(" and ").every((part) => {
|
|
78
|
+
if (part === "trashed = false") {
|
|
79
|
+
return !item.trashed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nameMatch = part.match(/^name = '(.+)'$/);
|
|
83
|
+
if (nameMatch) {
|
|
84
|
+
return item.name === decodeQueryValue(nameMatch[1]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mimeMatch = part.match(/^mimeType = '(.+)'$/);
|
|
88
|
+
if (mimeMatch) {
|
|
89
|
+
return item.mimeType === decodeQueryValue(mimeMatch[1]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parentMatch = part.match(/^'(.+)' in parents$/);
|
|
93
|
+
if (parentMatch) {
|
|
94
|
+
return (item.parents || []).includes(parentMatch[1]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drain(stream) {
|
|
102
|
+
if (!stream) {
|
|
103
|
+
return Buffer.alloc(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for await (const chunk of stream) {
|
|
108
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
109
|
+
}
|
|
110
|
+
return Buffer.concat(chunks);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function touch(item) {
|
|
114
|
+
item.modifiedTime = new Date(1710000000000 + sequence++).toISOString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
files: {
|
|
119
|
+
async list({ q, pageSize = 1000, pageToken, orderBy }) {
|
|
120
|
+
const matchesQuery = [...items.values()].filter((item) => matches(item, q));
|
|
121
|
+
matchesQuery.sort((left, right) => {
|
|
122
|
+
if (orderBy === "createdTime desc") {
|
|
123
|
+
return Date.parse(right.createdTime) - Date.parse(left.createdTime);
|
|
124
|
+
}
|
|
125
|
+
return String(left.id).localeCompare(String(right.id));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const start = Number(pageToken || 0);
|
|
129
|
+
const slice = matchesQuery.slice(start, start + pageSize).map(clone);
|
|
130
|
+
const nextPageToken =
|
|
131
|
+
start + pageSize < matchesQuery.length ? String(start + pageSize) : undefined;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
data: {
|
|
135
|
+
files: slice,
|
|
136
|
+
nextPageToken,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
async create({ requestBody, media }) {
|
|
141
|
+
const body = await drain(media?.body);
|
|
142
|
+
const id = `id-${++idCounter}`;
|
|
143
|
+
const createdTime = new Date(1710000000000 + sequence++).toISOString();
|
|
144
|
+
const item = {
|
|
145
|
+
id,
|
|
146
|
+
name: requestBody.name,
|
|
147
|
+
mimeType: requestBody.mimeType || "application/octet-stream",
|
|
148
|
+
parents: requestBody.parents || [],
|
|
149
|
+
createdTime,
|
|
150
|
+
modifiedTime: createdTime,
|
|
151
|
+
md5Checksum: requestBody.mimeType === FOLDER_MIME ? null : md5(body),
|
|
152
|
+
size: requestBody.mimeType === FOLDER_MIME ? null : body.length,
|
|
153
|
+
trashed: false,
|
|
154
|
+
};
|
|
155
|
+
items.set(id, item);
|
|
156
|
+
if (item.mimeType !== FOLDER_MIME) {
|
|
157
|
+
contentById.set(id, body);
|
|
158
|
+
}
|
|
159
|
+
return { data: clone(item) };
|
|
160
|
+
},
|
|
161
|
+
async update({ fileId, requestBody = {}, addParents, removeParents, media }) {
|
|
162
|
+
const body = await drain(media?.body);
|
|
163
|
+
const item = items.get(fileId);
|
|
164
|
+
|
|
165
|
+
if (!item) {
|
|
166
|
+
throw new Error(`Missing file: ${fileId}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (requestBody.name) {
|
|
170
|
+
item.name = requestBody.name;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (Object.hasOwn(requestBody, "trashed")) {
|
|
174
|
+
item.trashed = Boolean(requestBody.trashed);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (addParents || removeParents) {
|
|
178
|
+
const nextParents = new Set(item.parents || []);
|
|
179
|
+
for (const parentId of String(removeParents || "").split(",").filter(Boolean)) {
|
|
180
|
+
nextParents.delete(parentId);
|
|
181
|
+
}
|
|
182
|
+
if (addParents) {
|
|
183
|
+
nextParents.add(addParents);
|
|
184
|
+
}
|
|
185
|
+
item.parents = [...nextParents];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (body.length && item.mimeType !== FOLDER_MIME) {
|
|
189
|
+
item.md5Checksum = md5(body);
|
|
190
|
+
item.size = body.length;
|
|
191
|
+
contentById.set(fileId, body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
touch(item);
|
|
195
|
+
return { data: clone(item) };
|
|
196
|
+
},
|
|
197
|
+
async delete({ fileId }) {
|
|
198
|
+
items.delete(fileId);
|
|
199
|
+
contentById.delete(fileId);
|
|
200
|
+
return { data: {} };
|
|
201
|
+
},
|
|
202
|
+
async get({ fileId, alt }) {
|
|
203
|
+
if (fileId === "root") {
|
|
204
|
+
return {
|
|
205
|
+
data: {
|
|
206
|
+
id: "root",
|
|
207
|
+
name: "My Drive",
|
|
208
|
+
mimeType: FOLDER_MIME,
|
|
209
|
+
parents: [],
|
|
210
|
+
createdTime: "2026-01-01T00:00:00.000Z",
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const item = items.get(fileId);
|
|
216
|
+
if (!item) {
|
|
217
|
+
throw new Error(`Missing file: ${fileId}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (alt === "media") {
|
|
221
|
+
return {
|
|
222
|
+
data: Readable.from([contentById.get(fileId) || Buffer.alloc(0)]),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { data: clone(item) };
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeLocal(root, relativePath, content) {
|
|
233
|
+
const absolutePath = path.join(root, relativePath);
|
|
234
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
235
|
+
fs.writeFileSync(absolutePath, content);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatStatus(diff, staged) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
|
|
241
|
+
if (diff.isClean && staged.length === 0) {
|
|
242
|
+
lines.push("Everything up to date.");
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (staged.length) {
|
|
247
|
+
lines.push(`Staged changes (${staged.length}):`);
|
|
248
|
+
for (const entry of staged) {
|
|
249
|
+
lines.push(` ${entry.action.padStart(15, " ")} ${entry.path}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const stagedPaths = new Set(staged.map((e) => e.path));
|
|
254
|
+
const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
|
|
255
|
+
const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
|
|
256
|
+
const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
|
|
257
|
+
|
|
258
|
+
if (unstagedRemote.length) {
|
|
259
|
+
lines.push(`Remote changes (${unstagedRemote.length}):`);
|
|
260
|
+
for (const change of unstagedRemote) {
|
|
261
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (unstagedLocal.length) {
|
|
266
|
+
lines.push(`Local changes (${unstagedLocal.length}):`);
|
|
267
|
+
for (const change of unstagedLocal) {
|
|
268
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (unstagedConflicts.length) {
|
|
273
|
+
lines.push(`Conflicts (${unstagedConflicts.length}):`);
|
|
274
|
+
for (const change of unstagedConflicts) {
|
|
275
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return lines;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatDiff(diff) {
|
|
283
|
+
if (diff.isClean) {
|
|
284
|
+
return ["No changes detected."];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lines = [];
|
|
288
|
+
const sections = [
|
|
289
|
+
["Remote changes", diff.remoteChanges],
|
|
290
|
+
["Local changes", diff.localChanges],
|
|
291
|
+
["Conflicts", diff.conflicts],
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
for (const [title, changes] of sections) {
|
|
295
|
+
if (!changes.length) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
lines.push(`${title}:`);
|
|
300
|
+
for (const change of changes) {
|
|
301
|
+
lines.push(` ${change.shortStatus} ${change.path}`);
|
|
302
|
+
lines.push(` ${change.description}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function commit(repo, message, snapshotHint) {
|
|
310
|
+
const result = await repo.executeStaged();
|
|
311
|
+
await repo.saveSnapshot(message, snapshotHint);
|
|
312
|
+
return result.summary;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function renderCommand(lines, command, output) {
|
|
316
|
+
if (lines.length) {
|
|
317
|
+
lines.push("");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
lines.push(`$ ${command}`);
|
|
321
|
+
lines.push(...output);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function generateDemoTranscript({ redactWorkspace = false } = {}) {
|
|
325
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "aethel-demo-"));
|
|
326
|
+
const visibleWorkspace = redactWorkspace ? "/tmp/aethel-demo-XXXXXX" : workspace;
|
|
327
|
+
resetFolderLookupCache();
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
initWorkspace(workspace, "root", "My Drive");
|
|
331
|
+
|
|
332
|
+
const drive = createFakeDrive([
|
|
333
|
+
folder("fld-docs", "docs", "root", "2026-04-01T10:00:00.000Z"),
|
|
334
|
+
folder("fld-notes", "notes", "root", "2026-04-01T10:01:00.000Z"),
|
|
335
|
+
file("file-spec", "spec.txt", "fld-docs", "2026-04-01T10:02:00.000Z", "Spec v1\n"),
|
|
336
|
+
file("file-ideas", "ideas.txt", "fld-notes", "2026-04-01T10:03:00.000Z", "Idea v1\n"),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const repo = new Repository(workspace, { drive });
|
|
340
|
+
|
|
341
|
+
writeLocal(workspace, "docs/spec.txt", "Spec v1\n");
|
|
342
|
+
writeLocal(workspace, "notes/ideas.txt", "Idea v1\n");
|
|
343
|
+
await repo.saveSnapshot("initial sync");
|
|
344
|
+
|
|
345
|
+
await drive.files.update({
|
|
346
|
+
fileId: "file-spec",
|
|
347
|
+
media: { body: Readable.from(["Spec v2 from Drive\n"]) },
|
|
348
|
+
});
|
|
349
|
+
const designFolderId = await ensureFolder(drive, "design");
|
|
350
|
+
await drive.files.create({
|
|
351
|
+
requestBody: {
|
|
352
|
+
name: "roadmap.txt",
|
|
353
|
+
parents: [designFolderId],
|
|
354
|
+
},
|
|
355
|
+
media: { body: Readable.from(["Roadmap from Drive\n"]) },
|
|
356
|
+
});
|
|
357
|
+
writeLocal(workspace, "notes/ideas.txt", "Idea v2 from local\n");
|
|
358
|
+
writeLocal(workspace, "drafts/todo.txt", "Local draft\n");
|
|
359
|
+
repo.invalidateRemoteCache();
|
|
360
|
+
|
|
361
|
+
const lines = [
|
|
362
|
+
"Aethel demo",
|
|
363
|
+
`Workspace: ${visibleWorkspace}`,
|
|
364
|
+
"Backend: fake Google Drive",
|
|
365
|
+
"",
|
|
366
|
+
"Scenario:",
|
|
367
|
+
" Drive changed docs/spec.txt and added design/roadmap.txt",
|
|
368
|
+
" Local changed notes/ideas.txt and added drafts/todo.txt",
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
let state = await repo.loadState({ useCache: false });
|
|
372
|
+
|
|
373
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
374
|
+
renderCommand(lines, "aethel diff --side all", formatDiff(state.diff));
|
|
375
|
+
|
|
376
|
+
const stagedCount = repo.stageChanges(
|
|
377
|
+
state.diff.changes.filter((change) => change.suggestedAction !== "conflict")
|
|
378
|
+
);
|
|
379
|
+
renderCommand(lines, "aethel add --all", [`Staged ${stagedCount} change(s).`]);
|
|
380
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
381
|
+
|
|
382
|
+
const summary = await commit(repo, "demo sync");
|
|
383
|
+
renderCommand(lines, 'aethel commit -m "demo sync"', [`Commit complete: ${summary}`]);
|
|
384
|
+
|
|
385
|
+
state = await repo.loadState({ useCache: false });
|
|
386
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
387
|
+
|
|
388
|
+
lines.push("");
|
|
389
|
+
lines.push(`Inspect the demo workspace at: ${visibleWorkspace}`);
|
|
390
|
+
|
|
391
|
+
return { workspace, lines };
|
|
392
|
+
} finally {
|
|
393
|
+
resetFolderLookupCache();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function runDemo({ cleanup = false, redactWorkspace = false } = {}) {
|
|
398
|
+
const { workspace, lines } = await generateDemoTranscript({ redactWorkspace });
|
|
399
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
400
|
+
|
|
401
|
+
if (cleanup) {
|
|
402
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
407
|
+
|
|
408
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === entryPath) {
|
|
409
|
+
const cleanup = process.argv.includes("--cleanup");
|
|
410
|
+
const redactWorkspace = process.argv.includes("--redact-workspace");
|
|
411
|
+
|
|
412
|
+
runDemo({ cleanup, redactWorkspace }).catch((error) => {
|
|
413
|
+
console.error(error?.stack || String(error));
|
|
414
|
+
process.exitCode = 1;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from subprocess import run
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
8
|
+
OUTPUT = ROOT / "docs" / "demo.gif"
|
|
9
|
+
DISPLAY_WIDTH = 1320
|
|
10
|
+
DISPLAY_MIN_HEIGHT = 760
|
|
11
|
+
LINE_HEIGHT = 30
|
|
12
|
+
SCALE = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_lines():
|
|
16
|
+
result = run(
|
|
17
|
+
["node", "scripts/demo.js", "--cleanup", "--redact-workspace"],
|
|
18
|
+
cwd=ROOT,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
check=True,
|
|
22
|
+
)
|
|
23
|
+
return result.stdout.rstrip("\n").splitlines()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_font(size):
|
|
27
|
+
for candidate in [
|
|
28
|
+
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
|
29
|
+
"/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
|
|
30
|
+
]:
|
|
31
|
+
path = Path(candidate)
|
|
32
|
+
if path.exists():
|
|
33
|
+
return ImageFont.truetype(str(path), size)
|
|
34
|
+
return ImageFont.load_default()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_title_font(size):
|
|
38
|
+
for candidate in [
|
|
39
|
+
"/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
|
|
40
|
+
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
|
41
|
+
]:
|
|
42
|
+
path = Path(candidate)
|
|
43
|
+
if path.exists():
|
|
44
|
+
return ImageFont.truetype(str(path), size)
|
|
45
|
+
return ImageFont.load_default()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_frame(lines, font, title_font, visible):
|
|
49
|
+
shown = lines[:visible]
|
|
50
|
+
width = DISPLAY_WIDTH * SCALE
|
|
51
|
+
height = max(DISPLAY_MIN_HEIGHT, 180 + len(lines) * LINE_HEIGHT) * SCALE
|
|
52
|
+
image = Image.new("RGB", (width, height), "#0b1324")
|
|
53
|
+
draw = ImageDraw.Draw(image)
|
|
54
|
+
draw.rounded_rectangle((40, 40, width - 40, height - 40), 44, fill="#0a0f1a", outline="#243044", width=4)
|
|
55
|
+
draw.rounded_rectangle((40, 40, width - 40, 108), 44, fill="#10192a")
|
|
56
|
+
draw.ellipse((84, 62, 108, 86), fill="#fb7185")
|
|
57
|
+
draw.ellipse((124, 62, 148, 86), fill="#f59e0b")
|
|
58
|
+
draw.ellipse((164, 62, 188, 86), fill="#22c55e")
|
|
59
|
+
draw.text((236, 52), "Aethel demo", font=title_font, fill="#dbe4f0")
|
|
60
|
+
y = 176
|
|
61
|
+
for line in shown:
|
|
62
|
+
draw.text((104, y), line, font=font, fill="#dbe4f0")
|
|
63
|
+
y += LINE_HEIGHT * SCALE
|
|
64
|
+
if visible < len(lines):
|
|
65
|
+
draw.rounded_rectangle((104, y + 8, 134, y + 48), 6, fill="#38bdf8")
|
|
66
|
+
return image.resize((DISPLAY_WIDTH, height // SCALE), Image.Resampling.LANCZOS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
lines = load_lines()
|
|
71
|
+
font = load_font(24 * SCALE)
|
|
72
|
+
title_font = load_title_font(22 * SCALE)
|
|
73
|
+
checkpoints = [3, 7, 13, 21, 27, len(lines)]
|
|
74
|
+
frames = [build_frame(lines, font, title_font, count) for count in checkpoints]
|
|
75
|
+
durations = [500, 600, 700, 800, 900, 1800]
|
|
76
|
+
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
frames[0].save(
|
|
78
|
+
OUTPUT,
|
|
79
|
+
save_all=True,
|
|
80
|
+
append_images=frames[1:],
|
|
81
|
+
duration=durations,
|
|
82
|
+
loop=0,
|
|
83
|
+
optimize=False,
|
|
84
|
+
disposal=2,
|
|
85
|
+
)
|
|
86
|
+
print(OUTPUT)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { generateDemoTranscript } from "./demo.js";
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const outputPath = path.join(root, "docs", "demo-screenshot.svg");
|
|
10
|
+
|
|
11
|
+
function escapeXml(value) {
|
|
12
|
+
return value
|
|
13
|
+
.replaceAll("&", "&")
|
|
14
|
+
.replaceAll("<", "<")
|
|
15
|
+
.replaceAll(">", ">")
|
|
16
|
+
.replaceAll('"', """);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { workspace, lines } = await generateDemoTranscript({ redactWorkspace: true });
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
23
|
+
const charWidth = 12.4;
|
|
24
|
+
const lineHeight = 32;
|
|
25
|
+
const width = Math.max(1120, Math.ceil(longest * charWidth) + 160);
|
|
26
|
+
const height = lines.length * lineHeight + 168;
|
|
27
|
+
const text = lines
|
|
28
|
+
.map(
|
|
29
|
+
(line, index) =>
|
|
30
|
+
`<text x="64" y="${100 + index * lineHeight}" xml:space="preserve">${escapeXml(line)}</text>`
|
|
31
|
+
)
|
|
32
|
+
.join("\n");
|
|
33
|
+
|
|
34
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Aethel demo screenshot">
|
|
35
|
+
<defs>
|
|
36
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
37
|
+
<stop offset="0%" stop-color="#0b1324"/>
|
|
38
|
+
<stop offset="55%" stop-color="#111827"/>
|
|
39
|
+
<stop offset="100%" stop-color="#1f2937"/>
|
|
40
|
+
</linearGradient>
|
|
41
|
+
<linearGradient id="glow" x1="0" y1="0" x2="1" y2="0">
|
|
42
|
+
<stop offset="0%" stop-color="#22c55e"/>
|
|
43
|
+
<stop offset="50%" stop-color="#38bdf8"/>
|
|
44
|
+
<stop offset="100%" stop-color="#f59e0b"/>
|
|
45
|
+
</linearGradient>
|
|
46
|
+
</defs>
|
|
47
|
+
<rect width="${width}" height="${height}" rx="28" fill="url(#bg)"/>
|
|
48
|
+
<rect x="24" y="24" width="${width - 48}" height="${height - 48}" rx="20" fill="#0a0f1a" stroke="#243044"/>
|
|
49
|
+
<rect x="24" y="24" width="${width - 48}" height="36" rx="20" fill="#10192a"/>
|
|
50
|
+
<circle cx="52" cy="42" r="7" fill="#fb7185"/>
|
|
51
|
+
<circle cx="74" cy="42" r="7" fill="#f59e0b"/>
|
|
52
|
+
<circle cx="96" cy="42" r="7" fill="#22c55e"/>
|
|
53
|
+
<rect x="132" y="35" width="${Math.min(320, width - 220)}" height="12" rx="6" fill="url(#glow)" opacity="0.8"/>
|
|
54
|
+
<g fill="#dbe4f0" font-family="'Times New Roman', Times, serif" font-size="22">
|
|
55
|
+
${text}
|
|
56
|
+
</g>
|
|
57
|
+
</svg>
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(outputPath, svg);
|
|
62
|
+
process.stdout.write(`${outputPath}\n`);
|
|
63
|
+
} finally {
|
|
64
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
65
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -301,23 +301,28 @@ async function handleStatus(options) {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
304
|
+
const stagedPaths = new Set(staged.map((e) => e.path));
|
|
305
|
+
const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
|
|
306
|
+
const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
|
|
307
|
+
const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
|
|
308
|
+
|
|
309
|
+
if (unstagedRemote.length) {
|
|
310
|
+
console.log(`\nRemote changes (${unstagedRemote.length}):`);
|
|
311
|
+
for (const change of unstagedRemote) {
|
|
307
312
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
308
313
|
}
|
|
309
314
|
}
|
|
310
315
|
|
|
311
|
-
if (
|
|
312
|
-
console.log(`\nLocal changes (${
|
|
313
|
-
for (const change of
|
|
316
|
+
if (unstagedLocal.length) {
|
|
317
|
+
console.log(`\nLocal changes (${unstagedLocal.length}):`);
|
|
318
|
+
for (const change of unstagedLocal) {
|
|
314
319
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
315
320
|
}
|
|
316
321
|
}
|
|
317
322
|
|
|
318
|
-
if (
|
|
319
|
-
console.log(`\nConflicts (${
|
|
320
|
-
for (const change of
|
|
323
|
+
if (unstagedConflicts.length) {
|
|
324
|
+
console.log(`\nConflicts (${unstagedConflicts.length}):`);
|
|
325
|
+
for (const change of unstagedConflicts) {
|
|
321
326
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
322
327
|
}
|
|
323
328
|
}
|
|
@@ -513,6 +518,37 @@ async function handlePull(paths, options) {
|
|
|
513
518
|
const repo = await openRepo(options);
|
|
514
519
|
const { diff, remoteState } = await loadStateWithProgress(repo, { useCache: false });
|
|
515
520
|
|
|
521
|
+
if (options.all) {
|
|
522
|
+
let remoteFiles = remoteState.files;
|
|
523
|
+
|
|
524
|
+
if (paths && paths.length > 0) {
|
|
525
|
+
remoteFiles = remoteFiles.filter((file) =>
|
|
526
|
+
paths.some((p) => matchesPattern(file.path, p))
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!remoteFiles.length) {
|
|
531
|
+
console.log("No remote files matched.");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (options.dryRun) {
|
|
536
|
+
console.log(`Would pull ${remoteFiles.length} remote item(s):`);
|
|
537
|
+
for (const file of remoteFiles) {
|
|
538
|
+
console.log(` +R ${file.path} (full remote download)`);
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const count = repo.stageRemoteFilesForDownload(remoteFiles);
|
|
544
|
+
console.log(`Staged ${count} remote item(s). Committing...`);
|
|
545
|
+
await handleCommit({ ...options, message: options.message || "pull" }, {
|
|
546
|
+
repo,
|
|
547
|
+
snapshotHint: { remote: remoteState },
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
516
552
|
let remoteChanges = diff.changes.filter((change) =>
|
|
517
553
|
[
|
|
518
554
|
ChangeType.REMOTE_ADDED,
|
|
@@ -1100,6 +1136,7 @@ async function main() {
|
|
|
1100
1136
|
.command("pull")
|
|
1101
1137
|
.description("Download remote changes")
|
|
1102
1138
|
.argument("[paths...]", "Specific paths to pull (default: all)")
|
|
1139
|
+
.option("--all", "Download all remote files regardless of snapshot state")
|
|
1103
1140
|
.option("-m, --message <message>", "Commit message")
|
|
1104
1141
|
.option("--force", "Force-pull conflicts (remote wins)")
|
|
1105
1142
|
.option("--dry-run", "Preview changes without applying")
|
package/src/core/repository.js
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
stageChange,
|
|
48
48
|
stageChanges,
|
|
49
49
|
stageConflictResolution,
|
|
50
|
+
stageRemoteFilesForDownload,
|
|
50
51
|
stagedEntries,
|
|
51
52
|
unstageAll,
|
|
52
53
|
unstagePath,
|
|
@@ -190,6 +191,10 @@ export class Repository {
|
|
|
190
191
|
return stageChanges(this._root, changes);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
stageRemoteFilesForDownload(remoteFiles) {
|
|
195
|
+
return stageRemoteFilesForDownload(this._root, remoteFiles);
|
|
196
|
+
}
|
|
197
|
+
|
|
193
198
|
unstagePath(targetPath) {
|
|
194
199
|
return unstagePath(this._root, targetPath);
|
|
195
200
|
}
|
package/src/core/staging.js
CHANGED
|
@@ -50,6 +50,26 @@ export function stageChanges(root, changes) {
|
|
|
50
50
|
return changes.length;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function stageRemoteFilesForDownload(root, remoteFiles) {
|
|
54
|
+
const index = readIndex(root);
|
|
55
|
+
const byPath = new Map((index.staged || []).map((entry) => [entry.path, entry]));
|
|
56
|
+
|
|
57
|
+
for (const remoteFile of remoteFiles) {
|
|
58
|
+
byPath.set(remoteFile.path, {
|
|
59
|
+
action: "download",
|
|
60
|
+
path: remoteFile.path,
|
|
61
|
+
localPath: remoteFile.path,
|
|
62
|
+
fileId: remoteFile.id,
|
|
63
|
+
remotePath: remoteFile.path,
|
|
64
|
+
...(remoteFile.isFolder ? { isFolder: true } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
index.staged = [...byPath.values()];
|
|
69
|
+
writeIndex(root, index);
|
|
70
|
+
return remoteFiles.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
export function unstagePath(root, targetPath) {
|
|
54
74
|
const index = readIndex(root);
|
|
55
75
|
const staged = index.staged || [];
|