@dboio/cli 0.13.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/bin/dbo.js +2 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +76 -74
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +57 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +2 -1
- package/src/commands/add.js +12 -7
- package/src/commands/clone.js +138 -94
- package/src/commands/deploy.js +9 -2
- package/src/commands/diff.js +4 -4
- package/src/commands/login.js +30 -4
- package/src/commands/mv.js +17 -4
- package/src/commands/push.js +34 -9
- package/src/commands/rm.js +6 -4
- package/src/commands/tag.js +65 -0
- package/src/lib/config.js +28 -0
- package/src/lib/deploy-config.js +137 -0
- package/src/lib/diff.js +5 -4
- package/src/lib/filenames.js +89 -24
- package/src/lib/scaffold.js +1 -1
- package/src/lib/tagging.js +380 -0
- package/src/lib/toe-stepping.js +2 -1
- package/src/migrations/006-remove-uid-companion-filenames.js +3 -3
- package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
- package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
package/src/lib/filenames.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Filename convention helpers for the
|
|
3
|
-
*
|
|
2
|
+
* Filename convention helpers for the metadata~uid convention.
|
|
3
|
+
* Metadata files: name.metadata~uid.json
|
|
4
|
+
* Companion files: natural names, no UID embedded.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
|
|
@@ -117,6 +118,68 @@ export function detectLegacyDotUid(filename) {
|
|
|
117
118
|
return match ? { name: match[1], uid: match[2] } : null;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Build the new-convention metadata filename.
|
|
123
|
+
* Format: <naturalBase>.metadata~<uid>.json
|
|
124
|
+
*
|
|
125
|
+
* For content records: naturalBase = "colors" → "colors.metadata~abc123.json"
|
|
126
|
+
* For media records: naturalBase = "logo.png" → "logo.png.metadata~abc123.json"
|
|
127
|
+
* For output records: naturalBase = "Sales" → "Sales.metadata~abc123.json"
|
|
128
|
+
*
|
|
129
|
+
* @param {string} naturalBase - Name without any ~uid (may include media extension)
|
|
130
|
+
* @param {string} uid - Server-assigned UID
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function buildMetaFilename(naturalBase, uid) {
|
|
134
|
+
return `${naturalBase}.metadata~${uid}.json`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Test whether a filename is a metadata file (new or legacy format).
|
|
139
|
+
*
|
|
140
|
+
* New format: name.metadata~uid.json
|
|
141
|
+
* Legacy format: name~uid.metadata.json (also accepted during migration)
|
|
142
|
+
*
|
|
143
|
+
* @param {string} filename
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
export function isMetadataFile(filename) {
|
|
147
|
+
return /\.metadata~[a-z0-9]+\.json$/i.test(filename) // new: name.metadata~uid.json
|
|
148
|
+
|| filename.endsWith('.metadata.json') // legacy + pre-UID temp files
|
|
149
|
+
|| /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse a new-format metadata filename into its components.
|
|
154
|
+
* Returns null for legacy-format filenames (use detectLegacyTildeMetadata for those).
|
|
155
|
+
*
|
|
156
|
+
* @param {string} filename - e.g. "colors.metadata~abc123.json"
|
|
157
|
+
* @returns {{ naturalBase: string, uid: string } | null}
|
|
158
|
+
*/
|
|
159
|
+
export function parseMetaFilename(filename) {
|
|
160
|
+
const m = filename.match(/^(.+)\.metadata~([a-z0-9]+)\.json$/i);
|
|
161
|
+
return m ? { naturalBase: m[1], uid: m[2] } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Detect whether a filename uses the legacy tilde convention:
|
|
166
|
+
* <name>~<uid>.metadata.json OR <name>~<uid>.<ext>.metadata.json
|
|
167
|
+
* where uid is ≥10 lowercase alphanumeric characters.
|
|
168
|
+
*
|
|
169
|
+
* Returns { naturalBase, uid } or null.
|
|
170
|
+
*/
|
|
171
|
+
export function detectLegacyTildeMetadata(filename) {
|
|
172
|
+
// Case 1: name~uid.metadata.json (content/entity metadata)
|
|
173
|
+
const m1 = filename.match(/^(.+)~([a-z0-9]{10,})\.metadata\.json$/);
|
|
174
|
+
if (m1) return { naturalBase: m1[1], uid: m1[2] };
|
|
175
|
+
|
|
176
|
+
// Case 2: name~uid.ext.metadata.json (media metadata)
|
|
177
|
+
const m2 = filename.match(/^(.+)~([a-z0-9]{10,})\.([a-z0-9]+)\.metadata\.json$/);
|
|
178
|
+
if (m2) return { naturalBase: `${m2[1]}.${m2[3]}`, uid: m2[2] };
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
120
183
|
/**
|
|
121
184
|
* Find the metadata file that references a given companion file via @reference.
|
|
122
185
|
*
|
|
@@ -133,19 +196,20 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
133
196
|
const ext = extname(companionName);
|
|
134
197
|
const base = basename(companionName, ext);
|
|
135
198
|
|
|
136
|
-
// 1. Direct match: base.metadata.json
|
|
137
|
-
const directMeta = join(dir, `${base}.metadata.json`);
|
|
138
|
-
try {
|
|
139
|
-
await readFile(directMeta, 'utf8');
|
|
140
|
-
return directMeta;
|
|
141
|
-
} catch { /* not found */ }
|
|
142
|
-
|
|
143
|
-
// 2. Scan sibling *.metadata.json files for @reference match
|
|
144
199
|
let entries;
|
|
145
200
|
try { entries = await readdir(dir); } catch { return null; }
|
|
146
201
|
|
|
202
|
+
// 1. Fast path: match naturalBase of new-format metadata files
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
const parsed = parseMetaFilename(entry);
|
|
205
|
+
if (parsed && (parsed.naturalBase === base || parsed.naturalBase === companionName)) {
|
|
206
|
+
return join(dir, entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 2. Scan all metadata files (new + legacy) for @reference match
|
|
147
211
|
for (const entry of entries) {
|
|
148
|
-
if (!entry
|
|
212
|
+
if (!isMetadataFile(entry)) continue;
|
|
149
213
|
const metaPath = join(dir, entry);
|
|
150
214
|
try {
|
|
151
215
|
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
@@ -155,7 +219,6 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
155
219
|
const ref = meta[col];
|
|
156
220
|
if (!ref || !String(ref).startsWith('@')) continue;
|
|
157
221
|
const refName = String(ref).substring(1);
|
|
158
|
-
// Match both relative and root-relative (@/) references
|
|
159
222
|
if (refName === companionName || refName === `/${companionName}`) {
|
|
160
223
|
return metaPath;
|
|
161
224
|
}
|
|
@@ -180,25 +243,27 @@ export async function findMetadataForCompanion(companionPath) {
|
|
|
180
243
|
*/
|
|
181
244
|
export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
|
|
182
245
|
const metaDir = dirname(metaPath);
|
|
183
|
-
const
|
|
246
|
+
const metaFilename = basename(metaPath);
|
|
184
247
|
|
|
185
|
-
|
|
248
|
+
// Already in new format — nothing to do
|
|
249
|
+
if (parseMetaFilename(metaFilename)?.uid === uid) {
|
|
186
250
|
return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
|
|
187
251
|
}
|
|
188
252
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
// Determine naturalBase from the temp/old metadata filename
|
|
254
|
+
// Temp format from add.js: "colors.metadata.json" → naturalBase = "colors"
|
|
255
|
+
// Old tilde format: "colors~uid.metadata.json" → naturalBase = "colors"
|
|
256
|
+
let naturalBase;
|
|
257
|
+
const legacyParsed = detectLegacyTildeMetadata(metaFilename);
|
|
258
|
+
if (legacyParsed) {
|
|
259
|
+
naturalBase = legacyParsed.naturalBase;
|
|
260
|
+
} else if (metaFilename.endsWith('.metadata.json')) {
|
|
261
|
+
naturalBase = metaFilename.slice(0, -'.metadata.json'.length);
|
|
197
262
|
} else {
|
|
198
|
-
|
|
263
|
+
naturalBase = basename(metaFilename, '.json');
|
|
199
264
|
}
|
|
200
265
|
|
|
201
|
-
const newMetaPath = join(metaDir,
|
|
266
|
+
const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
|
|
202
267
|
|
|
203
268
|
// Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
|
|
204
269
|
const updatedMeta = { ...meta, UID: uid };
|
package/src/lib/scaffold.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, stat, readFile, writeFile, access } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { SCAFFOLD_DIRS } from './structure.js';
|
|
4
4
|
import { log } from './logger.js';
|
|
5
|
-
import { applyTrashIcon } from './
|
|
5
|
+
import { applyTrashIcon } from './tagging.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Scaffold the standard DBO project directory structure in cwd.
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { writeFile, readFile, unlink, stat, access } from 'fs/promises';
|
|
3
|
+
import { join, dirname, relative } from 'path';
|
|
4
|
+
import { readdir } from 'fs/promises';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { findMetadataFiles, hasLocalModifications } from './diff.js';
|
|
8
|
+
import { loadIgnore } from './ignore.js';
|
|
9
|
+
import { loadAppConfig, loadTagConfig } from './config.js';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
// ── Tag status map ─────────────────────────────────────────────────────────────
|
|
14
|
+
// Tag names use 'dbo:' prefix so --clear only removes dbo-applied tags.
|
|
15
|
+
// macCode values: 0=None 1=Grey 2=Green 3=Purple 4=Blue 5=Yellow 6=Orange 7=Red
|
|
16
|
+
const TAG_MAP = {
|
|
17
|
+
synced: { name: 'dbo:Synced', macCode: 2, linuxEmblem: 'emblem-default' },
|
|
18
|
+
modified: { name: 'dbo:Modified', macCode: 4, linuxEmblem: 'emblem-important' },
|
|
19
|
+
untracked: { name: 'dbo:Untracked', macCode: 5, linuxEmblem: 'emblem-new' },
|
|
20
|
+
conflict: { name: 'dbo:Conflict', macCode: 6, linuxEmblem: 'emblem-urgent' },
|
|
21
|
+
trashed: { name: 'dbo:Trashed', macCode: 7, linuxEmblem: 'emblem-unreadable' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply sync-status tag to a single file. Best-effort, never throws.
|
|
28
|
+
* @param {string} filePath Absolute path to the content/companion file.
|
|
29
|
+
* @param {string} status One of: synced | modified | untracked | conflict | trashed
|
|
30
|
+
*/
|
|
31
|
+
export async function setFileTag(filePath, status) {
|
|
32
|
+
try {
|
|
33
|
+
const info = TAG_MAP[status];
|
|
34
|
+
if (!info) return;
|
|
35
|
+
switch (process.platform) {
|
|
36
|
+
case 'darwin': await _setMacTag(filePath, info); break;
|
|
37
|
+
case 'linux': await _setLinuxTag(filePath, info); break;
|
|
38
|
+
// win32 and others: silently skip
|
|
39
|
+
}
|
|
40
|
+
} catch { /* best-effort */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove all dbo:* tags from a single file. Best-effort, never throws.
|
|
45
|
+
* @param {string} filePath Absolute path to the content/companion file.
|
|
46
|
+
*/
|
|
47
|
+
export async function clearFileTag(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
switch (process.platform) {
|
|
50
|
+
case 'darwin': await _clearMacTag(filePath); break;
|
|
51
|
+
case 'linux': await _clearLinuxTag(filePath); break;
|
|
52
|
+
}
|
|
53
|
+
} catch { /* best-effort */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scan all companion files in the project and apply sync-status tags.
|
|
58
|
+
* Respects .dboignore. Skips if tagFiles config is false or platform unsupported.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} [options]
|
|
61
|
+
* @param {boolean} [options.verbose] Log each file tagged.
|
|
62
|
+
* @param {boolean} [options.clearAll] Remove all dbo:* tags instead of applying.
|
|
63
|
+
* @param {string} [options.dir] Root directory to scan (defaults to cwd).
|
|
64
|
+
* @returns {Promise<{synced:number,modified:number,untracked:number,conflict:number,trashed:number}|null>}
|
|
65
|
+
* Returns null on unsupported platform or when tagFiles is disabled.
|
|
66
|
+
*/
|
|
67
|
+
export async function tagProjectFiles(options = {}) {
|
|
68
|
+
const { verbose = false, clearAll = false, dir = process.cwd() } = options;
|
|
69
|
+
const platform = process.platform;
|
|
70
|
+
if (platform !== 'darwin' && platform !== 'linux') return null;
|
|
71
|
+
|
|
72
|
+
const { tagFiles } = await loadTagConfig().catch(() => ({ tagFiles: true }));
|
|
73
|
+
if (!tagFiles) return null;
|
|
74
|
+
|
|
75
|
+
let config;
|
|
76
|
+
try { config = await loadAppConfig(); } catch { config = {}; }
|
|
77
|
+
|
|
78
|
+
const ig = await loadIgnore();
|
|
79
|
+
const metaPaths = await findMetadataFiles(dir, ig);
|
|
80
|
+
const counts = { synced: 0, modified: 0, untracked: 0, conflict: 0, trashed: 0 };
|
|
81
|
+
|
|
82
|
+
if (clearAll) {
|
|
83
|
+
const companions = (await Promise.all(metaPaths.map(mp => _getCompanionPaths(mp)))).flat();
|
|
84
|
+
await _bulkApplyTags(companions.map(fp => ({ filePath: fp, clear: true })));
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Collect file→status pairs from metadata
|
|
89
|
+
const toTag = [];
|
|
90
|
+
for (const metaPath of metaPaths) {
|
|
91
|
+
const inTrash = metaPath.replace(/\\/g, '/').includes('/trash/');
|
|
92
|
+
const status = inTrash
|
|
93
|
+
? 'trashed'
|
|
94
|
+
: (await hasLocalModifications(metaPath, config).catch(() => false)) ? 'modified'
|
|
95
|
+
: 'synced';
|
|
96
|
+
const companions = await _getCompanionPaths(metaPath);
|
|
97
|
+
for (const filePath of companions) {
|
|
98
|
+
toTag.push({ filePath, status });
|
|
99
|
+
counts[status]++;
|
|
100
|
+
if (verbose) console.log(` ${status.padEnd(10)} ${relative(dir, filePath)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Detect untracked files (content files without any metadata)
|
|
105
|
+
const untrackedFiles = await _findUntrackedFiles(dir, ig, metaPaths);
|
|
106
|
+
for (const filePath of untrackedFiles) {
|
|
107
|
+
toTag.push({ filePath, status: 'untracked' });
|
|
108
|
+
counts.untracked++;
|
|
109
|
+
if (verbose) console.log(` untracked ${relative(dir, filePath)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await _bulkApplyTags(toTag);
|
|
113
|
+
|
|
114
|
+
// Re-apply trash folder icon (best-effort, in case it was cleared)
|
|
115
|
+
const trashDir = join(dir, 'trash');
|
|
116
|
+
ensureTrashIcon(trashDir).catch(() => {});
|
|
117
|
+
|
|
118
|
+
return counts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Private helpers ────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async function _getCompanionPaths(metaPath) {
|
|
124
|
+
try {
|
|
125
|
+
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
126
|
+
const dir = dirname(metaPath);
|
|
127
|
+
const paths = [];
|
|
128
|
+
for (const col of (meta._contentColumns || [])) {
|
|
129
|
+
const ref = meta[col];
|
|
130
|
+
if (ref && String(ref).startsWith('@')) {
|
|
131
|
+
const candidate = join(dir, String(ref).substring(1));
|
|
132
|
+
try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const col of (meta._mediaColumns || [])) {
|
|
136
|
+
const ref = meta[col];
|
|
137
|
+
if (ref && String(ref).startsWith('@')) {
|
|
138
|
+
const candidate = join(dir, String(ref).substring(1));
|
|
139
|
+
try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return paths;
|
|
143
|
+
} catch { return []; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function _findUntrackedFiles(dir, ig, knownMetaPaths) {
|
|
147
|
+
const knownCompanions = new Set();
|
|
148
|
+
for (const mp of knownMetaPaths) {
|
|
149
|
+
for (const fp of await _getCompanionPaths(mp)) knownCompanions.add(fp);
|
|
150
|
+
}
|
|
151
|
+
const all = await _collectContentFiles(dir, ig);
|
|
152
|
+
return all.filter(fp => !knownCompanions.has(fp));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Recursively collect non-metadata, non-hidden, non-.dbo content files
|
|
156
|
+
async function _collectContentFiles(dir, ig) {
|
|
157
|
+
const results = [];
|
|
158
|
+
let entries;
|
|
159
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.name.startsWith('.')) continue; // skip hidden and .dbo
|
|
162
|
+
const fullPath = join(dir, entry.name);
|
|
163
|
+
const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
|
|
166
|
+
results.push(...await _collectContentFiles(fullPath, ig));
|
|
167
|
+
} else {
|
|
168
|
+
if (entry.name.endsWith('.json')) continue; // skip metadata and config files
|
|
169
|
+
if (ig.ignores(relPath)) continue;
|
|
170
|
+
results.push(fullPath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Apply tags in bulk. For macOS with >50 files, uses a shell script for performance.
|
|
177
|
+
// items: Array<{ filePath: string, status?: string, clear?: boolean }>
|
|
178
|
+
async function _bulkApplyTags(items) {
|
|
179
|
+
if (!items.length) return;
|
|
180
|
+
const platform = process.platform;
|
|
181
|
+
|
|
182
|
+
if (platform === 'darwin') {
|
|
183
|
+
if (items.length <= 50) {
|
|
184
|
+
await Promise.all(items.map(({ filePath, status, clear }) =>
|
|
185
|
+
clear ? _clearMacTag(filePath) : _setMacTag(filePath, TAG_MAP[status])
|
|
186
|
+
));
|
|
187
|
+
} else {
|
|
188
|
+
// Batch mode: write one shell script and execute it once
|
|
189
|
+
const lines = items.map(({ filePath, status, clear }) => {
|
|
190
|
+
const escaped = filePath.replace(/'/g, "'\\''");
|
|
191
|
+
if (clear) return `xattr -d com.apple.metadata:_kMDItemUserTags '${escaped}' 2>/dev/null || true`;
|
|
192
|
+
const info = TAG_MAP[status];
|
|
193
|
+
if (!info) return '';
|
|
194
|
+
return `xattr -w com.apple.metadata:_kMDItemUserTags '("${info.name}\\n${info.macCode}")' '${escaped}'`;
|
|
195
|
+
}).filter(Boolean);
|
|
196
|
+
const tmpFile = join(tmpdir(), `dbo-tag-${Date.now()}.sh`);
|
|
197
|
+
await writeFile(tmpFile, `#!/bin/sh\n${lines.join('\n')}\n`);
|
|
198
|
+
try {
|
|
199
|
+
await execFileAsync('sh', [tmpFile], { timeout: 30000 });
|
|
200
|
+
} finally {
|
|
201
|
+
await unlink(tmpFile).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else if (platform === 'linux') {
|
|
205
|
+
await Promise.all(items.map(({ filePath, status, clear }) =>
|
|
206
|
+
clear ? _clearLinuxTag(filePath) : _setLinuxTag(filePath, TAG_MAP[status])
|
|
207
|
+
));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── macOS implementation ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async function _setMacTag(filePath, tagInfo) {
|
|
214
|
+
// Read existing non-dbo tags, append the new dbo: tag, write back.
|
|
215
|
+
const existing = await _readMacTags(filePath);
|
|
216
|
+
const filtered = existing.filter(t => !t.startsWith('dbo:'));
|
|
217
|
+
filtered.push(`${tagInfo.name}\n${tagInfo.macCode}`);
|
|
218
|
+
await _writeMacTags(filePath, filtered);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function _clearMacTag(filePath) {
|
|
222
|
+
const existing = await _readMacTags(filePath);
|
|
223
|
+
const remaining = existing.filter(t => !t.startsWith('dbo:'));
|
|
224
|
+
if (remaining.length === 0) {
|
|
225
|
+
await execFileAsync('xattr', ['-d', 'com.apple.metadata:_kMDItemUserTags', filePath], { timeout: 5000 }).catch(() => {});
|
|
226
|
+
} else {
|
|
227
|
+
await _writeMacTags(filePath, remaining);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Use python3 (always on macOS 10.15+) to read binary plist — no third-party modules needed.
|
|
232
|
+
async function _readMacTags(filePath) {
|
|
233
|
+
try {
|
|
234
|
+
const script =
|
|
235
|
+
`import plistlib,sys,subprocess\n` +
|
|
236
|
+
`r=subprocess.run(['xattr','-px','com.apple.metadata:_kMDItemUserTags',sys.argv[1]],capture_output=True,text=True)\n` +
|
|
237
|
+
`if r.returncode!=0:print('');exit()\n` +
|
|
238
|
+
`hex_=r.stdout.strip().replace(' ','').replace('\\n','')\n` +
|
|
239
|
+
`tags=plistlib.loads(bytes.fromhex(hex_))\n` +
|
|
240
|
+
`print('\\x00'.join(tags))`;
|
|
241
|
+
const { stdout } = await execFileAsync('python3', ['-c', script, filePath], { timeout: 5000 });
|
|
242
|
+
return stdout.trim() ? stdout.trim().split('\x00') : [];
|
|
243
|
+
} catch { return []; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function _writeMacTags(filePath, tags) {
|
|
247
|
+
const script =
|
|
248
|
+
`import plistlib,sys,subprocess\n` +
|
|
249
|
+
`tags=sys.argv[2:]\n` +
|
|
250
|
+
`data=plistlib.dumps(tags,fmt=plistlib.FMT_BINARY)\n` +
|
|
251
|
+
`hex_=data.hex()\n` +
|
|
252
|
+
`subprocess.run(['xattr','-wx','com.apple.metadata:_kMDItemUserTags',hex_,sys.argv[1]],check=True)`;
|
|
253
|
+
await execFileAsync('python3', ['-c', script, filePath, ...tags], { timeout: 5000 });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Linux implementation ───────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
async function _setLinuxTag(filePath, tagInfo) {
|
|
259
|
+
await execFileAsync('gio', ['set', filePath, 'metadata::emblems', tagInfo.linuxEmblem], { timeout: 5000 });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function _clearLinuxTag(filePath) {
|
|
263
|
+
await execFileAsync('gio', ['set', filePath, 'metadata::emblems', ''], { timeout: 5000 }).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Folder icon (consolidated from folder-icon.js) ────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Best-effort: apply the system trash/recycle-bin icon to a folder.
|
|
270
|
+
* Never throws — all errors are silently swallowed.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} folderPath Absolute path to the trash directory
|
|
273
|
+
*/
|
|
274
|
+
export async function applyTrashIcon(folderPath) {
|
|
275
|
+
try {
|
|
276
|
+
const s = await stat(folderPath);
|
|
277
|
+
if (!s.isDirectory()) return;
|
|
278
|
+
|
|
279
|
+
switch (process.platform) {
|
|
280
|
+
case 'darwin':
|
|
281
|
+
await applyMacIcon(folderPath);
|
|
282
|
+
break;
|
|
283
|
+
case 'win32':
|
|
284
|
+
await applyWindowsIcon(folderPath);
|
|
285
|
+
break;
|
|
286
|
+
case 'linux':
|
|
287
|
+
await applyLinuxIcon(folderPath);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Best-effort — never propagate
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Re-apply the trash icon only if the icon marker file is missing.
|
|
297
|
+
* Much cheaper than applyTrashIcon — skips the osascript/attrib call
|
|
298
|
+
* when the icon is already in place.
|
|
299
|
+
*
|
|
300
|
+
* Call this after moving files into trash/ to self-heal the icon
|
|
301
|
+
* in case the user cleared the directory contents.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} folderPath Absolute path to the trash directory
|
|
304
|
+
*/
|
|
305
|
+
export async function ensureTrashIcon(folderPath) {
|
|
306
|
+
try {
|
|
307
|
+
switch (process.platform) {
|
|
308
|
+
case 'darwin': {
|
|
309
|
+
// macOS: Icon\r file exists when icon is applied
|
|
310
|
+
const iconFile = join(folderPath, 'Icon\r');
|
|
311
|
+
try { await access(iconFile); return; } catch { /* missing — re-apply */ }
|
|
312
|
+
await applyMacIcon(folderPath);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case 'win32': {
|
|
316
|
+
const iniPath = join(folderPath, 'desktop.ini');
|
|
317
|
+
try { await access(iniPath); return; } catch { /* missing */ }
|
|
318
|
+
await applyWindowsIcon(folderPath);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case 'linux': {
|
|
322
|
+
const dirFile = join(folderPath, '.directory');
|
|
323
|
+
try { await access(dirFile); return; } catch { /* missing */ }
|
|
324
|
+
await applyLinuxIcon(folderPath);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Best-effort — never propagate
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── macOS ──────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
const MACOS_TRASH_ICON =
|
|
336
|
+
'/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/TrashIcon.icns';
|
|
337
|
+
|
|
338
|
+
async function applyMacIcon(folderPath) {
|
|
339
|
+
await access(MACOS_TRASH_ICON);
|
|
340
|
+
|
|
341
|
+
const script =
|
|
342
|
+
'use framework "AppKit"\n' +
|
|
343
|
+
`set iconImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"${MACOS_TRASH_ICON}")\n` +
|
|
344
|
+
`(current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:"${folderPath}" options:0)`;
|
|
345
|
+
|
|
346
|
+
await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Windows ────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
async function applyWindowsIcon(folderPath) {
|
|
352
|
+
const iniPath = join(folderPath, 'desktop.ini');
|
|
353
|
+
const iniContent =
|
|
354
|
+
'[.ShellClassInfo]\r\nIconResource=%SystemRoot%\\System32\\shell32.dll,31\r\n';
|
|
355
|
+
|
|
356
|
+
await writeFile(iniPath, iniContent);
|
|
357
|
+
await execFileAsync('attrib', ['+H', '+S', iniPath], { timeout: 5000 });
|
|
358
|
+
await execFileAsync('attrib', ['+S', folderPath], { timeout: 5000 });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Linux ──────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
async function applyLinuxIcon(folderPath) {
|
|
364
|
+
// KDE Dolphin — .directory file
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(folderPath, '.directory'),
|
|
367
|
+
'[Desktop Entry]\nIcon=user-trash\n',
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// GNOME Nautilus — gio metadata (may not be available on KDE-only systems)
|
|
371
|
+
try {
|
|
372
|
+
await execFileAsync(
|
|
373
|
+
'gio',
|
|
374
|
+
['set', folderPath, 'metadata::custom-icon-name', 'user-trash'],
|
|
375
|
+
{ timeout: 5000 },
|
|
376
|
+
);
|
|
377
|
+
} catch {
|
|
378
|
+
// gio not available — .directory file is enough for KDE
|
|
379
|
+
}
|
|
380
|
+
}
|
package/src/lib/toe-stepping.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile } from 'fs/promises';
|
|
|
4
4
|
import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
|
|
5
5
|
import { resolveContentValue } from '../commands/clone.js';
|
|
6
6
|
import { computeLineDiff, formatDiff } from './diff.js';
|
|
7
|
+
import { parseMetaFilename } from './filenames.js';
|
|
7
8
|
import { log } from './logger.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -358,7 +359,7 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
358
359
|
|
|
359
360
|
// Conflict detected: server changed since our baseline
|
|
360
361
|
const metaDir = dirname(metaPath);
|
|
361
|
-
const label = basename(metaPath, '.metadata.json');
|
|
362
|
+
const label = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
362
363
|
const diffColumns = await buildRecordDiff(serverEntry, baselineEntry, meta, metaDir);
|
|
363
364
|
|
|
364
365
|
// If the only server-side changes are metadata columns (_LastUpdated,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir, readFile, writeFile, rename, access, mkdir } from 'fs/promises';
|
|
2
2
|
import { join, extname, basename, dirname } from 'path';
|
|
3
3
|
import { log } from '../lib/logger.js';
|
|
4
|
-
import { stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
|
|
5
|
-
import { ensureTrashIcon } from '../lib/
|
|
4
|
+
import { stripUidFromFilename, hasUidInFilename, isMetadataFile } from '../lib/filenames.js';
|
|
5
|
+
import { ensureTrashIcon } from '../lib/tagging.js';
|
|
6
6
|
|
|
7
7
|
export const description = 'Rename legacy ~UID companion files to natural filenames';
|
|
8
8
|
|
|
@@ -172,7 +172,7 @@ async function findAllMetadataFiles(dir) {
|
|
|
172
172
|
const full = join(dir, entry.name);
|
|
173
173
|
if (entry.isDirectory()) {
|
|
174
174
|
results.push(...await findAllMetadataFiles(full));
|
|
175
|
-
} else if (entry.name
|
|
175
|
+
} else if (isMetadataFile(entry.name)) {
|
|
176
176
|
results.push(full);
|
|
177
177
|
}
|
|
178
178
|
}
|