@l22-io/orchard-mcp 0.3.2 → 0.6.1
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 +11 -8
- package/build/bridge.d.ts +13 -2
- package/build/bridge.js +140 -23
- package/build/bridge.js.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +37 -0
- package/build/tools/contacts.js.map +1 -0
- package/build/tools/files.js +4 -1
- package/build/tools/files.js.map +1 -1
- package/build/tools/keynote.d.ts +2 -0
- package/build/tools/keynote.js +198 -0
- package/build/tools/keynote.js.map +1 -0
- package/build/tools/mail.js +69 -13
- package/build/tools/mail.js.map +1 -1
- package/build/tools/notes.d.ts +2 -0
- package/build/tools/notes.js +83 -0
- package/build/tools/notes.js.map +1 -0
- package/build/tools/numbers.d.ts +2 -0
- package/build/tools/numbers.js +167 -0
- package/build/tools/numbers.js.map +1 -0
- package/build/tools/pages.d.ts +2 -0
- package/build/tools/pages.js +143 -0
- package/build/tools/pages.js.map +1 -0
- package/package.json +12 -9
- package/scripts/postinstall.sh +77 -8
- package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
- package/swift/.build/AppleBridge.app.sha256 +1 -0
- package/swift/Package.resolved +14 -0
- package/swift/Package.swift +28 -0
- package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
- package/swift/Sources/AppleBridge/Calendar.swift +221 -0
- package/swift/Sources/AppleBridge/Contacts.swift +225 -0
- package/swift/Sources/AppleBridge/Doctor.swift +252 -0
- package/swift/Sources/AppleBridge/Files.swift +474 -0
- package/swift/Sources/AppleBridge/JSON.swift +57 -0
- package/swift/Sources/AppleBridge/Keynote.swift +599 -0
- package/swift/Sources/AppleBridge/Mail.swift +854 -0
- package/swift/Sources/AppleBridge/Notes.swift +263 -0
- package/swift/Sources/AppleBridge/Numbers.swift +601 -0
- package/swift/Sources/AppleBridge/Pages.swift +467 -0
- package/swift/Sources/AppleBridge/Reminders.swift +347 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import PDFKit
|
|
3
|
+
import UniformTypeIdentifiers
|
|
4
|
+
import Vision
|
|
5
|
+
|
|
6
|
+
enum FilesBridge {
|
|
7
|
+
private static var home: String {
|
|
8
|
+
FileManager.default.homeDirectoryForCurrentUser.path
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// Resolve and validate a path is under the home directory.
|
|
12
|
+
/// Accepts relative paths (to ~), ~/... paths, or absolute paths.
|
|
13
|
+
/// Resolves symlinks for existing paths to prevent escape.
|
|
14
|
+
static func validatePath(_ input: String, mustExist: Bool = true) -> String? {
|
|
15
|
+
let expanded: String
|
|
16
|
+
if input.hasPrefix("/") {
|
|
17
|
+
expanded = input
|
|
18
|
+
} else if input.hasPrefix("~/") {
|
|
19
|
+
expanded = home + String(input.dropFirst())
|
|
20
|
+
} else {
|
|
21
|
+
expanded = home + "/" + input
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let url = URL(fileURLWithPath: expanded)
|
|
25
|
+
let resolved: String
|
|
26
|
+
if FileManager.default.fileExists(atPath: url.path) {
|
|
27
|
+
resolved = url.resolvingSymlinksInPath().path
|
|
28
|
+
} else if mustExist {
|
|
29
|
+
return nil
|
|
30
|
+
} else {
|
|
31
|
+
resolved = url.standardized.path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
guard resolved == home || resolved.hasPrefix(home + "/") else {
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
return resolved
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - List
|
|
41
|
+
|
|
42
|
+
static func list(path: String, recursive: Bool, depth: Int) {
|
|
43
|
+
guard let resolved = validatePath(path) else {
|
|
44
|
+
JSONOutput.error("Path is outside home directory or does not exist: \(path)")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let fm = FileManager.default
|
|
49
|
+
let url = URL(fileURLWithPath: resolved)
|
|
50
|
+
|
|
51
|
+
var isDir: ObjCBool = false
|
|
52
|
+
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
|
|
53
|
+
JSONOutput.error("Not a directory: \(path)")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var items: [[String: Any]] = []
|
|
58
|
+
|
|
59
|
+
if recursive {
|
|
60
|
+
if let enumerator = fm.enumerator(
|
|
61
|
+
at: url,
|
|
62
|
+
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey, .isDirectoryKey, .contentTypeKey],
|
|
63
|
+
options: [.skipsHiddenFiles]
|
|
64
|
+
) {
|
|
65
|
+
while let itemURL = enumerator.nextObject() as? URL {
|
|
66
|
+
if enumerator.level > depth {
|
|
67
|
+
enumerator.skipDescendants()
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if let entry = fileEntry(itemURL) {
|
|
71
|
+
items.append(entry)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
do {
|
|
77
|
+
let contents = try fm.contentsOfDirectory(
|
|
78
|
+
at: url,
|
|
79
|
+
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey, .isDirectoryKey, .contentTypeKey],
|
|
80
|
+
options: [.skipsHiddenFiles]
|
|
81
|
+
)
|
|
82
|
+
for itemURL in contents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
|
83
|
+
if let entry = fileEntry(itemURL) {
|
|
84
|
+
items.append(entry)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
JSONOutput.error("Failed to list directory: \(error.localizedDescription)")
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
JSONOutput.success(items)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static func fileEntry(_ url: URL) -> [String: Any]? {
|
|
97
|
+
do {
|
|
98
|
+
let values = try url.resourceValues(forKeys: [
|
|
99
|
+
.fileSizeKey, .creationDateKey, .contentModificationDateKey,
|
|
100
|
+
.isDirectoryKey, .contentTypeKey
|
|
101
|
+
])
|
|
102
|
+
var entry: [String: Any] = [
|
|
103
|
+
"name": url.lastPathComponent,
|
|
104
|
+
"path": url.path,
|
|
105
|
+
"isDirectory": values.isDirectory ?? false,
|
|
106
|
+
]
|
|
107
|
+
if let size = values.fileSize { entry["size"] = size }
|
|
108
|
+
if let created = values.creationDate { entry["created"] = iso8601(created) }
|
|
109
|
+
if let modified = values.contentModificationDate { entry["modified"] = iso8601(modified) }
|
|
110
|
+
if let type = values.contentType { entry["type"] = type.identifier }
|
|
111
|
+
return entry
|
|
112
|
+
} catch {
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - Info
|
|
118
|
+
|
|
119
|
+
static func info(path: String) {
|
|
120
|
+
guard let resolved = validatePath(path) else {
|
|
121
|
+
JSONOutput.error("Path is outside home directory or does not exist: \(path)")
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let fm = FileManager.default
|
|
126
|
+
let url = URL(fileURLWithPath: resolved)
|
|
127
|
+
|
|
128
|
+
var info: [String: Any] = ["path": resolved, "name": url.lastPathComponent]
|
|
129
|
+
|
|
130
|
+
do {
|
|
131
|
+
let attrs = try fm.attributesOfItem(atPath: resolved)
|
|
132
|
+
if let size = attrs[.size] as? Int64 { info["size"] = size }
|
|
133
|
+
if let created = attrs[.creationDate] as? Date { info["created"] = iso8601(created) }
|
|
134
|
+
if let modified = attrs[.modificationDate] as? Date { info["modified"] = iso8601(modified) }
|
|
135
|
+
if let type = attrs[.type] as? FileAttributeType {
|
|
136
|
+
info["fileType"] = type == .typeDirectory ? "directory" : type == .typeSymbolicLink ? "symlink" : "regular"
|
|
137
|
+
}
|
|
138
|
+
if let perms = attrs[.posixPermissions] as? Int {
|
|
139
|
+
info["permissions"] = String(format: "%o", perms)
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
|
|
143
|
+
// Spotlight metadata via mdls
|
|
144
|
+
let process = Process()
|
|
145
|
+
let pipe = Pipe()
|
|
146
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/mdls")
|
|
147
|
+
process.arguments = ["-plist", "-", resolved]
|
|
148
|
+
process.standardOutput = pipe
|
|
149
|
+
process.standardError = Pipe()
|
|
150
|
+
|
|
151
|
+
do {
|
|
152
|
+
try process.run()
|
|
153
|
+
process.waitUntilExit()
|
|
154
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
155
|
+
if let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] {
|
|
156
|
+
let mdKeys: [String: String] = [
|
|
157
|
+
"kMDItemContentType": "contentType",
|
|
158
|
+
"kMDItemKind": "kind",
|
|
159
|
+
"kMDItemWhereFroms": "whereFroms",
|
|
160
|
+
"kMDItemPixelHeight": "pixelHeight",
|
|
161
|
+
"kMDItemPixelWidth": "pixelWidth",
|
|
162
|
+
"kMDItemDurationSeconds": "duration",
|
|
163
|
+
"kMDItemNumberOfPages": "pageCount",
|
|
164
|
+
"kMDItemAuthors": "authors",
|
|
165
|
+
"kMDItemTitle": "title",
|
|
166
|
+
]
|
|
167
|
+
var spotlight: [String: Any] = [:]
|
|
168
|
+
for (mdKey, friendlyKey) in mdKeys {
|
|
169
|
+
if let value = plist[mdKey], !(value is NSNull) {
|
|
170
|
+
spotlight[friendlyKey] = value
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if !spotlight.isEmpty {
|
|
174
|
+
info["spotlight"] = spotlight
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
|
|
179
|
+
JSONOutput.success(info)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MARK: - Search (Spotlight)
|
|
183
|
+
|
|
184
|
+
private static func sanitizeSpotlightQuery(_ query: String) -> String {
|
|
185
|
+
let forbidden = CharacterSet(charactersIn: "()'\"\\")
|
|
186
|
+
return query.components(separatedBy: forbidden).joined(separator: " ")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static func search(query: String, kind: String?, scope: String?) {
|
|
190
|
+
let scopeDir = scope.flatMap({ validatePath($0) }) ?? home
|
|
191
|
+
|
|
192
|
+
let safeQuery = sanitizeSpotlightQuery(query)
|
|
193
|
+
var mdfindQuery = safeQuery
|
|
194
|
+
if let kind = kind {
|
|
195
|
+
let typeMap: [String: String] = [
|
|
196
|
+
"folder": "public.folder",
|
|
197
|
+
"image": "public.image",
|
|
198
|
+
"pdf": "com.adobe.pdf",
|
|
199
|
+
"document": "public.composite-content",
|
|
200
|
+
"audio": "public.audio",
|
|
201
|
+
"video": "public.movie",
|
|
202
|
+
"presentation": "public.presentation",
|
|
203
|
+
"spreadsheet": "public.spreadsheet",
|
|
204
|
+
]
|
|
205
|
+
if let uti = typeMap[kind] {
|
|
206
|
+
mdfindQuery = "(\(safeQuery)) && (kMDItemContentTypeTree == '\(uti)')"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let process = Process()
|
|
211
|
+
let pipe = Pipe()
|
|
212
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/mdfind")
|
|
213
|
+
process.arguments = ["-onlyin", scopeDir, mdfindQuery]
|
|
214
|
+
process.standardOutput = pipe
|
|
215
|
+
process.standardError = Pipe()
|
|
216
|
+
|
|
217
|
+
do {
|
|
218
|
+
try process.run()
|
|
219
|
+
process.waitUntilExit()
|
|
220
|
+
|
|
221
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
222
|
+
guard let output = String(data: data, encoding: .utf8) else {
|
|
223
|
+
JSONOutput.success([Any]())
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let paths = output.components(separatedBy: "\n").filter { !$0.isEmpty }
|
|
228
|
+
var results: [[String: Any]] = []
|
|
229
|
+
|
|
230
|
+
for path in paths.prefix(50) {
|
|
231
|
+
let url = URL(fileURLWithPath: path)
|
|
232
|
+
var entry: [String: Any] = [
|
|
233
|
+
"path": path,
|
|
234
|
+
"name": url.lastPathComponent,
|
|
235
|
+
]
|
|
236
|
+
do {
|
|
237
|
+
let values = try url.resourceValues(forKeys: [
|
|
238
|
+
.fileSizeKey, .contentModificationDateKey, .contentTypeKey
|
|
239
|
+
])
|
|
240
|
+
if let size = values.fileSize { entry["size"] = size }
|
|
241
|
+
if let modified = values.contentModificationDate { entry["modified"] = iso8601(modified) }
|
|
242
|
+
if let type = values.contentType { entry["type"] = type.identifier }
|
|
243
|
+
} catch {}
|
|
244
|
+
results.append(entry)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
JSONOutput.success(results)
|
|
248
|
+
} catch {
|
|
249
|
+
JSONOutput.error("mdfind failed: \(error.localizedDescription)")
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// MARK: - Read (text extraction)
|
|
254
|
+
|
|
255
|
+
static func read(path: String) {
|
|
256
|
+
guard let resolved = validatePath(path) else {
|
|
257
|
+
JSONOutput.error("Path is outside home directory or does not exist: \(path)")
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let url = URL(fileURLWithPath: resolved)
|
|
262
|
+
let fm = FileManager.default
|
|
263
|
+
|
|
264
|
+
guard fm.fileExists(atPath: resolved) else {
|
|
265
|
+
JSONOutput.error("File not found: \(path)")
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let attrs = try? fm.attributesOfItem(atPath: resolved)
|
|
270
|
+
let byteSize = (attrs?[.size] as? Int64) ?? 0
|
|
271
|
+
|
|
272
|
+
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey])
|
|
273
|
+
let contentType = resourceValues?.contentType
|
|
274
|
+
|
|
275
|
+
let maxTextBytes = 1_000_000
|
|
276
|
+
var text: String?
|
|
277
|
+
var method = "unknown"
|
|
278
|
+
|
|
279
|
+
if let ct = contentType {
|
|
280
|
+
if ct.conforms(to: .plainText) || ct.conforms(to: .sourceCode)
|
|
281
|
+
|| ct.conforms(to: .shellScript) || ct.conforms(to: .json)
|
|
282
|
+
|| ct.conforms(to: .xml) || ct.conforms(to: .yaml) {
|
|
283
|
+
text = try? String(contentsOfFile: resolved, encoding: .utf8)
|
|
284
|
+
method = "direct"
|
|
285
|
+
} else if ct.conforms(to: .pdf) {
|
|
286
|
+
text = extractPDF(url: url)
|
|
287
|
+
method = "pdfkit"
|
|
288
|
+
} else if ct.conforms(to: .image) {
|
|
289
|
+
text = extractImageText(url: url)
|
|
290
|
+
method = "vision-ocr"
|
|
291
|
+
} else {
|
|
292
|
+
text = extractViaTextutil(path: resolved)
|
|
293
|
+
method = "textutil"
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
text = try? String(contentsOfFile: resolved, encoding: .utf8)
|
|
297
|
+
if text != nil {
|
|
298
|
+
method = "direct"
|
|
299
|
+
} else {
|
|
300
|
+
text = extractViaTextutil(path: resolved)
|
|
301
|
+
method = "textutil"
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
guard var extractedText = text, !extractedText.isEmpty else {
|
|
306
|
+
JSONOutput.error("Could not extract text from file: \(url.lastPathComponent)")
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var truncated = false
|
|
311
|
+
if extractedText.utf8.count > maxTextBytes {
|
|
312
|
+
extractedText = String(extractedText.prefix(maxTextBytes))
|
|
313
|
+
truncated = true
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let result: [String: Any] = [
|
|
317
|
+
"path": resolved,
|
|
318
|
+
"contentType": contentType?.identifier ?? "unknown",
|
|
319
|
+
"method": method,
|
|
320
|
+
"text": extractedText,
|
|
321
|
+
"truncated": truncated,
|
|
322
|
+
"byteSize": byteSize,
|
|
323
|
+
]
|
|
324
|
+
JSONOutput.success(result)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private static func extractPDF(url: URL) -> String? {
|
|
328
|
+
guard let doc = PDFDocument(url: url) else { return nil }
|
|
329
|
+
var text = ""
|
|
330
|
+
for i in 0..<doc.pageCount {
|
|
331
|
+
if let page = doc.page(at: i), let pageText = page.string {
|
|
332
|
+
text += pageText + "\n"
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return text.isEmpty ? nil : text
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private static func extractImageText(url: URL) -> String? {
|
|
339
|
+
guard let imageData = try? Data(contentsOf: url) else { return nil }
|
|
340
|
+
|
|
341
|
+
let requestHandler = VNImageRequestHandler(data: imageData, options: [:])
|
|
342
|
+
let request = VNRecognizeTextRequest()
|
|
343
|
+
request.recognitionLevel = .accurate
|
|
344
|
+
|
|
345
|
+
do {
|
|
346
|
+
try requestHandler.perform([request])
|
|
347
|
+
guard let observations = request.results else { return nil }
|
|
348
|
+
let text = observations
|
|
349
|
+
.compactMap { $0.topCandidates(1).first?.string }
|
|
350
|
+
.joined(separator: "\n")
|
|
351
|
+
return text.isEmpty ? nil : text
|
|
352
|
+
} catch {
|
|
353
|
+
return nil
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private static func extractViaTextutil(path: String) -> String? {
|
|
358
|
+
let process = Process()
|
|
359
|
+
let pipe = Pipe()
|
|
360
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/textutil")
|
|
361
|
+
process.arguments = ["-convert", "txt", "-stdout", path]
|
|
362
|
+
process.standardOutput = pipe
|
|
363
|
+
process.standardError = Pipe()
|
|
364
|
+
|
|
365
|
+
do {
|
|
366
|
+
try process.run()
|
|
367
|
+
process.waitUntilExit()
|
|
368
|
+
guard process.terminationStatus == 0 else { return nil }
|
|
369
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
370
|
+
let text = String(data: data, encoding: .utf8)
|
|
371
|
+
return (text?.isEmpty == true) ? nil : text
|
|
372
|
+
} catch {
|
|
373
|
+
return nil
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// MARK: - File Operations
|
|
378
|
+
|
|
379
|
+
static func move(itemsJSON: String) {
|
|
380
|
+
guard let data = itemsJSON.data(using: .utf8),
|
|
381
|
+
let items = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else {
|
|
382
|
+
JSONOutput.error("Invalid JSON. Expected: [{\"source\": \"...\", \"destination\": \"...\"}]")
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Validate all paths first
|
|
387
|
+
for item in items {
|
|
388
|
+
guard let src = item["source"], let dst = item["destination"] else {
|
|
389
|
+
JSONOutput.error("Each item must have 'source' and 'destination' keys")
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
guard validatePath(src) != nil else {
|
|
393
|
+
JSONOutput.error("Source path outside home directory: \(src)")
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
guard validatePath(dst, mustExist: false) != nil else {
|
|
397
|
+
JSONOutput.error("Destination path outside home directory: \(dst)")
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let fm = FileManager.default
|
|
403
|
+
var results: [[String: Any]] = []
|
|
404
|
+
|
|
405
|
+
for item in items {
|
|
406
|
+
let src = validatePath(item["source"]!)!
|
|
407
|
+
let dst = validatePath(item["destination"]!, mustExist: false)!
|
|
408
|
+
var result: [String: Any] = ["source": src, "destination": dst]
|
|
409
|
+
do {
|
|
410
|
+
try fm.moveItem(atPath: src, toPath: dst)
|
|
411
|
+
result["success"] = true
|
|
412
|
+
} catch {
|
|
413
|
+
result["success"] = false
|
|
414
|
+
result["error"] = error.localizedDescription
|
|
415
|
+
}
|
|
416
|
+
results.append(result)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
JSONOutput.success(results)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
static func copy(source: String, destination: String) {
|
|
423
|
+
guard let src = validatePath(source) else {
|
|
424
|
+
JSONOutput.error("Source path outside home directory or does not exist: \(source)")
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
guard let dst = validatePath(destination, mustExist: false) else {
|
|
428
|
+
JSONOutput.error("Destination path outside home directory: \(destination)")
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
do {
|
|
433
|
+
try FileManager.default.copyItem(atPath: src, toPath: dst)
|
|
434
|
+
JSONOutput.success(["source": src, "destination": dst, "success": true])
|
|
435
|
+
} catch {
|
|
436
|
+
JSONOutput.error("Copy failed: \(error.localizedDescription)")
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
static func createFolder(path: String) {
|
|
441
|
+
guard let resolved = validatePath(path, mustExist: false) else {
|
|
442
|
+
JSONOutput.error("Path is outside home directory: \(path)")
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
do {
|
|
447
|
+
try FileManager.default.createDirectory(atPath: resolved, withIntermediateDirectories: true)
|
|
448
|
+
JSONOutput.success(["path": resolved, "success": true])
|
|
449
|
+
} catch {
|
|
450
|
+
JSONOutput.error("Create folder failed: \(error.localizedDescription)")
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
static func trash(path: String) {
|
|
455
|
+
guard let resolved = validatePath(path) else {
|
|
456
|
+
JSONOutput.error("Path is outside home directory or does not exist: \(path)")
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let url = URL(fileURLWithPath: resolved)
|
|
461
|
+
var trashURL: NSURL?
|
|
462
|
+
|
|
463
|
+
do {
|
|
464
|
+
try FileManager.default.trashItem(at: url, resultingItemURL: &trashURL)
|
|
465
|
+
var result: [String: Any] = ["path": resolved, "success": true]
|
|
466
|
+
if let trashPath = trashURL?.path {
|
|
467
|
+
result["trashPath"] = trashPath
|
|
468
|
+
}
|
|
469
|
+
JSONOutput.success(result)
|
|
470
|
+
} catch {
|
|
471
|
+
JSONOutput.error("Trash failed: \(error.localizedDescription)")
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Reason: Centralised JSON envelope so every subcommand returns a consistent
|
|
4
|
+
// {"status": "ok"|"error", "data": ..., "error": ...} structure.
|
|
5
|
+
|
|
6
|
+
enum JSONOutput {
|
|
7
|
+
/// When set, output is written to this file path instead of stdout.
|
|
8
|
+
/// Used by .app bundle mode where stdout is not capturable.
|
|
9
|
+
static var outputPath: String?
|
|
10
|
+
|
|
11
|
+
static func success(_ data: Any) {
|
|
12
|
+
let envelope: [String: Any] = [
|
|
13
|
+
"status": "ok",
|
|
14
|
+
"data": data
|
|
15
|
+
]
|
|
16
|
+
printJSON(envelope)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static func error(_ message: String) {
|
|
20
|
+
let envelope: [String: Any] = [
|
|
21
|
+
"status": "error",
|
|
22
|
+
"error": message
|
|
23
|
+
]
|
|
24
|
+
printJSON(envelope)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private static func printJSON(_ dict: [String: Any]) {
|
|
28
|
+
do {
|
|
29
|
+
let data = try JSONSerialization.data(
|
|
30
|
+
withJSONObject: dict,
|
|
31
|
+
options: [.prettyPrinted, .sortedKeys]
|
|
32
|
+
)
|
|
33
|
+
if let str = String(data: data, encoding: .utf8) {
|
|
34
|
+
if let path = outputPath {
|
|
35
|
+
try str.write(toFile: path, atomically: true, encoding: .utf8)
|
|
36
|
+
} else {
|
|
37
|
+
print(str)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Reason: Last-resort fallback if JSONSerialization itself fails.
|
|
42
|
+
let fallback = "{\"status\":\"error\",\"error\":\"JSON serialization failed: \(error.localizedDescription)\"}"
|
|
43
|
+
if let path = outputPath {
|
|
44
|
+
try? fallback.write(toFile: path, atomically: true, encoding: .utf8)
|
|
45
|
+
} else {
|
|
46
|
+
print(fallback)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Convert a Date to ISO 8601 string in the local timezone.
|
|
53
|
+
func iso8601(_ date: Date) -> String {
|
|
54
|
+
let formatter = ISO8601DateFormatter()
|
|
55
|
+
formatter.formatOptions = [.withInternetDateTime]
|
|
56
|
+
return formatter.string(from: date)
|
|
57
|
+
}
|