portless 0.11.1 → 0.13.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/README.md +54 -1
- package/dist/{chunk-RRH2JUIU.js → chunk-3WLVQXFE.js} +24 -1
- package/dist/cli.js +1024 -86
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
parseHostname,
|
|
14
14
|
shouldAutoSyncHosts,
|
|
15
15
|
syncHostsFile
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-3WLVQXFE.js";
|
|
17
17
|
|
|
18
18
|
// src/colors.ts
|
|
19
19
|
function supportsColor() {
|
|
@@ -39,9 +39,9 @@ var gray = dim;
|
|
|
39
39
|
var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
|
|
40
40
|
|
|
41
41
|
// src/cli.ts
|
|
42
|
-
import * as
|
|
43
|
-
import * as
|
|
44
|
-
import { spawn as spawn3, spawnSync as
|
|
42
|
+
import * as fs9 from "fs";
|
|
43
|
+
import * as path9 from "path";
|
|
44
|
+
import { spawn as spawn3, spawnSync as spawnSync4 } from "child_process";
|
|
45
45
|
import { StringDecoder } from "string_decoder";
|
|
46
46
|
|
|
47
47
|
// src/certs.ts
|
|
@@ -754,6 +754,251 @@ function untrustCAWindows(caCertPath) {
|
|
|
754
754
|
}
|
|
755
755
|
}
|
|
756
756
|
|
|
757
|
+
// src/tailscale.ts
|
|
758
|
+
import { spawnSync } from "child_process";
|
|
759
|
+
var TAILSCALE_BINARY = "tailscale";
|
|
760
|
+
var TAILSCALE_COMMAND_TIMEOUT_MS = 3e4;
|
|
761
|
+
var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
|
|
762
|
+
var FUNNEL_PORTS = [443, 8443, 1e4];
|
|
763
|
+
function defaultRunner(args) {
|
|
764
|
+
const result = spawnSync(TAILSCALE_BINARY, args, {
|
|
765
|
+
encoding: "utf-8",
|
|
766
|
+
killSignal: "SIGKILL",
|
|
767
|
+
timeout: TAILSCALE_COMMAND_TIMEOUT_MS
|
|
768
|
+
});
|
|
769
|
+
return {
|
|
770
|
+
status: result.status,
|
|
771
|
+
stdout: result.stdout ?? "",
|
|
772
|
+
stderr: result.stderr ?? "",
|
|
773
|
+
...result.error ? { error: result.error } : {}
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function trimDot(value) {
|
|
777
|
+
return value.endsWith(".") ? value.slice(0, -1) : value;
|
|
778
|
+
}
|
|
779
|
+
function normalizeSpace(value) {
|
|
780
|
+
return value.trim().replace(/\s+/g, " ");
|
|
781
|
+
}
|
|
782
|
+
function runOrThrow(args, action, runner) {
|
|
783
|
+
const result = runner(args);
|
|
784
|
+
if (result.error) {
|
|
785
|
+
const errno = result.error;
|
|
786
|
+
if (errno.code === "ENOENT") {
|
|
787
|
+
throw new Error(
|
|
788
|
+
"Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
throw new Error(`Failed to ${action}: ${result.error.message}`);
|
|
792
|
+
}
|
|
793
|
+
if (result.status !== 0) {
|
|
794
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
795
|
+
throw new Error(`Failed to ${action}: ${details || "unknown tailscale error"}`);
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
function parseStatusJson(raw) {
|
|
800
|
+
try {
|
|
801
|
+
return JSON.parse(raw);
|
|
802
|
+
} catch {
|
|
803
|
+
throw new Error("Failed to parse `tailscale status --json` output.");
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function statusToDnsName(status) {
|
|
807
|
+
const dnsName = status.Self?.DNSName;
|
|
808
|
+
if (typeof dnsName === "string" && dnsName.length > 0) {
|
|
809
|
+
return trimDot(dnsName);
|
|
810
|
+
}
|
|
811
|
+
const host = status.Self?.HostName;
|
|
812
|
+
const suffix = status.CurrentTailnet?.MagicDNSSuffix;
|
|
813
|
+
if (typeof host === "string" && host.length > 0 && typeof suffix === "string" && suffix.length > 0) {
|
|
814
|
+
return `${host}.${trimDot(suffix)}`;
|
|
815
|
+
}
|
|
816
|
+
throw new Error(
|
|
817
|
+
"Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
function isFunnelCapability(value) {
|
|
821
|
+
const normalized = value.toLowerCase();
|
|
822
|
+
return normalized === "funnel" || normalized.endsWith("/funnel");
|
|
823
|
+
}
|
|
824
|
+
function isHttpsCapability(value) {
|
|
825
|
+
const normalized = value.toLowerCase();
|
|
826
|
+
return normalized === "https" || normalized.endsWith("/https");
|
|
827
|
+
}
|
|
828
|
+
function hasCapability(status, predicate) {
|
|
829
|
+
const capabilities = status.Self?.Capabilities;
|
|
830
|
+
if (Array.isArray(capabilities) && capabilities.some(predicate)) {
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
const capMap = status.Self?.CapMap;
|
|
834
|
+
return Boolean(capMap && Object.keys(capMap).some(predicate));
|
|
835
|
+
}
|
|
836
|
+
function hasHttpsCapability(status) {
|
|
837
|
+
return hasCapability(status, isHttpsCapability);
|
|
838
|
+
}
|
|
839
|
+
function hasFunnelCapability(status) {
|
|
840
|
+
return hasCapability(status, isFunnelCapability);
|
|
841
|
+
}
|
|
842
|
+
function throwHttpsNotEnabled() {
|
|
843
|
+
throw new Error(
|
|
844
|
+
"Tailscale HTTPS is not enabled on your tailnet. Enable HTTPS certificates in Tailscale DNS settings, then run portless again."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
function throwFunnelNotEnabled(status) {
|
|
848
|
+
const nodeId = status.Self?.ID;
|
|
849
|
+
const enableUrl = typeof nodeId === "string" && nodeId.length > 0 ? ` Visit https://login.tailscale.com/f/funnel?node=${nodeId} to enable it.` : "";
|
|
850
|
+
throw new Error(
|
|
851
|
+
"Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + enableUrl
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
function ensureTailscaleReady(options = {}) {
|
|
855
|
+
const runner = options.runner ?? defaultRunner;
|
|
856
|
+
runOrThrow(["version"], "check tailscale version", runner);
|
|
857
|
+
const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
|
|
858
|
+
const status = parseStatusJson(statusResult.stdout);
|
|
859
|
+
const dnsName = statusToDnsName(status);
|
|
860
|
+
if (options.requireHttps && !hasHttpsCapability(status)) {
|
|
861
|
+
throwHttpsNotEnabled();
|
|
862
|
+
}
|
|
863
|
+
if (options.requireFunnel && !hasFunnelCapability(status)) {
|
|
864
|
+
throwFunnelNotEnabled(status);
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
dnsName,
|
|
868
|
+
baseUrl: `https://${dnsName}`
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function getUsedServePorts(runner = defaultRunner) {
|
|
872
|
+
const result = runner(["serve", "status", "--json"]);
|
|
873
|
+
if (result.error || result.status !== 0) {
|
|
874
|
+
return /* @__PURE__ */ new Set();
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const config = JSON.parse(result.stdout);
|
|
878
|
+
const ports = /* @__PURE__ */ new Set();
|
|
879
|
+
if (config.Web) {
|
|
880
|
+
for (const hostPort of Object.keys(config.Web)) {
|
|
881
|
+
const match = hostPort.match(/:(\d+)$/);
|
|
882
|
+
if (match) {
|
|
883
|
+
ports.add(parseInt(match[1], 10));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (config.TCP) {
|
|
888
|
+
for (const portStr of Object.keys(config.TCP)) {
|
|
889
|
+
const p = parseInt(portStr, 10);
|
|
890
|
+
if (!isNaN(p)) ports.add(p);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return ports;
|
|
894
|
+
} catch {
|
|
895
|
+
return /* @__PURE__ */ new Set();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function findAvailableServePort(usedPorts, mode = "serve") {
|
|
899
|
+
const pool = mode === "funnel" ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS;
|
|
900
|
+
for (const port2 of pool) {
|
|
901
|
+
if (!usedPorts.has(port2)) return port2;
|
|
902
|
+
}
|
|
903
|
+
if (mode === "funnel") {
|
|
904
|
+
throw new Error(
|
|
905
|
+
"All Tailscale Funnel ports are in use (443, 8443, 10000). Stop an existing funnel to free a port."
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
let port = PREFERRED_SERVE_PORTS[PREFERRED_SERVE_PORTS.length - 1] + 1;
|
|
909
|
+
while (usedPorts.has(port)) port++;
|
|
910
|
+
return port;
|
|
911
|
+
}
|
|
912
|
+
function isConflictError(stderr, stdout) {
|
|
913
|
+
const text = `${stderr}
|
|
914
|
+
${stdout}`.toLowerCase();
|
|
915
|
+
return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
|
|
916
|
+
}
|
|
917
|
+
function isFunnelNotEnabledError(stderr, stdout) {
|
|
918
|
+
const text = `${stderr}
|
|
919
|
+
${stdout}`.toLowerCase();
|
|
920
|
+
return text.includes("funnel is not enabled on your tailnet");
|
|
921
|
+
}
|
|
922
|
+
function formatFunnelNotEnabledError(stderr, stdout) {
|
|
923
|
+
const details = normalizeSpace(`${stderr}
|
|
924
|
+
${stdout}`);
|
|
925
|
+
return "Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + (details ? ` Tailscale said: ${details}` : "");
|
|
926
|
+
}
|
|
927
|
+
var CONFLICT_MESSAGES = {
|
|
928
|
+
serve: "Stop the existing serve or let portless auto-assign a different port.",
|
|
929
|
+
funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
|
|
930
|
+
};
|
|
931
|
+
function register(mode, localPort, httpsPort, runner) {
|
|
932
|
+
const target = `http://127.0.0.1:${localPort}`;
|
|
933
|
+
const result = runner([mode, "--bg", "--yes", `--https=${httpsPort}`, target]);
|
|
934
|
+
if (result.error) {
|
|
935
|
+
const errno = result.error;
|
|
936
|
+
if (errno.code === "ENOENT") {
|
|
937
|
+
throw new Error(
|
|
938
|
+
"Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
|
|
942
|
+
throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
|
|
943
|
+
}
|
|
944
|
+
if (mode === "funnel" && errno.code === "ETIMEDOUT") {
|
|
945
|
+
throw new Error(
|
|
946
|
+
"Tailscale Funnel registration timed out. Make sure Funnel is enabled on your tailnet, then run portless again."
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
|
|
950
|
+
}
|
|
951
|
+
if (result.status !== 0) {
|
|
952
|
+
if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
|
|
953
|
+
throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
|
|
954
|
+
}
|
|
955
|
+
if (isConflictError(result.stderr, result.stdout)) {
|
|
956
|
+
throw new Error(
|
|
957
|
+
`Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
961
|
+
throw new Error(
|
|
962
|
+
`Failed to register tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function unregister(mode, httpsPort, options) {
|
|
967
|
+
const runner = options?.runner ?? defaultRunner;
|
|
968
|
+
const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
|
|
969
|
+
if (result.error) {
|
|
970
|
+
const errno = result.error;
|
|
971
|
+
if (errno.code === "ENOENT") return;
|
|
972
|
+
throw new Error(`Failed to remove tailscale ${mode}: ${result.error.message}`);
|
|
973
|
+
}
|
|
974
|
+
if (result.status !== 0) {
|
|
975
|
+
const text = `${result.stderr}
|
|
976
|
+
${result.stdout}`.toLowerCase();
|
|
977
|
+
const looksLikeMissing = text.includes("not found") || text.includes("no serve config") || text.includes("nothing to remove") || text.includes("does not exist");
|
|
978
|
+
if (options?.ignoreMissing && looksLikeMissing) return;
|
|
979
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
980
|
+
throw new Error(
|
|
981
|
+
`Failed to remove tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function registerServe(localPort, httpsPort, options) {
|
|
986
|
+
register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
|
|
987
|
+
}
|
|
988
|
+
function registerFunnel(localPort, httpsPort, options) {
|
|
989
|
+
register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
|
|
990
|
+
}
|
|
991
|
+
function unregisterTailscale(route) {
|
|
992
|
+
if (!route.tailscaleHttpsPort) return;
|
|
993
|
+
const mode = route.tailscaleFunnel ? "funnel" : "serve";
|
|
994
|
+
unregister(mode, route.tailscaleHttpsPort, { ignoreMissing: true });
|
|
995
|
+
}
|
|
996
|
+
function formatTailscaleUrl(baseUrl, httpsPort) {
|
|
997
|
+
const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
998
|
+
if (httpsPort === 443) return trimmed;
|
|
999
|
+
return `${trimmed}:${httpsPort}`;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
757
1002
|
// src/auto.ts
|
|
758
1003
|
import { createHash as createHash2 } from "crypto";
|
|
759
1004
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
@@ -1622,7 +1867,7 @@ function removePortlessStateFiles(dir) {
|
|
|
1622
1867
|
}
|
|
1623
1868
|
|
|
1624
1869
|
// src/mdns.ts
|
|
1625
|
-
import { spawn as spawn2, spawnSync } from "child_process";
|
|
1870
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
|
|
1626
1871
|
|
|
1627
1872
|
// src/lan-ip.ts
|
|
1628
1873
|
import { createSocket } from "dgram";
|
|
@@ -1738,7 +1983,7 @@ function getMdnsPublisher() {
|
|
|
1738
1983
|
return null;
|
|
1739
1984
|
}
|
|
1740
1985
|
function hasCommand(command, probeArgs) {
|
|
1741
|
-
const result =
|
|
1986
|
+
const result = spawnSync2(command, probeArgs, {
|
|
1742
1987
|
stdio: "ignore",
|
|
1743
1988
|
timeout: 1e3,
|
|
1744
1989
|
windowsHide: true
|
|
@@ -2346,6 +2591,522 @@ function hasTurboConfig(wsRoot) {
|
|
|
2346
2591
|
}
|
|
2347
2592
|
}
|
|
2348
2593
|
|
|
2594
|
+
// src/service.ts
|
|
2595
|
+
import * as fs8 from "fs";
|
|
2596
|
+
import * as os2 from "os";
|
|
2597
|
+
import * as path8 from "path";
|
|
2598
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2599
|
+
var SERVICE_PORT = getProtocolPort(true);
|
|
2600
|
+
var SERVICE_LABEL = "sh.portless.proxy";
|
|
2601
|
+
var SYSTEMD_SERVICE = "portless.service";
|
|
2602
|
+
var WINDOWS_TASK_NAME = "Portless Proxy";
|
|
2603
|
+
var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
|
|
2604
|
+
function defaultRunner2(command, args, options) {
|
|
2605
|
+
return spawnSync3(command, args, {
|
|
2606
|
+
encoding: "utf-8",
|
|
2607
|
+
stdio: options?.stdio ?? "pipe"
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
function isSupportedPlatform(platform) {
|
|
2611
|
+
return platform === "darwin" || platform === "linux" || platform === "win32";
|
|
2612
|
+
}
|
|
2613
|
+
function xmlEscape(value) {
|
|
2614
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2615
|
+
}
|
|
2616
|
+
function systemdEscape(value) {
|
|
2617
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2618
|
+
}
|
|
2619
|
+
function windowsQuote(value) {
|
|
2620
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
2621
|
+
}
|
|
2622
|
+
function readPasswdHome(username) {
|
|
2623
|
+
try {
|
|
2624
|
+
const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
|
|
2625
|
+
for (const line of passwd.split("\n")) {
|
|
2626
|
+
const fields = line.split(":");
|
|
2627
|
+
if (fields[0] === username && fields[5]) {
|
|
2628
|
+
return fields[5];
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
} catch {
|
|
2632
|
+
}
|
|
2633
|
+
return null;
|
|
2634
|
+
}
|
|
2635
|
+
function resolveUserContext(platform) {
|
|
2636
|
+
if (platform === "win32") {
|
|
2637
|
+
const home = process.env.USERPROFILE || os2.homedir();
|
|
2638
|
+
return { home, username: process.env.USERNAME };
|
|
2639
|
+
}
|
|
2640
|
+
const sudoUser = process.env.SUDO_USER;
|
|
2641
|
+
const sudoUid = process.env.SUDO_UID;
|
|
2642
|
+
const sudoGid = process.env.SUDO_GID;
|
|
2643
|
+
if (sudoUser && sudoUser !== "root") {
|
|
2644
|
+
const home = process.env.HOME && process.env.HOME !== "/var/root" && process.env.HOME !== "/root" ? process.env.HOME : readPasswdHome(sudoUser) || (platform === "darwin" ? path8.posix.join("/Users", sudoUser) : path8.posix.join("/home", sudoUser));
|
|
2645
|
+
return { home, uid: sudoUid, gid: sudoGid, username: sudoUser };
|
|
2646
|
+
}
|
|
2647
|
+
const userInfo2 = os2.userInfo();
|
|
2648
|
+
return {
|
|
2649
|
+
home: os2.homedir(),
|
|
2650
|
+
uid: process.getuid?.()?.toString(),
|
|
2651
|
+
gid: process.getgid?.()?.toString(),
|
|
2652
|
+
username: userInfo2.username
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
function buildProxyCommand(entryScript) {
|
|
2656
|
+
const config = buildProxyStartConfig({
|
|
2657
|
+
useHttps: true,
|
|
2658
|
+
lanMode: false,
|
|
2659
|
+
tld: DEFAULT_TLD,
|
|
2660
|
+
foreground: true,
|
|
2661
|
+
includePort: true,
|
|
2662
|
+
proxyPort: SERVICE_PORT,
|
|
2663
|
+
skipTrust: true
|
|
2664
|
+
});
|
|
2665
|
+
return [entryScript, "proxy", "start", ...config.args];
|
|
2666
|
+
}
|
|
2667
|
+
function buildServiceEnv(ctx) {
|
|
2668
|
+
const env = {
|
|
2669
|
+
PORTLESS_STATE_DIR: ctx.stateDir
|
|
2670
|
+
};
|
|
2671
|
+
if (ctx.platform === "win32") {
|
|
2672
|
+
env.USERPROFILE = ctx.user.home;
|
|
2673
|
+
env.PATH = ctx.pathEnv;
|
|
2674
|
+
} else {
|
|
2675
|
+
env.HOME = ctx.user.home;
|
|
2676
|
+
if (ctx.user.uid) env.SUDO_UID = ctx.user.uid;
|
|
2677
|
+
if (ctx.user.gid) env.SUDO_GID = ctx.user.gid;
|
|
2678
|
+
}
|
|
2679
|
+
return env;
|
|
2680
|
+
}
|
|
2681
|
+
function defaultStateDir(platform, userHome) {
|
|
2682
|
+
return platform === "win32" ? path8.win32.join(userHome, ".portless") : path8.posix.join(userHome, ".portless");
|
|
2683
|
+
}
|
|
2684
|
+
function buildLaunchdPlist(ctx, programArguments) {
|
|
2685
|
+
const env = buildServiceEnv(ctx);
|
|
2686
|
+
const envEntries = Object.entries(env).map(
|
|
2687
|
+
([key, value]) => ` <key>${xmlEscape(key)}</key>
|
|
2688
|
+
<string>${xmlEscape(value)}</string>`
|
|
2689
|
+
).join("\n");
|
|
2690
|
+
const args = programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
|
|
2691
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2692
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2693
|
+
<plist version="1.0">
|
|
2694
|
+
<dict>
|
|
2695
|
+
<key>Label</key>
|
|
2696
|
+
<string>${SERVICE_LABEL}</string>
|
|
2697
|
+
<key>ProgramArguments</key>
|
|
2698
|
+
<array>
|
|
2699
|
+
${args}
|
|
2700
|
+
</array>
|
|
2701
|
+
<key>EnvironmentVariables</key>
|
|
2702
|
+
<dict>
|
|
2703
|
+
${envEntries}
|
|
2704
|
+
</dict>
|
|
2705
|
+
<key>RunAtLoad</key>
|
|
2706
|
+
<true/>
|
|
2707
|
+
<key>KeepAlive</key>
|
|
2708
|
+
<true/>
|
|
2709
|
+
<key>StandardOutPath</key>
|
|
2710
|
+
<string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
|
|
2711
|
+
<key>StandardErrorPath</key>
|
|
2712
|
+
<string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
|
|
2713
|
+
</dict>
|
|
2714
|
+
</plist>
|
|
2715
|
+
`;
|
|
2716
|
+
}
|
|
2717
|
+
function buildSystemdUnit(ctx, execStart) {
|
|
2718
|
+
const env = buildServiceEnv(ctx);
|
|
2719
|
+
const envLines = Object.entries(env).map(([key, value]) => `Environment=${key}=${systemdEscape(value)}`).join("\n");
|
|
2720
|
+
return `[Unit]
|
|
2721
|
+
Description=Portless HTTPS proxy
|
|
2722
|
+
After=network-online.target
|
|
2723
|
+
Wants=network-online.target
|
|
2724
|
+
|
|
2725
|
+
[Service]
|
|
2726
|
+
Type=simple
|
|
2727
|
+
${envLines}
|
|
2728
|
+
Environment=PATH=${systemdEscape(ctx.pathEnv)}
|
|
2729
|
+
ExecStart=${execStart.map(systemdEscape).join(" ")}
|
|
2730
|
+
Restart=on-failure
|
|
2731
|
+
RestartSec=2
|
|
2732
|
+
KillSignal=SIGTERM
|
|
2733
|
+
TimeoutStopSec=5
|
|
2734
|
+
|
|
2735
|
+
[Install]
|
|
2736
|
+
WantedBy=multi-user.target
|
|
2737
|
+
`;
|
|
2738
|
+
}
|
|
2739
|
+
function buildWindowsScript(ctx, command) {
|
|
2740
|
+
const env = buildServiceEnv(ctx);
|
|
2741
|
+
const setEnv = Object.entries(env).map(([key, value]) => `set "${key}=${value.replace(/"/g, "").replace(/%/g, "%%")}"`).join("\r\n");
|
|
2742
|
+
const proxyCommand = [windowsQuote(ctx.nodePath), ...command.map(windowsQuote)].join(" ");
|
|
2743
|
+
return `@echo off\r
|
|
2744
|
+
${setEnv}\r
|
|
2745
|
+
${proxyCommand}\r
|
|
2746
|
+
`;
|
|
2747
|
+
}
|
|
2748
|
+
function buildServiceSpec(options) {
|
|
2749
|
+
const ctx = {
|
|
2750
|
+
platform: options.platform,
|
|
2751
|
+
nodePath: options.nodePath,
|
|
2752
|
+
entryScript: options.entryScript,
|
|
2753
|
+
stateDir: options.stateDir || defaultStateDir(options.platform, options.userHome),
|
|
2754
|
+
user: {
|
|
2755
|
+
home: options.userHome,
|
|
2756
|
+
uid: options.uid,
|
|
2757
|
+
gid: options.gid,
|
|
2758
|
+
username: options.username
|
|
2759
|
+
},
|
|
2760
|
+
pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
2761
|
+
programData: options.programData || "C:\\ProgramData"
|
|
2762
|
+
};
|
|
2763
|
+
const proxyCommand = buildProxyCommand(ctx.entryScript);
|
|
2764
|
+
if (ctx.platform === "darwin") {
|
|
2765
|
+
const programArguments = [ctx.nodePath, ...proxyCommand];
|
|
2766
|
+
return {
|
|
2767
|
+
platform: "darwin",
|
|
2768
|
+
label: SERVICE_LABEL,
|
|
2769
|
+
plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
|
|
2770
|
+
plist: buildLaunchdPlist(ctx, programArguments),
|
|
2771
|
+
stateDir: ctx.stateDir,
|
|
2772
|
+
programArguments
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
if (ctx.platform === "linux") {
|
|
2776
|
+
const execStart = [ctx.nodePath, ...proxyCommand];
|
|
2777
|
+
return {
|
|
2778
|
+
platform: "linux",
|
|
2779
|
+
serviceName: SYSTEMD_SERVICE,
|
|
2780
|
+
unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
|
|
2781
|
+
unit: buildSystemdUnit(ctx, execStart),
|
|
2782
|
+
stateDir: ctx.stateDir,
|
|
2783
|
+
execStart
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
const scriptDir = path8.win32.join(ctx.programData, "portless", "service");
|
|
2787
|
+
const scriptPath = path8.win32.join(scriptDir, "portless-service.cmd");
|
|
2788
|
+
const script = buildWindowsScript(ctx, proxyCommand);
|
|
2789
|
+
const taskRun = windowsQuote(scriptPath);
|
|
2790
|
+
return {
|
|
2791
|
+
platform: "win32",
|
|
2792
|
+
taskName: WINDOWS_TASK_NAME,
|
|
2793
|
+
stateDir: ctx.stateDir,
|
|
2794
|
+
scriptDir,
|
|
2795
|
+
scriptPath,
|
|
2796
|
+
script,
|
|
2797
|
+
taskRun,
|
|
2798
|
+
createArgs: [
|
|
2799
|
+
"/Create",
|
|
2800
|
+
"/TN",
|
|
2801
|
+
WINDOWS_TASK_NAME,
|
|
2802
|
+
"/SC",
|
|
2803
|
+
"ONSTART",
|
|
2804
|
+
"/RU",
|
|
2805
|
+
"SYSTEM",
|
|
2806
|
+
"/RL",
|
|
2807
|
+
"HIGHEST",
|
|
2808
|
+
"/TR",
|
|
2809
|
+
taskRun,
|
|
2810
|
+
"/F"
|
|
2811
|
+
],
|
|
2812
|
+
runArgs: ["/Run", "/TN", WINDOWS_TASK_NAME],
|
|
2813
|
+
deleteArgs: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"],
|
|
2814
|
+
queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
function currentServiceSpec(entryScript) {
|
|
2818
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
2819
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
2820
|
+
}
|
|
2821
|
+
const user = resolveUserContext(process.platform);
|
|
2822
|
+
return buildServiceSpec({
|
|
2823
|
+
platform: process.platform,
|
|
2824
|
+
nodePath: process.execPath,
|
|
2825
|
+
entryScript,
|
|
2826
|
+
userHome: user.home,
|
|
2827
|
+
uid: user.uid,
|
|
2828
|
+
gid: user.gid,
|
|
2829
|
+
username: user.username,
|
|
2830
|
+
stateDir: process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home),
|
|
2831
|
+
pathEnv: process.env.PATH,
|
|
2832
|
+
programData: process.env.ProgramData
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
|
|
2836
|
+
const envArgs = [];
|
|
2837
|
+
for (const key of Object.keys(env)) {
|
|
2838
|
+
if (key.startsWith("PORTLESS_") && env[key] && !omit.has(key)) {
|
|
2839
|
+
envArgs.push(`${key}=${env[key]}`);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return envArgs;
|
|
2843
|
+
}
|
|
2844
|
+
function buildElevatedEnvArgs(options) {
|
|
2845
|
+
const extraEnv = options.extraEnv ?? {};
|
|
2846
|
+
const overrideKeys = /* @__PURE__ */ new Set(["PORTLESS_STATE_DIR", ...Object.keys(extraEnv)]);
|
|
2847
|
+
return [
|
|
2848
|
+
"env",
|
|
2849
|
+
...collectPortlessEnvArgs(options.env, overrideKeys),
|
|
2850
|
+
...Object.entries(extraEnv).map(([key, value]) => `${key}=${value}`),
|
|
2851
|
+
`HOME=${options.home}`,
|
|
2852
|
+
`PORTLESS_STATE_DIR=${options.stateDir}`
|
|
2853
|
+
];
|
|
2854
|
+
}
|
|
2855
|
+
function buildServiceUninstallSudoArgs(entryScript, options = {}) {
|
|
2856
|
+
const env = options.env ?? process.env;
|
|
2857
|
+
const home = options.home ?? os2.homedir();
|
|
2858
|
+
const stateDir = options.stateDir ?? env.PORTLESS_STATE_DIR ?? path8.join(home, ".portless");
|
|
2859
|
+
return [
|
|
2860
|
+
...buildElevatedEnvArgs({ home, stateDir, env }),
|
|
2861
|
+
options.nodePath ?? process.execPath,
|
|
2862
|
+
entryScript,
|
|
2863
|
+
"service",
|
|
2864
|
+
"uninstall"
|
|
2865
|
+
];
|
|
2866
|
+
}
|
|
2867
|
+
function requireUnixElevation(args, runner) {
|
|
2868
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
2869
|
+
if ((process.getuid?.() ?? -1) === 0) return;
|
|
2870
|
+
if (process.env[INTERNAL_ELEVATED_ENV] === "1") return;
|
|
2871
|
+
const home = os2.homedir();
|
|
2872
|
+
const stateDir = process.env.PORTLESS_STATE_DIR || path8.join(home, ".portless");
|
|
2873
|
+
const result = runner(
|
|
2874
|
+
"sudo",
|
|
2875
|
+
[
|
|
2876
|
+
...buildElevatedEnvArgs({
|
|
2877
|
+
home,
|
|
2878
|
+
stateDir,
|
|
2879
|
+
extraEnv: { [INTERNAL_ELEVATED_ENV]: "1" }
|
|
2880
|
+
}),
|
|
2881
|
+
process.execPath,
|
|
2882
|
+
args[0],
|
|
2883
|
+
...args.slice(1)
|
|
2884
|
+
],
|
|
2885
|
+
{ stdio: "inherit" }
|
|
2886
|
+
);
|
|
2887
|
+
process.exit(result.status ?? 1);
|
|
2888
|
+
}
|
|
2889
|
+
function runRequired(runner, command, args) {
|
|
2890
|
+
const result = runner(command, args);
|
|
2891
|
+
if (result.status !== 0) {
|
|
2892
|
+
const detail = result.stderr || result.stdout || result.error?.message || `${command} failed`;
|
|
2893
|
+
throw new Error(detail.trim());
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
function runOptional(runner, command, args) {
|
|
2897
|
+
runner(command, args);
|
|
2898
|
+
}
|
|
2899
|
+
function isPermissionError(err) {
|
|
2900
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
2901
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2902
|
+
return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
|
|
2903
|
+
}
|
|
2904
|
+
function stopExistingProxy(entryScript, runner) {
|
|
2905
|
+
runRequired(runner, process.execPath, [
|
|
2906
|
+
entryScript,
|
|
2907
|
+
"proxy",
|
|
2908
|
+
"stop",
|
|
2909
|
+
"--port",
|
|
2910
|
+
SERVICE_PORT.toString()
|
|
2911
|
+
]);
|
|
2912
|
+
}
|
|
2913
|
+
function prepareTrust(stateDir) {
|
|
2914
|
+
fs8.mkdirSync(stateDir, { recursive: true });
|
|
2915
|
+
fixOwnership(stateDir);
|
|
2916
|
+
try {
|
|
2917
|
+
ensureCerts(stateDir);
|
|
2918
|
+
} catch (err) {
|
|
2919
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2920
|
+
throw new Error(
|
|
2921
|
+
`Failed to generate certificates in ${stateDir}. Ensure OpenSSL is installed.
|
|
2922
|
+
${detail}`
|
|
2923
|
+
);
|
|
2924
|
+
}
|
|
2925
|
+
if (isCATrusted(stateDir)) return;
|
|
2926
|
+
console.log(colors_default.gray("Trusting portless CA for service startup..."));
|
|
2927
|
+
const trustResult = trustCA(stateDir);
|
|
2928
|
+
if (trustResult.trusted) {
|
|
2929
|
+
console.log(colors_default.green("CA added to the system trust store."));
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
console.warn(colors_default.yellow("Could not add the CA to the system trust store."));
|
|
2933
|
+
if (trustResult.error) {
|
|
2934
|
+
console.warn(colors_default.gray(trustResult.error));
|
|
2935
|
+
}
|
|
2936
|
+
console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
|
|
2937
|
+
}
|
|
2938
|
+
async function installService(entryScript, runner) {
|
|
2939
|
+
requireUnixElevation([entryScript, "service", "install"], runner);
|
|
2940
|
+
const spec = currentServiceSpec(entryScript);
|
|
2941
|
+
prepareTrust(spec.stateDir);
|
|
2942
|
+
if (spec.platform === "darwin") {
|
|
2943
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
2944
|
+
stopExistingProxy(entryScript, runner);
|
|
2945
|
+
fs8.writeFileSync(spec.plistPath, spec.plist);
|
|
2946
|
+
fs8.chmodSync(spec.plistPath, 420);
|
|
2947
|
+
runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
|
|
2948
|
+
runRequired(runner, "launchctl", ["bootstrap", "system", spec.plistPath]);
|
|
2949
|
+
runRequired(runner, "launchctl", ["enable", `system/${spec.label}`]);
|
|
2950
|
+
runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
|
|
2951
|
+
} else if (spec.platform === "linux") {
|
|
2952
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
2953
|
+
stopExistingProxy(entryScript, runner);
|
|
2954
|
+
fs8.writeFileSync(spec.unitPath, spec.unit);
|
|
2955
|
+
fs8.chmodSync(spec.unitPath, 420);
|
|
2956
|
+
runRequired(runner, "systemctl", ["daemon-reload"]);
|
|
2957
|
+
runRequired(runner, "systemctl", ["enable", spec.serviceName]);
|
|
2958
|
+
runRequired(runner, "systemctl", ["restart", spec.serviceName]);
|
|
2959
|
+
} else {
|
|
2960
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
2961
|
+
stopExistingProxy(entryScript, runner);
|
|
2962
|
+
fs8.mkdirSync(spec.scriptDir, { recursive: true });
|
|
2963
|
+
fs8.writeFileSync(spec.scriptPath, spec.script);
|
|
2964
|
+
runRequired(runner, "schtasks", spec.createArgs);
|
|
2965
|
+
runOptional(runner, "schtasks", spec.runArgs);
|
|
2966
|
+
}
|
|
2967
|
+
console.log(colors_default.green("Portless service installed."));
|
|
2968
|
+
console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
|
|
2969
|
+
}
|
|
2970
|
+
async function uninstallService(entryScript, runner) {
|
|
2971
|
+
requireUnixElevation([entryScript, "service", "uninstall"], runner);
|
|
2972
|
+
const spec = currentServiceSpec(entryScript);
|
|
2973
|
+
if (spec.platform === "darwin") {
|
|
2974
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
2975
|
+
fs8.rmSync(spec.plistPath, { force: true });
|
|
2976
|
+
} else if (spec.platform === "linux") {
|
|
2977
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
2978
|
+
fs8.rmSync(spec.unitPath, { force: true });
|
|
2979
|
+
runOptional(runner, "systemctl", ["daemon-reload"]);
|
|
2980
|
+
} else {
|
|
2981
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
2982
|
+
runOptional(runner, "schtasks", spec.deleteArgs);
|
|
2983
|
+
fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
|
|
2984
|
+
}
|
|
2985
|
+
console.log(colors_default.green("Portless service uninstalled."));
|
|
2986
|
+
}
|
|
2987
|
+
function tryUninstallService(entryScript, runner = defaultRunner2) {
|
|
2988
|
+
let installed = false;
|
|
2989
|
+
try {
|
|
2990
|
+
const spec = currentServiceSpec(entryScript);
|
|
2991
|
+
if (spec.platform === "darwin") {
|
|
2992
|
+
installed = fs8.existsSync(spec.plistPath);
|
|
2993
|
+
if (!installed) return { removed: false, installed: false };
|
|
2994
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
2995
|
+
fs8.rmSync(spec.plistPath, { force: true });
|
|
2996
|
+
} else if (spec.platform === "linux") {
|
|
2997
|
+
installed = fs8.existsSync(spec.unitPath);
|
|
2998
|
+
if (!installed) return { removed: false, installed: false };
|
|
2999
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
3000
|
+
fs8.rmSync(spec.unitPath, { force: true });
|
|
3001
|
+
runOptional(runner, "systemctl", ["daemon-reload"]);
|
|
3002
|
+
} else {
|
|
3003
|
+
const query = runner("schtasks", ["/Query", "/TN", spec.taskName, "/FO", "LIST"]);
|
|
3004
|
+
installed = query.status === 0;
|
|
3005
|
+
if (!installed) return { removed: false, installed: false };
|
|
3006
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
3007
|
+
runRequired(runner, "schtasks", spec.deleteArgs);
|
|
3008
|
+
fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
|
|
3009
|
+
}
|
|
3010
|
+
return { removed: true, installed: true };
|
|
3011
|
+
} catch (err) {
|
|
3012
|
+
return {
|
|
3013
|
+
removed: false,
|
|
3014
|
+
installed,
|
|
3015
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3016
|
+
needsElevation: installed && isPermissionError(err)
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
async function getServiceStatus(entryScript, runner) {
|
|
3021
|
+
const spec = currentServiceSpec(entryScript);
|
|
3022
|
+
const proxyRunning = await isProxyRunning(SERVICE_PORT);
|
|
3023
|
+
if (spec.platform === "darwin") {
|
|
3024
|
+
const installed2 = fs8.existsSync(spec.plistPath);
|
|
3025
|
+
const result = runner("launchctl", ["print", `system/${spec.label}`]);
|
|
3026
|
+
const output2 = `${result.stdout || ""}${result.stderr || ""}`;
|
|
3027
|
+
const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
|
|
3028
|
+
return { installed: installed2, managerState, proxyRunning, details: spec.plistPath };
|
|
3029
|
+
}
|
|
3030
|
+
if (spec.platform === "linux") {
|
|
3031
|
+
const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
|
|
3032
|
+
const active = runner("systemctl", ["is-active", spec.serviceName]);
|
|
3033
|
+
const installed2 = enabled2.status === 0 || active.status === 0 || fs8.existsSync(spec.unitPath);
|
|
3034
|
+
const activeText = (active.stdout || "").trim();
|
|
3035
|
+
return {
|
|
3036
|
+
installed: installed2,
|
|
3037
|
+
managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
|
|
3038
|
+
proxyRunning,
|
|
3039
|
+
details: spec.unitPath
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
const query = runner("schtasks", spec.queryArgs);
|
|
3043
|
+
const output = `${query.stdout || ""}${query.stderr || ""}`;
|
|
3044
|
+
const installed = query.status === 0;
|
|
3045
|
+
const stateMatch = output.match(/^\s*Status:\s*(.+)$/im);
|
|
3046
|
+
return {
|
|
3047
|
+
installed,
|
|
3048
|
+
managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
|
|
3049
|
+
proxyRunning,
|
|
3050
|
+
details: spec.taskName
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
async function printServiceStatus(entryScript, runner) {
|
|
3054
|
+
const spec = currentServiceSpec(entryScript);
|
|
3055
|
+
const status = await getServiceStatus(entryScript, runner);
|
|
3056
|
+
console.log(colors_default.bold("portless service"));
|
|
3057
|
+
console.log(` Manager state: ${status.managerState}`);
|
|
3058
|
+
console.log(` Installed: ${status.installed ? "yes" : "no"}`);
|
|
3059
|
+
console.log(` Proxy on 443: ${status.proxyRunning ? "responding" : "not responding"}`);
|
|
3060
|
+
console.log(` State directory: ${spec.stateDir}`);
|
|
3061
|
+
if (status.details) {
|
|
3062
|
+
console.log(` Service entry: ${status.details}`);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
function printServiceHelp() {
|
|
3066
|
+
console.log(`
|
|
3067
|
+
${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
|
|
3068
|
+
|
|
3069
|
+
${colors_default.bold("Usage:")}
|
|
3070
|
+
${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
|
|
3071
|
+
${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
|
|
3072
|
+
${colors_default.cyan("portless service status")} Show service and proxy status
|
|
3073
|
+
|
|
3074
|
+
${colors_default.bold("Notes:")}
|
|
3075
|
+
The service uses the default clean URL mode: HTTPS on port 443.
|
|
3076
|
+
macOS and Linux install a root-owned service so port 443 can bind at boot.
|
|
3077
|
+
Windows installs a Task Scheduler startup task that runs as SYSTEM.
|
|
3078
|
+
`);
|
|
3079
|
+
}
|
|
3080
|
+
async function handleService(args, options) {
|
|
3081
|
+
const action = args[1];
|
|
3082
|
+
const runner = options.runner || defaultRunner2;
|
|
3083
|
+
if (!action || action === "--help" || action === "-h") {
|
|
3084
|
+
printServiceHelp();
|
|
3085
|
+
process.exit(0);
|
|
3086
|
+
}
|
|
3087
|
+
try {
|
|
3088
|
+
if (action === "install") {
|
|
3089
|
+
await installService(options.entryScript, runner);
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
if (action === "uninstall") {
|
|
3093
|
+
await uninstallService(options.entryScript, runner);
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
3096
|
+
if (action === "status") {
|
|
3097
|
+
await printServiceStatus(options.entryScript, runner);
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
console.error(colors_default.red(`Error: Unknown service command "${action}".`));
|
|
3101
|
+
printServiceHelp();
|
|
3102
|
+
process.exit(1);
|
|
3103
|
+
} catch (err) {
|
|
3104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3105
|
+
console.error(colors_default.red("Error:"), message);
|
|
3106
|
+
process.exit(1);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
2349
3110
|
// src/cli.ts
|
|
2350
3111
|
var chalk = colors_default;
|
|
2351
3112
|
var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
|
|
@@ -2498,16 +3259,16 @@ function getEntryScript() {
|
|
|
2498
3259
|
function isLocallyInstalled() {
|
|
2499
3260
|
let dir = process.cwd();
|
|
2500
3261
|
for (; ; ) {
|
|
2501
|
-
if (
|
|
3262
|
+
if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
|
|
2502
3263
|
return true;
|
|
2503
3264
|
}
|
|
2504
|
-
const parent =
|
|
3265
|
+
const parent = path9.dirname(dir);
|
|
2505
3266
|
if (parent === dir) break;
|
|
2506
3267
|
dir = parent;
|
|
2507
3268
|
}
|
|
2508
3269
|
return false;
|
|
2509
3270
|
}
|
|
2510
|
-
function
|
|
3271
|
+
function collectPortlessEnvArgs2() {
|
|
2511
3272
|
const envArgs = [];
|
|
2512
3273
|
for (const key of Object.keys(process.env)) {
|
|
2513
3274
|
if (key.startsWith("PORTLESS_") && process.env[key]) {
|
|
@@ -2519,7 +3280,35 @@ function collectPortlessEnvArgs() {
|
|
|
2519
3280
|
function sudoStop(port) {
|
|
2520
3281
|
const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
|
|
2521
3282
|
console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
|
|
2522
|
-
const result =
|
|
3283
|
+
const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
|
|
3284
|
+
stdio: "inherit",
|
|
3285
|
+
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3286
|
+
});
|
|
3287
|
+
return result.status === 0;
|
|
3288
|
+
}
|
|
3289
|
+
function runCleanWithSudo(reason) {
|
|
3290
|
+
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3291
|
+
const home = process.env.HOME;
|
|
3292
|
+
const result = spawnSync4(
|
|
3293
|
+
"sudo",
|
|
3294
|
+
[
|
|
3295
|
+
"env",
|
|
3296
|
+
...collectPortlessEnvArgs2(),
|
|
3297
|
+
...home ? [`HOME=${home}`] : [],
|
|
3298
|
+
process.execPath,
|
|
3299
|
+
getEntryScript(),
|
|
3300
|
+
"clean"
|
|
3301
|
+
],
|
|
3302
|
+
{
|
|
3303
|
+
stdio: "inherit",
|
|
3304
|
+
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3305
|
+
}
|
|
3306
|
+
);
|
|
3307
|
+
return result.status === 0;
|
|
3308
|
+
}
|
|
3309
|
+
function runServiceUninstallWithSudo(reason) {
|
|
3310
|
+
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3311
|
+
const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
|
|
2523
3312
|
stdio: "inherit",
|
|
2524
3313
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
2525
3314
|
});
|
|
@@ -2537,11 +3326,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2537
3326
|
console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
|
|
2538
3327
|
}
|
|
2539
3328
|
const routesPath = store.getRoutesPath();
|
|
2540
|
-
if (!
|
|
2541
|
-
|
|
3329
|
+
if (!fs9.existsSync(routesPath)) {
|
|
3330
|
+
fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
|
|
2542
3331
|
}
|
|
2543
3332
|
try {
|
|
2544
|
-
|
|
3333
|
+
fs9.chmodSync(routesPath, FILE_MODE);
|
|
2545
3334
|
} catch {
|
|
2546
3335
|
}
|
|
2547
3336
|
fixOwnership(routesPath);
|
|
@@ -2601,7 +3390,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2601
3390
|
}
|
|
2602
3391
|
};
|
|
2603
3392
|
try {
|
|
2604
|
-
watcher =
|
|
3393
|
+
watcher = fs9.watch(routesPath, () => {
|
|
2605
3394
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
2606
3395
|
debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
|
|
2607
3396
|
});
|
|
@@ -2651,8 +3440,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2651
3440
|
redirectServer.listen(80);
|
|
2652
3441
|
}
|
|
2653
3442
|
server.listen(proxyPort, () => {
|
|
2654
|
-
|
|
2655
|
-
|
|
3443
|
+
fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
|
|
3444
|
+
fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
|
|
2656
3445
|
writeTlsMarker(store.dir, isTls);
|
|
2657
3446
|
writeTldFile(store.dir, tld);
|
|
2658
3447
|
writeLanMarker(store.dir, activeLanIp);
|
|
@@ -2668,7 +3457,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2668
3457
|
console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
|
|
2669
3458
|
if (isTls) {
|
|
2670
3459
|
console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
|
|
2671
|
-
console.log(chalk.gray(` ${
|
|
3460
|
+
console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
|
|
2672
3461
|
}
|
|
2673
3462
|
if (!lanIpPinned) {
|
|
2674
3463
|
lanMonitor = startLanIpMonitor({
|
|
@@ -2700,11 +3489,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2700
3489
|
redirectServer.close();
|
|
2701
3490
|
}
|
|
2702
3491
|
try {
|
|
2703
|
-
|
|
3492
|
+
fs9.unlinkSync(store.pidPath);
|
|
2704
3493
|
} catch {
|
|
2705
3494
|
}
|
|
2706
3495
|
try {
|
|
2707
|
-
|
|
3496
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2708
3497
|
} catch {
|
|
2709
3498
|
}
|
|
2710
3499
|
writeTlsMarker(store.dir, false);
|
|
@@ -2734,7 +3523,7 @@ function sudoStopOrHint(port) {
|
|
|
2734
3523
|
}
|
|
2735
3524
|
async function stopProxy(store, proxyPort, _tls) {
|
|
2736
3525
|
const pidPath = store.pidPath;
|
|
2737
|
-
if (!
|
|
3526
|
+
if (!fs9.existsSync(pidPath)) {
|
|
2738
3527
|
if (await isProxyRunning(proxyPort)) {
|
|
2739
3528
|
console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
|
|
2740
3529
|
const pid = findPidOnPort(proxyPort);
|
|
@@ -2742,7 +3531,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2742
3531
|
try {
|
|
2743
3532
|
process.kill(pid, "SIGTERM");
|
|
2744
3533
|
try {
|
|
2745
|
-
|
|
3534
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2746
3535
|
} catch {
|
|
2747
3536
|
}
|
|
2748
3537
|
writeTlsMarker(store.dir, false);
|
|
@@ -2780,10 +3569,10 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2780
3569
|
return;
|
|
2781
3570
|
}
|
|
2782
3571
|
try {
|
|
2783
|
-
const pid = parseInt(
|
|
3572
|
+
const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
|
|
2784
3573
|
if (isNaN(pid)) {
|
|
2785
3574
|
console.error(colors_default.red("Corrupted PID file. Removing it."));
|
|
2786
|
-
|
|
3575
|
+
fs9.unlinkSync(pidPath);
|
|
2787
3576
|
writeTlsMarker(store.dir, false);
|
|
2788
3577
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
2789
3578
|
writeLanMarker(store.dir, null);
|
|
@@ -2797,9 +3586,9 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2797
3586
|
return;
|
|
2798
3587
|
}
|
|
2799
3588
|
console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
|
|
2800
|
-
|
|
3589
|
+
fs9.unlinkSync(pidPath);
|
|
2801
3590
|
try {
|
|
2802
|
-
|
|
3591
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2803
3592
|
} catch {
|
|
2804
3593
|
}
|
|
2805
3594
|
writeTlsMarker(store.dir, false);
|
|
@@ -2814,16 +3603,16 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2814
3603
|
)
|
|
2815
3604
|
);
|
|
2816
3605
|
console.log(colors_default.yellow("Removing stale PID file."));
|
|
2817
|
-
|
|
3606
|
+
fs9.unlinkSync(pidPath);
|
|
2818
3607
|
writeTlsMarker(store.dir, false);
|
|
2819
3608
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
2820
3609
|
writeLanMarker(store.dir, null);
|
|
2821
3610
|
return;
|
|
2822
3611
|
}
|
|
2823
3612
|
process.kill(pid, "SIGTERM");
|
|
2824
|
-
|
|
3613
|
+
fs9.unlinkSync(pidPath);
|
|
2825
3614
|
try {
|
|
2826
|
-
|
|
3615
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2827
3616
|
} catch {
|
|
2828
3617
|
}
|
|
2829
3618
|
writeTlsMarker(store.dir, false);
|
|
@@ -2859,6 +3648,10 @@ function listRoutes(store, proxyPort, tls2) {
|
|
|
2859
3648
|
console.log(
|
|
2860
3649
|
` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
|
|
2861
3650
|
);
|
|
3651
|
+
if (route.tailscaleUrl) {
|
|
3652
|
+
const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
|
|
3653
|
+
console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
|
|
3654
|
+
}
|
|
2862
3655
|
}
|
|
2863
3656
|
console.log();
|
|
2864
3657
|
}
|
|
@@ -2942,7 +3735,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
2942
3735
|
proxyPort: startPort
|
|
2943
3736
|
});
|
|
2944
3737
|
const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
|
|
2945
|
-
const result =
|
|
3738
|
+
const result = spawnSync4(process.execPath, startArgs, {
|
|
2946
3739
|
stdio: "inherit",
|
|
2947
3740
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
2948
3741
|
});
|
|
@@ -2960,10 +3753,10 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
2960
3753
|
if (!discovered) {
|
|
2961
3754
|
console.error(colors_default.red("Failed to start proxy."));
|
|
2962
3755
|
const fallbackDir = resolveStateDir(effectivePort);
|
|
2963
|
-
const logPath =
|
|
3756
|
+
const logPath = path9.join(fallbackDir, "proxy.log");
|
|
2964
3757
|
console.error(colors_default.blue("Try starting it manually:"));
|
|
2965
3758
|
console.error(colors_default.cyan(` ${manualStartCommand}`));
|
|
2966
|
-
if (
|
|
3759
|
+
if (fs9.existsSync(logPath)) {
|
|
2967
3760
|
console.error(colors_default.gray(`Logs: ${logPath}`));
|
|
2968
3761
|
}
|
|
2969
3762
|
process.exit(1);
|
|
@@ -2976,6 +3769,28 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
|
|
|
2976
3769
|
console.log(chalk.blue.bold(`
|
|
2977
3770
|
portless
|
|
2978
3771
|
`));
|
|
3772
|
+
const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
|
|
3773
|
+
const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
|
|
3774
|
+
let tsBaseUrl;
|
|
3775
|
+
if (wantsTailscale) {
|
|
3776
|
+
try {
|
|
3777
|
+
const tsReady = ensureTailscaleReady({
|
|
3778
|
+
requireFunnel: wantsFunnel,
|
|
3779
|
+
requireHttps: true
|
|
3780
|
+
});
|
|
3781
|
+
tsBaseUrl = tsReady.baseUrl;
|
|
3782
|
+
} catch (err) {
|
|
3783
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3784
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
3785
|
+
if (message.includes("not found")) {
|
|
3786
|
+
console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
|
|
3787
|
+
} else if (!message.includes("not enabled on your tailnet")) {
|
|
3788
|
+
console.error(colors_default.blue("Make sure Tailscale is connected:"));
|
|
3789
|
+
console.error(colors_default.cyan(" tailscale up"));
|
|
3790
|
+
}
|
|
3791
|
+
process.exit(1);
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
2979
3794
|
let desired;
|
|
2980
3795
|
try {
|
|
2981
3796
|
desired = resolveProxyDesiredState(lanMode);
|
|
@@ -3059,7 +3874,46 @@ portless
|
|
|
3059
3874
|
console.log(chalk.green(` LAN -> ${finalUrl}`));
|
|
3060
3875
|
console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
|
|
3061
3876
|
}
|
|
3062
|
-
|
|
3877
|
+
let tailscaleHttpsPort;
|
|
3878
|
+
let tailscaleUrl;
|
|
3879
|
+
if (wantsTailscale && tsBaseUrl) {
|
|
3880
|
+
const maxAttempts = 3;
|
|
3881
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3882
|
+
const usedPorts = getUsedServePorts();
|
|
3883
|
+
tailscaleHttpsPort = findAvailableServePort(usedPorts, wantsFunnel ? "funnel" : "serve");
|
|
3884
|
+
try {
|
|
3885
|
+
if (wantsFunnel) {
|
|
3886
|
+
registerFunnel(port, tailscaleHttpsPort);
|
|
3887
|
+
} else {
|
|
3888
|
+
registerServe(port, tailscaleHttpsPort);
|
|
3889
|
+
}
|
|
3890
|
+
break;
|
|
3891
|
+
} catch (err) {
|
|
3892
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3893
|
+
const isConflict = message.includes("already in use");
|
|
3894
|
+
if (isConflict && attempt < maxAttempts) continue;
|
|
3895
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
3896
|
+
process.exit(1);
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort);
|
|
3900
|
+
const label = wantsFunnel ? "Funnel (public)" : "Tailscale";
|
|
3901
|
+
console.log(chalk.green(` ${label} -> ${tailscaleUrl}`));
|
|
3902
|
+
if (wantsFunnel) {
|
|
3903
|
+
console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n"));
|
|
3904
|
+
} else {
|
|
3905
|
+
console.log(chalk.gray(" (accessible from your tailnet)\n"));
|
|
3906
|
+
}
|
|
3907
|
+
try {
|
|
3908
|
+
store.updateRoute(hostname, {
|
|
3909
|
+
tailscaleUrl,
|
|
3910
|
+
tailscaleHttpsPort,
|
|
3911
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
3912
|
+
});
|
|
3913
|
+
} catch {
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
const basename5 = path9.basename(commandArgs[0]);
|
|
3063
3917
|
const isExpo = basename5 === "expo";
|
|
3064
3918
|
const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
|
|
3065
3919
|
const hostBind = isExpoLan ? void 0 : "127.0.0.1";
|
|
@@ -3069,8 +3923,8 @@ portless
|
|
|
3069
3923
|
injectFrameworkFlags(commandArgs, port);
|
|
3070
3924
|
const caEnv = {};
|
|
3071
3925
|
if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
|
|
3072
|
-
const caPath =
|
|
3073
|
-
if (
|
|
3926
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
3927
|
+
if (fs9.existsSync(caPath)) {
|
|
3074
3928
|
caEnv.NODE_EXTRA_CA_CERTS = caPath;
|
|
3075
3929
|
}
|
|
3076
3930
|
}
|
|
@@ -3092,9 +3946,17 @@ portless
|
|
|
3092
3946
|
// baked-in pinging, making this env var ineffective. Expo handles its
|
|
3093
3947
|
// own LAN discovery natively.
|
|
3094
3948
|
...lanMode ? { PORTLESS_LAN: "1" } : {},
|
|
3949
|
+
...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
|
|
3095
3950
|
...caEnv
|
|
3096
3951
|
},
|
|
3097
3952
|
onCleanup: () => {
|
|
3953
|
+
try {
|
|
3954
|
+
unregisterTailscale({
|
|
3955
|
+
tailscaleHttpsPort,
|
|
3956
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
3957
|
+
});
|
|
3958
|
+
} catch {
|
|
3959
|
+
}
|
|
3098
3960
|
try {
|
|
3099
3961
|
store.removeRoute(hostname);
|
|
3100
3962
|
} catch {
|
|
@@ -3124,6 +3986,18 @@ function appPortFromEnv() {
|
|
|
3124
3986
|
}
|
|
3125
3987
|
return port;
|
|
3126
3988
|
}
|
|
3989
|
+
function applyTailscaleFlag(flag) {
|
|
3990
|
+
if (flag === "--tailscale") {
|
|
3991
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
3992
|
+
return true;
|
|
3993
|
+
}
|
|
3994
|
+
if (flag === "--funnel") {
|
|
3995
|
+
process.env.PORTLESS_FUNNEL = "1";
|
|
3996
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
3997
|
+
return true;
|
|
3998
|
+
}
|
|
3999
|
+
return false;
|
|
4000
|
+
}
|
|
3127
4001
|
function parseRunArgs(args) {
|
|
3128
4002
|
let force = false;
|
|
3129
4003
|
let appPort;
|
|
@@ -3180,9 +4054,12 @@ ${colors_default.bold("Examples:")}
|
|
|
3180
4054
|
process.exit(1);
|
|
3181
4055
|
}
|
|
3182
4056
|
name = args[i];
|
|
4057
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3183
4058
|
} else {
|
|
3184
4059
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3185
|
-
console.error(
|
|
4060
|
+
console.error(
|
|
4061
|
+
colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
|
|
4062
|
+
);
|
|
3186
4063
|
process.exit(1);
|
|
3187
4064
|
}
|
|
3188
4065
|
i++;
|
|
@@ -3203,9 +4080,10 @@ function parseAppArgs(args) {
|
|
|
3203
4080
|
} else if (args[i] === "--app-port") {
|
|
3204
4081
|
i++;
|
|
3205
4082
|
appPort = parseAppPort(args[i]);
|
|
4083
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3206
4084
|
} else {
|
|
3207
4085
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3208
|
-
console.error(colors_default.blue("Known flags: --force, --app-port"));
|
|
4086
|
+
console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
|
|
3209
4087
|
process.exit(1);
|
|
3210
4088
|
}
|
|
3211
4089
|
i++;
|
|
@@ -3221,9 +4099,10 @@ function parseAppArgs(args) {
|
|
|
3221
4099
|
} else if (args[i] === "--app-port") {
|
|
3222
4100
|
i++;
|
|
3223
4101
|
appPort = parseAppPort(args[i]);
|
|
4102
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3224
4103
|
} else {
|
|
3225
4104
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3226
|
-
console.error(colors_default.blue("Known flags: --force, --app-port"));
|
|
4105
|
+
console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
|
|
3227
4106
|
process.exit(1);
|
|
3228
4107
|
}
|
|
3229
4108
|
i++;
|
|
@@ -3250,6 +4129,7 @@ ${colors_default.bold("Usage:")}
|
|
|
3250
4129
|
${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
|
|
3251
4130
|
${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
|
|
3252
4131
|
${colors_default.cyan("portless proxy stop")} Stop the proxy
|
|
4132
|
+
${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
|
|
3253
4133
|
${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
|
|
3254
4134
|
${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
|
|
3255
4135
|
${colors_default.cyan("portless alias --remove <name>")} Remove a static route
|
|
@@ -3267,7 +4147,10 @@ ${colors_default.bold("Examples:")}
|
|
|
3267
4147
|
portless myapp next dev # -> https://myapp.localhost
|
|
3268
4148
|
portless run next dev # -> https://<project>.localhost
|
|
3269
4149
|
portless run next dev # in worktree -> https://<worktree>.<project>.localhost
|
|
4150
|
+
portless service install # Start HTTPS proxy on OS startup
|
|
3270
4151
|
portless get backend # -> https://backend.localhost
|
|
4152
|
+
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
4153
|
+
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
3271
4154
|
|
|
3272
4155
|
${colors_default.bold("Configuration (portless.json):")}
|
|
3273
4156
|
Optional. Portless works out of the box by running the "dev" script
|
|
@@ -3318,6 +4201,17 @@ ${colors_default.bold("LAN mode:")}
|
|
|
3318
4201
|
${colors_default.cyan("portless proxy start --lan --https")}
|
|
3319
4202
|
${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
|
|
3320
4203
|
|
|
4204
|
+
${colors_default.bold("Tailscale sharing:")}
|
|
4205
|
+
Use --tailscale to share your dev server with teammates on your tailnet.
|
|
4206
|
+
Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
|
|
4207
|
+
8444, etc.) so no basePath configuration is needed.
|
|
4208
|
+
Use --funnel to expose your dev server to the public internet via
|
|
4209
|
+
Tailscale Funnel. Requires Tailscale CLI to be installed and connected,
|
|
4210
|
+
with Tailscale HTTPS certificates enabled. Funnel must also be enabled
|
|
4211
|
+
on your tailnet.
|
|
4212
|
+
${colors_default.cyan("portless myapp --tailscale next dev")}
|
|
4213
|
+
${colors_default.cyan("portless myapp --funnel next dev")}
|
|
4214
|
+
|
|
3321
4215
|
${colors_default.bold("Options:")}
|
|
3322
4216
|
run [--name <name>] <cmd> Infer project name (or override with --name)
|
|
3323
4217
|
Adds worktree prefix in git worktrees
|
|
@@ -3334,6 +4228,8 @@ ${colors_default.bold("Options:")}
|
|
|
3334
4228
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
|
|
3335
4229
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
3336
4230
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4231
|
+
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4232
|
+
--funnel Share the app publicly via Tailscale Funnel
|
|
3337
4233
|
--force Kill the existing process and take over its route
|
|
3338
4234
|
--name <name> Use <name> as the app name (bypasses subcommand dispatch)
|
|
3339
4235
|
-- Stop flag parsing; everything after is passed to the child
|
|
@@ -3346,6 +4242,8 @@ ${colors_default.bold("Environment variables:")}
|
|
|
3346
4242
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
|
|
3347
4243
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
3348
4244
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
4245
|
+
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
|
|
4246
|
+
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
|
|
3349
4247
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
3350
4248
|
PORTLESS=0 Run command directly without proxy
|
|
3351
4249
|
|
|
@@ -3354,6 +4252,7 @@ ${colors_default.bold("Child process environment:")}
|
|
|
3354
4252
|
HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
|
|
3355
4253
|
PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
|
|
3356
4254
|
PORTLESS_LAN Set to 1 when proxy is in LAN mode
|
|
4255
|
+
PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
|
|
3357
4256
|
NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
|
|
3358
4257
|
|
|
3359
4258
|
${colors_default.bold("Safari / DNS:")}
|
|
@@ -3369,20 +4268,20 @@ ${colors_default.bold("Skip portless:")}
|
|
|
3369
4268
|
PORTLESS=0 pnpm dev # Runs command directly without proxy
|
|
3370
4269
|
|
|
3371
4270
|
${colors_default.bold("Reserved names:")}
|
|
3372
|
-
run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
|
|
4271
|
+
run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
|
|
3373
4272
|
cannot be used as app names directly. Use "portless run" to infer the name,
|
|
3374
4273
|
or "portless --name <name>" to force any name including reserved ones.
|
|
3375
4274
|
`);
|
|
3376
4275
|
process.exit(0);
|
|
3377
4276
|
}
|
|
3378
4277
|
function printVersion() {
|
|
3379
|
-
console.log("0.
|
|
4278
|
+
console.log("0.13.0");
|
|
3380
4279
|
process.exit(0);
|
|
3381
4280
|
}
|
|
3382
4281
|
async function handleTrust() {
|
|
3383
4282
|
const { dir } = await discoverState();
|
|
3384
|
-
if (!
|
|
3385
|
-
|
|
4283
|
+
if (!fs9.existsSync(dir)) {
|
|
4284
|
+
fs9.mkdirSync(dir, { recursive: true });
|
|
3386
4285
|
}
|
|
3387
4286
|
const { caGenerated } = ensureCerts(dir);
|
|
3388
4287
|
if (caGenerated) {
|
|
@@ -3394,14 +4293,14 @@ async function handleTrust() {
|
|
|
3394
4293
|
console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
|
|
3395
4294
|
return;
|
|
3396
4295
|
}
|
|
3397
|
-
const
|
|
3398
|
-
if (
|
|
4296
|
+
const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
|
|
4297
|
+
if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
|
|
3399
4298
|
console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
|
|
3400
|
-
const sudoResult =
|
|
4299
|
+
const sudoResult = spawnSync4(
|
|
3401
4300
|
"sudo",
|
|
3402
4301
|
[
|
|
3403
4302
|
"env",
|
|
3404
|
-
...
|
|
4303
|
+
...collectPortlessEnvArgs2(),
|
|
3405
4304
|
`PORTLESS_STATE_DIR=${dir}`,
|
|
3406
4305
|
process.execPath,
|
|
3407
4306
|
getEntryScript(),
|
|
@@ -3423,10 +4322,11 @@ async function handleClean(args) {
|
|
|
3423
4322
|
console.log(`
|
|
3424
4323
|
${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
|
|
3425
4324
|
|
|
3426
|
-
Stops the proxy if it is running,
|
|
3427
|
-
when it was installed by portless,
|
|
3428
|
-
(~/.portless, the system state
|
|
3429
|
-
and removes the portless block
|
|
4325
|
+
Stops the proxy if it is running, uninstalls the startup service if installed,
|
|
4326
|
+
removes the local CA from the OS trust store when it was installed by portless,
|
|
4327
|
+
deletes known files under state directories (~/.portless, the system state
|
|
4328
|
+
directory, and PORTLESS_STATE_DIR when set), and removes the portless block
|
|
4329
|
+
from ${HOSTS_DISPLAY}.
|
|
3430
4330
|
|
|
3431
4331
|
Only allowlisted filenames under each state directory are deleted. Custom
|
|
3432
4332
|
certificate paths from --cert and --key are never removed.
|
|
@@ -3447,16 +4347,43 @@ ${colors_default.bold("Options:")}
|
|
|
3447
4347
|
console.error(colors_default.cyan(" portless clean --help"));
|
|
3448
4348
|
process.exit(1);
|
|
3449
4349
|
}
|
|
4350
|
+
const serviceResult = tryUninstallService(getEntryScript());
|
|
4351
|
+
if (serviceResult.removed) {
|
|
4352
|
+
console.log(colors_default.green("Removed startup service."));
|
|
4353
|
+
} else if (serviceResult.needsElevation && !isWindows && (process.getuid?.() ?? -1) !== 0) {
|
|
4354
|
+
if (!runServiceUninstallWithSudo("Removing the startup service requires elevated privileges.")) {
|
|
4355
|
+
console.error(colors_default.red("Failed to remove startup service with sudo."));
|
|
4356
|
+
process.exit(1);
|
|
4357
|
+
}
|
|
4358
|
+
} else if (serviceResult.error) {
|
|
4359
|
+
const adminHint = isWindows ? " Run as Administrator and try again." : "";
|
|
4360
|
+
const message = `Could not remove startup service: ${serviceResult.error}${adminHint}`;
|
|
4361
|
+
if (serviceResult.installed) {
|
|
4362
|
+
console.error(colors_default.red(message));
|
|
4363
|
+
process.exit(1);
|
|
4364
|
+
}
|
|
4365
|
+
console.warn(colors_default.yellow(message));
|
|
4366
|
+
}
|
|
3450
4367
|
console.log(colors_default.cyan("Stopping proxy if it is running..."));
|
|
3451
4368
|
const { dir, port, tls: tls2 } = await discoverState();
|
|
3452
4369
|
const store = new RouteStore(dir, {
|
|
3453
4370
|
onWarning: (msg) => console.warn(colors_default.yellow(msg))
|
|
3454
4371
|
});
|
|
3455
4372
|
await stopProxy(store, port, tls2);
|
|
4373
|
+
const routesForClean = store.loadRoutesRaw();
|
|
4374
|
+
for (const route of routesForClean) {
|
|
4375
|
+
if (route.tailscaleHttpsPort) {
|
|
4376
|
+
try {
|
|
4377
|
+
unregisterTailscale(route);
|
|
4378
|
+
console.log(colors_default.green(`Removed tailscale serve on port ${route.tailscaleHttpsPort}.`));
|
|
4379
|
+
} catch {
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
3456
4383
|
const stateDirs = collectStateDirsForCleanup();
|
|
3457
4384
|
for (const stateDir of stateDirs) {
|
|
3458
|
-
const caPath =
|
|
3459
|
-
if (!
|
|
4385
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
4386
|
+
if (!fs9.existsSync(caPath)) continue;
|
|
3460
4387
|
const wasTrusted = isCATrusted(stateDir);
|
|
3461
4388
|
if (!wasTrusted) continue;
|
|
3462
4389
|
const untrustResult = untrustCA(stateDir);
|
|
@@ -3478,18 +4405,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
|
|
|
3478
4405
|
if (cleanHostsFile()) {
|
|
3479
4406
|
console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
|
|
3480
4407
|
} else if (!isWindows && process.getuid?.() !== 0) {
|
|
3481
|
-
|
|
3482
|
-
colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
3483
|
-
);
|
|
3484
|
-
const result = spawnSync2(
|
|
3485
|
-
"sudo",
|
|
3486
|
-
["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
|
|
3487
|
-
{
|
|
3488
|
-
stdio: "inherit",
|
|
3489
|
-
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3490
|
-
}
|
|
3491
|
-
);
|
|
3492
|
-
if (result.status !== 0) {
|
|
4408
|
+
if (!runCleanWithSudo(`Updating ${HOSTS_DISPLAY} requires elevated privileges.`)) {
|
|
3493
4409
|
console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
|
|
3494
4410
|
process.exit(1);
|
|
3495
4411
|
}
|
|
@@ -3532,6 +4448,17 @@ ${colors_default.bold("Options:")}
|
|
|
3532
4448
|
console.log("No orphaned routes found.");
|
|
3533
4449
|
return;
|
|
3534
4450
|
}
|
|
4451
|
+
for (const route of stale) {
|
|
4452
|
+
if (route.tailscaleHttpsPort) {
|
|
4453
|
+
try {
|
|
4454
|
+
unregisterTailscale(route);
|
|
4455
|
+
console.log(
|
|
4456
|
+
` ${route.hostname} - removed tailscale serve on port ${route.tailscaleHttpsPort}`
|
|
4457
|
+
);
|
|
4458
|
+
} catch {
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
3535
4462
|
let killed = 0;
|
|
3536
4463
|
for (const route of stale) {
|
|
3537
4464
|
const pids = findPidsOnPort(route.port);
|
|
@@ -3709,9 +4636,9 @@ ${colors_default.bold("Auto-sync:")}
|
|
|
3709
4636
|
`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
|
|
3710
4637
|
)
|
|
3711
4638
|
);
|
|
3712
|
-
const result =
|
|
4639
|
+
const result = spawnSync4(
|
|
3713
4640
|
"sudo",
|
|
3714
|
-
["env", ...
|
|
4641
|
+
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
|
|
3715
4642
|
{
|
|
3716
4643
|
stdio: "inherit",
|
|
3717
4644
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
@@ -3762,9 +4689,9 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
3762
4689
|
console.log(
|
|
3763
4690
|
colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
3764
4691
|
);
|
|
3765
|
-
const result =
|
|
4692
|
+
const result = spawnSync4(
|
|
3766
4693
|
"sudo",
|
|
3767
|
-
["env", ...
|
|
4694
|
+
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
|
|
3768
4695
|
{
|
|
3769
4696
|
stdio: "inherit",
|
|
3770
4697
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
@@ -4053,7 +4980,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4053
4980
|
if (!hasExplicitPort) {
|
|
4054
4981
|
console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
|
|
4055
4982
|
}
|
|
4056
|
-
const result =
|
|
4983
|
+
const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
|
|
4057
4984
|
stdio: "inherit",
|
|
4058
4985
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
4059
4986
|
});
|
|
@@ -4063,8 +4990,8 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4063
4990
|
console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
|
|
4064
4991
|
} else {
|
|
4065
4992
|
console.error(colors_default.red("Proxy process started but is not responding."));
|
|
4066
|
-
const logPath2 =
|
|
4067
|
-
if (
|
|
4993
|
+
const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
|
|
4994
|
+
if (fs9.existsSync(logPath2)) {
|
|
4068
4995
|
console.error(colors_default.gray(`Logs: ${logPath2}`));
|
|
4069
4996
|
}
|
|
4070
4997
|
}
|
|
@@ -4103,8 +5030,8 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4103
5030
|
store.ensureDir();
|
|
4104
5031
|
if (customCertPath && customKeyPath) {
|
|
4105
5032
|
try {
|
|
4106
|
-
const cert =
|
|
4107
|
-
const key =
|
|
5033
|
+
const cert = fs9.readFileSync(customCertPath);
|
|
5034
|
+
const key = fs9.readFileSync(customKeyPath);
|
|
4108
5035
|
const certStr = cert.toString("utf-8");
|
|
4109
5036
|
const keyStr = key.toString("utf-8");
|
|
4110
5037
|
if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
|
|
@@ -4149,9 +5076,9 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4149
5076
|
console.warn(colors_default.cyan(" portless trust"));
|
|
4150
5077
|
}
|
|
4151
5078
|
}
|
|
4152
|
-
const cert =
|
|
4153
|
-
const key =
|
|
4154
|
-
const ca =
|
|
5079
|
+
const cert = fs9.readFileSync(certs.certPath);
|
|
5080
|
+
const key = fs9.readFileSync(certs.keyPath);
|
|
5081
|
+
const ca = fs9.readFileSync(certs.caPath);
|
|
4155
5082
|
tlsOptions = {
|
|
4156
5083
|
cert,
|
|
4157
5084
|
key,
|
|
@@ -4166,11 +5093,11 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4166
5093
|
return;
|
|
4167
5094
|
}
|
|
4168
5095
|
store.ensureDir();
|
|
4169
|
-
const logPath =
|
|
4170
|
-
const logFd =
|
|
5096
|
+
const logPath = path9.join(stateDir, "proxy.log");
|
|
5097
|
+
const logFd = fs9.openSync(logPath, "a");
|
|
4171
5098
|
try {
|
|
4172
5099
|
try {
|
|
4173
|
-
|
|
5100
|
+
fs9.chmodSync(logPath, FILE_MODE);
|
|
4174
5101
|
} catch {
|
|
4175
5102
|
}
|
|
4176
5103
|
fixOwnership(logPath);
|
|
@@ -4201,13 +5128,13 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4201
5128
|
});
|
|
4202
5129
|
child.unref();
|
|
4203
5130
|
} finally {
|
|
4204
|
-
|
|
5131
|
+
fs9.closeSync(logFd);
|
|
4205
5132
|
}
|
|
4206
5133
|
if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
|
|
4207
5134
|
console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
4208
5135
|
console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
|
|
4209
5136
|
console.error(colors_default.cyan(" portless proxy start --foreground"));
|
|
4210
|
-
if (
|
|
5137
|
+
if (fs9.existsSync(logPath)) {
|
|
4211
5138
|
console.error(colors_default.gray(`Logs: ${logPath}`));
|
|
4212
5139
|
}
|
|
4213
5140
|
process.exit(1);
|
|
@@ -4359,8 +5286,8 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
|
|
|
4359
5286
|
PORTLESS_URL: url
|
|
4360
5287
|
};
|
|
4361
5288
|
if (tls2) {
|
|
4362
|
-
const caPath =
|
|
4363
|
-
if (
|
|
5289
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
5290
|
+
if (fs9.existsSync(caPath)) {
|
|
4364
5291
|
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
4365
5292
|
}
|
|
4366
5293
|
}
|
|
@@ -4442,7 +5369,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
|
|
|
4442
5369
|
}
|
|
4443
5370
|
const apps = [];
|
|
4444
5371
|
for (const pkg of packages) {
|
|
4445
|
-
const rel =
|
|
5372
|
+
const rel = path9.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
|
|
4446
5373
|
const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
|
|
4447
5374
|
let pkgConfig;
|
|
4448
5375
|
try {
|
|
@@ -4550,8 +5477,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
4550
5477
|
PORTLESS_URL: url
|
|
4551
5478
|
};
|
|
4552
5479
|
if (tls2) {
|
|
4553
|
-
const caPath =
|
|
4554
|
-
if (
|
|
5480
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
5481
|
+
if (fs9.existsSync(caPath)) {
|
|
4555
5482
|
entry.NODE_EXTRA_CA_CERTS = caPath;
|
|
4556
5483
|
}
|
|
4557
5484
|
}
|
|
@@ -4825,6 +5752,13 @@ async function main() {
|
|
|
4825
5752
|
process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
|
|
4826
5753
|
process.env.PORTLESS_LAN = "1";
|
|
4827
5754
|
}
|
|
5755
|
+
if (stripGlobalFlag("--tailscale", false)) {
|
|
5756
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
5757
|
+
}
|
|
5758
|
+
if (stripGlobalFlag("--funnel", false)) {
|
|
5759
|
+
process.env.PORTLESS_FUNNEL = "1";
|
|
5760
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
5761
|
+
}
|
|
4828
5762
|
const scriptResult = stripGlobalFlag("--script", true);
|
|
4829
5763
|
if (scriptResult === false) {
|
|
4830
5764
|
console.error(colors_default.red("Error: --script requires a script name."));
|
|
@@ -4857,7 +5791,7 @@ async function main() {
|
|
|
4857
5791
|
args.shift();
|
|
4858
5792
|
}
|
|
4859
5793
|
const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
|
|
4860
|
-
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
|
|
5794
|
+
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
|
|
4861
5795
|
const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
|
|
4862
5796
|
let commandArgs = parsed.commandArgs;
|
|
4863
5797
|
if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
|
|
@@ -4921,6 +5855,10 @@ async function main() {
|
|
|
4921
5855
|
await handleProxy(args);
|
|
4922
5856
|
return;
|
|
4923
5857
|
}
|
|
5858
|
+
if (args[0] === "service") {
|
|
5859
|
+
await handleService(args, { entryScript: getEntryScript() });
|
|
5860
|
+
return;
|
|
5861
|
+
}
|
|
4924
5862
|
}
|
|
4925
5863
|
if (isRunCommand) {
|
|
4926
5864
|
await handleRunMode(args, globalScript);
|