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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0 (2026-04-06)
4
+
5
+ - release: 1.0.0
6
+
7
+ ## 0.4.0 (2026-04-06)
8
+
9
+ - Add pull --all for full remote download
10
+
3
11
  ## 0.3.8 (2026-04-06)
4
12
 
5
13
  - Fix Drive upload checksum test stub
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
+ ![Aethel setup flow](docs/setup.gif)
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
+ ![Aethel init flow](docs/init.gif)
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
+ ![Aethel usage flow](docs/usage.gif)
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 | Description |
108
- |---------|-------------|
109
- | `auth` | OAuth flow — creates `token.json`, verifies Drive access |
110
- | `init` | Initialize a local sync workspace |
111
- | `status` | Show local vs remote changes |
112
- | `diff` | Detailed file differences |
113
- | `add` | Stage changes |
114
- | `reset` | Unstage changes |
115
- | `commit` | Execute staged sync operations |
116
- | `pull` | Fetch and apply remote changes |
117
- | `push` | Push local changes to Drive |
118
- | `log` | Sync history |
119
- | `fetch` | Refresh remote state without applying |
120
- | `resolve` | Resolve conflicts (local / remote / both) |
121
- | `ignore` | Manage `.aethelignore` patterns |
122
- | `show` | Inspect a saved snapshot |
123
- | `restore` | Restore files from the last snapshot |
124
- | `rm` | Remove local files and stage remote deletion |
125
- | `mv` | Move or rename local files |
126
- | `clean` | List and optionally trash/delete Drive files |
127
- | `dedupe-folders` | Detect and merge duplicate remote folders |
128
- | `tui` | Launch interactive terminal UI |
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 | Action |
139
- |-----|--------|
140
- | `Tab` | Switch panes |
141
- | `Left` / `Right` | Navigate up / into directories |
142
- | `u` | Upload selected local file or folder to Drive |
143
- | `s` | Batch sync local folder to current Drive directory |
144
- | `U` | Upload from a manually entered path |
145
- | `n` | Rename selected local item |
146
- | `x` | Delete selected local item |
147
- | `Space` | Toggle selection in Drive pane |
148
- | `t` / `d` | Trash / permanently delete selected Drive items |
149
- | `/` | Filter by name |
150
- | `f` | Open the commands page and choose a TUI action |
151
- | `:` | Run any Aethel CLI command inside the TUI |
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 | Default | Description |
169
- |----------|---------|-------------|
170
- | `GOOGLE_DRIVE_CREDENTIALS_PATH` | `~/.config/aethel/credentials.json` | Path to OAuth credentials |
171
- | `GOOGLE_DRIVE_TOKEN_PATH` | `~/.config/aethel/token.json` | Path to cached OAuth token |
172
- | `AETHEL_DRIVE_CONCURRENCY` | `40` | Max concurrent Drive API requests |
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.8",
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",
@@ -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("&", "&amp;")
14
+ .replaceAll("<", "&lt;")
15
+ .replaceAll(">", "&gt;")
16
+ .replaceAll('"', "&quot;");
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
- if (diff.remoteChanges.length) {
305
- console.log(`\nRemote changes (${diff.remoteChanges.length}):`);
306
- for (const change of diff.remoteChanges) {
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 (diff.localChanges.length) {
312
- console.log(`\nLocal changes (${diff.localChanges.length}):`);
313
- for (const change of diff.localChanges) {
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 (diff.conflicts.length) {
319
- console.log(`\nConflicts (${diff.conflicts.length}):`);
320
- for (const change of diff.conflicts) {
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")
@@ -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
  }
@@ -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 || [];