fpasoterm 1.6.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.
Potentially problematic release.
This version of fpasoterm might be problematic. Click here for more details.
- package/CHANGELOG.md +230 -0
- package/CONTRIBUTING.md +67 -0
- package/INSTALL.ja.md +204 -0
- package/INSTALL.md +229 -0
- package/LICENSE +21 -0
- package/README.ja.md +389 -0
- package/README.md +607 -0
- package/bin/fpasoterm +2882 -0
- package/completions/_fpasoterm +81 -0
- package/completions/fpasoterm.bash +55 -0
- package/completions/fpasoterm.fish +64 -0
- package/completions/fpasoterm.ps1 +47 -0
- package/docs/capability-diagnostics.en.md +77 -0
- package/docs/capability-diagnostics.ja.md +63 -0
- package/docs/completion.en.md +141 -0
- package/docs/completion.ja.md +113 -0
- package/docs/config.en.md +554 -0
- package/docs/config.ja.md +544 -0
- package/docs/debugging.en.md +72 -0
- package/docs/debugging.ja.md +66 -0
- package/docs/diagnostics.en.md +69 -0
- package/docs/diagnostics.ja.md +64 -0
- package/docs/font-diagnostics.en.md +116 -0
- package/docs/font-diagnostics.ja.md +105 -0
- package/docs/fpasoterm-plugin.d.ts +51 -0
- package/docs/known-issues.en.md +23 -0
- package/docs/known-issues.ja.md +23 -0
- package/docs/plugins.en.md +266 -0
- package/docs/plugins.ja.md +176 -0
- package/docs/pr-review.en.md +177 -0
- package/docs/pr-review.ja.md +170 -0
- package/docs/release-checklist.en.md +72 -0
- package/docs/release-checklist.ja.md +71 -0
- package/docs/security.en.md +36 -0
- package/docs/security.ja.md +31 -0
- package/docs/spec.en.md +106 -0
- package/docs/spec.ja.md +99 -0
- package/docs/sshfs.en.md +28 -0
- package/docs/sshfs.ja.md +28 -0
- package/docs/sync.en.md +280 -0
- package/docs/sync.ja.md +280 -0
- package/examples/apply-default-appearance.bat +8 -0
- package/examples/apply-default-appearance.ps1 +7 -0
- package/examples/apply-default-appearance.sh +7 -0
- package/examples/apply-runtime-appearance.bat +8 -0
- package/examples/apply-runtime-appearance.ps1 +7 -0
- package/examples/apply-runtime-appearance.sh +7 -0
- package/examples/config/default-appearance.toml +36 -0
- package/examples/config/minimal.toml +30 -0
- package/examples/config/profiles.toml +20 -0
- package/examples/config/runtime-appearance.toml +24 -0
- package/examples/config/sync-folder.toml +17 -0
- package/examples/config/tui-compatibility.toml +7 -0
- package/examples/config/with-plugins.toml +33 -0
- package/examples/plugins/hello.ts +17 -0
- package/examples/plugins/status-banner.ts +14 -0
- package/examples/plugins/theme.ts +32 -0
- package/examples/plugins/welcome-banner.ts +16 -0
- package/extra/linux/icons/hicolor/128x128/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/16x16/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/192x192/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/256x256/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/32x32/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/48x48/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/512x512/apps/fpasoterm.png +0 -0
- package/extra/linux/icons/hicolor/64x64/apps/fpasoterm.png +0 -0
- package/extra/linux/io.github.oyoguhito.fpasoterm.desktop +10 -0
- package/extra/logo/fpasoterm.png +0 -0
- package/extra/macos/fpasoterm.icns +0 -0
- package/extra/windows/fpasoterm.cmd +40 -0
- package/extra/windows/fpasoterm.ico +0 -0
- package/package.json +81 -0
- package/scripts/build-artifacts.js +183 -0
- package/scripts/generate-icon.js +251 -0
- package/scripts/init-jj-empty-main +41 -0
- package/scripts/install-linux-desktop.js +238 -0
- package/scripts/run +16 -0
- package/scripts/security/scan-secrets.js +89 -0
- package/scripts/tests/smoke.js +2476 -0
- package/scripts/uninstall-desktop.js +20 -0
- package/scripts/uninstall-linux-desktop.js +96 -0
- package/scripts/uninstall-windows-path.js +94 -0
- package/src/config.js +855 -0
- package/src/renderer/confirm.html +71 -0
- package/src/renderer/index.html +223 -0
- package/src/renderer/renderer.js +4166 -0
- package/src/renderer/styles.css +969 -0
- package/src/renderer/vendor/addon-fit/addon-fit.js +2 -0
- package/src/renderer/vendor/addon-image/LICENSE +19 -0
- package/src/renderer/vendor/addon-image/addon-image.js +3 -0
- package/src/renderer/vendor/addon-web-links/LICENSE +19 -0
- package/src/renderer/vendor/addon-web-links/addon-web-links.js +2 -0
- package/src/renderer/vendor/xterm/xterm.css +292 -0
- package/src/renderer/vendor/xterm/xterm.js +2 -0
- package/src-tauri/Cargo.lock +5253 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +140 -0
- package/src-tauri/capabilities/default.json +13 -0
- package/src-tauri/default-config.toml +128 -0
- package/src-tauri/src/main.rs +9347 -0
- package/src-tauri/tauri.conf.json +60 -0
package/bin/fpasoterm
ADDED
|
@@ -0,0 +1,2882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const readline = require('node:readline/promises');
|
|
9
|
+
const toml = require('smol-toml');
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(__dirname, '..');
|
|
12
|
+
const {
|
|
13
|
+
configPath,
|
|
14
|
+
defaultConfigExample,
|
|
15
|
+
deleteWindowState,
|
|
16
|
+
discoverPluginFiles,
|
|
17
|
+
loadConfig,
|
|
18
|
+
mergeConfig,
|
|
19
|
+
missingConfigKeys,
|
|
20
|
+
pluginMetadata,
|
|
21
|
+
readPluginMetadata,
|
|
22
|
+
readUserConfig,
|
|
23
|
+
resolvePluginSelector,
|
|
24
|
+
selectProfileConfig,
|
|
25
|
+
platformDefaultConfig,
|
|
26
|
+
profileNames,
|
|
27
|
+
validateUserConfig,
|
|
28
|
+
writableConfigDefaults,
|
|
29
|
+
pruneUnsupportedConfig,
|
|
30
|
+
writeUserConfig,
|
|
31
|
+
} = require(path.join(root, 'src', 'config'));
|
|
32
|
+
const packageJson = require(path.join(root, 'package.json'));
|
|
33
|
+
const launcherStartedAt = Number(process.env.FPASOTERM_LAUNCH_STARTED_AT) || Date.now();
|
|
34
|
+
const launcherLogPath = process.env.FPASOTERM_LAUNCHER_LOG || path.join(
|
|
35
|
+
process.platform === 'win32'
|
|
36
|
+
? (process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'))
|
|
37
|
+
: (process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache')),
|
|
38
|
+
'fpasoterm',
|
|
39
|
+
'launcher.log',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const helpText = `Usage: fpasoterm [options]
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
-h, --help Show this help.
|
|
46
|
+
-v, --version Show the version and source commit.
|
|
47
|
+
--update-check Compare the installed version with npm latest, then exit.
|
|
48
|
+
--completion <shell> Print a completion script: bash, zsh, fish, or powershell.
|
|
49
|
+
--completion-install <shell>
|
|
50
|
+
Install persistent command completion for a shell.
|
|
51
|
+
--completion-uninstall <shell>
|
|
52
|
+
Remove fpasoterm's persistent command completion.
|
|
53
|
+
-l, --list List running fpasoterm windows, then exit.
|
|
54
|
+
-q, --close <pid|title|all> Close windows by PID, exact title, or all.
|
|
55
|
+
--broadcast <text> Send text plus Enter to running local terminal windows.
|
|
56
|
+
--broadcast-target <pid|title>
|
|
57
|
+
Limit Broadcast targets (comma-separated/repeatable).
|
|
58
|
+
--broadcast-sync Also send Broadcast through the trusted sync channel.
|
|
59
|
+
-d, --dev Force a local debug-binary rebuild and launch.
|
|
60
|
+
-F, --foreground Keep the launcher attached to the current console.
|
|
61
|
+
-C, --console-diagnostics Print diagnostics to stderr as well as the log file.
|
|
62
|
+
-c, --config <path> Use a specific config.toml for this launch.
|
|
63
|
+
-p, --profile <name> Apply a named [profiles.<name>] overlay for this launch.
|
|
64
|
+
--profile-list List available named profiles, then exit.
|
|
65
|
+
--show-config Print resolved settings and plugin load status, then exit.
|
|
66
|
+
--config-check Validate config.toml and report warnings, then exit.
|
|
67
|
+
--config-path Print the active config.toml path, then exit.
|
|
68
|
+
--config-example Print the active config.toml.example contents, then exit.
|
|
69
|
+
--diagnostics Print a Markdown diagnostics report, then exit.
|
|
70
|
+
--doctor Check updates, npm audit, and config health, then exit.
|
|
71
|
+
--open-log-dir Open the configured terminal log directory, then exit.
|
|
72
|
+
--copy-diagnostics Copy the Markdown diagnostics report to the clipboard, then exit.
|
|
73
|
+
--plugin-list List local User/plugins files and enabled plugins, then exit.
|
|
74
|
+
--plugin-path Print the active User/plugins directory, then exit.
|
|
75
|
+
--plugin-info <file> Show a .js/.ts plugin's state, version, source, and load status.
|
|
76
|
+
--plugin-uninstall <file> Remove comma-separated local User/plugins files, then exit.
|
|
77
|
+
--plugin-search [query] Search official public plugin ports, then exit.
|
|
78
|
+
--plugin-install <port> Install one port from GitHub, or --plugin-ports-dir when supplied.
|
|
79
|
+
--plugin-ports-dir <path> Use this local fpasoterm-plugins checkout with --plugin-install.
|
|
80
|
+
--plugin-install-file <path>
|
|
81
|
+
Copy one trusted local .js/.ts plugin into User/plugins.
|
|
82
|
+
--force Replace an existing file during a plugin install.
|
|
83
|
+
--enable Enable a plugin installed by one --plugin-install command.
|
|
84
|
+
--update-config Add missing default settings and back up config.toml, then exit.
|
|
85
|
+
--prune-config Remove unsupported settings and back up config.toml, then exit.
|
|
86
|
+
--setup-sync Ask questions and configure a local sync folder, then exit.
|
|
87
|
+
--sync-status Report sync-folder health, command state, and channels, then exit.
|
|
88
|
+
--sync-clean Remove expired sync commands and abandoned temporary files, then exit.
|
|
89
|
+
--sync-diagnostics Print a Markdown sync diagnostic report, then exit.
|
|
90
|
+
--self-update Update an npm-installed fpasoterm package, then refresh desktop files on Linux.
|
|
91
|
+
--self-update-checkout Update a clean git checkout with git pull, npm install, and desktop refresh.
|
|
92
|
+
--update-desktop Reinstall the local command, desktop entry, and launcher icons, then exit.
|
|
93
|
+
-s, --shell <command> Override the configured shell for this launch.
|
|
94
|
+
-o, --cwd <path> Start the terminal in this directory. Relative paths are allowed.
|
|
95
|
+
-e, --command <command> Send a command to the shell after launch.
|
|
96
|
+
-t, --title <text> Override the titlebar title for this launch.
|
|
97
|
+
-b, --titlebar-color <color> Override the custom titlebar color for this launch.
|
|
98
|
+
-r, --reset-window-state Delete saved window size, then exit.
|
|
99
|
+
-R, --reset-config Rename config.toml, restore defaults and default size, then exit.
|
|
100
|
+
--enable-plugin <names> Enable plugin files by name (comma-separated/repeatable), then exit.
|
|
101
|
+
--disable-plugin <names> Disable plugin files by name (comma-separated/repeatable), then exit.
|
|
102
|
+
--plugin-enable-all Enable every discovered User/plugins .js/.ts file, then exit.
|
|
103
|
+
--plugin-disable-all Disable every plugin without deleting plugin files, then exit.
|
|
104
|
+
--plugin-enable <names> Alias for --enable-plugin.
|
|
105
|
+
--plugin-disable <names> Alias for --disable-plugin.
|
|
106
|
+
Local selectors may omit plugins/ and .js/.ts when unambiguous.
|
|
107
|
+
-W, --width <px> Override the configured window width for this launch.
|
|
108
|
+
-H, --height <px> Override the configured window height for this launch.
|
|
109
|
+
-z, --size <width>x<height> Override both window dimensions for this launch.
|
|
110
|
+
-k, --debug-keys Enable key/composition diagnostics.
|
|
111
|
+
--disable-dmabuf Set WEBKIT_DISABLE_DMABUF_RENDERER=1 for Linux WebKitGTK diagnostics.
|
|
112
|
+
|
|
113
|
+
Normal launches build and run the local Tauri binary when needed, then detach
|
|
114
|
+
from the console so the shell prompt is available again.
|
|
115
|
+
Use --foreground when you want the command to wait for the app process.
|
|
116
|
+
|
|
117
|
+
Completion usage:
|
|
118
|
+
fpasoterm --completion-install bash
|
|
119
|
+
fpasoterm --completion-uninstall bash
|
|
120
|
+
Supported shell values: bash, zsh, fish, powershell
|
|
121
|
+
|
|
122
|
+
Profile usage:
|
|
123
|
+
1. Find config: fpasoterm --config-path
|
|
124
|
+
2. Add to config.toml:
|
|
125
|
+
[profiles.large-font.terminal]
|
|
126
|
+
fontSize = 18
|
|
127
|
+
3. Launch: fpasoterm --profile large-font
|
|
128
|
+
List names: fpasoterm --profile-list
|
|
129
|
+
Sample file: --config examples/config/profiles.toml --profile large-font
|
|
130
|
+
Docs: docs/config.en.md#profiles and docs/config.ja.md#profile
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
// Appends diagnostics for launcher/icon starts where stdout and stderr are hidden.
|
|
134
|
+
function appendLauncherLog(message) {
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.dirname(launcherLogPath), { recursive: true });
|
|
137
|
+
fs.appendFileSync(launcherLogPath, `${message}\n`);
|
|
138
|
+
} catch {
|
|
139
|
+
// Launcher diagnostics must never prevent the app from starting.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Records elapsed launcher time without keeping the invoking shell attached.
|
|
144
|
+
function appendLaunchPhase(phase) {
|
|
145
|
+
appendLauncherLog(`launcher phase=${phase} elapsed_ms=${Date.now() - launcherStartedAt}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Converts CLI options into environment variables consumed by the main process.
|
|
149
|
+
function parseArgs(argv) {
|
|
150
|
+
validateArgs(argv);
|
|
151
|
+
|
|
152
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
153
|
+
return { help: true };
|
|
154
|
+
}
|
|
155
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
156
|
+
return { help: false, version: true };
|
|
157
|
+
}
|
|
158
|
+
if (argv.includes('--update-check')) {
|
|
159
|
+
return { help: false, version: false, updateCheck: true };
|
|
160
|
+
}
|
|
161
|
+
const pluginSearchIndex = argv.indexOf('--plugin-search');
|
|
162
|
+
if (pluginSearchIndex >= 0) {
|
|
163
|
+
const query = argv[pluginSearchIndex + 1];
|
|
164
|
+
return { help: false, version: false, pluginSearch: query && !query.startsWith('-') ? query : '' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const env = {};
|
|
168
|
+
const runtimeArgs = [];
|
|
169
|
+
const enablePlugins = [];
|
|
170
|
+
const disablePlugins = [];
|
|
171
|
+
let enableAllPlugins = false;
|
|
172
|
+
let disableAllPlugins = false;
|
|
173
|
+
let foreground = false;
|
|
174
|
+
let dev = false;
|
|
175
|
+
let completionShell = '';
|
|
176
|
+
let completionInstallShell = '';
|
|
177
|
+
let completionUninstallShell = '';
|
|
178
|
+
let showConfig = false;
|
|
179
|
+
let configCheck = false;
|
|
180
|
+
let profileList = false;
|
|
181
|
+
let configPathOnly = false;
|
|
182
|
+
let configExample = false;
|
|
183
|
+
let diagnostics = false;
|
|
184
|
+
let doctor = false;
|
|
185
|
+
let openLogDir = false;
|
|
186
|
+
let copyDiagnostics = false;
|
|
187
|
+
let pluginList = false;
|
|
188
|
+
let pluginPath = false;
|
|
189
|
+
let pluginInfo = '';
|
|
190
|
+
const pluginUninstall = [];
|
|
191
|
+
let pluginInstall = '';
|
|
192
|
+
let pluginPortsDir = '';
|
|
193
|
+
let pluginInstallFile = '';
|
|
194
|
+
let pluginInstallForce = false;
|
|
195
|
+
let pluginInstallEnable = false;
|
|
196
|
+
let listInstances = false;
|
|
197
|
+
let closeTarget = '';
|
|
198
|
+
let setupSync = false;
|
|
199
|
+
let syncStatus = false;
|
|
200
|
+
let syncClean = false;
|
|
201
|
+
let syncDiagnostics = false;
|
|
202
|
+
let broadcastText = '';
|
|
203
|
+
const broadcastTargets = [];
|
|
204
|
+
let broadcastSync = false;
|
|
205
|
+
let resetWindowState = false;
|
|
206
|
+
let resetConfig = false;
|
|
207
|
+
let updateConfig = false;
|
|
208
|
+
let pruneConfig = false;
|
|
209
|
+
let selfUpdate = false;
|
|
210
|
+
let selfUpdateCheckout = false;
|
|
211
|
+
let updateDesktop = false;
|
|
212
|
+
let startCommand = '';
|
|
213
|
+
let updateCheck = false;
|
|
214
|
+
|
|
215
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
216
|
+
const arg = argv[index];
|
|
217
|
+
|
|
218
|
+
if (arg === '--foreground' || arg === '-F') {
|
|
219
|
+
foreground = true;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (arg === '--dev' || arg === '-d') {
|
|
224
|
+
dev = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (arg === '--completion') {
|
|
229
|
+
completionShell = requireValue(argv, index, arg);
|
|
230
|
+
index += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (arg === '--completion-install') {
|
|
235
|
+
completionInstallShell = requireValue(argv, index, arg);
|
|
236
|
+
index += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (arg === '--completion-uninstall') {
|
|
241
|
+
completionUninstallShell = requireValue(argv, index, arg);
|
|
242
|
+
index += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (arg === '--show-config') {
|
|
247
|
+
showConfig = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (arg === '--config-check') {
|
|
252
|
+
configCheck = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (arg === '--profile-list') {
|
|
257
|
+
profileList = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (arg === '--config-path') {
|
|
262
|
+
configPathOnly = true;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (arg === '--config-example') {
|
|
267
|
+
configExample = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (arg === '--diagnostics') {
|
|
272
|
+
diagnostics = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (arg === '--doctor') {
|
|
277
|
+
doctor = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (arg === '--open-log-dir') {
|
|
282
|
+
openLogDir = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (arg === '--copy-diagnostics') {
|
|
287
|
+
copyDiagnostics = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (arg === '--plugin-list') {
|
|
292
|
+
pluginList = true;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (arg === '--plugin-path') {
|
|
297
|
+
pluginPath = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (arg === '--plugin-info') {
|
|
302
|
+
pluginInfo = requireValue(argv, index, arg);
|
|
303
|
+
index += 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (arg === '--plugin-uninstall') {
|
|
308
|
+
pluginUninstall.push(...parsePluginSelectors(requireValue(argv, index, arg), arg));
|
|
309
|
+
index += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (arg === '--plugin-install') {
|
|
314
|
+
pluginInstall = requireValue(argv, index, arg);
|
|
315
|
+
index += 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (arg === '--plugin-ports-dir') {
|
|
320
|
+
pluginPortsDir = requireValue(argv, index, arg);
|
|
321
|
+
index += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (arg === '--plugin-install-file') {
|
|
326
|
+
pluginInstallFile = requireValue(argv, index, arg);
|
|
327
|
+
index += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (arg === '--force') {
|
|
332
|
+
pluginInstallForce = true;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (arg === '--enable') {
|
|
337
|
+
pluginInstallEnable = true;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (arg === '--list' || arg === '-l') {
|
|
342
|
+
listInstances = true;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (arg === '--close' || arg === '-q') {
|
|
347
|
+
closeTarget = requireValue(argv, index, arg);
|
|
348
|
+
index += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (arg === '--setup-sync') {
|
|
353
|
+
setupSync = true;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (arg === '--sync-status') {
|
|
358
|
+
syncStatus = true;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (arg === '--sync-clean') {
|
|
363
|
+
syncClean = true;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (arg === '--sync-diagnostics') {
|
|
368
|
+
syncDiagnostics = true;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (arg === '--broadcast') {
|
|
373
|
+
broadcastText = requireValue(argv, index, arg);
|
|
374
|
+
index += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (arg === '--broadcast-target') {
|
|
379
|
+
broadcastTargets.push(...parseBroadcastSelectors(requireValue(argv, index, arg), arg));
|
|
380
|
+
index += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (arg === '--broadcast-sync') {
|
|
385
|
+
broadcastSync = true;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (arg === '--self-update') {
|
|
390
|
+
selfUpdate = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (arg === '--update-check') {
|
|
395
|
+
updateCheck = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (arg === '--self-update-checkout') {
|
|
400
|
+
selfUpdateCheckout = true;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (arg === '--update-desktop') {
|
|
405
|
+
updateDesktop = true;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (arg === '--reset-window-state' || arg === '-r') {
|
|
410
|
+
resetWindowState = true;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (arg === '--reset-config' || arg === '-R') {
|
|
415
|
+
resetConfig = true;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (arg === '--update-config') {
|
|
420
|
+
updateConfig = true;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (arg === '--prune-config') {
|
|
425
|
+
pruneConfig = true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (arg === '--command' || arg === '-e') {
|
|
430
|
+
startCommand = requireValue(argv, index, arg);
|
|
431
|
+
index += 1;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (arg === '--shell' || arg === '-s') {
|
|
436
|
+
env.FPASOTERM_SHELL = requireValue(argv, index, arg);
|
|
437
|
+
index += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (arg === '--cwd' || arg === '-o') {
|
|
442
|
+
const directory = path.resolve(requireValue(argv, index, arg));
|
|
443
|
+
let metadata;
|
|
444
|
+
try {
|
|
445
|
+
metadata = fs.statSync(directory);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
throw new Error(`${arg} directory is unavailable: ${error.message}`);
|
|
448
|
+
}
|
|
449
|
+
if (!metadata.isDirectory()) {
|
|
450
|
+
throw new Error(`${arg} must name an existing directory`);
|
|
451
|
+
}
|
|
452
|
+
env.FPASOTERM_START_CWD = directory;
|
|
453
|
+
index += 1;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (arg === '--title' || arg === '-t') {
|
|
458
|
+
env.FPASOTERM_WINDOW_TITLE = requireValue(argv, index, arg);
|
|
459
|
+
index += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (arg === '--titlebar-color' || arg === '-b') {
|
|
464
|
+
env.FPASOTERM_TITLEBAR_COLOR = requireValue(argv, index, arg);
|
|
465
|
+
index += 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (arg === '--enable-plugin' || arg === '--plugin-enable') {
|
|
470
|
+
enablePlugins.push(...parsePluginSelectors(requireValue(argv, index, arg), arg));
|
|
471
|
+
index += 1;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (arg === '--disable-plugin' || arg === '--plugin-disable') {
|
|
476
|
+
disablePlugins.push(...parsePluginSelectors(requireValue(argv, index, arg), arg));
|
|
477
|
+
index += 1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (arg === '--plugin-enable-all') {
|
|
482
|
+
enableAllPlugins = true;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (arg === '--plugin-disable-all') {
|
|
487
|
+
disableAllPlugins = true;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (arg === '--console-diagnostics' || arg === '-C') {
|
|
492
|
+
env.FPASOTERM_CONSOLE_DIAGNOSTICS = '1';
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (arg === '--debug-keys' || arg === '-k') {
|
|
497
|
+
env.FPASOTERM_DEBUG_KEYS = '1';
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (arg === '--disable-dmabuf') {
|
|
502
|
+
env.FPASOTERM_DISABLE_DMABUF = '1';
|
|
503
|
+
env.WEBKIT_DISABLE_DMABUF_RENDERER = '1';
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (arg === '--config' || arg === '-c') {
|
|
508
|
+
env.FPASOTERM_CONFIG_PATH = requireValue(argv, index, arg);
|
|
509
|
+
index += 1;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (arg === '--profile' || arg === '-p') {
|
|
514
|
+
env.FPASOTERM_PROFILE = requireValue(argv, index, arg);
|
|
515
|
+
index += 1;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (arg === '--width' || arg === '-W') {
|
|
520
|
+
env.FPASOTERM_WINDOW_WIDTH = requirePositiveInteger(requireValue(argv, index, arg), arg);
|
|
521
|
+
index += 1;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (arg === '--height' || arg === '-H') {
|
|
526
|
+
env.FPASOTERM_WINDOW_HEIGHT = requirePositiveInteger(requireValue(argv, index, arg), arg);
|
|
527
|
+
index += 1;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (arg === '--size' || arg === '-z') {
|
|
532
|
+
const value = requireValue(argv, index, arg);
|
|
533
|
+
const match = value.match(/^(\d+)x(\d+)$/i);
|
|
534
|
+
if (!match) {
|
|
535
|
+
throw new Error(`${arg} must be formatted as <width>x<height>`);
|
|
536
|
+
}
|
|
537
|
+
env.FPASOTERM_WINDOW_WIDTH = requirePositiveInteger(match[1], '--width');
|
|
538
|
+
env.FPASOTERM_WINDOW_HEIGHT = requirePositiveInteger(match[2], '--height');
|
|
539
|
+
index += 1;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
throw new Error(arg.startsWith('-')
|
|
544
|
+
? `unknown option: ${arg}`
|
|
545
|
+
: `unexpected argument: ${arg}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (enableAllPlugins && disableAllPlugins) {
|
|
549
|
+
throw new Error('--plugin-enable-all and --plugin-disable-all cannot be used together');
|
|
550
|
+
}
|
|
551
|
+
if ((broadcastTargets.length > 0 || broadcastSync) && !broadcastText) {
|
|
552
|
+
throw new Error('--broadcast-target and --broadcast-sync require --broadcast <text>');
|
|
553
|
+
}
|
|
554
|
+
const pluginInstallCount = [pluginInstall, pluginInstallFile].filter(Boolean).length;
|
|
555
|
+
if (pluginInstallCount > 1) {
|
|
556
|
+
throw new Error('use only one of --plugin-install or --plugin-install-file');
|
|
557
|
+
}
|
|
558
|
+
if ((pluginInstallForce || pluginInstallEnable) && pluginInstallCount === 0) {
|
|
559
|
+
throw new Error('--force and --enable require a --plugin-install command');
|
|
560
|
+
}
|
|
561
|
+
if (pluginPortsDir && !pluginInstall) {
|
|
562
|
+
throw new Error('--plugin-ports-dir requires --plugin-install <category/name>');
|
|
563
|
+
}
|
|
564
|
+
if (pluginUninstall.length > 0 && (
|
|
565
|
+
enablePlugins.length > 0 || disablePlugins.length > 0 || enableAllPlugins || disableAllPlugins || pluginInstallCount > 0
|
|
566
|
+
)) {
|
|
567
|
+
throw new Error('--plugin-uninstall cannot be combined with other plugin mutation options');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
help: false,
|
|
572
|
+
version: false,
|
|
573
|
+
foreground,
|
|
574
|
+
dev,
|
|
575
|
+
completionShell,
|
|
576
|
+
completionInstallShell,
|
|
577
|
+
completionUninstallShell,
|
|
578
|
+
showConfig,
|
|
579
|
+
configCheck,
|
|
580
|
+
profileList,
|
|
581
|
+
configPathOnly,
|
|
582
|
+
configExample,
|
|
583
|
+
diagnostics,
|
|
584
|
+
doctor,
|
|
585
|
+
openLogDir,
|
|
586
|
+
copyDiagnostics,
|
|
587
|
+
pluginList,
|
|
588
|
+
pluginPath,
|
|
589
|
+
pluginInfo,
|
|
590
|
+
pluginUninstall,
|
|
591
|
+
pluginInstall,
|
|
592
|
+
pluginPortsDir,
|
|
593
|
+
pluginInstallFile,
|
|
594
|
+
pluginInstallForce,
|
|
595
|
+
pluginInstallEnable,
|
|
596
|
+
listInstances,
|
|
597
|
+
closeTarget,
|
|
598
|
+
setupSync,
|
|
599
|
+
syncStatus,
|
|
600
|
+
syncClean,
|
|
601
|
+
syncDiagnostics,
|
|
602
|
+
broadcastText,
|
|
603
|
+
broadcastTargets,
|
|
604
|
+
broadcastSync,
|
|
605
|
+
resetWindowState,
|
|
606
|
+
resetConfig,
|
|
607
|
+
updateConfig,
|
|
608
|
+
pruneConfig,
|
|
609
|
+
selfUpdate,
|
|
610
|
+
updateCheck,
|
|
611
|
+
selfUpdateCheckout,
|
|
612
|
+
updateDesktop,
|
|
613
|
+
env: {
|
|
614
|
+
...env,
|
|
615
|
+
...(startCommand ? { FPASOTERM_START_COMMAND: startCommand } : {}),
|
|
616
|
+
},
|
|
617
|
+
startCommand,
|
|
618
|
+
enablePlugins,
|
|
619
|
+
disablePlugins,
|
|
620
|
+
enableAllPlugins,
|
|
621
|
+
disableAllPlugins,
|
|
622
|
+
runtimeArgs,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Rejects unknown options before help/version handling can bypass validation.
|
|
627
|
+
function validateArgs(argv) {
|
|
628
|
+
const flags = new Set([
|
|
629
|
+
'--help', '-h', '--version', '-v', '--list', '-l', '--dev', '-d', '--foreground', '-F',
|
|
630
|
+
'--console-diagnostics', '-C', '--show-config', '--config-check', '--config-path', '--config-example',
|
|
631
|
+
'--diagnostics', '--doctor', '--open-log-dir', '--copy-diagnostics', '--profile-list', '--plugin-list', '--plugin-path', '--plugin-search', '--setup-sync', '--sync-status', '--sync-clean', '--sync-diagnostics', '--self-update',
|
|
632
|
+
'--self-update-checkout', '--update-desktop', '--update-check', '--reset-window-state', '-r', '--reset-config', '-R',
|
|
633
|
+
'--update-config',
|
|
634
|
+
'--prune-config', '--plugin-enable-all', '--plugin-disable-all', '--force', '--enable',
|
|
635
|
+
'--debug-keys', '-k', '--disable-dmabuf', '--broadcast-sync',
|
|
636
|
+
]);
|
|
637
|
+
const valueOptions = new Set([
|
|
638
|
+
'--config', '-c', '--profile', '-p', '--shell', '-s', '--cwd', '-o', '--command', '-e', '--title', '-t',
|
|
639
|
+
'--titlebar-color', '-b', '--close', '-q', '--enable-plugin', '--disable-plugin', '--plugin-enable', '--plugin-disable', '--plugin-info', '--plugin-uninstall', '--width', '-W',
|
|
640
|
+
'--height', '-H', '--size', '-z', '--broadcast', '--broadcast-target', '--completion',
|
|
641
|
+
'--completion-install', '--completion-uninstall', '--plugin-install', '--plugin-ports-dir', '--plugin-install-file',
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
645
|
+
const arg = argv[index];
|
|
646
|
+
if (arg === '--plugin-search') {
|
|
647
|
+
if (argv[index + 1] && !argv[index + 1].startsWith('-')) index += 1;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (flags.has(arg)) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (valueOptions.has(arg)) {
|
|
654
|
+
requireValue(argv, index, arg);
|
|
655
|
+
index += 1;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
throw new Error(arg.startsWith('-')
|
|
659
|
+
? `unknown option: ${arg}`
|
|
660
|
+
: `unexpected argument: ${arg}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Prints the package version and the checkout commit without starting the desktop process.
|
|
665
|
+
function printVersion() {
|
|
666
|
+
console.log(versionText());
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Returns one stable build identifier for CLI output and diagnostics reports.
|
|
670
|
+
function versionText() {
|
|
671
|
+
if (fs.existsSync(path.join(root, '.jj'))) {
|
|
672
|
+
const jjResult = spawnSync('jj', ['-R', root, 'log', '-r', '@', '--no-graph', '-T', 'commit_id.short(12)'], {
|
|
673
|
+
encoding: 'utf8',
|
|
674
|
+
windowsHide: true,
|
|
675
|
+
});
|
|
676
|
+
if (jjResult.status === 0 && jjResult.stdout.trim()) {
|
|
677
|
+
return `fpasoterm ${packageJson.version} (commit ${jjResult.stdout.trim()})`;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const gitResult = spawnSync('git', ['-C', root, 'rev-parse', '--short=12', 'HEAD'], {
|
|
681
|
+
encoding: 'utf8',
|
|
682
|
+
windowsHide: true,
|
|
683
|
+
});
|
|
684
|
+
const commit = gitResult.status === 0 ? gitResult.stdout.trim() : 'unknown';
|
|
685
|
+
return `fpasoterm ${packageJson.version} (commit ${commit})`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Compares stable npm package versions without making normal help/version calls network-bound.
|
|
689
|
+
function comparePackageVersions(left, right) {
|
|
690
|
+
const parse = (value) => String(value).split('.').map((part) => Number.parseInt(part, 10));
|
|
691
|
+
const leftParts = parse(left);
|
|
692
|
+
const rightParts = parse(right);
|
|
693
|
+
if (leftParts.length !== 3 || rightParts.length !== 3 || [...leftParts, ...rightParts].some(Number.isNaN)) {
|
|
694
|
+
throw new Error(`cannot compare package versions: ${left} and ${right}`);
|
|
695
|
+
}
|
|
696
|
+
for (let index = 0; index < 3; index += 1) {
|
|
697
|
+
if (leftParts[index] !== rightParts[index]) {
|
|
698
|
+
return leftParts[index] - rightParts[index];
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return 0;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Queries npm only for an explicit update check so ordinary CLI commands stay fast offline.
|
|
705
|
+
async function checkForUpdate() {
|
|
706
|
+
const response = await fetch('https://registry.npmjs.org/fpasoterm/latest', {
|
|
707
|
+
headers: { accept: 'application/json', 'user-agent': `fpasoterm/${packageJson.version}` },
|
|
708
|
+
signal: AbortSignal.timeout(10_000),
|
|
709
|
+
});
|
|
710
|
+
if (!response.ok) {
|
|
711
|
+
throw new Error(`npm registry returned HTTP ${response.status}`);
|
|
712
|
+
}
|
|
713
|
+
const metadata = await response.json();
|
|
714
|
+
const latest = String(metadata?.version || '');
|
|
715
|
+
if (!/^\d+\.\d+\.\d+$/.test(latest)) {
|
|
716
|
+
throw new Error('npm registry did not return a stable latest version');
|
|
717
|
+
}
|
|
718
|
+
const comparison = comparePackageVersions(packageJson.version, latest);
|
|
719
|
+
console.log(`installed: ${versionText()}`);
|
|
720
|
+
console.log(`latest npm release: ${latest}`);
|
|
721
|
+
if (comparison < 0) {
|
|
722
|
+
console.log('status: update available');
|
|
723
|
+
console.log('update: fpasoterm --self-update');
|
|
724
|
+
} else if (comparison === 0) {
|
|
725
|
+
console.log('status: up to date');
|
|
726
|
+
} else {
|
|
727
|
+
console.log('status: local build is newer than npm latest');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Runs npm audit without modifying dependencies and summarizes JSON output for doctor reports.
|
|
732
|
+
function npmAuditSummary() {
|
|
733
|
+
try {
|
|
734
|
+
const npm = npmInvocation(['audit', '--omit=dev', '--json']);
|
|
735
|
+
const result = spawnSync(npm.command, npm.args, {
|
|
736
|
+
cwd: root,
|
|
737
|
+
encoding: 'utf8',
|
|
738
|
+
windowsHide: true,
|
|
739
|
+
});
|
|
740
|
+
if (result.error) return { status: 'unavailable', detail: result.error.message };
|
|
741
|
+
const report = JSON.parse(result.stdout || '{}');
|
|
742
|
+
const vulnerabilities = report.metadata?.vulnerabilities || {};
|
|
743
|
+
const total = Number(vulnerabilities.total || 0);
|
|
744
|
+
return {
|
|
745
|
+
status: total === 0 && result.status === 0 ? 'ok' : 'warnings',
|
|
746
|
+
detail: `total=${total} low=${vulnerabilities.low || 0} moderate=${vulnerabilities.moderate || 0} high=${vulnerabilities.high || 0} critical=${vulnerabilities.critical || 0}`,
|
|
747
|
+
};
|
|
748
|
+
} catch (error) {
|
|
749
|
+
return { status: 'unavailable', detail: String(error.message || error) };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Prints a read-only maintenance report; updates remain an explicit --self-update action.
|
|
754
|
+
async function runDoctor(options) {
|
|
755
|
+
applyEnv(options.env);
|
|
756
|
+
const config = validateUserConfig(configPath());
|
|
757
|
+
console.log('## fpasoterm doctor');
|
|
758
|
+
console.log('');
|
|
759
|
+
console.log(`- version: ${versionText()}`);
|
|
760
|
+
console.log(`- config: ${config.configPath}`);
|
|
761
|
+
console.log(`- config status: ${config.error ? 'error' : 'ok'}`);
|
|
762
|
+
for (const warning of config.warnings) console.log(`- config warning: ${warning}`);
|
|
763
|
+
if (config.error) console.log(`- config error: ${config.error}`);
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
const response = await fetch('https://registry.npmjs.org/fpasoterm/latest', {
|
|
767
|
+
headers: { accept: 'application/json', 'user-agent': `fpasoterm/${packageJson.version}` },
|
|
768
|
+
signal: AbortSignal.timeout(10_000),
|
|
769
|
+
});
|
|
770
|
+
if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`);
|
|
771
|
+
const latest = String((await response.json())?.version || '');
|
|
772
|
+
const comparison = comparePackageVersions(packageJson.version, latest);
|
|
773
|
+
console.log(`- npm latest: ${latest}`);
|
|
774
|
+
console.log(`- update: ${comparison < 0 ? 'available; run fpasoterm --self-update' : comparison === 0 ? 'up to date' : 'local build is newer'}`);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
console.log(`- update: unavailable (${error.message || error})`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const audit = npmAuditSummary();
|
|
780
|
+
console.log(`- npm audit: ${audit.status} (${audit.detail})`);
|
|
781
|
+
console.log('');
|
|
782
|
+
console.log('Doctor is read-only. Review findings, then run --self-update or fix config.toml explicitly.');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Searches public port metadata only; source is neither downloaded nor executed.
|
|
786
|
+
async function searchPublicPlugins(query) {
|
|
787
|
+
const response = await fetch('https://raw.githubusercontent.com/oyoguhito/fpasoterm-plugins/main/INDEX', {
|
|
788
|
+
signal: AbortSignal.timeout(10_000),
|
|
789
|
+
});
|
|
790
|
+
if (!response.ok) throw new Error(`public plugin index returned HTTP ${response.status}`);
|
|
791
|
+
const needle = String(query || '').trim().toLocaleLowerCase();
|
|
792
|
+
const ports = (await response.text()).split(/\r?\n/).filter(Boolean).map((line) => line.split('|'))
|
|
793
|
+
.filter((fields) => fields.length === 8)
|
|
794
|
+
.filter((fields) => fields.slice(0, 5).join(' ').toLocaleLowerCase().includes(needle));
|
|
795
|
+
process.stdout.write(`Public plugins${needle ? ` matching "${query}"` : ''}:\nsource: remote official INDEX (oyoguhito/fpasoterm-plugins)\n`);
|
|
796
|
+
if (ports.length === 0) return process.stdout.write(' (none)\n');
|
|
797
|
+
for (const [id, name, version, author, description, , minimum] of ports) {
|
|
798
|
+
process.stdout.write(` ${id} (${name} ${version}, ${author}, requires >= ${minimum})\n ${description}\n install: fpasoterm --plugin-install ${id} --enable\n`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Validates a public catalog ID before it is used to construct a fixed-repository URL.
|
|
803
|
+
function validatePublicPluginPortId(value) {
|
|
804
|
+
const id = String(value || '').trim();
|
|
805
|
+
const parts = id.split('/');
|
|
806
|
+
if (parts.length < 2 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) {
|
|
807
|
+
throw new Error(`invalid public plugin port '${value}'; use a category/name identifier`);
|
|
808
|
+
}
|
|
809
|
+
return parts.join('/');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Downloads bounded UTF-8 metadata or source only from the official public ports repository.
|
|
813
|
+
async function fetchPublicPluginText(relativePath, maximumBytes) {
|
|
814
|
+
const response = await fetch(`https://raw.githubusercontent.com/oyoguhito/fpasoterm-plugins/main/${relativePath}`, {
|
|
815
|
+
headers: { accept: 'text/plain', 'user-agent': `fpasoterm/${packageJson.version}` },
|
|
816
|
+
signal: AbortSignal.timeout(20_000),
|
|
817
|
+
});
|
|
818
|
+
if (!response.ok) throw new Error(`public plugin download returned HTTP ${response.status}`);
|
|
819
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
820
|
+
if (contentLength > maximumBytes) throw new Error(`public plugin download exceeds ${maximumBytes} bytes`);
|
|
821
|
+
const text = await response.text();
|
|
822
|
+
if (Buffer.byteLength(text, 'utf8') > maximumBytes) {
|
|
823
|
+
throw new Error(`public plugin download exceeds ${maximumBytes} bytes`);
|
|
824
|
+
}
|
|
825
|
+
return text;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Parses and validates the narrow port manifest contract shared by remote and local sources.
|
|
829
|
+
function parsePluginPortManifest(id, manifestText, label) {
|
|
830
|
+
const manifest = toml.parse(manifestText)?.port;
|
|
831
|
+
const required = (key) => {
|
|
832
|
+
const value = typeof manifest?.[key] === 'string' ? manifest[key].trim() : '';
|
|
833
|
+
if (!value) throw new Error(`${label} port manifest is missing port.${key}`);
|
|
834
|
+
return value;
|
|
835
|
+
};
|
|
836
|
+
if (required('id') !== id) throw new Error(`${label} port manifest id mismatch: requested ${id}`);
|
|
837
|
+
const version = required('version');
|
|
838
|
+
const minimumVersion = required('minFpasotermVersion');
|
|
839
|
+
const sourceName = required('source');
|
|
840
|
+
const installPath = required('installPath').replaceAll('\\', '/');
|
|
841
|
+
if (!/^\d+\.\d+\.\d+$/.test(version) || !/^\d+\.\d+\.\d+$/.test(minimumVersion)) {
|
|
842
|
+
throw new Error(`${label} port manifest has an invalid version`);
|
|
843
|
+
}
|
|
844
|
+
if (comparePackageVersions(packageJson.version, minimumVersion) < 0) {
|
|
845
|
+
throw new Error(`${id} requires fpasoterm >= ${minimumVersion}; found ${packageJson.version}`);
|
|
846
|
+
}
|
|
847
|
+
if (!/^[^/\\]+\.(?:js|ts)$/.test(sourceName)
|
|
848
|
+
|| !/^(?:[A-Za-z0-9_-]+\/)+[A-Za-z0-9_-]+\.(?:js|ts)$/.test(installPath)) {
|
|
849
|
+
throw new Error(`${label} port manifest has an unsafe source or install path`);
|
|
850
|
+
}
|
|
851
|
+
return { version, sourceName, installPath };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Writes one reviewed source into User/plugins without following an existing symlink.
|
|
855
|
+
function installPluginSource(options, source, installPath, origin, version) {
|
|
856
|
+
if (!source.includes(`@fpasoterm-plugin version: ${version}`)
|
|
857
|
+
|| !source.includes('window.fpasotermPluginApi')) {
|
|
858
|
+
throw new Error(`${origin} does not contain a valid fpasoterm plugin header`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
applyEnv(options.env);
|
|
862
|
+
const targetPath = configPath();
|
|
863
|
+
const pluginsDirectory = path.join(path.dirname(targetPath), 'plugins');
|
|
864
|
+
fs.mkdirSync(pluginsDirectory, { recursive: true });
|
|
865
|
+
const destination = path.resolve(pluginsDirectory, installPath);
|
|
866
|
+
if (!destination.startsWith(`${path.resolve(pluginsDirectory)}${path.sep}`)) {
|
|
867
|
+
throw new Error('public port destination escapes User/plugins');
|
|
868
|
+
}
|
|
869
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
870
|
+
if (fs.existsSync(destination)) {
|
|
871
|
+
const metadata = fs.lstatSync(destination);
|
|
872
|
+
if (metadata.isSymbolicLink()) throw new Error(`refusing to replace symlinked plugin ${destination}`);
|
|
873
|
+
if (!metadata.isFile()) throw new Error(`cannot replace non-file plugin ${destination}`);
|
|
874
|
+
if (!options.pluginInstallForce) {
|
|
875
|
+
throw new Error(`${destination} already exists; review it and rerun with --force to replace it`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const temporary = `${destination}.fpasoterm-download-${process.pid}.tmp`;
|
|
879
|
+
fs.writeFileSync(temporary, source, 'utf8');
|
|
880
|
+
try {
|
|
881
|
+
fs.renameSync(temporary, destination);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
if (fs.existsSync(destination) && options.pluginInstallForce) {
|
|
884
|
+
fs.unlinkSync(destination);
|
|
885
|
+
fs.renameSync(temporary, destination);
|
|
886
|
+
} else {
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const plugin = `plugins/${installPath}`;
|
|
892
|
+
console.log(`installed ${origin} -> ${plugin}`);
|
|
893
|
+
if (options.pluginInstallEnable) {
|
|
894
|
+
const userConfig = readUserConfig(targetPath);
|
|
895
|
+
const plugins = userConfig.plugins && typeof userConfig.plugins === 'object' ? userConfig.plugins : {};
|
|
896
|
+
const enabled = new Set(Array.isArray(plugins.enabled) ? plugins.enabled : []);
|
|
897
|
+
enabled.add(plugin);
|
|
898
|
+
writeUserConfig({ ...userConfig, plugins: { ...plugins, enabled: [...enabled].sort() } }, targetPath);
|
|
899
|
+
console.log(`enabled installed plugin in ${targetPath}`);
|
|
900
|
+
} else {
|
|
901
|
+
console.log(`review the source, then enable it with: fpasoterm --plugin-enable ${installPath.replace(/\.(?:js|ts)$/, '')}`);
|
|
902
|
+
}
|
|
903
|
+
console.log('Restart fpasoterm to load the installed plugin selection.');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Installs one reviewed public port without starting Cargo or the native desktop runtime.
|
|
907
|
+
async function installPublicPlugin(options) {
|
|
908
|
+
const id = validatePublicPluginPortId(options.pluginInstall);
|
|
909
|
+
const manifestText = await fetchPublicPluginText(`ports/${id}/port.toml`, 64 * 1024);
|
|
910
|
+
const { version, sourceName, installPath } = parsePluginPortManifest(id, manifestText, 'public');
|
|
911
|
+
const source = await fetchPublicPluginText(`ports/${id}/${sourceName}`, 1024 * 1024);
|
|
912
|
+
installPluginSource(options, source, installPath, `public port ${id}`, version);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Resolves a local ports checkout from an explicit path, environment, or sibling repository.
|
|
916
|
+
function localPluginPortsDirectory(options) {
|
|
917
|
+
const directory = path.resolve(options.pluginPortsDir || '');
|
|
918
|
+
if (!options.pluginPortsDir || !fs.existsSync(path.join(directory, 'ports'))) {
|
|
919
|
+
throw new Error('local fpasoterm-plugins checkout not found; pass --plugin-ports-dir <path>');
|
|
920
|
+
}
|
|
921
|
+
return directory;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Reads a bounded regular local file so an arbitrary path cannot be followed through a symlink.
|
|
925
|
+
function readLocalPluginText(filePath, maximumBytes, label) {
|
|
926
|
+
const metadata = fs.lstatSync(filePath, { throwIfNoEntry: false });
|
|
927
|
+
if (!metadata || !metadata.isFile() || metadata.isSymbolicLink()) {
|
|
928
|
+
throw new Error(`${label} must be a regular file`);
|
|
929
|
+
}
|
|
930
|
+
if (metadata.size > maximumBytes) throw new Error(`${label} exceeds ${maximumBytes} bytes`);
|
|
931
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Installs a port from a checked-out fpasoterm-plugins tree for local development and review.
|
|
935
|
+
function installLocalPluginPort(options) {
|
|
936
|
+
const id = validatePublicPluginPortId(options.pluginInstall);
|
|
937
|
+
const portsDirectory = localPluginPortsDirectory(options);
|
|
938
|
+
const portDirectory = path.join(portsDirectory, 'ports', ...id.split('/'));
|
|
939
|
+
const manifestText = readLocalPluginText(path.join(portDirectory, 'port.toml'), 64 * 1024, 'local port manifest');
|
|
940
|
+
const { version, sourceName, installPath } = parsePluginPortManifest(id, manifestText, 'local');
|
|
941
|
+
const source = readLocalPluginText(path.join(portDirectory, sourceName), 1024 * 1024, 'local plugin source');
|
|
942
|
+
installPluginSource(options, source, installPath, `local port ${id}`, version);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Copies one explicit, trusted local renderer plugin without needing a ports checkout.
|
|
946
|
+
function installLocalPluginFile(options) {
|
|
947
|
+
const filePath = path.resolve(options.pluginInstallFile);
|
|
948
|
+
if (!/\.(?:js|ts)$/i.test(filePath)) throw new Error('--plugin-install-file requires a .js or .ts file');
|
|
949
|
+
const source = readLocalPluginText(filePath, 1024 * 1024, 'local plugin source');
|
|
950
|
+
const version = pluginMetadata(source).version;
|
|
951
|
+
if (!version || version === '(not declared)') {
|
|
952
|
+
throw new Error('local plugin source must declare // @fpasoterm-plugin version: <version>');
|
|
953
|
+
}
|
|
954
|
+
installPluginSource(options, source, path.basename(filePath), `local plugin ${path.basename(filePath)}`, version);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Prints a shell-specific completion script without resolving or launching the app runtime.
|
|
958
|
+
function printCompletion(shell) {
|
|
959
|
+
const fileNames = {
|
|
960
|
+
bash: 'fpasoterm.bash',
|
|
961
|
+
zsh: '_fpasoterm',
|
|
962
|
+
fish: 'fpasoterm.fish',
|
|
963
|
+
powershell: 'fpasoterm.ps1',
|
|
964
|
+
};
|
|
965
|
+
const normalized = String(shell || '').toLowerCase();
|
|
966
|
+
const fileName = fileNames[normalized];
|
|
967
|
+
if (!fileName) {
|
|
968
|
+
throw new Error(`unsupported completion shell: ${shell} (expected bash, zsh, fish, or powershell)`);
|
|
969
|
+
}
|
|
970
|
+
process.stdout.write(fs.readFileSync(path.join(root, 'completions', fileName), 'utf8'));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Normalizes and validates shell names shared by completion output and file management.
|
|
974
|
+
function completionFileName(shell) {
|
|
975
|
+
const fileNames = {
|
|
976
|
+
bash: 'fpasoterm.bash',
|
|
977
|
+
zsh: '_fpasoterm',
|
|
978
|
+
fish: 'fpasoterm.fish',
|
|
979
|
+
powershell: 'fpasoterm.ps1',
|
|
980
|
+
};
|
|
981
|
+
const normalized = String(shell || '').toLowerCase();
|
|
982
|
+
if (!fileNames[normalized]) {
|
|
983
|
+
throw new Error(`unsupported completion shell: ${shell} (expected bash, zsh, fish, or powershell)`);
|
|
984
|
+
}
|
|
985
|
+
return { shell: normalized, fileName: fileNames[normalized] };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Resolves a user-owned location so install and uninstall never alter system completion files.
|
|
989
|
+
function completionInstallPath(shell) {
|
|
990
|
+
const normalized = completionFileName(shell).shell;
|
|
991
|
+
if (normalized === 'bash') {
|
|
992
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'bash-completion', 'completions', 'fpasoterm');
|
|
993
|
+
}
|
|
994
|
+
if (normalized === 'zsh') {
|
|
995
|
+
return path.join(os.homedir(), '.zfunc', '_fpasoterm');
|
|
996
|
+
}
|
|
997
|
+
if (normalized === 'fish') {
|
|
998
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'fish', 'completions', 'fpasoterm.fish');
|
|
999
|
+
}
|
|
1000
|
+
if (process.platform !== 'win32') {
|
|
1001
|
+
throw new Error('powershell completion installation is supported on Windows only');
|
|
1002
|
+
}
|
|
1003
|
+
return path.join(path.dirname(configPath()), 'completions', 'fpasoterm.ps1');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Decodes the UTF-16LE profile path represented as ASCII-safe Base64 by PowerShell.
|
|
1007
|
+
function decodePowerShellProfilePath(encodedPath) {
|
|
1008
|
+
const bytes = Buffer.from(encodedPath, 'base64');
|
|
1009
|
+
if (bytes.length === 0 || bytes.length % 2 !== 0) {
|
|
1010
|
+
return '';
|
|
1011
|
+
}
|
|
1012
|
+
return bytes.toString('utf16le').replace(/^\uFEFF/, '');
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Asks every available Windows PowerShell host for its profile path rather than assuming Documents is not redirected.
|
|
1016
|
+
function powershellProfilePaths() {
|
|
1017
|
+
const profiles = new Map();
|
|
1018
|
+
for (const command of ['pwsh.exe', 'powershell.exe']) {
|
|
1019
|
+
const result = spawnSync(command, ['-NoProfile', '-Command', '[Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($PROFILE.CurrentUserCurrentHost))'], {
|
|
1020
|
+
encoding: 'utf8',
|
|
1021
|
+
windowsHide: true,
|
|
1022
|
+
});
|
|
1023
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
1024
|
+
const profilePath = decodePowerShellProfilePath(result.stdout.trim());
|
|
1025
|
+
if (profilePath) {
|
|
1026
|
+
profiles.set(profilePath.toLowerCase(), profilePath);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (profiles.size > 0) {
|
|
1031
|
+
return [...profiles.values()];
|
|
1032
|
+
}
|
|
1033
|
+
throw new Error('PowerShell was not found; install it or use --completion powershell manually');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const powershellCompletionStart = '# >>> fpasoterm completion >>>';
|
|
1037
|
+
const powershellCompletionEnd = '# <<< fpasoterm completion <<<';
|
|
1038
|
+
|
|
1039
|
+
// Escapes marker text before using it to identify fpasoterm's profile block.
|
|
1040
|
+
function escapeRegularExpression(value) {
|
|
1041
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Removes only the managed block, preserving every other user PowerShell profile setting.
|
|
1045
|
+
function updatePowershellProfile(profilePath, scriptPath) {
|
|
1046
|
+
const existing = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '';
|
|
1047
|
+
const block = new RegExp(`\\r?\\n?${escapeRegularExpression(powershellCompletionStart)}[\\s\\S]*?${escapeRegularExpression(powershellCompletionEnd)}\\r?\\n?`, 'g');
|
|
1048
|
+
const hadManagedBlock = existing.includes(powershellCompletionStart);
|
|
1049
|
+
const withoutExistingBlock = existing.replace(block, '').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
1050
|
+
if (scriptPath === null) {
|
|
1051
|
+
if (hadManagedBlock && !withoutExistingBlock) {
|
|
1052
|
+
fs.unlinkSync(profilePath);
|
|
1053
|
+
} else if (hadManagedBlock) {
|
|
1054
|
+
fs.writeFileSync(profilePath, withoutExistingBlock ? `${withoutExistingBlock}\n` : '', 'utf8');
|
|
1055
|
+
}
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
fs.mkdirSync(path.dirname(profilePath), { recursive: true });
|
|
1059
|
+
const escapedPath = scriptPath.replace(/'/g, "''");
|
|
1060
|
+
const managedBlock = `${powershellCompletionStart}\n. '${escapedPath}'\n${powershellCompletionEnd}\n`;
|
|
1061
|
+
fs.writeFileSync(profilePath, `${withoutExistingBlock}${withoutExistingBlock ? '\n\n' : ''}${managedBlock}`, 'utf8');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Installs a completion file for later shell sessions without launching the app runtime.
|
|
1065
|
+
function installCompletion(shell) {
|
|
1066
|
+
const { shell: normalized, fileName } = completionFileName(shell);
|
|
1067
|
+
const targetPath = completionInstallPath(normalized);
|
|
1068
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1069
|
+
fs.copyFileSync(path.join(root, 'completions', fileName), targetPath);
|
|
1070
|
+
if (normalized === 'powershell') {
|
|
1071
|
+
const profilePaths = powershellProfilePaths();
|
|
1072
|
+
for (const profilePath of profilePaths) {
|
|
1073
|
+
updatePowershellProfile(profilePath, targetPath);
|
|
1074
|
+
}
|
|
1075
|
+
console.log(`updated PowerShell profile${profilePaths.length === 1 ? '' : 's'}: ${profilePaths.join(', ')}`);
|
|
1076
|
+
console.log('restart PowerShell, or run: . $PROFILE');
|
|
1077
|
+
console.log('if scripts are blocked: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned');
|
|
1078
|
+
}
|
|
1079
|
+
console.log(`installed ${normalized} completion: ${targetPath}`);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Deletes just fpasoterm's installed completion and its managed PowerShell profile block.
|
|
1083
|
+
function uninstallCompletion(shell) {
|
|
1084
|
+
const { shell: normalized } = completionFileName(shell);
|
|
1085
|
+
const targetPath = completionInstallPath(normalized);
|
|
1086
|
+
if (normalized === 'powershell') {
|
|
1087
|
+
for (const profilePath of powershellProfilePaths()) {
|
|
1088
|
+
updatePowershellProfile(profilePath, null);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (fs.existsSync(targetPath)) {
|
|
1092
|
+
fs.unlinkSync(targetPath);
|
|
1093
|
+
console.log(`removed ${normalized} completion: ${targetPath}`);
|
|
1094
|
+
} else {
|
|
1095
|
+
console.log(`${normalized} completion is not installed: ${targetPath}`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Returns the runtime marker directory shared with the native application.
|
|
1100
|
+
function instanceMarkerDirectory() {
|
|
1101
|
+
const cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
1102
|
+
return path.join(cacheHome, 'fpasoterm', 'instances');
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Returns the local command directory shared with the native Broadcast watcher.
|
|
1106
|
+
function localBroadcastCommandDirectory() {
|
|
1107
|
+
const cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
1108
|
+
return path.join(cacheHome, 'fpasoterm', 'broadcast', 'commands');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Checks whether a marker PID still belongs to a running process.
|
|
1112
|
+
function instanceProcessIsLive(pid) {
|
|
1113
|
+
if (process.platform === 'linux') {
|
|
1114
|
+
return fs.existsSync(`/proc/${pid}`);
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
process.kill(pid, 0);
|
|
1118
|
+
return true;
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
return error?.code === 'EPERM';
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Best-effort cleanup must not prevent a read-only instance listing.
|
|
1125
|
+
function removeStaleInstanceMarker(markerPath) {
|
|
1126
|
+
try {
|
|
1127
|
+
fs.rmSync(markerPath, { force: true });
|
|
1128
|
+
} catch {
|
|
1129
|
+
// Read-only cache mounts can still be inspected even when cleanup is denied.
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Reads live native per-process markers and removes stale entries when possible.
|
|
1134
|
+
function runningInstances() {
|
|
1135
|
+
const directory = instanceMarkerDirectory();
|
|
1136
|
+
const instances = [];
|
|
1137
|
+
if (fs.existsSync(directory)) {
|
|
1138
|
+
for (const name of fs.readdirSync(directory).filter((entry) => entry.endsWith('.pid')).sort()) {
|
|
1139
|
+
const markerPath = path.join(directory, name);
|
|
1140
|
+
try {
|
|
1141
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
1142
|
+
const pid = Number(marker.pid || path.basename(name, '.pid'));
|
|
1143
|
+
if (!Number.isInteger(pid) || pid <= 0 || !instanceProcessIsLive(pid)) {
|
|
1144
|
+
removeStaleInstanceMarker(markerPath);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
instances.push({
|
|
1148
|
+
id: String(marker.instanceId || ''),
|
|
1149
|
+
pid,
|
|
1150
|
+
title: String(marker.title || marker.baseTitle || 'fpasoterm'),
|
|
1151
|
+
createdAt: Number(marker.createdAt || 0),
|
|
1152
|
+
});
|
|
1153
|
+
} catch {
|
|
1154
|
+
removeStaleInstanceMarker(markerPath);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
instances.sort((left, right) => left.createdAt - right.createdAt || left.pid - right.pid);
|
|
1160
|
+
return instances;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Lists running windows from the native per-process markers without launching the GUI.
|
|
1164
|
+
function printRunningInstances() {
|
|
1165
|
+
const instances = runningInstances();
|
|
1166
|
+
if (instances.length === 0) {
|
|
1167
|
+
console.log('No running fpasoterm windows.');
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
for (const instance of instances) {
|
|
1171
|
+
const started = instance.createdAt > 0 ? new Date(instance.createdAt).toISOString() : 'unknown';
|
|
1172
|
+
console.log(`session=${instance.pid} pid=${instance.pid} title=${JSON.stringify(instance.title)} started=${started}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Publishes one complete command file so running terminal watchers never read partial JSON.
|
|
1177
|
+
function writeBroadcastItem(directory, item) {
|
|
1178
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
1179
|
+
const baseName = `command-${item.id}`;
|
|
1180
|
+
const temporaryPath = path.join(directory, `${baseName}.tmp`);
|
|
1181
|
+
const commandPath = path.join(directory, `${baseName}.json`);
|
|
1182
|
+
fs.writeFileSync(temporaryPath, JSON.stringify(item));
|
|
1183
|
+
fs.renameSync(temporaryPath, commandPath);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Sends a command to selected live local windows and optionally the configured sync channel.
|
|
1187
|
+
function broadcastTerminalInput(options) {
|
|
1188
|
+
const allInstances = runningInstances().filter((instance) => instance.id);
|
|
1189
|
+
if (allInstances.length === 0) {
|
|
1190
|
+
throw new Error('no running fpasoterm windows were found');
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const selected = new Map();
|
|
1194
|
+
if (options.broadcastTargets.length === 0) {
|
|
1195
|
+
allInstances.forEach((instance) => selected.set(instance.id, instance));
|
|
1196
|
+
} else {
|
|
1197
|
+
for (const selector of options.broadcastTargets) {
|
|
1198
|
+
const numericPid = /^\d+$/.test(selector) ? Number(selector) : undefined;
|
|
1199
|
+
const matches = allInstances.filter((instance) => (
|
|
1200
|
+
numericPid === undefined ? instance.title === selector : instance.pid === numericPid
|
|
1201
|
+
));
|
|
1202
|
+
if (matches.length === 0) {
|
|
1203
|
+
throw new Error(`no running window matches Broadcast target ${JSON.stringify(selector)}`);
|
|
1204
|
+
}
|
|
1205
|
+
matches.forEach((instance) => selected.set(instance.id, instance));
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (options.broadcastSync && selected.size !== allInstances.length) {
|
|
1210
|
+
throw new Error('--broadcast-sync requires every running local window to be selected');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const text = `${options.broadcastText.replace(/\r\n?/g, '\n').replace(/\n/g, '\r').replace(/\r+$/, '')}\r`;
|
|
1214
|
+
const status = syncStatusReport(options);
|
|
1215
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
1216
|
+
if (bytes > status.maxBytes) {
|
|
1217
|
+
throw new Error(`broadcast payload is too large: ${bytes} bytes > ${status.maxBytes} bytes`);
|
|
1218
|
+
}
|
|
1219
|
+
if (options.broadcastSync && !status.commandsEnabled) {
|
|
1220
|
+
throw new Error('sync commands are disabled or commandSecret is missing; run fpasoterm --setup-sync');
|
|
1221
|
+
}
|
|
1222
|
+
if (options.broadcastSync && !status.enabled) {
|
|
1223
|
+
throw new Error(status.message);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const createdAt = Date.now();
|
|
1227
|
+
const item = {
|
|
1228
|
+
schemaVersion: 1,
|
|
1229
|
+
id: `cli-${process.pid}-${createdAt}`,
|
|
1230
|
+
sourceId: `cli-${process.pid}`,
|
|
1231
|
+
createdAt,
|
|
1232
|
+
expiresAt: createdAt + status.commandTtlMs,
|
|
1233
|
+
text,
|
|
1234
|
+
targetInstanceIds: [...selected.keys()],
|
|
1235
|
+
};
|
|
1236
|
+
if (options.broadcastSync) {
|
|
1237
|
+
item.signature = signSyncBroadcastItem(item, status.commandSecret);
|
|
1238
|
+
}
|
|
1239
|
+
writeBroadcastItem(localBroadcastCommandDirectory(), item);
|
|
1240
|
+
if (options.broadcastSync) {
|
|
1241
|
+
writeBroadcastItem(status.commandsPath, item);
|
|
1242
|
+
}
|
|
1243
|
+
console.log(`broadcast: sent id=${item.id} local_targets=${selected.size} synced=${options.broadcastSync}`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Keeps the HMAC payload stable across Node CLI and the Rust desktop runtime.
|
|
1247
|
+
function syncBroadcastSignaturePayload(item) {
|
|
1248
|
+
return JSON.stringify({
|
|
1249
|
+
schemaVersion: item.schemaVersion,
|
|
1250
|
+
id: item.id,
|
|
1251
|
+
sourceId: item.sourceId,
|
|
1252
|
+
createdAt: item.createdAt,
|
|
1253
|
+
expiresAt: item.expiresAt,
|
|
1254
|
+
text: item.text,
|
|
1255
|
+
targetInstanceIds: item.targetInstanceIds,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Signs only commands intentionally sent to a trusted sync folder.
|
|
1260
|
+
function signSyncBroadcastItem(item, commandSecret) {
|
|
1261
|
+
return crypto.createHmac('sha256', commandSecret)
|
|
1262
|
+
.update(syncBroadcastSignaturePayload(item), 'utf8')
|
|
1263
|
+
.digest('hex');
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Atomically broadcasts a graceful close request by PID, exact title, or all.
|
|
1267
|
+
function closeRunningInstance(target) {
|
|
1268
|
+
const normalizedTarget = String(target || '').trim();
|
|
1269
|
+
const closeAll = normalizedTarget.toLowerCase() === 'all';
|
|
1270
|
+
const numericPid = /^\d+$/.test(normalizedTarget) ? Number(normalizedTarget) : null;
|
|
1271
|
+
const matches = runningInstances().filter((instance) => (
|
|
1272
|
+
closeAll || (numericPid === null ? instance.title === normalizedTarget : instance.pid === numericPid)
|
|
1273
|
+
));
|
|
1274
|
+
if (matches.length === 0) {
|
|
1275
|
+
throw new Error(`no running window matches: ${normalizedTarget}`);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const cacheDirectory = path.dirname(instanceMarkerDirectory());
|
|
1279
|
+
fs.mkdirSync(cacheDirectory, { recursive: true });
|
|
1280
|
+
const requestPath = path.join(cacheDirectory, 'close.json');
|
|
1281
|
+
const temporaryPath = path.join(cacheDirectory, `close-${process.pid}.json.tmp`);
|
|
1282
|
+
fs.writeFileSync(temporaryPath, JSON.stringify({
|
|
1283
|
+
createdAt: Date.now(),
|
|
1284
|
+
pids: matches.map((instance) => instance.pid),
|
|
1285
|
+
}));
|
|
1286
|
+
removeStaleInstanceMarker(requestPath);
|
|
1287
|
+
fs.renameSync(temporaryPath, requestPath);
|
|
1288
|
+
for (const instance of matches) {
|
|
1289
|
+
console.log(`requested close session=${instance.pid} pid=${instance.pid} title=${JSON.stringify(instance.title)}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Resolves npm through its JavaScript entry point when available. This avoids
|
|
1294
|
+
// invoking npm.cmd through a shell on Windows, which Node deprecates (DEP0190).
|
|
1295
|
+
function npmInvocation(args) {
|
|
1296
|
+
const bundledNpmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
1297
|
+
const npmCli = [process.env.npm_execpath, bundledNpmCli].find((candidate) => candidate && fs.existsSync(candidate));
|
|
1298
|
+
if (npmCli) {
|
|
1299
|
+
return { command: process.execPath, args: [npmCli, ...args] };
|
|
1300
|
+
}
|
|
1301
|
+
return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Runs an update command and fails fast with a clear message.
|
|
1305
|
+
function runChecked(command, args, options = {}) {
|
|
1306
|
+
const result = spawnSync(command, args, {
|
|
1307
|
+
cwd: options.cwd || root,
|
|
1308
|
+
env: process.env,
|
|
1309
|
+
stdio: 'inherit',
|
|
1310
|
+
});
|
|
1311
|
+
if (result.error) {
|
|
1312
|
+
throw result.error;
|
|
1313
|
+
}
|
|
1314
|
+
if (result.status !== 0) {
|
|
1315
|
+
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Returns true when this command is running from a source checkout.
|
|
1320
|
+
function isSourceCheckout() {
|
|
1321
|
+
return fs.existsSync(path.join(root, '.git')) || fs.existsSync(path.join(root, '.jj'));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Returns true for jj workspaces, which need explicit user-controlled updates.
|
|
1325
|
+
function isJjCheckout() {
|
|
1326
|
+
return fs.existsSync(path.join(root, '.jj'));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Checks whether a git checkout is clean before automated pull/update work.
|
|
1330
|
+
function ensureCleanGitCheckout() {
|
|
1331
|
+
const result = spawnSync('git', ['status', '--porcelain'], {
|
|
1332
|
+
cwd: root,
|
|
1333
|
+
encoding: 'utf8',
|
|
1334
|
+
});
|
|
1335
|
+
if (result.error) {
|
|
1336
|
+
throw result.error;
|
|
1337
|
+
}
|
|
1338
|
+
if (result.status !== 0) {
|
|
1339
|
+
throw new Error('git status failed; refusing to self-update this checkout');
|
|
1340
|
+
}
|
|
1341
|
+
if (result.stdout.trim()) {
|
|
1342
|
+
throw new Error('checkout has local changes; commit, stash, or update manually before --self-update-checkout');
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Reinstalls desktop integration from the currently running package root.
|
|
1347
|
+
function updateDesktopIntegration() {
|
|
1348
|
+
runChecked(process.execPath, [path.join(root, 'scripts', 'install-linux-desktop.js')]);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Updates an npm-installed package. Source checkouts are intentionally not pulled.
|
|
1352
|
+
function selfUpdate() {
|
|
1353
|
+
if (isSourceCheckout()) {
|
|
1354
|
+
console.log('source checkout detected; not running npm global self-update from this tree');
|
|
1355
|
+
console.log('Use --self-update-checkout for a clean git checkout, or update manually and run:');
|
|
1356
|
+
console.log(' fpasoterm --update-desktop');
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const npm = npmInvocation(['install', '-g', 'fpasoterm@latest']);
|
|
1361
|
+
runChecked(npm.command, npm.args);
|
|
1362
|
+
if (process.platform === 'linux' && fs.existsSync(path.join(root, 'scripts', 'install-linux-desktop.js'))) {
|
|
1363
|
+
updateDesktopIntegration();
|
|
1364
|
+
}
|
|
1365
|
+
console.log('fpasoterm updated. Restart fpasoterm to use the updated app.');
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Updates a clean non-jj git checkout and refreshes its desktop integration.
|
|
1369
|
+
function selfUpdateCheckout() {
|
|
1370
|
+
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
1371
|
+
throw new Error('--self-update-checkout requires a git checkout');
|
|
1372
|
+
}
|
|
1373
|
+
if (isJjCheckout()) {
|
|
1374
|
+
console.log('jj checkout detected; automatic checkout updates are intentionally disabled.');
|
|
1375
|
+
console.log('Update with your jj workflow, then run:');
|
|
1376
|
+
console.log(' npm install');
|
|
1377
|
+
console.log(' fpasoterm --update-desktop');
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
ensureCleanGitCheckout();
|
|
1382
|
+
runChecked('git', ['pull', '--ff-only']);
|
|
1383
|
+
const npm = npmInvocation(['install']);
|
|
1384
|
+
runChecked(npm.command, npm.args);
|
|
1385
|
+
updateDesktopIntegration();
|
|
1386
|
+
console.log('checkout updated. Restart fpasoterm to use the updated app.');
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Reads the value after a flag and reports a CLI-friendly error when missing.
|
|
1390
|
+
function requireValue(argv, index, flag) {
|
|
1391
|
+
const value = argv[index + 1];
|
|
1392
|
+
if (!value || value.startsWith('--')) {
|
|
1393
|
+
if (['--completion', '--completion-install', '--completion-uninstall'].includes(flag)) {
|
|
1394
|
+
throw new Error(`${flag} requires a shell: bash, zsh, fish, or powershell (example: fpasoterm ${flag} bash)`);
|
|
1395
|
+
}
|
|
1396
|
+
throw new Error(`${flag} requires a value`);
|
|
1397
|
+
}
|
|
1398
|
+
return value;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Normalizes dimension arguments before they are passed to the app process.
|
|
1402
|
+
function requirePositiveInteger(value, flag) {
|
|
1403
|
+
const parsed = Number(value);
|
|
1404
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1405
|
+
throw new Error(`${flag} must be a positive integer`);
|
|
1406
|
+
}
|
|
1407
|
+
return String(parsed);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Splits a comma-separated plugin argument while preserving repeated options.
|
|
1411
|
+
function parsePluginSelectors(value, flag) {
|
|
1412
|
+
const selectors = value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
1413
|
+
if (selectors.length === 0) {
|
|
1414
|
+
throw new Error(`${flag} requires at least one plugin name`);
|
|
1415
|
+
}
|
|
1416
|
+
return selectors;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Splits explicit Broadcast target selectors without accepting empty entries.
|
|
1420
|
+
function parseBroadcastSelectors(value, flag) {
|
|
1421
|
+
const selectors = value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
1422
|
+
if (selectors.length === 0) {
|
|
1423
|
+
throw new Error(`${flag} requires at least one PID or exact title`);
|
|
1424
|
+
}
|
|
1425
|
+
return selectors;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Applies environment overrides while running config-only CLI commands.
|
|
1429
|
+
function applyEnv(env) {
|
|
1430
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1431
|
+
process.env[key] = value;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Updates plugins.enabled in config.toml without launching the application.
|
|
1436
|
+
function updatePluginConfig(options) {
|
|
1437
|
+
if (
|
|
1438
|
+
options.enablePlugins.length === 0 &&
|
|
1439
|
+
options.disablePlugins.length === 0 &&
|
|
1440
|
+
!options.enableAllPlugins &&
|
|
1441
|
+
!options.disableAllPlugins
|
|
1442
|
+
) {
|
|
1443
|
+
return false;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
applyEnv(options.env);
|
|
1447
|
+
const targetPath = configPath();
|
|
1448
|
+
const userConfig = readUserConfig(targetPath);
|
|
1449
|
+
const plugins = userConfig.plugins && typeof userConfig.plugins === 'object' ? userConfig.plugins : {};
|
|
1450
|
+
const enabled = new Set(Array.isArray(plugins.enabled) ? plugins.enabled : []);
|
|
1451
|
+
const available = discoverPluginFiles(targetPath);
|
|
1452
|
+
|
|
1453
|
+
if (options.enableAllPlugins) {
|
|
1454
|
+
for (const plugin of available) {
|
|
1455
|
+
enabled.add(plugin);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (options.disableAllPlugins) {
|
|
1460
|
+
enabled.clear();
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
for (const selector of options.enablePlugins) {
|
|
1464
|
+
enabled.add(resolvePluginSelector(selector, available, 'enable'));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
for (const selector of options.disablePlugins) {
|
|
1468
|
+
const plugin = resolvePluginSelector(selector, [...enabled], 'disable');
|
|
1469
|
+
enabled.delete(plugin);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
writeUserConfig({
|
|
1473
|
+
...userConfig,
|
|
1474
|
+
plugins: {
|
|
1475
|
+
...plugins,
|
|
1476
|
+
enabled: [...enabled].sort(),
|
|
1477
|
+
},
|
|
1478
|
+
}, targetPath);
|
|
1479
|
+
|
|
1480
|
+
console.log(`updated ${targetPath}`);
|
|
1481
|
+
const runtimeConfig = loadConfig();
|
|
1482
|
+
printPluginList(targetPath, [...enabled].sort(), runtimeConfig.pluginUrls);
|
|
1483
|
+
console.log('Restart fpasoterm to load the updated plugin selection.');
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Removes selected local plugin sources, their generated cache entries, and enabled-list references.
|
|
1488
|
+
function uninstallLocalPlugins(options) {
|
|
1489
|
+
if (options.pluginUninstall.length === 0) {
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
applyEnv(options.env);
|
|
1494
|
+
const targetPath = configPath();
|
|
1495
|
+
const configDir = path.dirname(targetPath);
|
|
1496
|
+
const pluginsDir = path.resolve(configDir, 'plugins');
|
|
1497
|
+
const pluginRoot = `${pluginsDir}${path.sep}`;
|
|
1498
|
+
const available = discoverPluginFiles(targetPath);
|
|
1499
|
+
const selected = [...new Set(options.pluginUninstall.map((selector) => (
|
|
1500
|
+
resolvePluginSelector(selector, available, 'uninstall')
|
|
1501
|
+
)))];
|
|
1502
|
+
const sources = selected.map((plugin) => {
|
|
1503
|
+
const sourcePath = path.resolve(configDir, plugin);
|
|
1504
|
+
if (!sourcePath.startsWith(pluginRoot)) {
|
|
1505
|
+
throw new Error(`refusing to uninstall plugin outside User/plugins: ${plugin}`);
|
|
1506
|
+
}
|
|
1507
|
+
const metadata = fs.lstatSync(sourcePath);
|
|
1508
|
+
if (metadata.isSymbolicLink()) {
|
|
1509
|
+
throw new Error(`refusing to uninstall symlinked plugin: ${plugin}`);
|
|
1510
|
+
}
|
|
1511
|
+
if (!metadata.isFile()) {
|
|
1512
|
+
throw new Error(`cannot uninstall plugin '${plugin}': source is not a regular file`);
|
|
1513
|
+
}
|
|
1514
|
+
const relativeName = path.relative(pluginsDir, sourcePath);
|
|
1515
|
+
return {
|
|
1516
|
+
plugin,
|
|
1517
|
+
sourcePath,
|
|
1518
|
+
cachePath: path.join(configDir, 'cache', 'plugins', relativeName).replace(/\.ts$/, '.js'),
|
|
1519
|
+
};
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
for (const { sourcePath, cachePath } of sources) {
|
|
1523
|
+
fs.unlinkSync(sourcePath);
|
|
1524
|
+
if (fs.existsSync(cachePath)) {
|
|
1525
|
+
const cacheMetadata = fs.lstatSync(cachePath);
|
|
1526
|
+
if (!cacheMetadata.isSymbolicLink() && cacheMetadata.isFile()) {
|
|
1527
|
+
fs.unlinkSync(cachePath);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const userConfig = readUserConfig(targetPath);
|
|
1533
|
+
const plugins = userConfig.plugins && typeof userConfig.plugins === 'object' ? userConfig.plugins : {};
|
|
1534
|
+
const removed = new Set(selected);
|
|
1535
|
+
const enabled = (Array.isArray(plugins.enabled) ? plugins.enabled : [])
|
|
1536
|
+
.filter((plugin) => !removed.has(plugin));
|
|
1537
|
+
writeUserConfig({
|
|
1538
|
+
...userConfig,
|
|
1539
|
+
plugins: {
|
|
1540
|
+
...plugins,
|
|
1541
|
+
enabled,
|
|
1542
|
+
},
|
|
1543
|
+
}, targetPath);
|
|
1544
|
+
|
|
1545
|
+
for (const { plugin } of sources) {
|
|
1546
|
+
console.log(`uninstalled local plugin ${plugin}`);
|
|
1547
|
+
}
|
|
1548
|
+
console.log(`updated ${targetPath}`);
|
|
1549
|
+
console.log('Restart fpasoterm to unload the removed plugin from open windows.');
|
|
1550
|
+
return true;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Prints the resolved config and which configured plugins can be loaded.
|
|
1554
|
+
function showConfig(options) {
|
|
1555
|
+
applyEnv(options.env);
|
|
1556
|
+
const runtimeConfig = loadConfig();
|
|
1557
|
+
applyWindowRuntimeOverrides(runtimeConfig.config.window, options.env);
|
|
1558
|
+
applyTerminalRuntimeOverrides(runtimeConfig.config.terminal, options.env);
|
|
1559
|
+
const enabled = Array.isArray(runtimeConfig.config.plugins?.enabled) ? runtimeConfig.config.plugins.enabled : [];
|
|
1560
|
+
|
|
1561
|
+
console.log(`Config path: ${runtimeConfig.configPath}`);
|
|
1562
|
+
console.log(`Config directory: ${runtimeConfig.configDir}`);
|
|
1563
|
+
console.log(`Active profile: ${runtimeConfig.activeProfile || '(none)'}`);
|
|
1564
|
+
console.log('');
|
|
1565
|
+
console.log('Resolved config:');
|
|
1566
|
+
const displayConfig = {
|
|
1567
|
+
...runtimeConfig.config,
|
|
1568
|
+
sync: {
|
|
1569
|
+
...runtimeConfig.config.sync,
|
|
1570
|
+
commandSecret: '[redacted]',
|
|
1571
|
+
},
|
|
1572
|
+
};
|
|
1573
|
+
process.stdout.write(toml.stringify(displayConfig));
|
|
1574
|
+
console.log('');
|
|
1575
|
+
printPluginList(runtimeConfig.configPath, enabled, runtimeConfig.pluginUrls);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Prints validation results without rewriting or resolving the user's config.
|
|
1579
|
+
function checkConfig(options) {
|
|
1580
|
+
applyEnv(options.env);
|
|
1581
|
+
const result = validateUserConfig(configPath());
|
|
1582
|
+
console.log(`config: ${result.configPath}`);
|
|
1583
|
+
if (result.error) {
|
|
1584
|
+
console.log('status: error');
|
|
1585
|
+
console.log('');
|
|
1586
|
+
console.log('errors:');
|
|
1587
|
+
console.log(`- ${result.error}`);
|
|
1588
|
+
process.exitCode = 1;
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
console.log('status: ok');
|
|
1593
|
+
if (result.warnings.length > 0) {
|
|
1594
|
+
console.log('');
|
|
1595
|
+
console.log('warnings:');
|
|
1596
|
+
for (const warning of result.warnings) {
|
|
1597
|
+
console.log(`- ${warning}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Prints the selected config path without creating files or opening a window.
|
|
1603
|
+
function showConfigPath(options) {
|
|
1604
|
+
applyEnv(options.env);
|
|
1605
|
+
console.log(configPath());
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Prints the generated example so it can be redirected to a chosen TOML file.
|
|
1609
|
+
function showConfigExample(options) {
|
|
1610
|
+
applyEnv(options.env);
|
|
1611
|
+
process.stdout.write(defaultConfigExample());
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Lists profile names without resolving plugins or opening a terminal window.
|
|
1615
|
+
function showProfileList(options) {
|
|
1616
|
+
applyEnv(options.env);
|
|
1617
|
+
const targetPath = configPath();
|
|
1618
|
+
const names = profileNames(readUserConfig(targetPath));
|
|
1619
|
+
console.log('Available profiles:');
|
|
1620
|
+
if (names.length === 0) {
|
|
1621
|
+
console.log(' (none found)');
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const active = process.env.FPASOTERM_PROFILE || '';
|
|
1625
|
+
for (const name of names) {
|
|
1626
|
+
console.log(` ${name}${name === active ? ' [selected]' : ''}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Resolves the local log directory using the same empty-directory default as the runtime.
|
|
1631
|
+
function configuredLogDirectory(targetPath, userConfig) {
|
|
1632
|
+
const configured = typeof userConfig.logging?.directory === 'string'
|
|
1633
|
+
? expandHome(userConfig.logging.directory)
|
|
1634
|
+
: '';
|
|
1635
|
+
return configured || path.join(path.dirname(targetPath), 'logs');
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Formats static launcher information as a GitHub Issue-friendly Markdown report.
|
|
1639
|
+
function diagnosticsMarkdown(options) {
|
|
1640
|
+
applyEnv(options.env);
|
|
1641
|
+
const targetPath = configPath();
|
|
1642
|
+
const checked = validateUserConfig(targetPath);
|
|
1643
|
+
const userConfig = checked.error ? {} : readUserConfig(targetPath);
|
|
1644
|
+
const resolved = mergeConfig(platformDefaultConfig(), userConfig);
|
|
1645
|
+
const enabledPlugins = Array.isArray(userConfig.plugins?.enabled) ? userConfig.plugins.enabled : [];
|
|
1646
|
+
const logDirectory = configuredLogDirectory(targetPath, userConfig);
|
|
1647
|
+
const windowState = (() => {
|
|
1648
|
+
const statePath = path.join(path.dirname(targetPath), 'window-state.json');
|
|
1649
|
+
try {
|
|
1650
|
+
return fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')).window : undefined;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return undefined;
|
|
1653
|
+
}
|
|
1654
|
+
})();
|
|
1655
|
+
const size = windowState?.width && windowState?.height
|
|
1656
|
+
? `${windowState.width}x${windowState.height} (saved)`
|
|
1657
|
+
: `${resolved.window.width}x${resolved.window.height} (configured)`;
|
|
1658
|
+
|
|
1659
|
+
return [
|
|
1660
|
+
'## fpasoterm diagnostics',
|
|
1661
|
+
'',
|
|
1662
|
+
`- version: ${versionText()}`,
|
|
1663
|
+
`- platform: ${process.platform} ${process.arch}`,
|
|
1664
|
+
'- webview: Tauri WebView (reported by launcher)',
|
|
1665
|
+
`- config path: ${targetPath}`,
|
|
1666
|
+
`- config status: ${checked.error ? 'error' : 'ok'}`,
|
|
1667
|
+
`- profile: ${process.env.FPASOTERM_PROFILE || '(none)'}`,
|
|
1668
|
+
`- plugins: ${enabledPlugins.length > 0 ? enabledPlugins.join(', ') : '(none enabled)'}`,
|
|
1669
|
+
`- terminal size: ${size}`,
|
|
1670
|
+
`- terminal font size: ${resolved.terminal.fontSize}`,
|
|
1671
|
+
`- logging: ${resolved.logging?.enabled === false ? 'disabled' : 'enabled'} (${logDirectory})`,
|
|
1672
|
+
`- sync: ${resolved.sync?.enabled ? `enabled (${resolved.sync.provider || 'folder'}:${resolved.sync.channel || 'default'})` : 'disabled'}`,
|
|
1673
|
+
`- debug log: ${path.join(path.dirname(targetPath), 'logs', 'fpasoterm-debug.log')}`,
|
|
1674
|
+
...(checked.error ? ['', '### configuration errors', `- ${checked.error}`] : []),
|
|
1675
|
+
...(checked.warnings.length > 0 ? ['', '### configuration warnings', ...checked.warnings.map((warning) => `- ${warning}`)] : []),
|
|
1676
|
+
'',
|
|
1677
|
+
].join('\n');
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Opens the local log folder with the operating system's file manager.
|
|
1681
|
+
function openLogDirectory(options) {
|
|
1682
|
+
applyEnv(options.env);
|
|
1683
|
+
const targetPath = configPath();
|
|
1684
|
+
const checked = validateUserConfig(targetPath);
|
|
1685
|
+
if (checked.error) {
|
|
1686
|
+
throw new Error(checked.error);
|
|
1687
|
+
}
|
|
1688
|
+
const directory = configuredLogDirectory(targetPath, readUserConfig(targetPath));
|
|
1689
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
1690
|
+
const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1691
|
+
const child = spawn(command, [directory], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
1692
|
+
child.unref();
|
|
1693
|
+
console.log(directory);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Writes the diagnostics report through platform clipboard tools without opening a window.
|
|
1697
|
+
function copyDiagnostics(options) {
|
|
1698
|
+
const report = diagnosticsMarkdown(options);
|
|
1699
|
+
const commands = process.platform === 'win32'
|
|
1700
|
+
? [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value ([Console]::In.ReadToEnd())']]]
|
|
1701
|
+
: process.platform === 'darwin'
|
|
1702
|
+
? [['pbcopy', []]]
|
|
1703
|
+
: [['wl-copy', ['--type', 'text/plain;charset=utf-8']], ['xclip', ['-selection', 'clipboard', '-target', 'UTF8_STRING']], ['xsel', ['--clipboard', '--input']]];
|
|
1704
|
+
const errors = [];
|
|
1705
|
+
for (const [command, args] of commands) {
|
|
1706
|
+
const result = spawnSync(command, args, { input: report, encoding: 'utf8', windowsHide: true });
|
|
1707
|
+
if (!result.error && result.status === 0) {
|
|
1708
|
+
console.log('copied diagnostics to clipboard');
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
errors.push(result.error?.message || `${command} exited with ${result.status}`);
|
|
1712
|
+
}
|
|
1713
|
+
throw new Error(`could not copy diagnostics: ${errors.join('; ')}`);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Clears remembered window bounds without touching config.toml.
|
|
1717
|
+
function resetWindowState(options) {
|
|
1718
|
+
applyEnv(options.env);
|
|
1719
|
+
const runtimeConfig = loadConfig();
|
|
1720
|
+
deleteWindowState(runtimeConfig.windowStatePath);
|
|
1721
|
+
console.log(`deleted ${runtimeConfig.windowStatePath}`);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Renames the selected config.toml, restores defaults, and clears saved bounds.
|
|
1725
|
+
function resetConfig(options) {
|
|
1726
|
+
applyEnv(options.env);
|
|
1727
|
+
const targetPath = configPath();
|
|
1728
|
+
let backupPath = '';
|
|
1729
|
+
if (fs.existsSync(targetPath)) {
|
|
1730
|
+
backupPath = `${targetPath}.backup-${Date.now()}`;
|
|
1731
|
+
fs.renameSync(targetPath, backupPath);
|
|
1732
|
+
}
|
|
1733
|
+
writeUserConfig(writableConfigDefaults(), targetPath);
|
|
1734
|
+
const statePath = path.join(path.dirname(targetPath), 'window-state.json');
|
|
1735
|
+
deleteWindowState(statePath);
|
|
1736
|
+
console.log(`reset config ${targetPath}`);
|
|
1737
|
+
if (backupPath) {
|
|
1738
|
+
console.log(`renamed previous config ${backupPath}`);
|
|
1739
|
+
}
|
|
1740
|
+
console.log(`deleted saved window state ${statePath}`);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Adds newly introduced defaults while retaining every existing user value.
|
|
1744
|
+
function updateConfig(options) {
|
|
1745
|
+
applyEnv(options.env);
|
|
1746
|
+
const targetPath = configPath();
|
|
1747
|
+
const userConfig = readUserConfig(targetPath);
|
|
1748
|
+
const defaults = writableConfigDefaults();
|
|
1749
|
+
const missing = missingConfigKeys(defaults, userConfig);
|
|
1750
|
+
if (missing.length === 0) {
|
|
1751
|
+
console.log(`config already includes all defaults: ${targetPath}`);
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
let backupPath = '';
|
|
1756
|
+
if (fs.existsSync(targetPath)) {
|
|
1757
|
+
backupPath = `${targetPath}.backup-${Date.now()}`;
|
|
1758
|
+
fs.copyFileSync(targetPath, backupPath);
|
|
1759
|
+
}
|
|
1760
|
+
writeUserConfig(mergeConfig(defaults, userConfig), targetPath);
|
|
1761
|
+
console.log(`updated ${targetPath}`);
|
|
1762
|
+
console.log(`added ${missing.length} default setting(s)`);
|
|
1763
|
+
if (backupPath) {
|
|
1764
|
+
console.log(`backed up previous config ${backupPath}`);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Removes obsolete configuration keys while retaining every supported value.
|
|
1769
|
+
function pruneConfigFile(options) {
|
|
1770
|
+
applyEnv(options.env);
|
|
1771
|
+
const targetPath = configPath();
|
|
1772
|
+
if (!fs.existsSync(targetPath)) {
|
|
1773
|
+
console.log(`config does not exist: ${targetPath}`);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const userConfig = readUserConfig(targetPath);
|
|
1778
|
+
const result = pruneUnsupportedConfig(writableConfigDefaults(), userConfig);
|
|
1779
|
+
if (result.removed.length === 0) {
|
|
1780
|
+
console.log(`config has no unsupported settings: ${targetPath}`);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const backupPath = `${targetPath}.backup-${Date.now()}`;
|
|
1785
|
+
fs.copyFileSync(targetPath, backupPath);
|
|
1786
|
+
writeUserConfig(result.config, targetPath);
|
|
1787
|
+
console.log(`pruned ${targetPath}`);
|
|
1788
|
+
console.log(`removed ${result.removed.length} unsupported setting(s): ${result.removed.join(', ')}`);
|
|
1789
|
+
console.log(`backed up previous config ${backupPath}`);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Prints plugin entries with load and declared-version information.
|
|
1793
|
+
function printPluginList(targetPath, enabled, pluginUrls = []) {
|
|
1794
|
+
const configDir = path.dirname(targetPath);
|
|
1795
|
+
const loadedByName = new Map(pluginUrls.map((plugin) => [plugin.name, plugin]));
|
|
1796
|
+
|
|
1797
|
+
console.log('');
|
|
1798
|
+
console.log('Plugins:');
|
|
1799
|
+
if (enabled.length === 0) {
|
|
1800
|
+
console.log(' (none enabled)');
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
for (const plugin of enabled) {
|
|
1805
|
+
const pluginPath = path.resolve(configDir, plugin);
|
|
1806
|
+
const relativeName = path.relative(configDir, pluginPath);
|
|
1807
|
+
const loaded = loadedByName.get(relativeName);
|
|
1808
|
+
const exists = fs.existsSync(pluginPath);
|
|
1809
|
+
const status = loaded ? 'loadable' : exists ? 'not loadable' : 'missing';
|
|
1810
|
+
const version = exists ? readPluginMetadata(pluginPath).version : '(not declared)';
|
|
1811
|
+
console.log(` - ${plugin} [${status}; version ${version}]${loaded ? ` -> ${loaded.url}` : ''}`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// Lists every plugin file below User/plugins and the explicit enabled selection.
|
|
1816
|
+
function showPluginList(options) {
|
|
1817
|
+
applyEnv(options.env);
|
|
1818
|
+
const targetPath = configPath();
|
|
1819
|
+
const userConfig = readUserConfig(targetPath);
|
|
1820
|
+
const enabled = Array.isArray(userConfig.plugins?.enabled) ? userConfig.plugins.enabled : [];
|
|
1821
|
+
const available = discoverPluginFiles(targetPath);
|
|
1822
|
+
|
|
1823
|
+
console.log('Local plugins (User/plugins):');
|
|
1824
|
+
if (available.length === 0) {
|
|
1825
|
+
console.log(' (none found)');
|
|
1826
|
+
} else {
|
|
1827
|
+
for (const plugin of available) {
|
|
1828
|
+
const metadata = readPluginMetadata(path.resolve(path.dirname(targetPath), plugin));
|
|
1829
|
+
console.log(` ${plugin} [version ${metadata.version}]`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
console.log('');
|
|
1834
|
+
console.log('Enabled plugins:');
|
|
1835
|
+
if (enabled.length === 0) {
|
|
1836
|
+
console.log(' (none enabled)');
|
|
1837
|
+
} else {
|
|
1838
|
+
for (const plugin of enabled) {
|
|
1839
|
+
console.log(` ${plugin}`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Prints the active configuration's plugin directory without creating it.
|
|
1845
|
+
function showPluginPath(options) {
|
|
1846
|
+
applyEnv(options.env);
|
|
1847
|
+
console.log(path.join(path.dirname(configPath()), 'plugins'));
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Prints one local plugin's resolved source and load details without opening a window.
|
|
1851
|
+
function showPluginInfo(options) {
|
|
1852
|
+
applyEnv(options.env);
|
|
1853
|
+
const targetPath = configPath();
|
|
1854
|
+
const available = discoverPluginFiles(targetPath);
|
|
1855
|
+
const plugin = resolvePluginSelector(options.pluginInfo, available, 'inspect');
|
|
1856
|
+
const sourcePath = path.resolve(path.dirname(targetPath), plugin);
|
|
1857
|
+
const userConfig = readUserConfig(targetPath);
|
|
1858
|
+
const enabled = Array.isArray(userConfig.plugins?.enabled) && userConfig.plugins.enabled.includes(plugin);
|
|
1859
|
+
const runtimeConfig = loadConfig();
|
|
1860
|
+
const resolved = runtimeConfig.pluginUrls.find((entry) => entry.name === plugin);
|
|
1861
|
+
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
1862
|
+
const metadata = readPluginMetadata(sourcePath);
|
|
1863
|
+
|
|
1864
|
+
console.log(`Plugin: ${plugin}`);
|
|
1865
|
+
console.log(`State: ${enabled ? 'enabled' : 'disabled'}`);
|
|
1866
|
+
console.log(`Type: ${path.extname(sourcePath) === '.ts' ? 'TypeScript' : 'JavaScript'}`);
|
|
1867
|
+
console.log(`Version: ${metadata.version}`);
|
|
1868
|
+
console.log(`Source: ${sourcePath}`);
|
|
1869
|
+
console.log(`Description: ${metadata.description}`);
|
|
1870
|
+
console.log(`Load status: ${resolved ? 'loadable' : 'not loadable'}`);
|
|
1871
|
+
if (resolved) {
|
|
1872
|
+
console.log(`Renderer URL: ${resolved.url}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Expands common environment variable forms used in configuration paths.
|
|
1877
|
+
function expandPathVariables(inputPath) {
|
|
1878
|
+
return String(inputPath || '')
|
|
1879
|
+
.replace(/%([^%]+)%/g, (match, name) => process.env[name] || match)
|
|
1880
|
+
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => process.env[name] || match);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Expands a leading ~/ and environment variables so prompts can accept portable paths.
|
|
1884
|
+
function expandHome(inputPath) {
|
|
1885
|
+
const normalized = expandPathVariables(inputPath).trim();
|
|
1886
|
+
if (normalized === '~') {
|
|
1887
|
+
return os.homedir();
|
|
1888
|
+
}
|
|
1889
|
+
if (normalized.startsWith('~/') || normalized.startsWith('~\\')) {
|
|
1890
|
+
return path.join(os.homedir(), normalized.slice(2));
|
|
1891
|
+
}
|
|
1892
|
+
return normalized;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Returns platform-specific Google Drive folder candidates for setup prompts.
|
|
1896
|
+
function googleDriveCandidates() {
|
|
1897
|
+
const home = os.homedir();
|
|
1898
|
+
const candidates = [
|
|
1899
|
+
path.join(home, 'Google Drive'),
|
|
1900
|
+
path.join(home, 'GoogleDrive'),
|
|
1901
|
+
path.join(home, 'MyDrive'),
|
|
1902
|
+
];
|
|
1903
|
+
|
|
1904
|
+
if (process.platform === 'linux') {
|
|
1905
|
+
candidates.unshift('/mnt/chromeos/GoogleDrive/MyDrive');
|
|
1906
|
+
candidates.unshift('/mnt/shared/GoogleDrive/MyDrive');
|
|
1907
|
+
candidates.unshift('/mnt/chromeos/shared/GoogleDrive/MyDrive');
|
|
1908
|
+
} else if (process.platform === 'darwin') {
|
|
1909
|
+
const cloudStorage = path.join(home, 'Library', 'CloudStorage');
|
|
1910
|
+
if (fs.existsSync(cloudStorage)) {
|
|
1911
|
+
for (const entry of fs.readdirSync(cloudStorage)) {
|
|
1912
|
+
if (entry.startsWith('GoogleDrive-')) {
|
|
1913
|
+
candidates.unshift(path.join(cloudStorage, entry, 'My Drive'));
|
|
1914
|
+
candidates.unshift(path.join(cloudStorage, entry));
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
candidates.push('/Volumes/GoogleDrive/My Drive');
|
|
1919
|
+
} else if (process.platform === 'win32') {
|
|
1920
|
+
for (const letter of 'DEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
1921
|
+
candidates.unshift(`${letter}:\\Google Drive`);
|
|
1922
|
+
candidates.unshift(`${letter}:\\マイドライブ`);
|
|
1923
|
+
candidates.unshift(`${letter}:\\My Drive`);
|
|
1924
|
+
}
|
|
1925
|
+
candidates.unshift('G:\\My Drive');
|
|
1926
|
+
candidates.push(path.join(home, 'My Drive'));
|
|
1927
|
+
candidates.push(path.join(home, 'Google Drive'));
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
return [...new Set(candidates)];
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Returns true when the current user should be able to create files in a directory.
|
|
1934
|
+
function canWriteDirectory(directory) {
|
|
1935
|
+
try {
|
|
1936
|
+
fs.accessSync(directory, fs.constants.W_OK);
|
|
1937
|
+
return true;
|
|
1938
|
+
} catch {
|
|
1939
|
+
return false;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Adds writable immediate subdirectories because ChromeOS often shares selected
|
|
1944
|
+
// Drive folders, not the whole MyDrive root, into Linux.
|
|
1945
|
+
function writableDriveCandidates() {
|
|
1946
|
+
const expanded = [];
|
|
1947
|
+
for (const candidate of googleDriveCandidates()) {
|
|
1948
|
+
if (!fs.existsSync(candidate)) {
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
expanded.push(candidate);
|
|
1952
|
+
try {
|
|
1953
|
+
for (const entry of fs.readdirSync(candidate, { withFileTypes: true })) {
|
|
1954
|
+
if (entry.isDirectory()) {
|
|
1955
|
+
expanded.push(path.join(candidate, entry.name));
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
} catch {
|
|
1959
|
+
// Some provider roots are visible but not listable until fully mounted.
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return [...new Set(expanded)].filter(canWriteDirectory);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// Picks the most likely writable sync root and appends the fpasoterm subdirectory.
|
|
1966
|
+
function defaultSyncPath() {
|
|
1967
|
+
const existing = writableDriveCandidates()[0] || googleDriveCandidates().find((candidate) => fs.existsSync(candidate));
|
|
1968
|
+
return path.join(existing || path.join(os.homedir(), 'Google Drive'), 'fpasoterm-sync');
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Returns concrete sync folder suggestions by appending fpasoterm-sync to roots.
|
|
1972
|
+
function syncPathCandidates() {
|
|
1973
|
+
const roots = writableDriveCandidates();
|
|
1974
|
+
const visibleRoots = googleDriveCandidates().filter((candidate) => fs.existsSync(candidate));
|
|
1975
|
+
return [...new Set([...(roots.length > 0 ? roots : visibleRoots), ...visibleRoots])]
|
|
1976
|
+
.map((candidate) => path.join(candidate, 'fpasoterm-sync'));
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Asks one setup question, returning the default when the user presses Enter.
|
|
1980
|
+
async function askQuestion(rl, question, defaultValue) {
|
|
1981
|
+
const suffix = defaultValue === undefined || defaultValue === '' ? '' : ` [${defaultValue}]`;
|
|
1982
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
1983
|
+
return answer || defaultValue;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Lets users choose a detected sync folder candidate by number or type a path.
|
|
1987
|
+
async function askSyncPath(rl, defaultValue) {
|
|
1988
|
+
const candidates = syncPathCandidates();
|
|
1989
|
+
if (candidates.length > 0) {
|
|
1990
|
+
console.log('Detected sync folder candidates:');
|
|
1991
|
+
candidates.forEach((candidate, index) => {
|
|
1992
|
+
console.log(` ${index + 1}. ${candidate}`);
|
|
1993
|
+
});
|
|
1994
|
+
console.log('');
|
|
1995
|
+
const answer = await askQuestion(rl, 'Select sync folder candidate number or type a path', '1');
|
|
1996
|
+
if (/^\d+$/.test(answer)) {
|
|
1997
|
+
const index = Number(answer) - 1;
|
|
1998
|
+
if (!candidates[index]) {
|
|
1999
|
+
throw new Error(`unknown sync folder candidate number: ${answer}`);
|
|
2000
|
+
}
|
|
2001
|
+
return candidates[index];
|
|
2002
|
+
}
|
|
2003
|
+
return answer;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const answer = await askQuestion(rl, 'Local sync folder path or candidate number', defaultValue);
|
|
2007
|
+
return answer;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Parses yes/no setup answers with a default.
|
|
2011
|
+
function isYes(value, defaultValue = true) {
|
|
2012
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
2013
|
+
if (!normalized) {
|
|
2014
|
+
return defaultValue;
|
|
2015
|
+
}
|
|
2016
|
+
return ['y', 'yes', 'true', '1', 'はい'].includes(normalized);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Prints Google Drive mounting guidance before asking for the local sync path.
|
|
2020
|
+
function printSyncSetupHelp() {
|
|
2021
|
+
console.log('fpasoterm sync uses a local folder that Google Drive or another tool already syncs.');
|
|
2022
|
+
console.log('It does not use Google Drive API, OAuth, API keys, or a Google Cloud project.');
|
|
2023
|
+
console.log('');
|
|
2024
|
+
console.log('Google Drive folder setup:');
|
|
2025
|
+
console.log('- ChromeOS/Baguette: open the Files app, create folders such as Google Drive/My Drive/shared or Google Drive/My Drive/temp, right-click each folder, and choose "Share with Linux".');
|
|
2026
|
+
console.log('- Then select a detected candidate such as /mnt/chromeos/shared/GoogleDrive/MyDrive/shared/fpasoterm-sync or /mnt/chromeos/shared/GoogleDrive/MyDrive/temp/fpasoterm-sync.');
|
|
2027
|
+
console.log('- Some ChromeOS Linux environments expose the same shared folders as /mnt/shared/GoogleDrive/MyDrive/<folder>/fpasoterm-sync or /mnt/chromeos/GoogleDrive/MyDrive/<folder>/fpasoterm-sync.');
|
|
2028
|
+
console.log('- If Linux cannot create a folder under MyDrive directly, use a subfolder that is individually shared with Linux.');
|
|
2029
|
+
console.log('- macOS: install Google Drive for desktop, sign in, then use the local Google Drive folder under ~/Library/CloudStorage/GoogleDrive-*/.');
|
|
2030
|
+
console.log('- Windows: install Google Drive for desktop, sign in, then use the drive/folder shown by Google Drive, often G:\\My Drive. In a source checkout, run setup as: node .\\bin\\fpasoterm --setup-sync');
|
|
2031
|
+
console.log('- Linux desktop: use your chosen sync client, rclone mount, Syncthing, Dropbox, or another local synced folder.');
|
|
2032
|
+
console.log('');
|
|
2033
|
+
console.log('Sync channel:');
|
|
2034
|
+
console.log('- A channel is a folder name used to separate sync data inside the same sync path.');
|
|
2035
|
+
console.log('- Use the same channel on every machine that should share clipboard text and diagnostics.');
|
|
2036
|
+
console.log('- Press Enter to keep "default" when you do not need separate work/test/personal channels.');
|
|
2037
|
+
console.log('');
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Interactively creates or updates [sync] and [logging] in config.toml.
|
|
2041
|
+
async function setupSync(options) {
|
|
2042
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2043
|
+
throw new Error('--setup-sync must be run from an interactive terminal');
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
applyEnv(options.env);
|
|
2047
|
+
const targetPath = configPath();
|
|
2048
|
+
const userConfig = readUserConfig(targetPath);
|
|
2049
|
+
const currentSync = userConfig.sync && typeof userConfig.sync === 'object' ? userConfig.sync : {};
|
|
2050
|
+
const currentLogging = userConfig.logging && typeof userConfig.logging === 'object' ? userConfig.logging : {};
|
|
2051
|
+
const defaultPath = currentSync.path || defaultSyncPath();
|
|
2052
|
+
const defaultChannel = currentSync.channel || 'default';
|
|
2053
|
+
const generatedCommandSecret = typeof currentSync.commandSecret === 'string' && currentSync.commandSecret.length >= 32
|
|
2054
|
+
? currentSync.commandSecret
|
|
2055
|
+
: crypto.randomBytes(32).toString('hex');
|
|
2056
|
+
const localDefaultLogDirectory = path.join(path.dirname(targetPath), 'logs');
|
|
2057
|
+
const syncDefaultLogDirectory = currentLogging.directory || path.join(defaultPath, 'logs');
|
|
2058
|
+
|
|
2059
|
+
printSyncSetupHelp();
|
|
2060
|
+
console.log(`Config file: ${targetPath}`);
|
|
2061
|
+
console.log('');
|
|
2062
|
+
|
|
2063
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2064
|
+
try {
|
|
2065
|
+
const syncPath = await askSyncPath(rl, defaultPath);
|
|
2066
|
+
console.log('Use the same sync channel on devices that should share data. Press Enter for default.');
|
|
2067
|
+
const channel = await askQuestion(rl, 'Sync channel name', defaultChannel);
|
|
2068
|
+
console.log('Remote Broadcast commands require a shared secret. Copy the same value to every trusted device.');
|
|
2069
|
+
const commandSecret = await askQuestion(rl, 'Sync command secret', generatedCommandSecret);
|
|
2070
|
+
if (commandSecret.length < 32) {
|
|
2071
|
+
throw new Error('sync command secret must contain at least 32 characters');
|
|
2072
|
+
}
|
|
2073
|
+
const createFolder = isYes(await askQuestion(rl, 'Create the folder if it does not exist? (Y/n)', 'Y'), true);
|
|
2074
|
+
console.log(`Terminal output logs stay local by default under ${localDefaultLogDirectory}.`);
|
|
2075
|
+
console.log('Answer y only when you want readable terminal output logs stored in the selected sync folder.');
|
|
2076
|
+
const enableLogs = isYes(await askQuestion(rl, 'Store terminal output logs in the sync folder? (y/N)', 'N'), false);
|
|
2077
|
+
const logDirectory = enableLogs
|
|
2078
|
+
? await askQuestion(rl, 'Terminal log directory', syncDefaultLogDirectory)
|
|
2079
|
+
: currentLogging.directory || '';
|
|
2080
|
+
|
|
2081
|
+
const resolvedSyncPath = expandHome(syncPath);
|
|
2082
|
+
if (!fs.existsSync(resolvedSyncPath)) {
|
|
2083
|
+
if (!createFolder) {
|
|
2084
|
+
throw new Error(`sync folder does not exist: ${resolvedSyncPath}`);
|
|
2085
|
+
}
|
|
2086
|
+
fs.mkdirSync(resolvedSyncPath, { recursive: true });
|
|
2087
|
+
}
|
|
2088
|
+
fs.mkdirSync(path.join(resolvedSyncPath, channel || 'default'), { recursive: true });
|
|
2089
|
+
if (enableLogs) {
|
|
2090
|
+
fs.mkdirSync(expandHome(logDirectory), { recursive: true });
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
writeUserConfig({
|
|
2094
|
+
...userConfig,
|
|
2095
|
+
sync: {
|
|
2096
|
+
...currentSync,
|
|
2097
|
+
enabled: true,
|
|
2098
|
+
provider: 'folder',
|
|
2099
|
+
path: syncPath,
|
|
2100
|
+
channel: channel || 'default',
|
|
2101
|
+
clipboard: currentSync.clipboard ?? true,
|
|
2102
|
+
diagnostics: currentSync.diagnostics ?? true,
|
|
2103
|
+
pasteRequiresConfirm: currentSync.pasteRequiresConfirm ?? true,
|
|
2104
|
+
maxBytes: currentSync.maxBytes ?? 1048576,
|
|
2105
|
+
commands: true,
|
|
2106
|
+
commandSecret,
|
|
2107
|
+
commandTtlSeconds: currentSync.commandTtlSeconds ?? 60,
|
|
2108
|
+
},
|
|
2109
|
+
logging: {
|
|
2110
|
+
...currentLogging,
|
|
2111
|
+
enabled: currentLogging.enabled ?? true,
|
|
2112
|
+
directory: logDirectory,
|
|
2113
|
+
autoStart: currentLogging.autoStart ?? false,
|
|
2114
|
+
maxBytes: currentLogging.maxBytes ?? 10485760,
|
|
2115
|
+
},
|
|
2116
|
+
}, targetPath);
|
|
2117
|
+
|
|
2118
|
+
console.log('');
|
|
2119
|
+
console.log(`updated ${targetPath}`);
|
|
2120
|
+
console.log(`sync path: ${syncPath}`);
|
|
2121
|
+
console.log(`sync channel: ${channel || 'default'}`);
|
|
2122
|
+
console.log('sync command authentication: HMAC-SHA-256 enabled');
|
|
2123
|
+
console.log(`terminal logs: ${enableLogs ? logDirectory : 'local default unless configured later'}`);
|
|
2124
|
+
} finally {
|
|
2125
|
+
rl.close();
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// Normalizes a configured sync channel to the directory name used by the backend.
|
|
2130
|
+
function normalizedSyncChannel(value) {
|
|
2131
|
+
const normalized = String(value || 'default')
|
|
2132
|
+
.trim()
|
|
2133
|
+
.replace(/[^A-Za-z0-9_.-]/g, '_')
|
|
2134
|
+
.replace(/^[._]+|[._]+$/g, '')
|
|
2135
|
+
.slice(0, 80);
|
|
2136
|
+
return normalized || 'default';
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// Reads a file modification time in milliseconds without treating a failure as
|
|
2140
|
+
// an error for an informational health check.
|
|
2141
|
+
function modificationTimeMs(filePath) {
|
|
2142
|
+
try {
|
|
2143
|
+
return fs.statSync(filePath).mtimeMs;
|
|
2144
|
+
} catch {
|
|
2145
|
+
return 0;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Inspects one channel directory. This is intentionally read-only so status
|
|
2150
|
+
// does not create a folder or alter a network-synced drive.
|
|
2151
|
+
function inspectSyncChannel(channelPath, name, now, ttlMs) {
|
|
2152
|
+
const result = {
|
|
2153
|
+
name,
|
|
2154
|
+
diagnosticsExists: fs.existsSync(path.join(channelPath, 'diagnostics.json')),
|
|
2155
|
+
activeCommands: 0,
|
|
2156
|
+
staleCommands: 0,
|
|
2157
|
+
invalidCommands: 0,
|
|
2158
|
+
temporaryFiles: 0,
|
|
2159
|
+
};
|
|
2160
|
+
const commandsPath = path.join(channelPath, 'commands');
|
|
2161
|
+
let entries;
|
|
2162
|
+
try {
|
|
2163
|
+
entries = fs.readdirSync(commandsPath, { withFileTypes: true });
|
|
2164
|
+
} catch {
|
|
2165
|
+
return result;
|
|
2166
|
+
}
|
|
2167
|
+
for (const entry of entries) {
|
|
2168
|
+
if (!entry.isFile() || !entry.name.startsWith('command-')) {
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
const itemPath = path.join(commandsPath, entry.name);
|
|
2172
|
+
if (entry.name.endsWith('.json')) {
|
|
2173
|
+
try {
|
|
2174
|
+
const item = JSON.parse(fs.readFileSync(itemPath, 'utf8'));
|
|
2175
|
+
if (Number(item.expiresAt) > now) {
|
|
2176
|
+
result.activeCommands += 1;
|
|
2177
|
+
} else {
|
|
2178
|
+
result.staleCommands += 1;
|
|
2179
|
+
}
|
|
2180
|
+
} catch {
|
|
2181
|
+
result.invalidCommands += 1;
|
|
2182
|
+
}
|
|
2183
|
+
} else if (entry.name.endsWith('.tmp') && modificationTimeMs(itemPath) + ttlMs <= now) {
|
|
2184
|
+
result.temporaryFiles += 1;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
return result;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Collects all direct-child channels, keeping ordinary files and log folders out.
|
|
2191
|
+
function inspectSyncChannels(rootPath, now, ttlMs) {
|
|
2192
|
+
let entries;
|
|
2193
|
+
try {
|
|
2194
|
+
entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
2195
|
+
} catch {
|
|
2196
|
+
return [];
|
|
2197
|
+
}
|
|
2198
|
+
return entries
|
|
2199
|
+
.filter((entry) => entry.isDirectory())
|
|
2200
|
+
.map((entry) => inspectSyncChannel(path.join(rootPath, entry.name), entry.name, now, ttlMs))
|
|
2201
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// Resolves and inspects the configured sync folder without starting the desktop app.
|
|
2205
|
+
function syncStatusReport(options) {
|
|
2206
|
+
applyEnv(options.env);
|
|
2207
|
+
const targetPath = configPath();
|
|
2208
|
+
const selected = selectProfileConfig(readUserConfig(targetPath));
|
|
2209
|
+
const sync = mergeConfig(
|
|
2210
|
+
mergeConfig(platformDefaultConfig(), selected.baseConfig),
|
|
2211
|
+
selected.profileConfig,
|
|
2212
|
+
).sync || {};
|
|
2213
|
+
const provider = String(sync.provider || 'folder');
|
|
2214
|
+
const configuredPath = String(sync.path || '').trim();
|
|
2215
|
+
const channel = normalizedSyncChannel(sync.channel);
|
|
2216
|
+
const enabled = sync.enabled === true && provider === 'folder' && Boolean(configuredPath);
|
|
2217
|
+
const rootPath = configuredPath ? expandHome(configuredPath) : '';
|
|
2218
|
+
const diagnosticsPath = rootPath ? path.join(rootPath, channel, 'diagnostics.json') : '';
|
|
2219
|
+
const commandsPath = rootPath ? path.join(rootPath, channel, 'commands') : '';
|
|
2220
|
+
const now = Date.now();
|
|
2221
|
+
const ttlMs = Math.min(Math.max(Number(sync.commandTtlSeconds) || 60, 1), 600) * 1000;
|
|
2222
|
+
const commandSecret = typeof sync.commandSecret === 'string' ? sync.commandSecret : '';
|
|
2223
|
+
const commandsEnabled = sync.commands === true && commandSecret.length >= 32;
|
|
2224
|
+
const maxBytes = Number(sync.maxBytes) > 0 ? Number(sync.maxBytes) : 1048576;
|
|
2225
|
+
const rootExists = Boolean(rootPath) && fs.existsSync(rootPath);
|
|
2226
|
+
let rootReadable = false;
|
|
2227
|
+
let rootWritable = false;
|
|
2228
|
+
if (rootExists) {
|
|
2229
|
+
try {
|
|
2230
|
+
fs.readdirSync(rootPath);
|
|
2231
|
+
rootReadable = true;
|
|
2232
|
+
} catch {}
|
|
2233
|
+
rootWritable = canWriteDirectory(rootPath);
|
|
2234
|
+
}
|
|
2235
|
+
const channels = rootExists ? inspectSyncChannels(rootPath, now, ttlMs) : [];
|
|
2236
|
+
const current = channels.find((item) => item.name === channel);
|
|
2237
|
+
const diagnosticsExists = Boolean(diagnosticsPath) && fs.existsSync(diagnosticsPath);
|
|
2238
|
+
const diagnosticsBytes = diagnosticsExists ? fs.statSync(diagnosticsPath).size : 0;
|
|
2239
|
+
const diagnosticsUpdatedAt = diagnosticsExists ? modificationTimeMs(diagnosticsPath) : 0;
|
|
2240
|
+
const staleCommands = current?.staleCommands || 0;
|
|
2241
|
+
const invalidCommands = current?.invalidCommands || 0;
|
|
2242
|
+
const temporaryFiles = current?.temporaryFiles || 0;
|
|
2243
|
+
let health = 'ok';
|
|
2244
|
+
let message = 'sync folder is healthy';
|
|
2245
|
+
if (!enabled) {
|
|
2246
|
+
health = 'disabled';
|
|
2247
|
+
message = !sync.enabled ? 'sync folder is disabled; set sync.enabled = true' : provider !== 'folder'
|
|
2248
|
+
? `unsupported sync provider: ${provider}`
|
|
2249
|
+
: 'sync path is empty; set sync.path to a synced local folder';
|
|
2250
|
+
} else if (!rootExists) {
|
|
2251
|
+
health = 'missing';
|
|
2252
|
+
message = 'sync root does not exist';
|
|
2253
|
+
} else if (!rootReadable) {
|
|
2254
|
+
health = 'unreadable';
|
|
2255
|
+
message = 'sync root cannot be read';
|
|
2256
|
+
} else if (!rootWritable) {
|
|
2257
|
+
health = 'read-only';
|
|
2258
|
+
message = 'sync root cannot be written';
|
|
2259
|
+
} else if (staleCommands + invalidCommands + temporaryFiles > 0) {
|
|
2260
|
+
health = 'cleanup recommended';
|
|
2261
|
+
message = 'expired or abandoned command files were found';
|
|
2262
|
+
}
|
|
2263
|
+
return {
|
|
2264
|
+
enabled,
|
|
2265
|
+
commandsEnabled,
|
|
2266
|
+
commandSecret,
|
|
2267
|
+
maxBytes,
|
|
2268
|
+
provider,
|
|
2269
|
+
path: rootPath,
|
|
2270
|
+
channel,
|
|
2271
|
+
diagnosticsPath,
|
|
2272
|
+
commandsPath,
|
|
2273
|
+
health,
|
|
2274
|
+
rootExists,
|
|
2275
|
+
rootReadable,
|
|
2276
|
+
rootWritable,
|
|
2277
|
+
diagnosticsExists,
|
|
2278
|
+
diagnosticsBytes,
|
|
2279
|
+
diagnosticsUpdatedAt,
|
|
2280
|
+
activeCommands: current?.activeCommands || 0,
|
|
2281
|
+
staleCommands,
|
|
2282
|
+
invalidCommands,
|
|
2283
|
+
temporaryFiles,
|
|
2284
|
+
channels,
|
|
2285
|
+
commandTtlMs: ttlMs,
|
|
2286
|
+
message,
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Makes the health check readable in a terminal and ready to paste into an issue.
|
|
2291
|
+
function syncStatusMarkdown(options) {
|
|
2292
|
+
const status = syncStatusReport(options);
|
|
2293
|
+
const lines = [
|
|
2294
|
+
'## fpasoterm sync status',
|
|
2295
|
+
'',
|
|
2296
|
+
`- status: ${status.enabled ? 'enabled' : 'disabled'}`,
|
|
2297
|
+
`- health: ${status.health}`,
|
|
2298
|
+
`- provider: ${status.provider}`,
|
|
2299
|
+
`- path: ${status.path || '(not configured)'}`,
|
|
2300
|
+
`- channel: ${status.channel || '(not configured)'}`,
|
|
2301
|
+
`- root exists: ${status.rootExists}`,
|
|
2302
|
+
`- root readable: ${status.rootReadable}`,
|
|
2303
|
+
`- root writable: ${status.rootWritable}`,
|
|
2304
|
+
`- diagnostics: ${status.diagnosticsExists} (${status.diagnosticsBytes} bytes)`,
|
|
2305
|
+
`- command directory: ${status.commandsPath || '(not available)'}`,
|
|
2306
|
+
`- commands: active=${status.activeCommands} stale=${status.staleCommands} invalid=${status.invalidCommands} temporary=${status.temporaryFiles}`,
|
|
2307
|
+
`- message: ${status.message}`,
|
|
2308
|
+
'',
|
|
2309
|
+
'### Channels',
|
|
2310
|
+
];
|
|
2311
|
+
if (status.channels.length === 0) {
|
|
2312
|
+
lines.push('- (none found)');
|
|
2313
|
+
} else {
|
|
2314
|
+
status.channels.forEach((item) => lines.push(
|
|
2315
|
+
`- ${item.name}: diagnostics=${item.diagnosticsExists} active=${item.activeCommands} stale=${item.staleCommands} invalid=${item.invalidCommands} temporary=${item.temporaryFiles}`,
|
|
2316
|
+
));
|
|
2317
|
+
}
|
|
2318
|
+
return `${lines.join('\n')}\n`;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// Deletes only expired commands and stale temporary/malformed requests from all
|
|
2322
|
+
// channels. Valid pending commands and diagnostics files remain untouched.
|
|
2323
|
+
function cleanSyncFolder(options) {
|
|
2324
|
+
const status = syncStatusReport(options);
|
|
2325
|
+
if (!status.enabled) {
|
|
2326
|
+
throw new Error(status.message);
|
|
2327
|
+
}
|
|
2328
|
+
if (!status.rootExists) {
|
|
2329
|
+
console.log('sync clean: sync root does not exist; nothing to clean');
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
const now = Date.now();
|
|
2333
|
+
const ttlMs = status.commandTtlMs;
|
|
2334
|
+
const removed = { expired: 0, invalid: 0, temporary: 0 };
|
|
2335
|
+
for (const channel of status.channels) {
|
|
2336
|
+
const directory = path.join(status.path, channel.name, 'commands');
|
|
2337
|
+
let entries;
|
|
2338
|
+
try {
|
|
2339
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
2340
|
+
} catch {
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
for (const entry of entries) {
|
|
2344
|
+
if (!entry.isFile() || !entry.name.startsWith('command-')) {
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
const itemPath = path.join(directory, entry.name);
|
|
2348
|
+
let kind = '';
|
|
2349
|
+
if (entry.name.endsWith('.json')) {
|
|
2350
|
+
try {
|
|
2351
|
+
const item = JSON.parse(fs.readFileSync(itemPath, 'utf8'));
|
|
2352
|
+
if (Number(item.expiresAt) <= now) {
|
|
2353
|
+
kind = 'expired';
|
|
2354
|
+
}
|
|
2355
|
+
} catch {
|
|
2356
|
+
if (modificationTimeMs(itemPath) + ttlMs <= now) {
|
|
2357
|
+
kind = 'invalid';
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
} else if (entry.name.endsWith('.tmp') && modificationTimeMs(itemPath) + ttlMs <= now) {
|
|
2361
|
+
kind = 'temporary';
|
|
2362
|
+
}
|
|
2363
|
+
if (kind) {
|
|
2364
|
+
fs.rmSync(itemPath, { force: true });
|
|
2365
|
+
removed[kind] += 1;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
console.log(`sync clean: removed expired=${removed.expired} invalid=${removed.invalid} temporary=${removed.temporary}`);
|
|
2370
|
+
console.log(`path: ${status.path}`);
|
|
2371
|
+
console.log(`channel: ${status.channel}`);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Finds a built Tauri application binary when one is available locally.
|
|
2375
|
+
function findTauriBinary() {
|
|
2376
|
+
if (process.env.FPASOTERM_TAURI_BIN) {
|
|
2377
|
+
return process.env.FPASOTERM_TAURI_BIN;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const executable = process.platform === 'win32' ? 'fpasoterm.exe' : 'fpasoterm';
|
|
2381
|
+
for (const profile of ['release', 'debug']) {
|
|
2382
|
+
const candidate = path.join(root, 'src-tauri', 'target', profile, executable);
|
|
2383
|
+
if (fs.existsSync(candidate) && isBuiltBinaryCurrent(candidate)) {
|
|
2384
|
+
return candidate;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
return undefined;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// Stores proof that the binary was created by normal cargo build, not tauri dev.
|
|
2391
|
+
function buildStampPath() {
|
|
2392
|
+
return path.join(root, 'src-tauri', 'target', 'debug', '.fpasoterm-normal-build.json');
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Reads the normal-build stamp used to avoid an unnecessary cargo build.
|
|
2396
|
+
function readBuildStamp() {
|
|
2397
|
+
try {
|
|
2398
|
+
return JSON.parse(fs.readFileSync(buildStampPath(), 'utf8'));
|
|
2399
|
+
} catch {
|
|
2400
|
+
return undefined;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// Writes the binary/source mtimes after a successful normal cargo build.
|
|
2405
|
+
function writeBuildStamp(binary) {
|
|
2406
|
+
const stamp = {
|
|
2407
|
+
binary,
|
|
2408
|
+
binaryMtime: fs.statSync(binary).mtimeMs,
|
|
2409
|
+
sourceMtime: latestRuntimeSourceMtime(),
|
|
2410
|
+
};
|
|
2411
|
+
fs.writeFileSync(buildStampPath(), JSON.stringify(stamp, null, 2));
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// Returns a binary only when the normal-build stamp still matches current files.
|
|
2415
|
+
function findStampedTauriBinary() {
|
|
2416
|
+
const binary = findTauriBinary();
|
|
2417
|
+
const stamp = readBuildStamp();
|
|
2418
|
+
if (!binary || !stamp || stamp.binary !== binary) {
|
|
2419
|
+
return undefined;
|
|
2420
|
+
}
|
|
2421
|
+
if (stamp.sourceMtime !== latestRuntimeSourceMtime()) {
|
|
2422
|
+
return undefined;
|
|
2423
|
+
}
|
|
2424
|
+
if (stamp.binaryMtime !== fs.statSync(binary).mtimeMs) {
|
|
2425
|
+
return undefined;
|
|
2426
|
+
}
|
|
2427
|
+
return binary;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Describes the work the detached launcher will perform before a window exists.
|
|
2431
|
+
function startupPreparationStatus(options) {
|
|
2432
|
+
if (options.dev) {
|
|
2433
|
+
return 'building local runtime with cargo';
|
|
2434
|
+
}
|
|
2435
|
+
if (findStampedTauriBinary()) {
|
|
2436
|
+
return 'using cached local runtime';
|
|
2437
|
+
}
|
|
2438
|
+
if (findTauriBinary()) {
|
|
2439
|
+
return 'using local runtime';
|
|
2440
|
+
}
|
|
2441
|
+
return 'building local runtime with cargo (first launch can take several minutes)';
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// Detects the only startup path that needs visible work before a window exists.
|
|
2445
|
+
function requiresLocalRuntimeBuild(options) {
|
|
2446
|
+
return options.dev || (!findStampedTauriBinary() && !findTauriBinary());
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Builds the debug binary used by normal source-checkout launches.
|
|
2450
|
+
function buildTauriBinary(options) {
|
|
2451
|
+
if (options.dev) {
|
|
2452
|
+
appendLauncherLog('launcher build=forced debug binary');
|
|
2453
|
+
return buildLocalTauriBinary(options);
|
|
2454
|
+
}
|
|
2455
|
+
const stampedBinary = findStampedTauriBinary();
|
|
2456
|
+
if (stampedBinary) {
|
|
2457
|
+
appendLauncherLog(`launcher build=skip stamped binary=${stampedBinary}`);
|
|
2458
|
+
return Promise.resolve(stampedBinary);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
const currentBinary = findTauriBinary();
|
|
2462
|
+
if (currentBinary) {
|
|
2463
|
+
writeBuildStamp(currentBinary);
|
|
2464
|
+
appendLauncherLog(`launcher build=skip current binary=${currentBinary}`);
|
|
2465
|
+
return Promise.resolve(currentBinary);
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
appendLauncherLog('launcher build=cargo build');
|
|
2469
|
+
return buildLocalTauriBinary(options);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Builds the static-asset debug binary without starting a localhost dev server.
|
|
2473
|
+
function buildLocalTauriBinary(options) {
|
|
2474
|
+
appendLaunchPhase('cargo-build-start');
|
|
2475
|
+
let logFd;
|
|
2476
|
+
let stdio = options.foreground ? 'inherit' : 'ignore';
|
|
2477
|
+
if (!options.foreground) {
|
|
2478
|
+
try {
|
|
2479
|
+
logFd = fs.openSync(launcherLogPath, 'a');
|
|
2480
|
+
// A detached first launch cannot write to the invoking terminal. Keep the
|
|
2481
|
+
// compiler diagnostics in launcher.log so a missing toolchain or native
|
|
2482
|
+
// dependency can be diagnosed without rerunning in the foreground.
|
|
2483
|
+
stdio = ['ignore', logFd, logFd];
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
appendLauncherLog(`launcher cargo log fd error=${error.message}`);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
// Tauri embeds frontendDist in the native binary, but Cargo does not always
|
|
2489
|
+
// notice a renderer-only edit. Clean only this crate before building so an
|
|
2490
|
+
// updated launcher never opens stale renderer assets.
|
|
2491
|
+
appendLaunchPhase('cargo-clean-start');
|
|
2492
|
+
const cleanResult = spawnSync('cargo', [
|
|
2493
|
+
'clean',
|
|
2494
|
+
'--manifest-path', path.join(root, 'src-tauri', 'Cargo.toml'),
|
|
2495
|
+
'-p', 'fpasoterm',
|
|
2496
|
+
], {
|
|
2497
|
+
cwd: root,
|
|
2498
|
+
env: process.env,
|
|
2499
|
+
stdio,
|
|
2500
|
+
windowsHide: !options.foreground,
|
|
2501
|
+
});
|
|
2502
|
+
if (cleanResult.error) {
|
|
2503
|
+
if (logFd !== undefined) {
|
|
2504
|
+
fs.closeSync(logFd);
|
|
2505
|
+
}
|
|
2506
|
+
return Promise.reject(new Error(`failed to start cargo clean: ${cleanResult.error.message}`));
|
|
2507
|
+
}
|
|
2508
|
+
if (cleanResult.status !== 0) {
|
|
2509
|
+
if (logFd !== undefined) {
|
|
2510
|
+
fs.closeSync(logFd);
|
|
2511
|
+
}
|
|
2512
|
+
return Promise.reject(new Error(`cargo clean failed with exit code ${cleanResult.status}`));
|
|
2513
|
+
}
|
|
2514
|
+
appendLaunchPhase('cargo-clean-complete');
|
|
2515
|
+
|
|
2516
|
+
const result = spawn('cargo', ['build', '--manifest-path', path.join(root, 'src-tauri', 'Cargo.toml')], {
|
|
2517
|
+
cwd: root,
|
|
2518
|
+
env: process.env,
|
|
2519
|
+
stdio,
|
|
2520
|
+
windowsHide: !options.foreground,
|
|
2521
|
+
});
|
|
2522
|
+
|
|
2523
|
+
return new Promise((resolve, reject) => {
|
|
2524
|
+
result.on('error', (error) => {
|
|
2525
|
+
if (logFd !== undefined) {
|
|
2526
|
+
fs.closeSync(logFd);
|
|
2527
|
+
}
|
|
2528
|
+
reject(new Error(`failed to start cargo build: ${error.message}`));
|
|
2529
|
+
});
|
|
2530
|
+
result.on('exit', (code, signal) => {
|
|
2531
|
+
if (logFd !== undefined) {
|
|
2532
|
+
fs.closeSync(logFd);
|
|
2533
|
+
}
|
|
2534
|
+
if (signal) {
|
|
2535
|
+
appendLauncherLog(`launcher cargo build exit signal=${signal}`);
|
|
2536
|
+
reject(new Error(`cargo build exited with signal ${signal}`));
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if (code !== 0) {
|
|
2540
|
+
appendLauncherLog(`launcher cargo build exit code=${code}`);
|
|
2541
|
+
reject(new Error(`cargo build failed with exit code ${code}`));
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
appendLaunchPhase('cargo-build-complete');
|
|
2545
|
+
const binary = findTauriBinary();
|
|
2546
|
+
if (!binary) {
|
|
2547
|
+
reject(new Error('cargo build finished but no current fpasoterm binary was found'));
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
writeBuildStamp(binary);
|
|
2551
|
+
resolve(binary);
|
|
2552
|
+
});
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Avoids launching stale local target binaries after source checkout edits.
|
|
2557
|
+
function isBuiltBinaryCurrent(candidate) {
|
|
2558
|
+
const binaryMtime = fs.statSync(candidate).mtimeMs;
|
|
2559
|
+
return binaryMtime >= latestRuntimeSourceMtime();
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// Tracks source files that require rebuilding the Tauri binary when changed.
|
|
2563
|
+
function latestRuntimeSourceMtime() {
|
|
2564
|
+
return [
|
|
2565
|
+
'src-tauri/src/main.rs',
|
|
2566
|
+
'src-tauri/Cargo.toml',
|
|
2567
|
+
'src-tauri/Cargo.lock',
|
|
2568
|
+
'src-tauri/build.rs',
|
|
2569
|
+
'src-tauri/default-config.toml',
|
|
2570
|
+
'src-tauri/tauri.conf.json',
|
|
2571
|
+
'src-tauri/capabilities',
|
|
2572
|
+
'src/renderer',
|
|
2573
|
+
'completions',
|
|
2574
|
+
'extra/logo/fpasoterm.png',
|
|
2575
|
+
'package.json',
|
|
2576
|
+
]
|
|
2577
|
+
.map((relativePath) => path.join(root, relativePath))
|
|
2578
|
+
.reduce((latest, sourcePath) => Math.max(latest, latestPathMtime(sourcePath)), 0);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// Returns the newest mtime in a file or a small source directory recursively.
|
|
2582
|
+
function latestPathMtime(sourcePath) {
|
|
2583
|
+
try {
|
|
2584
|
+
const stat = fs.statSync(sourcePath);
|
|
2585
|
+
if (!stat.isDirectory()) {
|
|
2586
|
+
return stat.mtimeMs;
|
|
2587
|
+
}
|
|
2588
|
+
return fs.readdirSync(sourcePath, { withFileTypes: true })
|
|
2589
|
+
.reduce((latest, entry) => Math.max(latest, latestPathMtime(path.join(sourcePath, entry.name))), stat.mtimeMs);
|
|
2590
|
+
} catch {
|
|
2591
|
+
return 0;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// Resolves config before launching so the Rust runtime can stay focused on
|
|
2596
|
+
// windowing and PTY work.
|
|
2597
|
+
function runtimeEnv(options) {
|
|
2598
|
+
applyEnv(options.env);
|
|
2599
|
+
const runtimeConfig = loadConfig();
|
|
2600
|
+
applyWindowRuntimeOverrides(runtimeConfig.config.window, options.env);
|
|
2601
|
+
applyTerminalRuntimeOverrides(runtimeConfig.config.terminal, options.env);
|
|
2602
|
+
runtimeConfig.diagnostics = {
|
|
2603
|
+
debugKeys: options.env.FPASOTERM_DEBUG_KEYS === '1' || process.env.FPASOTERM_DEBUG_KEYS === '1',
|
|
2604
|
+
consoleDiagnostics:
|
|
2605
|
+
options.env.FPASOTERM_CONSOLE_DIAGNOSTICS === '1' || process.env.FPASOTERM_CONSOLE_DIAGNOSTICS === '1',
|
|
2606
|
+
opaqueTerminal:
|
|
2607
|
+
options.env.FPASOTERM_DEBUG_OPAQUE_TERMINAL === '1' ||
|
|
2608
|
+
process.env.FPASOTERM_DEBUG_OPAQUE_TERMINAL === '1',
|
|
2609
|
+
};
|
|
2610
|
+
return {
|
|
2611
|
+
...process.env,
|
|
2612
|
+
...options.env,
|
|
2613
|
+
FPASOTERM_RUNTIME_CONFIG_JSON: JSON.stringify(runtimeConfig),
|
|
2614
|
+
// Native processes replace this marker with their own PID. A command
|
|
2615
|
+
// launched from a terminal shell must reload config.toml instead.
|
|
2616
|
+
FPASOTERM_RUNTIME_CONFIG_SOURCE: 'launcher',
|
|
2617
|
+
FPASOTERM_RUNTIME_CONFIG_OWNER_PID: '',
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// Applies one-shot CLI terminal overrides after config resolution.
|
|
2622
|
+
function applyTerminalRuntimeOverrides(terminalConfig, env) {
|
|
2623
|
+
if (env.FPASOTERM_SHELL) {
|
|
2624
|
+
terminalConfig.shell = env.FPASOTERM_SHELL;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// Applies one-shot CLI window overrides after config and saved state resolution.
|
|
2629
|
+
function applyWindowRuntimeOverrides(windowConfig, env) {
|
|
2630
|
+
if (env.FPASOTERM_WINDOW_TITLE) {
|
|
2631
|
+
windowConfig.title = env.FPASOTERM_WINDOW_TITLE;
|
|
2632
|
+
windowConfig.titleLocked = true;
|
|
2633
|
+
}
|
|
2634
|
+
if (env.FPASOTERM_TITLEBAR_COLOR) {
|
|
2635
|
+
windowConfig.titlebarColor = env.FPASOTERM_TITLEBAR_COLOR;
|
|
2636
|
+
}
|
|
2637
|
+
if (env.FPASOTERM_WINDOW_WIDTH) {
|
|
2638
|
+
windowConfig.width = Number(env.FPASOTERM_WINDOW_WIDTH);
|
|
2639
|
+
}
|
|
2640
|
+
if (env.FPASOTERM_WINDOW_HEIGHT) {
|
|
2641
|
+
windowConfig.height = Number(env.FPASOTERM_WINDOW_HEIGHT);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// Starts the desktop runtime. By default the child is detached so the shell prompt returns.
|
|
2646
|
+
async function launch(options) {
|
|
2647
|
+
validateLaunchProfile(options);
|
|
2648
|
+
let preparedBinary;
|
|
2649
|
+
// Re-exec before a possible cargo build so normal launches return the caller
|
|
2650
|
+
// prompt consistently on macOS, Linux, and Windows.
|
|
2651
|
+
if (!options.foreground && process.env.FPASOTERM_LAUNCHER_DETACHED !== '1') {
|
|
2652
|
+
const preparation = startupPreparationStatus(options);
|
|
2653
|
+
if (requiresLocalRuntimeBuild(options)) {
|
|
2654
|
+
// First-run compilation can take minutes. Keep this process attached so
|
|
2655
|
+
// the caller sees what is loading and the actual compiler error, then
|
|
2656
|
+
// detach only after a native runtime is ready to open its window.
|
|
2657
|
+
process.stdout.write(`fpasoterm: ${preparation}.\n`);
|
|
2658
|
+
process.stdout.write('fpasoterm: phase 1/3 - preparing the local Rust runtime.\n');
|
|
2659
|
+
process.stdout.write('fpasoterm: phase 2/3 - cargo build started; compiler progress follows.\n');
|
|
2660
|
+
appendLaunchPhase('visible-cargo-build-start');
|
|
2661
|
+
preparedBinary = await buildTauriBinary({ ...options, foreground: true });
|
|
2662
|
+
appendLaunchPhase('visible-cargo-build-complete');
|
|
2663
|
+
process.stdout.write('fpasoterm: phase 2/3 - cargo build completed.\n');
|
|
2664
|
+
process.stdout.write('fpasoterm: phase 3/3 - opening the native window in the background.\n');
|
|
2665
|
+
} else {
|
|
2666
|
+
const child = spawn(process.execPath, [__filename, ...process.argv.slice(2)], {
|
|
2667
|
+
detached: true,
|
|
2668
|
+
env: {
|
|
2669
|
+
...process.env,
|
|
2670
|
+
FPASOTERM_LAUNCHER_DETACHED: '1',
|
|
2671
|
+
FPASOTERM_LAUNCH_STARTED_AT: String(launcherStartedAt),
|
|
2672
|
+
},
|
|
2673
|
+
stdio: 'ignore',
|
|
2674
|
+
windowsHide: true,
|
|
2675
|
+
});
|
|
2676
|
+
child.on('error', (error) => appendLauncherLog(`launcher detach error=${error.message}`));
|
|
2677
|
+
child.unref();
|
|
2678
|
+
if (process.stdout.isTTY) {
|
|
2679
|
+
process.stdout.write(`fpasoterm: ${preparation}; starting in background (pid ${child.pid ?? 'unknown'}).\n`);
|
|
2680
|
+
}
|
|
2681
|
+
appendLaunchPhase(`detached pid=${child.pid ?? 'unknown'}`);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
appendLaunchPhase('resolving-runtime');
|
|
2687
|
+
const tauriBinary = preparedBinary || await buildTauriBinary(options);
|
|
2688
|
+
appendLaunchPhase('runtime-ready');
|
|
2689
|
+
const command = tauriBinary;
|
|
2690
|
+
const args = options.runtimeArgs;
|
|
2691
|
+
const env = runtimeEnv(options);
|
|
2692
|
+
appendLauncherLog(`launcher command=${command}`);
|
|
2693
|
+
appendLauncherLog(`launcher args=${JSON.stringify(args)}`);
|
|
2694
|
+
appendLauncherLog(`launcher mode=${options.dev ? 'debug-binary' : 'binary'}`);
|
|
2695
|
+
appendLauncherLog(`launcher cwd=${root}`);
|
|
2696
|
+
|
|
2697
|
+
let logFd;
|
|
2698
|
+
let stdio = options.foreground ? 'inherit' : 'ignore';
|
|
2699
|
+
if (!options.foreground) {
|
|
2700
|
+
try {
|
|
2701
|
+
logFd = fs.openSync(launcherLogPath, 'a');
|
|
2702
|
+
stdio = ['ignore', logFd, logFd];
|
|
2703
|
+
} catch (error) {
|
|
2704
|
+
appendLauncherLog(`launcher log fd error=${error.message}`);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const child = spawn(command, args, {
|
|
2709
|
+
detached: !options.foreground,
|
|
2710
|
+
env,
|
|
2711
|
+
stdio,
|
|
2712
|
+
windowsHide: !options.foreground,
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
appendLauncherLog(`launcher child pid=${child.pid ?? 'unknown'}`);
|
|
2716
|
+
appendLaunchPhase('desktop-spawned');
|
|
2717
|
+
child.on('error', (error) => {
|
|
2718
|
+
appendLauncherLog(`launcher child error=${error.message}`);
|
|
2719
|
+
if (logFd !== undefined) {
|
|
2720
|
+
fs.closeSync(logFd);
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
if (!options.foreground) {
|
|
2725
|
+
if (preparedBinary && process.stdout.isTTY) {
|
|
2726
|
+
process.stdout.write(`fpasoterm: startup complete; window process pid ${child.pid ?? 'unknown'}.\n`);
|
|
2727
|
+
}
|
|
2728
|
+
child.on('exit', (code, signal) => {
|
|
2729
|
+
appendLauncherLog(`launcher child exit code=${code ?? ''} signal=${signal ?? ''}`);
|
|
2730
|
+
if (logFd !== undefined) {
|
|
2731
|
+
fs.closeSync(logFd);
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
child.unref();
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGUSR2']) {
|
|
2739
|
+
process.on(signal, () => {
|
|
2740
|
+
if (!child.killed) {
|
|
2741
|
+
child.kill(signal);
|
|
2742
|
+
}
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
child.on('exit', (code, signal) => {
|
|
2747
|
+
if (signal) {
|
|
2748
|
+
console.error(`${command} exited with signal ${signal}`);
|
|
2749
|
+
process.exit(1);
|
|
2750
|
+
}
|
|
2751
|
+
process.exit(code ?? 0);
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// Validates a requested profile before a normal launch detaches from the shell.
|
|
2756
|
+
// This keeps a typo or missing profile visible to the invoking user instead of
|
|
2757
|
+
// only recording the failure in launcher.log.
|
|
2758
|
+
function validateLaunchProfile(options) {
|
|
2759
|
+
const profile = options.env.FPASOTERM_PROFILE;
|
|
2760
|
+
if (!profile) {
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
applyEnv(options.env);
|
|
2764
|
+
selectProfileConfig(readUserConfig(configPath()), profile);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
let parsedArguments = false;
|
|
2768
|
+
try {
|
|
2769
|
+
const options = parseArgs(process.argv.slice(2));
|
|
2770
|
+
parsedArguments = true;
|
|
2771
|
+
if (options.help) {
|
|
2772
|
+
process.stdout.write(helpText);
|
|
2773
|
+
} else if (options.version) {
|
|
2774
|
+
printVersion();
|
|
2775
|
+
} else if (options.updateCheck) {
|
|
2776
|
+
Promise.resolve(checkForUpdate()).catch((updateError) => {
|
|
2777
|
+
console.error(`fpasoterm: could not check for updates: ${updateError.message}`);
|
|
2778
|
+
process.exitCode = 1;
|
|
2779
|
+
});
|
|
2780
|
+
} else if (options.doctor) {
|
|
2781
|
+
Promise.resolve(runDoctor(options)).catch((doctorError) => {
|
|
2782
|
+
console.error(`fpasoterm: doctor failed: ${doctorError.message}`);
|
|
2783
|
+
process.exitCode = 1;
|
|
2784
|
+
});
|
|
2785
|
+
} else if (Object.hasOwn(options, 'pluginSearch')) {
|
|
2786
|
+
Promise.resolve(searchPublicPlugins(options.pluginSearch)).catch((searchError) => {
|
|
2787
|
+
console.error(`fpasoterm: could not search public plugins: ${searchError.message}`);
|
|
2788
|
+
process.exitCode = 1;
|
|
2789
|
+
});
|
|
2790
|
+
} else if (options.completionShell) {
|
|
2791
|
+
printCompletion(options.completionShell);
|
|
2792
|
+
} else if (options.completionInstallShell) {
|
|
2793
|
+
installCompletion(options.completionInstallShell);
|
|
2794
|
+
} else if (options.completionUninstallShell) {
|
|
2795
|
+
uninstallCompletion(options.completionUninstallShell);
|
|
2796
|
+
} else if (options.listInstances) {
|
|
2797
|
+
printRunningInstances();
|
|
2798
|
+
} else if (options.closeTarget) {
|
|
2799
|
+
closeRunningInstance(options.closeTarget);
|
|
2800
|
+
} else if (options.pluginInstall && options.pluginPortsDir) {
|
|
2801
|
+
try {
|
|
2802
|
+
installLocalPluginPort(options);
|
|
2803
|
+
} catch (installError) {
|
|
2804
|
+
console.error(`fpasoterm: could not install local plugin port: ${installError.message}`);
|
|
2805
|
+
process.exitCode = 1;
|
|
2806
|
+
}
|
|
2807
|
+
} else if (options.pluginInstall) {
|
|
2808
|
+
Promise.resolve(installPublicPlugin(options)).catch((installError) => {
|
|
2809
|
+
console.error(`fpasoterm: could not install public plugin: ${installError.message}`);
|
|
2810
|
+
process.exitCode = 1;
|
|
2811
|
+
});
|
|
2812
|
+
} else if (options.pluginInstallFile) {
|
|
2813
|
+
try {
|
|
2814
|
+
installLocalPluginFile(options);
|
|
2815
|
+
} catch (installError) {
|
|
2816
|
+
console.error(`fpasoterm: could not install local plugin file: ${installError.message}`);
|
|
2817
|
+
process.exitCode = 1;
|
|
2818
|
+
}
|
|
2819
|
+
} else if (uninstallLocalPlugins(options)) {
|
|
2820
|
+
// The selected local plugin sources and config entries have been removed.
|
|
2821
|
+
} else if (updatePluginConfig(options)) {
|
|
2822
|
+
// The config command has completed without starting the desktop process.
|
|
2823
|
+
} else if (options.resetWindowState) {
|
|
2824
|
+
resetWindowState(options);
|
|
2825
|
+
} else if (options.resetConfig) {
|
|
2826
|
+
resetConfig(options);
|
|
2827
|
+
} else if (options.updateConfig) {
|
|
2828
|
+
updateConfig(options);
|
|
2829
|
+
} else if (options.pruneConfig) {
|
|
2830
|
+
pruneConfigFile(options);
|
|
2831
|
+
} else if (options.syncStatus || options.syncDiagnostics) {
|
|
2832
|
+
process.stdout.write(syncStatusMarkdown(options));
|
|
2833
|
+
} else if (options.syncClean) {
|
|
2834
|
+
cleanSyncFolder(options);
|
|
2835
|
+
} else if (options.broadcastText) {
|
|
2836
|
+
broadcastTerminalInput(options);
|
|
2837
|
+
} else if (options.setupSync) {
|
|
2838
|
+
Promise.resolve(setupSync(options)).catch((setupError) => {
|
|
2839
|
+
console.error(`fpasoterm: ${setupError.message}`);
|
|
2840
|
+
process.exitCode = 1;
|
|
2841
|
+
});
|
|
2842
|
+
} else if (options.showConfig) {
|
|
2843
|
+
showConfig(options);
|
|
2844
|
+
} else if (options.configCheck) {
|
|
2845
|
+
checkConfig(options);
|
|
2846
|
+
} else if (options.profileList) {
|
|
2847
|
+
showProfileList(options);
|
|
2848
|
+
} else if (options.configPathOnly) {
|
|
2849
|
+
showConfigPath(options);
|
|
2850
|
+
} else if (options.configExample) {
|
|
2851
|
+
showConfigExample(options);
|
|
2852
|
+
} else if (options.diagnostics) {
|
|
2853
|
+
process.stdout.write(diagnosticsMarkdown(options));
|
|
2854
|
+
} else if (options.openLogDir) {
|
|
2855
|
+
openLogDirectory(options);
|
|
2856
|
+
} else if (options.copyDiagnostics) {
|
|
2857
|
+
copyDiagnostics(options);
|
|
2858
|
+
} else if (options.pluginList) {
|
|
2859
|
+
showPluginList(options);
|
|
2860
|
+
} else if (options.pluginPath) {
|
|
2861
|
+
showPluginPath(options);
|
|
2862
|
+
} else if (options.pluginInfo) {
|
|
2863
|
+
showPluginInfo(options);
|
|
2864
|
+
} else if (options.updateDesktop) {
|
|
2865
|
+
updateDesktopIntegration();
|
|
2866
|
+
} else if (options.selfUpdateCheckout) {
|
|
2867
|
+
selfUpdateCheckout();
|
|
2868
|
+
} else if (options.selfUpdate) {
|
|
2869
|
+
selfUpdate();
|
|
2870
|
+
} else {
|
|
2871
|
+
Promise.resolve(launch(options)).catch((launchError) => {
|
|
2872
|
+
console.error(`fpasoterm: ${launchError.message}`);
|
|
2873
|
+
appendLauncherLog(`launcher error=${launchError.message}`);
|
|
2874
|
+
process.exitCode = 1;
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
} catch (error) {
|
|
2878
|
+
console.error(`fpasoterm: ${error.message}`);
|
|
2879
|
+
// Invalid CLI input must remain concise enough for scripts and shell history.
|
|
2880
|
+
// Explicit --help is the only operation that prints the full option list.
|
|
2881
|
+
process.exitCode = parsedArguments ? 1 : 2;
|
|
2882
|
+
}
|