@fonlan/dsh-quick-commands 0.1.7 → 0.1.9
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/lib/index.js +197 -41
- package/lib/types/index.d.ts +3 -3
- package/lib/types/server/settings.d.ts +46 -3
- package/lib/types/server/shell-path.d.ts +69 -0
- package/package.json +18 -18
- package/src/index.ts +42 -35
- package/src/server/rpc.ts +8 -3
- package/src/server/runner.ts +7 -3
- package/src/server/settings.ts +65 -20
- package/src/server/shell-path.ts +160 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,141 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
//#region src/server/shell-path.ts
|
|
7
|
+
/**
|
|
8
|
+
* Login-shell PATH resolution for the local quick-command backend.
|
|
9
|
+
*
|
|
10
|
+
* `dsh web` started from a terminal inherits the user's full PATH, so a
|
|
11
|
+
* configured command such as `npm run typecheck` or `docker compose up -d`
|
|
12
|
+
* finds its toolchain. **DSH Desktop does not**: the Electron host is launched
|
|
13
|
+
* by launchd and runs with the minimal system PATH
|
|
14
|
+
* (`/usr/bin:/bin:/usr/sbin:/sbin`), which hides homebrew, `/usr/local/bin`,
|
|
15
|
+
* nvm, corepack and Docker Desktop. Every command that leans on the user's
|
|
16
|
+
* toolchain then fails with "command not found" in Desktop while the very same
|
|
17
|
+
* profile keeps working in a browser-launched host.
|
|
18
|
+
*
|
|
19
|
+
* This module asks the user's interactive login shell for its PATH once
|
|
20
|
+
* (cached for the process lifetime) and prepends it to the inherited PATH for
|
|
21
|
+
* local runs. Resolution is best effort by design: a missing `$SHELL`, a
|
|
22
|
+
* timeout, a non-zero exit or unparseable output all leave the inherited PATH
|
|
23
|
+
* untouched, so a terminal-launched host behaves exactly as before.
|
|
24
|
+
*/
|
|
25
|
+
/** Delimiters wrapping the shell's own PATH in the probe's stdout. */
|
|
26
|
+
const PATH_START = "__DSH_QC_PATH_START__";
|
|
27
|
+
const PATH_END = "__DSH_QC_PATH_END__";
|
|
28
|
+
const defaultRunner = (file, args, options) => new Promise((resolve, reject) => {
|
|
29
|
+
execFile(file, args, options, (error, stdout) => {
|
|
30
|
+
if (error) reject(error);
|
|
31
|
+
else resolve({ stdout });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Extract the PATH a shell printed between the markers.
|
|
36
|
+
*
|
|
37
|
+
* Login profiles are free to print their own noise (nvm notices, version
|
|
38
|
+
* managers, MOTDs), so only the marked span counts — never the whole stdout.
|
|
39
|
+
* @param stdout - raw probe stdout.
|
|
40
|
+
* @returns the marked PATH, or '' when the markers are missing.
|
|
41
|
+
*/
|
|
42
|
+
function extractShellPath(stdout) {
|
|
43
|
+
if (typeof stdout !== "string" && !(stdout instanceof Buffer)) return "";
|
|
44
|
+
const text = String(stdout);
|
|
45
|
+
const start = text.indexOf(PATH_START);
|
|
46
|
+
const end = text.lastIndexOf(PATH_END);
|
|
47
|
+
if (start < 0 || end < 0 || end < start) return "";
|
|
48
|
+
return text.slice(start + 21, end);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Put the login-shell PATH in front of the inherited one.
|
|
52
|
+
*
|
|
53
|
+
* The login PATH wins (that is where the user's toolchain lives); entries from
|
|
54
|
+
* the inherited PATH stay behind it as a fallback. Empty entries are dropped
|
|
55
|
+
* and duplicates keep their first position.
|
|
56
|
+
* @param loginPath - PATH reported by the login shell ('' when unavailable).
|
|
57
|
+
* @param inheritedPath - the host process's own PATH.
|
|
58
|
+
* @returns the merged PATH (the inherited value when there is no login one).
|
|
59
|
+
*/
|
|
60
|
+
function mergePaths(loginPath, inheritedPath) {
|
|
61
|
+
const seen = /* @__PURE__ */ new Set();
|
|
62
|
+
const entries = [];
|
|
63
|
+
for (const part of `${loginPath}:${inheritedPath ?? ""}`.split(":")) {
|
|
64
|
+
const entry = part.trim();
|
|
65
|
+
if (entry === "" || seen.has(entry)) continue;
|
|
66
|
+
seen.add(entry);
|
|
67
|
+
entries.push(entry);
|
|
68
|
+
}
|
|
69
|
+
return entries.join(":");
|
|
70
|
+
}
|
|
71
|
+
/** The shell program whose login PATH describes this user's environment. */
|
|
72
|
+
function loginShell(env = process.env) {
|
|
73
|
+
const shell = env.SHELL?.trim();
|
|
74
|
+
return shell !== void 0 && shell !== "" ? shell : void 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Ask `shell` (interactive login) for its PATH.
|
|
78
|
+
*
|
|
79
|
+
* Both flags matter: `-l` sources the profile files that own the system PATH
|
|
80
|
+
* (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
|
|
81
|
+
* interactive config where nvm/fnm/volta and other version managers install
|
|
82
|
+
* their shims (this user's node lives in `~/.zshrc`). A hung or noisy
|
|
83
|
+
* interactive profile cannot block a run: the probe carries a deadline, and
|
|
84
|
+
* only the marked span of stdout is read.
|
|
85
|
+
* @param options.shell - shell program; defaults to `$SHELL`.
|
|
86
|
+
* @param options.timeoutMs - probe deadline.
|
|
87
|
+
* @param options.run - probe runner, injectable for tests.
|
|
88
|
+
* @param options.env - environment the probe itself runs with.
|
|
89
|
+
* @returns the login PATH, or '' when it cannot be determined.
|
|
90
|
+
*/
|
|
91
|
+
async function resolveLoginShellPath(options = {}) {
|
|
92
|
+
if (process.platform === "win32") return "";
|
|
93
|
+
const env = options.env ?? process.env;
|
|
94
|
+
const shell = options.shell ?? loginShell(env);
|
|
95
|
+
if (shell === void 0) return "";
|
|
96
|
+
const run = options.run ?? defaultRunner;
|
|
97
|
+
const command = `printf '%s' "${PATH_START}\${PATH}${PATH_END}"`;
|
|
98
|
+
try {
|
|
99
|
+
const { stdout } = await run(shell, [
|
|
100
|
+
"-i",
|
|
101
|
+
"-l",
|
|
102
|
+
"-c",
|
|
103
|
+
command
|
|
104
|
+
], {
|
|
105
|
+
timeout: options.timeoutMs ?? 3e3,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
env
|
|
108
|
+
});
|
|
109
|
+
const path = extractShellPath(stdout).trim();
|
|
110
|
+
return /[\s]/.test(path) ? "" : path;
|
|
111
|
+
} catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let cached;
|
|
116
|
+
/** Cached, process-wide login-shell PATH (one probe per host lifetime). */
|
|
117
|
+
function loginShellPath() {
|
|
118
|
+
cached ??= resolveLoginShellPath();
|
|
119
|
+
return cached;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Environment for one local quick command.
|
|
123
|
+
*
|
|
124
|
+
* A copy of the host environment with the login-shell PATH merged in front, so
|
|
125
|
+
* Desktop-launched hosts see the same toolchain a terminal-launched host does.
|
|
126
|
+
* @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
|
|
127
|
+
* @returns the spawn environment.
|
|
128
|
+
*/
|
|
129
|
+
async function localCommandEnv(overrides = {}) {
|
|
130
|
+
const env = { ...process.env };
|
|
131
|
+
const loginPath = await loginShellPath();
|
|
132
|
+
if (loginPath !== "") env.PATH = mergePaths(loginPath, process.env.PATH);
|
|
133
|
+
return {
|
|
134
|
+
...env,
|
|
135
|
+
...overrides
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
5
139
|
//#region src/server/remote.ts
|
|
6
140
|
/**
|
|
7
141
|
* Remote (SSH) execution support: detect that a workspace path is a dsh-ssh
|
|
@@ -312,12 +446,11 @@ var QuickCommandRunner = class {
|
|
|
312
446
|
stdout: { maxBytes: TAIL_MAX_BYTES },
|
|
313
447
|
stderr: { maxBytes: TAIL_MAX_BYTES }
|
|
314
448
|
},
|
|
315
|
-
env: {
|
|
316
|
-
...process.env,
|
|
449
|
+
env: await localCommandEnv({
|
|
317
450
|
DSH_WORKSPACE: input.workspacePath,
|
|
318
451
|
DSH_WORKSPACE_ID: input.workspaceId,
|
|
319
452
|
DSH_WORKSPACE_TITLE: input.workspaceTitle
|
|
320
|
-
}
|
|
453
|
+
})
|
|
321
454
|
});
|
|
322
455
|
const live = {
|
|
323
456
|
runId,
|
|
@@ -555,6 +688,28 @@ const WorkspaceCommandsSchema = z.object({
|
|
|
555
688
|
workspaceId: z.string().min(1),
|
|
556
689
|
commands: z.array(CommandSchema).max(200)
|
|
557
690
|
});
|
|
691
|
+
/**
|
|
692
|
+
* Entry config schema, and the schema dsh derives the plugin's settings page
|
|
693
|
+
* from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
|
|
694
|
+
* purpose:
|
|
695
|
+
*
|
|
696
|
+
* - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
|
|
697
|
+
* `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
|
|
698
|
+
* cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
|
|
699
|
+
* compiling;
|
|
700
|
+
* - dropping the annotation and letting the type be inferred does not build
|
|
701
|
+
* either: declaration emit then cannot portably name the inferred type
|
|
702
|
+
* (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
|
|
703
|
+
* into the schema type while the program holds two schemastery copies
|
|
704
|
+
* (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
|
|
705
|
+
* resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
|
|
706
|
+
* the former), and cosmokit itself is not nameable from this package.
|
|
707
|
+
*
|
|
708
|
+
* The annotation is type-only. At runtime all three fields are still declared
|
|
709
|
+
* `volatile()` — that is what makes them writable by the settings plane (its
|
|
710
|
+
* `write()` refuses an entry whose form has no volatile field) and what keeps
|
|
711
|
+
* the auto-generated page working.
|
|
712
|
+
*/
|
|
558
713
|
const QuickCommandsSettingsSchema = z.object({
|
|
559
714
|
workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
|
|
560
715
|
popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner").volatile(),
|
|
@@ -564,25 +719,30 @@ const QuickCommandsSettingsSchema = z.object({
|
|
|
564
719
|
}).volatile()
|
|
565
720
|
});
|
|
566
721
|
/**
|
|
567
|
-
* Read
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
722
|
+
* Read a config node that may be a plain value or a live (`.volatile()`) node.
|
|
723
|
+
*
|
|
724
|
+
* A plugin whose Config declares volatile fields receives a reactive config
|
|
725
|
+
* whose fields expose `get()`, while hand-written config objects and tests pass
|
|
726
|
+
* plain values. Both shapes are supported here so the same code path serves the
|
|
727
|
+
* settings page, `cordis.patch.yml`, and the unit tests.
|
|
571
728
|
*/
|
|
572
|
-
function
|
|
573
|
-
if (
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
729
|
+
function liveValue(node, fallback) {
|
|
730
|
+
if (node === void 0 || node === null) return fallback;
|
|
731
|
+
const getter = node.get;
|
|
732
|
+
if (typeof getter === "function") {
|
|
733
|
+
const value = getter.call(node);
|
|
734
|
+
return value === void 0 ? fallback : value;
|
|
735
|
+
}
|
|
736
|
+
return node;
|
|
577
737
|
}
|
|
578
738
|
function registerSettings(ctx, config) {
|
|
579
739
|
const readWorkspaces = () => {
|
|
580
|
-
const value =
|
|
740
|
+
const value = liveValue(config?.workspaces, []);
|
|
581
741
|
return Array.isArray(value) ? value : [];
|
|
582
742
|
};
|
|
583
|
-
const readAnchor = () =>
|
|
743
|
+
const readAnchor = () => liveValue(config?.popupAnchor, "corner") === "button" ? "button" : "corner";
|
|
584
744
|
const readPopupSize = () => {
|
|
585
|
-
const value =
|
|
745
|
+
const value = liveValue(config?.popupSize, void 0);
|
|
586
746
|
if (typeof value !== "object" || value === null) return void 0;
|
|
587
747
|
const { width, height } = value;
|
|
588
748
|
if (typeof width !== "number" || typeof height !== "number") return void 0;
|
|
@@ -861,10 +1021,8 @@ function registerApiRoutes(ctx, runner, settings) {
|
|
|
861
1021
|
};
|
|
862
1022
|
}
|
|
863
1023
|
}
|
|
864
|
-
const webServer = ctx.get("webServer");
|
|
865
|
-
if (webServer === void 0) return () => void 0;
|
|
866
1024
|
return ctx.effect(() => {
|
|
867
|
-
return webServer.register({
|
|
1025
|
+
return ctx.webServer.register({
|
|
868
1026
|
kind: "prefix",
|
|
869
1027
|
path: API_PREFIX,
|
|
870
1028
|
handler: async (req, res) => {
|
|
@@ -899,30 +1057,28 @@ const Config = QuickCommandsSettingsSchema;
|
|
|
899
1057
|
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
900
1058
|
function apply(ctx, config) {
|
|
901
1059
|
const settings = registerSettings(ctx, config);
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
}
|
|
918
|
-
}, hostId)
|
|
1060
|
+
ctx.inject(["subprocess", "webServer"], (sctx) => {
|
|
1061
|
+
const subprocess = sctx.get("subprocess");
|
|
1062
|
+
const resolveRemote = () => {
|
|
1063
|
+
const sshPool = sctx.get("sshPool");
|
|
1064
|
+
if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
|
|
1065
|
+
return {
|
|
1066
|
+
pool: sshPool,
|
|
1067
|
+
resolveHost: (hostId) => readHostConfig((ns) => {
|
|
1068
|
+
try {
|
|
1069
|
+
return sctx.get("settings")?.get(ns);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
}, hostId)
|
|
1074
|
+
};
|
|
919
1075
|
};
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
1076
|
+
const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
|
|
1077
|
+
sctx.effect(() => registerApiRoutes(sctx, runner, settings), "quick-commands: api routes");
|
|
1078
|
+
sctx.effect(() => () => {
|
|
1079
|
+
for (const id of runner.listAll()) runner.kill(id);
|
|
1080
|
+
}, "quick-commands: process cleanup");
|
|
1081
|
+
});
|
|
926
1082
|
}
|
|
927
1083
|
//#endregion
|
|
928
1084
|
export { Config, apply, inject, name };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* absence of dsh-ssh degrades cleanly to local-only behaviour.
|
|
14
14
|
*/
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
-
import type
|
|
16
|
+
import { type QuickCommandsConfig } from './server/settings.js';
|
|
17
17
|
export declare const name = "@fonlan/dsh-quick-commands";
|
|
18
18
|
export declare const inject: string[];
|
|
19
|
-
export declare const Config: import("@deepseek-ai/schemastery").default<QuickCommandsSettings>;
|
|
19
|
+
export declare const Config: import("@deepseek-ai/schemastery").default<import("./shared/contract.js").QuickCommandsSettings, import("./server/settings.js").QuickCommandsLiveSettings>;
|
|
20
20
|
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
21
|
-
export declare function apply(ctx: Context, config:
|
|
21
|
+
export declare function apply(ctx: Context, config: QuickCommandsConfig): void;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* field therefore yields the handle object, which silently destroys the whole
|
|
11
11
|
* document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
|
|
12
12
|
* `.default('corner')` union arrives as the handle rather than a literal.
|
|
13
|
-
* Every read goes through {@link
|
|
13
|
+
* Every read goes through {@link liveValue} instead, which unwraps handles and
|
|
14
14
|
* still accepts plain values (hosts that resolve the config without the
|
|
15
15
|
* volatile wrapper).
|
|
16
16
|
*/
|
|
@@ -18,7 +18,49 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
18
18
|
import z from '@deepseek-ai/schemastery';
|
|
19
19
|
import type { QuickCommandsSettings, QuickCommandEntry, QuickPopupSize } from '../shared/contract.js';
|
|
20
20
|
export declare const QUICK_COMMANDS_NS = "quick-commands";
|
|
21
|
-
|
|
21
|
+
/** Live handle schemastery wraps around one `volatile()` config field. */
|
|
22
|
+
interface VolatileField {
|
|
23
|
+
get(): unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The entry config in its RESOLVED form: schemastery hands every `.volatile()`
|
|
27
|
+
* field to the loader (and so to `apply()`) as a live handle instead of the
|
|
28
|
+
* plain value. This is the schema's real output type.
|
|
29
|
+
*/
|
|
30
|
+
export interface QuickCommandsLiveSettings {
|
|
31
|
+
workspaces: VolatileField;
|
|
32
|
+
popupAnchor: VolatileField;
|
|
33
|
+
popupSize: VolatileField;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The entry config as `apply()` receives it: the resolved (live) document, or
|
|
37
|
+
* the plain document in hand-built contexts and hosts that resolve the config
|
|
38
|
+
* without the volatile wrapper. Both load through the same `apply`.
|
|
39
|
+
*/
|
|
40
|
+
export type QuickCommandsConfig = QuickCommandsSettings | QuickCommandsLiveSettings;
|
|
41
|
+
/**
|
|
42
|
+
* Entry config schema, and the schema dsh derives the plugin's settings page
|
|
43
|
+
* from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
|
|
44
|
+
* purpose:
|
|
45
|
+
*
|
|
46
|
+
* - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
|
|
47
|
+
* `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
|
|
48
|
+
* cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
|
|
49
|
+
* compiling;
|
|
50
|
+
* - dropping the annotation and letting the type be inferred does not build
|
|
51
|
+
* either: declaration emit then cannot portably name the inferred type
|
|
52
|
+
* (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
|
|
53
|
+
* into the schema type while the program holds two schemastery copies
|
|
54
|
+
* (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
|
|
55
|
+
* resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
|
|
56
|
+
* the former), and cosmokit itself is not nameable from this package.
|
|
57
|
+
*
|
|
58
|
+
* The annotation is type-only. At runtime all three fields are still declared
|
|
59
|
+
* `volatile()` — that is what makes them writable by the settings plane (its
|
|
60
|
+
* `write()` refuses an entry whose form has no volatile field) and what keeps
|
|
61
|
+
* the auto-generated page working.
|
|
62
|
+
*/
|
|
63
|
+
export declare const QuickCommandsSettingsSchema: z<QuickCommandsSettings, QuickCommandsLiveSettings>;
|
|
22
64
|
/** Settings service face for the quick-commands engine. */
|
|
23
65
|
export interface QuickCommandsSettingsFace {
|
|
24
66
|
get(): QuickCommandsSettings;
|
|
@@ -31,4 +73,5 @@ export interface QuickCommandsSettingsFace {
|
|
|
31
73
|
/** Persist the output popup size (drag-resize). */
|
|
32
74
|
setPopupSize(size: QuickPopupSize): Promise<void>;
|
|
33
75
|
}
|
|
34
|
-
export declare function registerSettings(ctx: Context, config:
|
|
76
|
+
export declare function registerSettings(ctx: Context, config: QuickCommandsConfig): QuickCommandsSettingsFace;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Delimiters wrapping the shell's own PATH in the probe's stdout. */
|
|
2
|
+
export declare const PATH_START = "__DSH_QC_PATH_START__";
|
|
3
|
+
export declare const PATH_END = "__DSH_QC_PATH_END__";
|
|
4
|
+
/** Default deadline for the login-shell probe (ms). */
|
|
5
|
+
export declare const PROBE_TIMEOUT_MS = 3000;
|
|
6
|
+
/** Minimal probe runner shape: `child_process.execFile`, or a test double. */
|
|
7
|
+
export type ProbeRunner = (file: string, args: string[], options: {
|
|
8
|
+
timeout: number;
|
|
9
|
+
windowsHide: boolean;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
stdout?: string | Buffer;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* Extract the PATH a shell printed between the markers.
|
|
16
|
+
*
|
|
17
|
+
* Login profiles are free to print their own noise (nvm notices, version
|
|
18
|
+
* managers, MOTDs), so only the marked span counts — never the whole stdout.
|
|
19
|
+
* @param stdout - raw probe stdout.
|
|
20
|
+
* @returns the marked PATH, or '' when the markers are missing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractShellPath(stdout: unknown): string;
|
|
23
|
+
/**
|
|
24
|
+
* Put the login-shell PATH in front of the inherited one.
|
|
25
|
+
*
|
|
26
|
+
* The login PATH wins (that is where the user's toolchain lives); entries from
|
|
27
|
+
* the inherited PATH stay behind it as a fallback. Empty entries are dropped
|
|
28
|
+
* and duplicates keep their first position.
|
|
29
|
+
* @param loginPath - PATH reported by the login shell ('' when unavailable).
|
|
30
|
+
* @param inheritedPath - the host process's own PATH.
|
|
31
|
+
* @returns the merged PATH (the inherited value when there is no login one).
|
|
32
|
+
*/
|
|
33
|
+
export declare function mergePaths(loginPath: string, inheritedPath: string | undefined): string;
|
|
34
|
+
/** The shell program whose login PATH describes this user's environment. */
|
|
35
|
+
export declare function loginShell(env?: NodeJS.ProcessEnv): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Ask `shell` (interactive login) for its PATH.
|
|
38
|
+
*
|
|
39
|
+
* Both flags matter: `-l` sources the profile files that own the system PATH
|
|
40
|
+
* (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
|
|
41
|
+
* interactive config where nvm/fnm/volta and other version managers install
|
|
42
|
+
* their shims (this user's node lives in `~/.zshrc`). A hung or noisy
|
|
43
|
+
* interactive profile cannot block a run: the probe carries a deadline, and
|
|
44
|
+
* only the marked span of stdout is read.
|
|
45
|
+
* @param options.shell - shell program; defaults to `$SHELL`.
|
|
46
|
+
* @param options.timeoutMs - probe deadline.
|
|
47
|
+
* @param options.run - probe runner, injectable for tests.
|
|
48
|
+
* @param options.env - environment the probe itself runs with.
|
|
49
|
+
* @returns the login PATH, or '' when it cannot be determined.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveLoginShellPath(options?: {
|
|
52
|
+
shell?: string | undefined;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
run?: ProbeRunner;
|
|
55
|
+
env?: NodeJS.ProcessEnv;
|
|
56
|
+
}): Promise<string>;
|
|
57
|
+
/** Cached, process-wide login-shell PATH (one probe per host lifetime). */
|
|
58
|
+
export declare function loginShellPath(): Promise<string>;
|
|
59
|
+
/** Drop the cache (tests, and settings reloads that may have changed `$SHELL`). */
|
|
60
|
+
export declare function resetLoginShellPathCache(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Environment for one local quick command.
|
|
63
|
+
*
|
|
64
|
+
* A copy of the host environment with the login-shell PATH merged in front, so
|
|
65
|
+
* Desktop-launched hosts see the same toolchain a terminal-launched host does.
|
|
66
|
+
* @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
|
|
67
|
+
* @returns the spawn environment.
|
|
68
|
+
*/
|
|
69
|
+
export declare function localCommandEnv(overrides?: Record<string, string>): Promise<NodeJS.ProcessEnv>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-quick-commands",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "DSH web plugin: per-workspace quick commands. Configure named shell commands per DSH workspace, run one from the session header with live stdout/stderr in a floating popup.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,27 +63,27 @@
|
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
66
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
69
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
70
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
71
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
72
|
-
"@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
|
|
73
73
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
74
74
|
"react": "^18.2.0",
|
|
75
75
|
"react-dom": "^18.2.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@deepseek-ai/dsh-client-locale": "
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-conversation": "
|
|
80
|
-
"@deepseek-ai/dsh-client-ui-primitives": "
|
|
81
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "
|
|
82
|
-
"@deepseek-ai/dsh-client-ui-slots": "
|
|
83
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
84
|
-
"@deepseek-ai/dsh-settings": "
|
|
85
|
-
"@deepseek-ai/dsh-subprocess": "
|
|
86
|
-
"@deepseek-ai/dsh-workspace": "
|
|
78
|
+
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "0.2.0-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
85
|
+
"@deepseek-ai/dsh-subprocess": "0.2.0-rc.1",
|
|
86
|
+
"@deepseek-ai/dsh-workspace": "0.2.0-rc.1",
|
|
87
87
|
"@deepseek-ai/schemastery": "^3.18.3",
|
|
88
88
|
"@types/node": "^22.0.0",
|
|
89
89
|
"@types/react": "~18.3.1",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
|
96
96
|
"build:client": "tsdown",
|
|
97
97
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
98
|
-
"test": "node --experimental-transform-types --test test/remote.test.ts test/settings-config.test.ts && node test/header-icons.test.mjs",
|
|
98
|
+
"test": "node --experimental-transform-types --test test/remote.test.ts test/settings-config.test.ts test/host-apply.test.ts test/shell-path.test.ts && node test/header-icons.test.mjs",
|
|
99
99
|
"bundle": "tsdown",
|
|
100
100
|
"watch": "tsdown --watch",
|
|
101
101
|
"release:patch": "npm version patch && git push && git push --tags",
|
package/src/index.ts
CHANGED
|
@@ -15,9 +15,8 @@
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { QuickCommandRunner, type RemoteBackend } from './server/runner.js'
|
|
17
17
|
import { readHostConfig } from './server/remote.js'
|
|
18
|
-
import { QuickCommandsSettingsSchema, registerSettings } from './server/settings.js'
|
|
18
|
+
import { QuickCommandsSettingsSchema, registerSettings, type QuickCommandsConfig } from './server/settings.js'
|
|
19
19
|
import { registerApiRoutes } from './server/rpc.js'
|
|
20
|
-
import type { QuickCommandsSettings } from './shared/contract.js'
|
|
21
20
|
|
|
22
21
|
export const name = '@fonlan/dsh-quick-commands'
|
|
23
22
|
|
|
@@ -30,39 +29,47 @@ export const inject = ['settings']
|
|
|
30
29
|
export const Config = QuickCommandsSettingsSchema
|
|
31
30
|
|
|
32
31
|
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
33
|
-
export function apply(ctx: Context, config:
|
|
32
|
+
export function apply(ctx: Context, config: QuickCommandsConfig): void {
|
|
34
33
|
const settings = registerSettings(ctx, config)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
// dsh >= 0.1.7 activates entries asynchronously: a fiber is only readable as a
|
|
35
|
+
// service once its own init settled, and the webserver's init waits for its
|
|
36
|
+
// listen. Resolving services with a one-shot `ctx.get()` during apply could
|
|
37
|
+
// therefore observe undefined with nothing to retry — the header menu's
|
|
38
|
+
// "HTTP 405": `subprocess` (and/or the route helper's own `webServer` guard)
|
|
39
|
+
// came back missing, the API prefix route was never registered, and every
|
|
40
|
+
// client POST fell through to the /plugins client-modules bundle route, which
|
|
41
|
+
// answers any non-GET with 405.
|
|
42
|
+
// ctx.inject is the retrying form: the body runs once both services are live,
|
|
43
|
+
// so the pairing also covers whichever one arrives after this entry.
|
|
44
|
+
ctx.inject(['subprocess', 'webServer'], (sctx) => {
|
|
45
|
+
const subprocess = sctx.get('subprocess')
|
|
46
|
+
// Remote backend (duck-typed over @dsh-ssh/dsh-ssh): resolved lazily so the
|
|
47
|
+
// sshPool service is picked up whenever the SSH plugin loads.
|
|
48
|
+
const resolveRemote = (): RemoteBackend | undefined => {
|
|
49
|
+
const sshPool = sctx.get('sshPool') as
|
|
50
|
+
| { acquire(cfg: { id: string; [key: string]: unknown }): Promise<unknown>; release(): void }
|
|
51
|
+
| undefined
|
|
52
|
+
if (sshPool === undefined || typeof sshPool.acquire !== 'function' || typeof sshPool.release !== 'function') {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
pool: sshPool as never,
|
|
57
|
+
resolveHost: (hostId) => readHostConfig((ns) => {
|
|
58
|
+
try {
|
|
59
|
+
return (sctx.get('settings') as { get(ns: string): unknown } | undefined)?.get(ns)
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}, hostId),
|
|
64
|
+
}
|
|
48
65
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
|
|
61
|
-
ctx.effect(() => registerApiRoutes(ctx, runner, settings), 'quick-commands: api routes')
|
|
62
|
-
ctx.effect(() => () => {
|
|
63
|
-
// Terminate every live process when the plugin fiber stops.
|
|
64
|
-
for (const id of runner.listAll()) {
|
|
65
|
-
runner.kill(id)
|
|
66
|
-
}
|
|
67
|
-
}, 'quick-commands: process cleanup')
|
|
66
|
+
const runner = new QuickCommandRunner(subprocess as never, () => resolveRemote())
|
|
67
|
+
sctx.effect(() => registerApiRoutes(sctx, runner, settings), 'quick-commands: api routes')
|
|
68
|
+
sctx.effect(() => () => {
|
|
69
|
+
// Terminate every live process when the plugin fiber stops.
|
|
70
|
+
for (const id of runner.listAll()) {
|
|
71
|
+
runner.kill(id)
|
|
72
|
+
}
|
|
73
|
+
}, 'quick-commands: process cleanup')
|
|
74
|
+
})
|
|
68
75
|
}
|
package/src/server/rpc.ts
CHANGED
|
@@ -255,10 +255,15 @@ export function registerApiRoutes(ctx: Context, runner: QuickRunner, settings: Q
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// The caller reaches this function through ctx.inject(['webServer']), so the
|
|
259
|
+
// service is active by construction. A one-shot `ctx.get('webServer')` guard
|
|
260
|
+
// here used to return an empty disposer whenever the webserver fiber had not
|
|
261
|
+
// finished activating yet (dsh >= 0.1.7 activates entries asynchronously and
|
|
262
|
+
// makes a service readable only once its own init settled — for the webserver
|
|
263
|
+
// that means listening): no route was ever registered and every client POST
|
|
264
|
+
// fell through to the /plugins bundle route, which answers non-GET with 405.
|
|
260
265
|
return ctx.effect(() => {
|
|
261
|
-
return webServer.register({
|
|
266
|
+
return ctx.webServer.register({
|
|
262
267
|
kind: 'prefix',
|
|
263
268
|
path: API_PREFIX,
|
|
264
269
|
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
package/src/server/runner.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { randomUUID } from 'node:crypto'
|
|
18
18
|
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
|
19
19
|
import type { QuickCommandsSettings } from '../shared/contract.js'
|
|
20
|
+
import { localCommandEnv } from './shell-path.js'
|
|
20
21
|
import {
|
|
21
22
|
localPathToRemoteRef,
|
|
22
23
|
resolveRemoteCwd,
|
|
@@ -203,12 +204,15 @@ export class QuickCommandRunner implements QuickRunner {
|
|
|
203
204
|
stdout: { maxBytes: TAIL_MAX_BYTES },
|
|
204
205
|
stderr: { maxBytes: TAIL_MAX_BYTES },
|
|
205
206
|
},
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
// The login-shell PATH rides in front of the host's own: DSH Desktop runs
|
|
208
|
+
// the host with launchd's minimal PATH, so `npm run typecheck` and
|
|
209
|
+
// `docker compose up -d` only resolve once the user's toolchain is
|
|
210
|
+
// visible. See ./shell-path.ts.
|
|
211
|
+
env: await localCommandEnv({
|
|
208
212
|
DSH_WORKSPACE: input.workspacePath,
|
|
209
213
|
DSH_WORKSPACE_ID: input.workspaceId,
|
|
210
214
|
DSH_WORKSPACE_TITLE: input.workspaceTitle,
|
|
211
|
-
},
|
|
215
|
+
}),
|
|
212
216
|
})
|
|
213
217
|
|
|
214
218
|
const live: LiveRun = {
|
package/src/server/settings.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* field therefore yields the handle object, which silently destroys the whole
|
|
11
11
|
* document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
|
|
12
12
|
* `.default('corner')` union arrives as the handle rather than a literal.
|
|
13
|
-
* Every read goes through {@link
|
|
13
|
+
* Every read goes through {@link liveValue} instead, which unwraps handles and
|
|
14
14
|
* still accepts plain values (hosts that resolve the config without the
|
|
15
15
|
* volatile wrapper).
|
|
16
16
|
*/
|
|
@@ -36,7 +36,52 @@ const WorkspaceCommandsSchema = z.object({
|
|
|
36
36
|
commands: z.array(CommandSchema).max(200),
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/** Live handle schemastery wraps around one `volatile()` config field. */
|
|
40
|
+
interface VolatileField {
|
|
41
|
+
get(): unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The entry config in its RESOLVED form: schemastery hands every `.volatile()`
|
|
46
|
+
* field to the loader (and so to `apply()`) as a live handle instead of the
|
|
47
|
+
* plain value. This is the schema's real output type.
|
|
48
|
+
*/
|
|
49
|
+
export interface QuickCommandsLiveSettings {
|
|
50
|
+
workspaces: VolatileField
|
|
51
|
+
popupAnchor: VolatileField
|
|
52
|
+
popupSize: VolatileField
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The entry config as `apply()` receives it: the resolved (live) document, or
|
|
57
|
+
* the plain document in hand-built contexts and hosts that resolve the config
|
|
58
|
+
* without the volatile wrapper. Both load through the same `apply`.
|
|
59
|
+
*/
|
|
60
|
+
export type QuickCommandsConfig = QuickCommandsSettings | QuickCommandsLiveSettings
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Entry config schema, and the schema dsh derives the plugin's settings page
|
|
64
|
+
* from. The `z<...>` annotation names the RESOLVED (live-handle) shape on
|
|
65
|
+
* purpose:
|
|
66
|
+
*
|
|
67
|
+
* - schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
|
|
68
|
+
* `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }`
|
|
69
|
+
* cell), never `T` — the old `z<QuickCommandsSettings>` annotation stopped
|
|
70
|
+
* compiling;
|
|
71
|
+
* - dropping the annotation and letting the type be inferred does not build
|
|
72
|
+
* either: declaration emit then cannot portably name the inferred type
|
|
73
|
+
* (TS2742), because `z.array(...)` leaks `@deepseek-ai/cosmokit`'s `Dict`
|
|
74
|
+
* into the schema type while the program holds two schemastery copies
|
|
75
|
+
* (this plugin's 3.18.3, and the one `@deepseek-ai/dsh-host-webserver`
|
|
76
|
+
* resolves — see pnpm-workspace.yaml's minimumReleaseAgeExclude, which pins
|
|
77
|
+
* the former), and cosmokit itself is not nameable from this package.
|
|
78
|
+
*
|
|
79
|
+
* The annotation is type-only. At runtime all three fields are still declared
|
|
80
|
+
* `volatile()` — that is what makes them writable by the settings plane (its
|
|
81
|
+
* `write()` refuses an entry whose form has no volatile field) and what keeps
|
|
82
|
+
* the auto-generated page working.
|
|
83
|
+
*/
|
|
84
|
+
export const QuickCommandsSettingsSchema: z<QuickCommandsSettings, QuickCommandsLiveSettings> = z.object({
|
|
40
85
|
// Values are addressed by index (array edits), so the whole array is the
|
|
41
86
|
// volatile handle the settings plane swaps in place.
|
|
42
87
|
workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
|
|
@@ -51,22 +96,22 @@ export const QuickCommandsSettingsSchema: z<QuickCommandsSettings> = z.object({
|
|
|
51
96
|
}).volatile(),
|
|
52
97
|
})
|
|
53
98
|
|
|
54
|
-
/** Live handle schemastery wraps around one `volatile()` config field. */
|
|
55
|
-
interface VolatileField<T> {
|
|
56
|
-
get(): T
|
|
57
|
-
}
|
|
58
|
-
|
|
59
99
|
/**
|
|
60
|
-
* Read
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
100
|
+
* Read a config node that may be a plain value or a live (`.volatile()`) node.
|
|
101
|
+
*
|
|
102
|
+
* A plugin whose Config declares volatile fields receives a reactive config
|
|
103
|
+
* whose fields expose `get()`, while hand-written config objects and tests pass
|
|
104
|
+
* plain values. Both shapes are supported here so the same code path serves the
|
|
105
|
+
* settings page, `cordis.patch.yml`, and the unit tests.
|
|
64
106
|
*/
|
|
65
|
-
function
|
|
66
|
-
if (
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
function liveValue<T>(node: unknown, fallback: T): T {
|
|
108
|
+
if (node === undefined || node === null) return fallback
|
|
109
|
+
const getter = (node as { get?: unknown }).get
|
|
110
|
+
if (typeof getter === 'function') {
|
|
111
|
+
const value = (getter as () => unknown).call(node)
|
|
112
|
+
return value === undefined ? fallback : (value as T)
|
|
113
|
+
}
|
|
114
|
+
return node as T
|
|
70
115
|
}
|
|
71
116
|
|
|
72
117
|
/** Structural settings-service subset used for persistence. */
|
|
@@ -87,15 +132,15 @@ export interface QuickCommandsSettingsFace {
|
|
|
87
132
|
setPopupSize(size: QuickPopupSize): Promise<void>
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
export function registerSettings(ctx: Context, config:
|
|
135
|
+
export function registerSettings(ctx: Context, config: QuickCommandsConfig): QuickCommandsSettingsFace {
|
|
91
136
|
const readWorkspaces = (): WorkspaceCommands[] => {
|
|
92
|
-
const value =
|
|
137
|
+
const value = liveValue<unknown>(config?.workspaces, [])
|
|
93
138
|
return Array.isArray(value) ? value as WorkspaceCommands[] : []
|
|
94
139
|
}
|
|
95
140
|
const readAnchor = (): QuickCommandsSettings['popupAnchor'] =>
|
|
96
|
-
(
|
|
141
|
+
(liveValue<unknown>(config?.popupAnchor, 'corner') === 'button' ? 'button' : 'corner')
|
|
97
142
|
const readPopupSize = (): QuickPopupSize | undefined => {
|
|
98
|
-
const value =
|
|
143
|
+
const value = liveValue<unknown>(config?.popupSize, undefined)
|
|
99
144
|
if (typeof value !== 'object' || value === null) return undefined
|
|
100
145
|
const { width, height } = value as { width?: unknown; height?: unknown }
|
|
101
146
|
// `{}` (never resized) and any partially written size read as absent.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login-shell PATH resolution for the local quick-command backend.
|
|
3
|
+
*
|
|
4
|
+
* `dsh web` started from a terminal inherits the user's full PATH, so a
|
|
5
|
+
* configured command such as `npm run typecheck` or `docker compose up -d`
|
|
6
|
+
* finds its toolchain. **DSH Desktop does not**: the Electron host is launched
|
|
7
|
+
* by launchd and runs with the minimal system PATH
|
|
8
|
+
* (`/usr/bin:/bin:/usr/sbin:/sbin`), which hides homebrew, `/usr/local/bin`,
|
|
9
|
+
* nvm, corepack and Docker Desktop. Every command that leans on the user's
|
|
10
|
+
* toolchain then fails with "command not found" in Desktop while the very same
|
|
11
|
+
* profile keeps working in a browser-launched host.
|
|
12
|
+
*
|
|
13
|
+
* This module asks the user's interactive login shell for its PATH once
|
|
14
|
+
* (cached for the process lifetime) and prepends it to the inherited PATH for
|
|
15
|
+
* local runs. Resolution is best effort by design: a missing `$SHELL`, a
|
|
16
|
+
* timeout, a non-zero exit or unparseable output all leave the inherited PATH
|
|
17
|
+
* untouched, so a terminal-launched host behaves exactly as before.
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process'
|
|
20
|
+
|
|
21
|
+
/** Delimiters wrapping the shell's own PATH in the probe's stdout. */
|
|
22
|
+
export const PATH_START = '__DSH_QC_PATH_START__'
|
|
23
|
+
export const PATH_END = '__DSH_QC_PATH_END__'
|
|
24
|
+
|
|
25
|
+
/** Default deadline for the login-shell probe (ms). */
|
|
26
|
+
export const PROBE_TIMEOUT_MS = 3000
|
|
27
|
+
|
|
28
|
+
/** Minimal probe runner shape: `child_process.execFile`, or a test double. */
|
|
29
|
+
export type ProbeRunner = (
|
|
30
|
+
file: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
options: { timeout: number; windowsHide: boolean; env: NodeJS.ProcessEnv },
|
|
33
|
+
) => Promise<{ stdout?: string | Buffer }>
|
|
34
|
+
|
|
35
|
+
const defaultRunner: ProbeRunner = (file, args, options) =>
|
|
36
|
+
new Promise((resolve, reject) => {
|
|
37
|
+
execFile(file, args, options, (error, stdout) => {
|
|
38
|
+
if (error) reject(error)
|
|
39
|
+
else resolve({ stdout })
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract the PATH a shell printed between the markers.
|
|
45
|
+
*
|
|
46
|
+
* Login profiles are free to print their own noise (nvm notices, version
|
|
47
|
+
* managers, MOTDs), so only the marked span counts — never the whole stdout.
|
|
48
|
+
* @param stdout - raw probe stdout.
|
|
49
|
+
* @returns the marked PATH, or '' when the markers are missing.
|
|
50
|
+
*/
|
|
51
|
+
export function extractShellPath(stdout: unknown): string {
|
|
52
|
+
if (typeof stdout !== 'string' && !(stdout instanceof Buffer)) return ''
|
|
53
|
+
const text = String(stdout)
|
|
54
|
+
const start = text.indexOf(PATH_START)
|
|
55
|
+
const end = text.lastIndexOf(PATH_END)
|
|
56
|
+
if (start < 0 || end < 0 || end < start) return ''
|
|
57
|
+
return text.slice(start + PATH_START.length, end)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Put the login-shell PATH in front of the inherited one.
|
|
62
|
+
*
|
|
63
|
+
* The login PATH wins (that is where the user's toolchain lives); entries from
|
|
64
|
+
* the inherited PATH stay behind it as a fallback. Empty entries are dropped
|
|
65
|
+
* and duplicates keep their first position.
|
|
66
|
+
* @param loginPath - PATH reported by the login shell ('' when unavailable).
|
|
67
|
+
* @param inheritedPath - the host process's own PATH.
|
|
68
|
+
* @returns the merged PATH (the inherited value when there is no login one).
|
|
69
|
+
*/
|
|
70
|
+
export function mergePaths(loginPath: string, inheritedPath: string | undefined): string {
|
|
71
|
+
const seen = new Set<string>()
|
|
72
|
+
const entries: string[] = []
|
|
73
|
+
for (const part of `${loginPath}:${inheritedPath ?? ''}`.split(':')) {
|
|
74
|
+
const entry = part.trim()
|
|
75
|
+
if (entry === '' || seen.has(entry)) continue
|
|
76
|
+
seen.add(entry)
|
|
77
|
+
entries.push(entry)
|
|
78
|
+
}
|
|
79
|
+
return entries.join(':')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The shell program whose login PATH describes this user's environment. */
|
|
83
|
+
export function loginShell(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
84
|
+
const shell = env.SHELL?.trim()
|
|
85
|
+
return shell !== undefined && shell !== '' ? shell : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ask `shell` (interactive login) for its PATH.
|
|
90
|
+
*
|
|
91
|
+
* Both flags matter: `-l` sources the profile files that own the system PATH
|
|
92
|
+
* (on macOS `/etc/zprofile` → `path_helper`), `-i` additionally sources the
|
|
93
|
+
* interactive config where nvm/fnm/volta and other version managers install
|
|
94
|
+
* their shims (this user's node lives in `~/.zshrc`). A hung or noisy
|
|
95
|
+
* interactive profile cannot block a run: the probe carries a deadline, and
|
|
96
|
+
* only the marked span of stdout is read.
|
|
97
|
+
* @param options.shell - shell program; defaults to `$SHELL`.
|
|
98
|
+
* @param options.timeoutMs - probe deadline.
|
|
99
|
+
* @param options.run - probe runner, injectable for tests.
|
|
100
|
+
* @param options.env - environment the probe itself runs with.
|
|
101
|
+
* @returns the login PATH, or '' when it cannot be determined.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveLoginShellPath(options: {
|
|
104
|
+
shell?: string | undefined
|
|
105
|
+
timeoutMs?: number
|
|
106
|
+
run?: ProbeRunner
|
|
107
|
+
env?: NodeJS.ProcessEnv
|
|
108
|
+
} = {}): Promise<string> {
|
|
109
|
+
if (process.platform === 'win32') return ''
|
|
110
|
+
const env = options.env ?? process.env
|
|
111
|
+
const shell = options.shell ?? loginShell(env)
|
|
112
|
+
if (shell === undefined) return ''
|
|
113
|
+
const run = options.run ?? defaultRunner
|
|
114
|
+
// `${PATH}` must stay braced: an unbraced `$PATH__DSH...` is read as one
|
|
115
|
+
// identifier by POSIX shells and silently expands to nothing.
|
|
116
|
+
const command = `printf '%s' "${PATH_START}\${PATH}${PATH_END}"`
|
|
117
|
+
try {
|
|
118
|
+
const { stdout } = await run(shell, ['-i', '-l', '-c', command], {
|
|
119
|
+
timeout: options.timeoutMs ?? PROBE_TIMEOUT_MS,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
env,
|
|
122
|
+
})
|
|
123
|
+
const path = extractShellPath(stdout).trim()
|
|
124
|
+
// A list-valued shell (fish) renders "$PATH" as space-separated entries:
|
|
125
|
+
// merging that would inject a single bogus entry, so keep the host PATH.
|
|
126
|
+
return /[\s]/.test(path) ? '' : path
|
|
127
|
+
} catch {
|
|
128
|
+
// Any failure (missing shell, timeout, killed profile) keeps the inherited
|
|
129
|
+
// PATH: a quick command must never be worse than the host's own shell.
|
|
130
|
+
return ''
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let cached: Promise<string> | undefined
|
|
135
|
+
|
|
136
|
+
/** Cached, process-wide login-shell PATH (one probe per host lifetime). */
|
|
137
|
+
export function loginShellPath(): Promise<string> {
|
|
138
|
+
cached ??= resolveLoginShellPath()
|
|
139
|
+
return cached
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drop the cache (tests, and settings reloads that may have changed `$SHELL`). */
|
|
143
|
+
export function resetLoginShellPathCache(): void {
|
|
144
|
+
cached = undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Environment for one local quick command.
|
|
149
|
+
*
|
|
150
|
+
* A copy of the host environment with the login-shell PATH merged in front, so
|
|
151
|
+
* Desktop-launched hosts see the same toolchain a terminal-launched host does.
|
|
152
|
+
* @param overrides - plugin-owned variables (DSH_WORKSPACE*) merged last.
|
|
153
|
+
* @returns the spawn environment.
|
|
154
|
+
*/
|
|
155
|
+
export async function localCommandEnv(overrides: Record<string, string> = {}): Promise<NodeJS.ProcessEnv> {
|
|
156
|
+
const env: NodeJS.ProcessEnv = { ...process.env }
|
|
157
|
+
const loginPath = await loginShellPath()
|
|
158
|
+
if (loginPath !== '') env.PATH = mergePaths(loginPath, process.env.PATH)
|
|
159
|
+
return { ...env, ...overrides }
|
|
160
|
+
}
|