@fonlan/dsh-quick-commands 0.1.6 → 0.1.8
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/client.js +138 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +206 -52
- package/lib/types/index.d.ts +4 -2
- package/lib/types/server/settings.d.ts +14 -4
- package/lib/types/server/shell-path.d.ts +69 -0
- package/lib/types/shared/contract.d.ts +7 -0
- package/package.json +30 -27
- package/src/client/header.tsx +15 -12
- package/src/client/icons.tsx +106 -0
- package/src/client/settings-section.tsx +6 -9
- package/src/index.ts +51 -36
- package/src/server/rpc.ts +8 -3
- package/src/server/runner.ts +7 -3
- package/src/server/settings.ts +74 -29
- package/src/server/shell-path.ts +160 -0
- package/src/shared/contract.ts +10 -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,
|
|
@@ -556,43 +689,67 @@ const WorkspaceCommandsSchema = z.object({
|
|
|
556
689
|
commands: z.array(CommandSchema).max(200)
|
|
557
690
|
});
|
|
558
691
|
const QuickCommandsSettingsSchema = z.object({
|
|
559
|
-
workspaces: z.array(WorkspaceCommandsSchema).max(500),
|
|
560
|
-
popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner"),
|
|
692
|
+
workspaces: z.array(WorkspaceCommandsSchema).max(500).volatile(),
|
|
693
|
+
popupAnchor: z.union([z.const("corner"), z.const("button")]).default("corner").volatile(),
|
|
561
694
|
popupSize: z.object({
|
|
562
695
|
width: z.number().min(320).max(8192),
|
|
563
696
|
height: z.number().min(200).max(8192)
|
|
564
|
-
})
|
|
697
|
+
}).volatile()
|
|
565
698
|
});
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
699
|
+
/**
|
|
700
|
+
* Read one config field. A volatile field arrives as a handle (dsh >= 0.1.7)
|
|
701
|
+
* and is read through `.get()` on EVERY access, so edits made through the
|
|
702
|
+
* settings plane are visible immediately; hosts that hand the plain value keep
|
|
703
|
+
* working unchanged.
|
|
704
|
+
*/
|
|
705
|
+
function readField(field, fallback) {
|
|
706
|
+
if (field === void 0 || field === null) return fallback;
|
|
707
|
+
const handle = field;
|
|
708
|
+
const value = typeof handle.get === "function" ? handle.get() : field;
|
|
709
|
+
return value === void 0 || value === null ? fallback : value;
|
|
710
|
+
}
|
|
711
|
+
function registerSettings(ctx, config) {
|
|
712
|
+
const readWorkspaces = () => {
|
|
713
|
+
const value = readField(config?.workspaces, []);
|
|
714
|
+
return Array.isArray(value) ? value : [];
|
|
715
|
+
};
|
|
716
|
+
const readAnchor = () => readField(config?.popupAnchor, "corner") === "button" ? "button" : "corner";
|
|
717
|
+
const readPopupSize = () => {
|
|
718
|
+
const value = readField(config?.popupSize, void 0);
|
|
719
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
720
|
+
const { width, height } = value;
|
|
721
|
+
if (typeof width !== "number" || typeof height !== "number") return void 0;
|
|
722
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return void 0;
|
|
723
|
+
return {
|
|
724
|
+
width: Math.round(width),
|
|
725
|
+
height: Math.round(height)
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
/** Current document, rebuilt from the live config fields on every read. */
|
|
729
|
+
const readDocument = () => {
|
|
730
|
+
const size = readPopupSize();
|
|
731
|
+
return {
|
|
732
|
+
workspaces: readWorkspaces(),
|
|
733
|
+
popupAnchor: readAnchor(),
|
|
734
|
+
...size === void 0 ? {} : { popupSize: size }
|
|
735
|
+
};
|
|
574
736
|
};
|
|
575
737
|
let service;
|
|
576
738
|
ctx.inject(["settings"], (sctx) => {
|
|
577
739
|
service = sctx.settings;
|
|
578
|
-
const scope = service.register(QUICK_COMMANDS_NS, QuickCommandsSettingsSchema);
|
|
579
|
-
current = scope.get();
|
|
580
|
-
scope.watch((next) => {
|
|
581
|
-
current = next;
|
|
582
|
-
});
|
|
583
740
|
});
|
|
584
741
|
async function persist(patch) {
|
|
585
742
|
if (service === void 0) throw new Error("@fonlan/dsh-quick-commands: settings service is not available in this profile");
|
|
586
743
|
await service.update(QUICK_COMMANDS_NS, patch);
|
|
587
744
|
}
|
|
588
745
|
return {
|
|
589
|
-
get:
|
|
746
|
+
get: readDocument,
|
|
590
747
|
commandsOf: (workspaceId) => {
|
|
591
|
-
const set =
|
|
748
|
+
const set = readWorkspaces().find((w) => w.workspaceId === workspaceId);
|
|
592
749
|
return set === void 0 ? [] : set.commands;
|
|
593
750
|
},
|
|
594
751
|
setCommands: async (workspaceId, commands) => {
|
|
595
|
-
const next =
|
|
752
|
+
const next = readWorkspaces().filter((w) => w.workspaceId !== workspaceId);
|
|
596
753
|
if (commands.length > 0) next.push({
|
|
597
754
|
workspaceId,
|
|
598
755
|
commands
|
|
@@ -837,10 +994,8 @@ function registerApiRoutes(ctx, runner, settings) {
|
|
|
837
994
|
};
|
|
838
995
|
}
|
|
839
996
|
}
|
|
840
|
-
const webServer = ctx.get("webServer");
|
|
841
|
-
if (webServer === void 0) return () => void 0;
|
|
842
997
|
return ctx.effect(() => {
|
|
843
|
-
return webServer.register({
|
|
998
|
+
return ctx.webServer.register({
|
|
844
999
|
kind: "prefix",
|
|
845
1000
|
path: API_PREFIX,
|
|
846
1001
|
handler: async (req, res) => {
|
|
@@ -871,33 +1026,32 @@ function registerApiRoutes(ctx, runner, settings) {
|
|
|
871
1026
|
//#region src/index.ts
|
|
872
1027
|
const name = "@fonlan/dsh-quick-commands";
|
|
873
1028
|
const inject = ["settings"];
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}, hostId)
|
|
1029
|
+
const Config = QuickCommandsSettingsSchema;
|
|
1030
|
+
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
1031
|
+
function apply(ctx, config) {
|
|
1032
|
+
const settings = registerSettings(ctx, config);
|
|
1033
|
+
ctx.inject(["subprocess", "webServer"], (sctx) => {
|
|
1034
|
+
const subprocess = sctx.get("subprocess");
|
|
1035
|
+
const resolveRemote = () => {
|
|
1036
|
+
const sshPool = sctx.get("sshPool");
|
|
1037
|
+
if (sshPool === void 0 || typeof sshPool.acquire !== "function" || typeof sshPool.release !== "function") return;
|
|
1038
|
+
return {
|
|
1039
|
+
pool: sshPool,
|
|
1040
|
+
resolveHost: (hostId) => readHostConfig((ns) => {
|
|
1041
|
+
try {
|
|
1042
|
+
return sctx.get("settings")?.get(ns);
|
|
1043
|
+
} catch {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
}, hostId)
|
|
1047
|
+
};
|
|
894
1048
|
};
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
1049
|
+
const runner = new QuickCommandRunner(subprocess, () => resolveRemote());
|
|
1050
|
+
sctx.effect(() => registerApiRoutes(sctx, runner, settings), "quick-commands: api routes");
|
|
1051
|
+
sctx.effect(() => () => {
|
|
1052
|
+
for (const id of runner.listAll()) runner.kill(id);
|
|
1053
|
+
}, "quick-commands: process cleanup");
|
|
1054
|
+
});
|
|
901
1055
|
}
|
|
902
1056
|
//#endregion
|
|
903
|
-
export { apply, inject, name };
|
|
1057
|
+
export { Config, apply, inject, name };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,7 +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 { QuickCommandsSettings } from './shared/contract.js';
|
|
16
17
|
export declare const name = "@fonlan/dsh-quick-commands";
|
|
17
18
|
export declare const inject: string[];
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
export declare const Config: import("@deepseek-ai/schemastery").default<QuickCommandsSettings>;
|
|
20
|
+
/** Configuration rides the entry config; edits persist through the settings service. */
|
|
21
|
+
export declare function apply(ctx: Context, config: QuickCommandsSettings): void;
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User-facing plugin settings (per-workspace command sets + popup anchor),
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* carried by this bundle's entry config: dsh >= 0.1.7 owns plugin settings as
|
|
4
|
+
* entry config (the plugin's `Config` schema) and persists edits through
|
|
5
|
+
* `settings.update`.
|
|
6
|
+
*
|
|
7
|
+
* All three fields are declared `volatile()` — that is what makes them
|
|
8
|
+
* writable by the settings plane — and schemastery hands a volatile field to
|
|
9
|
+
* `apply()` as a LIVE HANDLE (`{ get() }`), not as the value. Reading the raw
|
|
10
|
+
* field therefore yields the handle object, which silently destroys the whole
|
|
11
|
+
* document: `Array.isArray(handle)` is false, JSON renders `{}`, and a
|
|
12
|
+
* `.default('corner')` union arrives as the handle rather than a literal.
|
|
13
|
+
* Every read goes through {@link readField} instead, which unwraps handles and
|
|
14
|
+
* still accepts plain values (hosts that resolve the config without the
|
|
15
|
+
* volatile wrapper).
|
|
6
16
|
*/
|
|
7
17
|
import type { Context } from '@deepseek-ai/cordis';
|
|
8
18
|
import z from '@deepseek-ai/schemastery';
|
|
@@ -21,4 +31,4 @@ export interface QuickCommandsSettingsFace {
|
|
|
21
31
|
/** Persist the output popup size (drag-resize). */
|
|
22
32
|
setPopupSize(size: QuickPopupSize): Promise<void>;
|
|
23
33
|
}
|
|
24
|
-
export declare function registerSettings(ctx: Context): QuickCommandsSettingsFace;
|
|
34
|
+
export declare function registerSettings(ctx: Context, config: QuickCommandsSettings): QuickCommandsSettingsFace;
|
|
@@ -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>;
|
|
@@ -33,6 +33,13 @@ export interface QuickCommandsSettings {
|
|
|
33
33
|
/** Output popup size; absent until the user drags the resize handle (then per-anchor defaults apply). */
|
|
34
34
|
popupSize?: QuickPopupSize;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The document's anchor as a usable literal. Wire data is defensive by
|
|
38
|
+
* contract: an older host can still answer with schemastery's empty-object
|
|
39
|
+
* resolution (`{}`) for this field, which must never reach a CSS class name or
|
|
40
|
+
* a radio `checked` comparison.
|
|
41
|
+
*/
|
|
42
|
+
export declare function anchorOf(value: unknown): 'corner' | 'button';
|
|
36
43
|
/** Placeholder substitution values resolved for one execution. */
|
|
37
44
|
export interface QuickRunPlaceholders {
|
|
38
45
|
/** Workspace root path (also the execution cwd). */
|
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.8",
|
|
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": {
|
|
@@ -44,6 +44,18 @@
|
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
|
49
|
+
"build:client": "tsdown",
|
|
50
|
+
"prepare": "npm run build",
|
|
51
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
52
|
+
"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",
|
|
53
|
+
"bundle": "tsdown",
|
|
54
|
+
"watch": "tsdown --watch",
|
|
55
|
+
"release:patch": "npm version patch && git push && git push --tags",
|
|
56
|
+
"release:minor": "npm version minor && git push && git push --tags",
|
|
57
|
+
"release:major": "npm version major && git push && git push --tags"
|
|
58
|
+
},
|
|
47
59
|
"dsh": {
|
|
48
60
|
"bundle": {
|
|
49
61
|
"patch": "./cordis.patch.yml"
|
|
@@ -63,42 +75,33 @@
|
|
|
63
75
|
},
|
|
64
76
|
"peerDependencies": {
|
|
65
77
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
78
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
81
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
66
82
|
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
67
83
|
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
68
84
|
"@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
|
|
69
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
70
85
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
71
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
74
86
|
"react": "^18.2.0",
|
|
75
87
|
"react-dom": "^18.2.0"
|
|
76
88
|
},
|
|
77
89
|
"devDependencies": {
|
|
90
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
|
|
92
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
93
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-alpha.2",
|
|
94
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
95
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
96
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
97
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
98
|
+
"@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
|
|
99
|
+
"@deepseek-ai/schemastery": "^3.18.3",
|
|
78
100
|
"@types/node": "^22.0.0",
|
|
79
101
|
"@types/react": "~18.3.1",
|
|
80
102
|
"@types/react-dom": "~18.3.0",
|
|
81
103
|
"tsdown": "^0.22.14",
|
|
82
|
-
"typescript": "^5.9.0"
|
|
83
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
84
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
85
|
-
"@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
|
|
86
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
|
|
87
|
-
"@deepseek-ai/schemastery": "^3.18.2",
|
|
88
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
89
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
90
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
91
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-alpha.2",
|
|
92
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2"
|
|
104
|
+
"typescript": "^5.9.0"
|
|
93
105
|
},
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
"build:client": "tsdown",
|
|
97
|
-
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
98
|
-
"bundle": "tsdown",
|
|
99
|
-
"watch": "tsdown --watch",
|
|
100
|
-
"release:patch": "npm version patch && git push && git push --tags",
|
|
101
|
-
"release:minor": "npm version minor && git push && git push --tags",
|
|
102
|
-
"release:major": "npm version major && git push && git push --tags"
|
|
103
|
-
}
|
|
104
|
-
}
|
|
106
|
+
"packageManager": "pnpm@11.21.0"
|
|
107
|
+
}
|
package/src/client/header.tsx
CHANGED
|
@@ -8,20 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
10
10
|
import { createPortal } from 'react-dom'
|
|
11
|
-
import {
|
|
12
|
-
IconPlayOutline16,
|
|
13
|
-
IconCloseOutline16,
|
|
14
|
-
IconStopFill16,
|
|
15
|
-
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
|
+
import { PlayIcon, CloseIcon, StopIcon } from './icons'
|
|
16
12
|
import { quickApi } from './api'
|
|
17
13
|
import { useRunPopover, openMenu, closeMenu, openRun, closeRun } from './run-state'
|
|
18
|
-
import type
|
|
14
|
+
import { anchorOf, type QuickPopupSize } from '../shared/contract'
|
|
19
15
|
|
|
20
16
|
/** Workspace row shape the standard useWorkspaces hook exposes. */
|
|
21
17
|
interface WorkspaceRow {
|
|
22
18
|
workspaceId: string
|
|
23
19
|
path: string
|
|
24
20
|
title: string
|
|
21
|
+
/** Session membership; the canonical link DSH itself resolves Workspaces by. */
|
|
22
|
+
sessionIds?: string[]
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
/** Session list state subset we read (cwd + id). */
|
|
@@ -91,7 +89,12 @@ function useCurrentWorkspace(
|
|
|
91
89
|
): { workspace: WorkspaceRow | undefined; cwd: string | undefined } {
|
|
92
90
|
const row = useSessions?.((s) => s.byId[sessionId]) as SessionRow | undefined
|
|
93
91
|
const workspaces = useWorkspaces?.((s) => s.items) as WorkspaceRow[] | undefined
|
|
94
|
-
|
|
92
|
+
const items = workspaces ?? []
|
|
93
|
+
// Membership first (how DSH core resolves a session's Workspace, and the only
|
|
94
|
+
// link that survives when a session runs outside the Workspace root), then
|
|
95
|
+
// the longest cwd prefix as the fallback for hosts without membership data.
|
|
96
|
+
const byMembership = items.find((ws) => ws.sessionIds?.includes(sessionId) === true)
|
|
97
|
+
return { workspace: byMembership ?? matchWorkspace(row?.cwd, items), cwd: row?.cwd }
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/** Header icon button + command menu + live output popup. */
|
|
@@ -159,7 +162,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
|
|
|
159
162
|
const own = entries.find((e) => e.workspaceId === workspace?.workspaceId)
|
|
160
163
|
setCommands(own === undefined ? [] : own.commands)
|
|
161
164
|
setMenuRemoteHost(own?.remoteHost ?? null)
|
|
162
|
-
setAnchor(settings.popupAnchor)
|
|
165
|
+
setAnchor(anchorOf(settings.popupAnchor))
|
|
163
166
|
setPopupSize(settings.popupSize)
|
|
164
167
|
setMenuLoadError(null)
|
|
165
168
|
} catch (e) {
|
|
@@ -201,7 +204,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
|
|
|
201
204
|
disabled={workspace === undefined}
|
|
202
205
|
onClick={() => { popover.menuOpen ? closeMenu() : openMenu() }}
|
|
203
206
|
>
|
|
204
|
-
<
|
|
207
|
+
<PlayIcon size={15} />
|
|
205
208
|
</button>
|
|
206
209
|
|
|
207
210
|
{popover.menuOpen && workspace !== undefined && menuPosition !== null && createPortal(
|
|
@@ -216,7 +219,7 @@ export function QuickCommandsHeaderAction(props: HeaderProps): JSX.Element {
|
|
|
216
219
|
{menuRemoteHost !== null && <span className="qc-menu-remote" title={`SSH · ${menuRemoteHost}`}>SSH</span>}
|
|
217
220
|
<span className="qc-menu-ws">{workspace.title}</span>
|
|
218
221
|
<button type="button" className="qc-menu-close" aria-label={t('runClose')} onClick={closeMenu}>
|
|
219
|
-
<
|
|
222
|
+
<CloseIcon size={12} />
|
|
220
223
|
</button>
|
|
221
224
|
</div>
|
|
222
225
|
{commands.length === 0 && (
|
|
@@ -495,7 +498,7 @@ function RunPopup(props: {
|
|
|
495
498
|
aria-label={t('runKill')}
|
|
496
499
|
onClick={() => void kill()}
|
|
497
500
|
>
|
|
498
|
-
<
|
|
501
|
+
<StopIcon size={14} />
|
|
499
502
|
</button>
|
|
500
503
|
<button
|
|
501
504
|
type="button"
|
|
@@ -505,7 +508,7 @@ function RunPopup(props: {
|
|
|
505
508
|
onPointerUp={onClosePointerUp}
|
|
506
509
|
onClick={closeOutput}
|
|
507
510
|
>
|
|
508
|
-
<
|
|
511
|
+
<CloseIcon size={14} />
|
|
509
512
|
</button>
|
|
510
513
|
</header>
|
|
511
514
|
|