portless-studio 0.1.0 → 0.1.1
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 +56 -12
- package/dist/cli/cli-args.js +29 -3
- package/dist/cli/index.js +10 -15
- package/dist/cli/login-service.js +16 -0
- package/dist/core/api/contract.js +2 -0
- package/dist/core/config/studio-config.js +2 -0
- package/dist/core/updates/version.js +62 -0
- package/dist/server/application/auto-namer.js +14 -0
- package/dist/server/application/doctor.js +26 -0
- package/dist/server/application/proxy-start-check.js +83 -0
- package/dist/server/application/update-check.js +66 -0
- package/dist/server/infrastructure/config/json-update-cache.js +35 -0
- package/dist/server/infrastructure/portless/portless-registry.js +31 -4
- package/dist/server/infrastructure/portless/state-files.js +23 -7
- package/dist/server/infrastructure/system/doctor-facts.js +1 -0
- package/dist/server/infrastructure/system/npm-registry.js +20 -0
- package/dist/server/interface/http/http-server.js +5 -0
- package/dist/server/interface/http/json-io.js +1 -0
- package/dist/server/main.js +23 -4
- package/dist/web/assets/index-B3KMqQ-l.js +15 -0
- package/dist/web/assets/{index-CA7HpYbk.css → index-CWujmTe4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CC6BquZf.js +0 -15
|
@@ -2,7 +2,10 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import { err, ok } from "../../../core/result.js";
|
|
3
3
|
import { appError } from "../../application/app-error.js";
|
|
4
4
|
import { addAlias, detectPortless, installPortless, removeAlias, startProxy, stopProxy, } from "./portless-cli.js";
|
|
5
|
-
import { readProxyPid, readProxyStatus,
|
|
5
|
+
import { readProxyPid, readProxyStatus, readRouteEntries, routesFromEntries, } from "./state-files.js";
|
|
6
|
+
/** How long a write waits for a half-written `routes.json` to be whole again. */
|
|
7
|
+
const TORN_RETRIES = 10;
|
|
8
|
+
const TORN_RETRY_MS = 50;
|
|
6
9
|
/** Reads portless state files directly and writes through the portless CLI, which owns the lock. */
|
|
7
10
|
export class PortlessRegistry {
|
|
8
11
|
#stateDir;
|
|
@@ -16,6 +19,8 @@ export class PortlessRegistry {
|
|
|
16
19
|
// Bumped by an install, so a detection that started before it cannot cache a stale answer.
|
|
17
20
|
#detectGeneration = 0;
|
|
18
21
|
#installing = false;
|
|
22
|
+
/** The entries of the last `routes.json` that parsed, shown while portless rewrites it. */
|
|
23
|
+
#lastEntries = [];
|
|
19
24
|
/** One per watcher: compares the installation with what that watcher last saw. */
|
|
20
25
|
#installationChecks = new Set();
|
|
21
26
|
constructor(options) {
|
|
@@ -29,7 +34,7 @@ export class PortlessRegistry {
|
|
|
29
34
|
async readSnapshot() {
|
|
30
35
|
const portless = await this.#detect();
|
|
31
36
|
const proxy = readProxyStatus(this.#stateDir);
|
|
32
|
-
return { proxy, portless, routes:
|
|
37
|
+
return { proxy, portless, routes: this.#routes(proxy), readAt: Date.now() };
|
|
33
38
|
}
|
|
34
39
|
watch(onChange) {
|
|
35
40
|
let last = this.#fingerprint();
|
|
@@ -103,8 +108,9 @@ export class PortlessRegistry {
|
|
|
103
108
|
}
|
|
104
109
|
async createAlias(name, port, force) {
|
|
105
110
|
// `portless alias` silently replaces an existing alias, so the conflict check lives here.
|
|
111
|
+
await this.#waitForWholeRoutesFile();
|
|
106
112
|
const proxy = readProxyStatus(this.#stateDir);
|
|
107
|
-
const existing =
|
|
113
|
+
const existing = this.#routes(proxy).find((route) => route.name === name);
|
|
108
114
|
if (existing && !force) {
|
|
109
115
|
if (existing.port === port)
|
|
110
116
|
return ok(undefined);
|
|
@@ -149,6 +155,27 @@ export class PortlessRegistry {
|
|
|
149
155
|
/** The snapshot without its read time, so two reads of the same state compare equal. */
|
|
150
156
|
#fingerprint() {
|
|
151
157
|
const proxy = readProxyStatus(this.#stateDir);
|
|
152
|
-
return JSON.stringify({ proxy, routes:
|
|
158
|
+
return JSON.stringify({ proxy, routes: this.#routes(proxy) });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Routes from `routes.json`. portless rewrites the file in place, so a read can land on an empty
|
|
162
|
+
* or cut-off file; that read keeps the last entries that parsed, and the next file event or poll
|
|
163
|
+
* reads it again. A missing file, or one in a format Studio does not know, means no routes.
|
|
164
|
+
*/
|
|
165
|
+
#routes(proxy) {
|
|
166
|
+
const read = readRouteEntries(this.#stateDir);
|
|
167
|
+
if (read.kind === "ok")
|
|
168
|
+
this.#lastEntries = read.entries;
|
|
169
|
+
else if (read.kind !== "torn")
|
|
170
|
+
this.#lastEntries = [];
|
|
171
|
+
return routesFromEntries(this.#lastEntries, proxy);
|
|
172
|
+
}
|
|
173
|
+
/** Gives portless a moment to finish a write, so a name check never runs on a torn file. */
|
|
174
|
+
async #waitForWholeRoutesFile() {
|
|
175
|
+
for (let attempt = 0; attempt < TORN_RETRIES; attempt += 1) {
|
|
176
|
+
if (readRouteEntries(this.#stateDir).kind !== "torn")
|
|
177
|
+
return;
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, TORN_RETRY_MS));
|
|
179
|
+
}
|
|
153
180
|
}
|
|
154
181
|
}
|
|
@@ -34,21 +34,29 @@ export function readProxyPid(dir) {
|
|
|
34
34
|
const pid = Number.parseInt(readText(dir, PID_FILE) ?? "", 10);
|
|
35
35
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
export function readRoutes(dir, proxy) {
|
|
37
|
+
export function readRouteEntries(dir) {
|
|
39
38
|
const raw = readText(dir, ROUTES_FILE);
|
|
40
39
|
if (raw === null)
|
|
41
|
-
return
|
|
40
|
+
return { kind: "missing" };
|
|
41
|
+
if (raw.trim() === "")
|
|
42
|
+
return { kind: "torn" };
|
|
42
43
|
let parsed;
|
|
43
44
|
try {
|
|
44
45
|
parsed = JSON.parse(raw);
|
|
45
46
|
}
|
|
46
47
|
catch {
|
|
47
|
-
return
|
|
48
|
+
return { kind: "torn" };
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
return Array.isArray(parsed) ? { kind: "ok", entries: parsed } : { kind: "unexpected" };
|
|
51
|
+
}
|
|
52
|
+
/** Live routes from `routes.json`. Invalid entries and apps whose process died are left out. */
|
|
53
|
+
export function readRoutes(dir, proxy) {
|
|
54
|
+
const read = readRouteEntries(dir);
|
|
55
|
+
return read.kind === "ok" ? routesFromEntries(read.entries, proxy) : [];
|
|
56
|
+
}
|
|
57
|
+
/** The routes in a parsed `routes.json`, without invalid entries and apps whose process died. */
|
|
58
|
+
export function routesFromEntries(entries, proxy) {
|
|
59
|
+
return entries
|
|
52
60
|
.flatMap((entry) => {
|
|
53
61
|
const route = toRoute(entry, proxy);
|
|
54
62
|
return route ? [route] : [];
|
|
@@ -56,6 +64,14 @@ export function readRoutes(dir, proxy) {
|
|
|
56
64
|
.filter((route) => route.pid === null || isProcessAlive(route.pid))
|
|
57
65
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
66
|
}
|
|
67
|
+
export function inspectRoutesFile(dir) {
|
|
68
|
+
const read = readRouteEntries(dir);
|
|
69
|
+
if (read.kind !== "ok")
|
|
70
|
+
return { state: read.kind, entries: 0, unreadable: 0 };
|
|
71
|
+
const proxy = { running: false, port: null, https: false, tlds: [DEFAULT_TLD] };
|
|
72
|
+
const unreadable = read.entries.filter((entry) => toRoute(entry, proxy) === null).length;
|
|
73
|
+
return { state: "ok", entries: read.entries.length, unreadable };
|
|
74
|
+
}
|
|
59
75
|
/** Signal 0 checks existence without touching the process; EPERM means it exists. */
|
|
60
76
|
export function isProcessAlive(pid) {
|
|
61
77
|
try {
|
|
@@ -127,6 +127,7 @@ export async function collectDoctorFacts(options) {
|
|
|
127
127
|
studioPort: { port: config.port, holder: await portHolder(config.port, options) },
|
|
128
128
|
loginService,
|
|
129
129
|
...(systemNotifier === undefined ? {} : { systemNotifier }),
|
|
130
|
+
...(options.routesFile ? { routesFile: options.routesFile() } : {}),
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
133
|
/** Paths are built for the platform the facts describe, not the one running the code. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Public npm metadata of the latest release; Studio sends nothing but this GET. */
|
|
2
|
+
export const LATEST_RELEASE_URL = "https://registry.npmjs.org/portless-studio/latest";
|
|
3
|
+
const TIMEOUT_MS = 3000;
|
|
4
|
+
/** The `version` npm reports as latest, or null on any failure, including the 3 s timeout. */
|
|
5
|
+
export async function fetchLatestVersion(options = {}) {
|
|
6
|
+
const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetchImpl(options.url ?? LATEST_RELEASE_URL, {
|
|
9
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
10
|
+
});
|
|
11
|
+
if (!response.ok)
|
|
12
|
+
return null;
|
|
13
|
+
const body = await response.json();
|
|
14
|
+
const version = typeof body === "object" && body !== null ? Reflect.get(body, "version") : undefined;
|
|
15
|
+
return typeof version === "string" && /^\d+\.\d+\.\d+\S*$/.test(version) ? version : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -102,6 +102,11 @@ export function createHttpServer(options) {
|
|
|
102
102
|
sendJson(res, 200, await options.studio.readSnapshot());
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
+
if (pathname === API_PATHS.update && method === "GET") {
|
|
106
|
+
if (!options.updates)
|
|
107
|
+
return sendAppError(res, unsupported);
|
|
108
|
+
return sendJson(res, 200, await options.updates.status());
|
|
109
|
+
}
|
|
105
110
|
if (pathname === API_PATHS.config && method === "GET") {
|
|
106
111
|
const result = await options.studio.readConfig();
|
|
107
112
|
if (!result.ok)
|
package/dist/server/main.js
CHANGED
|
@@ -5,20 +5,24 @@ import * as path from "node:path";
|
|
|
5
5
|
import { createDoctorService } from "./application/doctor.js";
|
|
6
6
|
import { OpenWith } from "./application/open-with.js";
|
|
7
7
|
import { controlProxy } from "./application/proxy-control.js";
|
|
8
|
+
import { confirmProxyStarts } from "./application/proxy-start-check.js";
|
|
8
9
|
import { createAlias, renameAlias } from "./application/route-use-cases.js";
|
|
9
10
|
import { stopProcess } from "./application/stop-process.js";
|
|
10
11
|
import { StudioRuntime } from "./application/studio-runtime.js";
|
|
11
12
|
import { TerminalManager } from "./application/terminals.js";
|
|
13
|
+
import { UpdateChecker, updateChecksAllowed } from "./application/update-check.js";
|
|
12
14
|
import { systemAppCatalog } from "./infrastructure/apps/app-catalog.js";
|
|
13
15
|
import { createDetachedLauncher } from "./infrastructure/apps/detached-launcher.js";
|
|
14
16
|
import { JsonCustomAppStore, JsonRouteProjectStore, openWithPathFor, routeProjectsPathFor, } from "./infrastructure/apps/json-stores.js";
|
|
15
17
|
import { autoNamedPathFor, JsonAutoNamedStore, } from "./infrastructure/config/json-auto-named-store.js";
|
|
16
18
|
import { defaultConfigPath, JsonConfigStore } from "./infrastructure/config/json-config-store.js";
|
|
19
|
+
import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/json-update-cache.js";
|
|
17
20
|
import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
|
|
18
21
|
import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
|
|
19
|
-
import { resolveStateDir } from "./infrastructure/portless/state-files.js";
|
|
22
|
+
import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
|
|
20
23
|
import { collectDoctorFacts, proxyPidsAmong } from "./infrastructure/system/doctor-facts.js";
|
|
21
24
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
25
|
+
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
22
26
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
23
27
|
import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
|
|
24
28
|
import { nodeSystemIo } from "./infrastructure/system/system-io.js";
|
|
@@ -39,9 +43,11 @@ export function createStudioServices(options = {}) {
|
|
|
39
43
|
bin: options.portlessBin ?? (process.env.PORTLESS_BIN || "portless"),
|
|
40
44
|
...(options.npmBin === undefined ? {} : { npmBin: options.npmBin }),
|
|
41
45
|
});
|
|
42
|
-
// Every proxy Studio starts is written down, so `uninstall` stops only Studio's own.
|
|
43
|
-
const registry = recordProxyStarts(portless, proxyStartRecordPathFor(configPath));
|
|
44
46
|
const scanner = createPortScanner();
|
|
47
|
+
const proxyPids = (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids);
|
|
48
|
+
// A start counts only once the proxy listens, and every proxy Studio starts is written down,
|
|
49
|
+
// so `uninstall` stops only Studio's own.
|
|
50
|
+
const registry = recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath));
|
|
45
51
|
const configStore = new JsonConfigStore(configPath);
|
|
46
52
|
const notifier = new OsNotifier({ platform: process.platform });
|
|
47
53
|
const runtime = new StudioRuntime({
|
|
@@ -51,7 +57,7 @@ export function createStudioServices(options = {}) {
|
|
|
51
57
|
projects: new ProjectInfoReader(),
|
|
52
58
|
studioPort: options.studioPort ?? null,
|
|
53
59
|
probe: (port) => probeHttpResponse(port),
|
|
54
|
-
proxyPids
|
|
60
|
+
proxyPids,
|
|
55
61
|
autoNamedStore: new JsonAutoNamedStore(autoNamedPathFor(configPath)),
|
|
56
62
|
notifier,
|
|
57
63
|
icons: new FaviconFetcher(),
|
|
@@ -97,10 +103,20 @@ export function createStudioServices(options = {}) {
|
|
|
97
103
|
probe: (port) => probeHttpResponse(port),
|
|
98
104
|
...(studioService ? { loginService: () => studioService.status() } : {}),
|
|
99
105
|
systemNotifier: () => notifier.availability(),
|
|
106
|
+
routesFile: () => inspectRoutesFile(stateDir),
|
|
100
107
|
}),
|
|
101
108
|
controlProxy: proxy,
|
|
102
109
|
updateConfig: (config) => runtime.updateConfig(config),
|
|
103
110
|
});
|
|
111
|
+
const version = options.version;
|
|
112
|
+
const updates = version === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: new UpdateChecker({
|
|
115
|
+
current: version,
|
|
116
|
+
enabled: async () => updateChecksAllowed(process.env) && (await runtime.settings()).checkForUpdates,
|
|
117
|
+
fetchLatest: () => fetchLatestVersion(),
|
|
118
|
+
cache: new JsonUpdateCache(updateCheckPathFor(configPath)),
|
|
119
|
+
});
|
|
104
120
|
const services = {
|
|
105
121
|
registry,
|
|
106
122
|
scanner,
|
|
@@ -117,6 +133,7 @@ export function createStudioServices(options = {}) {
|
|
|
117
133
|
openWith,
|
|
118
134
|
terminals,
|
|
119
135
|
openUrl: options.openUrl,
|
|
136
|
+
updates,
|
|
120
137
|
};
|
|
121
138
|
runtimes.set(services, runtime);
|
|
122
139
|
return services;
|
|
@@ -130,6 +147,7 @@ export function createUninstallServices(configPath = defaultConfigPath()) {
|
|
|
130
147
|
recordPath,
|
|
131
148
|
routeProjectsPathFor(configPath),
|
|
132
149
|
openWithPathFor(configPath),
|
|
150
|
+
updateCheckPathFor(configPath),
|
|
133
151
|
];
|
|
134
152
|
const processes = new SystemProcessControl({
|
|
135
153
|
platform: process.platform,
|
|
@@ -174,6 +192,7 @@ export async function startServer(options) {
|
|
|
174
192
|
...(services.openWith ? { openWith: services.openWith } : {}),
|
|
175
193
|
...(services.terminals ? { terminals: services.terminals } : {}),
|
|
176
194
|
...(services.openUrl ? { openUrl: services.openUrl } : {}),
|
|
195
|
+
...(services.updates ? { updates: services.updates } : {}),
|
|
177
196
|
});
|
|
178
197
|
await new Promise((resolve, reject) => {
|
|
179
198
|
http.server.once("error", reject);
|