@flighthq/shell 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.1047.858b9b6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @flighthq/shell
2
2
 
3
- OS shell integration over a swappable web/native backend: open external URLs and paths, reveal items in the file manager, move to trash, beep
3
+ OS shell commands over seven explicit Host capability slots with required external URL policy
4
4
 
5
5
  ## Install
6
6
 
@@ -13,5 +13,5 @@ Import the supported application-facing API from `@flighthq/shell`. The `@flight
13
13
  This package is part of the locked-version Flight SDK graph. Applications may instead install and import `@flighthq/sdk` when package-level tree shaking is sufficient.
14
14
 
15
15
  - [Flight project](https://github.com/flighthq/flight)
16
- - [Source for @flighthq/shell@0.5.0-next.1944.cd6b6e7](https://github.com/flighthq/flight/tree/cd6b6e71eb472b3a4249830a7743333f019fa7b7/packages/shell)
17
- - [License](https://github.com/flighthq/flight/blob/cd6b6e71eb472b3a4249830a7743333f019fa7b7/LICENSE.md)
16
+ - [Source for @flighthq/shell@0.5.1-edge.1047.858b9b6](https://github.com/flighthq/flight/tree/858b9b601de814f6b6940cbb1ffff0916b903268/packages/shell)
17
+ - [License](https://github.com/flighthq/flight/blob/858b9b601de814f6b6940cbb1ffff0916b903268/LICENSE.md)
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { explainShellBackend, isShellUrlAllowed, moveItemToTrash, moveItemsToTrash, openShellExternalUrl, openShellPath, openShellPathResult, readShellShortcutLink, setShellUrlSchemeAllowlist, shellBeep, showItemInFolder, writeShellShortcutLink, } from './contract';
1
+ export { isShellUrlAllowed, moveShellItemToTrash, moveShellItemsToTrash, openShellExternalUrl, openShellPath, readShellShortcutLink, revealShellPath, shellBeep, spawnShellProcess, writeShellShortcutLink, } from './contract';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,SAAS,EACT,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,aAAa,EACb,qBAAqB,EACrB,eAAe,EACf,SAAS,EACT,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { explainShellBackend, isShellUrlAllowed, moveItemToTrash, moveItemsToTrash, openShellExternalUrl, openShellPath, openShellPathResult, readShellShortcutLink, setShellUrlSchemeAllowlist, shellBeep, showItemInFolder, writeShellShortcutLink, } from './contract';
1
+ export { isShellUrlAllowed, moveShellItemToTrash, moveShellItemsToTrash, openShellExternalUrl, openShellPath, readShellShortcutLink, revealShellPath, shellBeep, spawnShellProcess, writeShellShortcutLink, } from './contract';
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,SAAS,EACT,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,aAAa,EACb,qBAAqB,EACrB,eAAe,EACf,SAAS,EACT,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,YAAY,CAAC"}
package/dist/shell.d.ts CHANGED
@@ -1,20 +1,12 @@
1
- import type { BackendExplanation } from '@flighthq/types/contract';
2
- import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions, ShellShortcutLink, ShellShortcutWriteOperation } from '@flighthq/types/contract';
3
- export declare function explainShellBackend(): BackendExplanation;
4
- export declare function getShellBackend(): ShellBackend;
5
- export declare function installShellHostBackend(backend: ShellBackend): void;
6
- export declare function isShellUrlAllowed(url: string): boolean;
7
- export declare function moveItemsToTrash(paths: readonly string[]): Promise<readonly boolean[]>;
8
- export declare function moveItemToTrash(path: string): Promise<boolean>;
9
- export declare function observeShellHostResult(operation: string, succeeded: boolean): void;
10
- export declare function openShellExternalUrl(url: string, options?: Readonly<ShellOpenExternalOptions>): Promise<boolean>;
11
- export declare function openShellPath(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<boolean>;
12
- export declare function openShellPathResult(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<string>;
13
- export declare function readShellShortcutLink(shortcutPath: string): Promise<ShellShortcutLink | null>;
14
- export declare function resetShellBackendForTest(): void;
15
- export declare function setShellBackend(backend: ShellBackend | null): void;
16
- export declare function setShellUrlSchemeAllowlist(schemes: readonly string[] | null): void;
17
- export declare function shellBeep(): void;
18
- export declare function showItemInFolder(path: string): Promise<boolean>;
19
- export declare function writeShellShortcutLink(shortcutPath: string, link: Readonly<ShellShortcutLink>, operation?: ShellShortcutWriteOperation): Promise<boolean>;
1
+ import type { HasShellBeep, HasShellExternal, HasShellPathOpen, HasShellPathReveal, HasShellShortcutLink, HasShellTrash, ShellExternalOutcome, ShellExternalUrlPolicy, ShellPathOpenOutcome, ShellPathRevealOutcome, ShellProcess, ShellProcessHost, ShellProcessOptions, ShellShortcutLink, ShellShortcutLinkReadOutcome, ShellShortcutLinkWriteOutcome, ShellShortcutWriteOperation, ShellTrashOutcome } from '@flighthq/types/contract';
2
+ export declare function isShellUrlAllowed(url: string, policy: Readonly<ShellExternalUrlPolicy>): boolean;
3
+ export declare function moveShellItemsToTrash(host: HasShellTrash, paths: readonly string[]): Promise<readonly ShellTrashOutcome[]>;
4
+ export declare function moveShellItemToTrash(host: HasShellTrash, path: string): Promise<ShellTrashOutcome>;
5
+ export declare function openShellExternalUrl(host: HasShellExternal, url: string, policy: Readonly<ShellExternalUrlPolicy>): Promise<ShellExternalOutcome>;
6
+ export declare function openShellPath(host: HasShellPathOpen, path: string): Promise<ShellPathOpenOutcome>;
7
+ export declare function readShellShortcutLink(host: HasShellShortcutLink, shortcutPath: string): Promise<ShellShortcutLinkReadOutcome>;
8
+ export declare function revealShellPath(host: HasShellPathReveal, path: string): Promise<ShellPathRevealOutcome>;
9
+ export declare function shellBeep(host: HasShellBeep): void;
10
+ export declare function spawnShellProcess(host: ShellProcessHost, command: string, args: readonly string[], options?: Readonly<ShellProcessOptions>): ShellProcess | null;
11
+ export declare function writeShellShortcutLink(host: HasShellShortcutLink, shortcutPath: string, link: Readonly<ShellShortcutLink>, operation: ShellShortcutWriteOperation): Promise<ShellShortcutLinkWriteOutcome>;
20
12
  //# sourceMappingURL=shell.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACnE,OAAO,KAAK,EACV,YAAY,EACZ,wBAAwB,EACxB,oBAAoB,EACpB,iBAAiB,EACjB,2BAA2B,EAC5B,MAAM,0BAA0B,CAAC;AAElC,wBAAgB,mBAAmB,IAAI,kBAAkB,CAaxD;AAGD,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAMnE;AAID,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQtD;AAID,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAEtF;AAGD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE9D;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,CAKlF;AAQD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGhH;AAGD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAEtG;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3G;AAID,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAE7F;AAED,wBAAgB,wBAAwB,IAAI,IAAI,CAK/C;AAGD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI,CAElE;AAMD,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,IAAI,CAElF;AAGD,wBAAgB,SAAS,IAAI,IAAI,CAEhC;AAGD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE/D;AAMD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,EACjC,SAAS,CAAC,EAAE,2BAA2B,GACtC,OAAO,CAAC,OAAO,CAAC,CAElB"}
1
+ {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,aAAa,EACb,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACpB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,4BAA4B,EAC5B,6BAA6B,EAC7B,2BAA2B,EAC3B,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAIlC,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,sBAAsB,CAAC,GAAG,OAAO,CAOhG;AAID,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,aAAa,EACnB,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAEvC;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAElG;AAKD,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,gBAAgB,EACtB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,QAAQ,CAAC,sBAAsB,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAG/B;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAEjG;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,oBAAoB,EAC1B,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,4BAA4B,CAAC,CAEvC;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAEvG;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAElD;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE,QAAQ,CAAC,mBAAmB,CAAC,GACtC,YAAY,GAAG,IAAI,CAErB;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,oBAAoB,EAC1B,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,EACjC,SAAS,EAAE,2BAA2B,GACrC,OAAO,CAAC,6BAA6B,CAAC,CAExC"}
package/dist/shell.js CHANGED
@@ -1,145 +1,46 @@
1
- export function explainShellBackend() {
2
- if (_custom !== null) {
3
- return { conflict: _hostConflict, layer: 'custom', operation: null, viability: 'unobserved' };
4
- }
5
- if (_host !== null) {
6
- return {
7
- conflict: _hostConflict,
8
- layer: 'host',
9
- operation: _hostObservation !== null ? _hostObservation.operation : null,
10
- viability: _hostObservation !== null ? _hostObservation.viability : 'unobserved',
11
- };
12
- }
13
- return { conflict: false, layer: 'host-not-enabled', operation: null, viability: 'unobserved' };
14
- }
15
- // The active shell backend. Precedence: custom > host > sentinel.
16
- export function getShellBackend() {
17
- return _custom ?? _host ?? _sentinel;
18
- }
19
- export function installShellHostBackend(backend) {
20
- if (_host !== null) {
21
- if (_host !== backend)
22
- _hostConflict = true;
23
- return;
24
- }
25
- _host = backend;
26
- }
27
- // True when url is allowed by the active URL-scheme allowlist. When no allowlist is set, all URLs
28
- // are allowed. Used internally by openShellExternalUrl; also exported for callers that need the check.
29
- export function isShellUrlAllowed(url) {
30
- if (_urlSchemeAllowlist === null)
31
- return true;
1
+ // Pure policy validation used by openShellExternalUrl before any host effect. Callers must supply the
2
+ // policy on every invocation; an empty allowedSchemes array intentionally blocks every URL.
3
+ export function isShellUrlAllowed(url, policy) {
32
4
  try {
33
- const scheme = new URL(url).protocol.replace(/:$/, '');
34
- return _urlSchemeAllowlist.includes(scheme);
5
+ const scheme = new URL(url).protocol.replace(/:$/, '').toLowerCase();
6
+ return policy.allowedSchemes.some((allowed) => allowed.toLowerCase() === scheme);
35
7
  }
36
8
  catch {
37
9
  return false;
38
10
  }
39
11
  }
40
- // Moves a batch of local paths to the OS trash. Returns a per-path boolean array. Empty array on the
41
- // web (native-host only). Single-path convenience: use moveItemToTrash.
42
- export function moveItemsToTrash(paths) {
43
- return getShellBackend().moveItemsToTrash(paths);
44
- }
45
- // Moves a local path to the OS trash. Returns false on the web; native-host capability.
46
- export function moveItemToTrash(path) {
47
- return getShellBackend().moveToTrash(path);
48
- }
49
- export function observeShellHostResult(operation, succeeded) {
50
- _hostObservation = {
51
- operation,
52
- viability: succeeded ? 'available' : 'runtime-api-unavailable',
53
- };
12
+ // Projects the one-path provider operation across the batch. Promise.all starts every operation,
13
+ // awaits every settlement, and returns outcomes in the same order as paths.
14
+ export function moveShellItemsToTrash(host, paths) {
15
+ return Promise.all(paths.map((path) => host.shell.trash.moveToTrash(path)));
54
16
  }
55
- // Opens a URL in the user's default browser / external handler. Returns false when blocked, popup-
56
- // blocked, or the URL scheme is not in the active allowlist (see setShellUrlSchemeAllowlist).
57
- // SECURITY: handing an attacker-controlled URL to the OS default handler is the classic openExternal
58
- // footgun — a non-http(s) scheme can launch a local application or protocol handler. When the url may
59
- // be untrusted, constrain the accepted schemes with setShellUrlSchemeAllowlist (e.g. ['https',
60
- // 'mailto']); isShellUrlAllowed exposes the same check for a caller that wants to gate the URL itself.
61
- export function openShellExternalUrl(url, options) {
62
- if (!isShellUrlAllowed(url))
63
- return Promise.resolve(false);
64
- return getShellBackend().openExternal(url, options);
17
+ export function moveShellItemToTrash(host, path) {
18
+ return host.shell.trash.moveToTrash(path);
65
19
  }
66
- // Opens a local path with its default OS application. Returns false on the web; native-host capability.
67
- export function openShellPath(path, options) {
68
- return getShellBackend().openPath(path, options);
20
+ // Handing a URL to an OS handler can launch a local application or registered protocol. The required
21
+ // per-call policy is validated before dispatch, so a blocked or malformed scheme never reaches the
22
+ // host. There is intentionally no policy default, ambient allowlist, or allow-all path.
23
+ export function openShellExternalUrl(host, url, policy) {
24
+ if (!isShellUrlAllowed(url, policy))
25
+ return Promise.resolve({ reason: 'blocked-scheme' });
26
+ return host.shell.external.open(url);
69
27
  }
70
- // Opens a local path and returns the OS error message, or '' on success. On the web returns
71
- // 'unavailable on web'. Use this when you need the reason a path could not be opened rather than
72
- // just a boolean; openShellPath is the boolean convenience wrapper over this.
73
- export function openShellPathResult(path, options) {
74
- return getShellBackend().openPathResult(path, options);
28
+ export function openShellPath(host, path) {
29
+ return host.shell.pathOpen.open(path);
75
30
  }
76
- // Reads a Windows .lnk shell shortcut. Returns null on non-Windows platforms, on the web, or when
77
- // the shortcut does not exist.
78
- export function readShellShortcutLink(shortcutPath) {
79
- return getShellBackend().readShortcutLink(shortcutPath);
31
+ export function readShellShortcutLink(host, shortcutPath) {
32
+ return host.shell.shortcutLink.read(shortcutPath);
80
33
  }
81
- export function resetShellBackendForTest() {
82
- _custom = null;
83
- _host = null;
84
- _hostConflict = false;
85
- _hostObservation = null;
34
+ export function revealShellPath(host, path) {
35
+ return host.shell.pathReveal.reveal(path);
86
36
  }
87
- // Installs a custom shell backend; pass null to clear the custom override.
88
- export function setShellBackend(backend) {
89
- _custom = backend;
37
+ export function shellBeep(host) {
38
+ host.shell.beep.beep();
90
39
  }
91
- // Sets the URL-scheme allowlist consulted by openShellExternalUrl. Pass null to allow all schemes
92
- // (default behavior). When a non-null list is set, openShellExternalUrl returns false for any URL whose
93
- // scheme is not in the list. Example: setShellUrlSchemeAllowlist(['https', 'mailto']).
94
- // This closes the classic openExternal security footgun with attacker-controlled URLs.
95
- export function setShellUrlSchemeAllowlist(schemes) {
96
- _urlSchemeAllowlist = schemes;
40
+ export function spawnShellProcess(host, command, args, options) {
41
+ return host.shell.process?.spawn(command, args, options) ?? null;
97
42
  }
98
- // Emits a system beep. No-op on the web until a native host provides one.
99
- export function shellBeep() {
100
- getShellBackend().beep();
43
+ export function writeShellShortcutLink(host, shortcutPath, link, operation) {
44
+ return host.shell.shortcutLink.write(shortcutPath, link, operation);
101
45
  }
102
- // Reveals a local path in the OS file manager. Returns false on the web; native-host capability.
103
- export function showItemInFolder(path) {
104
- return getShellBackend().showItemInFolder(path);
105
- }
106
- // Creates a Windows .lnk shell shortcut at shortcutPath pointing to link. Returns false on
107
- // non-Windows platforms and on the web. operation defaults to 'create'.
108
- // IMPORTANT: URL safety — only use openShellExternalUrl for user-facing link opening; do not pass
109
- // attacker-controlled paths to writeShellShortcutLink without validation.
110
- export function writeShellShortcutLink(shortcutPath, link, operation) {
111
- return getShellBackend().writeShortcutLink(shortcutPath, link, operation);
112
- }
113
- const _sentinel = {
114
- beep() { },
115
- async moveItemsToTrash() {
116
- return [];
117
- },
118
- async moveToTrash() {
119
- return false;
120
- },
121
- async openExternal() {
122
- return false;
123
- },
124
- async openPath() {
125
- return false;
126
- },
127
- async openPathResult() {
128
- return 'unavailable on web';
129
- },
130
- async readShortcutLink() {
131
- return null;
132
- },
133
- async showItemInFolder() {
134
- return false;
135
- },
136
- async writeShortcutLink() {
137
- return false;
138
- },
139
- };
140
- let _custom = null;
141
- let _host = null;
142
- let _hostConflict = false;
143
- let _hostObservation = null;
144
- let _urlSchemeAllowlist = null;
145
46
  //# sourceMappingURL=shell.js.map
package/dist/shell.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAqBA,sGAAsG;AACtG,4FAA4F;AAC5F,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,MAAwC;IACrF,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACrE,OAAO,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC,CAAC;IACnF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,iGAAiG;AACjG,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CACnC,IAAmB,EACnB,KAAwB;IAExB,OAAO,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAmB,EAAE,IAAY;IACpE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AAC5C,CAAC;AAED,qGAAqG;AACrG,mGAAmG;AACnG,wFAAwF;AACxF,MAAM,UAAU,oBAAoB,CAClC,IAAsB,EACtB,GAAW,EACX,MAAwC;IAExC,IAAI,CAAC,iBAAiB,CAAC,GAAG,EAAE,MAAM,CAAC;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAC1F,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACvC,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAsB,EAAE,IAAY;IAChE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,IAA0B,EAC1B,YAAoB;IAEpB,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAwB,EAAE,IAAY;IACpE,OAAO,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAkB;IAC1C,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,IAAsB,EACtB,OAAe,EACf,IAAuB,EACvB,OAAuC;IAEvC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,sBAAsB,CACpC,IAA0B,EAC1B,YAAoB,EACpB,IAAiC,EACjC,SAAsC;IAEtC,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,YAAY,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;AACtE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/shell",
3
- "version": "0.5.0-next.1944.cd6b6e7",
3
+ "version": "0.5.1-edge.1047.858b9b6",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,11 +38,12 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/types": "0.5.0-next.1944.cd6b6e7"
41
+ "@flighthq/types": "0.5.1-edge.1047.858b9b6"
42
42
  },
43
43
  "devDependencies": {
44
+ "@flighthq/entity": "*",
44
45
  "typescript": "^5.3.0"
45
46
  },
46
- "description": "OS shell integration over a swappable web/native backend: open external URLs and paths, reveal items in the file manager, move to trash, beep",
47
+ "description": "OS shell commands over seven explicit Host capability slots with required external URL policy",
47
48
  "sideEffects": false
48
49
  }
package/src/shell.test.ts CHANGED
@@ -1,359 +1,296 @@
1
- import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions } from '@flighthq/types/contract';
1
+ import { allocateEntity, finishEntity } from '@flighthq/entity/contract';
2
+ import type {
3
+ EntityRuntimeKey,
4
+ HasShellBeep,
5
+ HasShellExternal,
6
+ HasShellPathOpen,
7
+ HasShellPathReveal,
8
+ HasShellShortcutLink,
9
+ HasShellTrash,
10
+ ShellBeepBackend,
11
+ ShellExternalBackend,
12
+ ShellExternalUrlPolicy,
13
+ ShellPathOpenBackend,
14
+ ShellPathRevealBackend,
15
+ ShellProcess,
16
+ ShellProcessBackend,
17
+ ShellProcessHost,
18
+ ShellProcessOptions,
19
+ ShellShortcutLinkBackend,
20
+ ShellTrashBackend,
21
+ } from '@flighthq/types/contract';
2
22
 
3
23
  import {
4
- explainShellBackend,
5
- getShellBackend,
6
- installShellHostBackend,
7
24
  isShellUrlAllowed,
8
- moveItemsToTrash,
9
- moveItemToTrash,
10
- observeShellHostResult,
25
+ moveShellItemsToTrash,
26
+ moveShellItemToTrash,
11
27
  openShellExternalUrl,
12
28
  openShellPath,
13
- openShellPathResult,
14
29
  readShellShortcutLink,
15
- resetShellBackendForTest,
16
- setShellBackend,
17
- setShellUrlSchemeAllowlist,
30
+ revealShellPath,
18
31
  shellBeep,
19
- showItemInFolder,
32
+ spawnShellProcess,
20
33
  writeShellShortcutLink,
21
34
  } from './shell';
22
35
 
23
- function fakeBackend(): ShellBackend & {
24
- beeped: number;
25
- lastOptions: ShellOpenExternalOptions | ShellOpenPathOptions | undefined;
26
- opened: string;
27
- pathResult: string;
28
- trashed: string;
29
- trashedBatch: readonly string[];
30
- } {
31
- return {
32
- beeped: 0,
33
- lastOptions: undefined,
34
- opened: '',
35
- pathResult: '',
36
- trashed: '',
37
- trashedBatch: [],
38
- beep() {
39
- this.beeped += 1;
40
- },
41
- async moveItemsToTrash(paths) {
42
- this.trashedBatch = paths;
43
- return paths.map(() => true);
44
- },
45
- async moveToTrash(path) {
46
- this.trashed = path;
47
- return true;
48
- },
49
- async openExternal(url, options) {
50
- this.opened = url;
51
- this.lastOptions = options;
52
- return true;
53
- },
54
- async openPath(path, options) {
55
- this.opened = path;
56
- this.lastOptions = options;
57
- return true;
58
- },
59
- async openPathResult(path, options) {
60
- this.opened = path;
61
- this.lastOptions = options;
62
- return this.pathResult;
63
- },
64
- async readShortcutLink() {
65
- return { target: '/path/to/target' };
66
- },
67
- async showItemInFolder(path) {
68
- this.opened = path;
69
- return true;
70
- },
71
- async writeShortcutLink() {
72
- return true;
73
- },
74
- };
75
- }
76
-
77
- function resetShellTestState(): void {
78
- resetShellBackendForTest();
79
- setShellUrlSchemeAllowlist(null);
80
- }
81
-
82
- // The root fast suite deliberately shares modules between files. A host-adapter test can therefore
83
- // be the state immediately before this file; reset on entry as well as exit so the first assertion has
84
- // the same isolation as every later one.
85
- beforeEach(resetShellTestState);
86
- afterEach(resetShellTestState);
87
-
88
- describe('explainShellBackend', () => {
89
- it('returns host-not-enabled when no backend is installed', () => {
90
- expect(explainShellBackend()).toEqual({
91
- conflict: false,
92
- layer: 'host-not-enabled',
93
- operation: null,
94
- viability: 'unobserved',
95
- });
96
- });
97
-
98
- it('returns custom layer when a custom backend is set', () => {
99
- setShellBackend(fakeBackend());
100
- expect(explainShellBackend().layer).toBe('custom');
36
+ describe('isShellUrlAllowed', () => {
37
+ it('matches schemes case-insensitively from an explicit policy', () => {
38
+ expect(isShellUrlAllowed('https://example.test', { allowedSchemes: ['HTTPS'] })).toBe(true);
39
+ expect(isShellUrlAllowed('mailto:user@example.test', { allowedSchemes: ['https'] })).toBe(false);
101
40
  });
102
41
 
103
- it('returns host layer when a host backend is installed', () => {
104
- installShellHostBackend(fakeBackend());
105
- expect(explainShellBackend().layer).toBe('host');
42
+ it('blocks malformed URLs and an empty policy', () => {
43
+ expect(isShellUrlAllowed('not a URL', { allowedSchemes: ['https'] })).toBe(false);
44
+ expect(isShellUrlAllowed('https://example.test', { allowedSchemes: [] })).toBe(false);
106
45
  });
107
46
  });
108
47
 
109
- describe('getShellBackend', () => {
110
- it('falls back to sentinel', () => {
111
- expect(getShellBackend()).not.toBeNull();
112
- });
113
-
114
- it('returns the registered backend', () => {
115
- const backend = fakeBackend();
116
- setShellBackend(backend);
117
- expect(getShellBackend()).toBe(backend);
48
+ describe('moveShellItemsToTrash', () => {
49
+ it('starts every one-path operation and preserves input order while awaiting all results', async () => {
50
+ const paths: string[] = [];
51
+ const resolvers: Array<(outcome: { reason: 'ok' | 'operation-failed' }) => void> = [];
52
+ const host = trashHost(
53
+ (() => {
54
+ const out = allocateEntity<any>();
55
+ out.moveToTrash = (path: string) => {
56
+ paths.push(path);
57
+ return new Promise((resolve) => resolvers.push(resolve));
58
+ };
59
+ return finishEntity(out);
60
+ })(),
61
+ );
62
+
63
+ const outcomes = moveShellItemsToTrash(host, ['/first', '/second']);
64
+ expect(paths).toEqual(['/first', '/second']);
65
+ resolvers[1]?.({ reason: 'operation-failed' });
66
+ let settled = false;
67
+ void outcomes.then(() => {
68
+ settled = true;
69
+ });
70
+ await Promise.resolve();
71
+ expect(settled).toBe(false);
72
+ resolvers[0]?.({ reason: 'ok' });
73
+ await expect(outcomes).resolves.toEqual([{ reason: 'ok' }, { reason: 'operation-failed' }]);
118
74
  });
119
75
  });
120
76
 
121
- describe('installShellHostBackend', () => {
122
- it('first-host-wins and sets conflict on second different backend', () => {
123
- const first = fakeBackend();
124
- const second = fakeBackend();
125
- installShellHostBackend(first);
126
- installShellHostBackend(second);
127
- expect(explainShellBackend().conflict).toBe(true);
128
- });
129
-
130
- it('does not set conflict when the same backend is installed twice', () => {
131
- const backend = fakeBackend();
132
- installShellHostBackend(backend);
133
- installShellHostBackend(backend);
134
- expect(explainShellBackend().conflict).toBe(false);
77
+ describe('moveShellItemToTrash', () => {
78
+ it('routes through the supplied trash provider', async () => {
79
+ const moveToTrash = vi.fn(async () => ({ reason: 'ok' as const }));
80
+ await expect(
81
+ moveShellItemToTrash(
82
+ trashHost(
83
+ (() => {
84
+ const out = allocateEntity<any>();
85
+ out.moveToTrash = moveToTrash;
86
+ return finishEntity(out);
87
+ })(),
88
+ ),
89
+ '/item',
90
+ ),
91
+ ).resolves.toEqual({
92
+ reason: 'ok',
93
+ });
94
+ expect(moveToTrash).toHaveBeenCalledWith('/item');
135
95
  });
136
96
  });
137
97
 
138
- describe('isShellUrlAllowed', () => {
139
- it('returns true when no allowlist is set', () => {
140
- expect(isShellUrlAllowed('https://example.com')).toBe(true);
141
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
142
- });
143
-
144
- it('returns true for a scheme in the allowlist', () => {
145
- setShellUrlSchemeAllowlist(['https', 'mailto']);
146
- expect(isShellUrlAllowed('https://example.com')).toBe(true);
147
- expect(isShellUrlAllowed('mailto:user@example.com')).toBe(true);
148
- });
149
-
150
- it('returns false for a scheme not in the allowlist', () => {
151
- setShellUrlSchemeAllowlist(['https']);
152
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(false);
153
- expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
154
- });
155
-
156
- it('returns false for a URL that cannot be parsed', () => {
157
- setShellUrlSchemeAllowlist(['https']);
158
- expect(isShellUrlAllowed('not-a-url')).toBe(false);
98
+ describe('openShellExternalUrl', () => {
99
+ it('requires policy as the third parameter at the type boundary', () => {
100
+ expectTypeOf(openShellExternalUrl).parameters.toEqualTypeOf<
101
+ [HasShellExternal, string, Readonly<ShellExternalUrlPolicy>]
102
+ >();
103
+ });
104
+
105
+ it('blocks before dispatch when the scheme is not allowed', async () => {
106
+ const open = vi.fn(async () => ({ reason: 'ok' as const }));
107
+ const host = externalHost(
108
+ (() => {
109
+ const out = allocateEntity<any>();
110
+ out.open = open;
111
+ return finishEntity(out);
112
+ })(),
113
+ );
114
+ await expect(openShellExternalUrl(host, 'file:///etc/passwd', { allowedSchemes: ['https'] })).resolves.toEqual({
115
+ reason: 'blocked-scheme',
116
+ });
117
+ expect(open).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('keeps two live hosts isolated', async () => {
121
+ const calls: string[] = [];
122
+ const first = externalHost(
123
+ (() => {
124
+ const out = allocateEntity<any>();
125
+ out.open = async (url: string) => {
126
+ calls.push(`first:${url}`);
127
+ return { reason: 'ok' };
128
+ };
129
+ return finishEntity(out);
130
+ })(),
131
+ );
132
+ const second = externalHost(
133
+ (() => {
134
+ const out = allocateEntity<any>();
135
+ out.open = async (url: string) => {
136
+ calls.push(`second:${url}`);
137
+ return { reason: 'operation-failed' };
138
+ };
139
+ return finishEntity(out);
140
+ })(),
141
+ );
142
+ await expect(openShellExternalUrl(first, 'https://one.test', HTTPS_ONLY)).resolves.toEqual({ reason: 'ok' });
143
+ await expect(openShellExternalUrl(second, 'https://two.test', HTTPS_ONLY)).resolves.toEqual({
144
+ reason: 'operation-failed',
145
+ });
146
+ expect(calls).toEqual(['first:https://one.test', 'second:https://two.test']);
159
147
  });
160
148
  });
161
149
 
162
- describe('moveItemsToTrash', () => {
163
- it('passes the path array to the backend and returns per-path results', async () => {
164
- const backend = fakeBackend();
165
- setShellBackend(backend);
166
- const results = await moveItemsToTrash(['/tmp/a', '/tmp/b']);
167
- expect(results).toEqual([true, true]);
168
- expect(backend.trashedBatch).toEqual(['/tmp/a', '/tmp/b']);
150
+ describe('openShellPath', () => {
151
+ it('preserves a provider failure and its message', async () => {
152
+ const provider = allocateEntity<ShellPathOpenBackend>();
153
+ provider.open = async () => {
154
+ return { message: '', reason: 'operation-failed' as const };
155
+ };
156
+ await expect(openShellPath(pathOpenHost(finishEntity(provider)), '/missing')).resolves.toEqual({
157
+ message: '',
158
+ reason: 'operation-failed',
159
+ });
169
160
  });
170
161
  });
171
162
 
172
- describe('moveItemToTrash', () => {
173
- it('trashes via the active backend', async () => {
174
- const backend = fakeBackend();
175
- setShellBackend(backend);
176
- expect(await moveItemToTrash('/tmp/x')).toBe(true);
177
- expect(backend.trashed).toBe('/tmp/x');
163
+ describe('readShellShortcutLink', () => {
164
+ it('returns the read-specific outcome', async () => {
165
+ const provider = shortcutLinkProvider();
166
+ await expect(readShellShortcutLink(shortcutLinkHost(provider), '/app.lnk')).resolves.toEqual({
167
+ link: { target: '/app' },
168
+ reason: 'ok',
169
+ });
178
170
  });
179
171
  });
180
172
 
181
- describe('observeShellHostResult', () => {
182
- it('records a successful observation into the explanation', () => {
183
- installShellHostBackend(fakeBackend());
184
- observeShellHostResult('openExternal', true);
185
- const explanation = explainShellBackend();
186
- expect(explanation.operation).toBe('openExternal');
187
- expect(explanation.viability).toBe('available');
188
- });
189
-
190
- it('records a failed observation into the explanation', () => {
191
- installShellHostBackend(fakeBackend());
192
- observeShellHostResult('openExternal', false);
193
- const explanation = explainShellBackend();
194
- expect(explanation.operation).toBe('openExternal');
195
- expect(explanation.viability).toBe('runtime-api-unavailable');
173
+ describe('revealShellPath', () => {
174
+ it('returns the reveal-specific outcome', async () => {
175
+ const reveal = vi.fn(async () => ({ reason: 'operation-failed' as const }));
176
+ await expect(
177
+ revealShellPath(
178
+ pathRevealHost(
179
+ (() => {
180
+ const out = allocateEntity<any>();
181
+ out.reveal = reveal;
182
+ return finishEntity(out);
183
+ })(),
184
+ ),
185
+ '/item',
186
+ ),
187
+ ).resolves.toEqual({
188
+ reason: 'operation-failed',
189
+ });
196
190
  });
197
191
  });
198
192
 
199
- describe('openShellExternalUrl', () => {
200
- it('opens via the active backend', async () => {
201
- const backend = fakeBackend();
202
- setShellBackend(backend);
203
- expect(await openShellExternalUrl('https://example.com')).toBe(true);
204
- expect(backend.opened).toBe('https://example.com');
205
- });
206
-
207
- it('forwards the activate option to the backend', async () => {
208
- const backend = fakeBackend();
209
- setShellBackend(backend);
210
- await openShellExternalUrl('https://example.com', { activate: true });
211
- expect(backend.lastOptions).toEqual({ activate: true });
212
- });
213
-
214
- it('returns false and does not call the backend when the URL scheme is blocked', async () => {
215
- const backend = fakeBackend();
216
- setShellBackend(backend);
217
- setShellUrlSchemeAllowlist(['https']);
218
- expect(await openShellExternalUrl('file:///etc/passwd')).toBe(false);
219
- expect(backend.opened).toBe('');
220
- });
221
-
222
- it('allows the URL when its scheme is in the allowlist', async () => {
223
- const backend = fakeBackend();
224
- setShellBackend(backend);
225
- setShellUrlSchemeAllowlist(['https', 'mailto']);
226
- expect(await openShellExternalUrl('https://example.com')).toBe(true);
227
- expect(backend.opened).toBe('https://example.com');
193
+ describe('shellBeep', () => {
194
+ it('dispatches synchronously', () => {
195
+ const beep = vi.fn();
196
+ shellBeep(
197
+ beepHost(
198
+ (() => {
199
+ const out = allocateEntity<any>();
200
+ out.beep = beep;
201
+ return finishEntity(out);
202
+ })(),
203
+ ),
204
+ );
205
+ expect(beep).toHaveBeenCalledOnce();
228
206
  });
229
207
  });
230
208
 
231
- describe('openShellPath', () => {
232
- it('opens via the active backend', async () => {
233
- const backend = fakeBackend();
234
- setShellBackend(backend);
235
- expect(await openShellPath('/tmp/x')).toBe(true);
236
- expect(backend.opened).toBe('/tmp/x');
209
+ describe('spawnShellProcess', () => {
210
+ it('exposes the explicit host, command, argument-vector, and optional-options API', () => {
211
+ expectTypeOf(spawnShellProcess).parameters.toEqualTypeOf<
212
+ [ShellProcessHost, string, readonly string[], Readonly<ShellProcessOptions>?]
213
+ >();
214
+ expectTypeOf(spawnShellProcess).returns.toEqualTypeOf<ShellProcess | null>();
237
215
  });
238
216
 
239
- it('forwards the workingDirectory option to the backend', async () => {
240
- const backend = fakeBackend();
241
- setShellBackend(backend);
242
- await openShellPath('/tmp/x', { workingDirectory: '/home/user' });
243
- expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
244
- });
217
+ it('forwards the command, argument vector, and options to the host backend unchanged', () => {
218
+ const process = shellProcess();
219
+ const spawn = vi.fn(() => process);
220
+ const host = processHost(
221
+ (() => {
222
+ const out = allocateEntity<any>();
223
+ out.spawn = spawn;
224
+ return finishEntity(out);
225
+ })(),
226
+ );
227
+ const args = ['--flag', 'value'] as const;
228
+ const options = { cwd: '/work', environment: { MODE: 'test' } } as const;
245
229
 
246
- it('forwards the application option to the backend', async () => {
247
- const backend = fakeBackend();
248
- setShellBackend(backend);
249
- await openShellPath('/tmp/x', { application: 'TextEdit' });
250
- expect(backend.lastOptions).toEqual({ application: 'TextEdit' });
230
+ expect(spawnShellProcess(host, '/bin/tool', args, options)).toBe(process);
231
+ expect(spawn).toHaveBeenCalledWith('/bin/tool', args, options);
251
232
  });
252
233
 
253
- it('omits options when none provided', async () => {
254
- const backend = fakeBackend();
255
- setShellBackend(backend);
256
- await openShellPath('/tmp/x');
257
- expect(backend.lastOptions).toBeUndefined();
234
+ it('returns null when the host does not expose child-process support', () => {
235
+ expect(spawnShellProcess({ shell: {} }, '/bin/tool', [])).toBeNull();
258
236
  });
259
237
  });
260
238
 
261
- describe('openShellPathResult', () => {
262
- it('returns an empty string on success', async () => {
263
- const backend = fakeBackend();
264
- backend.pathResult = '';
265
- setShellBackend(backend);
266
- expect(await openShellPathResult('/tmp/x')).toBe('');
267
- expect(backend.opened).toBe('/tmp/x');
268
- });
269
-
270
- it('returns the OS error string on failure', async () => {
271
- const backend = fakeBackend();
272
- backend.pathResult = 'No such file or directory';
273
- setShellBackend(backend);
274
- expect(await openShellPathResult('/nonexistent')).toBe('No such file or directory');
275
- });
276
-
277
- it('forwards options to the backend', async () => {
278
- const backend = fakeBackend();
279
- setShellBackend(backend);
280
- await openShellPathResult('/tmp/x', { workingDirectory: '/home/user' });
281
- expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
239
+ describe('writeShellShortcutLink', () => {
240
+ it('requires an explicit write operation and returns the write-specific outcome', async () => {
241
+ const provider = shortcutLinkProvider();
242
+ await expect(
243
+ writeShellShortcutLink(shortcutLinkHost(provider), '/app.lnk', { target: '/app' }, 'replace'),
244
+ ).resolves.toEqual({ reason: 'ok' });
245
+ expect(provider.write).toHaveBeenCalledWith('/app.lnk', { target: '/app' }, 'replace');
282
246
  });
283
247
  });
284
248
 
285
- describe('readShellShortcutLink', () => {
286
- it('returns null with sentinel (no backend set)', async () => {
287
- const result = await readShellShortcutLink('/tmp/x.lnk');
288
- expect(result).toBeNull();
289
- });
249
+ const HTTPS_ONLY: ShellExternalUrlPolicy = { allowedSchemes: ['https'] };
290
250
 
291
- it('returns the shortcut link from the active backend', async () => {
292
- const backend = fakeBackend();
293
- setShellBackend(backend);
294
- const result = await readShellShortcutLink('/tmp/x.lnk');
295
- expect(result).toEqual({ target: '/path/to/target' });
296
- });
297
- });
251
+ function beepHost(beep: ShellBeepBackend): HasShellBeep {
252
+ return { shell: { beep } };
253
+ }
298
254
 
299
- describe('resetShellBackendForTest', () => {
300
- it('clears all backend slots', () => {
301
- setShellBackend(fakeBackend());
302
- installShellHostBackend(fakeBackend());
303
- resetShellBackendForTest();
304
- expect(explainShellBackend().layer).toBe('host-not-enabled');
305
- });
306
- });
255
+ function externalHost(external: ShellExternalBackend): HasShellExternal {
256
+ return { shell: { external } };
257
+ }
307
258
 
308
- describe('setShellBackend', () => {
309
- it('clears back to sentinel when passed null', () => {
310
- setShellBackend(fakeBackend());
311
- setShellBackend(null);
312
- expect(getShellBackend()).not.toBeNull();
313
- });
314
- });
259
+ function pathOpenHost(pathOpen: ShellPathOpenBackend): HasShellPathOpen {
260
+ return { shell: { pathOpen } };
261
+ }
315
262
 
316
- describe('setShellUrlSchemeAllowlist', () => {
317
- it('allows all URLs when set to null', () => {
318
- setShellUrlSchemeAllowlist(['https']);
319
- setShellUrlSchemeAllowlist(null);
320
- expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
321
- });
263
+ function pathRevealHost(pathReveal: ShellPathRevealBackend): HasShellPathReveal {
264
+ return { shell: { pathReveal } };
265
+ }
322
266
 
323
- it('restricts URLs to the listed schemes', () => {
324
- setShellUrlSchemeAllowlist(['https', 'mailto']);
325
- expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
326
- });
327
- });
267
+ function processHost(process: ShellProcessBackend): ShellProcessHost {
268
+ return { shell: { process } };
269
+ }
328
270
 
329
- describe('shellBeep', () => {
330
- it('forwards to the active backend', () => {
331
- const backend = fakeBackend();
332
- setShellBackend(backend);
333
- shellBeep();
334
- expect(backend.beeped).toBe(1);
335
- });
336
- });
271
+ function shellProcess(): ShellProcess {
272
+ const out = allocateEntity<any>();
273
+ out.exit = Promise.resolve({ code: 0, signal: null });
274
+ out.stderr = new ReadableStream<Uint8Array>();
275
+ out.stdin = new WritableStream<Uint8Array>();
276
+ out.stdout = new ReadableStream<Uint8Array>();
277
+ out.terminate = vi.fn();
278
+ return finishEntity(out);
279
+ }
337
280
 
338
- describe('showItemInFolder', () => {
339
- it('reveals via the active backend', async () => {
340
- const backend = fakeBackend();
341
- setShellBackend(backend);
342
- expect(await showItemInFolder('/tmp/x')).toBe(true);
343
- expect(backend.opened).toBe('/tmp/x');
344
- });
345
- });
281
+ function shortcutLinkHost(shortcutLink: ShellShortcutLinkBackend): HasShellShortcutLink {
282
+ return { shell: { shortcutLink } };
283
+ }
346
284
 
347
- describe('writeShellShortcutLink', () => {
348
- it('returns false with sentinel (no backend set)', async () => {
349
- const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' });
350
- expect(result).toBe(false);
351
- });
285
+ function shortcutLinkProvider(): ShellShortcutLinkBackend & { write: ReturnType<typeof vi.fn> } {
286
+ const out = allocateEntity<any>();
287
+ out.read = async () => {
288
+ return { link: { target: '/app' }, reason: 'ok' };
289
+ };
290
+ out.write = vi.fn(async () => ({ reason: 'ok' as const }));
291
+ return finishEntity(out);
292
+ }
352
293
 
353
- it('writes via the active backend', async () => {
354
- const backend = fakeBackend();
355
- setShellBackend(backend);
356
- const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' }, 'create');
357
- expect(result).toBe(true);
358
- });
359
- });
294
+ function trashHost(trash: ShellTrashBackend): HasShellTrash {
295
+ return { shell: { trash } };
296
+ }