Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/downloads/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -eu
# No-op on POSIX sh/dash, active on bash/zsh.
(set -o pipefail) 2>/dev/null && set -o pipefail || true

VERSION="${UP_VERSION:-v0.1.0-beta.3}"
VERSION="${UP_VERSION:-v0.1.0-beta.4}"
INSTALL_DIR="${UP_INSTALL_DIR:-${HOME:?HOME is required}/.local/bin}"
DOWNLOAD_ROOT="${UP_DOWNLOAD_ROOT:-https://cdn.upcli.dev/releases/${VERSION}}"

Expand Down Expand Up @@ -71,6 +71,6 @@ chmod 755 "$INSTALL_DIR/up"

printf 'Installed up to %s/up\n' "$INSTALL_DIR"
case ":${PATH:-}:" in
*":$INSTALL_DIR:"*) ;;
*":$INSTALL_DIR:"*) printf 'Run: cd <your-project> && up .\n' ;;
*) printf 'Add %s to PATH, then run: up .\n' "$INSTALL_DIR" ;;
esac
1 change: 1 addition & 0 deletions apps/downloads/public/releases/v0.1.0-beta.4/checksums.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

447 changes: 447 additions & 0 deletions apps/downloads/public/releases/v0.1.0-beta.4/up.mjs

Large diffs are not rendered by default.

56 changes: 50 additions & 6 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ async function confirmSharedCommands(
await ui.note(
`${pc.bold("Install:")} ${displayCommand(profile.installCommand)}\n` +
`${pc.bold("Dev:")} ${displayCommand(profile.devCommand)}` +
(hasEnvFile ? `\n! Local env values are injected for this run.` : "") +
(hasEnvFile ? `\nLocal env values are injected for this run.` : "") +
(includeSensitiveConfig ? `\n! Sensitive config may persist in this sandbox.` : ""),
"Review up.config.json commands",
);
Expand Down Expand Up @@ -232,13 +232,17 @@ async function resolveEnvFile(
explicit?: string,
): Promise<EnvFile | undefined> {
if (explicit) {
return await readEnvFile(dir, explicit).catch((err) => {
const file = await readEnvFile(dir, explicit).catch((err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
p.log.error(`Env file not found: ${pc.bold(sanitizeTerminalText(explicit))}`);
process.exit(1);
}
return fail(err);
});
// The flag sticks: opting in once should not require remembering the
// flag on every later run (it also overrides an earlier "no").
await rememberEnvFilePreference(dir, file.rel);
return file;
}

const preference = await readEnvFilePreference(dir);
Expand All @@ -257,7 +261,9 @@ async function resolveEnvFile(

const accepted = await p.confirm({
message: `Use ${candidate} for this dev server? Injected as env vars, never uploaded.`,
initialValue: false,
// An app with a local dotenv almost always needs it to run; default to
// injecting so the common path is one Enter and the app just works.
initialValue: true,
});
if (p.isCancel(accepted)) {
await ui.outro(pc.dim("Configuration cancelled."));
Expand Down Expand Up @@ -410,7 +416,9 @@ async function runDev(
};
if (envFile) {
await ui.info(`Using ${pc.bold(envFile.rel)} for this dev server.`);
} else if (skippedEnv) {
} else if (skippedEnv && (await readEnvFilePreference(dir)) === undefined) {
// Hint only while no decision exists; a recorded "no" stays quiet
// instead of nagging on every run.
await ui.info(`Pass ${pc.bold("--env-file")} to use a local env file.`);
}
if (includeSensitiveConfig) {
Expand Down Expand Up @@ -863,7 +871,10 @@ async function runDev(
await stopDevProcess(devProc);
await spin.fail("Dev server did not become ready");
process.stdout.write(sanitizeTerminalText(recent.text(), { preserveNewlines: true }));
throw new Error("Timed out waiting for the dev server. See logs above.");
throw new Error(
`Timed out waiting for the dev server on port ${devPort}. ` +
"If your app listens on a different port, re-run with --port. See logs above.",
);
}
return devProc;
};
Expand Down Expand Up @@ -1022,6 +1033,23 @@ async function runStop(input: string) {
try {
await s.stop(`Snapshot saved ${pc.dim(await stopAndVerify(dir))}`);
} catch (err) {
const notLoggedIn = err instanceof Error && err.message === "Not logged in.";
if (notLoggedIn || isAuthError(err)) {
await s.stop("Not signed in");
await signInAndExit(ui);
}
if (isNotFoundError(err)) {
await s.stop("No sandbox for this project");
await ui.info(
pc.dim(
"Nothing is running for this directory; it may have expired, or `up .` ran " +
`from a different directory. Check ${pc.cyan("up ls")}.`,
),
);
await ui.outro(pc.dim("Nothing to stop."));
process.exitCode = 1;
return;
}
await s.fail("Could not stop");
return fail(err);
}
Expand Down Expand Up @@ -1109,9 +1137,25 @@ function isAuthError(err: unknown): boolean {
return /\b40[13]\b|forbidden|not authorized|unauthorized|invalidtoken/i.test(message);
}

/**
* True when the API says the sandbox (or its snapshot) no longer exists.
* Status-only on purpose: matching "not found" in messages would also match
* DNS failures (ENOTFOUND) and report "nothing to stop" while the sandbox is
* actually still running. Unknown errors must surface, not soothe.
*/
function isNotFoundError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const status = (err as { response?: { status?: number } }).response?.status;
return status === 404 || status === 410;
}

/** Calm sign-in nudge for any credential issue: no scary failure or raw 403 dump. */
async function signInAndExit(ui: TerminalFlow): Promise<never> {
await ui.note(`Run ${pc.cyan("vercel login")}, then try again.`, "Sign in to continue");
await ui.note(
`Run ${pc.cyan("vercel login")}, then try again.\n` +
pc.dim("No Vercel CLI? Install it with npm i -g vercel, or set VERCEL_TOKEN."),
"Sign in to continue",
);
await ui.outro(pc.dim("Aborted."));
process.exit(1);
}
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,28 @@ function installCommandText(pm: PackageManager, hasLockfile: boolean): string {
return [install.cmd, ...install.args].join(" ");
}

/**
* The port a dev script binds explicitly, so the supervisor waits on the port
* the server actually uses instead of the framework default. Recognizes the
* common forms `--port 4000`, `--port=4000`, `-p 4000` (Next.js, Storybook)
* and a leading `PORT=4000` env assignment. Without this, a hardcoded port
* silently mismatches the default and boot times out.
*/
export function portFromDevScript(script: string): number | undefined {
const patterns = [
/(?<![\w-])--port[=\s]+(\d{1,5})(?![\d.])/,
/(?<![\w-])-p[=\s]+(\d{1,5})(?![\d.])/,
/(?:^|\s)PORT=(\d{1,5})(?![\d.])/,
];
for (const re of patterns) {
const match = re.exec(script);
if (!match?.[1]) continue;
const port = Number(match[1]);
if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
}
return undefined;
}

function scriptCommand(pm: PackageManager): string {
// PORT is always injected as an env var at spawn time; modern frameworks
// (Next.js, Vite, SvelteKit, Nuxt, …) read it automatically. Passing
Expand All @@ -274,9 +296,10 @@ async function nodeProfile(
name: string,
devCommand: string,
dependencyFiles: string[],
portOverride?: number,
): Promise<Detected> {
const install = installCommandText(packageManager, await hasLockfileFor(dir, packageManager));
const suggestedPort = portFor(slug);
const suggestedPort = portOverride ?? portFor(slug);
return {
slug,
name,
Expand Down Expand Up @@ -317,6 +340,8 @@ export async function detect(dir: string): Promise<Detected> {
if (pkg?.scripts?.dev) {
const dependencyFiles = await nodeDependencyFiles(dir, pkg);
const nodeSlug = detectedPython ? null : slug;
const scriptPort =
typeof pkg.scripts.dev === "string" ? portFromDevScript(pkg.scripts.dev) : undefined;
return nodeProfile(
dir,
packageManager,
Expand All @@ -325,6 +350,7 @@ export async function detect(dir: string): Promise<Detected> {
detectedPython ? "Node.js" : (record?.name ?? "Node.js"),
scriptCommand(packageManager),
dependencyFiles,
scriptPort,
);
}

Expand Down
42 changes: 42 additions & 0 deletions packages/cli/test/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
detectPackageManager,
installCommand,
portFlagForSlug,
portFromDevScript,
shellInvocation,
} from "../src/detect.js";

Expand Down Expand Up @@ -143,6 +144,21 @@ describe("automatic profiles", () => {
expect(d.profile?.dependencyFiles).toContain("pnpm-workspace.yaml");
});

it.each([
["next dev -p 4000", 4000],
["next dev --port 4100", 4100],
["next dev --port=4200", 4200],
["PORT=4300 node server.js", 4300],
])("honors a hardcoded port in the dev script: %s", async (script, port) => {
await write(
"package.json",
JSON.stringify({ dependencies: { next: "15.0.0" }, scripts: { dev: script } }),
);
const d = await detect(dir);
expect(d.profile?.port).toBe(port);
expect(d.suggestedPort).toBe(port);
});

it("passes a reliable port variable to Vite scripts", async () => {
await write(
"package.json",
Expand Down Expand Up @@ -235,6 +251,32 @@ describe("commands", () => {
});
});

describe("portFromDevScript", () => {
it("reads explicit ports from common dev-script forms", () => {
expect(portFromDevScript("next dev -p 4000")).toBe(4000);
expect(portFromDevScript("next dev --port 8080")).toBe(8080);
expect(portFromDevScript("vite --port=5000")).toBe(5000);
expect(portFromDevScript("storybook dev -p 6006")).toBe(6006);
expect(portFromDevScript("PORT=4000 node server.js")).toBe(4000);
expect(portFromDevScript("cross-env PORT=4000 remix dev")).toBe(4000);
});

it("returns undefined when no explicit port is present", () => {
expect(portFromDevScript("next dev")).toBeUndefined();
expect(portFromDevScript("vite")).toBeUndefined();
expect(portFromDevScript("node server.js")).toBeUndefined();
});

it("does not misread similar flags or out-of-range values as a port", () => {
// `--prefix`/`-prod` contain "-p" but are not the port flag.
expect(portFromDevScript("eleventy --prefix 5 --serve")).toBeUndefined();
expect(portFromDevScript("tool -prod")).toBeUndefined();
// Reject non-port numbers so a stray flag never overrides the default.
expect(portFromDevScript("next dev --port 99999")).toBeUndefined();
expect(portFromDevScript("next dev --port 0")).toBeUndefined();
});
});

describe("framework port flags", () => {
// Drift guard: these assert the flag is read from @vercel/frameworks' own dev
// templates, so a renamed slug or changed flag fails here instead of silently
Expand Down
Loading