memax-cli 0.1.0-alpha.26 → 0.1.0-alpha.28
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/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +8 -2
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/recall.d.ts +5 -0
- package/dist/commands/recall.d.ts.map +1 -1
- package/dist/commands/recall.js +89 -6
- package/dist/commands/recall.js.map +1 -1
- package/dist/commands/recall.test.d.ts +2 -0
- package/dist/commands/recall.test.d.ts.map +1 -0
- package/dist/commands/recall.test.js +23 -0
- package/dist/commands/recall.test.js.map +1 -0
- package/dist/commands/setup-mcp.d.ts.map +1 -1
- package/dist/commands/setup-mcp.js +3 -3
- package/dist/commands/setup-mcp.js.map +1 -1
- package/dist/commands/sync.d.ts +26 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +508 -79
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/sync.test.d.ts +2 -0
- package/dist/commands/sync.test.d.ts.map +1 -0
- package/dist/commands/sync.test.js +130 -0
- package/dist/commands/sync.test.js.map +1 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +63 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/project-context.d.ts +14 -4
- package/dist/lib/project-context.d.ts.map +1 -1
- package/dist/lib/project-context.js +98 -54
- package/dist/lib/project-context.js.map +1 -1
- package/dist/lib/project-context.test.d.ts +2 -0
- package/dist/lib/project-context.test.d.ts.map +1 -0
- package/dist/lib/project-context.test.js +75 -0
- package/dist/lib/project-context.test.js.map +1 -0
- package/package.json +2 -2
package/dist/commands/sync.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
|
|
4
4
|
import { join, relative, extname, resolve, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { getClient } from "../lib/client.js";
|
|
7
|
-
import { getProjectScope, resolveClaudeProjectFolder, normalizeFilePath, } from "../lib/project-context.js";
|
|
7
|
+
import { getProjectScope, resolveProjectScope, resolveClaudeProjectFolder, normalizeFilePath, readMemaxYmlConfig, detectProjectContext, } from "../lib/project-context.js";
|
|
8
|
+
import { getOrCreateDeviceID, listSyncSources, updateSyncSourceRun, } from "../lib/config.js";
|
|
8
9
|
import { confirm, ask, confirmDefault } from "../lib/prompt.js";
|
|
9
10
|
const DEFAULT_IGNORE = new Set([
|
|
10
11
|
"node_modules",
|
|
@@ -83,6 +84,154 @@ export async function listAgentConfigsCommand() {
|
|
|
83
84
|
}
|
|
84
85
|
console.log(chalk.gray(` ${configs.length} config${configs.length > 1 ? "s" : ""} synced to cloud.\n`));
|
|
85
86
|
}
|
|
87
|
+
export function classifyAgentConfigPlacement(agent, filePath, scope, options = {}) {
|
|
88
|
+
const key = `${agent}|${normalizeFilePath(filePath)}|${scope}`;
|
|
89
|
+
const existing = options.localByKey?.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
return {
|
|
92
|
+
kind: "present",
|
|
93
|
+
path: existing.path,
|
|
94
|
+
reason: "present locally",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const cwd = options.cwd ?? process.cwd();
|
|
98
|
+
const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
|
|
99
|
+
if (scope.startsWith("project:") &&
|
|
100
|
+
scope !== "project" &&
|
|
101
|
+
scope !== currentProjectScope) {
|
|
102
|
+
return {
|
|
103
|
+
kind: "different_project",
|
|
104
|
+
reason: `belongs to ${scope.replace(/^project:/, "")}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const path = resolveAgentConfigWritePath(agent, filePath, scope, options);
|
|
108
|
+
if (path) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "restorable",
|
|
111
|
+
path,
|
|
112
|
+
reason: "safe restore path available",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
kind: "unresolved",
|
|
117
|
+
reason: "no safe restore path on this machine",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export async function doctorAgentConfigsCommand() {
|
|
121
|
+
const cwd = process.cwd();
|
|
122
|
+
const home = homedir();
|
|
123
|
+
const deviceID = getOrCreateDeviceID();
|
|
124
|
+
const project = resolveProjectScope(cwd);
|
|
125
|
+
const memaxYml = readMemaxYmlConfig(cwd);
|
|
126
|
+
const locations = discoverAgentConfigs();
|
|
127
|
+
const localConfigs = locations.filter((loc) => {
|
|
128
|
+
if (!existsSync(loc.path))
|
|
129
|
+
return false;
|
|
130
|
+
try {
|
|
131
|
+
const stat = statSync(loc.path);
|
|
132
|
+
return stat.isFile() && stat.size > 0;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
const localByKey = new Map();
|
|
139
|
+
for (const loc of localConfigs) {
|
|
140
|
+
localByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
|
|
141
|
+
}
|
|
142
|
+
let cloudConfigs = null;
|
|
143
|
+
try {
|
|
144
|
+
cloudConfigs = await getClient().configs.list();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error(chalk.red(` Failed to fetch cloud configs: ${err.message}\n`));
|
|
148
|
+
}
|
|
149
|
+
const scopeSource = project.source === "memax_yml"
|
|
150
|
+
? ".memax.yml project_id"
|
|
151
|
+
: project.source === "git_remote"
|
|
152
|
+
? "git origin"
|
|
153
|
+
: "no canonical project identity";
|
|
154
|
+
console.log(chalk.bold("\n Memax Agent Config Doctor\n"));
|
|
155
|
+
console.log(chalk.white(" Device"));
|
|
156
|
+
console.log(` ID ${chalk.bold(deviceID)}`);
|
|
157
|
+
console.log(` Home ${chalk.gray(home)}`);
|
|
158
|
+
console.log();
|
|
159
|
+
console.log(chalk.white(" Project"));
|
|
160
|
+
console.log(` CWD ${chalk.gray(cwd)}`);
|
|
161
|
+
console.log(` Scope ${chalk.bold(project.scope)}`);
|
|
162
|
+
console.log(` Source ${chalk.gray(scopeSource)}`);
|
|
163
|
+
if (memaxYml?.hub) {
|
|
164
|
+
console.log(` Hub ${chalk.gray(memaxYml.hub)}`);
|
|
165
|
+
}
|
|
166
|
+
if (memaxYml?.project_id) {
|
|
167
|
+
console.log(` project_id ${chalk.gray(memaxYml.project_id)}`);
|
|
168
|
+
}
|
|
169
|
+
if (project.warning) {
|
|
170
|
+
console.log(` Warning ${chalk.yellow(project.warning)}`);
|
|
171
|
+
}
|
|
172
|
+
if (project.scope === "project") {
|
|
173
|
+
console.log(` Note ${chalk.yellow("project-scoped cross-device restore is disabled until git origin or .memax.yml project_id is available")}`);
|
|
174
|
+
}
|
|
175
|
+
console.log();
|
|
176
|
+
const byAgent = new Map();
|
|
177
|
+
for (const loc of localConfigs) {
|
|
178
|
+
const group = byAgent.get(loc.agent) ?? [];
|
|
179
|
+
group.push(loc);
|
|
180
|
+
byAgent.set(loc.agent, group);
|
|
181
|
+
}
|
|
182
|
+
console.log(chalk.white(" Local Discovery"));
|
|
183
|
+
if (localConfigs.length === 0) {
|
|
184
|
+
console.log(` ${chalk.gray("No local agent configs discovered.")}`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
for (const [agent, group] of byAgent) {
|
|
188
|
+
console.log(` ${chalk.cyan(formatAgentName(agent))}`);
|
|
189
|
+
for (const loc of group) {
|
|
190
|
+
const scopeLabel = loc.scope === "global"
|
|
191
|
+
? "global"
|
|
192
|
+
: loc.scope.replace(/^project:/, "");
|
|
193
|
+
console.log(` • ${loc.filePath} ${chalk.gray(scopeLabel)} ${chalk.gray(loc.path)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
console.log();
|
|
198
|
+
if (!cloudConfigs) {
|
|
199
|
+
console.log(chalk.gray(" Cloud inspection skipped because cloud config fetch failed.\n"));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const placements = cloudConfigs.configs.map((config) => ({
|
|
203
|
+
config,
|
|
204
|
+
placement: classifyAgentConfigPlacement(config.agent, config.file_path, config.scope, {
|
|
205
|
+
cwd,
|
|
206
|
+
home,
|
|
207
|
+
currentProjectScope: project.scope,
|
|
208
|
+
localByKey,
|
|
209
|
+
findClaudeProjectDir,
|
|
210
|
+
}),
|
|
211
|
+
}));
|
|
212
|
+
const present = placements.filter((p) => p.placement.kind === "present");
|
|
213
|
+
const restorable = placements.filter((p) => p.placement.kind === "restorable");
|
|
214
|
+
const differentProject = placements.filter((p) => p.placement.kind === "different_project");
|
|
215
|
+
const unresolved = placements.filter((p) => p.placement.kind === "unresolved");
|
|
216
|
+
console.log(chalk.white(" Cloud Coverage"));
|
|
217
|
+
console.log(` Present locally ${chalk.bold(String(present.length))}`);
|
|
218
|
+
console.log(` Restorable here ${chalk.bold(String(restorable.length))}`);
|
|
219
|
+
console.log(` Other project ${chalk.bold(String(differentProject.length))}`);
|
|
220
|
+
console.log(` Unresolved here ${chalk.bold(String(unresolved.length))}`);
|
|
221
|
+
console.log();
|
|
222
|
+
printPlacementSection(" Restorable Here", chalk.cyan, restorable, (item) => [
|
|
223
|
+
item.config.agent,
|
|
224
|
+
item.config.file_path,
|
|
225
|
+
item.placement.path ?? "",
|
|
226
|
+
]);
|
|
227
|
+
printPlacementSection(" Different Project", chalk.yellow, differentProject, (item) => [item.config.agent, item.config.file_path, item.placement.reason]);
|
|
228
|
+
printPlacementSection(" Unresolved", chalk.magenta, unresolved, (item) => [
|
|
229
|
+
item.config.agent,
|
|
230
|
+
item.config.file_path,
|
|
231
|
+
item.placement.reason,
|
|
232
|
+
]);
|
|
233
|
+
console.log(chalk.gray(" Use this command to verify what sync can restore safely on this machine.\n"));
|
|
234
|
+
}
|
|
86
235
|
export async function deleteAgentConfigsCommand() {
|
|
87
236
|
let configs;
|
|
88
237
|
try {
|
|
@@ -112,6 +261,7 @@ export async function deleteAgentConfigsCommand() {
|
|
|
112
261
|
agent: c.agent,
|
|
113
262
|
filePath: c.file_path,
|
|
114
263
|
scope: c.scope,
|
|
264
|
+
version: c.version,
|
|
115
265
|
});
|
|
116
266
|
const idx = chalk.dim(`${items.length}.`);
|
|
117
267
|
const scopeTag = c.scope === "global"
|
|
@@ -133,30 +283,89 @@ export async function deleteAgentConfigsCommand() {
|
|
|
133
283
|
console.log(chalk.gray(" No valid selections.\n"));
|
|
134
284
|
return;
|
|
135
285
|
}
|
|
136
|
-
|
|
286
|
+
const modeAnswer = await ask(" Delete from [l] this device only, [e] everywhere, or [s] skip? ");
|
|
287
|
+
const mode = modeAnswer.trim().toLowerCase();
|
|
288
|
+
if (mode !== "l" && mode !== "e") {
|
|
289
|
+
console.log(chalk.gray(" Cancelled.\n"));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
137
292
|
console.log();
|
|
138
293
|
for (const i of indices) {
|
|
139
294
|
const item = items[i - 1];
|
|
140
295
|
console.log(chalk.yellow(` ${item.agent}/${item.filePath}`));
|
|
141
296
|
}
|
|
142
|
-
const ok = await confirm(
|
|
297
|
+
const ok = await confirm(mode === "e"
|
|
298
|
+
? `\n Delete ${indices.length} config${indices.length > 1 ? "s" : ""} everywhere? (y/N) `
|
|
299
|
+
: `\n Remove ${indices.length} config${indices.length > 1 ? "s" : ""} from this device only? (y/N) `);
|
|
143
300
|
if (!ok) {
|
|
144
301
|
console.log(chalk.gray(" Cancelled.\n"));
|
|
145
302
|
return;
|
|
146
303
|
}
|
|
304
|
+
const deviceID = getOrCreateDeviceID();
|
|
305
|
+
const cwd = process.cwd();
|
|
306
|
+
const currentProjectScope = getProjectScope(cwd);
|
|
307
|
+
const locations = discoverAgentConfigs();
|
|
308
|
+
const locByKey = new Map();
|
|
309
|
+
for (const loc of locations) {
|
|
310
|
+
locByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
|
|
311
|
+
}
|
|
312
|
+
const resolveDeletePath = (agent, filePath, scope) => {
|
|
313
|
+
const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
|
|
314
|
+
if (loc)
|
|
315
|
+
return loc.path;
|
|
316
|
+
return resolveAgentConfigWritePath(agent, filePath, scope, {
|
|
317
|
+
cwd,
|
|
318
|
+
home: homedir(),
|
|
319
|
+
currentProjectScope,
|
|
320
|
+
});
|
|
321
|
+
};
|
|
147
322
|
let deleted = 0;
|
|
148
323
|
for (const i of indices) {
|
|
149
324
|
const item = items[i - 1];
|
|
150
325
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
326
|
+
const localPath = resolveDeletePath(item.agent, item.filePath, item.scope);
|
|
327
|
+
if (mode === "l") {
|
|
328
|
+
await getClient().configs.localDelete({
|
|
329
|
+
device_id: deviceID,
|
|
330
|
+
agent: item.agent,
|
|
331
|
+
file_path: item.filePath,
|
|
332
|
+
scope: item.scope,
|
|
333
|
+
local_path: localPath ?? undefined,
|
|
334
|
+
});
|
|
335
|
+
if (localPath && existsSync(localPath)) {
|
|
336
|
+
rmSync(localPath);
|
|
337
|
+
}
|
|
338
|
+
console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("removed from this device"));
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
await getClient().configs.delete(item.id);
|
|
342
|
+
if (localPath && existsSync(localPath)) {
|
|
343
|
+
rmSync(localPath);
|
|
344
|
+
}
|
|
345
|
+
await getClient().configs.ack({
|
|
346
|
+
device_id: deviceID,
|
|
347
|
+
configs: [
|
|
348
|
+
{
|
|
349
|
+
agent: item.agent,
|
|
350
|
+
file_path: item.filePath,
|
|
351
|
+
scope: item.scope,
|
|
352
|
+
version: item.version + 1,
|
|
353
|
+
local_path: localPath ?? undefined,
|
|
354
|
+
deleted: true,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("deleted everywhere"));
|
|
359
|
+
}
|
|
153
360
|
deleted++;
|
|
154
361
|
}
|
|
155
362
|
catch (err) {
|
|
156
363
|
console.log(chalk.red(` \u2717 ${item.agent}/${item.filePath}`), chalk.gray(err.message));
|
|
157
364
|
}
|
|
158
365
|
}
|
|
159
|
-
console.log(chalk.gray(
|
|
366
|
+
console.log(chalk.gray(mode === "e"
|
|
367
|
+
? `\n ${deleted} config${deleted > 1 ? "s" : ""} deleted everywhere.\n`
|
|
368
|
+
: `\n ${deleted} config${deleted > 1 ? "s" : ""} removed from this device.\n`));
|
|
160
369
|
}
|
|
161
370
|
function formatAge(dateStr) {
|
|
162
371
|
const ms = Date.now() - new Date(dateStr).getTime();
|
|
@@ -171,14 +380,27 @@ function formatAge(dateStr) {
|
|
|
171
380
|
}
|
|
172
381
|
export async function syncCommand(directory, options) {
|
|
173
382
|
const dir = directory ?? ".";
|
|
383
|
+
const syncRoot = resolve(dir);
|
|
174
384
|
const customIgnore = options.ignore
|
|
175
385
|
? new Set(options.ignore.split(",").map((s) => s.trim()))
|
|
176
386
|
: new Set();
|
|
177
387
|
const ignoreSet = new Set([...DEFAULT_IGNORE, ...customIgnore]);
|
|
388
|
+
const ignorePatterns = [...customIgnore].sort();
|
|
389
|
+
const projectContext = detectProjectContext(syncRoot);
|
|
178
390
|
console.log(chalk.blue("Scanning"), dir);
|
|
179
|
-
const files = walkDir(
|
|
391
|
+
const files = walkDir(syncRoot, ignoreSet);
|
|
180
392
|
if (files.length === 0) {
|
|
181
393
|
console.log(chalk.yellow("No supported files found."));
|
|
394
|
+
updateSyncSourceRun(syncRoot, {
|
|
395
|
+
ignorePatterns,
|
|
396
|
+
defaultCategory: options.category,
|
|
397
|
+
defaultBoundary: options.boundary,
|
|
398
|
+
mode: options.watch ? "watch" : "manual",
|
|
399
|
+
scanCount: 0,
|
|
400
|
+
pushed: 0,
|
|
401
|
+
skipped: 0,
|
|
402
|
+
errors: 0,
|
|
403
|
+
});
|
|
182
404
|
return;
|
|
183
405
|
}
|
|
184
406
|
console.log(chalk.gray(`Found ${files.length} files to sync`));
|
|
@@ -193,25 +415,38 @@ export async function syncCommand(directory, options) {
|
|
|
193
415
|
}
|
|
194
416
|
console.log();
|
|
195
417
|
let pushed = 0;
|
|
418
|
+
let skipped = 0;
|
|
196
419
|
let errors = 0;
|
|
197
420
|
for (const file of files) {
|
|
198
|
-
const result = await pushFile(file, options);
|
|
421
|
+
const result = await pushFile(file, syncRoot, projectContext, options);
|
|
199
422
|
if (result === "pushed")
|
|
200
423
|
pushed++;
|
|
424
|
+
else if (result === "skipped")
|
|
425
|
+
skipped++;
|
|
201
426
|
else if (result === "error")
|
|
202
427
|
errors++;
|
|
203
428
|
}
|
|
429
|
+
updateSyncSourceRun(syncRoot, {
|
|
430
|
+
ignorePatterns,
|
|
431
|
+
defaultCategory: options.category,
|
|
432
|
+
defaultBoundary: options.boundary,
|
|
433
|
+
mode: options.watch ? "watch" : "manual",
|
|
434
|
+
scanCount: files.length,
|
|
435
|
+
pushed,
|
|
436
|
+
skipped,
|
|
437
|
+
errors,
|
|
438
|
+
});
|
|
204
439
|
console.log();
|
|
205
|
-
console.log(chalk.blue(`Synced ${pushed} files`), errors > 0 ? chalk.red(`(${errors} errors)`) : "");
|
|
440
|
+
console.log(chalk.blue(`Synced ${pushed} files`), skipped > 0 ? chalk.gray(`(${skipped} skipped)`) : "", errors > 0 ? chalk.red(`(${errors} errors)`) : "");
|
|
441
|
+
console.log(chalk.gray(" Missing local files are retained in Memax until removed explicitly."));
|
|
206
442
|
if (options.watch) {
|
|
207
|
-
const resolvedDir = resolve(dir);
|
|
208
443
|
console.log(chalk.cyan(`\nWatching ${dir} for changes... (Ctrl+C to stop)`));
|
|
209
444
|
let debounceTimer = null;
|
|
210
445
|
const pendingChanges = new Set();
|
|
211
|
-
watch(
|
|
446
|
+
watch(syncRoot, { recursive: true }, (_eventType, filename) => {
|
|
212
447
|
if (!filename)
|
|
213
448
|
return;
|
|
214
|
-
const fullPath = join(
|
|
449
|
+
const fullPath = join(syncRoot, filename);
|
|
215
450
|
if (!isSupportedFile(filename) || isIgnored(filename, ignoreSet))
|
|
216
451
|
return;
|
|
217
452
|
pendingChanges.add(fullPath);
|
|
@@ -220,22 +455,22 @@ export async function syncCommand(directory, options) {
|
|
|
220
455
|
debounceTimer = setTimeout(async () => {
|
|
221
456
|
for (const file of pendingChanges) {
|
|
222
457
|
if (!existsSync(file)) {
|
|
223
|
-
console.log(chalk.gray(" ~"),
|
|
458
|
+
console.log(chalk.gray(" ~"), buildSyncSourcePath(syncRoot, file), chalk.gray("[deleted locally, retained in Memax]"));
|
|
224
459
|
continue;
|
|
225
460
|
}
|
|
226
|
-
await pushFile(file, options);
|
|
461
|
+
await pushFile(file, syncRoot, projectContext, options);
|
|
227
462
|
}
|
|
228
463
|
pendingChanges.clear();
|
|
229
464
|
}, 500);
|
|
230
465
|
});
|
|
231
466
|
}
|
|
232
467
|
}
|
|
233
|
-
async function pushFile(file, options) {
|
|
468
|
+
async function pushFile(file, syncRoot, projectContext, options) {
|
|
234
469
|
try {
|
|
235
470
|
const content = readFileSync(file, "utf-8");
|
|
236
471
|
if (!content.trim())
|
|
237
472
|
return "skipped";
|
|
238
|
-
const relPath =
|
|
473
|
+
const relPath = buildSyncSourcePath(syncRoot, file);
|
|
239
474
|
const ext = extname(file);
|
|
240
475
|
const contentType = ext === ".md"
|
|
241
476
|
? "markdown"
|
|
@@ -248,6 +483,7 @@ async function pushFile(file, options) {
|
|
|
248
483
|
source: "sync",
|
|
249
484
|
sourcePath: relPath,
|
|
250
485
|
contentType,
|
|
486
|
+
projectContext,
|
|
251
487
|
});
|
|
252
488
|
console.log(chalk.green(" +"), relPath, chalk.gray(`[${memory.category}]`));
|
|
253
489
|
return "pushed";
|
|
@@ -257,6 +493,46 @@ async function pushFile(file, options) {
|
|
|
257
493
|
return "error";
|
|
258
494
|
}
|
|
259
495
|
}
|
|
496
|
+
export function buildSyncSourcePath(syncRoot, file) {
|
|
497
|
+
const relativePath = relative(syncRoot, file);
|
|
498
|
+
if (!relativePath ||
|
|
499
|
+
relativePath.startsWith("..") ||
|
|
500
|
+
relativePath === "." ||
|
|
501
|
+
resolve(syncRoot, relativePath) !== resolve(file)) {
|
|
502
|
+
throw new Error("file is outside sync root");
|
|
503
|
+
}
|
|
504
|
+
return normalizeFilePath(relativePath);
|
|
505
|
+
}
|
|
506
|
+
export function syncStatusCommand() {
|
|
507
|
+
const sources = listSyncSources();
|
|
508
|
+
console.log(chalk.bold("\n Memax Sync Status\n"));
|
|
509
|
+
if (sources.length === 0) {
|
|
510
|
+
console.log(chalk.gray(" No sync sources recorded yet."));
|
|
511
|
+
console.log(chalk.gray(" Run: memax sync <dir>\n"));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
for (const source of sources) {
|
|
515
|
+
console.log(` ${chalk.cyan(source.root_path)}`);
|
|
516
|
+
console.log(` kind ${chalk.gray(source.kind)}`);
|
|
517
|
+
console.log(` deletion policy ${chalk.gray("retain missing files in Memax")}`);
|
|
518
|
+
if (source.default_category) {
|
|
519
|
+
console.log(` category ${chalk.gray(source.default_category)}`);
|
|
520
|
+
}
|
|
521
|
+
if (source.default_boundary) {
|
|
522
|
+
console.log(` boundary ${chalk.gray(source.default_boundary)}`);
|
|
523
|
+
}
|
|
524
|
+
if (source.ignore_patterns.length > 0) {
|
|
525
|
+
console.log(` ignore ${chalk.gray(source.ignore_patterns.join(", "))}`);
|
|
526
|
+
}
|
|
527
|
+
if (source.last_sync_at) {
|
|
528
|
+
console.log(` last sync ${chalk.gray(formatAge(source.last_sync_at))} (${chalk.gray(source.last_mode ?? "manual")})`);
|
|
529
|
+
}
|
|
530
|
+
if (source.last_scan_count !== undefined) {
|
|
531
|
+
console.log(` last run ${chalk.gray(`${source.last_scan_count} scanned, ${source.last_pushed ?? 0} pushed, ${source.last_skipped ?? 0} skipped, ${source.last_errors ?? 0} errors`)}`);
|
|
532
|
+
}
|
|
533
|
+
console.log();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
260
536
|
function isSupportedFile(filename) {
|
|
261
537
|
return SUPPORTED_EXTENSIONS.has(extname(filename));
|
|
262
538
|
}
|
|
@@ -312,6 +588,126 @@ function guessCategory(path) {
|
|
|
312
588
|
return "reference/config";
|
|
313
589
|
return "daily/note";
|
|
314
590
|
}
|
|
591
|
+
function findClaudeProjectDir(scope) {
|
|
592
|
+
const home = homedir();
|
|
593
|
+
const claudeProjectsDir = join(home, ".claude", "projects");
|
|
594
|
+
if (!existsSync(claudeProjectsDir))
|
|
595
|
+
return null;
|
|
596
|
+
try {
|
|
597
|
+
for (const project of readdirSync(claudeProjectsDir)) {
|
|
598
|
+
const repoUrl = resolveClaudeProjectFolder(project);
|
|
599
|
+
if (repoUrl && scope === `project:${repoUrl}`) {
|
|
600
|
+
return join(claudeProjectsDir, project);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Permission denied — skip
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
function printPlacementSection(title, color, items, format) {
|
|
610
|
+
if (items.length === 0)
|
|
611
|
+
return;
|
|
612
|
+
console.log(chalk.white(title));
|
|
613
|
+
for (const item of items) {
|
|
614
|
+
const [agent, filePath, detail] = format(item);
|
|
615
|
+
console.log(` ${color(formatAgentName(agent))} ${filePath}`);
|
|
616
|
+
console.log(` ${chalk.gray(detail)}`);
|
|
617
|
+
}
|
|
618
|
+
console.log();
|
|
619
|
+
}
|
|
620
|
+
export function resolveAgentConfigWritePath(agent, filePath, scope, options = {}) {
|
|
621
|
+
const cwd = options.cwd ?? process.cwd();
|
|
622
|
+
const home = options.home ?? homedir();
|
|
623
|
+
const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
|
|
624
|
+
const normalizedFilePath = normalizeFilePath(filePath);
|
|
625
|
+
if (scope === "global") {
|
|
626
|
+
switch (agent) {
|
|
627
|
+
case "claude-code":
|
|
628
|
+
return join(home, ".claude", normalizedFilePath);
|
|
629
|
+
case "codex":
|
|
630
|
+
return join(home, ".codex", normalizedFilePath);
|
|
631
|
+
case "gemini":
|
|
632
|
+
return join(home, ".gemini", normalizedFilePath);
|
|
633
|
+
case "openclaw":
|
|
634
|
+
return join(home, ".openclaw", normalizedFilePath);
|
|
635
|
+
case "opencode":
|
|
636
|
+
return join(home, ".opencode", normalizedFilePath);
|
|
637
|
+
default:
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!scope.startsWith("project:") || scope !== currentProjectScope) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
switch (agent) {
|
|
645
|
+
case "claude-code":
|
|
646
|
+
if (normalizedFilePath === "CLAUDE.md" ||
|
|
647
|
+
normalizedFilePath === "MEMORY.md") {
|
|
648
|
+
return join(cwd, ".claude", normalizedFilePath);
|
|
649
|
+
}
|
|
650
|
+
if (normalizedFilePath.startsWith(".claude/")) {
|
|
651
|
+
return join(cwd, normalizedFilePath);
|
|
652
|
+
}
|
|
653
|
+
if (normalizedFilePath.startsWith("memory/")) {
|
|
654
|
+
const projectDir = options.findClaudeProjectDir?.(scope);
|
|
655
|
+
if (projectDir) {
|
|
656
|
+
return join(projectDir, normalizedFilePath);
|
|
657
|
+
}
|
|
658
|
+
const mangledCwd = cwd.replace(/\//g, "-");
|
|
659
|
+
return join(home, ".claude", "projects", mangledCwd, normalizedFilePath);
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
case "cursor":
|
|
663
|
+
if (normalizedFilePath === ".cursorrules" ||
|
|
664
|
+
normalizedFilePath.startsWith(".cursor/")) {
|
|
665
|
+
return join(cwd, normalizedFilePath);
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
case "codex":
|
|
669
|
+
if (normalizedFilePath === "instructions.md") {
|
|
670
|
+
return join(cwd, ".codex", "instructions.md");
|
|
671
|
+
}
|
|
672
|
+
if (normalizedFilePath.startsWith(".codex/")) {
|
|
673
|
+
return join(cwd, normalizedFilePath);
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
case "gemini":
|
|
677
|
+
if (normalizedFilePath === "GEMINI.md") {
|
|
678
|
+
return join(cwd, "GEMINI.md");
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
case "copilot":
|
|
682
|
+
if (normalizedFilePath === "copilot-instructions.md") {
|
|
683
|
+
return join(cwd, ".github", "copilot-instructions.md");
|
|
684
|
+
}
|
|
685
|
+
if (normalizedFilePath.startsWith(".github/")) {
|
|
686
|
+
return join(cwd, normalizedFilePath);
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
case "windsurf":
|
|
690
|
+
if (normalizedFilePath === ".windsurfrules" ||
|
|
691
|
+
normalizedFilePath.startsWith(".windsurf/")) {
|
|
692
|
+
return join(cwd, normalizedFilePath);
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
case "opencode":
|
|
696
|
+
if (normalizedFilePath.startsWith(".opencode/")) {
|
|
697
|
+
return join(cwd, normalizedFilePath);
|
|
698
|
+
}
|
|
699
|
+
return join(cwd, ".opencode", normalizedFilePath);
|
|
700
|
+
case "generic":
|
|
701
|
+
if (normalizedFilePath === "AGENTS.md" ||
|
|
702
|
+
normalizedFilePath === "CLAUDE.md" ||
|
|
703
|
+
normalizedFilePath === "GEMINI.md") {
|
|
704
|
+
return join(cwd, normalizedFilePath);
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
default:
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
315
711
|
function discoverAgentConfigs() {
|
|
316
712
|
const home = homedir();
|
|
317
713
|
const cwd = process.cwd();
|
|
@@ -448,6 +844,11 @@ function discoverAgentConfigs() {
|
|
|
448
844
|
}
|
|
449
845
|
async function syncAgentMemory(options = {}) {
|
|
450
846
|
console.log(chalk.bold("\n Memax Config Sync\n"));
|
|
847
|
+
const projectScopeResolution = resolveProjectScope();
|
|
848
|
+
if (projectScopeResolution.warning) {
|
|
849
|
+
console.log(chalk.yellow(` Warning: ${projectScopeResolution.warning}`));
|
|
850
|
+
console.log(chalk.gray(" Using .memax.yml project_id as the canonical project identity.\n"));
|
|
851
|
+
}
|
|
451
852
|
// Discover local config files
|
|
452
853
|
const locations = discoverAgentConfigs();
|
|
453
854
|
const localConfigs = [];
|
|
@@ -474,6 +875,7 @@ async function syncAgentMemory(options = {}) {
|
|
|
474
875
|
}
|
|
475
876
|
}
|
|
476
877
|
const isBootstrap = localConfigs.length === 0;
|
|
878
|
+
const deviceID = getOrCreateDeviceID();
|
|
477
879
|
if (isBootstrap) {
|
|
478
880
|
console.log(chalk.gray(" No local agent configs found. Checking cloud for backups...\n"));
|
|
479
881
|
}
|
|
@@ -487,10 +889,14 @@ async function syncAgentMemory(options = {}) {
|
|
|
487
889
|
scope: c.loc.scope,
|
|
488
890
|
content_hash: c.hash,
|
|
489
891
|
updated_at: c.updatedAt,
|
|
892
|
+
local_path: c.loc.path,
|
|
490
893
|
}));
|
|
491
894
|
let actions;
|
|
492
895
|
try {
|
|
493
|
-
const plan = await getClient().configs.sync(
|
|
896
|
+
const plan = await getClient().configs.sync({
|
|
897
|
+
device_id: deviceID,
|
|
898
|
+
configs: manifest,
|
|
899
|
+
});
|
|
494
900
|
actions = plan.actions;
|
|
495
901
|
}
|
|
496
902
|
catch (err) {
|
|
@@ -512,7 +918,7 @@ async function syncAgentMemory(options = {}) {
|
|
|
512
918
|
// current project. Without this, running `memax sync agents` from ~/
|
|
513
919
|
// would dump project configs (like .cursorrules from repo X) into the
|
|
514
920
|
// home directory.
|
|
515
|
-
const currentProjectScope =
|
|
921
|
+
const currentProjectScope = projectScopeResolution.scope;
|
|
516
922
|
actions = actions.filter((a) => {
|
|
517
923
|
if (!a.scope.startsWith("project:"))
|
|
518
924
|
return true; // global → always sync
|
|
@@ -540,76 +946,24 @@ async function syncAgentMemory(options = {}) {
|
|
|
540
946
|
// Resolve a local write path for any config — even ones not discovered locally.
|
|
541
947
|
// This enables pulling configs to a brand-new device where agent dirs don't exist yet.
|
|
542
948
|
const resolveWritePath = (agent, filePath, scope) => {
|
|
543
|
-
// First check if we have a known location from local discovery
|
|
544
949
|
const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
|
|
545
950
|
if (loc)
|
|
546
951
|
return loc.path;
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
codex: join(home, ".codex"),
|
|
554
|
-
gemini: join(home, ".gemini"),
|
|
555
|
-
copilot: join(home, ".copilot"),
|
|
556
|
-
windsurf: join(home, ".windsurf"),
|
|
557
|
-
openclaw: join(home, ".openclaw"),
|
|
558
|
-
opencode: join(home, ".opencode"),
|
|
559
|
-
};
|
|
560
|
-
const dir = agentDirs[agent];
|
|
561
|
-
if (dir)
|
|
562
|
-
return join(dir, filePath);
|
|
563
|
-
}
|
|
564
|
-
// Project-scoped configs — ONLY write if we're in the matching project.
|
|
565
|
-
// This is the safety net: never write project files to the wrong directory.
|
|
566
|
-
if (scope.startsWith("project")) {
|
|
567
|
-
// Verify this scope matches the current project
|
|
568
|
-
if (scope !== "project" && scope !== currentProjectScope) {
|
|
569
|
-
return null; // Wrong project — refuse to write
|
|
570
|
-
}
|
|
571
|
-
// Claude per-project memories: filePath like "memory/feedback.md"
|
|
572
|
-
if (agent === "claude-code" && filePath.startsWith("memory/")) {
|
|
573
|
-
const projectDir = findClaudeProjectDir(scope);
|
|
574
|
-
if (projectDir)
|
|
575
|
-
return join(projectDir, filePath);
|
|
576
|
-
// Fallback: use mangled cwd path
|
|
577
|
-
const mangledCwd = process.cwd().replace(/\//g, "-");
|
|
578
|
-
return join(home, ".claude", "projects", mangledCwd, filePath);
|
|
579
|
-
}
|
|
580
|
-
// Regular project configs: write relative to cwd
|
|
581
|
-
return join(process.cwd(), filePath);
|
|
582
|
-
}
|
|
583
|
-
return null;
|
|
952
|
+
return resolveAgentConfigWritePath(agent, filePath, scope, {
|
|
953
|
+
cwd: process.cwd(),
|
|
954
|
+
home: homedir(),
|
|
955
|
+
currentProjectScope,
|
|
956
|
+
findClaudeProjectDir,
|
|
957
|
+
});
|
|
584
958
|
};
|
|
585
|
-
/**
|
|
586
|
-
* Find the local ~/.claude/projects/<mangled> directory that corresponds
|
|
587
|
-
* to a given project scope (e.g., "project:github.com/memaxlabs/memax").
|
|
588
|
-
*/
|
|
589
|
-
function findClaudeProjectDir(scope) {
|
|
590
|
-
const home = homedir();
|
|
591
|
-
const claudeProjectsDir = join(home, ".claude", "projects");
|
|
592
|
-
if (!existsSync(claudeProjectsDir))
|
|
593
|
-
return null;
|
|
594
|
-
try {
|
|
595
|
-
for (const project of readdirSync(claudeProjectsDir)) {
|
|
596
|
-
const repoUrl = resolveClaudeProjectFolder(project);
|
|
597
|
-
if (repoUrl && scope === `project:${repoUrl}`) {
|
|
598
|
-
return join(claudeProjectsDir, project);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
catch {
|
|
603
|
-
// Permission denied — skip
|
|
604
|
-
}
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
959
|
// Execute sync plan
|
|
608
960
|
let pushed = 0;
|
|
609
961
|
let pulled = 0;
|
|
962
|
+
let deletedLocal = 0;
|
|
610
963
|
let unchangedCount = 0;
|
|
611
964
|
let skipped = 0;
|
|
612
965
|
let errors = 0;
|
|
966
|
+
const ackConfigs = [];
|
|
613
967
|
// Group actions by agent for display
|
|
614
968
|
const byAgent = new Map();
|
|
615
969
|
for (const action of actions) {
|
|
@@ -622,7 +976,18 @@ async function syncAgentMemory(options = {}) {
|
|
|
622
976
|
for (const action of agentActions) {
|
|
623
977
|
const key = `${action.agent}|${action.file_path}|${action.scope}`;
|
|
624
978
|
if (action.action === "unchanged") {
|
|
979
|
+
const local = localByKey.get(key);
|
|
625
980
|
console.log(chalk.gray(` = ${action.file_path}`), chalk.gray("unchanged"));
|
|
981
|
+
if (local && action.version) {
|
|
982
|
+
ackConfigs.push({
|
|
983
|
+
agent: action.agent,
|
|
984
|
+
file_path: action.file_path,
|
|
985
|
+
scope: action.scope,
|
|
986
|
+
content_hash: local.hash,
|
|
987
|
+
version: action.version,
|
|
988
|
+
local_path: local.loc.path,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
626
991
|
unchangedCount++;
|
|
627
992
|
continue;
|
|
628
993
|
}
|
|
@@ -639,6 +1004,8 @@ async function syncAgentMemory(options = {}) {
|
|
|
639
1004
|
file_path: action.file_path,
|
|
640
1005
|
scope: action.scope,
|
|
641
1006
|
content: local.content,
|
|
1007
|
+
device_id: deviceID,
|
|
1008
|
+
local_path: local.loc.path,
|
|
642
1009
|
});
|
|
643
1010
|
console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray(action.reason === "local_only"
|
|
644
1011
|
? "pushing (new)"
|
|
@@ -682,6 +1049,16 @@ async function syncAgentMemory(options = {}) {
|
|
|
682
1049
|
const config = await getClient().configs.get(action.config_id);
|
|
683
1050
|
mkdirSync(dirname(writePath), { recursive: true });
|
|
684
1051
|
writeFileSync(writePath, config.content);
|
|
1052
|
+
if (action.version) {
|
|
1053
|
+
ackConfigs.push({
|
|
1054
|
+
agent: action.agent,
|
|
1055
|
+
file_path: action.file_path,
|
|
1056
|
+
scope: action.scope,
|
|
1057
|
+
content_hash: config.content_hash,
|
|
1058
|
+
version: action.version,
|
|
1059
|
+
local_path: writePath,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
685
1062
|
console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray(isNewLocally ? "restored" : "pulling (cloud newer)"));
|
|
686
1063
|
pulled++;
|
|
687
1064
|
}
|
|
@@ -691,6 +1068,31 @@ async function syncAgentMemory(options = {}) {
|
|
|
691
1068
|
}
|
|
692
1069
|
continue;
|
|
693
1070
|
}
|
|
1071
|
+
if (action.action === "delete_local") {
|
|
1072
|
+
try {
|
|
1073
|
+
const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
|
|
1074
|
+
if (writePath && existsSync(writePath)) {
|
|
1075
|
+
rmSync(writePath);
|
|
1076
|
+
}
|
|
1077
|
+
if (action.version) {
|
|
1078
|
+
ackConfigs.push({
|
|
1079
|
+
agent: action.agent,
|
|
1080
|
+
file_path: action.file_path,
|
|
1081
|
+
scope: action.scope,
|
|
1082
|
+
version: action.version,
|
|
1083
|
+
local_path: writePath ?? undefined,
|
|
1084
|
+
deleted: true,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("deleted locally (cloud removed everywhere)"));
|
|
1088
|
+
deletedLocal++;
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
|
|
1092
|
+
errors++;
|
|
1093
|
+
}
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
694
1096
|
if (action.action === "conflict") {
|
|
695
1097
|
const resolution = await promptConflict(agent, action.file_path);
|
|
696
1098
|
if (resolution === "local") {
|
|
@@ -702,6 +1104,8 @@ async function syncAgentMemory(options = {}) {
|
|
|
702
1104
|
file_path: action.file_path,
|
|
703
1105
|
scope: action.scope,
|
|
704
1106
|
content: local.content,
|
|
1107
|
+
device_id: deviceID,
|
|
1108
|
+
local_path: local.loc.path,
|
|
705
1109
|
});
|
|
706
1110
|
console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray("kept local"));
|
|
707
1111
|
pushed++;
|
|
@@ -719,6 +1123,16 @@ async function syncAgentMemory(options = {}) {
|
|
|
719
1123
|
const config = await getClient().configs.get(action.config_id);
|
|
720
1124
|
mkdirSync(dirname(writePath), { recursive: true });
|
|
721
1125
|
writeFileSync(writePath, config.content);
|
|
1126
|
+
if (action.version) {
|
|
1127
|
+
ackConfigs.push({
|
|
1128
|
+
agent: action.agent,
|
|
1129
|
+
file_path: action.file_path,
|
|
1130
|
+
scope: action.scope,
|
|
1131
|
+
content_hash: config.content_hash,
|
|
1132
|
+
version: action.version,
|
|
1133
|
+
local_path: writePath,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
722
1136
|
console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray("used cloud"));
|
|
723
1137
|
pulled++;
|
|
724
1138
|
}
|
|
@@ -748,6 +1162,8 @@ async function syncAgentMemory(options = {}) {
|
|
|
748
1162
|
file_path: action.file_path,
|
|
749
1163
|
scope: action.scope,
|
|
750
1164
|
content: merged,
|
|
1165
|
+
device_id: deviceID,
|
|
1166
|
+
local_path: writePath ?? undefined,
|
|
751
1167
|
});
|
|
752
1168
|
console.log(chalk.magenta(` \u2194 ${action.file_path}`), chalk.gray("merged (LLM)"));
|
|
753
1169
|
pushed++;
|
|
@@ -769,6 +1185,17 @@ async function syncAgentMemory(options = {}) {
|
|
|
769
1185
|
}
|
|
770
1186
|
}
|
|
771
1187
|
}
|
|
1188
|
+
if (ackConfigs.length > 0) {
|
|
1189
|
+
try {
|
|
1190
|
+
await getClient().configs.ack({
|
|
1191
|
+
device_id: deviceID,
|
|
1192
|
+
configs: ackConfigs,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
console.log(chalk.yellow("\n Warning: failed to persist sync state"), chalk.gray(err.message));
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
772
1199
|
// Summary
|
|
773
1200
|
if (pushed === 0 &&
|
|
774
1201
|
pulled === 0 &&
|
|
@@ -783,6 +1210,8 @@ async function syncAgentMemory(options = {}) {
|
|
|
783
1210
|
parts.push(`${pushed} pushed`);
|
|
784
1211
|
if (pulled > 0)
|
|
785
1212
|
parts.push(`${pulled} restored`);
|
|
1213
|
+
if (deletedLocal > 0)
|
|
1214
|
+
parts.push(`${deletedLocal} deleted locally`);
|
|
786
1215
|
if (unchangedCount > 0)
|
|
787
1216
|
parts.push(`${unchangedCount} unchanged`);
|
|
788
1217
|
if (skipped > 0)
|