@bobfrankston/gcards 0.1.0 → 0.1.4
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/.claude/settings.local.json +14 -1
- package/.gitattributes +1 -0
- package/.vscode/settings.json +22 -2
- package/README.md +215 -61
- package/gcards.ts +498 -185
- package/gfix.ts +1472 -0
- package/glib/gctypes.ts +110 -0
- package/glib/gmerge.ts +290 -0
- package/glib/gutils.ts +141 -0
- package/{cli.ts → glib/parsecli.ts} +41 -5
- package/{types.ts → glib/types.ts} +305 -305
- package/package.json +6 -4
package/gcards.ts
CHANGED
|
@@ -5,102 +5,58 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from 'fs';
|
|
8
|
+
import fp from 'fs/promises';
|
|
8
9
|
import path from 'path';
|
|
9
|
-
import
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
|
|
10
12
|
import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
|
|
11
|
-
import type { GooglePerson, GoogleConnectionsResponse } from './types.ts';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
|
|
16
|
-
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
13
|
+
import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
14
|
+
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
15
|
+
import type { ContactIndex, IndexEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
16
|
+
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
|
|
17
17
|
|
|
18
18
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
19
19
|
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
20
20
|
const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
function ts(): string {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
return `[${now.toTimeString().slice(0, 8)}]`;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
userDir: string;
|
|
28
|
-
contactsDir: string;
|
|
29
|
-
deletedDir: string;
|
|
30
|
-
indexFile: string;
|
|
31
|
-
statusFile: string;
|
|
32
|
-
syncTokenFile: string;
|
|
33
|
-
tokenFile: string;
|
|
34
|
-
tokenWriteFile: string;
|
|
35
|
-
}
|
|
27
|
+
let escapePressed = false;
|
|
36
28
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
29
|
+
function setupEscapeHandler(): void {
|
|
30
|
+
// Handle Ctrl+C via SIGINT (works without raw mode)
|
|
31
|
+
process.on('SIGINT', () => {
|
|
32
|
+
escapePressed = true;
|
|
33
|
+
console.log('\n\nCtrl+C pressed - stopping after current operation...');
|
|
34
|
+
});
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
36
|
+
// Handle ESC key via raw mode (optional, for graceful stop)
|
|
37
|
+
if (process.stdin.isTTY) {
|
|
38
|
+
process.stdin.setRawMode(true);
|
|
39
|
+
process.stdin.resume();
|
|
40
|
+
process.stdin.on('data', (key) => {
|
|
41
|
+
if (key[0] === 27) { // ESC key
|
|
42
|
+
escapePressed = true;
|
|
43
|
+
console.log('\n\nESC pressed - stopping after current operation...');
|
|
44
|
+
} else if (key[0] === 3) { // Ctrl+C in raw mode
|
|
45
|
+
escapePressed = true;
|
|
46
|
+
console.log('\n\nCtrl+C pressed - stopping after current operation...');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
47
49
|
}
|
|
48
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Normalize Gmail username: lowercase, remove dots, strip +suffix and @domain */
|
|
52
|
-
function normalizeUser(user: string): string {
|
|
53
|
-
return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function getUserPaths(user: string): UserPaths {
|
|
57
|
-
const userDir = path.join(DATA_DIR, user);
|
|
58
|
-
return {
|
|
59
|
-
userDir,
|
|
60
|
-
contactsDir: path.join(userDir, 'contacts'),
|
|
61
|
-
deletedDir: path.join(userDir, 'deleted'),
|
|
62
|
-
indexFile: path.join(userDir, 'index.json'),
|
|
63
|
-
statusFile: path.join(userDir, 'status.json'),
|
|
64
|
-
syncTokenFile: path.join(userDir, 'sync-token.json'),
|
|
65
|
-
tokenFile: path.join(userDir, 'token.json'),
|
|
66
|
-
tokenWriteFile: path.join(userDir, 'token-write.json')
|
|
67
|
-
};
|
|
68
50
|
}
|
|
69
51
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
function cleanupEscapeHandler(): void {
|
|
53
|
+
if (process.stdin.isTTY) {
|
|
54
|
+
process.stdin.setRawMode(false);
|
|
55
|
+
process.stdin.pause();
|
|
74
56
|
}
|
|
75
57
|
}
|
|
76
58
|
|
|
77
|
-
|
|
78
|
-
contacts: Record<string, IndexEntry>; /** resourceName -> entry */
|
|
79
|
-
lastSync: string;
|
|
80
|
-
syncToken?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface IndexEntry {
|
|
84
|
-
resourceName: string;
|
|
85
|
-
displayName: string;
|
|
86
|
-
etag?: string;
|
|
87
|
-
deleted: boolean;
|
|
88
|
-
deletedAt?: string;
|
|
89
|
-
updatedAt: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface PushStatus {
|
|
93
|
-
lastPush: string; /** ISO timestamp of last push */
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface PendingChange {
|
|
97
|
-
type: 'update' | 'delete';
|
|
98
|
-
resourceName: string;
|
|
99
|
-
displayName: string;
|
|
100
|
-
filePath: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function getAccessToken(user: string, writeAccess = false): Promise<string> {
|
|
59
|
+
async function getAccessToken(user: string, writeAccess = false, forceRefresh = false): Promise<string> {
|
|
104
60
|
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
105
61
|
throw new Error(`Credentials file not found: ${CREDENTIALS_FILE}`);
|
|
106
62
|
}
|
|
@@ -110,6 +66,13 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
|
|
|
110
66
|
|
|
111
67
|
const scope = writeAccess ? CONTACTS_SCOPE_WRITE : CONTACTS_SCOPE_READ;
|
|
112
68
|
const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
|
|
69
|
+
const tokenFilePath = path.join(paths.userDir, tokenFileName);
|
|
70
|
+
|
|
71
|
+
// Delete cached token to force re-auth
|
|
72
|
+
if (forceRefresh && fs.existsSync(tokenFilePath)) {
|
|
73
|
+
fs.unlinkSync(tokenFilePath);
|
|
74
|
+
console.log(`${ts()} Token expired, refreshing...`);
|
|
75
|
+
}
|
|
113
76
|
|
|
114
77
|
const token = await authenticateOAuth(CREDENTIALS_FILE, {
|
|
115
78
|
scope,
|
|
@@ -126,18 +89,56 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
|
|
|
126
89
|
return token.access_token;
|
|
127
90
|
}
|
|
128
91
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
92
|
+
// Mutable token holder for refresh during long operations
|
|
93
|
+
let currentAccessToken = '';
|
|
94
|
+
let currentUser = '';
|
|
95
|
+
|
|
96
|
+
async function refreshAccessToken(): Promise<string> {
|
|
97
|
+
currentAccessToken = await getAccessToken(currentUser, true, true);
|
|
98
|
+
return currentAccessToken;
|
|
134
99
|
}
|
|
135
100
|
|
|
136
101
|
function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
102
|
+
// Sort contacts by displayName for convenience
|
|
103
|
+
const sortedContacts: Record<string, IndexEntry> = {};
|
|
104
|
+
const entries = Object.entries(index.contacts);
|
|
105
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
106
|
+
for (const [key, value] of entries) {
|
|
107
|
+
sortedContacts[key] = value;
|
|
108
|
+
}
|
|
109
|
+
index.contacts = sortedContacts;
|
|
137
110
|
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
138
111
|
}
|
|
139
112
|
|
|
140
|
-
|
|
113
|
+
/** Deleted contacts index (separate from active index) */
|
|
114
|
+
interface DeletedIndex {
|
|
115
|
+
deleted: Record<string, IndexEntry>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
119
|
+
if (fs.existsSync(paths.deletedFile)) {
|
|
120
|
+
return JSON.parse(fs.readFileSync(paths.deletedFile, 'utf-8'));
|
|
121
|
+
}
|
|
122
|
+
return { deleted: {} };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
126
|
+
// Sort by displayName
|
|
127
|
+
const sorted: Record<string, IndexEntry> = {};
|
|
128
|
+
const entries = Object.entries(deletedIndex.deleted);
|
|
129
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
130
|
+
for (const [key, value] of entries) {
|
|
131
|
+
sorted[key] = value;
|
|
132
|
+
}
|
|
133
|
+
deletedIndex.deleted = sorted;
|
|
134
|
+
fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function generateGuid(): string {
|
|
138
|
+
return crypto.randomUUID();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadSyncToken(paths: UserPaths): string {
|
|
141
142
|
if (fs.existsSync(paths.syncTokenFile)) {
|
|
142
143
|
const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
|
|
143
144
|
return data.syncToken || null;
|
|
@@ -163,7 +164,7 @@ async function fetchContactsWithRetry(
|
|
|
163
164
|
const BASE_DELAY = 5000; /** 5 seconds base delay */
|
|
164
165
|
|
|
165
166
|
const params = new URLSearchParams({
|
|
166
|
-
personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
|
|
167
|
+
personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata,fileAses',
|
|
167
168
|
pageSize: '100' /** Smaller page size to avoid quota issues */
|
|
168
169
|
});
|
|
169
170
|
|
|
@@ -230,8 +231,22 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
230
231
|
const accessToken = await getAccessToken(user);
|
|
231
232
|
|
|
232
233
|
const index = loadIndex(paths);
|
|
234
|
+
const deletedIndex = loadDeleted(paths);
|
|
233
235
|
let syncToken = options.full ? null : loadSyncToken(paths);
|
|
234
236
|
|
|
237
|
+
// Migrate any deleted entries from index.json to deleted.json
|
|
238
|
+
let migrated = 0;
|
|
239
|
+
for (const [rn, entry] of Object.entries(index.contacts)) {
|
|
240
|
+
if (entry.deleted) {
|
|
241
|
+
deletedIndex.deleted[rn] = entry;
|
|
242
|
+
delete index.contacts[rn];
|
|
243
|
+
migrated++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (migrated > 0) {
|
|
247
|
+
console.log(`Migrated ${migrated} deleted entries to deleted.json`);
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
if (syncToken) {
|
|
236
251
|
console.log('Performing incremental sync...');
|
|
237
252
|
} else {
|
|
@@ -243,6 +258,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
243
258
|
let added = 0;
|
|
244
259
|
let updated = 0;
|
|
245
260
|
let deleted = 0;
|
|
261
|
+
let conflicts = 0;
|
|
246
262
|
let pageNum = 0;
|
|
247
263
|
|
|
248
264
|
do {
|
|
@@ -257,23 +273,48 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
257
273
|
const displayName = person.names?.[0]?.displayName || 'Unknown';
|
|
258
274
|
|
|
259
275
|
if (isDeleted) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
276
|
+
const entry = index.contacts[person.resourceName];
|
|
277
|
+
if (entry) {
|
|
278
|
+
// Move to deleted.json
|
|
279
|
+
entry.deleted = true;
|
|
280
|
+
entry.deletedAt = new Date().toISOString();
|
|
281
|
+
deletedIndex.deleted[person.resourceName] = entry;
|
|
282
|
+
delete index.contacts[person.resourceName];
|
|
263
283
|
deleteContactFile(paths, person.resourceName);
|
|
264
284
|
deleted++;
|
|
265
285
|
if (options.verbose) console.log(`\n Deleted: ${displayName}`);
|
|
266
286
|
}
|
|
267
287
|
} else {
|
|
268
288
|
const existed = !!index.contacts[person.resourceName];
|
|
289
|
+
|
|
290
|
+
// Check for local conflict before overwriting
|
|
291
|
+
const filePath = path.join(paths.contactsDir, `${person.resourceName.replace(/\//g, '_')}.json`);
|
|
292
|
+
const indexEntry = index.contacts[person.resourceName];
|
|
293
|
+
if (existed && fs.existsSync(filePath)) {
|
|
294
|
+
const stat = fs.statSync(filePath);
|
|
295
|
+
const modTime = stat.mtimeMs;
|
|
296
|
+
const indexUpdatedAt = indexEntry?.updatedAt ? new Date(indexEntry.updatedAt).getTime() : 0;
|
|
297
|
+
|
|
298
|
+
if (modTime > indexUpdatedAt + 1000) {
|
|
299
|
+
// Local file was modified - conflict!
|
|
300
|
+
console.log(`\n CONFLICT: ${displayName} - local changes exist, skipping (delete local file and sync to get server version)`);
|
|
301
|
+
conflicts++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
269
306
|
saveContact(paths, person);
|
|
270
307
|
|
|
308
|
+
// Extract GUIDs from userDefined fields
|
|
309
|
+
const guids = extractGuids(person);
|
|
310
|
+
|
|
271
311
|
index.contacts[person.resourceName] = {
|
|
272
312
|
resourceName: person.resourceName,
|
|
273
313
|
displayName,
|
|
274
314
|
etag: person.etag,
|
|
275
315
|
deleted: false,
|
|
276
|
-
updatedAt: new Date().toISOString()
|
|
316
|
+
updatedAt: new Date().toISOString(),
|
|
317
|
+
guids: guids.length > 0 ? guids : undefined
|
|
277
318
|
};
|
|
278
319
|
|
|
279
320
|
if (existed) {
|
|
@@ -298,21 +339,30 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
298
339
|
// Save index after each page in case of interruption
|
|
299
340
|
index.lastSync = new Date().toISOString();
|
|
300
341
|
saveIndex(paths, index);
|
|
342
|
+
saveDeleted(paths, deletedIndex);
|
|
301
343
|
|
|
302
344
|
// Small delay between pages to be nice to the API
|
|
303
345
|
if (pageToken) {
|
|
304
346
|
await sleep(500);
|
|
305
347
|
}
|
|
348
|
+
|
|
349
|
+
if (escapePressed) {
|
|
350
|
+
console.log('\n\nStopped by user. Progress saved.');
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
306
353
|
} while (pageToken);
|
|
307
354
|
|
|
308
|
-
const activeContacts = Object.
|
|
309
|
-
const tombstones = Object.
|
|
355
|
+
const activeContacts = Object.keys(index.contacts).length;
|
|
356
|
+
const tombstones = Object.keys(deletedIndex.deleted).length;
|
|
310
357
|
|
|
311
358
|
console.log(`\n\nSync complete:`);
|
|
312
359
|
console.log(` Processed: ${totalProcessed}`);
|
|
313
360
|
console.log(` Added: ${added}`);
|
|
314
361
|
console.log(` Updated: ${updated}`);
|
|
315
362
|
console.log(` Deleted: ${deleted}`);
|
|
363
|
+
if (conflicts > 0) {
|
|
364
|
+
console.log(` Conflicts: ${conflicts} (delete local file and sync to resolve)`);
|
|
365
|
+
}
|
|
316
366
|
console.log(` Active contacts: ${activeContacts}`);
|
|
317
367
|
console.log(` Tombstones: ${tombstones}`);
|
|
318
368
|
}
|
|
@@ -328,48 +378,92 @@ function saveStatus(paths: UserPaths, status: PushStatus): void {
|
|
|
328
378
|
fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
|
|
329
379
|
}
|
|
330
380
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
381
|
+
interface PendingChangesResult {
|
|
382
|
+
changes: PendingChange[];
|
|
383
|
+
parseErrors: string[];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
|
|
334
387
|
const changes: PendingChange[] = [];
|
|
388
|
+
const parseErrors: string[] = [];
|
|
389
|
+
const index = loadIndex(paths);
|
|
390
|
+
const lastSync = index.lastSync ? new Date(index.lastSync).getTime() : 0;
|
|
391
|
+
|
|
392
|
+
// Check index.json for _delete requests
|
|
393
|
+
for (const [resourceName, entry] of Object.entries(index.contacts)) {
|
|
394
|
+
if (entry._delete && !entry.deleted) {
|
|
395
|
+
const filePath = path.join(paths.contactsDir, `${resourceName.replace(/\//g, '_')}.json`);
|
|
396
|
+
changes.push({
|
|
397
|
+
type: 'delete',
|
|
398
|
+
resourceName,
|
|
399
|
+
displayName: entry.displayName,
|
|
400
|
+
filePath
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
335
404
|
|
|
336
|
-
// Check contacts/ for modified files and
|
|
405
|
+
// Check contacts/ for modified files and _delete markers
|
|
406
|
+
// Only check files modified after lastSync (fast filter using mtime)
|
|
337
407
|
if (fs.existsSync(paths.contactsDir)) {
|
|
338
408
|
const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
|
|
409
|
+
let checked = 0;
|
|
410
|
+
|
|
339
411
|
for (const file of files) {
|
|
340
412
|
const filePath = path.join(paths.contactsDir, file);
|
|
341
|
-
const stat =
|
|
413
|
+
const stat = await fp.stat(filePath);
|
|
342
414
|
const modTime = stat.mtimeMs;
|
|
343
415
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
416
|
+
// Skip files not modified since last sync
|
|
417
|
+
if (modTime <= lastSync + 1000) continue;
|
|
418
|
+
|
|
419
|
+
checked++;
|
|
420
|
+
let content: GooglePerson & { _delete?: boolean };
|
|
421
|
+
try {
|
|
422
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
423
|
+
} catch (e: any) {
|
|
424
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
425
|
+
parseErrors.push(errMsg);
|
|
426
|
+
logger.error(errMsg);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
347
429
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
430
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
431
|
+
|
|
432
|
+
if (content._delete) {
|
|
433
|
+
changes.push({
|
|
434
|
+
type: 'delete',
|
|
435
|
+
resourceName: content.resourceName,
|
|
436
|
+
displayName,
|
|
437
|
+
filePath
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
changes.push({
|
|
441
|
+
type: 'update',
|
|
442
|
+
resourceName: content.resourceName,
|
|
443
|
+
displayName,
|
|
444
|
+
filePath
|
|
445
|
+
});
|
|
363
446
|
}
|
|
364
447
|
}
|
|
448
|
+
if (checked > 0) {
|
|
449
|
+
console.log(`${ts()} Found ${checked} modified contact(s)`);
|
|
450
|
+
}
|
|
365
451
|
}
|
|
366
452
|
|
|
367
|
-
// Check
|
|
368
|
-
if (fs.existsSync(paths.
|
|
369
|
-
const files = fs.readdirSync(paths.
|
|
453
|
+
// Check _delete/ folder for user deletion requests
|
|
454
|
+
if (fs.existsSync(paths.toDeleteDir)) {
|
|
455
|
+
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
370
456
|
for (const file of files) {
|
|
371
|
-
const filePath = path.join(paths.
|
|
372
|
-
|
|
457
|
+
const filePath = path.join(paths.toDeleteDir, file);
|
|
458
|
+
let content: GooglePerson;
|
|
459
|
+
try {
|
|
460
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
461
|
+
} catch (e: any) {
|
|
462
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
463
|
+
parseErrors.push(errMsg);
|
|
464
|
+
logger.error(errMsg);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
373
467
|
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
374
468
|
|
|
375
469
|
changes.push({
|
|
@@ -381,10 +475,41 @@ function findPendingChanges(paths: UserPaths): PendingChange[] {
|
|
|
381
475
|
}
|
|
382
476
|
}
|
|
383
477
|
|
|
384
|
-
|
|
478
|
+
// Check _add/ folder for new contacts to create
|
|
479
|
+
if (fs.existsSync(paths.toAddDir)) {
|
|
480
|
+
const files = fs.readdirSync(paths.toAddDir).filter(f => f.endsWith('.json'));
|
|
481
|
+
for (const file of files) {
|
|
482
|
+
const filePath = path.join(paths.toAddDir, file);
|
|
483
|
+
let content: GooglePerson;
|
|
484
|
+
try {
|
|
485
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
486
|
+
} catch (e: any) {
|
|
487
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
488
|
+
parseErrors.push(errMsg);
|
|
489
|
+
logger.error(errMsg);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
493
|
+
|
|
494
|
+
changes.push({
|
|
495
|
+
type: 'add',
|
|
496
|
+
resourceName: '', // Will be assigned by Google
|
|
497
|
+
displayName,
|
|
498
|
+
filePath
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { changes, parseErrors };
|
|
385
504
|
}
|
|
386
505
|
|
|
387
506
|
async function confirm(message: string): Promise<boolean> {
|
|
507
|
+
// Temporarily disable raw mode for readline to work
|
|
508
|
+
const wasRawMode = process.stdin.isTTY && (process.stdin as any).isRaw;
|
|
509
|
+
if (wasRawMode) {
|
|
510
|
+
process.stdin.setRawMode(false);
|
|
511
|
+
}
|
|
512
|
+
|
|
388
513
|
const readline = await import('readline');
|
|
389
514
|
const rl = readline.createInterface({
|
|
390
515
|
input: process.stdin,
|
|
@@ -394,55 +519,163 @@ async function confirm(message: string): Promise<boolean> {
|
|
|
394
519
|
return new Promise(resolve => {
|
|
395
520
|
rl.question(`${message} (y/N): `, answer => {
|
|
396
521
|
rl.close();
|
|
522
|
+
// Restore raw mode if it was enabled
|
|
523
|
+
if (wasRawMode && process.stdin.isTTY) {
|
|
524
|
+
process.stdin.setRawMode(true);
|
|
525
|
+
}
|
|
397
526
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
398
527
|
});
|
|
399
528
|
});
|
|
400
529
|
}
|
|
401
530
|
|
|
402
|
-
async function updateContactOnGoogle(
|
|
403
|
-
const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
|
|
531
|
+
async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<void> {
|
|
532
|
+
const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,fileAses';
|
|
404
533
|
|
|
405
534
|
const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
|
|
406
535
|
method: 'PATCH',
|
|
407
536
|
headers: {
|
|
408
|
-
'Authorization': `Bearer ${
|
|
537
|
+
'Authorization': `Bearer ${currentAccessToken}`,
|
|
409
538
|
'Content-Type': 'application/json'
|
|
410
539
|
},
|
|
411
540
|
body: JSON.stringify(person)
|
|
412
541
|
});
|
|
413
542
|
|
|
543
|
+
// Token expired - refresh and retry once
|
|
544
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
545
|
+
await refreshAccessToken();
|
|
546
|
+
return updateContactOnGoogle(person, retryCount, true);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (response.status === 429) {
|
|
550
|
+
if (retryCount >= 3 || escapePressed) {
|
|
551
|
+
throw new Error(`rate limit - try again later`);
|
|
552
|
+
}
|
|
553
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
554
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
555
|
+
await sleep(delay);
|
|
556
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
557
|
+
console.log(`${ts()} retrying...`);
|
|
558
|
+
return updateContactOnGoogle(person, retryCount + 1, tokenRefreshed);
|
|
559
|
+
}
|
|
560
|
+
|
|
414
561
|
if (!response.ok) {
|
|
415
|
-
throw new Error(
|
|
562
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
416
563
|
}
|
|
417
564
|
}
|
|
418
565
|
|
|
419
|
-
async function deleteContactOnGoogle(
|
|
566
|
+
async function deleteContactOnGoogle(resourceName: string, retryCount = 0, tokenRefreshed = false): Promise<void> {
|
|
420
567
|
const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
|
|
421
568
|
method: 'DELETE',
|
|
422
|
-
headers: { 'Authorization': `Bearer ${
|
|
569
|
+
headers: { 'Authorization': `Bearer ${currentAccessToken}` }
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Already deleted - treat as success
|
|
573
|
+
if (response.status === 404) {
|
|
574
|
+
console.log(' (already deleted)');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Token expired - refresh and retry once
|
|
579
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
580
|
+
await refreshAccessToken();
|
|
581
|
+
return deleteContactOnGoogle(resourceName, retryCount, true);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (response.status === 429) {
|
|
585
|
+
if (retryCount >= 3 || escapePressed) {
|
|
586
|
+
throw new Error(`rate limit - try again later`);
|
|
587
|
+
}
|
|
588
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
589
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
590
|
+
await sleep(delay);
|
|
591
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
592
|
+
console.log(`${ts()} retrying...`);
|
|
593
|
+
return deleteContactOnGoogle(resourceName, retryCount + 1, tokenRefreshed);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!response.ok) {
|
|
597
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function createContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<GooglePerson> {
|
|
602
|
+
// Remove resourceName and etag - Google will assign new ones
|
|
603
|
+
const { resourceName, etag, ...personData } = person as any;
|
|
604
|
+
|
|
605
|
+
// Add a GUID if not already present in userDefined
|
|
606
|
+
if (!personData.userDefined) {
|
|
607
|
+
personData.userDefined = [];
|
|
608
|
+
}
|
|
609
|
+
const hasGuid = personData.userDefined.some((u: any) => u.key === GCARDS_GUID_KEY);
|
|
610
|
+
if (!hasGuid) {
|
|
611
|
+
personData.userDefined.push({ key: GCARDS_GUID_KEY, value: generateGuid() });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const response = await fetch(`${PEOPLE_API_BASE}/people:createContact`, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: {
|
|
617
|
+
'Authorization': `Bearer ${currentAccessToken}`,
|
|
618
|
+
'Content-Type': 'application/json'
|
|
619
|
+
},
|
|
620
|
+
body: JSON.stringify(personData)
|
|
423
621
|
});
|
|
424
622
|
|
|
623
|
+
// Token expired - refresh and retry once
|
|
624
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
625
|
+
await refreshAccessToken();
|
|
626
|
+
return createContactOnGoogle(person, retryCount, true);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (response.status === 429) {
|
|
630
|
+
if (retryCount >= 3 || escapePressed) {
|
|
631
|
+
throw new Error(`rate limit - try again later`);
|
|
632
|
+
}
|
|
633
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
634
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
635
|
+
await sleep(delay);
|
|
636
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
637
|
+
console.log(`${ts()} retrying...`);
|
|
638
|
+
return createContactOnGoogle(person, retryCount + 1, tokenRefreshed);
|
|
639
|
+
}
|
|
640
|
+
|
|
425
641
|
if (!response.ok) {
|
|
426
|
-
throw new Error(
|
|
642
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
427
643
|
}
|
|
644
|
+
|
|
645
|
+
return await response.json();
|
|
428
646
|
}
|
|
429
647
|
|
|
430
|
-
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean }): Promise<void> {
|
|
648
|
+
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
|
|
431
649
|
const paths = getUserPaths(user);
|
|
432
650
|
ensureUserDir(user);
|
|
433
651
|
|
|
652
|
+
// Create logger for problems
|
|
653
|
+
const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
|
|
654
|
+
const logger = new FileLogger(problemsFile, true);
|
|
655
|
+
|
|
434
656
|
console.log(`Pushing contacts for user: ${user}`);
|
|
435
|
-
|
|
657
|
+
let { changes, parseErrors } = await findPendingChanges(paths, logger);
|
|
658
|
+
|
|
659
|
+
if (parseErrors.length > 0) {
|
|
660
|
+
console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
|
|
661
|
+
}
|
|
436
662
|
|
|
437
663
|
if (changes.length === 0) {
|
|
438
664
|
console.log('No pending changes to push.');
|
|
439
665
|
return;
|
|
440
666
|
}
|
|
441
667
|
|
|
668
|
+
const adds = changes.filter(c => c.type === 'add');
|
|
442
669
|
const updates = changes.filter(c => c.type === 'update');
|
|
443
670
|
const deletes = changes.filter(c => c.type === 'delete');
|
|
444
671
|
|
|
445
672
|
console.log('\nPending changes:');
|
|
673
|
+
if (adds.length > 0) {
|
|
674
|
+
console.log(`\n New contacts (${adds.length}):`);
|
|
675
|
+
for (const c of adds) {
|
|
676
|
+
console.log(` - ${c.displayName}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
446
679
|
if (updates.length > 0) {
|
|
447
680
|
console.log(`\n Updates (${updates.length}):`);
|
|
448
681
|
for (const c of updates) {
|
|
@@ -465,32 +698,95 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
465
698
|
}
|
|
466
699
|
|
|
467
700
|
console.log('\nGetting access token (write access)...');
|
|
468
|
-
|
|
701
|
+
currentUser = user;
|
|
702
|
+
currentAccessToken = await getAccessToken(user, true);
|
|
469
703
|
|
|
470
704
|
const index = loadIndex(paths);
|
|
705
|
+
const deletedIndex = loadDeleted(paths);
|
|
706
|
+
// Apply limit if specified
|
|
707
|
+
if (options.limit > 0 && changes.length > options.limit) {
|
|
708
|
+
console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
|
|
709
|
+
changes = changes.slice(0, options.limit);
|
|
710
|
+
}
|
|
711
|
+
|
|
471
712
|
let successCount = 0;
|
|
472
713
|
let errorCount = 0;
|
|
714
|
+
let processed = 0;
|
|
715
|
+
const total = changes.length;
|
|
716
|
+
const problems: string[] = [];
|
|
473
717
|
|
|
474
718
|
for (const change of changes) {
|
|
719
|
+
if (escapePressed) {
|
|
720
|
+
console.log('\n\nStopped by user. Progress saved.');
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
processed++;
|
|
475
725
|
try {
|
|
476
|
-
if (change.type === '
|
|
726
|
+
if (change.type === 'add') {
|
|
477
727
|
const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
|
|
478
|
-
process.stdout.write(
|
|
479
|
-
await
|
|
480
|
-
|
|
728
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
|
|
729
|
+
const created = await createContactOnGoogle(content);
|
|
730
|
+
|
|
731
|
+
// Extract GUIDs from userDefined
|
|
732
|
+
const guids = extractGuids(created);
|
|
733
|
+
|
|
734
|
+
// Add to index
|
|
735
|
+
index.contacts[created.resourceName] = {
|
|
736
|
+
resourceName: created.resourceName,
|
|
737
|
+
displayName: change.displayName,
|
|
738
|
+
etag: created.etag,
|
|
739
|
+
deleted: false,
|
|
740
|
+
updatedAt: new Date().toISOString(),
|
|
741
|
+
guids: guids.length > 0 ? guids : undefined
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// Save new contact to contacts/ folder
|
|
745
|
+
const newFilePath = path.join(paths.contactsDir, `${created.resourceName.replace(/\//g, '_')}.json`);
|
|
746
|
+
if (!fs.existsSync(paths.contactsDir)) {
|
|
747
|
+
fs.mkdirSync(paths.contactsDir, { recursive: true });
|
|
748
|
+
}
|
|
749
|
+
fs.writeFileSync(newFilePath, JSON.stringify(created, null, 2));
|
|
750
|
+
|
|
751
|
+
// Remove from _add/ folder
|
|
752
|
+
fs.unlinkSync(change.filePath);
|
|
753
|
+
|
|
754
|
+
console.log(` done (${created.resourceName})`);
|
|
481
755
|
successCount++;
|
|
482
|
-
} else {
|
|
483
|
-
|
|
484
|
-
|
|
756
|
+
} else if (change.type === 'update') {
|
|
757
|
+
const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
|
|
758
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
|
|
759
|
+
await updateContactOnGoogle(content);
|
|
485
760
|
|
|
486
|
-
// Update index
|
|
761
|
+
// Update index timestamp so file no longer appears modified
|
|
487
762
|
if (index.contacts[change.resourceName]) {
|
|
488
|
-
index.contacts[change.resourceName].
|
|
489
|
-
|
|
763
|
+
index.contacts[change.resourceName].updatedAt = new Date().toISOString();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
console.log(' done');
|
|
767
|
+
successCount++;
|
|
768
|
+
} else if (change.type === 'delete') {
|
|
769
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
|
|
770
|
+
await deleteContactOnGoogle(change.resourceName);
|
|
771
|
+
|
|
772
|
+
// Move to deleted.json
|
|
773
|
+
const entry = index.contacts[change.resourceName];
|
|
774
|
+
if (entry) {
|
|
775
|
+
entry.deleted = true;
|
|
776
|
+
entry.deletedAt = new Date().toISOString();
|
|
777
|
+
delete entry._delete; // Clear the delete request
|
|
778
|
+
deletedIndex.deleted[change.resourceName] = entry;
|
|
779
|
+
delete index.contacts[change.resourceName];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Remove contact file from contacts/
|
|
783
|
+
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
784
|
+
if (fs.existsSync(contactFile)) {
|
|
785
|
+
fs.unlinkSync(contactFile);
|
|
490
786
|
}
|
|
491
787
|
|
|
492
|
-
// Remove from
|
|
493
|
-
if (change.filePath.startsWith(paths.
|
|
788
|
+
// Remove from _delete/ folder if it was there
|
|
789
|
+
if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
|
|
494
790
|
fs.unlinkSync(change.filePath);
|
|
495
791
|
}
|
|
496
792
|
|
|
@@ -498,77 +794,94 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
498
794
|
successCount++;
|
|
499
795
|
}
|
|
500
796
|
|
|
501
|
-
await sleep(
|
|
797
|
+
await sleep(700); // 90 writes/min limit = ~700ms between ops
|
|
502
798
|
} catch (error: any) {
|
|
799
|
+
const fileName = path.basename(change.filePath);
|
|
800
|
+
const errorMsg = `${fileName}: ${error.message}`;
|
|
503
801
|
console.log(` ERROR: ${error.message}`);
|
|
802
|
+
problems.push(errorMsg);
|
|
504
803
|
errorCount++;
|
|
505
|
-
debugger;
|
|
506
804
|
}
|
|
507
805
|
}
|
|
508
806
|
|
|
807
|
+
// Write problems to file if any
|
|
808
|
+
if (problems.length > 0) {
|
|
809
|
+
const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
|
|
810
|
+
fs.writeFileSync(problemsFile, problems.join('\n'));
|
|
811
|
+
console.log(`\nErrors written to: ${problemsFile}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
509
814
|
// Save status with new timestamp
|
|
510
815
|
saveStatus(paths, { lastPush: new Date().toISOString() });
|
|
511
816
|
saveIndex(paths, index);
|
|
817
|
+
saveDeleted(paths, deletedIndex);
|
|
512
818
|
|
|
513
819
|
console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
|
|
514
820
|
}
|
|
515
821
|
|
|
516
|
-
function resolveUser(cliUser: string): string {
|
|
517
|
-
// 1. Use CLI-specified user if provided (and not 'default')
|
|
518
|
-
if (cliUser && cliUser !== 'default') {
|
|
519
|
-
const normalized = normalizeUser(cliUser);
|
|
520
|
-
const config = loadConfig();
|
|
521
|
-
config.lastUser = normalized;
|
|
522
|
-
saveConfig(config);
|
|
523
|
-
return normalized;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// 2. Use lastUser from config (already normalized)
|
|
527
|
-
const config = loadConfig();
|
|
528
|
-
if (config.lastUser) {
|
|
529
|
-
return config.lastUser;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// 3. No user - require --user on first use
|
|
533
|
-
console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
|
|
534
|
-
console.error('This will be remembered for future runs.');
|
|
535
|
-
process.exit(1);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
822
|
async function main(): Promise<void> {
|
|
539
823
|
const args = process.argv.slice(2);
|
|
540
824
|
const options = parseArgs(args);
|
|
541
825
|
|
|
542
|
-
if (options.help
|
|
826
|
+
if (options.help) {
|
|
543
827
|
showHelp();
|
|
544
828
|
return;
|
|
545
829
|
}
|
|
546
830
|
|
|
831
|
+
if (!options.command) {
|
|
832
|
+
showUsage();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
setupEscapeHandler();
|
|
837
|
+
|
|
547
838
|
try {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
case 'push':
|
|
555
|
-
await pushContacts(user, { yes: options.yes, verbose: options.verbose });
|
|
556
|
-
break;
|
|
557
|
-
default:
|
|
558
|
-
console.error(`Unknown command: ${options.command}`);
|
|
559
|
-
showHelp();
|
|
839
|
+
// Determine users to process
|
|
840
|
+
let users: string[];
|
|
841
|
+
if (options.all) {
|
|
842
|
+
users = getAllUsers();
|
|
843
|
+
if (users.length === 0) {
|
|
844
|
+
console.error('No users found. Use --user <name> to create one first.');
|
|
560
845
|
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
console.log(`Processing all users: ${users.join(', ')}\n`);
|
|
848
|
+
} else {
|
|
849
|
+
users = [resolveUser(options.user, true)];
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for (const user of users) {
|
|
853
|
+
if (escapePressed) break;
|
|
854
|
+
|
|
855
|
+
if (users.length > 1) {
|
|
856
|
+
console.log(`\n${'='.repeat(50)}\nUser: ${user}\n${'='.repeat(50)}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
switch (options.command) {
|
|
860
|
+
case 'sync':
|
|
861
|
+
await syncContacts(user, { full: options.full, verbose: options.verbose });
|
|
862
|
+
break;
|
|
863
|
+
case 'push':
|
|
864
|
+
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
|
|
865
|
+
break;
|
|
866
|
+
default:
|
|
867
|
+
console.error(`Unknown command: ${options.command}`);
|
|
868
|
+
showHelp();
|
|
869
|
+
process.exit(1);
|
|
870
|
+
}
|
|
561
871
|
}
|
|
562
872
|
|
|
563
|
-
// Update lastUser in config
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
873
|
+
// Update lastUser in config (use last processed user)
|
|
874
|
+
if (!options.all && users.length === 1) {
|
|
875
|
+
const config = loadConfig();
|
|
876
|
+
config.lastUser = users[0];
|
|
877
|
+
saveConfig(config);
|
|
878
|
+
}
|
|
567
879
|
|
|
568
880
|
} catch (error: any) {
|
|
569
881
|
console.error(`Error: ${error.message}`);
|
|
570
|
-
debugger;
|
|
571
882
|
process.exit(1);
|
|
883
|
+
} finally {
|
|
884
|
+
cleanupEscapeHandler();
|
|
572
885
|
}
|
|
573
886
|
}
|
|
574
887
|
|