contextloop-cli 0.1.1 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +1113 -53
  2. package/package.json +16 -16
package/dist/index.js CHANGED
@@ -31,7 +31,10 @@ var CLIError = class extends Error {
31
31
  AUTH_EXPIRED: 2,
32
32
  PROJECT_NOT_SET: 6,
33
33
  TIMEOUT: 12,
34
- FILE_TOO_LARGE: 13
34
+ FILE_TOO_LARGE: 13,
35
+ SYNC_CONFLICT: 7,
36
+ SYNC_PARTIAL_FAILURE: 8,
37
+ VALIDATION_ERROR: 5
35
38
  };
36
39
  return exitCodes[this.code] ?? 1;
37
40
  }
@@ -131,6 +134,7 @@ import * as crypto2 from "crypto";
131
134
  import * as os from "os";
132
135
  var SERVICE_NAME = "contextloop-cli";
133
136
  var CREDENTIALS_FILENAME = "credentials.enc";
137
+ var LOCAL_CONFIG_DIR = ".contextloop";
134
138
  var KEY_API_KEY = "api-key";
135
139
  var KEY_ACCESS_TOKEN = "access-token";
136
140
  var KEY_REFRESH_TOKEN = "refresh-token";
@@ -138,13 +142,32 @@ var ENCRYPTION_ALGORITHM = "aes-256-gcm";
138
142
  var KEY_LENGTH = 32;
139
143
  var IV_LENGTH = 16;
140
144
  var AUTH_TAG_LENGTH = 16;
141
- function getConfigDir() {
145
+ function getGlobalConfigDir() {
142
146
  const home = os.homedir();
143
147
  if (process.platform === "win32") {
144
148
  return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "contextloop-cli");
145
149
  }
146
150
  return path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "contextloop-cli");
147
151
  }
152
+ function findLocalConfigDir() {
153
+ let current = process.cwd();
154
+ const root = path.parse(current).root;
155
+ while (current !== root) {
156
+ const configDir = path.join(current, LOCAL_CONFIG_DIR);
157
+ if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) {
158
+ return configDir;
159
+ }
160
+ current = path.dirname(current);
161
+ }
162
+ return null;
163
+ }
164
+ function getOrCreateLocalConfigDir() {
165
+ const configDir = path.join(process.cwd(), LOCAL_CONFIG_DIR);
166
+ if (!fs.existsSync(configDir)) {
167
+ fs.mkdirSync(configDir, { recursive: true, mode: 448 });
168
+ }
169
+ return configDir;
170
+ }
148
171
  function getMachineKey() {
149
172
  const machineId = `${os.hostname()}-${os.userInfo().username}-${SERVICE_NAME}`;
150
173
  return crypto2.scryptSync(machineId, "contextloop-cli-salt", KEY_LENGTH);
@@ -217,11 +240,14 @@ var KeyringBackend = class {
217
240
  }
218
241
  };
219
242
  var EncryptedFileBackend = class {
220
- name = "encrypted-file";
243
+ name;
221
244
  configDir;
222
245
  filePath;
223
- constructor() {
224
- this.configDir = getConfigDir();
246
+ scope;
247
+ constructor(scope = "global", configDir) {
248
+ this.scope = scope;
249
+ this.name = scope === "local" ? "encrypted-file-local" : "encrypted-file";
250
+ this.configDir = configDir ?? getGlobalConfigDir();
225
251
  this.filePath = path.join(this.configDir, CREDENTIALS_FILENAME);
226
252
  }
227
253
  readStore() {
@@ -281,10 +307,16 @@ async function getBackend() {
281
307
  backendInitialized = true;
282
308
  return activeBackend;
283
309
  }
284
- async function storeApiKey(apiKey) {
310
+ async function storeApiKey(apiKey, local = false) {
285
311
  try {
286
- const backend = await getBackend();
287
- await backend.set(KEY_API_KEY, apiKey);
312
+ if (local) {
313
+ const configDir = getOrCreateLocalConfigDir();
314
+ const backend = new EncryptedFileBackend("local", configDir);
315
+ await backend.set(KEY_API_KEY, apiKey);
316
+ } else {
317
+ const backend = await getBackend();
318
+ await backend.set(KEY_API_KEY, apiKey);
319
+ }
288
320
  } catch (error) {
289
321
  throw new CLIError(
290
322
  "KEYCHAIN_ERROR",
@@ -292,20 +324,44 @@ async function storeApiKey(apiKey) {
292
324
  );
293
325
  }
294
326
  }
295
- async function getApiKey() {
327
+ async function getApiKeyWithScope() {
296
328
  if (process.env.CONTEXTLOOP_API_KEY) {
297
- return process.env.CONTEXTLOOP_API_KEY;
329
+ return { apiKey: process.env.CONTEXTLOOP_API_KEY, scope: "environment" };
330
+ }
331
+ const localDir = findLocalConfigDir();
332
+ if (localDir) {
333
+ try {
334
+ const localBackend = new EncryptedFileBackend("local", localDir);
335
+ const localKey = await localBackend.get(KEY_API_KEY);
336
+ if (localKey) {
337
+ return { apiKey: localKey, scope: "local" };
338
+ }
339
+ } catch {
340
+ }
298
341
  }
299
342
  try {
300
343
  const backend = await getBackend();
301
- return await backend.get(KEY_API_KEY);
344
+ const key = await backend.get(KEY_API_KEY);
345
+ if (key) {
346
+ return { apiKey: key, scope: "global" };
347
+ }
302
348
  } catch {
303
- return null;
304
349
  }
350
+ return { apiKey: null, scope: null };
351
+ }
352
+ async function getApiKey() {
353
+ const { apiKey } = await getApiKeyWithScope();
354
+ return apiKey;
305
355
  }
306
- async function storeTokens(accessToken, refreshToken) {
356
+ async function storeTokens(accessToken, refreshToken, local = false) {
307
357
  try {
308
- const backend = await getBackend();
358
+ let backend;
359
+ if (local) {
360
+ const configDir = getOrCreateLocalConfigDir();
361
+ backend = new EncryptedFileBackend("local", configDir);
362
+ } else {
363
+ backend = await getBackend();
364
+ }
309
365
  await backend.set(KEY_ACCESS_TOKEN, accessToken);
310
366
  if (refreshToken) {
311
367
  await backend.set(KEY_REFRESH_TOKEN, refreshToken);
@@ -317,32 +373,68 @@ async function storeTokens(accessToken, refreshToken) {
317
373
  );
318
374
  }
319
375
  }
320
- async function getTokens() {
376
+ async function getTokensWithScope() {
377
+ const localDir = findLocalConfigDir();
378
+ if (localDir) {
379
+ try {
380
+ const localBackend = new EncryptedFileBackend("local", localDir);
381
+ const accessToken = await localBackend.get(KEY_ACCESS_TOKEN);
382
+ if (accessToken) {
383
+ const refreshToken = await localBackend.get(KEY_REFRESH_TOKEN);
384
+ return { accessToken, refreshToken, scope: "local" };
385
+ }
386
+ } catch {
387
+ }
388
+ }
321
389
  try {
322
390
  const backend = await getBackend();
323
391
  const accessToken = await backend.get(KEY_ACCESS_TOKEN);
324
- const refreshToken = await backend.get(KEY_REFRESH_TOKEN);
325
- return { accessToken, refreshToken };
392
+ if (accessToken) {
393
+ const refreshToken = await backend.get(KEY_REFRESH_TOKEN);
394
+ return { accessToken, refreshToken, scope: "global" };
395
+ }
326
396
  } catch {
327
- return { accessToken: null, refreshToken: null };
328
397
  }
398
+ return { accessToken: null, refreshToken: null, scope: null };
329
399
  }
330
- async function getCredentials() {
331
- const apiKey = await getApiKey();
332
- const { accessToken, refreshToken } = await getTokens();
400
+ async function getTokens() {
401
+ const { accessToken, refreshToken } = await getTokensWithScope();
402
+ return { accessToken, refreshToken };
403
+ }
404
+ async function getCredentialsWithScope() {
405
+ const { apiKey, scope: apiKeyScope } = await getApiKeyWithScope();
406
+ const { accessToken, refreshToken, scope: tokenScope } = await getTokensWithScope();
407
+ const scope = apiKeyScope ?? tokenScope;
333
408
  return {
334
- apiKey: apiKey ?? void 0,
335
- accessToken: accessToken ?? void 0,
336
- refreshToken: refreshToken ?? void 0
409
+ credentials: {
410
+ apiKey: apiKey ?? void 0,
411
+ accessToken: accessToken ?? void 0,
412
+ refreshToken: refreshToken ?? void 0
413
+ },
414
+ scope
337
415
  };
338
416
  }
339
- async function clearCredentials() {
340
- try {
341
- const backend = await getBackend();
342
- await backend.delete(KEY_API_KEY);
343
- await backend.delete(KEY_ACCESS_TOKEN);
344
- await backend.delete(KEY_REFRESH_TOKEN);
345
- } catch {
417
+ async function clearCredentials(scope = "all") {
418
+ if (scope === "local" || scope === "all") {
419
+ const localDir = findLocalConfigDir();
420
+ if (localDir) {
421
+ try {
422
+ const localBackend = new EncryptedFileBackend("local", localDir);
423
+ await localBackend.delete(KEY_API_KEY);
424
+ await localBackend.delete(KEY_ACCESS_TOKEN);
425
+ await localBackend.delete(KEY_REFRESH_TOKEN);
426
+ } catch {
427
+ }
428
+ }
429
+ }
430
+ if (scope === "global" || scope === "all") {
431
+ try {
432
+ const backend = await getBackend();
433
+ await backend.delete(KEY_API_KEY);
434
+ await backend.delete(KEY_ACCESS_TOKEN);
435
+ await backend.delete(KEY_REFRESH_TOKEN);
436
+ } catch {
437
+ }
346
438
  }
347
439
  }
348
440
 
@@ -381,7 +473,7 @@ function createAPIClient(config2, logger) {
381
473
  function sleep(ms) {
382
474
  return new Promise((resolve) => setTimeout(resolve, ms));
383
475
  }
384
- async function request(method, path4, options = {}) {
476
+ async function request(method, path6, options = {}) {
385
477
  const authHeader = await getAuthHeader();
386
478
  const headers = {
387
479
  "Content-Type": "application/json"
@@ -395,8 +487,8 @@ function createAPIClient(config2, logger) {
395
487
  let lastError = null;
396
488
  for (let attempt = 0; attempt <= RATE_LIMIT_CONFIG.maxRetries; attempt++) {
397
489
  try {
398
- logger.debug(`${method} ${path4}`, { query, attempt });
399
- const response = await ofetch(`${baseURL}${path4}`, {
490
+ logger.debug(`${method} ${path6}`, { query, attempt });
491
+ const response = await ofetch(`${baseURL}${path6}`, {
400
492
  method,
401
493
  headers,
402
494
  body: options.body,
@@ -508,9 +600,9 @@ function createAPIClient(config2, logger) {
508
600
  `/cli/projects/${projectId}/documents/${documentId}`
509
601
  );
510
602
  },
511
- async getDocumentByPath(projectId, path4) {
603
+ async getDocumentByPath(projectId, path6) {
512
604
  return request("GET", `/cli/projects/${projectId}/documents`, {
513
- query: { path: path4 }
605
+ query: { path: path6 }
514
606
  });
515
607
  },
516
608
  async createDocument(projectId, data) {
@@ -699,6 +791,18 @@ import http from "http";
699
791
  import { URL } from "url";
700
792
  import open from "open";
701
793
  import chalk2 from "chalk";
794
+ import { ofetch as ofetch2 } from "ofetch";
795
+ async function fetchProjectInfo(apiUrl, projectId, accessToken) {
796
+ try {
797
+ const project = await ofetch2(`${apiUrl}/cli/projects/${projectId}`, {
798
+ headers: { Authorization: `Bearer ${accessToken}` },
799
+ timeout: 1e4
800
+ });
801
+ return { name: project.name, slug: project.slug };
802
+ } catch {
803
+ return null;
804
+ }
805
+ }
702
806
  var CALLBACK_PORT = 54320;
703
807
  var CALLBACK_PATH = "/callback";
704
808
  var AUTH_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -881,7 +985,18 @@ ${authUrl.toString()}`);
881
985
  if (tokens.project_id) {
882
986
  saveConfig({ defaultProjectId: tokens.project_id });
883
987
  console.log(chalk2.green("Successfully authenticated!"));
884
- console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
988
+ const projectInfo = await fetchProjectInfo(
989
+ config2.apiUrl,
990
+ tokens.project_id,
991
+ tokens.access_token
992
+ );
993
+ if (projectInfo) {
994
+ console.log(
995
+ chalk2.gray(`Default project set to ${chalk2.bold(projectInfo.name)} (${tokens.project_id})`)
996
+ );
997
+ } else {
998
+ console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
999
+ }
885
1000
  } else {
886
1001
  console.log(chalk2.green("Successfully authenticated!"));
887
1002
  }
@@ -964,7 +1079,18 @@ async function manualLogin(config2, logger) {
964
1079
  if (tokens.project_id) {
965
1080
  saveConfig({ defaultProjectId: tokens.project_id });
966
1081
  console.log(chalk2.green("Successfully authenticated!"));
967
- console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
1082
+ const projectInfo = await fetchProjectInfo(
1083
+ config2.apiUrl,
1084
+ tokens.project_id,
1085
+ tokens.access_token
1086
+ );
1087
+ if (projectInfo) {
1088
+ console.log(
1089
+ chalk2.gray(`Default project set to ${chalk2.bold(projectInfo.name)} (${tokens.project_id})`)
1090
+ );
1091
+ } else {
1092
+ console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
1093
+ }
968
1094
  } else {
969
1095
  console.log(chalk2.green("Successfully authenticated!"));
970
1096
  }
@@ -1061,22 +1187,36 @@ function formatRelativeTime(date) {
1061
1187
  // src/commands/auth.ts
1062
1188
  function registerAuthCommands(program2, context2) {
1063
1189
  const auth = program2.command("auth").description("Authentication commands");
1064
- auth.command("login").description("Authenticate with ContextLoop").option("--api-key <key>", "Login with an API key").option("--manual", "Use manual login flow (for environments without browser)").action(async (options) => {
1190
+ auth.command("login").description("Authenticate with ContextLoop").option("--api-key <key>", "Login with an API key").option("--local", "Store credentials in project directory (.contextloop/)").option("--manual", "Use manual login flow (for environments without browser)").action(async (options) => {
1065
1191
  const globalOptions = program2.opts();
1066
1192
  try {
1193
+ const isLocal = options.local ?? false;
1194
+ const scopeLabel = isLocal ? "project" : "global";
1067
1195
  if (options.apiKey) {
1068
1196
  const spinner = ora("Validating API key...").start();
1069
1197
  try {
1070
1198
  const { user } = await context2.apiClient.validateApiKey(options.apiKey);
1071
- await storeApiKey(options.apiKey);
1072
- spinner.succeed(`Logged in as ${chalk4.bold(user.name)} (${user.email})`);
1199
+ await storeApiKey(options.apiKey, isLocal);
1200
+ spinner.succeed(
1201
+ `Logged in as ${chalk4.bold(user.name)} (${user.email}) [${chalk4.cyan(scopeLabel)}]`
1202
+ );
1203
+ if (isLocal) {
1204
+ console.log(chalk4.gray("Credentials stored in .contextloop/ directory"));
1205
+ console.log(chalk4.yellow("Tip: Add .contextloop/ to your .gitignore"));
1206
+ }
1073
1207
  } catch (error) {
1074
1208
  spinner.fail("Login failed");
1075
1209
  throw error;
1076
1210
  }
1077
1211
  } else if (options.manual) {
1212
+ if (isLocal) {
1213
+ console.log(chalk4.yellow("Note: OAuth login currently only supports global scope"));
1214
+ }
1078
1215
  await manualLogin(context2.config, context2.logger);
1079
1216
  } else {
1217
+ if (isLocal) {
1218
+ console.log(chalk4.yellow("Note: OAuth login currently only supports global scope"));
1219
+ }
1080
1220
  await interactiveLogin(context2.config, context2.logger);
1081
1221
  }
1082
1222
  } catch (error) {
@@ -1084,12 +1224,21 @@ function registerAuthCommands(program2, context2) {
1084
1224
  process.exit(getExitCode(error));
1085
1225
  }
1086
1226
  });
1087
- auth.command("logout").description("Clear stored credentials").action(async () => {
1227
+ auth.command("logout").description("Clear stored credentials").option("--local", "Clear only project-local credentials").option("--global", "Clear only global credentials").option("--all", "Clear all credentials (default)").action(async (options) => {
1088
1228
  const globalOptions = program2.opts();
1089
1229
  try {
1090
- await clearCredentials();
1091
- clearConfig(["defaultProjectId", "defaultProjectSlug"]);
1092
- console.log(chalk4.green("Logged out successfully."));
1230
+ let scope = "all";
1231
+ if (options.local && !options.global) {
1232
+ scope = "local";
1233
+ } else if (options.global && !options.local) {
1234
+ scope = "global";
1235
+ }
1236
+ await clearCredentials(scope);
1237
+ if (scope === "global" || scope === "all") {
1238
+ clearConfig(["defaultProjectId", "defaultProjectSlug"]);
1239
+ }
1240
+ const scopeMsg = scope === "local" ? "project-local" : scope === "global" ? "global" : "all";
1241
+ console.log(chalk4.green(`Logged out successfully (${scopeMsg} credentials cleared).`));
1093
1242
  } catch (error) {
1094
1243
  console.error(formatError(error, globalOptions.format));
1095
1244
  process.exit(getExitCode(error));
@@ -1098,7 +1247,7 @@ function registerAuthCommands(program2, context2) {
1098
1247
  auth.command("whoami").description("Display current user information").action(async () => {
1099
1248
  const globalOptions = program2.opts();
1100
1249
  try {
1101
- const creds = await getCredentials();
1250
+ const { credentials: creds, scope } = await getCredentialsWithScope();
1102
1251
  if (!creds.apiKey && !creds.accessToken) {
1103
1252
  if (globalOptions.format === "json") {
1104
1253
  console.log(JSON.stringify({ loggedIn: false }));
@@ -1113,27 +1262,56 @@ function registerAuthCommands(program2, context2) {
1113
1262
  const spinner = ora("Fetching user info...").start();
1114
1263
  try {
1115
1264
  const user = await context2.apiClient.getCurrentUser();
1116
- spinner.stop();
1117
1265
  const defaultProjectId = context2.config.defaultProjectId;
1266
+ let project = null;
1267
+ if (defaultProjectId) {
1268
+ spinner.text = "Fetching project info...";
1269
+ try {
1270
+ project = await context2.apiClient.getProject(defaultProjectId);
1271
+ } catch {
1272
+ project = null;
1273
+ }
1274
+ }
1275
+ spinner.stop();
1276
+ const localDir = findLocalConfigDir();
1118
1277
  printOutput(
1119
- user,
1278
+ { user, project, scope, localDir },
1120
1279
  {
1121
- text: (u) => {
1280
+ text: ({ user: u, project: p, scope: s, localDir: ld }) => {
1122
1281
  const lines = [
1123
1282
  chalk4.bold(u.name),
1124
1283
  chalk4.gray(u.email),
1125
1284
  chalk4.gray(`ID: ${u.id}`)
1126
1285
  ];
1127
- if (defaultProjectId) {
1286
+ lines.push("");
1287
+ const scopeLabel = s === "environment" ? chalk4.magenta("environment variable") : s === "local" ? chalk4.cyan("project-local") : chalk4.blue("global");
1288
+ lines.push(`Auth: ${scopeLabel}`);
1289
+ if (s === "local" && ld) {
1290
+ lines.push(chalk4.gray(` ${ld}`));
1291
+ }
1292
+ if (p) {
1293
+ lines.push("");
1294
+ lines.push(chalk4.bold("Current project:"));
1295
+ lines.push(` ${chalk4.cyan(p.name)}`);
1296
+ lines.push(chalk4.gray(` ID: ${p.id}`));
1297
+ if (p.slug) {
1298
+ lines.push(chalk4.gray(` Slug: ${p.slug}`));
1299
+ }
1300
+ } else if (defaultProjectId) {
1128
1301
  lines.push("");
1129
- lines.push(chalk4.gray(`Default project: ${defaultProjectId}`));
1302
+ lines.push(chalk4.yellow(`Default project: ${defaultProjectId} (not found)`));
1303
+ } else {
1304
+ lines.push("");
1305
+ lines.push(chalk4.gray("No default project set."));
1306
+ lines.push(chalk4.gray("Run 'contextloop project use <id>' to set one."));
1130
1307
  }
1131
1308
  return lines.join("\n");
1132
1309
  },
1133
- json: (u) => ({
1310
+ json: ({ user: u, project: p, scope: s }) => ({
1134
1311
  loggedIn: true,
1135
1312
  user: u,
1136
- defaultProjectId: defaultProjectId || null
1313
+ authScope: s,
1314
+ project: p ? { id: p.id, name: p.name, slug: p.slug, role: p.role } : null
1137
1315
  })
1138
1316
  },
1139
1317
  globalOptions
@@ -2222,6 +2400,887 @@ Page ${r.pagination.page} of ${r.pagination.totalPages} (${r.pagination.totalIte
2222
2400
  });
2223
2401
  }
2224
2402
 
2403
+ // src/commands/sync.ts
2404
+ import ora6 from "ora";
2405
+ import chalk9 from "chalk";
2406
+
2407
+ // src/lib/sync/pool.ts
2408
+ async function executeWithPool(items, executor, options) {
2409
+ const { concurrency, onProgress } = options;
2410
+ const successes = [];
2411
+ const failures = [];
2412
+ if (items.length === 0) {
2413
+ return { successes, failures };
2414
+ }
2415
+ let completed = 0;
2416
+ let nextIndex = 0;
2417
+ return new Promise((resolve) => {
2418
+ const executing = /* @__PURE__ */ new Set();
2419
+ function processNext() {
2420
+ while (executing.size < concurrency && nextIndex < items.length) {
2421
+ const currentIndex = nextIndex++;
2422
+ const item = items[currentIndex];
2423
+ const promise = executor(item).then((result) => {
2424
+ successes.push({ item, result });
2425
+ }).catch((error) => {
2426
+ failures.push({
2427
+ item,
2428
+ error: error instanceof Error ? error : new Error(String(error))
2429
+ });
2430
+ }).finally(() => {
2431
+ executing.delete(promise);
2432
+ completed++;
2433
+ onProgress?.(completed, items.length);
2434
+ if (completed === items.length) {
2435
+ resolve({ successes, failures });
2436
+ } else {
2437
+ processNext();
2438
+ }
2439
+ });
2440
+ executing.add(promise);
2441
+ }
2442
+ }
2443
+ processNext();
2444
+ });
2445
+ }
2446
+ async function mapWithPool(items, executor, options) {
2447
+ const { concurrency, onProgress } = options;
2448
+ const results = new Array(items.length);
2449
+ if (items.length === 0) {
2450
+ return results;
2451
+ }
2452
+ let completed = 0;
2453
+ let nextIndex = 0;
2454
+ return new Promise((resolve) => {
2455
+ const executing = /* @__PURE__ */ new Set();
2456
+ function processNext() {
2457
+ while (executing.size < concurrency && nextIndex < items.length) {
2458
+ const currentIndex = nextIndex++;
2459
+ const item = items[currentIndex];
2460
+ const promise = executor(item).then((value) => {
2461
+ results[currentIndex] = { success: true, value };
2462
+ }).catch((error) => {
2463
+ results[currentIndex] = {
2464
+ success: false,
2465
+ error: error instanceof Error ? error : new Error(String(error))
2466
+ };
2467
+ }).finally(() => {
2468
+ executing.delete(promise);
2469
+ completed++;
2470
+ onProgress?.(completed, items.length);
2471
+ if (completed === items.length) {
2472
+ resolve(results);
2473
+ } else {
2474
+ processNext();
2475
+ }
2476
+ });
2477
+ executing.add(promise);
2478
+ }
2479
+ }
2480
+ processNext();
2481
+ });
2482
+ }
2483
+
2484
+ // src/lib/sync/local.ts
2485
+ import fs4 from "fs";
2486
+ import path4 from "path";
2487
+ import { glob as glob2 } from "glob";
2488
+
2489
+ // src/lib/hash.ts
2490
+ import crypto3 from "crypto";
2491
+ function computeMD5(content) {
2492
+ return crypto3.createHash("md5").update(content, "utf8").digest("hex");
2493
+ }
2494
+
2495
+ // src/lib/sync/local.ts
2496
+ var IGNORE_FILE_NAME = ".contextignore";
2497
+ var DEFAULT_INCLUDE = ["**/*.md"];
2498
+ var DEFAULT_EXCLUDE = [
2499
+ "node_modules/**",
2500
+ ".git/**",
2501
+ ".DS_Store",
2502
+ "**/.DS_Store",
2503
+ "**/*.tmp",
2504
+ "**/~*",
2505
+ "**/*.swp",
2506
+ "**/*.swo",
2507
+ IGNORE_FILE_NAME
2508
+ // Don't sync the ignore file itself
2509
+ ];
2510
+ function parseIgnoreFile(content) {
2511
+ const patterns = [];
2512
+ for (const line of content.split("\n")) {
2513
+ const trimmed = line.trim();
2514
+ if (!trimmed || trimmed.startsWith("#")) {
2515
+ continue;
2516
+ }
2517
+ patterns.push(trimmed);
2518
+ }
2519
+ return patterns;
2520
+ }
2521
+ function loadIgnorePatterns(baseDir) {
2522
+ const ignoreFilePath = path4.join(baseDir, IGNORE_FILE_NAME);
2523
+ try {
2524
+ if (fs4.existsSync(ignoreFilePath)) {
2525
+ const content = fs4.readFileSync(ignoreFilePath, "utf8");
2526
+ return parseIgnoreFile(content);
2527
+ }
2528
+ } catch {
2529
+ }
2530
+ return [];
2531
+ }
2532
+ async function listLocalFiles(options) {
2533
+ const { baseDir, include = DEFAULT_INCLUDE, exclude = [] } = options;
2534
+ const absoluteBaseDir = path4.resolve(baseDir);
2535
+ if (!fs4.existsSync(absoluteBaseDir)) {
2536
+ throw new Error(`Directory does not exist: ${absoluteBaseDir}`);
2537
+ }
2538
+ const stats = fs4.statSync(absoluteBaseDir);
2539
+ if (!stats.isDirectory()) {
2540
+ throw new Error(`Not a directory: ${absoluteBaseDir}`);
2541
+ }
2542
+ const ignorePatterns = loadIgnorePatterns(absoluteBaseDir);
2543
+ const allExclude = [...DEFAULT_EXCLUDE, ...ignorePatterns, ...exclude];
2544
+ const files = await glob2(include, {
2545
+ cwd: absoluteBaseDir,
2546
+ nodir: true,
2547
+ dot: false,
2548
+ ignore: allExclude
2549
+ });
2550
+ const items = /* @__PURE__ */ new Map();
2551
+ for (const relativePath of files) {
2552
+ const absolutePath = path4.join(absoluteBaseDir, relativePath);
2553
+ try {
2554
+ const fileStats = fs4.statSync(absolutePath);
2555
+ if (fileStats.isDirectory()) {
2556
+ continue;
2557
+ }
2558
+ const content = fs4.readFileSync(absolutePath, "utf8");
2559
+ const normalizedPath = "/" + relativePath.split(path4.sep).join("/");
2560
+ items.set(normalizedPath, {
2561
+ path: normalizedPath,
2562
+ absolutePath,
2563
+ contentHash: computeMD5(content),
2564
+ mtime: fileStats.mtime,
2565
+ size: content.length
2566
+ });
2567
+ } catch {
2568
+ continue;
2569
+ }
2570
+ }
2571
+ return items;
2572
+ }
2573
+ function normalizePath(inputPath) {
2574
+ let normalized = inputPath.replace(/\\/g, "/");
2575
+ normalized = normalized.replace(/\/+/g, "/");
2576
+ if (!normalized.startsWith("/")) {
2577
+ normalized = "/" + normalized;
2578
+ }
2579
+ if (normalized.length > 1 && normalized.endsWith("/")) {
2580
+ normalized = normalized.slice(0, -1);
2581
+ }
2582
+ return normalized;
2583
+ }
2584
+ function toLocalPath(normalizedPath, baseDir) {
2585
+ const relativePath = normalizedPath.slice(1);
2586
+ return path4.join(path4.resolve(baseDir), relativePath);
2587
+ }
2588
+
2589
+ // src/lib/sync/remote.ts
2590
+ async function listRemoteFiles(options) {
2591
+ const { apiClient: apiClient2, projectId, remotePath, concurrency } = options;
2592
+ const allDocuments = [];
2593
+ let page = 1;
2594
+ const pageSize = 100;
2595
+ let hasMore = true;
2596
+ while (hasMore) {
2597
+ const response = await apiClient2.listDocuments(projectId, {
2598
+ page,
2599
+ pageSize,
2600
+ path: remotePath
2601
+ });
2602
+ const documents = response.documents.filter((d) => !d.isFolder);
2603
+ allDocuments.push(...documents);
2604
+ hasMore = response.pagination.page < response.pagination.totalPages;
2605
+ page++;
2606
+ }
2607
+ const filteredDocuments = remotePath ? allDocuments.filter((d) => {
2608
+ const normalizedDocPath = normalizePath(d.path);
2609
+ const normalizedRemotePath = normalizePath(remotePath);
2610
+ return normalizedDocPath === normalizedRemotePath || normalizedDocPath.startsWith(normalizedRemotePath + "/");
2611
+ }) : allDocuments;
2612
+ const results = await mapWithPool(
2613
+ filteredDocuments,
2614
+ async (doc) => {
2615
+ const fullDoc = await apiClient2.getDocument(projectId, doc.id);
2616
+ return fullDoc;
2617
+ },
2618
+ { concurrency }
2619
+ );
2620
+ const items = /* @__PURE__ */ new Map();
2621
+ for (const result of results) {
2622
+ if (result.success) {
2623
+ const doc = result.value;
2624
+ const normalizedPath = normalizePath(doc.path);
2625
+ const adjustedPath = remotePath ? getRelativePath(normalizedPath, normalizePath(remotePath)) : normalizedPath;
2626
+ items.set(adjustedPath, {
2627
+ path: adjustedPath,
2628
+ id: doc.id,
2629
+ version: doc.version,
2630
+ contentHash: computeMD5(doc.content),
2631
+ mtime: new Date(doc.updatedAt),
2632
+ size: doc.content.length
2633
+ });
2634
+ }
2635
+ }
2636
+ return items;
2637
+ }
2638
+ function getRelativePath(fullPath, basePath) {
2639
+ if (fullPath === basePath) {
2640
+ return "/" + fullPath.split("/").pop();
2641
+ }
2642
+ if (fullPath.startsWith(basePath + "/")) {
2643
+ return fullPath.slice(basePath.length);
2644
+ }
2645
+ return fullPath;
2646
+ }
2647
+
2648
+ // src/lib/sync/compare.ts
2649
+ function compareSyncItems(localItems, remoteItems, options) {
2650
+ const { direction, deleteOrphans, conflictStrategy } = options;
2651
+ const actions = [];
2652
+ const conflicts = [];
2653
+ const allPaths = /* @__PURE__ */ new Set([...localItems.keys(), ...remoteItems.keys()]);
2654
+ for (const path6 of allPaths) {
2655
+ const local = localItems.get(path6);
2656
+ const remote = remoteItems.get(path6);
2657
+ if (local && remote) {
2658
+ if (local.contentHash === remote.contentHash) {
2659
+ actions.push({
2660
+ type: "skip",
2661
+ path: path6,
2662
+ local,
2663
+ remote,
2664
+ reason: "Identical content"
2665
+ });
2666
+ } else {
2667
+ handleContentDiff(local, remote, direction, conflictStrategy, actions, conflicts);
2668
+ }
2669
+ } else if (local && !remote) {
2670
+ handleLocalOnly(local, direction, deleteOrphans, actions);
2671
+ } else if (!local && remote) {
2672
+ handleRemoteOnly(remote, direction, deleteOrphans, actions);
2673
+ }
2674
+ }
2675
+ actions.sort((a, b) => a.path.localeCompare(b.path));
2676
+ conflicts.sort((a, b) => a.path.localeCompare(b.path));
2677
+ return {
2678
+ actions,
2679
+ conflicts,
2680
+ summary: computeSummary(actions, conflicts, localItems.size, remoteItems.size)
2681
+ };
2682
+ }
2683
+ function handleContentDiff(local, remote, direction, conflictStrategy, actions, conflicts) {
2684
+ const path6 = local.path;
2685
+ if (direction === "push") {
2686
+ actions.push({
2687
+ type: "update-remote",
2688
+ path: path6,
2689
+ local,
2690
+ remote,
2691
+ reason: "Local is source of truth (push mode)"
2692
+ });
2693
+ } else if (direction === "pull") {
2694
+ actions.push({
2695
+ type: "update-local",
2696
+ path: path6,
2697
+ local,
2698
+ remote,
2699
+ reason: "Remote is source of truth (pull mode)"
2700
+ });
2701
+ } else {
2702
+ switch (conflictStrategy) {
2703
+ case "newest": {
2704
+ const localNewer = local.mtime > remote.mtime;
2705
+ actions.push({
2706
+ type: localNewer ? "update-remote" : "update-local",
2707
+ path: path6,
2708
+ local,
2709
+ remote,
2710
+ reason: localNewer ? `Local newer (${formatTime(local.mtime)} > ${formatTime(remote.mtime)})` : `Remote newer (${formatTime(remote.mtime)} > ${formatTime(local.mtime)})`
2711
+ });
2712
+ break;
2713
+ }
2714
+ case "local":
2715
+ actions.push({
2716
+ type: "update-remote",
2717
+ path: path6,
2718
+ local,
2719
+ remote,
2720
+ reason: "Local wins (--conflict local)"
2721
+ });
2722
+ break;
2723
+ case "remote":
2724
+ actions.push({
2725
+ type: "update-local",
2726
+ path: path6,
2727
+ local,
2728
+ remote,
2729
+ reason: "Remote wins (--conflict remote)"
2730
+ });
2731
+ break;
2732
+ case "skip":
2733
+ actions.push({
2734
+ type: "skip",
2735
+ path: path6,
2736
+ local,
2737
+ remote,
2738
+ reason: "Conflict skipped (--conflict skip)"
2739
+ });
2740
+ break;
2741
+ case "error":
2742
+ default:
2743
+ conflicts.push({
2744
+ path: path6,
2745
+ local,
2746
+ remote
2747
+ });
2748
+ break;
2749
+ }
2750
+ }
2751
+ }
2752
+ function handleLocalOnly(local, direction, deleteOrphans, actions) {
2753
+ const path6 = local.path;
2754
+ if (direction === "pull") {
2755
+ if (deleteOrphans) {
2756
+ actions.push({
2757
+ type: "delete-local",
2758
+ path: path6,
2759
+ local,
2760
+ reason: "Not on remote (--delete)"
2761
+ });
2762
+ } else {
2763
+ actions.push({
2764
+ type: "skip",
2765
+ path: path6,
2766
+ local,
2767
+ reason: "Local-only (no --delete)"
2768
+ });
2769
+ }
2770
+ } else {
2771
+ actions.push({
2772
+ type: "create-remote",
2773
+ path: path6,
2774
+ local,
2775
+ reason: "New local file"
2776
+ });
2777
+ }
2778
+ }
2779
+ function handleRemoteOnly(remote, direction, deleteOrphans, actions) {
2780
+ const path6 = remote.path;
2781
+ if (direction === "push") {
2782
+ if (deleteOrphans) {
2783
+ actions.push({
2784
+ type: "delete-remote",
2785
+ path: path6,
2786
+ remote,
2787
+ reason: "Not local (--delete)"
2788
+ });
2789
+ } else {
2790
+ actions.push({
2791
+ type: "skip",
2792
+ path: path6,
2793
+ remote,
2794
+ reason: "Remote-only (no --delete)"
2795
+ });
2796
+ }
2797
+ } else {
2798
+ actions.push({
2799
+ type: "create-local",
2800
+ path: path6,
2801
+ remote,
2802
+ reason: "New remote file"
2803
+ });
2804
+ }
2805
+ }
2806
+ function computeSummary(actions, conflicts, totalLocal, totalRemote) {
2807
+ const summary = {
2808
+ totalLocal,
2809
+ totalRemote,
2810
+ toCreate: { local: 0, remote: 0 },
2811
+ toUpdate: { local: 0, remote: 0 },
2812
+ toDelete: { local: 0, remote: 0 },
2813
+ toSkip: 0,
2814
+ conflicts: conflicts.length
2815
+ };
2816
+ for (const action of actions) {
2817
+ switch (action.type) {
2818
+ case "create-local":
2819
+ summary.toCreate.local++;
2820
+ break;
2821
+ case "create-remote":
2822
+ summary.toCreate.remote++;
2823
+ break;
2824
+ case "update-local":
2825
+ summary.toUpdate.local++;
2826
+ break;
2827
+ case "update-remote":
2828
+ summary.toUpdate.remote++;
2829
+ break;
2830
+ case "delete-local":
2831
+ summary.toDelete.local++;
2832
+ break;
2833
+ case "delete-remote":
2834
+ summary.toDelete.remote++;
2835
+ break;
2836
+ case "skip":
2837
+ summary.toSkip++;
2838
+ break;
2839
+ }
2840
+ }
2841
+ return summary;
2842
+ }
2843
+ function formatTime(date) {
2844
+ return date.toISOString().replace("T", " ").slice(0, 19);
2845
+ }
2846
+ function hasChanges(plan) {
2847
+ return plan.actions.some((a) => a.type !== "skip") || plan.conflicts.length > 0;
2848
+ }
2849
+ function getOperationCount(plan) {
2850
+ return plan.actions.filter((a) => a.type !== "skip").length;
2851
+ }
2852
+
2853
+ // src/lib/sync/execute.ts
2854
+ import fs5 from "fs";
2855
+ import path5 from "path";
2856
+ async function executeSyncPlan(plan, options) {
2857
+ const { apiClient: apiClient2, projectId, localBaseDir, remotePath, concurrency, onProgress } = options;
2858
+ const skipped = plan.actions.filter((a) => a.type === "skip");
2859
+ const actionsToExecute = plan.actions.filter((a) => a.type !== "skip");
2860
+ if (actionsToExecute.length === 0) {
2861
+ return { executed: [], failed: [], skipped };
2862
+ }
2863
+ const result = await executeWithPool(
2864
+ actionsToExecute,
2865
+ async (action) => {
2866
+ await executeSingleAction(action, {
2867
+ apiClient: apiClient2,
2868
+ projectId,
2869
+ localBaseDir,
2870
+ remotePath
2871
+ });
2872
+ },
2873
+ {
2874
+ concurrency,
2875
+ onProgress: (completed, total) => {
2876
+ const currentAction = actionsToExecute[completed - 1];
2877
+ onProgress?.(completed, total, currentAction);
2878
+ }
2879
+ }
2880
+ );
2881
+ return {
2882
+ executed: result.successes.map((s) => s.item),
2883
+ failed: result.failures.map((f) => ({ action: f.item, error: f.error })),
2884
+ skipped
2885
+ };
2886
+ }
2887
+ async function executeSingleAction(action, context2) {
2888
+ const { apiClient: apiClient2, projectId, localBaseDir, remotePath } = context2;
2889
+ switch (action.type) {
2890
+ case "create-local":
2891
+ case "update-local": {
2892
+ if (!action.remote) {
2893
+ throw new Error(`No remote info for action: ${action.path}`);
2894
+ }
2895
+ const doc = await apiClient2.getDocument(projectId, action.remote.id);
2896
+ const localPath = toLocalPath(action.path, localBaseDir);
2897
+ const dir = path5.dirname(localPath);
2898
+ if (!fs5.existsSync(dir)) {
2899
+ fs5.mkdirSync(dir, { recursive: true });
2900
+ }
2901
+ fs5.writeFileSync(localPath, doc.content, "utf8");
2902
+ break;
2903
+ }
2904
+ case "create-remote": {
2905
+ if (!action.local) {
2906
+ throw new Error(`No local info for action: ${action.path}`);
2907
+ }
2908
+ const content = fs5.readFileSync(action.local.absolutePath, "utf8");
2909
+ const title = path5.basename(action.path);
2910
+ const fullRemotePath = remotePath ? normalizePath(remotePath + action.path) : action.path;
2911
+ await apiClient2.createDocument(projectId, {
2912
+ title,
2913
+ content,
2914
+ path: fullRemotePath
2915
+ });
2916
+ break;
2917
+ }
2918
+ case "update-remote": {
2919
+ if (!action.local || !action.remote) {
2920
+ throw new Error(`Missing info for action: ${action.path}`);
2921
+ }
2922
+ const content = fs5.readFileSync(action.local.absolutePath, "utf8");
2923
+ await apiClient2.updateDocument(projectId, action.remote.id, {
2924
+ content,
2925
+ createVersion: true
2926
+ });
2927
+ break;
2928
+ }
2929
+ case "delete-local": {
2930
+ if (!action.local) {
2931
+ throw new Error(`No local info for action: ${action.path}`);
2932
+ }
2933
+ fs5.unlinkSync(action.local.absolutePath);
2934
+ cleanupEmptyDirs(path5.dirname(action.local.absolutePath), localBaseDir);
2935
+ break;
2936
+ }
2937
+ case "delete-remote": {
2938
+ if (!action.remote) {
2939
+ throw new Error(`No remote info for action: ${action.path}`);
2940
+ }
2941
+ await apiClient2.deleteDocument(projectId, action.remote.id);
2942
+ break;
2943
+ }
2944
+ case "skip":
2945
+ break;
2946
+ }
2947
+ }
2948
+ function cleanupEmptyDirs(dirPath, baseDir) {
2949
+ const resolvedBase = path5.resolve(baseDir);
2950
+ let current = path5.resolve(dirPath);
2951
+ while (current !== resolvedBase && current.startsWith(resolvedBase)) {
2952
+ try {
2953
+ const entries = fs5.readdirSync(current);
2954
+ if (entries.length === 0) {
2955
+ fs5.rmdirSync(current);
2956
+ current = path5.dirname(current);
2957
+ } else {
2958
+ break;
2959
+ }
2960
+ } catch {
2961
+ break;
2962
+ }
2963
+ }
2964
+ }
2965
+ function countOperations(result) {
2966
+ let uploaded = 0;
2967
+ let downloaded = 0;
2968
+ let deleted = 0;
2969
+ for (const action of result.executed) {
2970
+ switch (action.type) {
2971
+ case "create-remote":
2972
+ case "update-remote":
2973
+ uploaded++;
2974
+ break;
2975
+ case "create-local":
2976
+ case "update-local":
2977
+ downloaded++;
2978
+ break;
2979
+ case "delete-local":
2980
+ case "delete-remote":
2981
+ deleted++;
2982
+ break;
2983
+ }
2984
+ }
2985
+ return {
2986
+ uploaded,
2987
+ downloaded,
2988
+ deleted,
2989
+ failed: result.failed.length
2990
+ };
2991
+ }
2992
+
2993
+ // src/commands/sync.ts
2994
+ var DEFAULT_CONCURRENCY = 5;
2995
+ var MAX_CONCURRENCY = 10;
2996
+ function collect(value, previous) {
2997
+ return [...previous, value];
2998
+ }
2999
+ function getProjectId4(globalOptions, context2) {
3000
+ const projectId = globalOptions.project || context2.config.defaultProjectId;
3001
+ if (!projectId) {
3002
+ throw new CLIError(
3003
+ "PROJECT_NOT_SET",
3004
+ "No project specified",
3005
+ "Use --project <id> or run 'contextloop project use <id>' to set a default"
3006
+ );
3007
+ }
3008
+ return projectId;
3009
+ }
3010
+ function parseConcurrency(value) {
3011
+ if (!value) {
3012
+ return DEFAULT_CONCURRENCY;
3013
+ }
3014
+ const num = parseInt(value, 10);
3015
+ if (isNaN(num) || num < 1) {
3016
+ throw new CLIError("VALIDATION_ERROR", "Concurrency must be a positive integer");
3017
+ }
3018
+ return Math.min(num, MAX_CONCURRENCY);
3019
+ }
3020
+ function parseConflictStrategy(value) {
3021
+ const strategies = ["newest", "local", "remote", "skip", "error"];
3022
+ if (!value) {
3023
+ return "error";
3024
+ }
3025
+ if (!strategies.includes(value)) {
3026
+ throw new CLIError(
3027
+ "VALIDATION_ERROR",
3028
+ `Invalid conflict strategy: ${value}`,
3029
+ `Valid strategies: ${strategies.join(", ")}`
3030
+ );
3031
+ }
3032
+ return value;
3033
+ }
3034
+ function formatDryRun(plan, globalOptions) {
3035
+ printOutput(
3036
+ plan,
3037
+ {
3038
+ text: (p) => formatDryRunText(p),
3039
+ json: (p) => ({
3040
+ dryRun: true,
3041
+ actions: p.actions,
3042
+ conflicts: p.conflicts,
3043
+ summary: p.summary
3044
+ })
3045
+ },
3046
+ globalOptions
3047
+ );
3048
+ }
3049
+ function formatDryRunText(plan) {
3050
+ const lines = [];
3051
+ lines.push(chalk9.cyan("\n=== Sync Preview (dry-run) ===\n"));
3052
+ const groups = {
3053
+ "create-remote": [],
3054
+ "update-remote": [],
3055
+ "create-local": [],
3056
+ "update-local": [],
3057
+ "delete-remote": [],
3058
+ "delete-local": []
3059
+ };
3060
+ for (const action of plan.actions) {
3061
+ if (action.type !== "skip" && groups[action.type]) {
3062
+ groups[action.type].push(action);
3063
+ }
3064
+ }
3065
+ const labels = {
3066
+ "create-remote": { label: "Upload (new)", icon: "+", color: chalk9.green },
3067
+ "update-remote": { label: "Upload (update)", icon: "~", color: chalk9.yellow },
3068
+ "create-local": { label: "Download (new)", icon: "+", color: chalk9.green },
3069
+ "update-local": { label: "Download (update)", icon: "~", color: chalk9.yellow },
3070
+ "delete-remote": { label: "Delete remote", icon: "-", color: chalk9.red },
3071
+ "delete-local": { label: "Delete local", icon: "-", color: chalk9.red }
3072
+ };
3073
+ for (const [type, actions] of Object.entries(groups)) {
3074
+ if (actions.length > 0) {
3075
+ const { label, icon, color } = labels[type];
3076
+ lines.push(color.bold(`${label} (${actions.length}):`));
3077
+ for (const action of actions) {
3078
+ lines.push(color(` ${icon} ${action.path}`));
3079
+ }
3080
+ lines.push("");
3081
+ }
3082
+ }
3083
+ if (plan.conflicts.length > 0) {
3084
+ lines.push(chalk9.red.bold(`Conflicts (${plan.conflicts.length}):`));
3085
+ for (const conflict of plan.conflicts) {
3086
+ lines.push(chalk9.red(` ! ${conflict.path}`));
3087
+ lines.push(chalk9.gray(` Local: ${conflict.local.mtime.toISOString()}`));
3088
+ lines.push(chalk9.gray(` Remote: ${conflict.remote.mtime.toISOString()}`));
3089
+ }
3090
+ lines.push("");
3091
+ }
3092
+ const s = plan.summary;
3093
+ lines.push(chalk9.bold("Summary:"));
3094
+ lines.push(` Upload: ${s.toCreate.remote} new, ${s.toUpdate.remote} updated`);
3095
+ lines.push(` Download: ${s.toCreate.local} new, ${s.toUpdate.local} updated`);
3096
+ lines.push(` Delete: ${s.toDelete.local} local, ${s.toDelete.remote} remote`);
3097
+ lines.push(` Skip: ${s.toSkip}`);
3098
+ if (s.conflicts > 0) {
3099
+ lines.push(chalk9.red(` Conflicts: ${s.conflicts}`));
3100
+ }
3101
+ lines.push("");
3102
+ lines.push(chalk9.cyan("Run without --dry-run to execute these changes."));
3103
+ return lines.join("\n");
3104
+ }
3105
+ function formatResult(result, globalOptions) {
3106
+ printOutput(
3107
+ result,
3108
+ {
3109
+ text: (r) => formatResultText(r),
3110
+ json: (r) => ({
3111
+ executed: r.executed,
3112
+ failed: r.failed.map((f) => ({
3113
+ action: f.action,
3114
+ error: f.error.message
3115
+ })),
3116
+ skipped: r.skipped.length
3117
+ })
3118
+ },
3119
+ globalOptions
3120
+ );
3121
+ }
3122
+ function formatResultText(result) {
3123
+ const lines = [];
3124
+ const counts = countOperations(result);
3125
+ const total = counts.uploaded + counts.downloaded + counts.deleted;
3126
+ if (total > 0) {
3127
+ const parts = [];
3128
+ if (counts.uploaded > 0) parts.push(`${counts.uploaded} uploaded`);
3129
+ if (counts.downloaded > 0) parts.push(`${counts.downloaded} downloaded`);
3130
+ if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`);
3131
+ lines.push(chalk9.green(`
3132
+ Synced ${total} file(s) (${parts.join(", ")})`));
3133
+ }
3134
+ if (result.failed.length > 0) {
3135
+ lines.push(chalk9.red(`
3136
+ Failed: ${result.failed.length} operation(s)`));
3137
+ for (const failure of result.failed) {
3138
+ lines.push(chalk9.red(` - ${failure.action.path}: ${failure.error.message}`));
3139
+ }
3140
+ }
3141
+ if (total === 0 && result.failed.length === 0) {
3142
+ lines.push(chalk9.green("\nEverything is in sync!"));
3143
+ }
3144
+ return lines.join("\n");
3145
+ }
3146
+ async function runSync(localDir, direction, options, globalOptions, context2) {
3147
+ const projectId = getProjectId4(globalOptions, context2);
3148
+ const concurrency = parseConcurrency(options.concurrency);
3149
+ const conflictStrategy = parseConflictStrategy(options.conflict);
3150
+ const syncOptions = {
3151
+ direction,
3152
+ remotePath: options.remotePath,
3153
+ deleteOrphans: options.delete || false,
3154
+ conflictStrategy,
3155
+ concurrency,
3156
+ dryRun: options.dryRun || false,
3157
+ include: options.include.length > 0 ? options.include : void 0,
3158
+ exclude: options.exclude.length > 0 ? options.exclude : void 0
3159
+ };
3160
+ const scanSpinner = ora6("Scanning local files...").start();
3161
+ let localItems;
3162
+ try {
3163
+ localItems = await listLocalFiles({
3164
+ baseDir: localDir,
3165
+ include: syncOptions.include,
3166
+ exclude: syncOptions.exclude
3167
+ });
3168
+ scanSpinner.succeed(`Found ${localItems.size} local file(s)`);
3169
+ } catch (error) {
3170
+ scanSpinner.fail("Failed to scan local files");
3171
+ throw error;
3172
+ }
3173
+ const fetchSpinner = ora6("Fetching remote files...").start();
3174
+ let remoteItems;
3175
+ try {
3176
+ remoteItems = await listRemoteFiles({
3177
+ apiClient: context2.apiClient,
3178
+ projectId,
3179
+ remotePath: syncOptions.remotePath,
3180
+ concurrency
3181
+ });
3182
+ fetchSpinner.succeed(`Found ${remoteItems.size} remote file(s)`);
3183
+ } catch (error) {
3184
+ fetchSpinner.fail("Failed to fetch remote files");
3185
+ throw error;
3186
+ }
3187
+ const compareSpinner = ora6("Comparing files...").start();
3188
+ const plan = compareSyncItems(localItems, remoteItems, syncOptions);
3189
+ compareSpinner.stop();
3190
+ if (plan.conflicts.length > 0 && conflictStrategy === "error") {
3191
+ formatDryRun(plan, globalOptions);
3192
+ throw new CLIError(
3193
+ "SYNC_CONFLICT",
3194
+ `${plan.conflicts.length} conflict(s) detected`,
3195
+ "Use --conflict <strategy> to resolve (newest, local, remote, skip)"
3196
+ );
3197
+ }
3198
+ if (syncOptions.dryRun) {
3199
+ formatDryRun(plan, globalOptions);
3200
+ return;
3201
+ }
3202
+ if (!hasChanges(plan)) {
3203
+ console.log(chalk9.green("\nEverything is in sync!"));
3204
+ return;
3205
+ }
3206
+ const deletionCount = plan.summary.toDelete.local + plan.summary.toDelete.remote;
3207
+ if (deletionCount > 0 && !options.force) {
3208
+ formatDryRun(plan, globalOptions);
3209
+ console.log(
3210
+ chalk9.yellow(`
3211
+ This will delete ${deletionCount} file(s). Use --force to skip this warning.`)
3212
+ );
3213
+ throw new CLIError("VALIDATION_ERROR", "Deletions require --force flag");
3214
+ }
3215
+ const opCount = getOperationCount(plan);
3216
+ const execSpinner = ora6(`Syncing 0/${opCount}...`).start();
3217
+ try {
3218
+ const result = await executeSyncPlan(plan, {
3219
+ apiClient: context2.apiClient,
3220
+ projectId,
3221
+ localBaseDir: localDir,
3222
+ remotePath: syncOptions.remotePath,
3223
+ concurrency,
3224
+ onProgress: (completed, total) => {
3225
+ execSpinner.text = `Syncing ${completed}/${total}...`;
3226
+ }
3227
+ });
3228
+ execSpinner.stop();
3229
+ formatResult(result, globalOptions);
3230
+ if (result.failed.length > 0) {
3231
+ throw new CLIError(
3232
+ "SYNC_PARTIAL_FAILURE",
3233
+ `${result.failed.length} operation(s) failed`,
3234
+ "Run again to retry failed operations"
3235
+ );
3236
+ }
3237
+ } catch (error) {
3238
+ execSpinner.stop();
3239
+ throw error;
3240
+ }
3241
+ }
3242
+ function registerSyncCommands(program2, context2) {
3243
+ const sync = program2.command("sync").description("Sync files with ContextLoop");
3244
+ const addCommonOptions = (cmd) => {
3245
+ return cmd.option("--remote-path <path>", "Remote path prefix to sync").option("--delete", "Delete files that do not exist on the source side").option(
3246
+ "--conflict <strategy>",
3247
+ "Conflict resolution: newest, local, remote, skip, error",
3248
+ "error"
3249
+ ).option("--concurrency <n>", "Max concurrent operations", String(DEFAULT_CONCURRENCY)).option("--dry-run", "Preview changes without executing").option("--include <glob>", "Include pattern (can be repeated)", collect, []).option("--exclude <glob>", "Exclude pattern (can be repeated)", collect, []).option("--force", "Skip confirmation for deletions");
3250
+ };
3251
+ addCommonOptions(sync).argument("[local-dir]", "Local directory to sync", ".").action(async (localDir, options) => {
3252
+ const globalOptions = program2.opts();
3253
+ try {
3254
+ await runSync(localDir, "both", options, globalOptions, context2);
3255
+ } catch (error) {
3256
+ console.error(formatError(error, globalOptions.format));
3257
+ process.exit(getExitCode(error));
3258
+ }
3259
+ });
3260
+ addCommonOptions(
3261
+ sync.command("push").argument("<local-dir>", "Local directory to push").description("Push local files to remote (local -> remote)")
3262
+ ).action(async (localDir, options) => {
3263
+ const globalOptions = program2.opts();
3264
+ try {
3265
+ await runSync(localDir, "push", options, globalOptions, context2);
3266
+ } catch (error) {
3267
+ console.error(formatError(error, globalOptions.format));
3268
+ process.exit(getExitCode(error));
3269
+ }
3270
+ });
3271
+ addCommonOptions(
3272
+ sync.command("pull").argument("<local-dir>", "Local directory to pull into").description("Pull remote files to local (remote -> local)")
3273
+ ).action(async (localDir, options) => {
3274
+ const globalOptions = program2.opts();
3275
+ try {
3276
+ await runSync(localDir, "pull", options, globalOptions, context2);
3277
+ } catch (error) {
3278
+ console.error(formatError(error, globalOptions.format));
3279
+ process.exit(getExitCode(error));
3280
+ }
3281
+ });
3282
+ }
3283
+
2225
3284
  // src/index.ts
2226
3285
  var program = new Command();
2227
3286
  program.name("contextloop").description("CLI for ContextLoop - manage documents and context").version("0.1.0").option("-p, --project <id>", "Project ID or slug").option("-f, --format <format>", "Output format: text, json", "text").option("-q, --quiet", "Suppress non-essential output").option("-v, --verbose", "Enable verbose logging").option("--no-color", "Disable colored output");
@@ -2238,6 +3297,7 @@ registerProjectCommands(program, context);
2238
3297
  registerDocumentCommands(program, context);
2239
3298
  registerContextCommands(program, context);
2240
3299
  registerCommentCommands(program, context);
3300
+ registerSyncCommands(program, context);
2241
3301
  async function main() {
2242
3302
  try {
2243
3303
  await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextloop-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for ContextLoop - upload, download, and manage documents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,23 +23,23 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@napi-rs/keyring": "^1.2.0",
26
- "chalk": "^5.3.0",
27
- "commander": "^12.0.0",
28
- "conf": "^12.0.0",
29
- "glob": "^10.3.0",
30
- "ofetch": "^1.3.0",
31
- "open": "^10.0.0",
32
- "ora": "^8.0.0"
26
+ "chalk": "^5.6.2",
27
+ "commander": "^14.0.2",
28
+ "conf": "^15.0.2",
29
+ "glob": "^13.0.0",
30
+ "ofetch": "^1.5.1",
31
+ "open": "^11.0.0",
32
+ "ora": "^9.0.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@types/node": "^20.0.0",
36
- "@typescript-eslint/eslint-plugin": "^7.0.0",
37
- "@typescript-eslint/parser": "^7.0.0",
38
- "@vitest/coverage-v8": "^1.3.0",
39
- "eslint": "^8.57.0",
40
- "tsup": "^8.0.0",
41
- "typescript": "^5.4.0",
42
- "vitest": "^1.3.0"
35
+ "@types/node": "^24.10.1",
36
+ "@typescript-eslint/eslint-plugin": "^8.48.1",
37
+ "@typescript-eslint/parser": "^8.48.1",
38
+ "@vitest/coverage-v8": "^4.0.15",
39
+ "eslint": "^9.39.1",
40
+ "tsup": "^8.5.1",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.15"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=20.0.0"