sourcemonkey 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/bytes.js +37 -0
- package/dist/channel-placeholder.js +5 -0
- package/dist/collection-scan.js +17 -0
- package/dist/collections.html +353 -0
- package/dist/compare.html +358 -0
- package/dist/composed-source.js +205 -0
- package/dist/connect-prompt.html +47 -0
- package/dist/debug/icons.html +211 -0
- package/dist/debug/storage.html +163 -0
- package/dist/debug-log.js +75 -0
- package/dist/diagnostic-location.js +64 -0
- package/dist/eval-hook.js +308 -0
- package/dist/extension/background.js +5263 -0
- package/dist/extension/channel.js +133 -0
- package/dist/extension/code-links.js +187 -0
- package/dist/extension/code-tabs.js +460 -0
- package/dist/extension/collection-dialog.js +619 -0
- package/dist/extension/collection-display.js +322 -0
- package/dist/extension/collections.js +2905 -0
- package/dist/extension/compare.js +867 -0
- package/dist/extension/concurrency.js +186 -0
- package/dist/extension/connect-grants.js +109 -0
- package/dist/extension/connect-prompt-page.js +70 -0
- package/dist/extension/connect-prompt.js +266 -0
- package/dist/extension/copy-button.js +63 -0
- package/dist/extension/copy-dialog.js +502 -0
- package/dist/extension/copy-preference.js +66 -0
- package/dist/extension/debug/icons.js +203 -0
- package/dist/extension/debug/storage.js +365 -0
- package/dist/extension/deep-link.js +77 -0
- package/dist/extension/delete-dialog.js +432 -0
- package/dist/extension/delete-icon.js +40 -0
- package/dist/extension/details-tooltips.js +66 -0
- package/dist/extension/directories.js +679 -0
- package/dist/extension/export-backup.js +407 -0
- package/dist/extension/export-dialog.js +142 -0
- package/dist/extension/file-access.js +74 -0
- package/dist/extension/file-changes.js +346 -0
- package/dist/extension/frame-info.js +85 -0
- package/dist/extension/github-provider.js +336 -0
- package/dist/extension/glyph.js +84 -0
- package/dist/extension/gm-bindings.js +601 -0
- package/dist/extension/gm-download.js +419 -0
- package/dist/extension/gm-menu.js +228 -0
- package/dist/extension/gm-notification.js +141 -0
- package/dist/extension/gm-originator-map.js +23 -0
- package/dist/extension/gm-tab-data.js +121 -0
- package/dist/extension/gm-tab.js +160 -0
- package/dist/extension/gm-url-change.js +111 -0
- package/dist/extension/gm-url-scheme.js +27 -0
- package/dist/extension/gm-values.js +176 -0
- package/dist/extension/gm-window-control.js +104 -0
- package/dist/extension/gm-xhr-headers.js +333 -0
- package/dist/extension/gm-xhr.js +584 -0
- package/dist/extension/injector.js +3145 -0
- package/dist/extension/install-capture.js +277 -0
- package/dist/extension/line-diff.js +233 -0
- package/dist/extension/local-copy.js +560 -0
- package/dist/extension/local-delete.js +276 -0
- package/dist/extension/location-kinds.js +108 -0
- package/dist/extension/log-format.js +158 -0
- package/dist/extension/managed-changes.js +361 -0
- package/dist/extension/managed-items.js +57 -0
- package/dist/extension/match-index.js +375 -0
- package/dist/extension/missing-scripts.js +66 -0
- package/dist/extension/more-menu.js +109 -0
- package/dist/extension/move-dialog.js +197 -0
- package/dist/extension/offscreen.js +37 -0
- package/dist/extension/options.js +504 -0
- package/dist/extension/page-heading.js +170 -0
- package/dist/extension/page-links.js +199 -0
- package/dist/extension/parse-check-client.js +70 -0
- package/dist/extension/parse-check.js +161 -0
- package/dist/extension/parse-sandbox-host.js +172 -0
- package/dist/extension/parse-sandbox.js +175 -0
- package/dist/extension/popup-commands-open.js +41 -0
- package/dist/extension/popup-view.js +387 -0
- package/dist/extension/popup.js +703 -0
- package/dist/extension/prism-userscript.js +53 -0
- package/dist/extension/refresh-error.js +136 -0
- package/dist/extension/refresh-targets.js +160 -0
- package/dist/extension/related-versions.js +1144 -0
- package/dist/extension/resize-observer-loop.js +19 -0
- package/dist/extension/row-cache.js +387 -0
- package/dist/extension/script-blobs.js +413 -0
- package/dist/extension/script-details.js +658 -0
- package/dist/extension/script-enablement.js +285 -0
- package/dist/extension/script-identity.js +310 -0
- package/dist/extension/script-keys.js +54 -0
- package/dist/extension/script-log-id.js +155 -0
- package/dist/extension/script-log.js +290 -0
- package/dist/extension/script-rejection.js +104 -0
- package/dist/extension/script-sites.js +158 -0
- package/dist/extension/script-store.js +3507 -0
- package/dist/extension/script-updater.js +991 -0
- package/dist/extension/script-url-hash.js +61 -0
- package/dist/extension/select-all.js +37 -0
- package/dist/extension/select-open-guard.js +90 -0
- package/dist/extension/site-icon.js +1261 -0
- package/dist/extension/sm-dev-link.js +302 -0
- package/dist/extension/solo-click.js +58 -0
- package/dist/extension/sticky-numbers.js +188 -0
- package/dist/extension/storage-breakdown.js +253 -0
- package/dist/extension/storage-scan.js +86 -0
- package/dist/extension/storage-usage.js +194 -0
- package/dist/extension/structural-keys.js +29 -0
- package/dist/extension/sw-teardown.js +36 -0
- package/dist/extension/tab-status.js +403 -0
- package/dist/extension/toolbar-summary.js +105 -0
- package/dist/extension/user-scripts-access.js +43 -0
- package/dist/extension/userscript.js +3672 -0
- package/dist/extension/value-store.js +204 -0
- package/dist/extension/version-probe-fetch.js +68 -0
- package/dist/extension/worker-client.js +109 -0
- package/dist/extension/worker-ops.js +171 -0
- package/dist/extension/zip-writer.js +24 -0
- package/dist/external-files.js +878 -0
- package/dist/file-loader.js +436 -0
- package/dist/file-locations.js +439 -0
- package/dist/generated-wrappers.js +5 -0
- package/dist/github-auth.js +148 -0
- package/dist/github-urls.js +46 -0
- package/dist/gm-api-catalog.js +217 -0
- package/dist/gm-connect.js +273 -0
- package/dist/gm-xhr-types.js +5 -0
- package/dist/http-read.js +142 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/local-file-overrides.js +558 -0
- package/dist/manifest.json +38 -0
- package/dist/markup-response.js +126 -0
- package/dist/match-targets.js +305 -0
- package/dist/menu-captions.js +22 -0
- package/dist/offscreen.html +39 -0
- package/dist/options.html +197 -0
- package/dist/parse-check-protocol.js +51 -0
- package/dist/parse-location.js +171 -0
- package/dist/parse-sandbox.html +56 -0
- package/dist/phase-timing.js +155 -0
- package/dist/popup.html +197 -0
- package/dist/public-suffixes.js +6957 -0
- package/dist/run-context.js +31 -0
- package/dist/runtime-messages.js +34 -0
- package/dist/sandboxed-page.js +35 -0
- package/dist/script-build.js +787 -0
- package/dist/script-display.js +383 -0
- package/dist/script-icon.js +43 -0
- package/dist/script-links.js +204 -0
- package/dist/script-source-url.js +214 -0
- package/dist/script-world.js +65 -0
- package/dist/sm-dev-protocol.js +84 -0
- package/dist/source-map.js +177 -0
- package/dist/stack-link.js +138 -0
- package/dist/styles.css +825 -0
- package/dist/unsafe-window-use.js +229 -0
- package/dist/url-match.js +734 -0
- package/dist/userscript-header.js +248 -0
- package/dist/userscript.html +1165 -0
- package/dist/vendor/fflate.LICENSE +21 -0
- package/dist/vendor/fflate.js +2692 -0
- package/dist/vendor/prism/prism-line-numbers.css +1 -0
- package/dist/vendor/prism/prism-line-numbers.js +1 -0
- package/dist/vendor/prism/prism.css +1 -0
- package/dist/vendor/prism/prism.js +1946 -0
- package/dist/version-compare.js +141 -0
- package/dist/version-probe.js +120 -0
- package/lib/build-info.json +1 -0
- package/lib/bytes.d.ts +3 -0
- package/lib/bytes.js +37 -0
- package/lib/channel-placeholder.d.ts +1 -0
- package/lib/channel-placeholder.js +5 -0
- package/lib/collection-scan.d.ts +8 -0
- package/lib/collection-scan.js +17 -0
- package/lib/composed-source.d.ts +28 -0
- package/lib/composed-source.js +205 -0
- package/lib/debug-log.d.ts +9 -0
- package/lib/debug-log.js +75 -0
- package/lib/diagnostic-location.d.ts +8 -0
- package/lib/diagnostic-location.js +64 -0
- package/lib/eval-hook.d.ts +34 -0
- package/lib/eval-hook.js +308 -0
- package/lib/external-files.d.ts +111 -0
- package/lib/external-files.js +878 -0
- package/lib/file-loader.d.ts +48 -0
- package/lib/file-loader.js +436 -0
- package/lib/file-locations.d.ts +44 -0
- package/lib/file-locations.js +439 -0
- package/lib/generated-wrappers.d.ts +11 -0
- package/lib/generated-wrappers.js +5 -0
- package/lib/github-auth.d.ts +12 -0
- package/lib/github-auth.js +148 -0
- package/lib/github-urls.d.ts +1 -0
- package/lib/github-urls.js +46 -0
- package/lib/gm-api-catalog.d.ts +24 -0
- package/lib/gm-api-catalog.js +217 -0
- package/lib/gm-connect.d.ts +34 -0
- package/lib/gm-connect.js +273 -0
- package/lib/gm-xhr-types.d.ts +68 -0
- package/lib/gm-xhr-types.js +5 -0
- package/lib/harness/browser-extensions.d.ts +73 -0
- package/lib/harness/browser-extensions.js +285 -0
- package/lib/harness/browser-lang.d.ts +20 -0
- package/lib/harness/browser-lang.js +86 -0
- package/lib/harness/browser-launch.d.ts +14 -0
- package/lib/harness/browser-launch.js +184 -0
- package/lib/harness/checks.d.ts +110 -0
- package/lib/harness/checks.js +321 -0
- package/lib/harness/cli.d.ts +2 -0
- package/lib/harness/cli.js +2259 -0
- package/lib/harness/command-args.d.ts +91 -0
- package/lib/harness/command-args.js +369 -0
- package/lib/harness/command-lines.d.ts +80 -0
- package/lib/harness/command-lines.js +180 -0
- package/lib/harness/command-list.d.ts +206 -0
- package/lib/harness/command-list.js +1475 -0
- package/lib/harness/console-commands.d.ts +463 -0
- package/lib/harness/console-commands.js +3216 -0
- package/lib/harness/download-watch.d.ts +21 -0
- package/lib/harness/download-watch.js +156 -0
- package/lib/harness/extension-backend.d.ts +41 -0
- package/lib/harness/extension-backend.js +100 -0
- package/lib/harness/extension-host.d.ts +112 -0
- package/lib/harness/extension-host.js +552 -0
- package/lib/harness/extension-launch.d.ts +67 -0
- package/lib/harness/extension-launch.js +285 -0
- package/lib/harness/extension-link.d.ts +315 -0
- package/lib/harness/extension-link.js +607 -0
- package/lib/harness/external-manager.d.ts +146 -0
- package/lib/harness/external-manager.js +414 -0
- package/lib/harness/fixture.d.ts +165 -0
- package/lib/harness/fixture.js +461 -0
- package/lib/harness/frame-sessions.d.ts +71 -0
- package/lib/harness/frame-sessions.js +248 -0
- package/lib/harness/frames.d.ts +68 -0
- package/lib/harness/frames.js +216 -0
- package/lib/harness/help-format.d.ts +17 -0
- package/lib/harness/help-format.js +127 -0
- package/lib/harness/host.d.ts +352 -0
- package/lib/harness/host.js +423 -0
- package/lib/harness/index.d.ts +8 -0
- package/lib/harness/index.js +10 -0
- package/lib/harness/input-file.d.ts +18 -0
- package/lib/harness/input-file.js +61 -0
- package/lib/harness/json-output.d.ts +565 -0
- package/lib/harness/json-output.js +71 -0
- package/lib/harness/manager-actions.d.ts +21 -0
- package/lib/harness/manager-actions.js +129 -0
- package/lib/harness/manager-commands.d.ts +142 -0
- package/lib/harness/manager-commands.js +1196 -0
- package/lib/harness/manager-fetch.d.ts +33 -0
- package/lib/harness/manager-fetch.js +239 -0
- package/lib/harness/managers.d.ts +24 -0
- package/lib/harness/managers.js +27 -0
- package/lib/harness/output-style.d.ts +125 -0
- package/lib/harness/output-style.js +322 -0
- package/lib/harness/page-commands.d.ts +134 -0
- package/lib/harness/page-commands.js +1141 -0
- package/lib/harness/page-output.d.ts +67 -0
- package/lib/harness/page-output.js +139 -0
- package/lib/harness/page-watch.d.ts +171 -0
- package/lib/harness/page-watch.js +607 -0
- package/lib/harness/parse-check-node.d.ts +3 -0
- package/lib/harness/parse-check-node.js +132 -0
- package/lib/harness/path-args.d.ts +22 -0
- package/lib/harness/path-args.js +148 -0
- package/lib/harness/playwright-chromium.d.ts +16 -0
- package/lib/harness/playwright-chromium.js +86 -0
- package/lib/harness/prepare.d.ts +168 -0
- package/lib/harness/prepare.js +364 -0
- package/lib/harness/prompt-editor.d.ts +17 -0
- package/lib/harness/prompt-editor.js +354 -0
- package/lib/harness/prompt-history.d.ts +13 -0
- package/lib/harness/prompt-history.js +89 -0
- package/lib/harness/rebuild-advice.d.ts +6 -0
- package/lib/harness/rebuild-advice.js +39 -0
- package/lib/harness/run-log.d.ts +47 -0
- package/lib/harness/run-log.js +140 -0
- package/lib/harness/run-logs.d.ts +132 -0
- package/lib/harness/run-logs.js +241 -0
- package/lib/harness/run.d.ts +194 -0
- package/lib/harness/run.js +1973 -0
- package/lib/harness/runs.d.ts +229 -0
- package/lib/harness/runs.js +726 -0
- package/lib/harness/save-html.d.ts +39 -0
- package/lib/harness/save-html.js +89 -0
- package/lib/harness/saved-pages.d.ts +120 -0
- package/lib/harness/saved-pages.js +275 -0
- package/lib/harness/script-mark.d.ts +17 -0
- package/lib/harness/script-mark.js +56 -0
- package/lib/harness/snapshot.d.ts +143 -0
- package/lib/harness/snapshot.js +474 -0
- package/lib/harness/stuck-tabs.d.ts +19 -0
- package/lib/harness/stuck-tabs.js +131 -0
- package/lib/harness/tab-refs.d.ts +20 -0
- package/lib/harness/tab-refs.js +80 -0
- package/lib/harness/tampermonkey.d.ts +71 -0
- package/lib/harness/tampermonkey.js +356 -0
- package/lib/harness/tm-ids.d.ts +18 -0
- package/lib/harness/tm-ids.js +88 -0
- package/lib/harness/violentmonkey.d.ts +34 -0
- package/lib/harness/violentmonkey.js +162 -0
- package/lib/harness/wait.d.ts +77 -0
- package/lib/harness/wait.js +458 -0
- package/lib/http-read.d.ts +30 -0
- package/lib/http-read.js +142 -0
- package/lib/local-file-overrides.d.ts +35 -0
- package/lib/local-file-overrides.js +558 -0
- package/lib/manifest.json +38 -0
- package/lib/markup-response.d.ts +11 -0
- package/lib/markup-response.js +126 -0
- package/lib/match-targets.d.ts +18 -0
- package/lib/match-targets.js +305 -0
- package/lib/menu-captions.d.ts +1 -0
- package/lib/menu-captions.js +22 -0
- package/lib/parse-check-protocol.d.ts +27 -0
- package/lib/parse-check-protocol.js +51 -0
- package/lib/parse-location.d.ts +12 -0
- package/lib/parse-location.js +171 -0
- package/lib/phase-timing.d.ts +12 -0
- package/lib/phase-timing.js +155 -0
- package/lib/public-suffixes.d.ts +1 -0
- package/lib/public-suffixes.js +6957 -0
- package/lib/run-context.d.ts +2 -0
- package/lib/run-context.js +31 -0
- package/lib/runtime-messages.d.ts +10 -0
- package/lib/runtime-messages.js +34 -0
- package/lib/sandboxed-page.d.ts +5 -0
- package/lib/sandboxed-page.js +35 -0
- package/lib/script-build.d.ts +111 -0
- package/lib/script-build.js +787 -0
- package/lib/script-display.d.ts +51 -0
- package/lib/script-display.js +383 -0
- package/lib/script-icon.d.ts +16 -0
- package/lib/script-icon.js +43 -0
- package/lib/script-links.d.ts +24 -0
- package/lib/script-links.js +204 -0
- package/lib/script-source-url.d.ts +8 -0
- package/lib/script-source-url.js +214 -0
- package/lib/script-world.d.ts +6 -0
- package/lib/script-world.js +65 -0
- package/lib/sm-dev-protocol.d.ts +251 -0
- package/lib/sm-dev-protocol.js +84 -0
- package/lib/source-map.d.ts +32 -0
- package/lib/source-map.js +177 -0
- package/lib/stack-link.d.ts +15 -0
- package/lib/stack-link.js +138 -0
- package/lib/unsafe-window-use.d.ts +1 -0
- package/lib/unsafe-window-use.js +229 -0
- package/lib/url-match.d.ts +40 -0
- package/lib/url-match.js +734 -0
- package/lib/userscript-header.d.ts +33 -0
- package/lib/userscript-header.js +248 -0
- package/lib/version-compare.d.ts +6 -0
- package/lib/version-compare.js +141 -0
- package/lib/version-probe.d.ts +17 -0
- package/lib/version-probe.js +120 -0
- package/package.json +55 -3
- package/skills/SourceMonkey-dev/SKILL.md +868 -0
- package/skills/SourceMonkey-dev/references/cdp.md +293 -0
- package/skills/SourceMonkey-dev/references/commands.md +297 -0
- package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
- package/skills/SourceMonkey-dev/references/specs.md +46 -0
- package/skills/SourceMonkey-friction/SKILL.md +103 -0
- package/skills/SourceMonkey-friction/references/template.md +59 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// The runs `sm-dev start` began on this machine, and how another shell
|
|
2
|
+
// reaches one: `sm-dev list`, `sm-dev stop`, and the commands sent to
|
|
3
|
+
// a run (`sm-dev status`, or piped to `sm-dev`'s stdin).
|
|
4
|
+
//
|
|
5
|
+
// Each run writes a record file and listens on a socket of its own,
|
|
6
|
+
// both named by its id (`D1`). A run has no terminal to type into:
|
|
7
|
+
// every command comes from a client over the socket, runs as a typed
|
|
8
|
+
// command would, and its output comes back (see RunClient).
|
|
9
|
+
//
|
|
10
|
+
// Node-only: no Playwright, so `list`, `stop` and the commands sent to
|
|
11
|
+
// a run work anywhere.
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
14
|
+
import net from 'node:net';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { STDERR_LINE } from './json-output.js';
|
|
18
|
+
import { err as errorLine, escapeMarkup, heading, id as idRole, markupLines, plainLine, quoted, roleCell, tableLines, textCell, url } from './output-style.js';
|
|
19
|
+
import { LOG_SOURCES } from './run-logs.js';
|
|
20
|
+
import { DEFAULT_MANAGER, MANAGERS } from './managers.js';
|
|
21
|
+
/**
|
|
22
|
+
* Who the runs belong to, in the directory and pipe names, so two
|
|
23
|
+
* users on one machine neither see nor clash with each other's runs:
|
|
24
|
+
* the user name, kept to characters a file name can take. The uid when
|
|
25
|
+
* there is no name: `os.userInfo()` throws for a uid with no passwd
|
|
26
|
+
* entry, as in a container run with `--user`.
|
|
27
|
+
*/
|
|
28
|
+
export function owner() {
|
|
29
|
+
let name;
|
|
30
|
+
try {
|
|
31
|
+
name = os.userInfo().username;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
name = process.env.USER ?? process.env.USERNAME;
|
|
35
|
+
}
|
|
36
|
+
const safe = name?.replace(/[^\w.-]/g, '_');
|
|
37
|
+
if (safe)
|
|
38
|
+
return safe;
|
|
39
|
+
const uid = process.getuid?.();
|
|
40
|
+
return uid !== undefined ? String(uid) : 'user';
|
|
41
|
+
}
|
|
42
|
+
// `SM_DEV_RUNS_DIR` moves it, for tests to keep out of real runs' way.
|
|
43
|
+
function runsDir() {
|
|
44
|
+
return process.env.SM_DEV_RUNS_DIR || path.join(os.tmpdir(), `sm-dev-runs-${owner()}`);
|
|
45
|
+
}
|
|
46
|
+
function recordPath(id) {
|
|
47
|
+
return path.join(runsDir(), `${id}.json`);
|
|
48
|
+
}
|
|
49
|
+
// Windows has no socket files; a named pipe stands in.
|
|
50
|
+
function socketPath(id) {
|
|
51
|
+
return process.platform === 'win32'
|
|
52
|
+
? `\\\\.\\pipe\\sm-dev-${owner()}-${id}`
|
|
53
|
+
: path.join(runsDir(), `${id}.sock`);
|
|
54
|
+
}
|
|
55
|
+
// Runs are named D1, D2, as tabs are T1 and menu commands M1: the
|
|
56
|
+
// lowest number free when the run starts, so they stay short to type.
|
|
57
|
+
// A number is reused once its run has ended. `start --run` can name one
|
|
58
|
+
// instead: `D` and then letters, digits, `_` or `-`. Kept short, since
|
|
59
|
+
// the id is part of the socket's path, which has a length limit.
|
|
60
|
+
const RUN_ID = /^(?:[Dd](\d{1,32})|D([0-9A-Za-z_-]{1,32}))$/;
|
|
61
|
+
/** Where `start` records the tabs it opened, for a later run to reuse. */
|
|
62
|
+
export function runTabsPath() {
|
|
63
|
+
return path.join(runsDir(), 'run-tabs.json');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A typed run id in its usual form (`d2` is `D2`, `D02` is `D2`), or
|
|
67
|
+
* null if it isn't one. Named ones are taken as typed.
|
|
68
|
+
*/
|
|
69
|
+
export function runIdOf(typed) {
|
|
70
|
+
const m = RUN_ID.exec(typed);
|
|
71
|
+
if (!m)
|
|
72
|
+
return null;
|
|
73
|
+
return m[1] !== undefined ? `D${BigInt(m[1])}` : `D${m[2]}`;
|
|
74
|
+
}
|
|
75
|
+
export function processAlive(pid) {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// EPERM: it exists, under another user.
|
|
82
|
+
return err.code === 'EPERM';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The directory for sm-dev's state across runs, made private to its
|
|
87
|
+
* user: the runs' records and sockets, and `start`'s tab records. Created
|
|
88
|
+
* when missing.
|
|
89
|
+
*/
|
|
90
|
+
export async function ownRunsDir() {
|
|
91
|
+
const dir = runsDir();
|
|
92
|
+
try {
|
|
93
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
throw new Error(`Could not create the runs directory ${dir}: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
await checkRunsDir();
|
|
99
|
+
return dir;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The directory, checked before anything in it is trusted, or null
|
|
103
|
+
* when there is none yet. In a shared temp directory another user could
|
|
104
|
+
* have made it first, or be able to write in it, and then plant records
|
|
105
|
+
* whose pids `stop` would signal; such a directory is refused.
|
|
106
|
+
*/
|
|
107
|
+
export async function checkRunsDir() {
|
|
108
|
+
return checkPrivateDir(runsDir(), 'runs');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `dir`, or null when it doesn't exist; refused when another user owns
|
|
112
|
+
* it or can write in it. `what` names it in the message (`runs`, `log`).
|
|
113
|
+
*/
|
|
114
|
+
export async function checkPrivateDir(dir, what) {
|
|
115
|
+
let info;
|
|
116
|
+
try {
|
|
117
|
+
info = await lstat(dir);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const uid = process.getuid?.();
|
|
123
|
+
if (!info.isDirectory() || (uid !== undefined && info.uid !== uid)) {
|
|
124
|
+
throw new Error(`The ${what} directory ${dir} belongs to another user`);
|
|
125
|
+
}
|
|
126
|
+
if (uid !== undefined && (info.mode & 0o022) !== 0) {
|
|
127
|
+
throw new Error(`The ${what} directory ${dir} can be written by other users. Remove it, and sm-dev makes a private one.`);
|
|
128
|
+
}
|
|
129
|
+
return dir;
|
|
130
|
+
}
|
|
131
|
+
const SOURCES = ['arg', 'stdin', 'prompt'];
|
|
132
|
+
// How long a sender gets to take the last of the output once the run
|
|
133
|
+
// ends its connection, before the run drops it and exits anyway.
|
|
134
|
+
const CLOSE_WAIT_MS = 1000;
|
|
135
|
+
/** A run's end of the registry: its record file and its socket. */
|
|
136
|
+
export class RunControl {
|
|
137
|
+
record;
|
|
138
|
+
server;
|
|
139
|
+
sockets = new Set();
|
|
140
|
+
// Record writes, one at a time, so an older one never lands last.
|
|
141
|
+
writes = Promise.resolve();
|
|
142
|
+
closed = false;
|
|
143
|
+
constructor(record, server) {
|
|
144
|
+
this.record = record;
|
|
145
|
+
this.server = server;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Takes the run's id, registers it and starts listening. Throws when
|
|
149
|
+
* that fails; the run goes on without, but can't be reached from
|
|
150
|
+
* another shell.
|
|
151
|
+
*/
|
|
152
|
+
static async open(fields, handle, wanted) {
|
|
153
|
+
await ownRunsDir();
|
|
154
|
+
const server = net.createServer();
|
|
155
|
+
const id = wanted === undefined ? await claimId(server) : await claimNamedId(server, wanted);
|
|
156
|
+
const record = { id, ...fields };
|
|
157
|
+
const control = new RunControl(record, server);
|
|
158
|
+
server.on('connection', (socket) => control.serve(socket, handle));
|
|
159
|
+
// Nothing keeps the process alive for a listener alone: the run
|
|
160
|
+
// ends on its own terms.
|
|
161
|
+
server.unref();
|
|
162
|
+
try {
|
|
163
|
+
await writeRecord(record);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
server.close();
|
|
167
|
+
throw new Error(`Could not write the run's record ${recordPath(record.id)}: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
return control;
|
|
170
|
+
}
|
|
171
|
+
get id() {
|
|
172
|
+
return this.record.id;
|
|
173
|
+
}
|
|
174
|
+
/** Records changed scripts or tabs, for `sm-dev list`. */
|
|
175
|
+
update(changes) {
|
|
176
|
+
this.record = { ...this.record, ...changes };
|
|
177
|
+
this.writes = this.writes.then(() => (this.closed ? undefined : writeRecord(this.record))).catch(() => undefined);
|
|
178
|
+
return this.writes;
|
|
179
|
+
}
|
|
180
|
+
/** Unregisters the run, and ends every connection still open. */
|
|
181
|
+
async close() {
|
|
182
|
+
this.closed = true;
|
|
183
|
+
await this.writes;
|
|
184
|
+
await rm(recordPath(this.record.id), { force: true }).catch(() => undefined);
|
|
185
|
+
for (const socket of this.sockets)
|
|
186
|
+
socket.end();
|
|
187
|
+
// Not waited on for good: a sender that never closes its end would
|
|
188
|
+
// otherwise hold the run open.
|
|
189
|
+
const closed = new Promise((resolve) => this.server.close(() => resolve()));
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
for (const socket of this.sockets)
|
|
192
|
+
socket.destroy();
|
|
193
|
+
}, CLOSE_WAIT_MS);
|
|
194
|
+
await closed;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A client's connection: JSON messages, a line each. The first may be
|
|
199
|
+
* `{"hello": {"follow": true, "pid": 123, "logSources": ["sm", ...], "markup": true}}`:
|
|
200
|
+
* the run's whole stream, of those log sources if given, which client
|
|
201
|
+
* it is, and that it takes lines with markup (output-style.ts), which
|
|
202
|
+
* others get as plain text. Then commands, `{"line": ..., "cwd": ...,
|
|
203
|
+
* "words": [...], "source": "arg"}`, each
|
|
204
|
+
* answered with its output and then a `COMMAND_DONE` line with its
|
|
205
|
+
* exit code before the next is taken.
|
|
206
|
+
*/
|
|
207
|
+
serve(socket, handle) {
|
|
208
|
+
this.sockets.add(socket);
|
|
209
|
+
// A client that goes away mid-reply is no concern of the run's.
|
|
210
|
+
socket.on('error', () => undefined);
|
|
211
|
+
// From the hello: whether the client paints markup itself.
|
|
212
|
+
let markup = false;
|
|
213
|
+
const reply = (line) => {
|
|
214
|
+
if (!socket.destroyed && socket.writable) {
|
|
215
|
+
// The client reads line by line, so markup goes as lines that each
|
|
216
|
+
// stand alone, each keeping a `--json` stderr tag the whole had.
|
|
217
|
+
const tag = line.startsWith(STDERR_LINE) ? STDERR_LINE : '';
|
|
218
|
+
const body = line.slice(tag.length);
|
|
219
|
+
const lines = (markup ? markupLines(body) : plainLine(body).split('\n')).map((l) => tag + l);
|
|
220
|
+
socket.write(lines.join('\n') + '\n');
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
let unfollow = null;
|
|
224
|
+
socket.on('close', () => {
|
|
225
|
+
this.sockets.delete(socket);
|
|
226
|
+
unfollow?.();
|
|
227
|
+
});
|
|
228
|
+
let buffered = '';
|
|
229
|
+
// One command at a time, in order; none after one the run holds.
|
|
230
|
+
let queue = Promise.resolve();
|
|
231
|
+
let held = false;
|
|
232
|
+
// The client's, from its hello.
|
|
233
|
+
let pid;
|
|
234
|
+
socket.setEncoding('utf8');
|
|
235
|
+
socket.on('data', (chunk) => {
|
|
236
|
+
buffered += chunk;
|
|
237
|
+
const lines = buffered.split('\n');
|
|
238
|
+
buffered = lines.pop();
|
|
239
|
+
for (const text of lines) {
|
|
240
|
+
let message;
|
|
241
|
+
try {
|
|
242
|
+
message = JSON.parse(text.replace(/\r$/, ''));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Not a client's message.
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (message.hello) {
|
|
249
|
+
if (typeof message.hello.pid === 'number')
|
|
250
|
+
pid = message.hello.pid;
|
|
251
|
+
markup = message.hello.markup === true;
|
|
252
|
+
const { logSources } = message.hello;
|
|
253
|
+
const sources = Array.isArray(logSources) ? new Set(logSources.filter((s) => LOG_SOURCES.includes(s))) : undefined;
|
|
254
|
+
if (message.hello.follow === true && !unfollow)
|
|
255
|
+
unfollow = handle.follow(reply, sources);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (typeof message.line !== 'string')
|
|
259
|
+
continue;
|
|
260
|
+
const line = message.line;
|
|
261
|
+
const sent = {
|
|
262
|
+
cwd: typeof message.cwd === 'string' ? message.cwd : undefined,
|
|
263
|
+
words: Array.isArray(message.words) && message.words.every((w) => typeof w === 'string') ? message.words : undefined,
|
|
264
|
+
source: SOURCES.includes(message.source) ? message.source : 'arg',
|
|
265
|
+
pid,
|
|
266
|
+
follows: unfollow !== null,
|
|
267
|
+
json: message.json === true,
|
|
268
|
+
};
|
|
269
|
+
queue = queue.then(async () => {
|
|
270
|
+
if (held)
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
const code = await handle.command(line, reply, sent);
|
|
274
|
+
if (code === 'hold')
|
|
275
|
+
held = true;
|
|
276
|
+
else
|
|
277
|
+
reply(doneLine(code));
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
reply(`[sm-dev] ${errorLine(err.message)}`);
|
|
281
|
+
reply(doneLine(1));
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// The lowest free id, taken by listening on its socket: only one
|
|
289
|
+
// process can, so two runs starting together never get the same one.
|
|
290
|
+
// The record is written after, so `list` never shows a run it can't
|
|
291
|
+
// reach yet.
|
|
292
|
+
async function claimId(server) {
|
|
293
|
+
for (let n = 1;; n++) {
|
|
294
|
+
const id = `D${n}`;
|
|
295
|
+
if (await listenOn(server, socketPath(id)))
|
|
296
|
+
return id;
|
|
297
|
+
// In use, or a socket file a killed run left, which nothing listens
|
|
298
|
+
// on. Windows pipes leave no such file.
|
|
299
|
+
if (process.platform !== 'win32' && await takeStale(server, id))
|
|
300
|
+
return id;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// `start --run`'s id: taken as the lowest free one is, or refused when
|
|
304
|
+
// a run holds it still.
|
|
305
|
+
async function claimNamedId(server, id) {
|
|
306
|
+
if (await listenOn(server, socketPath(id)))
|
|
307
|
+
return id;
|
|
308
|
+
if (process.platform !== 'win32' && await takeStale(server, id))
|
|
309
|
+
return id;
|
|
310
|
+
throw new Error(`Run ${quoted(id)} is running already`);
|
|
311
|
+
}
|
|
312
|
+
// The longest socket path the system takes, in bytes, with room for
|
|
313
|
+
// the NUL: `sun_path` is 108 bytes on Linux and 104 on macOS.
|
|
314
|
+
const MAX_SOCKET_PATH = process.platform === 'linux' ? 107 : 103;
|
|
315
|
+
// Listens on `where`: false when something already holds it.
|
|
316
|
+
async function listenOn(server, where) {
|
|
317
|
+
// Too long, it would be cut short, and fail as though it were taken.
|
|
318
|
+
if (process.platform !== 'win32' && Buffer.byteLength(where) > MAX_SOCKET_PATH) {
|
|
319
|
+
throw new Error(`A run's socket path can have at most ${MAX_SOCKET_PATH} bytes, and ${where} is longer. Set ${quoted('SM_DEV_RUNS_DIR')} to a shorter directory.`);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await new Promise((resolve, reject) => {
|
|
323
|
+
server.once('error', reject);
|
|
324
|
+
server.listen(where, () => {
|
|
325
|
+
server.off('error', reject);
|
|
326
|
+
resolve();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (err.code === 'EADDRINUSE')
|
|
333
|
+
return false;
|
|
334
|
+
throw new Error(`Could not listen on ${where}: ${err.message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Clears a killed run's socket file and listens there instead. Under a
|
|
338
|
+
// lock held until the listen, since between one run finding the file
|
|
339
|
+
// dead and deleting it, another could clear it and start listening,
|
|
340
|
+
// and then lose its live socket to the delete. A lock already held, or
|
|
341
|
+
// left by a run killed while holding it, only means trying the next
|
|
342
|
+
// number.
|
|
343
|
+
async function takeStale(server, id) {
|
|
344
|
+
const lock = path.join(runsDir(), `${id}.clearing`);
|
|
345
|
+
try {
|
|
346
|
+
await mkdir(lock);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
if (await listening(id) !== false)
|
|
353
|
+
return false;
|
|
354
|
+
await rm(socketPath(id), { force: true });
|
|
355
|
+
return await listenOn(server, socketPath(id));
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
await rmdir(lock).catch(() => undefined);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Written whole under a name of its own and renamed into place, so
|
|
362
|
+
// `list` never reads half a file.
|
|
363
|
+
async function writeRecord(record) {
|
|
364
|
+
const file = recordPath(record.id);
|
|
365
|
+
const temp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
366
|
+
await writeFile(temp, JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
367
|
+
await rename(temp, file);
|
|
368
|
+
}
|
|
369
|
+
// Deletes an ended run's files. Each is checked first, since a new run
|
|
370
|
+
// may have taken the id meanwhile: the record only if it still names
|
|
371
|
+
// the old pid, the socket only if nothing listens on it.
|
|
372
|
+
async function forget(id, pid) {
|
|
373
|
+
try {
|
|
374
|
+
const record = JSON.parse(await readFile(recordPath(id), 'utf8'));
|
|
375
|
+
if (record.pid === pid)
|
|
376
|
+
await rm(recordPath(id), { force: true });
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Gone already, or unreadable: nothing to delete.
|
|
380
|
+
}
|
|
381
|
+
if (process.platform !== 'win32' && await listening(id) === false) {
|
|
382
|
+
await rm(socketPath(id), { force: true }).catch(() => undefined);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Nothing listens on the run's socket: it has ended, even if its pid
|
|
386
|
+
// has since gone to another process.
|
|
387
|
+
function goneCode(err) {
|
|
388
|
+
const code = err.code;
|
|
389
|
+
return code === 'ENOENT' || code === 'ECONNREFUSED';
|
|
390
|
+
}
|
|
391
|
+
// Whether a run's socket takes a connection. A connection that sends no
|
|
392
|
+
// line runs nothing in the run.
|
|
393
|
+
function listening(id) {
|
|
394
|
+
return new Promise((resolve) => {
|
|
395
|
+
const socket = net.connect(socketPath(id));
|
|
396
|
+
socket.on('connect', () => {
|
|
397
|
+
socket.destroy();
|
|
398
|
+
resolve(true);
|
|
399
|
+
});
|
|
400
|
+
socket.on('error', (err) => resolve(goneCode(err) ? false : 'unknown'));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The runs active now, oldest first. A record whose process has gone
|
|
405
|
+
* (killed, or its machine rebooted), or whose socket nothing listens on
|
|
406
|
+
* (its pid reused since), is deleted on the way.
|
|
407
|
+
*/
|
|
408
|
+
export async function liveRuns() {
|
|
409
|
+
const dir = await checkRunsDir();
|
|
410
|
+
if (!dir)
|
|
411
|
+
return [];
|
|
412
|
+
let names;
|
|
413
|
+
try {
|
|
414
|
+
names = await readdir(dir);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
const runs = [];
|
|
420
|
+
// Only runs' records: the directory holds other state too.
|
|
421
|
+
await Promise.all(names.filter((n) => n.endsWith('.json') && runIdOf(n.slice(0, -'.json'.length)) === n.slice(0, -'.json'.length)).map(async (name) => {
|
|
422
|
+
const id = name.slice(0, -'.json'.length);
|
|
423
|
+
let record;
|
|
424
|
+
try {
|
|
425
|
+
record = JSON.parse(await readFile(path.join(dir, name), 'utf8'));
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Unreadable is as good as gone: nothing could reach it.
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (processAlive(record.pid) && await listening(id) !== false)
|
|
432
|
+
runs.push(record);
|
|
433
|
+
else
|
|
434
|
+
await forget(id, record.pid);
|
|
435
|
+
}));
|
|
436
|
+
return runs.sort((a, b) => a.started - b.started);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The start of the line a run sends after each command's output, with
|
|
440
|
+
* its exit code after it, for the client to take rather than print.
|
|
441
|
+
* Starts with a NUL, which no line of output does.
|
|
442
|
+
*/
|
|
443
|
+
export const COMMAND_DONE = '\u0000sm-dev:done';
|
|
444
|
+
function doneLine(code) {
|
|
445
|
+
return `${COMMAND_DONE} ${code}`;
|
|
446
|
+
}
|
|
447
|
+
/** A connection's failure when the run has ended, or was never there. */
|
|
448
|
+
export class RunGoneError extends Error {
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A client's connection to a run, which it sends its commands over, one
|
|
452
|
+
* at a time. `follow`: also receive everything the run prints from now
|
|
453
|
+
* on, between commands too, as a person watching the run would. Output
|
|
454
|
+
* goes to `out` as it arrives, in the order the run printed it.
|
|
455
|
+
*/
|
|
456
|
+
export class RunClient {
|
|
457
|
+
id;
|
|
458
|
+
socket;
|
|
459
|
+
out;
|
|
460
|
+
buffered = '';
|
|
461
|
+
pending = null;
|
|
462
|
+
closedBy = null;
|
|
463
|
+
/** Settles once the connection has closed, from either end. */
|
|
464
|
+
ended;
|
|
465
|
+
end;
|
|
466
|
+
constructor(id, socket, out) {
|
|
467
|
+
this.id = id;
|
|
468
|
+
this.socket = socket;
|
|
469
|
+
this.out = out;
|
|
470
|
+
this.ended = new Promise((resolve) => { this.end = resolve; });
|
|
471
|
+
socket.on('data', (chunk) => {
|
|
472
|
+
this.buffered += chunk;
|
|
473
|
+
const lines = this.buffered.split('\n');
|
|
474
|
+
this.buffered = lines.pop();
|
|
475
|
+
for (const l of lines)
|
|
476
|
+
this.take(l);
|
|
477
|
+
});
|
|
478
|
+
socket.on('close', () => {
|
|
479
|
+
if (this.buffered)
|
|
480
|
+
this.take(this.buffered);
|
|
481
|
+
this.buffered = '';
|
|
482
|
+
this.closedBy ??= 'run';
|
|
483
|
+
// A command the run closed on (stop) is done; any other was cut
|
|
484
|
+
// off by the run ending.
|
|
485
|
+
const pending = this.pending;
|
|
486
|
+
this.pending = null;
|
|
487
|
+
if (pending) {
|
|
488
|
+
const stopping = /^\s*stop\s*$/.test(pending.line);
|
|
489
|
+
// Only when the run went; not when this end let go (Ctrl-C).
|
|
490
|
+
if (!stopping && this.closedBy === 'run')
|
|
491
|
+
this.out(`[sm-dev] Run ${this.id} ended before the command was done`);
|
|
492
|
+
pending.resolve(stopping ? 0 : 1);
|
|
493
|
+
}
|
|
494
|
+
this.end();
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Connects to run `id`, passing each line it sends to `out`. `markup`:
|
|
499
|
+
* lines come with their markup (output-style.ts), for an `out` that
|
|
500
|
+
* paints them; otherwise as plain text.
|
|
501
|
+
*/
|
|
502
|
+
static open(id, out, { follow = false, logSources, markup = false } = {}) {
|
|
503
|
+
return new Promise((resolve, reject) => {
|
|
504
|
+
const socket = net.connect(socketPath(id));
|
|
505
|
+
socket.setEncoding('utf8');
|
|
506
|
+
const early = (err) => {
|
|
507
|
+
reject(goneCode(err) ? new RunGoneError(`Run ${id} has ended`) : new Error(`Run ${id} could not be reached: ${err.message}`));
|
|
508
|
+
};
|
|
509
|
+
socket.once('error', early);
|
|
510
|
+
socket.once('connect', () => {
|
|
511
|
+
socket.off('error', early);
|
|
512
|
+
// Lost later, it is said when a command was waiting on it.
|
|
513
|
+
socket.on('error', () => undefined);
|
|
514
|
+
// Which client this is, for the run to name where its commands came from.
|
|
515
|
+
// With `markup`, lines come with it, for writeOutputLine to paint.
|
|
516
|
+
socket.write(JSON.stringify({ hello: { follow, pid: process.pid, ...(markup ? { markup } : {}), ...(logSources ? { logSources: [...logSources] } : {}) } }) + '\n');
|
|
517
|
+
resolve(new RunClient(id, socket, out));
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/** Whether the connection is still open. */
|
|
522
|
+
get open() {
|
|
523
|
+
return this.closedBy === null;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Sends one command (one line, or a {{ block's several), and
|
|
527
|
+
* resolves to its exit code once it is done. `words`: the sender's own,
|
|
528
|
+
* as its shell gave them. `rewrite`: changes each line that arrives
|
|
529
|
+
* while it runs.
|
|
530
|
+
*/
|
|
531
|
+
send(line, { words, source = 'arg', json = false, rewrite } = {}) {
|
|
532
|
+
if (!this.open) {
|
|
533
|
+
this.out(`[sm-dev] Run ${this.id} has ended`);
|
|
534
|
+
return Promise.resolve(1);
|
|
535
|
+
}
|
|
536
|
+
return new Promise((resolve) => {
|
|
537
|
+
this.pending = { resolve, line, ...(rewrite ? { rewrite } : {}) };
|
|
538
|
+
this.socket.write(JSON.stringify({ line, cwd: process.cwd(), words, source, ...(json ? { json } : {}) }) + '\n');
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Closes the connection; the run goes on. `force`: at once, not
|
|
543
|
+
* waiting for the run to take the last of it, for a client done
|
|
544
|
+
* waiting. A run that doesn't answer is let go after a moment anyway.
|
|
545
|
+
*/
|
|
546
|
+
close({ force = false } = {}) {
|
|
547
|
+
if (this.closedBy === null)
|
|
548
|
+
this.closedBy = 'client';
|
|
549
|
+
if (force) {
|
|
550
|
+
this.socket.destroy();
|
|
551
|
+
return this.ended;
|
|
552
|
+
}
|
|
553
|
+
this.socket.end();
|
|
554
|
+
const timer = setTimeout(() => this.socket.destroy(), CLOSE_WAIT_MS);
|
|
555
|
+
return this.ended.finally(() => clearTimeout(timer));
|
|
556
|
+
}
|
|
557
|
+
take(line) {
|
|
558
|
+
if (!line.startsWith(COMMAND_DONE)) {
|
|
559
|
+
this.out(this.pending?.rewrite ? this.pending.rewrite(line) : line);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const pending = this.pending;
|
|
563
|
+
this.pending = null;
|
|
564
|
+
pending?.resolve(Number(line.slice(COMMAND_DONE.length).trim()) || 0);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Sends one command to a run over a connection of its own, passing each
|
|
569
|
+
* line of its output to `out` as it arrives, and resolves to its exit
|
|
570
|
+
* code. `signal` drops the connection, for a sender done waiting.
|
|
571
|
+
* `markup`: lines come with their markup, for an `out` that paints them.
|
|
572
|
+
*/
|
|
573
|
+
export async function sendLine(id, line, out, signal, words, markup = false) {
|
|
574
|
+
const client = await RunClient.open(id, out, { markup });
|
|
575
|
+
const abort = () => { void client.close({ force: true }); };
|
|
576
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
577
|
+
try {
|
|
578
|
+
const code = await Promise.race([
|
|
579
|
+
client.send(line, { words }),
|
|
580
|
+
new Promise((_, reject) => signal?.addEventListener('abort', () => reject(new Error(`Stopped waiting on run ${id}`)), { once: true })),
|
|
581
|
+
]);
|
|
582
|
+
await client.close();
|
|
583
|
+
return code;
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
signal?.removeEventListener('abort', abort);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// How long a run gets to answer `stop`, and once it has,
|
|
590
|
+
// how long to finish ending (a tab stopped on a dialog takes a while to
|
|
591
|
+
// let go). Past either, it is sent SIGTERM, and given the first again
|
|
592
|
+
// to go.
|
|
593
|
+
const STOP_WAIT_MS = 15_000;
|
|
594
|
+
const TEARDOWN_WAIT_MS = 60_000;
|
|
595
|
+
/**
|
|
596
|
+
* Ends a run as its `stop` would, passing on what it prints as it ends.
|
|
597
|
+
* One that doesn't answer, or doesn't finish ending in time, is sent
|
|
598
|
+
* SIGTERM: it ends one that never got the `stop` as Ctrl-C would, and
|
|
599
|
+
* kills one stuck ending. A run found to have ended already is never
|
|
600
|
+
* signalled, since its pid may be another process's by now. Resolves to
|
|
601
|
+
* whether the run has gone. `markup`: as for sendLine.
|
|
602
|
+
*/
|
|
603
|
+
export async function stopRun(record, out, markup = false) {
|
|
604
|
+
const abort = new AbortController();
|
|
605
|
+
// It answers `stop` with a line straight away, then ends.
|
|
606
|
+
let answered = false;
|
|
607
|
+
const sent = sendLine(record.id, 'stop', (line) => {
|
|
608
|
+
answered = true;
|
|
609
|
+
out(line);
|
|
610
|
+
}, abort.signal, undefined, markup).then(() => 'closed', (err) => (err instanceof RunGoneError ? 'gone' : 'failed'));
|
|
611
|
+
let outcome = await within(sent, STOP_WAIT_MS);
|
|
612
|
+
if (outcome === 'timeout' && answered)
|
|
613
|
+
outcome = await within(sent, TEARDOWN_WAIT_MS);
|
|
614
|
+
// Closed without that first line: whatever took it, it wasn't a run
|
|
615
|
+
// ending.
|
|
616
|
+
if (outcome === 'closed' && !answered)
|
|
617
|
+
outcome = 'failed';
|
|
618
|
+
// So a connection still open can't keep this process alive.
|
|
619
|
+
abort.abort();
|
|
620
|
+
if (outcome === 'gone') {
|
|
621
|
+
await forget(record.id, record.pid);
|
|
622
|
+
out(`[sm-dev] Run ${record.id} had already ended`);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
if (outcome === 'closed' && await exited(record.pid, STOP_WAIT_MS))
|
|
626
|
+
return true;
|
|
627
|
+
try {
|
|
628
|
+
process.kill(record.pid, 'SIGTERM');
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Gone meanwhile.
|
|
632
|
+
}
|
|
633
|
+
if (await exited(record.pid, STOP_WAIT_MS)) {
|
|
634
|
+
out(`[sm-dev] Run ${record.id} ${answered ? 'did not finish ending' : 'did not answer'}, so it was sent SIGTERM, and has ended`);
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
out(`[sm-dev] Run ${record.id} (pid ${record.pid}) has not ended, even after SIGTERM`);
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
// `work`'s result, or `timeout` if it takes longer than `ms`.
|
|
641
|
+
async function within(work, ms) {
|
|
642
|
+
let timer;
|
|
643
|
+
try {
|
|
644
|
+
return await Promise.race([work, new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), ms); })]);
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
clearTimeout(timer);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async function exited(pid, ms) {
|
|
651
|
+
const deadline = Date.now() + ms;
|
|
652
|
+
while (processAlive(pid)) {
|
|
653
|
+
if (Date.now() >= deadline)
|
|
654
|
+
return false;
|
|
655
|
+
await sleep(100);
|
|
656
|
+
}
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
function sleep(ms) {
|
|
660
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
661
|
+
}
|
|
662
|
+
// Past this many, `list` names the first few and counts the rest.
|
|
663
|
+
const LISTED_SCRIPTS = 3;
|
|
664
|
+
/**
|
|
665
|
+
* `sm-dev list`'s table: a heading row, then a row per run, in aligned
|
|
666
|
+
* columns. Scripts are listed by file name, the first few and a count
|
|
667
|
+
* of the rest, so the total shows without a column of its own. `-` is
|
|
668
|
+
* none, or no tab count for a run that keeps none. `markup`: lines
|
|
669
|
+
* marked up for color (output-style.ts), for printing; otherwise plain,
|
|
670
|
+
* as an error message needs them.
|
|
671
|
+
*/
|
|
672
|
+
export function runTable(records, now = Date.now(), { markup = false } = {}) {
|
|
673
|
+
const rows = [
|
|
674
|
+
['Run', 'PID', 'Uptime', 'Browser', 'Manager', 'Tabs', 'Scripts'].map((text) => roleCell(heading, text)),
|
|
675
|
+
...records.map((r) => {
|
|
676
|
+
const names = r.scripts.map((s) => path.basename(s));
|
|
677
|
+
const shown = names.slice(0, LISTED_SCRIPTS).map((n) => roleCell(url, n));
|
|
678
|
+
if (names.length > LISTED_SCRIPTS)
|
|
679
|
+
shown.push(textCell(`(${names.length - LISTED_SCRIPTS} more)`));
|
|
680
|
+
const browser = browserLabel(r);
|
|
681
|
+
const where = browserAddress(r);
|
|
682
|
+
return [
|
|
683
|
+
roleCell(idRole, r.id),
|
|
684
|
+
textCell(String(r.pid)),
|
|
685
|
+
textCell(uptime(now - r.started)),
|
|
686
|
+
{ plain: browser, styled: url(where) + escapeMarkup(browser.slice(where.length)) },
|
|
687
|
+
textCell(MANAGERS[r.manager ?? DEFAULT_MANAGER].label),
|
|
688
|
+
textCell(r.tabs === undefined ? '-' : String(r.tabs)),
|
|
689
|
+
shown.length > 0
|
|
690
|
+
? { plain: shown.map((c) => c.plain).join(', '), styled: shown.map((c) => c.styled).join(', ') }
|
|
691
|
+
: textCell('-'),
|
|
692
|
+
];
|
|
693
|
+
}),
|
|
694
|
+
];
|
|
695
|
+
return tableLines(rows, { markup });
|
|
696
|
+
}
|
|
697
|
+
// A run's browser address, without its scheme.
|
|
698
|
+
function browserAddress(r) {
|
|
699
|
+
return r.browser.replace(/^https?:\/\//, '');
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* A run's browser as `list` shows it: `127.0.0.1:9233`, with
|
|
703
|
+
* `(owned, headless)` after it when so. `status` words it as a
|
|
704
|
+
* sentence (see `browserSentence`).
|
|
705
|
+
*/
|
|
706
|
+
export function browserLabel(r) {
|
|
707
|
+
const marks = [r.owned && 'owned', r.headless && 'headless'].filter(Boolean);
|
|
708
|
+
const where = browserAddress(r);
|
|
709
|
+
return marks.length > 0 ? `${where} (${marks.join(', ')})` : where;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* A run's browser as `status` says it: `connected to owned headless
|
|
713
|
+
* browser 127.0.0.1:9233`. `how` changes the first words (`launched`).
|
|
714
|
+
*/
|
|
715
|
+
export function browserSentence(r, how = 'connected to') {
|
|
716
|
+
const marks = [r.owned && 'owned', r.headless && 'headless'].filter(Boolean);
|
|
717
|
+
return `${how} ${[...marks, 'browser'].join(' ')} ${browserAddress(r)}`;
|
|
718
|
+
}
|
|
719
|
+
function uptime(ms) {
|
|
720
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
721
|
+
if (s < 60)
|
|
722
|
+
return `${s}s`;
|
|
723
|
+
if (s < 3600)
|
|
724
|
+
return `${Math.floor(s / 60)}m`;
|
|
725
|
+
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
|
726
|
+
}
|