aethel 0.3.0 → 0.3.2
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 +31 -7
- package/package.json +2 -1
- package/src/cli.js +69 -17
- package/src/core/auth.js +17 -5
- package/src/core/drive-api.js +18 -0
- package/src/core/repository.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 (2026-04-05)
|
|
4
|
+
|
|
5
|
+
- Add --version flag, interactive init folder selection, and release script
|
|
6
|
+
|
|
7
|
+
## 0.3.1 (2026-04-05)
|
|
8
|
+
|
|
9
|
+
- Improve setup SOP: default credentials to ~/.config/aethel/ with guided error message
|
|
10
|
+
|
|
3
11
|
## 0.3.0 (2026-04-05)
|
|
4
12
|
|
|
5
13
|
- Refactor to Repository pattern; add TUI command system with catalog, CLI runner, and tests
|
package/README.md
CHANGED
|
@@ -33,14 +33,38 @@ npm run install:cli # symlinks `aethel` into ~/.local/bin
|
|
|
33
33
|
|
|
34
34
|
## Setup
|
|
35
35
|
|
|
36
|
-
1.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
### 1. Get Google OAuth Credentials
|
|
37
|
+
|
|
38
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
39
|
+
2. Create a project (or select an existing one)
|
|
40
|
+
3. Enable the **Google Drive API** (APIs & Services → Library)
|
|
41
|
+
4. Go to **APIs & Services → Credentials**
|
|
42
|
+
5. Click **Create Credentials → OAuth 2.0 Client ID**
|
|
43
|
+
6. Application type: **Desktop application**
|
|
44
|
+
7. Download the JSON file
|
|
45
|
+
|
|
46
|
+
### 2. Save Credentials
|
|
47
|
+
|
|
48
|
+
Save the downloaded JSON as `~/.config/aethel/credentials.json`:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
mkdir -p ~/.config/aethel
|
|
52
|
+
mv ~/Downloads/client_secret_*.json ~/.config/aethel/credentials.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
You can also place `credentials.json` in the current directory, or pass a custom path with `--credentials`.
|
|
56
|
+
|
|
57
|
+
### 3. Authenticate
|
|
40
58
|
|
|
41
59
|
```bash
|
|
42
60
|
aethel auth # opens browser, saves token.json
|
|
43
|
-
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 4. Initialize a Workspace
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
aethel init --local-path ./my-drive # sync entire My Drive
|
|
67
|
+
aethel init --local-path ./workspace --drive-folder <folder-id> # sync specific folder
|
|
44
68
|
```
|
|
45
69
|
|
|
46
70
|
> `credentials.json` and `token.json` are local secrets — never commit them.
|
|
@@ -143,8 +167,8 @@ build/
|
|
|
143
167
|
|
|
144
168
|
| Variable | Default | Description |
|
|
145
169
|
|----------|---------|-------------|
|
|
146
|
-
| `GOOGLE_DRIVE_CREDENTIALS_PATH` |
|
|
147
|
-
| `GOOGLE_DRIVE_TOKEN_PATH` |
|
|
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 |
|
|
148
172
|
| `AETHEL_DRIVE_CONCURRENCY` | `40` | Max concurrent Drive API requests |
|
|
149
173
|
|
|
150
174
|
## Architecture
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aethel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Git-style Google Drive sync CLI with interactive TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"auth": "node src/cli.js auth",
|
|
38
38
|
"clean": "node src/cli.js clean",
|
|
39
39
|
"install:cli": "bash scripts/install.sh",
|
|
40
|
+
"release": "bash scripts/release.sh",
|
|
40
41
|
"pack:check": "npm pack --dry-run",
|
|
41
42
|
"prepublishOnly": "npm test && npm run pack:check",
|
|
42
43
|
"test": "node --test",
|
package/src/cli.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
6
|
+
import readline from "node:readline/promises";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
5
8
|
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
|
|
12
|
+
|
|
13
|
+
function getGitHash() {
|
|
14
|
+
try {
|
|
15
|
+
return execSync("git rev-parse --short HEAD", { cwd: __dirname, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const gitHash = getGitHash();
|
|
22
|
+
const versionString = gitHash ? `${pkg.version} (${gitHash})` : pkg.version;
|
|
6
23
|
import { resolveCredentialsPath, resolveTokenPath } from "./core/auth.js";
|
|
7
24
|
import {
|
|
8
25
|
initWorkspace,
|
|
@@ -145,26 +162,59 @@ async function handleClean(options) {
|
|
|
145
162
|
|
|
146
163
|
async function handleInit(options) {
|
|
147
164
|
const localPath = path.resolve(options.localPath);
|
|
165
|
+
let driveFolderId = options.driveFolder || null;
|
|
166
|
+
let driveFolderName = options.driveFolderName || null;
|
|
167
|
+
|
|
168
|
+
// Interactive folder selection when no --drive-folder is provided
|
|
169
|
+
if (!driveFolderId) {
|
|
170
|
+
const repo = await openRepo(options, { requireWorkspace: false });
|
|
171
|
+
console.log("Fetching root-level Drive folders...");
|
|
172
|
+
const folders = await repo.listRootFolders();
|
|
173
|
+
|
|
174
|
+
if (folders.length === 0) {
|
|
175
|
+
console.log("No folders found in Drive root. Syncing entire My Drive.");
|
|
176
|
+
driveFolderName = "My Drive";
|
|
177
|
+
} else {
|
|
178
|
+
console.log("\nDrive folders:");
|
|
179
|
+
console.log(" 0) My Drive (entire drive)");
|
|
180
|
+
for (const [i, folder] of folders.entries()) {
|
|
181
|
+
console.log(` ${i + 1}) ${folder.name}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
185
|
+
try {
|
|
186
|
+
const answer = await rl.question(`\nSelect a folder [0-${folders.length}]: `);
|
|
187
|
+
const index = Number.parseInt(answer, 10);
|
|
188
|
+
|
|
189
|
+
if (index === 0 || answer.trim() === "") {
|
|
190
|
+
driveFolderName = "My Drive";
|
|
191
|
+
} else if (index >= 1 && index <= folders.length) {
|
|
192
|
+
const selected = folders[index - 1];
|
|
193
|
+
driveFolderId = selected.id;
|
|
194
|
+
driveFolderName = selected.name;
|
|
195
|
+
} else {
|
|
196
|
+
console.log("Invalid selection. Aborting.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
rl.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
148
204
|
|
|
149
205
|
if (!fs.existsSync(localPath)) {
|
|
150
206
|
await fs.promises.mkdir(localPath, { recursive: true });
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
const root = initWorkspace(
|
|
154
|
-
localPath,
|
|
155
|
-
options.driveFolder || null,
|
|
156
|
-
options.driveFolderName || "My Drive"
|
|
157
|
-
);
|
|
209
|
+
const root = initWorkspace(localPath, driveFolderId, driveFolderName || "My Drive");
|
|
158
210
|
|
|
159
211
|
const created = createDefaultIgnoreFile(root);
|
|
160
|
-
console.log(
|
|
212
|
+
console.log(`\nInitialised Aethel workspace at ${root}`);
|
|
161
213
|
if (created) {
|
|
162
214
|
console.log(" Created .aethelignore with default patterns");
|
|
163
215
|
}
|
|
164
|
-
if (
|
|
165
|
-
console.log(
|
|
166
|
-
` Drive folder: ${options.driveFolderName || "My Drive"} (${options.driveFolder})`
|
|
167
|
-
);
|
|
216
|
+
if (driveFolderId) {
|
|
217
|
+
console.log(` Drive folder: ${driveFolderName} (${driveFolderId})`);
|
|
168
218
|
} else {
|
|
169
219
|
console.log(" Syncing entire My Drive");
|
|
170
220
|
}
|
|
@@ -816,6 +866,7 @@ async function main() {
|
|
|
816
866
|
|
|
817
867
|
program
|
|
818
868
|
.name("aethel")
|
|
869
|
+
.version(versionString, "-v, --version")
|
|
819
870
|
.description("Git-like Google Drive sync management and cleanup")
|
|
820
871
|
.showHelpAfterError();
|
|
821
872
|
|
|
@@ -835,13 +886,14 @@ async function main() {
|
|
|
835
886
|
.option("--confirm <phrase>", "Confirmation phrase required for --execute", "")
|
|
836
887
|
).action(handleClean);
|
|
837
888
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
889
|
+
addAuthOptions(
|
|
890
|
+
program
|
|
891
|
+
.command("init")
|
|
892
|
+
.description("Initialise a sync workspace")
|
|
893
|
+
.option("--local-path <path>", "Local directory to sync", ".")
|
|
894
|
+
.option("--drive-folder <id>", "Drive folder ID to sync (omit for interactive selection)")
|
|
895
|
+
.option("--drive-folder-name <name>", "Display name for the Drive folder")
|
|
896
|
+
).action(handleInit);
|
|
845
897
|
|
|
846
898
|
addAuthOptions(program.command("status").description("Show sync status")).action(
|
|
847
899
|
handleStatus
|
package/src/core/auth.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import fsSyncFallback from "node:fs";
|
|
3
3
|
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { URL } from "node:url";
|
|
7
7
|
import { google } from "googleapis";
|
|
8
8
|
import open from "open";
|
|
@@ -10,8 +10,7 @@ import open from "open";
|
|
|
10
10
|
const SCOPES = ["https://www.googleapis.com/auth/drive"];
|
|
11
11
|
const DEFAULT_CREDENTIALS_PATH = "credentials.json";
|
|
12
12
|
const DEFAULT_TOKEN_PATH = "token.json";
|
|
13
|
-
const
|
|
14
|
-
const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", "..");
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "aethel");
|
|
15
14
|
const AUTH_TIMEOUT_MS = 120_000;
|
|
16
15
|
|
|
17
16
|
// ── Path resolution ─────────────────────────────────────────────────
|
|
@@ -22,7 +21,12 @@ function resolvePath(candidatePath, fallbackFileName) {
|
|
|
22
21
|
? candidatePath
|
|
23
22
|
: path.resolve(process.cwd(), candidatePath);
|
|
24
23
|
}
|
|
25
|
-
|
|
24
|
+
// Also check cwd before falling back to ~/.config/aethel/
|
|
25
|
+
const cwdPath = path.resolve(process.cwd(), fallbackFileName);
|
|
26
|
+
if (fsSyncFallback.existsSync(cwdPath)) {
|
|
27
|
+
return cwdPath;
|
|
28
|
+
}
|
|
29
|
+
return path.join(CONFIG_DIR, fallbackFileName);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function resolveCredentialsPath(customPath) {
|
|
@@ -47,7 +51,15 @@ async function loadClientConfig(credentialsPath) {
|
|
|
47
51
|
raw = await fs.readFile(credentialsPath, "utf8");
|
|
48
52
|
} catch {
|
|
49
53
|
throw new Error(
|
|
50
|
-
`OAuth credentials file
|
|
54
|
+
`OAuth credentials file not found: ${credentialsPath}\n\n` +
|
|
55
|
+
"Setup steps:\n" +
|
|
56
|
+
" 1. Go to https://console.cloud.google.com/\n" +
|
|
57
|
+
" 2. Create a project and enable the Google Drive API\n" +
|
|
58
|
+
" 3. Create an OAuth 2.0 Client ID (Desktop application)\n" +
|
|
59
|
+
" 4. Download the credentials JSON and save it as:\n" +
|
|
60
|
+
` ${path.join(CONFIG_DIR, DEFAULT_CREDENTIALS_PATH)}\n\n` +
|
|
61
|
+
"Or pass a custom path:\n" +
|
|
62
|
+
" aethel auth --credentials /path/to/credentials.json"
|
|
51
63
|
);
|
|
52
64
|
}
|
|
53
65
|
|
package/src/core/drive-api.js
CHANGED
|
@@ -685,6 +685,24 @@ export async function getAccountInfo(drive) {
|
|
|
685
685
|
};
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
+
export async function listRootFolders(drive) {
|
|
689
|
+
const items = [];
|
|
690
|
+
let pageToken = null;
|
|
691
|
+
|
|
692
|
+
do {
|
|
693
|
+
const response = await drive.files.list({
|
|
694
|
+
q: `'root' in parents and mimeType = '${FOLDER_MIME}' and trashed = false`,
|
|
695
|
+
fields: "nextPageToken, files(id, name, modifiedTime)",
|
|
696
|
+
pageSize: PAGE_SIZE,
|
|
697
|
+
pageToken,
|
|
698
|
+
});
|
|
699
|
+
items.push(...(response.data.files || []));
|
|
700
|
+
pageToken = response.data.nextPageToken;
|
|
701
|
+
} while (pageToken);
|
|
702
|
+
|
|
703
|
+
return items.sort((a, b) => a.name.localeCompare(b.name));
|
|
704
|
+
}
|
|
705
|
+
|
|
688
706
|
export async function listAccessibleFiles(drive, includeSharedDrives = false) {
|
|
689
707
|
const richFields =
|
|
690
708
|
"nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, ownedByMe, shared, driveId, owners(displayName,emailAddress), capabilities(canAddChildren,canEdit,canTrash,canDelete,canRename))";
|
package/src/core/repository.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getAccountInfo,
|
|
27
27
|
getRemoteState,
|
|
28
28
|
listAccessibleFiles,
|
|
29
|
+
listRootFolders,
|
|
29
30
|
syncLocalDirectoryToParent,
|
|
30
31
|
uploadLocalEntry,
|
|
31
32
|
withDriveRetry,
|
|
@@ -201,6 +202,10 @@ export class Repository {
|
|
|
201
202
|
|
|
202
203
|
// ── File browser (TUI) ─────────────────────────────────────────────
|
|
203
204
|
|
|
205
|
+
async listRootFolders() {
|
|
206
|
+
return listRootFolders(this.drive);
|
|
207
|
+
}
|
|
208
|
+
|
|
204
209
|
async listRemoteFiles({ includeSharedDrives = false } = {}) {
|
|
205
210
|
return listAccessibleFiles(this.drive, includeSharedDrives);
|
|
206
211
|
}
|