open-ready 0.1.115 → 0.2.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 +21 -3
- package/open-when-ready.mjs +178 -9
- package/package.json +1 -1
- package/test/portless.test.mjs +106 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
<!-- template-git-repo:badges:start -->
|
|
24
24
|
<p align="center">
|
|
25
|
-
<a href="https://
|
|
25
|
+
<a href="https://1computer.online/docs/packages/open-when-ready"><img src="https://img.shields.io/badge/Docs-blue?logo=ReadTheDocs&logoColor=white" alt="Documentation" /></a>
|
|
26
26
|
<br />
|
|
27
27
|
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/stargazers"><img src="https://img.shields.io/github/stars/OpenSourceAGI/dev-tools-starter-agent" alt="GitHub Stars" /></a>
|
|
28
28
|
<a href="https://www.npmjs.com/package/open-ready"><img src="https://img.shields.io/npm/dm/open-ready.svg" alt="NPM Monthly Downloads" /></a>
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
Smart dev server launcher that watches your server's output and automatically opens the browser when ready — or opens an AI assistant with the error context when something goes wrong.
|
|
54
54
|
|
|
55
|
-
Works with Next.js, Vite, and any CLI-based dev server.
|
|
55
|
+
Works with Next.js, Vite, and any CLI-based dev server. When [portless](https://www.npmjs.com/package/portless) is installed, your app gets a stable named URL like `https://myapp.localhost` instead of a port number — and that's the URL that opens.
|
|
56
56
|
|
|
57
57
|
## Install
|
|
58
58
|
|
|
@@ -85,6 +85,24 @@ open-ready next dev
|
|
|
85
85
|
| `--noAi` | `false` | Disable opening AI on error |
|
|
86
86
|
| `--noOpen` | `false` | Disable opening browser when ready |
|
|
87
87
|
| `--pollDelay <ms>` | `1200` | How often to poll the log for ready/error signals |
|
|
88
|
+
| `--name <app>` | inferred | App name for the portless URL (`https://<app>.localhost`) |
|
|
89
|
+
| `--no-portless` | `false` | Don't route through portless even if it's installed |
|
|
90
|
+
| `--portless` | `false` | Use portless even without a TTY or when `CI` is set |
|
|
91
|
+
|
|
92
|
+
### Named `.localhost` URLs with portless
|
|
93
|
+
|
|
94
|
+
If `portless` is installed (globally, or in any `node_modules/.bin` up from the current directory), open-ready runs your command through it by default:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npm install -g portless # Node >= 24
|
|
98
|
+
|
|
99
|
+
open-ready next dev # -> https://<package-name>.localhost
|
|
100
|
+
open-ready next dev --name shop # -> https://shop.localhost
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The app name comes from `--name`, else the `package.json` name (scope dropped, e.g. `@acme/web` → `web`), else the directory name. open-ready starts the portless proxy in your terminal first, so the one-time sudo/CA-trust prompt happens there, then runs `portless run --name <app> <command>` and opens the URL portless prints once the dev server is ready.
|
|
104
|
+
|
|
105
|
+
Without a TTY or with `CI` set, portless is skipped unless you pass `--portless` (the proxy must already be running). If portless isn't installed, or its proxy can't start, open-ready falls back to the plain `http://localhost:<port>` behaviour.
|
|
88
106
|
|
|
89
107
|
### Disable AI on error
|
|
90
108
|
|
|
@@ -105,7 +123,7 @@ open-ready npm run dev --ai-base "https://chatgpt.com/?q="
|
|
|
105
123
|
- **Error signal** — lines matching `error`, `failed`, `exception`, `SyntaxError`, or `⨯`
|
|
106
124
|
- **Ready signal** — lines matching `ready - started server` or `Ready in Xms`
|
|
107
125
|
3. On **error**: extracts up to ~1000 chars of surrounding context and opens your AI assistant with a pre-filled prompt explaining the error and asking for a fix
|
|
108
|
-
4. On **ready**: waits for the port to be reachable, then opens the local URL in your default browser
|
|
126
|
+
4. On **ready**: waits for the port to be reachable, then opens the portless URL (when running through portless) or the local URL in your default browser
|
|
109
127
|
|
|
110
128
|
For Next.js projects, the log is written to `.next/port.log`; otherwise `open-when-ready.log` in the current directory.
|
|
111
129
|
|
package/open-when-ready.mjs
CHANGED
|
@@ -5,23 +5,33 @@
|
|
|
5
5
|
* output for a "ready" signal, and automatically opens the local URL in the
|
|
6
6
|
* browser. If an error is detected first, it opens an AI assistant with the
|
|
7
7
|
* error context for troubleshooting.
|
|
8
|
+
*
|
|
9
|
+
* When portless (npm: portless) is installed, the
|
|
10
|
+
* command is run through it by default so the app gets a stable named URL
|
|
11
|
+
* such as https://myapp.localhost, and that URL is the one opened.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import fsPromises from "fs/promises";
|
|
11
15
|
import fs from "fs";
|
|
12
16
|
import path from "path";
|
|
13
|
-
import { spawn } from "child_process";
|
|
17
|
+
import { spawn, spawnSync } from "child_process";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
14
19
|
import opener from "opener";
|
|
15
20
|
import minimist from "minimist";
|
|
16
21
|
import waitOn from "wait-on";
|
|
17
22
|
|
|
18
|
-
const
|
|
23
|
+
const rawArgs = process.argv.slice(2);
|
|
24
|
+
const argv = minimist(rawArgs, { string: ["name"], boolean: ["portless"] });
|
|
19
25
|
const cmdArgs = argv._;
|
|
20
26
|
const aiBase = argv["ai-base"] || "https://perplexity.ai?q=";
|
|
21
27
|
const noAi = argv.noAi || argv.noai || argv.ai === false;
|
|
22
28
|
const noOpen = argv.noOpen || argv.noopen || false;
|
|
23
29
|
const maxErrorContextChars = 1000;
|
|
24
30
|
const pollDelay = argv.pollDelay || 1200;
|
|
31
|
+
// minimist defaults booleans to false, so read the explicit flags from argv.
|
|
32
|
+
const noPortless =
|
|
33
|
+
argv.noPortless || argv.noportless || rawArgs.includes("--no-portless");
|
|
34
|
+
const forcePortless = rawArgs.includes("--portless");
|
|
25
35
|
|
|
26
36
|
const nextDir = path.join(".", ".next");
|
|
27
37
|
const logPath = fs.existsSync(nextDir)
|
|
@@ -72,6 +82,138 @@ function extractUrl(log) {
|
|
|
72
82
|
return null;
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Derives a portless-safe app name (a single DNS label) from a package name or
|
|
87
|
+
* directory name: drops an npm scope, lowercases, and collapses anything that
|
|
88
|
+
* isn't a letter, digit or hyphen.
|
|
89
|
+
* @param {string} raw - Package or directory name
|
|
90
|
+
* @returns {string} Sanitized name, or empty string if nothing usable remains
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeAppName(raw) {
|
|
93
|
+
return String(raw || "")
|
|
94
|
+
.replace(/^@[^/]+\//, "")
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
97
|
+
.replace(/-{2,}/g, "-")
|
|
98
|
+
.replace(/^-+|-+$/g, "")
|
|
99
|
+
.slice(0, 63)
|
|
100
|
+
.replace(/-+$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Infers the app name for portless: package.json "name" in `cwd`, falling back
|
|
105
|
+
* to the directory name, then "app".
|
|
106
|
+
* @param {string} [cwd=process.cwd()] - Directory to infer from
|
|
107
|
+
* @returns {string} App name
|
|
108
|
+
*/
|
|
109
|
+
function inferAppName(cwd = process.cwd()) {
|
|
110
|
+
try {
|
|
111
|
+
const pkg = JSON.parse(
|
|
112
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
|
|
113
|
+
);
|
|
114
|
+
const fromPkg = sanitizeAppName(pkg.name);
|
|
115
|
+
if (fromPkg) return fromPkg;
|
|
116
|
+
} catch {}
|
|
117
|
+
return sanitizeAppName(path.basename(path.resolve(cwd))) || "app";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Locates the portless binary: a local node_modules/.bin (walking up from
|
|
122
|
+
* `cwd`), then the PATH.
|
|
123
|
+
* @param {string} [cwd=process.cwd()] - Directory to start searching from
|
|
124
|
+
* @param {string} [envPath=process.env.PATH] - PATH to search
|
|
125
|
+
* @returns {string|null} Absolute path to the binary, or null if not installed
|
|
126
|
+
*/
|
|
127
|
+
function findPortless(cwd = process.cwd(), envPath = process.env.PATH || "") {
|
|
128
|
+
const names =
|
|
129
|
+
process.platform === "win32"
|
|
130
|
+
? ["portless.cmd", "portless.exe", "portless"]
|
|
131
|
+
: ["portless"];
|
|
132
|
+
const dirs = [];
|
|
133
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
134
|
+
dirs.push(path.join(dir, "node_modules", ".bin"));
|
|
135
|
+
if (path.dirname(dir) === dir) break;
|
|
136
|
+
}
|
|
137
|
+
dirs.push(...envPath.split(path.delimiter).filter(Boolean));
|
|
138
|
+
for (const dir of dirs) {
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
const candidate = path.join(dir, name);
|
|
141
|
+
try {
|
|
142
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Builds the shell command to spawn. With a portless binary, the command is
|
|
151
|
+
* wrapped as `portless run --name <app> <command>`; otherwise it runs as-is.
|
|
152
|
+
* @param {string[]} args - The wrapped command and its arguments
|
|
153
|
+
* @param {{portlessBin?: string|null, appName?: string}} [opts]
|
|
154
|
+
* @returns {string} Shell command line
|
|
155
|
+
*/
|
|
156
|
+
function buildCommand(args, { portlessBin = null, appName = "" } = {}) {
|
|
157
|
+
const command = args.join(" ");
|
|
158
|
+
if (!portlessBin) return command;
|
|
159
|
+
return `"${portlessBin}" run --name ${appName} ${command}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parses log output for the URL portless prints when it registers the app
|
|
164
|
+
* (a line like `-> https://myapp.localhost`).
|
|
165
|
+
* @param {string} log - Raw log output
|
|
166
|
+
* @returns {string|null} The portless URL, or null if not found
|
|
167
|
+
*/
|
|
168
|
+
function extractPortlessUrl(log) {
|
|
169
|
+
const clean = log.replace(/\x1b\[[0-9;]*m/g, "");
|
|
170
|
+
const match = clean.match(/^\s*->\s+(https?:\/\/\S+)/m);
|
|
171
|
+
return match ? match[1].replace(/\/+$/, "") : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Makes sure the portless proxy is running before the dev command is spawned.
|
|
176
|
+
* The wrapped command runs with piped, detached stdio, so portless cannot
|
|
177
|
+
* prompt for sudo (to bind 443 and trust its CA) from inside it. Starting the
|
|
178
|
+
* proxy here, attached to the terminal, lets that one-time prompt happen.
|
|
179
|
+
* @param {string} portlessBin - Path to the portless binary
|
|
180
|
+
* @returns {boolean} True if the proxy is (now) running
|
|
181
|
+
*/
|
|
182
|
+
function ensurePortlessProxy(portlessBin) {
|
|
183
|
+
const result = spawnSync(`"${portlessBin}" proxy start`, {
|
|
184
|
+
stdio: "inherit",
|
|
185
|
+
shell: true,
|
|
186
|
+
});
|
|
187
|
+
return !result.error && result.status === 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Decides whether to run through portless and prepares it.
|
|
192
|
+
* @returns {{portlessBin: string|null, appName: string}}
|
|
193
|
+
*/
|
|
194
|
+
function resolvePortless() {
|
|
195
|
+
const appName = sanitizeAppName(argv.name) || inferAppName();
|
|
196
|
+
if (noPortless) return { portlessBin: null, appName };
|
|
197
|
+
const interactive = !!process.stdin.isTTY && !process.env.CI;
|
|
198
|
+
if (!interactive && !forcePortless) return { portlessBin: null, appName };
|
|
199
|
+
const portlessBin = findPortless();
|
|
200
|
+
if (!portlessBin) {
|
|
201
|
+
if (forcePortless) {
|
|
202
|
+
console.error(
|
|
203
|
+
"[open-ready] --portless given but portless is not installed (npm install -g portless). Running without it.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return { portlessBin: null, appName };
|
|
207
|
+
}
|
|
208
|
+
if (interactive && !ensurePortlessProxy(portlessBin)) {
|
|
209
|
+
console.error(
|
|
210
|
+
"[open-ready] Could not start the portless proxy. Running without it.",
|
|
211
|
+
);
|
|
212
|
+
return { portlessBin: null, appName };
|
|
213
|
+
}
|
|
214
|
+
return { portlessBin, appName };
|
|
215
|
+
}
|
|
216
|
+
|
|
75
217
|
/**
|
|
76
218
|
* Main entry point. Spawns the dev server command, pipes its output to a log
|
|
77
219
|
* file, and polls the log for ready/error signals to open the browser or AI helper.
|
|
@@ -81,7 +223,8 @@ async function run() {
|
|
|
81
223
|
await fsPromises.rm(logPath, { force: true });
|
|
82
224
|
} catch {}
|
|
83
225
|
|
|
84
|
-
const
|
|
226
|
+
const { portlessBin, appName } = resolvePortless();
|
|
227
|
+
const proc = spawn(buildCommand(cmdArgs, { portlessBin, appName }), [], {
|
|
85
228
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
86
229
|
shell: true,
|
|
87
230
|
detached: true,
|
|
@@ -135,11 +278,16 @@ async function run() {
|
|
|
135
278
|
opened = true;
|
|
136
279
|
lastReadySeen = true;
|
|
137
280
|
const url = extractUrl(currentLog);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
281
|
+
const portlessUrl = portlessBin ? extractPortlessUrl(currentLog) : null;
|
|
282
|
+
if ((url || portlessUrl) && !noOpen) {
|
|
283
|
+
// Wait on the app's own port; the portless URL sits behind a proxy
|
|
284
|
+
// with a locally-trusted CA that Node itself may not trust.
|
|
285
|
+
if (url) {
|
|
286
|
+
try {
|
|
287
|
+
await waitOn(url, { timeout: 10000, http: true });
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
opener(portlessUrl || url);
|
|
143
291
|
}
|
|
144
292
|
clearInterval(poll);
|
|
145
293
|
return;
|
|
@@ -154,4 +302,25 @@ async function run() {
|
|
|
154
302
|
});
|
|
155
303
|
}
|
|
156
304
|
|
|
157
|
-
|
|
305
|
+
export {
|
|
306
|
+
sanitizeAppName,
|
|
307
|
+
inferAppName,
|
|
308
|
+
findPortless,
|
|
309
|
+
buildCommand,
|
|
310
|
+
extractPortlessUrl,
|
|
311
|
+
extractUrl,
|
|
312
|
+
getErrorContext,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
function isMain() {
|
|
316
|
+
try {
|
|
317
|
+
return (
|
|
318
|
+
fs.realpathSync(process.argv[1]) ===
|
|
319
|
+
fs.realpathSync(fileURLToPath(import.meta.url))
|
|
320
|
+
);
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (isMain()) run().catch(console.error);
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
buildCommand,
|
|
7
|
+
extractPortlessUrl,
|
|
8
|
+
extractUrl,
|
|
9
|
+
findPortless,
|
|
10
|
+
inferAppName,
|
|
11
|
+
sanitizeAppName,
|
|
12
|
+
} from "../open-when-ready.mjs";
|
|
13
|
+
|
|
14
|
+
let tmp;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "open-ready-"));
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("sanitizeAppName", () => {
|
|
23
|
+
it("drops the npm scope and lowercases", () => {
|
|
24
|
+
expect(sanitizeAppName("@MyOrg/Web-App")).toBe("web-app");
|
|
25
|
+
});
|
|
26
|
+
it("collapses invalid characters into single hyphens", () => {
|
|
27
|
+
expect(sanitizeAppName("my_cool..app!")).toBe("my-cool-app");
|
|
28
|
+
});
|
|
29
|
+
it("returns empty string for unusable input", () => {
|
|
30
|
+
expect(sanitizeAppName("___")).toBe("");
|
|
31
|
+
expect(sanitizeAppName(undefined)).toBe("");
|
|
32
|
+
});
|
|
33
|
+
it("caps the length at one DNS label", () => {
|
|
34
|
+
expect(sanitizeAppName("a".repeat(100))).toHaveLength(63);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("inferAppName", () => {
|
|
39
|
+
it("uses the package.json name", () => {
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(tmp, "package.json"),
|
|
42
|
+
JSON.stringify({ name: "@acme/dashboard" }),
|
|
43
|
+
);
|
|
44
|
+
expect(inferAppName(tmp)).toBe("dashboard");
|
|
45
|
+
});
|
|
46
|
+
it("falls back to the directory name", () => {
|
|
47
|
+
const dir = path.join(tmp, "My Project");
|
|
48
|
+
fs.mkdirSync(dir);
|
|
49
|
+
expect(inferAppName(dir)).toBe("my-project");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("findPortless", () => {
|
|
54
|
+
it("finds a local node_modules/.bin binary walking up", () => {
|
|
55
|
+
const bin = path.join(tmp, "node_modules", ".bin");
|
|
56
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(bin, "portless"), "");
|
|
58
|
+
const nested = path.join(tmp, "apps", "web");
|
|
59
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
60
|
+
expect(findPortless(nested, "")).toBe(path.join(bin, "portless"));
|
|
61
|
+
});
|
|
62
|
+
it("falls back to PATH", () => {
|
|
63
|
+
const pathDir = path.join(tmp, "bin");
|
|
64
|
+
fs.mkdirSync(pathDir);
|
|
65
|
+
fs.writeFileSync(path.join(pathDir, "portless"), "");
|
|
66
|
+
const cwd = path.join(tmp, "app");
|
|
67
|
+
fs.mkdirSync(cwd);
|
|
68
|
+
expect(findPortless(cwd, pathDir)).toBe(path.join(pathDir, "portless"));
|
|
69
|
+
});
|
|
70
|
+
it("returns null when not installed", () => {
|
|
71
|
+
expect(findPortless(tmp, "")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("buildCommand", () => {
|
|
76
|
+
it("runs the command as-is without portless", () => {
|
|
77
|
+
expect(buildCommand(["next", "dev"])).toBe("next dev");
|
|
78
|
+
});
|
|
79
|
+
it("wraps the command with portless run --name", () => {
|
|
80
|
+
expect(
|
|
81
|
+
buildCommand(["next", "dev"], {
|
|
82
|
+
portlessBin: "/usr/bin/portless",
|
|
83
|
+
appName: "myapp",
|
|
84
|
+
}),
|
|
85
|
+
).toBe('"/usr/bin/portless" run --name myapp next dev');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("extractPortlessUrl", () => {
|
|
90
|
+
it("reads the URL portless prints", () => {
|
|
91
|
+
const log = "Starting proxy...\n\n -> https://myapp.localhost\n\nRunning: PORT=4123 next dev\n";
|
|
92
|
+
expect(extractPortlessUrl(log)).toBe("https://myapp.localhost");
|
|
93
|
+
});
|
|
94
|
+
it("ignores ANSI color codes", () => {
|
|
95
|
+
const log = "\x1b[36m\x1b[1m\n -> https://fix-ui.myapp.localhost:1355\n\x1b[22m\x1b[39m";
|
|
96
|
+
expect(extractPortlessUrl(log)).toBe("https://fix-ui.myapp.localhost:1355");
|
|
97
|
+
});
|
|
98
|
+
it("returns null without a portless line", () => {
|
|
99
|
+
expect(extractPortlessUrl(" - Local: http://localhost:3000")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
it("coexists with the framework's own localhost URL", () => {
|
|
102
|
+
const log = " -> https://myapp.localhost\n - Local: http://localhost:4123\n ✓ Ready in 900ms";
|
|
103
|
+
expect(extractPortlessUrl(log)).toBe("https://myapp.localhost");
|
|
104
|
+
expect(extractUrl(log)).toBe("http://localhost:4123");
|
|
105
|
+
});
|
|
106
|
+
});
|