@x1a0f3n9/dsh-native-command 0.1.5-rc.4 → 0.1.7-rc.2

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.
@@ -0,0 +1,191 @@
1
+ /** Native file associations queried from the desktop that owns the path. */
2
+ import { runNativeCommand } from "./runner.js";
3
+ import { linuxFileApplications } from "./file-applications-linux.js";
4
+ import { windowsFileApplications } from "./file-applications-windows.js";
5
+ import { nativeFileManager } from "./path-opener.js";
6
+ import { parseNativeFileApplications } from "./types.js";
7
+ /** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */
8
+ const MAC_APPLICATIONS = `
9
+ ObjC.import('AppKit');
10
+ function run(argv) {
11
+ var workspace = $.NSWorkspace.sharedWorkspace;
12
+ var file = $.NSURL.fileURLWithPath(argv[0]);
13
+ var preferred = workspace.URLForApplicationToOpenURL(file);
14
+ var preferredPath = preferred.isNil() ? null : ObjC.unwrap(preferred.path);
15
+ var urls = workspace.URLsForApplicationsToOpenURL(file);
16
+ var apps = [];
17
+ for (var i = 0; i < urls.count; i++) {
18
+ var url = urls.objectAtIndex(i);
19
+ var path = ObjC.unwrap(url.path);
20
+ var image = null;
21
+ if (argv[1] === 'icons') {
22
+ var icon = workspace.iconForFile(path);
23
+ var thumbnail = $.NSImage.alloc.initWithSize($.NSMakeSize(32, 32));
24
+ thumbnail.lockFocus;
25
+ icon.drawInRectFromRectOperationFraction($.NSMakeRect(0, 0, 32, 32), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1);
26
+ thumbnail.unlockFocus;
27
+ var bitmap = $.NSBitmapImageRep.imageRepWithData(thumbnail.TIFFRepresentation);
28
+ var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
29
+ image = png.isNil() ? null : 'data:image/png;base64,' + ObjC.unwrap(png.base64EncodedStringWithOptions(0));
30
+ }
31
+ var bundle = $.NSBundle.bundleWithURL(url);
32
+ var bundleId = bundle.isNil() || bundle.bundleIdentifier.isNil() ? null : ObjC.unwrap(bundle.bundleIdentifier);
33
+ var version = bundle.isNil() ? null : bundle.objectForInfoDictionaryKey('CFBundleShortVersionString');
34
+ apps.push({
35
+ id: path,
36
+ name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path)),
37
+ default: path === preferredPath,
38
+ icon: image,
39
+ bundle: bundleId,
40
+ version: version === null || version.isNil() ? null : String(ObjC.unwrap(version))
41
+ });
42
+ }
43
+ return JSON.stringify(apps);
44
+ }`;
45
+ /**
46
+ * List registered handlers in OS preference order, including the current default and application icons.
47
+ * @param path - verified absolute local file path.
48
+ * @param signal - caller lifetime, propagated to the OS query.
49
+ * @param internals - platform and command adapter for deterministic tests.
50
+ * @returns current file handlers; on macOS, copies sharing a bundle identifier and display name collapse
51
+ * to one entry; an empty list when the platform has no association query.
52
+ */
53
+ export async function nativeFileApplications(path, signal, internals = {}) {
54
+ return queryFileApplications(path, signal, internals, true);
55
+ }
56
+ /**
57
+ * Query handler metadata; display queries render macOS icons and collapse duplicate copies,
58
+ * launch validation keeps every registered copy.
59
+ */
60
+ async function queryFileApplications(path, signal, internals, display) {
61
+ signal.throwIfAborted();
62
+ const target = await desktopTarget(path, signal, internals);
63
+ const run = internals.run ?? runNativeCommand;
64
+ if (target.platform === 'linux')
65
+ return linuxFileApplications(path, signal, run, internals.env ?? process.env);
66
+ if (target.platform === 'darwin') {
67
+ const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, target.path, display ? 'icons' : 'handlers'], signal);
68
+ const applications = parseMacApplications(JSON.parse(stdout));
69
+ return display ? dedupeMacApplications(applications) : applications;
70
+ }
71
+ if (target.platform !== 'win32')
72
+ return [];
73
+ const stdout = await windowsFileApplications(target.path, null, signal, run);
74
+ return parseNativeFileApplications(JSON.parse(stdout));
75
+ }
76
+ /**
77
+ * Validate every entry of the decoded macOS query output, base fields and grouping metadata alike.
78
+ * @param value - decoded application list from the macOS query.
79
+ * @returns validated handlers in OS preference order.
80
+ * @throws Error when any entry is malformed, matching the other platform parsers.
81
+ */
82
+ function parseMacApplications(value) {
83
+ if (!Array.isArray(value))
84
+ throw new Error('Invalid native application list');
85
+ const entries = value;
86
+ return entries.map((entry) => {
87
+ if (typeof entry !== 'object' || entry === null)
88
+ throw new Error('Invalid native application entry');
89
+ const bundle = macField('bundle' in entry ? entry.bundle : null);
90
+ const version = macField('version' in entry ? entry.version : null);
91
+ const [base] = parseNativeFileApplications([entry]);
92
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- a one-entry input parses to one entry
93
+ return { ...base, bundle, version };
94
+ });
95
+ }
96
+ /** Validate one optional string field value of a macOS entry; missing fields, null, and empty strings read as null. */
97
+ function macField(field) {
98
+ if (field === null)
99
+ return null;
100
+ if (typeof field !== 'string')
101
+ throw new Error('Invalid native application entry');
102
+ return field.length === 0 ? null : field;
103
+ }
104
+ /**
105
+ * Collapse duplicate registrations of the same application for display. Self-updating
106
+ * apps leave extra copies on disk (an update staged under Application Support,
107
+ * per-version installs) and LaunchServices registers every one, so the raw handler
108
+ * list repeats the app. Finder shows one entry per app and splits only deliberate
109
+ * side-by-side installs, which carry distinct display names; matching that, copies
110
+ * sharing a bundle identifier and display name collapse to the system default, else
111
+ * the highest version, at the group's first position. Launch validation bypasses
112
+ * this collapse, so every registered copy stays openable.
113
+ * @param applications - validated handlers in OS preference order.
114
+ * @returns application metadata with one entry per application and no grouping metadata.
115
+ */
116
+ function dedupeMacApplications(applications) {
117
+ const order = [];
118
+ const groups = new Map();
119
+ for (const app of applications) {
120
+ if (app.bundle === null) {
121
+ order.push(app);
122
+ continue;
123
+ }
124
+ const key = `${app.bundle}\u0000${app.name}`;
125
+ const index = groups.get(key);
126
+ if (index === undefined) {
127
+ groups.set(key, order.length);
128
+ order.push(app);
129
+ continue;
130
+ }
131
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- group indices point at pushed entries
132
+ const held = order[index];
133
+ if (held.default)
134
+ continue;
135
+ if (app.default || compareVersions(app.version, held.version) > 0)
136
+ order[index] = app;
137
+ }
138
+ return order.map(({ id, name, default: preferred, icon }) => ({ id, name, default: preferred, icon }));
139
+ }
140
+ /** Order two dotted version strings numerically; non-numeric segments count as 0, and a missing version sorts lowest. */
141
+ function compareVersions(left, right) {
142
+ if (left === null || right === null)
143
+ return left === right ? 0 : left === null ? -1 : 1;
144
+ const a = left.split('.');
145
+ const b = right.split('.');
146
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
147
+ const difference = (Number(a[i]) || 0) - (Number(b[i]) || 0);
148
+ if (difference !== 0)
149
+ return difference;
150
+ }
151
+ return 0;
152
+ }
153
+ /**
154
+ * Open a file in a currently registered handler; stale or arbitrary application identifiers are rejected.
155
+ * Validation checks the complete registered list, so macOS copies collapsed out of the display list stay openable.
156
+ * @param path - verified absolute local file path.
157
+ * @param application - identifier returned by the file association query.
158
+ * @param signal - caller lifetime, propagated to query and launch.
159
+ * @param internals - platform and command adapter for deterministic tests.
160
+ * @returns after the system launcher accepts the file.
161
+ */
162
+ export async function openNativeFileApplication(path, application, signal, internals = {}) {
163
+ const target = await desktopTarget(path, signal, internals);
164
+ const run = internals.run ?? runNativeCommand;
165
+ if (target.platform === 'win32') {
166
+ await windowsFileApplications(target.path, application, signal, run);
167
+ return;
168
+ }
169
+ const apps = await queryFileApplications(path, signal, internals, false);
170
+ if (!apps.some(app => app.id === application))
171
+ throw new Error('Application is not registered for this file');
172
+ if (target.platform === 'linux')
173
+ await run('gio', ['launch', application, path], signal);
174
+ else
175
+ await run('open', ['-a', application, path], signal);
176
+ }
177
+ /** Resolve the desktop that owns the file, including Windows applications reached from WSL. */
178
+ async function desktopTarget(path, signal, internals) {
179
+ signal.throwIfAborted();
180
+ const platform = internals.platform ?? process.platform;
181
+ if (platform === 'linux' && nativeFileManager(internals) === 'explorer') {
182
+ const translated = await (internals.run ?? runNativeCommand)('wslpath', ['-w', path], signal);
183
+ signal.throwIfAborted();
184
+ const windowsPath = translated.stdout.replace(/[\r\n]+$/, '');
185
+ if (windowsPath === '')
186
+ throw new Error('wslpath returned no Windows path');
187
+ return { platform: 'win32', path: windowsPath };
188
+ }
189
+ return { platform, path };
190
+ }
191
+ //# sourceMappingURL=file-applications.js.map
@@ -4,6 +4,9 @@
4
4
  */
5
5
  export { runNativeCommand } from './runner.ts';
6
6
  export type { NativeCommandRunner } from './runner.ts';
7
- export { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeTextFile, } from './path-opener.ts';
7
+ export { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeAssociatedPath, openNativeTextFile, } from './path-opener.ts';
8
8
  export type { NativeFileManager, PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts';
9
+ export { nativeFileApplications, openNativeFileApplication } from './file-applications.ts';
10
+ export type { NativeFileApplication } from './types.ts';
11
+ export { desktopEntryFields, desktopDataDirectories, desktopApplicationIcon } from './desktop-entry.ts';
9
12
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Host-native command execution and path-opening utilities.
3
+ * @module @x1a0f3n9/dsh-native-command
4
+ */
5
+ export { runNativeCommand } from "./runner.js";
6
+ export { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeAssociatedPath, openNativeTextFile, } from "./path-opener.js";
7
+ export { nativeFileApplications, openNativeFileApplication } from "./file-applications.js";
8
+ export { desktopEntryFields, desktopDataDirectories, desktopApplicationIcon } from "./desktop-entry.js";
9
+ //# sourceMappingURL=index.js.map
@@ -5,7 +5,10 @@
5
5
  * The default intent prefers the default browser for documents it renders when
6
6
  * the platform can name one, then falls back to the default application. WSL
7
7
  * translates every path for the Windows desktop instead of assuming a Linux
8
- * GUI. The text-editor intent never consults the browser.
8
+ * GUI. The text-editor intent never consults the browser. Windows hands every
9
+ * intent to Explorer: the shell's own default-application resolution, the one
10
+ * a double-click uses, selects the application, while a process that resolves
11
+ * the association itself reads a narrower record and reports none.
9
12
  * @module @x1a0f3n9/dsh-native-command/path-opener
10
13
  */
11
14
  import { type NativeCommandRunner } from './runner.ts';
@@ -40,6 +43,14 @@ export declare function canOpenNativePath(internals?: PathOpenerInternals): bool
40
43
  * @param internals - Platform, environment, and runner hooks for deterministic tests.
41
44
  */
42
45
  export declare function openNativePath(path: string, signal: AbortSignal, internals?: PathOpenerInternals): Promise<void>;
46
+ /**
47
+ * Open a filesystem path through its file-type association, including HTML and SVG.
48
+ * @param path - absolute or host-resolvable path; the caller verifies local access.
49
+ * @param signal - caller lifetime; abort terminates the native command.
50
+ * @param internals - platform, environment, and runner facts for adapter tests.
51
+ * @returns after the associated application accepts the path.
52
+ */
53
+ export declare function openNativeAssociatedPath(path: string, signal: AbortSignal, internals?: PathOpenerInternals): Promise<void>;
43
54
  /**
44
55
  * Open a text document for editing; macOS bypasses the file-type association
45
56
  * so a YAML association with a browser cannot consume the gesture.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Cross-platform native path and text-document openers for Host UI
3
+ * integrations.
4
+ *
5
+ * The default intent prefers the default browser for documents it renders when
6
+ * the platform can name one, then falls back to the default application. WSL
7
+ * translates every path for the Windows desktop instead of assuming a Linux
8
+ * GUI. The text-editor intent never consults the browser. Windows hands every
9
+ * intent to Explorer: the shell's own default-application resolution, the one
10
+ * a double-click uses, selects the application, while a process that resolves
11
+ * the association itself reads a narrower record and reports none.
12
+ * @module @x1a0f3n9/dsh-native-command/path-opener
13
+ */
14
+ import { release as osRelease } from 'node:os';
15
+ import { dirname, extname } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { runNativeCommand } from "./runner.js";
18
+ /** Documents a browser renders, as opposed to ones an editor merely edits. */
19
+ const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg']);
20
+ /**
21
+ * The macOS bundle registered for `https` — the default browser, as
22
+ * LaunchServices records it. The nested version dict is stripped first
23
+ * because it carries its own `LSHandlerRoleAll`.
24
+ */
25
+ function macBundleForHttps(plist) {
26
+ const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '');
27
+ const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0];
28
+ if (block === undefined)
29
+ return undefined;
30
+ return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1];
31
+ }
32
+ /**
33
+ * Open one browser-renderable document with the default browser.
34
+ * @returns true when a browser took it; false when this platform cannot name
35
+ * one, or naming it failed — the caller then uses the default application.
36
+ */
37
+ async function openInBrowser(path, signal, platform, run, env) {
38
+ if (platform === 'darwin') {
39
+ let bundle;
40
+ try {
41
+ const { stdout } = await run('defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal);
42
+ bundle = macBundleForHttps(stdout);
43
+ }
44
+ catch {
45
+ // No LaunchServices record (a fresh account never changed a default):
46
+ // the content-type handler is then the system's own choice anyway.
47
+ return false;
48
+ }
49
+ if (bundle === undefined)
50
+ return false;
51
+ await run('open', ['-b', bundle, path], signal);
52
+ return true;
53
+ }
54
+ if (platform === 'linux') {
55
+ // $BROWSER is the portable convention; desktop-entry resolution through
56
+ // xdg-settings needs a launcher this package has no business shipping.
57
+ const browser = env.BROWSER;
58
+ if (browser === undefined || browser === '')
59
+ return false;
60
+ await run(browser, [path], signal);
61
+ return true;
62
+ }
63
+ // Windows names no browser without reading the UserChoice registry, and its
64
+ // .html association is the browser in the ordinary case.
65
+ return false;
66
+ }
67
+ /** Whether one environment marker is set to a non-empty value. */
68
+ function present(value) {
69
+ return value !== undefined && value !== '';
70
+ }
71
+ /** Distinguish WSL from desktop Linux using its process and kernel markers. */
72
+ function isWsl(internals) {
73
+ const env = internals.env ?? process.env;
74
+ if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP))
75
+ return true;
76
+ return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft');
77
+ }
78
+ /**
79
+ * Encode one Windows path as the target Explorer can receive intact.
80
+ *
81
+ * Explorer parses its own command line and splits fields at commas and equals
82
+ * signs, so a raw path loses everything after the first separator and the shell
83
+ * opens a different target without reporting it; both separators are escaped.
84
+ * Nothing else is: Explorer rejects percent-encoded non-ASCII in a file URI and
85
+ * opens the user's Documents folder instead, while it resolves the literal
86
+ * characters, so Node's non-ASCII escapes are decoded back and its ASCII escapes
87
+ * stand. Node resolves the path before encoding it, so a verbatim `\\?\` or
88
+ * `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC URI; a `\\.\`
89
+ * device path keeps that same UNC handling and names a device rather than a
90
+ * shell item, which this opener does not open.
91
+ * @param windowsPath - path already translated for the Windows desktop.
92
+ * @returns the target for an open, or the object of a `/select,` reveal.
93
+ */
94
+ function explorerTarget(windowsPath) {
95
+ const href = pathToFileURL(windowsPath, { windows: true }).href;
96
+ // A run of escapes whose every byte starts above ASCII is one non-ASCII character.
97
+ return href
98
+ .replace(/(?:%[89A-F][0-9A-F])+/gi, escaped => decodeURIComponent(escaped))
99
+ .replaceAll(',', '%2C')
100
+ .replaceAll('=', '%3D');
101
+ }
102
+ /**
103
+ * Hand one target to Explorer, accepting its delegated-handoff exit code.
104
+ *
105
+ * Explorer exits 1 after handing the request to the desktop process already
106
+ * running, so exit 1 means the shell took it. Every other failure still
107
+ * rejects, and cancellation wins over a delegate's exit 1.
108
+ * @param args - Explorer argv: the encoded target alone to open it, or `/select,<encoded target>` to reveal it.
109
+ * @param signal - caller lifetime; abort terminates the command.
110
+ * @param run - shell-free command runner.
111
+ * @throws The runner's failure unless it is Explorer's delegate exit 1.
112
+ */
113
+ async function runExplorer(args, signal, run) {
114
+ try {
115
+ await run('explorer.exe', args, signal);
116
+ }
117
+ catch (error) {
118
+ signal.throwIfAborted();
119
+ // Explorer can exit 1 after delegating to the existing desktop process.
120
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 1)
121
+ throw error;
122
+ }
123
+ }
124
+ /**
125
+ * Open one Windows-resolvable path through Explorer, the shell that owns the
126
+ * default-application resolution a double-click uses.
127
+ * @param path - Windows-resolvable path; Explorer receives its encoded file URI as one argv element, never a command string.
128
+ * @param signal - caller lifetime; abort terminates the command.
129
+ * @param run - shell-free command runner.
130
+ */
131
+ async function openWindowsPath(path, signal, run) {
132
+ await runExplorer([explorerTarget(path)], signal, run);
133
+ }
134
+ /** Translate a WSL path before handing it to the Windows desktop. */
135
+ async function openWslPath(path, signal, run) {
136
+ const translated = await run('wslpath', ['-w', path], signal);
137
+ signal.throwIfAborted();
138
+ const windowsPath = translated.stdout.replace(/[\r\n]+$/, '');
139
+ if (windowsPath === '')
140
+ throw new Error('wslpath returned no Windows path');
141
+ await openWindowsPath(windowsPath, signal, run);
142
+ }
143
+ /** Dispatch one shell-free platform command for the requested open intent. */
144
+ async function openNativePathWithIntent(path, signal, intent, internals = {}) {
145
+ const platform = internals.platform ?? process.platform;
146
+ const run = internals.run ?? runNativeCommand;
147
+ const env = internals.env ?? process.env;
148
+ const wsl = platform === 'linux' && isWsl(internals);
149
+ if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
150
+ && await openInBrowser(path, signal, platform, run, env))
151
+ return;
152
+ if (platform === 'darwin') {
153
+ await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal);
154
+ return;
155
+ }
156
+ if (platform === 'win32') {
157
+ await openWindowsPath(path, signal, run);
158
+ return;
159
+ }
160
+ if (platform === 'linux') {
161
+ if (wsl) {
162
+ await openWslPath(path, signal, run);
163
+ return;
164
+ }
165
+ await run('xdg-open', [path], signal);
166
+ return;
167
+ }
168
+ throw new Error(`native path opener is unsupported on ${platform}`);
169
+ }
170
+ /**
171
+ * Whether {@link openNativePath} plausibly reaches a desktop on this host.
172
+ *
173
+ * macOS and Windows always carry a desktop opener; Linux does when it is WSL
174
+ * (the Windows desktop takes the path) or a display server is announced.
175
+ * A headless or containerised Linux host answers false, which is what lets a
176
+ * surface show a path as text instead of offering a button that would spawn
177
+ * `xdg-open` into nothing.
178
+ * @param internals - platform and environment seam for deterministic tests.
179
+ * @returns true when handing a path to the native opener can work at all.
180
+ */
181
+ export function canOpenNativePath(internals = {}) {
182
+ const platform = internals.platform ?? process.platform;
183
+ if (platform === 'darwin' || platform === 'win32')
184
+ return true;
185
+ if (platform !== 'linux')
186
+ return false;
187
+ const env = internals.env ?? process.env;
188
+ return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY);
189
+ }
190
+ /**
191
+ * Open a filesystem path with the operating system's default application, or
192
+ * with the default browser when the path names a document a browser renders.
193
+ * @param path - absolute or host-resolvable path (caller owns resolution).
194
+ * @param signal - caller/connection lifetime; abort terminates the native command.
195
+ * @param internals - Platform, environment, and runner hooks for deterministic tests.
196
+ */
197
+ export function openNativePath(path, signal, internals = {}) {
198
+ return openNativePathWithIntent(path, signal, 'default', internals);
199
+ }
200
+ /**
201
+ * Open a filesystem path through its file-type association, including HTML and SVG.
202
+ * @param path - absolute or host-resolvable path; the caller verifies local access.
203
+ * @param signal - caller lifetime; abort terminates the native command.
204
+ * @param internals - platform, environment, and runner facts for adapter tests.
205
+ * @returns after the associated application accepts the path.
206
+ */
207
+ export function openNativeAssociatedPath(path, signal, internals = {}) {
208
+ return openNativePathWithIntent(path, signal, 'association', internals);
209
+ }
210
+ /**
211
+ * Open a text document for editing; macOS bypasses the file-type association
212
+ * so a YAML association with a browser cannot consume the gesture.
213
+ * @param path - absolute or host-resolvable text-document path.
214
+ * @param signal - caller/connection lifetime; abort terminates the native command.
215
+ * @param internals - Platform and runner hooks for deterministic tests.
216
+ */
217
+ export function openNativeTextFile(path, signal, internals = {}) {
218
+ return openNativePathWithIntent(path, signal, 'text-editor', internals);
219
+ }
220
+ /**
221
+ * Identify the native file-manager action without inspecting the browser's platform.
222
+ * @param internals - platform and WSL facts.
223
+ * @returns the supported file-manager action, or null on unsupported platforms.
224
+ */
225
+ export function nativeFileManager(internals = {}) {
226
+ const platform = internals.platform ?? process.platform;
227
+ if (platform === 'darwin')
228
+ return 'finder';
229
+ if (platform === 'win32' || (platform === 'linux' && isWsl(internals)))
230
+ return 'explorer';
231
+ return platform === 'linux' ? 'directory' : null;
232
+ }
233
+ /**
234
+ * Reveal a file in Finder or Explorer, or open its parent in the Linux default file manager.
235
+ * @param path - absolute file path already authorized by the caller.
236
+ * @param signal - caller lifetime; abort terminates the native command.
237
+ * @param internals - platform, environment, and command runner for adapter tests.
238
+ * @returns after command completion; Explorer exit 1 is accepted as a delegated handoff, not proof of selection.
239
+ */
240
+ export async function revealNativePath(path, signal, internals = {}) {
241
+ signal.throwIfAborted();
242
+ const platform = internals.platform ?? process.platform;
243
+ const run = internals.run ?? runNativeCommand;
244
+ const manager = nativeFileManager({ ...internals, platform });
245
+ if (manager === 'finder') {
246
+ await run('open', ['-R', path], signal);
247
+ return;
248
+ }
249
+ if (manager === 'explorer') {
250
+ let windowsPath = path;
251
+ if (platform === 'linux') {
252
+ const translated = await run('wslpath', ['-w', path], signal);
253
+ signal.throwIfAborted();
254
+ windowsPath = translated.stdout.replace(/[\r\n]+$/, '');
255
+ if (windowsPath === '')
256
+ throw new Error('wslpath returned no Windows path');
257
+ }
258
+ await runExplorer(['/select,', explorerTarget(windowsPath)], signal, run);
259
+ return;
260
+ }
261
+ if (manager === 'directory') {
262
+ await run('xdg-open', [dirname(path)], signal);
263
+ return;
264
+ }
265
+ throw new Error(`native file manager is unsupported on ${platform}`);
266
+ }
267
+ //# sourceMappingURL=path-opener.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared no-shell `execFile` runner for host-native OS integrations.
3
+ * @module @x1a0f3n9/dsh-native-command/runner
4
+ */
5
+ import { execFile } from 'node:child_process';
6
+ /**
7
+ * Run a host command with utf8 stdio, abort propagation, and Windows hide.
8
+ * @param command - executable path or PATH name.
9
+ * @param args - argv (never a shell string).
10
+ * @param signal - caller/connection lifetime; abort terminates the child.
11
+ * @returns captured stdout/stderr on exit 0.
12
+ */
13
+ export const runNativeCommand = (command, args, signal) => new Promise((resolve, reject) => {
14
+ execFile(command, [...args], { encoding: 'utf8', signal, windowsHide: true }, (error, stdout, stderr) => {
15
+ if (error !== null) {
16
+ const failure = Object.assign(new Error(error.message, { cause: error }), {
17
+ code: error.code,
18
+ stdout,
19
+ stderr,
20
+ });
21
+ reject(failure);
22
+ return;
23
+ }
24
+ resolve({ stdout, stderr });
25
+ });
26
+ });
27
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,18 @@
1
+ /** Browser-safe metadata for native file associations. */
2
+ /** One OS-registered application capable of opening the requested file. */
3
+ export interface NativeFileApplication {
4
+ /** OS application identifier; callers must revalidate it against the file's current handlers before opening. */
5
+ readonly id: string;
6
+ readonly name: string;
7
+ readonly default: boolean;
8
+ /** PNG or SVG data URL, or null when the desktop supplies no icon. */
9
+ readonly icon: string | null;
10
+ }
11
+ /**
12
+ * Validate file association metadata received from a native process or authenticated Host.
13
+ * @param value - decoded application list.
14
+ * @returns validated application metadata.
15
+ * @throws Error for malformed entries or unsupported icon URLs.
16
+ */
17
+ export declare function parseNativeFileApplications(value: unknown): readonly NativeFileApplication[];
18
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,25 @@
1
+ /** Browser-safe metadata for native file associations. */
2
+ /**
3
+ * Validate file association metadata received from a native process or authenticated Host.
4
+ * @param value - decoded application list.
5
+ * @returns validated application metadata.
6
+ * @throws Error for malformed entries or unsupported icon URLs.
7
+ */
8
+ export function parseNativeFileApplications(value) {
9
+ if (!Array.isArray(value))
10
+ throw new Error('Invalid native application list');
11
+ const applications = [];
12
+ const entries = value;
13
+ for (const entry of entries) {
14
+ if (typeof entry !== 'object' || entry === null
15
+ || !('id' in entry) || !('name' in entry) || !('default' in entry) || !('icon' in entry)
16
+ || typeof entry.id !== 'string' || entry.id.length === 0
17
+ || typeof entry.name !== 'string' || typeof entry.default !== 'boolean'
18
+ || !(entry.icon === null || (typeof entry.icon === 'string' && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon)))) {
19
+ throw new Error('Invalid native application entry');
20
+ }
21
+ applications.push({ id: entry.id, name: entry.name, default: entry.default, icon: entry.icon });
22
+ }
23
+ return applications;
24
+ }
25
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x1a0f3n9/dsh-native-command",
3
3
  "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff",
4
- "version": "0.1.5-rc.4",
4
+ "version": "0.1.7-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -19,17 +19,22 @@
19
19
  "default": "./lib/index.js"
20
20
  },
21
21
  "./src/*": "./src/*",
22
- "./package.json": "./package.json"
22
+ "./package.json": "./package.json",
23
+ "./types": {
24
+ "types": "./lib/types/types.d.ts",
25
+ "default": "./lib/types/types.js"
26
+ }
23
27
  },
24
28
  "files": [
25
29
  "lib/index.js",
30
+ "lib/types/**/*.js",
26
31
  "lib/types/**/*.d.ts"
27
32
  ],
28
33
  "license": "MIT",
29
34
  "peerDependencies": {
30
- "@deepseek-ai/cordis": "^4.0.2"
35
+ "@deepseek-ai/cordis": "~4.0.4"
31
36
  },
32
37
  "devDependencies": {
33
- "@deepseek-ai/cordis": "^4.0.2"
38
+ "@deepseek-ai/cordis": "~4.0.4"
34
39
  }
35
40
  }