@tt-a1i/hive 1.1.5 → 1.2.0
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/CHANGELOG.md +24 -0
- package/dist/src/server/app.d.ts +3 -1
- package/dist/src/server/app.js +3 -1
- package/dist/src/server/open-target-commands.d.ts +53 -0
- package/dist/src/server/open-target-commands.js +193 -0
- package/dist/src/server/route-types.d.ts +6 -0
- package/dist/src/server/routes-open-workspace.d.ts +2 -0
- package/dist/src/server/routes-open-workspace.js +47 -0
- package/dist/src/server/routes.js +2 -0
- package/dist/src/shared/open-targets.d.ts +20 -0
- package/dist/src/shared/open-targets.js +43 -0
- package/package.json +2 -2
- package/web/dist/assets/index-BgXxFsKj.css +1 -0
- package/web/dist/assets/index-VeKhgpe_.js +66 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BHCSrZ_0.js +0 -66
- package/web/dist/assets/index-BUjVAMN8.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.2.0 - 2026-05-18
|
|
6
|
+
|
|
7
|
+
Opens the active workspace in your editor, terminal, or file manager from
|
|
8
|
+
Hive's topbar.
|
|
9
|
+
|
|
10
|
+
- Adds an "Open" split button to the topbar that launches the active workspace
|
|
11
|
+
in a chosen application. Ten targets on macOS (VS Code, VS Code Insiders,
|
|
12
|
+
Cursor, Windsurf, Finder, Terminal, iTerm2, Ghostty, IntelliJ IDEA, Zed) and
|
|
13
|
+
six on Windows / Linux (VS Code, VS Code Insiders, Cursor, Windsurf, File
|
|
14
|
+
Explorer / File Manager, Zed).
|
|
15
|
+
- Persists the preferred target per browser via `localStorage` so the next
|
|
16
|
+
click jumps to the same app. Stale preferences for apps that aren't valid on
|
|
17
|
+
the current platform fall back to the OS file manager instead of erroring.
|
|
18
|
+
- Surfaces failures as localized toast notifications. Distinguishes
|
|
19
|
+
"app not installed", "launcher not on PATH", and other failure modes so a
|
|
20
|
+
missing Cursor install reads differently from a misconfigured `code` CLI.
|
|
21
|
+
- Backend launches each command via `execFile` with an argv array — no shell
|
|
22
|
+
is involved, so workspace paths containing spaces, Unicode, or quotes pass
|
|
23
|
+
through verbatim. Paths containing newlines or NUL bytes are rejected before
|
|
24
|
+
dispatch.
|
|
25
|
+
- Special-cases Windows `explorer.exe`, which returns exit code 1 even on
|
|
26
|
+
success: spawn-errors are still surfaced, but a non-zero exit no longer
|
|
27
|
+
shows a spurious toast.
|
|
28
|
+
|
|
5
29
|
## 1.1.5 - 2026-05-18
|
|
6
30
|
|
|
7
31
|
Custom startup command and close-guard fixes.
|
package/dist/src/server/app.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import { type PickFolderResponse } from './fs-pick-folder.js';
|
|
3
|
+
import type { OpenWorkspaceService } from './route-types.js';
|
|
3
4
|
import type { RuntimeStore } from './runtime-store.js';
|
|
4
5
|
import { type TasksFileService } from './tasks-file.js';
|
|
5
6
|
import { type VersionService } from './version-service.js';
|
|
6
7
|
interface CreateAppOptions {
|
|
7
8
|
store: RuntimeStore;
|
|
8
9
|
pickFolderService?: () => Promise<PickFolderResponse>;
|
|
10
|
+
openWorkspaceService?: OpenWorkspaceService;
|
|
9
11
|
tasksFileService?: TasksFileService;
|
|
10
12
|
versionService?: VersionService;
|
|
11
13
|
}
|
|
12
|
-
export declare const createApp: ({ store, pickFolderService, tasksFileService, versionService, }: CreateAppOptions) => {
|
|
14
|
+
export declare const createApp: ({ store, pickFolderService, openWorkspaceService, tasksFileService, versionService, }: CreateAppOptions) => {
|
|
13
15
|
server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
14
16
|
store: RuntimeStore;
|
|
15
17
|
};
|
package/dist/src/server/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { pickFolder } from './fs-pick-folder.js';
|
|
7
7
|
import { HttpError } from './http-errors.js';
|
|
8
8
|
import { assertLocalRequest } from './local-request-guard.js';
|
|
9
|
+
import { openWorkspace } from './open-target-commands.js';
|
|
9
10
|
import { matchRoute } from './routes.js';
|
|
10
11
|
import { createTasksFileService } from './tasks-file.js';
|
|
11
12
|
import { createTerminalWebSocketServer } from './terminal-ws-server.js';
|
|
@@ -75,7 +76,7 @@ const sendJson = (response, statusCode, body) => {
|
|
|
75
76
|
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
76
77
|
response.end(JSON.stringify(body));
|
|
77
78
|
};
|
|
78
|
-
export const createApp = ({ store, pickFolderService = pickFolder, tasksFileService = createTasksFileService(), versionService = createVersionService(), }) => {
|
|
79
|
+
export const createApp = ({ store, pickFolderService = pickFolder, openWorkspaceService = (input) => openWorkspace(input), tasksFileService = createTasksFileService(), versionService = createVersionService(), }) => {
|
|
79
80
|
const staticDir = process.env.HIVE_STATIC_DIR ?? getDefaultStaticDir();
|
|
80
81
|
const staticAvailablePromise = canServeStatic(staticDir);
|
|
81
82
|
const server = createServer(async (request, response) => {
|
|
@@ -91,6 +92,7 @@ export const createApp = ({ store, pickFolderService = pickFolder, tasksFileServ
|
|
|
91
92
|
store,
|
|
92
93
|
tasksFileService,
|
|
93
94
|
pickFolderService,
|
|
95
|
+
openWorkspaceService,
|
|
94
96
|
versionService,
|
|
95
97
|
params: match.params,
|
|
96
98
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ExecFileOptions } from 'node:child_process';
|
|
2
|
+
import { type OpenTargetId, type OpenTargetPlatform, type OpenWorkspaceErrorCode } from '../shared/open-targets.js';
|
|
3
|
+
export type { OpenTargetId, OpenTargetPlatform, OpenWorkspaceErrorCode, } from '../shared/open-targets.js';
|
|
4
|
+
export { getEffectiveOpenTargetId, isOpenTargetId, isOpenTargetSupported, OPEN_TARGET_IDS_BY_PLATFORM, } from '../shared/open-targets.js';
|
|
5
|
+
export declare const resolveOpenTargetPlatform: (platform: NodeJS.Platform) => OpenTargetPlatform;
|
|
6
|
+
export interface OpenAttempt {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Returns the ordered list of commands to try. First success wins; remaining
|
|
12
|
+
* entries are fallbacks (e.g. IntelliJ IDEA → IntelliJ IDEA CE on older Macs).
|
|
13
|
+
* Empty list means the requested target is unsupported on this platform —
|
|
14
|
+
* callers should have already routed through `getEffectiveOpenTargetId` to
|
|
15
|
+
* fall back, so this should never happen in practice.
|
|
16
|
+
*/
|
|
17
|
+
export declare const buildOpenAttempts: (targetId: OpenTargetId, path: string, platform: OpenTargetPlatform) => OpenAttempt[];
|
|
18
|
+
export interface OpenCommandSuccess {
|
|
19
|
+
ok: true;
|
|
20
|
+
effectiveTargetId: OpenTargetId;
|
|
21
|
+
}
|
|
22
|
+
export interface OpenCommandFailure {
|
|
23
|
+
ok: false;
|
|
24
|
+
effectiveTargetId: OpenTargetId;
|
|
25
|
+
errorCode: OpenWorkspaceErrorCode;
|
|
26
|
+
stderr: string;
|
|
27
|
+
}
|
|
28
|
+
export type OpenCommandResult = OpenCommandSuccess | OpenCommandFailure;
|
|
29
|
+
interface SpawnResult {
|
|
30
|
+
stderr: string;
|
|
31
|
+
stdout: string;
|
|
32
|
+
status: number | null;
|
|
33
|
+
signal: string | null;
|
|
34
|
+
spawnError: NodeJS.ErrnoException | null;
|
|
35
|
+
}
|
|
36
|
+
export type RunOpenCommand = (command: string, args: string[], options: ExecFileOptions) => Promise<SpawnResult>;
|
|
37
|
+
export interface OpenWorkspaceInput {
|
|
38
|
+
path: string;
|
|
39
|
+
targetId: OpenTargetId;
|
|
40
|
+
}
|
|
41
|
+
export interface OpenWorkspaceOptions {
|
|
42
|
+
platform?: NodeJS.Platform;
|
|
43
|
+
runCommand?: RunOpenCommand;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Workspace paths originate from the OS folder picker or from manual paste;
|
|
47
|
+
* the picker output is sandbox-validated at create time, but a path stored
|
|
48
|
+
* before path-validation existed (or one pasted into a hypothetical migration
|
|
49
|
+
* future) could contain `\n` / `\0`. Reject those here so we never hand an
|
|
50
|
+
* ambiguous path to `xdg-open`, where shell wrappers split on newline.
|
|
51
|
+
*/
|
|
52
|
+
export declare const isOpenWorkspacePathSafe: (path: string) => boolean;
|
|
53
|
+
export declare const openWorkspace: (input: OpenWorkspaceInput, options?: OpenWorkspaceOptions) => Promise<OpenCommandResult>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getDefaultOpenTargetIdForPlatform, getEffectiveOpenTargetId, isOpenTargetId, } from '../shared/open-targets.js';
|
|
3
|
+
export { getEffectiveOpenTargetId, isOpenTargetId, isOpenTargetSupported, OPEN_TARGET_IDS_BY_PLATFORM, } from '../shared/open-targets.js';
|
|
4
|
+
export const resolveOpenTargetPlatform = (platform) => {
|
|
5
|
+
if (platform === 'darwin')
|
|
6
|
+
return 'mac';
|
|
7
|
+
if (platform === 'win32')
|
|
8
|
+
return 'windows';
|
|
9
|
+
if (platform === 'linux')
|
|
10
|
+
return 'linux';
|
|
11
|
+
return 'other';
|
|
12
|
+
};
|
|
13
|
+
const macAttempts = (targetId, path) => {
|
|
14
|
+
switch (targetId) {
|
|
15
|
+
case 'finder':
|
|
16
|
+
return [{ command: 'open', args: [path] }];
|
|
17
|
+
case 'vscode':
|
|
18
|
+
return [{ command: 'open', args: ['-a', 'Visual Studio Code', path] }];
|
|
19
|
+
case 'vscode-insiders':
|
|
20
|
+
return [{ command: 'open', args: ['-a', 'Visual Studio Code - Insiders', path] }];
|
|
21
|
+
case 'cursor':
|
|
22
|
+
return [{ command: 'open', args: ['-a', 'Cursor', path] }];
|
|
23
|
+
case 'windsurf':
|
|
24
|
+
return [{ command: 'open', args: ['-a', 'Windsurf', path] }];
|
|
25
|
+
case 'terminal':
|
|
26
|
+
return [{ command: 'open', args: ['-a', 'Terminal', path] }];
|
|
27
|
+
case 'iterm2':
|
|
28
|
+
// Bundle name has always been `iTerm.app` even after the iTerm2 rename;
|
|
29
|
+
// the `iTerm2` fallback in earlier ports is cargo-cult.
|
|
30
|
+
return [{ command: 'open', args: ['-a', 'iTerm', path] }];
|
|
31
|
+
case 'ghostty':
|
|
32
|
+
return [{ command: 'open', args: ['-a', 'Ghostty', path] }];
|
|
33
|
+
case 'intellijidea':
|
|
34
|
+
// 2025.3 unified the editions, but installs predating that still ship
|
|
35
|
+
// `IntelliJ IDEA CE.app` — retry once on the CE bundle.
|
|
36
|
+
return [
|
|
37
|
+
{ command: 'open', args: ['-a', 'IntelliJ IDEA', path] },
|
|
38
|
+
{ command: 'open', args: ['-a', 'IntelliJ IDEA CE', path] },
|
|
39
|
+
];
|
|
40
|
+
case 'zed':
|
|
41
|
+
return [{ command: 'open', args: ['-a', 'Zed', path] }];
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const linuxAttempts = (targetId, path) => {
|
|
45
|
+
switch (targetId) {
|
|
46
|
+
case 'finder':
|
|
47
|
+
return [{ command: 'xdg-open', args: [path] }];
|
|
48
|
+
case 'vscode':
|
|
49
|
+
return [{ command: 'code', args: [path] }];
|
|
50
|
+
case 'vscode-insiders':
|
|
51
|
+
return [{ command: 'code-insiders', args: [path] }];
|
|
52
|
+
case 'cursor':
|
|
53
|
+
return [{ command: 'cursor', args: [path] }];
|
|
54
|
+
case 'windsurf':
|
|
55
|
+
return [{ command: 'windsurf', args: [path] }];
|
|
56
|
+
case 'zed':
|
|
57
|
+
return [{ command: 'zed', args: [path] }];
|
|
58
|
+
default:
|
|
59
|
+
return [{ command: 'xdg-open', args: [path] }];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const windowsAttempts = (targetId, path) => {
|
|
63
|
+
switch (targetId) {
|
|
64
|
+
case 'finder':
|
|
65
|
+
return [{ command: 'explorer', args: [path] }];
|
|
66
|
+
case 'vscode':
|
|
67
|
+
return [{ command: 'code', args: [path] }];
|
|
68
|
+
case 'vscode-insiders':
|
|
69
|
+
return [{ command: 'code-insiders', args: [path] }];
|
|
70
|
+
case 'cursor':
|
|
71
|
+
return [{ command: 'cursor', args: [path] }];
|
|
72
|
+
case 'windsurf':
|
|
73
|
+
return [{ command: 'windsurf', args: [path] }];
|
|
74
|
+
case 'zed':
|
|
75
|
+
return [{ command: 'zed', args: [path] }];
|
|
76
|
+
default:
|
|
77
|
+
return [{ command: 'explorer', args: [path] }];
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Returns the ordered list of commands to try. First success wins; remaining
|
|
82
|
+
* entries are fallbacks (e.g. IntelliJ IDEA → IntelliJ IDEA CE on older Macs).
|
|
83
|
+
* Empty list means the requested target is unsupported on this platform —
|
|
84
|
+
* callers should have already routed through `getEffectiveOpenTargetId` to
|
|
85
|
+
* fall back, so this should never happen in practice.
|
|
86
|
+
*/
|
|
87
|
+
export const buildOpenAttempts = (targetId, path, platform) => {
|
|
88
|
+
const effectiveTargetId = getEffectiveOpenTargetId(targetId, platform);
|
|
89
|
+
if (platform === 'mac')
|
|
90
|
+
return macAttempts(effectiveTargetId, path);
|
|
91
|
+
if (platform === 'linux')
|
|
92
|
+
return linuxAttempts(effectiveTargetId, path);
|
|
93
|
+
if (platform === 'windows')
|
|
94
|
+
return windowsAttempts(effectiveTargetId, path);
|
|
95
|
+
return [{ command: 'open', args: [path] }];
|
|
96
|
+
};
|
|
97
|
+
const defaultRunOpenCommand = (command, args, options) => new Promise((resolve) => {
|
|
98
|
+
const child = execFile(command, args, options, (error, stdout, stderr) => {
|
|
99
|
+
const errno = error;
|
|
100
|
+
resolve({
|
|
101
|
+
stderr: String(stderr ?? ''),
|
|
102
|
+
stdout: String(stdout ?? ''),
|
|
103
|
+
status: typeof errno?.code === 'number' ? errno.code : (child.exitCode ?? 0),
|
|
104
|
+
signal: typeof errno?.signal === 'string' ? errno.signal : null,
|
|
105
|
+
spawnError: errno && typeof errno.code === 'string' ? errno : null,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
const APP_NOT_INSTALLED_PATTERNS = [
|
|
110
|
+
/unable to find application/i,
|
|
111
|
+
/can'?t find/i,
|
|
112
|
+
/not authorized to send keystrokes/i,
|
|
113
|
+
/application can'?t be found/i,
|
|
114
|
+
];
|
|
115
|
+
const classifyFailure = (result) => {
|
|
116
|
+
if (result.spawnError?.code === 'ENOENT')
|
|
117
|
+
return 'command-not-in-path';
|
|
118
|
+
const stderr = result.stderr.toLowerCase();
|
|
119
|
+
if (APP_NOT_INSTALLED_PATTERNS.some((re) => re.test(stderr)))
|
|
120
|
+
return 'app-not-installed';
|
|
121
|
+
return 'unknown';
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Workspace paths originate from the OS folder picker or from manual paste;
|
|
125
|
+
* the picker output is sandbox-validated at create time, but a path stored
|
|
126
|
+
* before path-validation existed (or one pasted into a hypothetical migration
|
|
127
|
+
* future) could contain `\n` / `\0`. Reject those here so we never hand an
|
|
128
|
+
* ambiguous path to `xdg-open`, where shell wrappers split on newline.
|
|
129
|
+
*/
|
|
130
|
+
export const isOpenWorkspacePathSafe = (path) => {
|
|
131
|
+
if (path.length === 0)
|
|
132
|
+
return false;
|
|
133
|
+
for (let i = 0; i < path.length; i++) {
|
|
134
|
+
const code = path.charCodeAt(i);
|
|
135
|
+
if (code === 0 || code === 10 || code === 13)
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
export const openWorkspace = async (input, options = {}) => {
|
|
141
|
+
const platform = resolveOpenTargetPlatform(options.platform ?? process.platform);
|
|
142
|
+
const run = options.runCommand ?? defaultRunOpenCommand;
|
|
143
|
+
if (!isOpenTargetId(input.targetId)) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
effectiveTargetId: getDefaultOpenTargetIdForPlatform(platform),
|
|
147
|
+
errorCode: 'invalid-target',
|
|
148
|
+
stderr: `Unknown open target: ${String(input.targetId)}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!isOpenWorkspacePathSafe(input.path)) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
effectiveTargetId: input.targetId,
|
|
155
|
+
errorCode: 'invalid-path',
|
|
156
|
+
stderr: 'Workspace path contains newline or null byte and was rejected.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const effectiveTargetId = getEffectiveOpenTargetId(input.targetId, platform);
|
|
160
|
+
const attempts = buildOpenAttempts(input.targetId, input.path, platform);
|
|
161
|
+
let lastFailure = null;
|
|
162
|
+
for (const attempt of attempts) {
|
|
163
|
+
const result = await run(attempt.command, attempt.args, {});
|
|
164
|
+
// Windows `explorer.exe` returns exit code 1 even on success — checking
|
|
165
|
+
// exit code here would surface a spurious error to the user on every
|
|
166
|
+
// File Explorer open. spawnError still catches the "explorer not on PATH"
|
|
167
|
+
// case, which is the only real failure mode worth surfacing.
|
|
168
|
+
if (attempt.command === 'explorer') {
|
|
169
|
+
if (result.spawnError?.code === 'ENOENT') {
|
|
170
|
+
lastFailure = result;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, effectiveTargetId };
|
|
174
|
+
}
|
|
175
|
+
if (!result.spawnError && (result.status === 0 || result.status === null)) {
|
|
176
|
+
return { ok: true, effectiveTargetId };
|
|
177
|
+
}
|
|
178
|
+
lastFailure = result;
|
|
179
|
+
}
|
|
180
|
+
const fallback = lastFailure ?? {
|
|
181
|
+
stderr: 'No command attempts were made.',
|
|
182
|
+
stdout: '',
|
|
183
|
+
status: null,
|
|
184
|
+
signal: null,
|
|
185
|
+
spawnError: null,
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
effectiveTargetId,
|
|
190
|
+
errorCode: classifyFailure(fallback),
|
|
191
|
+
stderr: fallback.stderr.trim() || fallback.stdout.trim() || 'Failed to open workspace.',
|
|
192
|
+
};
|
|
193
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { WorkerRole } from '../shared/types.js';
|
|
3
3
|
import type { PickFolderResponse } from './fs-pick-folder.js';
|
|
4
|
+
import type { OpenCommandResult, OpenWorkspaceInput as OpenWorkspaceServiceInput } from './open-target-commands.js';
|
|
4
5
|
import type { RuntimeStore } from './runtime-store.js';
|
|
5
6
|
import type { TasksFileService } from './tasks-file.js';
|
|
6
7
|
import type { VersionService } from './version-service.js';
|
|
@@ -48,12 +49,17 @@ export interface ConfigureAgentLaunchBody {
|
|
|
48
49
|
args?: string[];
|
|
49
50
|
command_preset_id?: string | null;
|
|
50
51
|
}
|
|
52
|
+
export interface OpenWorkspaceBody {
|
|
53
|
+
target_id: string;
|
|
54
|
+
}
|
|
55
|
+
export type OpenWorkspaceService = (input: OpenWorkspaceServiceInput) => Promise<OpenCommandResult>;
|
|
51
56
|
export interface RouteContext {
|
|
52
57
|
request: IncomingMessage;
|
|
53
58
|
response: ServerResponse;
|
|
54
59
|
store: RuntimeStore;
|
|
55
60
|
tasksFileService: TasksFileService;
|
|
56
61
|
pickFolderService: () => Promise<PickFolderResponse>;
|
|
62
|
+
openWorkspaceService: OpenWorkspaceService;
|
|
57
63
|
versionService: VersionService;
|
|
58
64
|
params: Record<string, string>;
|
|
59
65
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { isOpenTargetId } from './open-target-commands.js';
|
|
2
|
+
import { getRequiredParam, readJsonBody, route, sendJson } from './route-helpers.js';
|
|
3
|
+
import { requireUiTokenFromRequest } from './ui-auth-helpers.js';
|
|
4
|
+
export const openWorkspaceRoutes = [
|
|
5
|
+
route('POST', '/api/workspaces/:workspaceId/open', async ({ openWorkspaceService, params, request, response, store }) => {
|
|
6
|
+
const workspaceId = getRequiredParam(response, params, 'workspaceId', 'Workspace id is required');
|
|
7
|
+
if (!workspaceId)
|
|
8
|
+
return;
|
|
9
|
+
requireUiTokenFromRequest(request, store.validateUiToken);
|
|
10
|
+
const body = await readJsonBody(request);
|
|
11
|
+
if (!isOpenTargetId(body.target_id)) {
|
|
12
|
+
sendJson(response, 400, { error: 'Unknown open target', target_id: body.target_id });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// store.getWorkspaceSnapshot throws raw Error("Workspace not found: ...")
|
|
16
|
+
// for missing workspaces — translate that to 404 here rather than letting
|
|
17
|
+
// app.ts catch it as a generic 500.
|
|
18
|
+
let workspacePath;
|
|
19
|
+
try {
|
|
20
|
+
workspacePath = store.getWorkspaceSnapshot(workspaceId).summary.path;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
sendJson(response, 404, { error: 'Workspace not found' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const result = await openWorkspaceService({
|
|
27
|
+
path: workspacePath,
|
|
28
|
+
targetId: body.target_id,
|
|
29
|
+
});
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
sendJson(response, 200, {
|
|
32
|
+
ok: true,
|
|
33
|
+
effective_target_id: result.effectiveTargetId,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Drop stderr from the wire response — defense in depth. The frontend
|
|
38
|
+
// never renders raw stderr (toast text is localized via error_code), so
|
|
39
|
+
// there's no reason to ship the OS-level message to the browser where
|
|
40
|
+
// it would live in the devtools network log.
|
|
41
|
+
sendJson(response, 502, {
|
|
42
|
+
ok: false,
|
|
43
|
+
effective_target_id: result.effectiveTargetId,
|
|
44
|
+
error_code: result.errorCode,
|
|
45
|
+
});
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { matchPath } from './route-helpers.js';
|
|
2
2
|
import { dispatchRoutes } from './routes-dispatches.js';
|
|
3
3
|
import { fsRoutes } from './routes-fs.js';
|
|
4
|
+
import { openWorkspaceRoutes } from './routes-open-workspace.js';
|
|
4
5
|
import { runtimeRoutes } from './routes-runtime.js';
|
|
5
6
|
import { settingsRoutes } from './routes-settings.js';
|
|
6
7
|
import { taskRoutes } from './routes-tasks.js';
|
|
@@ -10,6 +11,7 @@ import { versionRoutes } from './routes-version.js';
|
|
|
10
11
|
import { workspaceRoutes } from './routes-workspaces.js';
|
|
11
12
|
const routes = [
|
|
12
13
|
...workspaceRoutes,
|
|
14
|
+
...openWorkspaceRoutes,
|
|
13
15
|
...dispatchRoutes,
|
|
14
16
|
...versionRoutes,
|
|
15
17
|
...uiRoutes,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting types for the "Open workspace in editor/app" feature.
|
|
3
|
+
* Both the server (command construction in `src/server/open-target-commands.ts`)
|
|
4
|
+
* and the web client (button + preference store in `web/src/workspace/open-targets.ts`)
|
|
5
|
+
* pull the union and platform whitelist from here so they cannot drift.
|
|
6
|
+
*/
|
|
7
|
+
export type OpenTargetId = 'vscode' | 'vscode-insiders' | 'cursor' | 'windsurf' | 'finder' | 'terminal' | 'iterm2' | 'ghostty' | 'intellijidea' | 'zed';
|
|
8
|
+
export type OpenTargetPlatform = 'mac' | 'windows' | 'linux' | 'other';
|
|
9
|
+
export declare const OPEN_TARGET_IDS_BY_PLATFORM: Record<OpenTargetPlatform, readonly OpenTargetId[]>;
|
|
10
|
+
export declare const isOpenTargetId: (value: unknown) => value is OpenTargetId;
|
|
11
|
+
export declare const isOpenTargetSupported: (targetId: OpenTargetId, platform: OpenTargetPlatform) => boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The id the server will actually attempt to launch. If the user's saved
|
|
14
|
+
* preference is unsupported on the current platform (e.g. they picked iTerm2
|
|
15
|
+
* on a Mac, then opened Hive on Windows), fall back to the platform default
|
|
16
|
+
* rather than erroring out — a stale preference shouldn't break the button.
|
|
17
|
+
*/
|
|
18
|
+
export declare const getEffectiveOpenTargetId: (targetId: OpenTargetId, platform: OpenTargetPlatform) => OpenTargetId;
|
|
19
|
+
export declare const getDefaultOpenTargetIdForPlatform: (platform: OpenTargetPlatform) => OpenTargetId;
|
|
20
|
+
export type OpenWorkspaceErrorCode = 'invalid-path' | 'invalid-target' | 'app-not-installed' | 'command-not-in-path' | 'unknown';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting types for the "Open workspace in editor/app" feature.
|
|
3
|
+
* Both the server (command construction in `src/server/open-target-commands.ts`)
|
|
4
|
+
* and the web client (button + preference store in `web/src/workspace/open-targets.ts`)
|
|
5
|
+
* pull the union and platform whitelist from here so they cannot drift.
|
|
6
|
+
*/
|
|
7
|
+
// Note: there is no `cursor-insiders` here. Cursor stopped shipping a separate
|
|
8
|
+
// Nightly bundle / `cursor-nightly` binary in March 2024; the pre-release
|
|
9
|
+
// channel is now an in-app toggle on the regular Cursor.app, so an "Insiders"
|
|
10
|
+
// menu entry would 100% fail with `app-not-installed` on every platform.
|
|
11
|
+
export const OPEN_TARGET_IDS_BY_PLATFORM = {
|
|
12
|
+
mac: [
|
|
13
|
+
'vscode',
|
|
14
|
+
'vscode-insiders',
|
|
15
|
+
'cursor',
|
|
16
|
+
'windsurf',
|
|
17
|
+
'finder',
|
|
18
|
+
'terminal',
|
|
19
|
+
'iterm2',
|
|
20
|
+
'ghostty',
|
|
21
|
+
'intellijidea',
|
|
22
|
+
'zed',
|
|
23
|
+
],
|
|
24
|
+
windows: ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'finder', 'zed'],
|
|
25
|
+
linux: ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'finder', 'zed'],
|
|
26
|
+
other: ['vscode', 'vscode-insiders', 'finder'],
|
|
27
|
+
};
|
|
28
|
+
const ALL_TARGET_IDS = new Set(OPEN_TARGET_IDS_BY_PLATFORM.mac);
|
|
29
|
+
export const isOpenTargetId = (value) => typeof value === 'string' && ALL_TARGET_IDS.has(value);
|
|
30
|
+
export const isOpenTargetSupported = (targetId, platform) => OPEN_TARGET_IDS_BY_PLATFORM[platform].includes(targetId);
|
|
31
|
+
/**
|
|
32
|
+
* The id the server will actually attempt to launch. If the user's saved
|
|
33
|
+
* preference is unsupported on the current platform (e.g. they picked iTerm2
|
|
34
|
+
* on a Mac, then opened Hive on Windows), fall back to the platform default
|
|
35
|
+
* rather than erroring out — a stale preference shouldn't break the button.
|
|
36
|
+
*/
|
|
37
|
+
export const getEffectiveOpenTargetId = (targetId, platform) => isOpenTargetSupported(targetId, platform) ? targetId : getDefaultOpenTargetIdForPlatform(platform);
|
|
38
|
+
export const getDefaultOpenTargetIdForPlatform = (platform) => {
|
|
39
|
+
// `finder` exists for every platform and never fails closed.
|
|
40
|
+
if (platform === 'mac' || platform === 'windows' || platform === 'linux')
|
|
41
|
+
return 'finder';
|
|
42
|
+
return 'vscode';
|
|
43
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tt-a1i/hive",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Browser-native hive-mind for CLI coding agents — Claude Code, Codex, Gemini, and OpenCode collaborate as real PTY processes via a team protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.30.3",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"postinstall": "node scripts/fix-runtime-artifacts.mjs",
|
|
61
61
|
"release:dry": "pnpm check && pnpm build && pnpm test && pnpm pack:check && pnpm pack:smoke",
|
|
62
62
|
"test": "vitest run",
|
|
63
|
-
"test:windows": "vitest run tests/unit/agent-command-resolver.test.ts tests/unit/session-capture-multi-cli.test.ts tests/unit/claude-session-support.test.ts tests/unit/worker-name-generator.test.ts tests/server/fs-pick-folder.test.ts tests/server/fs-browse.test.ts tests/server/schema-version.test.ts tests/server/runtime-rehydration.test.ts tests/web/workspace-picker.test.tsx tests/web/confirm-dialog.test.tsx tests/web/toast.test.tsx --no-file-parallelism --maxWorkers=1 --testTimeout=30000 --hookTimeout=30000",
|
|
63
|
+
"test:windows": "vitest run tests/unit/agent-command-resolver.test.ts tests/unit/session-capture-multi-cli.test.ts tests/unit/claude-session-support.test.ts tests/unit/worker-name-generator.test.ts tests/unit/open-target-commands.test.ts tests/server/fs-pick-folder.test.ts tests/server/fs-browse.test.ts tests/server/schema-version.test.ts tests/server/runtime-rehydration.test.ts tests/server/open-workspace-route.test.ts tests/web/workspace-picker.test.tsx tests/web/confirm-dialog.test.tsx tests/web/toast.test.tsx tests/web/open-workspace-button.test.tsx --no-file-parallelism --maxWorkers=1 --testTimeout=30000 --hookTimeout=30000",
|
|
64
64
|
"test:watch": "vitest"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|