@haowjy/remote-workspace 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/launcher.js +308 -0
- package/dist/server.js +914 -0
- package/package.json +46 -0
- package/static/app.js +1240 -0
- package/static/index.html +185 -0
- package/static/styles.css +140 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 haowjy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Remote Workspace
|
|
2
|
+
|
|
3
|
+
Mobile-friendly read/upload web workspace for this repository.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Browse repository folders/files
|
|
8
|
+
- Upload images into `.clipboard/` at repo root (single image per upload)
|
|
9
|
+
- Delete images from `.clipboard/` and `.playwright-mcp/` from the UI
|
|
10
|
+
- Preview text files and images
|
|
11
|
+
- Render Markdown with Mermaid diagrams
|
|
12
|
+
- Hide and block access to dotfiles/dot-directories (for example `.env`, `.git`)
|
|
13
|
+
- Dedicated collapsible `.clipboard` panel for upload + quick image viewing
|
|
14
|
+
|
|
15
|
+
This app is intentionally **no text editing** to keep remote access simple and lower risk.
|
|
16
|
+
Image operations are limited to upload/delete with strict filename validation.
|
|
17
|
+
|
|
18
|
+
## Start
|
|
19
|
+
|
|
20
|
+
From repo root:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm dev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
By default it serves on `127.0.0.1:18080`.
|
|
27
|
+
|
|
28
|
+
The Node launcher configures Tailscale Serve (tailnet-only) by default before starting.
|
|
29
|
+
|
|
30
|
+
Disable Serve mode (local-only):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm dev -- no-serve
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Enable Funnel mode (public internet):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm dev -- password your-password --funnel
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Enable Basic Auth:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm dev -- password your-password
|
|
46
|
+
# or
|
|
47
|
+
REMOTE_WS_PASSWORD=your-password pnpm dev -- password
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
When a password is set and serve mode is not explicitly chosen, the launcher defaults to local-only (`--no-serve`). Add `--serve` if you want password-protected remote access through Tailscale.
|
|
51
|
+
`--funnel` requires password auth and exposes the workspace publicly.
|
|
52
|
+
When this auto-switch happens, the launcher prints a warning with the exact `--serve` override.
|
|
53
|
+
|
|
54
|
+
Copy/paste startup command:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd /path/to/your/repo && pnpm dev
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm dev -- config /path/to/config
|
|
64
|
+
pnpm dev -- port 18111
|
|
65
|
+
pnpm dev -- install
|
|
66
|
+
pnpm dev -- no-serve
|
|
67
|
+
pnpm dev -- password your-password
|
|
68
|
+
pnpm dev -- password your-password --serve
|
|
69
|
+
pnpm dev -- password your-password --funnel
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Environment
|
|
73
|
+
|
|
74
|
+
- `REMOTE_WS_PORT` (default `18080`)
|
|
75
|
+
- `REMOTE_WS_MAX_PREVIEW_BYTES` (default `1048576`)
|
|
76
|
+
- `REMOTE_WS_MAX_UPLOAD_BYTES` (default `26214400`)
|
|
77
|
+
- `REMOTE_WS_PASSWORD` (optional, enables HTTP Basic Auth when set)
|
|
78
|
+
- `REMOTE_WS_CONFIG_FILE` (optional config file path override)
|
|
79
|
+
- `REPO_ROOT` (injected by launcher script)
|
|
80
|
+
|
|
81
|
+
Password config file format (default: repo root `.remote-workspace.conf`):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
REMOTE_WS_PASSWORD=your-password
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Password/config precedence:
|
|
88
|
+
|
|
89
|
+
1. CLI inline password (`--password <pwd>`)
|
|
90
|
+
2. Env password (`REMOTE_WS_PASSWORD`)
|
|
91
|
+
3. Selected config file value (`REMOTE_WS_PASSWORD=...`)
|
|
92
|
+
|
|
93
|
+
Config file selection precedence:
|
|
94
|
+
|
|
95
|
+
1. CLI config path (`--config <path>`)
|
|
96
|
+
2. Env config path (`REMOTE_WS_CONFIG_FILE`)
|
|
97
|
+
3. Project config (`<repo-root>/.remote-workspace.conf`)
|
|
98
|
+
4. User config (`$XDG_CONFIG_HOME/remote-workspace/config`, then `$APPDATA/remote-workspace/config`, then `~/.config/remote-workspace/config`)
|
|
99
|
+
|
|
100
|
+
## Upload Clipboard
|
|
101
|
+
|
|
102
|
+
- `POST /api/clipboard/upload` always writes to `REPO_ROOT/.clipboard`
|
|
103
|
+
- `DELETE /api/clipboard/file?name=<filename>` deletes one image in `REPO_ROOT/.clipboard`
|
|
104
|
+
- `.clipboard` panel uses dedicated clipboard endpoints (`/api/clipboard/upload`, `/api/clipboard/list`, `/api/clipboard/file`)
|
|
105
|
+
- Main repository browser still blocks all hidden paths and gitignored paths
|
|
106
|
+
- Gitignored paths are hidden/blocked (for example `node_modules/`, build artifacts, local secrets)
|
|
107
|
+
- Accepted upload types are images only (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `heic`, `heif`, `avif`)
|
|
108
|
+
- Clipboard panel supports both file picker and `Paste From Clipboard` button (when browser clipboard image API is available)
|
|
109
|
+
- Upload requires `name` query parameter (filename is user-controlled)
|
|
110
|
+
- Filename rules: no spaces, no leading dot, `[A-Za-z0-9._-]` only, and must use an allowed image extension
|
|
111
|
+
- Multipart field names accepted: `file` (current UI) and `files` (legacy cached UI compatibility)
|
|
112
|
+
- Legacy alias: `/api/upload` is still accepted for older cached clients
|
|
113
|
+
|
|
114
|
+
## Screenshots
|
|
115
|
+
|
|
116
|
+
- `GET /api/screenshots/list` lists images in `REPO_ROOT/.playwright-mcp`
|
|
117
|
+
- `GET /api/screenshots/file?name=<filename>` streams one screenshot image
|
|
118
|
+
- `DELETE /api/screenshots/file?name=<filename>` deletes one screenshot image
|
|
119
|
+
|
|
120
|
+
## Caching
|
|
121
|
+
|
|
122
|
+
- The browser now caches image bytes (`/api/clipboard/file`, `/api/screenshots/file`, image responses from `/api/file`) with short-lived cache headers and validators.
|
|
123
|
+
- The client keeps a small local metadata cache (tree + clipboard/screenshot lists) and hydrates immediately on reload, then refreshes in the background.
|
|
124
|
+
- Refresh buttons bypass local metadata cache and force a new server fetch.
|
|
125
|
+
|
|
126
|
+
## Tailscale
|
|
127
|
+
|
|
128
|
+
Tailscale Serve is enabled by default and stays private to your tailnet.
|
|
129
|
+
Use `--no-serve` if you want local-only mode.
|
|
130
|
+
Use `--funnel` to publish via Tailscale Funnel (password required).
|
|
131
|
+
If Tailscale is missing or disconnected while serve mode is enabled, the launcher exits with guidance to either switch to local-only mode or run `tailscale up` and retry `--serve`.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Tailnet-only URL
|
|
135
|
+
pnpm dev
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Manual commands (equivalent):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
tailscale serve --bg --https=443 127.0.0.1:18080
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Auth
|
|
145
|
+
|
|
146
|
+
When `REMOTE_WS_PASSWORD` is set (or `--password` is passed to the launcher), the app requires HTTP Basic Auth for all routes (UI + API).
|
|
147
|
+
When auth is enabled, mutating routes (`POST`/`DELETE`) also require same-origin `Origin`/`Referer` headers.
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const currentFilePath = fileURLToPath(import.meta.url);
|
|
7
|
+
const currentDirectoryPath = path.dirname(currentFilePath);
|
|
8
|
+
const appDir = path.resolve(currentDirectoryPath, "..");
|
|
9
|
+
function printUsage() {
|
|
10
|
+
console.log(`Usage: pnpm dev -- [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--repo-root <path> Repository root to expose (default: REPO_ROOT or cwd)
|
|
14
|
+
--config <path> Config file path (default uses precedence search)
|
|
15
|
+
--port <port> Listen port (default: REMOTE_WS_PORT or 18080)
|
|
16
|
+
--install Force dependency install before start
|
|
17
|
+
--skip-install Skip install check even if node_modules is missing
|
|
18
|
+
--no-serve Skip tailscale serve setup
|
|
19
|
+
--funnel Enable tailscale funnel (public internet)
|
|
20
|
+
--password [pwd] Enable Basic Auth (optional inline password)
|
|
21
|
+
--serve Force tailscale serve setup
|
|
22
|
+
-h, --help Show this help text
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
pnpm dev
|
|
26
|
+
pnpm dev -- --repo-root /path/to/repo
|
|
27
|
+
pnpm dev -- --config ~/.config/remote-workspace/config
|
|
28
|
+
pnpm dev -- --port 18111
|
|
29
|
+
pnpm dev -- --password
|
|
30
|
+
pnpm dev -- --password mypass --serve
|
|
31
|
+
pnpm dev -- --password mypass --funnel
|
|
32
|
+
REMOTE_WS_PASSWORD=mypass pnpm dev -- --password
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
function fail(message) {
|
|
36
|
+
console.error(message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
function parsePort(rawPort) {
|
|
40
|
+
if (!/^\d+$/.test(rawPort)) {
|
|
41
|
+
fail(`Invalid --port value: ${rawPort} (must be numeric).`);
|
|
42
|
+
}
|
|
43
|
+
const value = Number.parseInt(rawPort, 10);
|
|
44
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
45
|
+
fail(`Invalid --port value: ${rawPort} (must be 1-65535).`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function parseConfigPassword(configFilePath) {
|
|
50
|
+
if (!existsSync(configFilePath)) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
const content = readFileSync(configFilePath, "utf8");
|
|
54
|
+
for (const line of content.split(/\r?\n/)) {
|
|
55
|
+
if (/^\s*$/.test(line) || /^\s*#/.test(line)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const match = line.match(/^\s*REMOTE_WS_PASSWORD\s*=(.*)$/);
|
|
59
|
+
if (!match) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let value = match[1].trim();
|
|
63
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
64
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
65
|
+
value = value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
function resolveUserConfigPath() {
|
|
72
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
73
|
+
if (xdgConfigHome) {
|
|
74
|
+
return path.resolve(xdgConfigHome, "remote-workspace", "config");
|
|
75
|
+
}
|
|
76
|
+
const appData = process.env.APPDATA;
|
|
77
|
+
if (appData) {
|
|
78
|
+
return path.resolve(appData, "remote-workspace", "config");
|
|
79
|
+
}
|
|
80
|
+
const home = process.env.HOME ??
|
|
81
|
+
process.env.USERPROFILE ??
|
|
82
|
+
(process.env.HOMEDRIVE && process.env.HOMEPATH
|
|
83
|
+
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
|
84
|
+
: undefined);
|
|
85
|
+
if (!home) {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
return path.resolve(home, ".config", "remote-workspace", "config");
|
|
89
|
+
}
|
|
90
|
+
function parseArgs(argv) {
|
|
91
|
+
let repoRoot = path.resolve(process.env.REPO_ROOT ?? process.cwd());
|
|
92
|
+
let port = parsePort(process.env.REMOTE_WS_PORT ?? "18080");
|
|
93
|
+
let skipInstall = false;
|
|
94
|
+
let forceInstall = false;
|
|
95
|
+
let serveEnabled = true;
|
|
96
|
+
let serveModeSet = false;
|
|
97
|
+
let funnelEnabled = false;
|
|
98
|
+
let passwordEnabled = false;
|
|
99
|
+
let workspacePassword = "";
|
|
100
|
+
let configFileFromArg = null;
|
|
101
|
+
let configFile = "";
|
|
102
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
103
|
+
const arg = argv[i];
|
|
104
|
+
switch (arg) {
|
|
105
|
+
case "--repo-root": {
|
|
106
|
+
const next = argv[i + 1];
|
|
107
|
+
if (!next || next.startsWith("--")) {
|
|
108
|
+
fail("Missing value for --repo-root.");
|
|
109
|
+
}
|
|
110
|
+
repoRoot = path.resolve(next);
|
|
111
|
+
i += 1;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "--config": {
|
|
115
|
+
const next = argv[i + 1];
|
|
116
|
+
if (!next || next.startsWith("--")) {
|
|
117
|
+
fail("Missing value for --config.");
|
|
118
|
+
}
|
|
119
|
+
configFileFromArg = path.resolve(next);
|
|
120
|
+
i += 1;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "--port": {
|
|
124
|
+
const next = argv[i + 1];
|
|
125
|
+
if (!next || next.startsWith("--")) {
|
|
126
|
+
fail("Missing value for --port.");
|
|
127
|
+
}
|
|
128
|
+
port = parsePort(next);
|
|
129
|
+
i += 1;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "--install":
|
|
133
|
+
forceInstall = true;
|
|
134
|
+
break;
|
|
135
|
+
case "--skip-install":
|
|
136
|
+
skipInstall = true;
|
|
137
|
+
break;
|
|
138
|
+
case "--no-serve":
|
|
139
|
+
serveEnabled = false;
|
|
140
|
+
funnelEnabled = false;
|
|
141
|
+
serveModeSet = true;
|
|
142
|
+
break;
|
|
143
|
+
case "--funnel":
|
|
144
|
+
serveEnabled = true;
|
|
145
|
+
funnelEnabled = true;
|
|
146
|
+
serveModeSet = true;
|
|
147
|
+
break;
|
|
148
|
+
case "--password": {
|
|
149
|
+
passwordEnabled = true;
|
|
150
|
+
const maybeValue = argv[i + 1];
|
|
151
|
+
if (maybeValue && !maybeValue.startsWith("--")) {
|
|
152
|
+
workspacePassword = maybeValue;
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "--serve":
|
|
158
|
+
serveEnabled = true;
|
|
159
|
+
funnelEnabled = false;
|
|
160
|
+
serveModeSet = true;
|
|
161
|
+
break;
|
|
162
|
+
case "-h":
|
|
163
|
+
case "--help":
|
|
164
|
+
printUsage();
|
|
165
|
+
process.exit(0);
|
|
166
|
+
default:
|
|
167
|
+
fail(`Unknown argument: ${arg}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!existsSync(repoRoot)) {
|
|
171
|
+
fail(`Repository root does not exist: ${repoRoot}`);
|
|
172
|
+
}
|
|
173
|
+
const projectConfigFile = path.resolve(repoRoot, ".remote-workspace.conf");
|
|
174
|
+
const userConfigFile = resolveUserConfigPath();
|
|
175
|
+
if (configFileFromArg) {
|
|
176
|
+
if (!existsSync(configFileFromArg)) {
|
|
177
|
+
fail(`Config file does not exist: ${configFileFromArg}`);
|
|
178
|
+
}
|
|
179
|
+
configFile = configFileFromArg;
|
|
180
|
+
}
|
|
181
|
+
else if (process.env.REMOTE_WS_CONFIG_FILE) {
|
|
182
|
+
const envConfigFile = path.resolve(process.env.REMOTE_WS_CONFIG_FILE);
|
|
183
|
+
if (!existsSync(envConfigFile)) {
|
|
184
|
+
fail(`Config file from REMOTE_WS_CONFIG_FILE does not exist: ${envConfigFile}`);
|
|
185
|
+
}
|
|
186
|
+
configFile = envConfigFile;
|
|
187
|
+
}
|
|
188
|
+
else if (existsSync(projectConfigFile)) {
|
|
189
|
+
configFile = projectConfigFile;
|
|
190
|
+
}
|
|
191
|
+
else if (userConfigFile && existsSync(userConfigFile)) {
|
|
192
|
+
configFile = userConfigFile;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// No discovered config file. Keep the conventional project path for messages.
|
|
196
|
+
configFile = projectConfigFile;
|
|
197
|
+
}
|
|
198
|
+
const configPassword = parseConfigPassword(configFile);
|
|
199
|
+
if (!workspacePassword) {
|
|
200
|
+
workspacePassword =
|
|
201
|
+
process.env.REMOTE_WS_PASSWORD ?? configPassword;
|
|
202
|
+
}
|
|
203
|
+
// Backward compatibility: explicit env password enables auth without flag.
|
|
204
|
+
if (!passwordEnabled && Boolean(process.env.REMOTE_WS_PASSWORD)) {
|
|
205
|
+
passwordEnabled = true;
|
|
206
|
+
}
|
|
207
|
+
if (passwordEnabled && !workspacePassword) {
|
|
208
|
+
fail(`--password was provided but no password value is available.\nSet REMOTE_WS_PASSWORD, pass --password <pwd>, or create ${configFile} with REMOTE_WS_PASSWORD=...`);
|
|
209
|
+
}
|
|
210
|
+
if (!passwordEnabled) {
|
|
211
|
+
workspacePassword = "";
|
|
212
|
+
}
|
|
213
|
+
if (funnelEnabled && !passwordEnabled) {
|
|
214
|
+
fail(`--funnel requires password auth.\nPass --password (or set REMOTE_WS_PASSWORD / ${configFile}).`);
|
|
215
|
+
}
|
|
216
|
+
if (!serveModeSet && passwordEnabled) {
|
|
217
|
+
serveEnabled = false;
|
|
218
|
+
console.warn("Warning: password auth is enabled and no serve mode was selected.\nDefaulting to local-only mode (--no-serve).\nTo expose through Tailscale, restart with --serve.");
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
repoRoot,
|
|
222
|
+
port,
|
|
223
|
+
skipInstall,
|
|
224
|
+
forceInstall,
|
|
225
|
+
serveEnabled,
|
|
226
|
+
serveModeSet,
|
|
227
|
+
funnelEnabled,
|
|
228
|
+
passwordEnabled,
|
|
229
|
+
workspacePassword,
|
|
230
|
+
configFile,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function hasCommand(commandName) {
|
|
234
|
+
const locator = process.platform === "win32" ? "where" : "which";
|
|
235
|
+
const result = spawnSync(locator, [commandName], { stdio: "ignore" });
|
|
236
|
+
return result.status === 0;
|
|
237
|
+
}
|
|
238
|
+
function runSyncOrFail(commandName, args, message) {
|
|
239
|
+
const result = spawnSync(commandName, args, { stdio: "inherit" });
|
|
240
|
+
if (result.error) {
|
|
241
|
+
fail(`${message}: ${result.error.message}`);
|
|
242
|
+
}
|
|
243
|
+
if (result.status !== 0) {
|
|
244
|
+
fail(message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function runSync(commandName, args) {
|
|
248
|
+
spawnSync(commandName, args, { stdio: "inherit" });
|
|
249
|
+
}
|
|
250
|
+
function main() {
|
|
251
|
+
const args = parseArgs(process.argv.slice(2));
|
|
252
|
+
if (!hasCommand("pnpm")) {
|
|
253
|
+
fail("pnpm is required but not installed.");
|
|
254
|
+
}
|
|
255
|
+
if (args.serveEnabled) {
|
|
256
|
+
if (!hasCommand("tailscale")) {
|
|
257
|
+
fail("tailscale is not installed, but serve mode is enabled.\nUse local-only mode: pnpm dev -- --no-serve (or pnpm dev -- --password <pwd>).\nOr install Tailscale, run 'tailscale up', then restart with --serve.");
|
|
258
|
+
}
|
|
259
|
+
runSyncOrFail("tailscale", ["status"], "tailscale is installed but not connected.\nRun 'tailscale up' and retry --serve, or use local-only mode with --no-serve.");
|
|
260
|
+
}
|
|
261
|
+
if (!existsSync(path.join(appDir, "package.json"))) {
|
|
262
|
+
fail(`remote-workspace app not found at: ${appDir}`);
|
|
263
|
+
}
|
|
264
|
+
if (args.forceInstall) {
|
|
265
|
+
runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
|
|
266
|
+
}
|
|
267
|
+
else if (!args.skipInstall && !existsSync(path.join(appDir, "node_modules"))) {
|
|
268
|
+
runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
|
|
269
|
+
}
|
|
270
|
+
if (args.serveEnabled) {
|
|
271
|
+
console.log(`Configuring tailscale serve (https:443 -> 127.0.0.1:${args.port})`);
|
|
272
|
+
runSyncOrFail("tailscale", ["serve", "--bg", "--https=443", `127.0.0.1:${args.port}`], "tailscale serve setup failed.");
|
|
273
|
+
if (args.funnelEnabled) {
|
|
274
|
+
console.log("Enabling tailscale funnel (public internet exposure)");
|
|
275
|
+
runSyncOrFail("tailscale", ["funnel", "--bg", "on"], "tailscale funnel setup failed.");
|
|
276
|
+
runSync("tailscale", ["funnel", "status"]);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
runSync("tailscale", ["serve", "status"]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
console.log("Skipping tailscale serve (--no-serve).");
|
|
284
|
+
}
|
|
285
|
+
console.log(`Starting remote-workspace on http://127.0.0.1:${args.port}`);
|
|
286
|
+
console.log(`Repo root: ${args.repoRoot}`);
|
|
287
|
+
if (args.passwordEnabled) {
|
|
288
|
+
console.log("Auth: Basic Auth enabled");
|
|
289
|
+
}
|
|
290
|
+
console.log("");
|
|
291
|
+
const child = spawn("pnpm", ["--dir", appDir, "dev:server"], {
|
|
292
|
+
stdio: "inherit",
|
|
293
|
+
env: {
|
|
294
|
+
...process.env,
|
|
295
|
+
REPO_ROOT: args.repoRoot,
|
|
296
|
+
REMOTE_WS_PORT: String(args.port),
|
|
297
|
+
REMOTE_WS_PASSWORD: args.workspacePassword,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
child.on("exit", (code, signal) => {
|
|
301
|
+
if (signal) {
|
|
302
|
+
process.kill(process.pid, signal);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
process.exit(code ?? 1);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
main();
|