Skip to content

Commit 95ae1ae

Browse files
authored
cli: show team and project after sandbox creation (#18)
# Problem When creating a new sandbox via the CLI (`sandbox create`), the output only shows the sandbox ID but doesn't indicate which team or project the sandbox belongs to. This leaves users confused about where their sandbox was created. # Solution Display team and project info in a framed format after sandbox creation: ``` ✅ Sandbox sbx_abc123 created. │ team: team_slug ╰ project: my-project ``` With ports: ``` ✅ Sandbox sbx_abc123 created. │ team: team_slug │ project: my-project │ ports: │ • 3000 -> https://... ╰ • 8080 -> https://... ``` Changes: - Extract `owner` and `project` slugs from OIDC token claims (with backwards compatibility for tokens without these fields) - Thread slug values through the scope parser to make them available in commands - Integrate port listing into the same framed view with consistent indentation - Remove redundant sandbox ID from interactive shell spinner and command prompt Supersedes #15 and therefore also resolves EC-4722. Resolves EC-4761
1 parent 44726be commit 95ae1ae

9 files changed

Lines changed: 101 additions & 34 deletions

File tree

.changeset/dull-icons-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sandbox": patch
3+
---
4+
5+
Display team and project info in a framed format after sandbox creation

packages/sandbox/src/args/scope.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const scope: ArgParser<{
5959
token: string;
6060
project: string;
6161
team: string;
62+
projectSlug?: string;
63+
teamSlug?: string;
6264
}> &
6365
ProvidesHelp = {
6466
register(ctx) {
@@ -91,6 +93,9 @@ export const scope: ArgParser<{
9193
return teamId;
9294
}
9395

96+
let projectSlug: string | undefined;
97+
let teamSlug: string | undefined;
98+
9499
if (
95100
typeof projectId.value === "undefined" ||
96101
typeof teamId.value === "undefined"
@@ -102,6 +107,8 @@ export const scope: ArgParser<{
102107
});
103108
projectId.value ??= scope.projectId;
104109
teamId.value ??= scope.ownerId;
110+
projectSlug = scope.projectSlug;
111+
teamSlug = scope.ownerSlug;
105112
} catch (err) {
106113
return {
107114
_tag: "error",
@@ -127,6 +134,8 @@ export const scope: ArgParser<{
127134
token: t.value,
128135
project: projectId.value,
129136
team: teamId.value,
137+
projectSlug,
138+
teamSlug,
130139
},
131140
};
132141
},

packages/sandbox/src/commands/create.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,6 @@ export const create = cmd.command({
7373
});
7474
spinner?.stop();
7575

76-
if (!silent) {
77-
process.stderr.write("✅ Sandbox ");
78-
process.stdout.write(chalk.cyan(sandbox.sandboxId));
79-
process.stderr.write(" created.\n");
80-
}
81-
8276
if (!sandbox.interactivePort) {
8377
throw new Error(
8478
[
@@ -93,11 +87,35 @@ export const create = cmd.command({
9387
(x) => x.port !== sandbox.interactivePort,
9488
);
9589

96-
if (routes.length) {
97-
console.log();
98-
console.log(chalk.bold("Mapped ports:"));
99-
for (const route of routes) {
100-
console.log(` • ${route.port} -> ${route.url}`);
90+
if (!silent) {
91+
const teamDisplay = scope.teamSlug ?? scope.team;
92+
const projectDisplay = scope.projectSlug ?? scope.project;
93+
const hasPorts = routes.length > 0;
94+
95+
process.stderr.write("✅ Sandbox ");
96+
process.stdout.write(chalk.cyan(sandbox.sandboxId));
97+
process.stderr.write(" created.\n");
98+
process.stderr.write(
99+
chalk.dim(" │ ") + "team: " + chalk.cyan(teamDisplay) + "\n",
100+
);
101+
102+
if (hasPorts) {
103+
process.stderr.write(
104+
chalk.dim(" │ ") + "project: " + chalk.cyan(projectDisplay) + "\n",
105+
);
106+
process.stderr.write(chalk.dim(" │ ") + "ports:\n");
107+
for (let i = 0; i < routes.length; i++) {
108+
const route = routes[i];
109+
const isLast = i === routes.length - 1;
110+
const prefix = isLast ? chalk.dim(" ╰ ") : chalk.dim(" │ ");
111+
process.stderr.write(
112+
prefix + "• " + route.port + " -> " + chalk.cyan(route.url) + "\n",
113+
);
114+
}
115+
} else {
116+
process.stderr.write(
117+
chalk.dim(" ╰ ") + "project: " + chalk.cyan(projectDisplay) + "\n",
118+
);
101119
}
102120
}
103121

packages/sandbox/src/commands/exec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const exec = cmd.command({
114114
}
115115

116116
if (!interactive) {
117-
console.error(printCommand(sandbox.sandboxId, command, args));
117+
console.error(printCommand(command, args));
118118
const result = await sandbox.runCommand({
119119
cmd: command,
120120
args,

packages/sandbox/src/commands/run.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { omit } from "../util/omit";
55
import ora from "ora";
66

77
const args = {
8-
...omit(Create.args, "silent"),
8+
...Create.args,
99
...omit(Exec.args, "sandbox"),
1010
removeAfterUse: cmd.flag({
1111
long: "rm",
@@ -18,9 +18,7 @@ export const run = cmd.command({
1818
description: "Create and run a command in a sandbox",
1919
args,
2020
async handler({ removeAfterUse, ...rest }) {
21-
const spinner = ora("Creating sandbox...").start();
22-
const sandbox = await Create.create.handler({ ...rest, silent: true });
23-
spinner.stop();
21+
const sandbox = await Create.create.handler({ ...rest });
2422
try {
2523
await Exec.exec.handler({ ...rest, sandbox });
2624
} finally {

packages/sandbox/src/interactive-shell/interactive-shell.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,8 @@ export async function startInteractiveShell(options: {
190190
process.once("beforeExit", cleanup);
191191
using _cleanup = defer(cleanup);
192192

193-
const spinner = { ...spinners.dots };
194-
spinner.frames = spinner.frames.map(
195-
(frame) => chalk.gray.dim(`${options.sandbox.sandboxId} `) + frame,
196-
);
197193
using progress = acquireRelease(
198-
() => ora({ discardStdin: false, spinner }).start(),
194+
() => ora({ discardStdin: false }).start(),
199195
(s) => s.clear(),
200196
);
201197

@@ -231,11 +227,7 @@ export async function startInteractiveShell(options: {
231227
skipExtendingTimeout: options.skipExtendingTimeout,
232228
printCommand: () =>
233229
console.error(
234-
printCommand(
235-
options.sandbox.sandboxId,
236-
options.execution[0],
237-
options.execution.slice(1),
238-
),
230+
printCommand(options.execution[0], options.execution.slice(1)),
239231
),
240232
}),
241233
]).catch(waitForProcess.ignoreInterruptions);

packages/sandbox/src/util/infer-scope.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@ import * as Auth from "@vercel/sandbox/dist/auth/index.js";
55

66
const debug = createDebugger("sandbox:scope");
77

8+
export type InferredScope = {
9+
projectId: string;
10+
ownerId: string;
11+
projectSlug?: string;
12+
ownerSlug?: string;
13+
};
14+
815
export async function inferScope({
916
token,
1017
team,
1118
}: {
1219
token: string;
1320
team?: string;
14-
}): Promise<{ projectId: string; ownerId: string }> {
21+
}): Promise<InferredScope> {
1522
// If the token is a JWT (OIDC token), extract scope from its claims
1623
const jwt = z.jwt().safeParse(token);
1724
if (jwt.success) {
@@ -40,9 +47,16 @@ const JwtSchema = z
4047
.object({
4148
project_id: z.string(),
4249
owner_id: z.string(),
50+
project: z.string().optional(),
51+
owner: z.string().optional(),
4352
})
4453
.transform((data) => {
45-
return { projectId: data.project_id, ownerId: data.owner_id };
54+
return {
55+
projectId: data.project_id,
56+
ownerId: data.owner_id,
57+
projectSlug: data.project,
58+
ownerSlug: data.owner,
59+
};
4660
});
4761

4862
async function inferFromJwt(jwt: string) {
@@ -55,5 +69,11 @@ async function inferFromToken(token: string, requestedTeam?: string) {
5569
token,
5670
teamId: requestedTeam,
5771
});
58-
return { ownerId: teamId, projectId };
72+
// Auth.inferScope returns team slug (not ID) and project name
73+
return {
74+
ownerId: teamId,
75+
projectId,
76+
ownerSlug: teamId,
77+
projectSlug: projectId,
78+
};
5979
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import chalk from "chalk";
22

3-
export function printCommand(sandbox: string, command: string, args: string[]) {
4-
return chalk.gray(chalk.dim(`${sandbox} $ `) + [command, ...args].join(" "));
3+
export function printCommand(command: string, args: string[]) {
4+
return chalk.gray(chalk.dim("$ ") + [command, ...args].join(" "));
55
}

packages/sandbox/test/args/scope.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@ describe("scope", () => {
4141
team: string;
4242
project: string;
4343
token: string;
44+
projectSlug?: string;
45+
teamSlug?: string;
4446
}>();
4547
expect(result.scope).toEqual({
4648
team: "team",
4749
project: "proj",
4850
token: "123",
51+
projectSlug: undefined,
52+
teamSlug: undefined,
4953
});
5054
});
5155

@@ -56,11 +60,15 @@ describe("scope", () => {
5660
team: string;
5761
project: string;
5862
token: string;
63+
projectSlug?: string;
64+
teamSlug?: string;
5965
}>();
6066
expect(result.scope).toEqual({
6167
team: "team",
6268
project: "proj",
6369
token: "from-env",
70+
projectSlug: undefined,
71+
teamSlug: undefined,
6472
});
6573
});
6674

@@ -70,23 +78,40 @@ describe("scope", () => {
7078
// Create two different OIDC tokens with different project/team claims.
7179
// Note: We use a fake signature because inferScope only parses the JWT
7280
// payload for claims extraction - it doesn't validate the signature.
73-
const createOidcToken = (projectId: string, ownerId: string) => {
81+
const createOidcToken = (
82+
projectId: string,
83+
ownerId: string,
84+
project: string,
85+
owner: string,
86+
) => {
7487
const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString(
7588
"base64url",
7689
);
7790
const payload = Buffer.from(
7891
JSON.stringify({
7992
project_id: projectId,
8093
owner_id: ownerId,
94+
project,
95+
owner,
8196
exp: Math.floor(Date.now() / 1000) + 3600,
8297
}),
8398
).toString("base64url");
8499
const signature = "fake-signature";
85100
return `${header}.${payload}.${signature}`;
86101
};
87102

88-
const oldToken = createOidcToken("old-project", "old-team");
89-
const newToken = createOidcToken("new-project", "new-team");
103+
const oldToken = createOidcToken(
104+
"old-project",
105+
"old-team",
106+
"old-project-slug",
107+
"old-team-slug",
108+
);
109+
const newToken = createOidcToken(
110+
"new-project",
111+
"new-team",
112+
"new-project-slug",
113+
"new-team-slug",
114+
);
90115

91116
// Env var has OLD token
92117
process.env.VERCEL_OIDC_TOKEN = oldToken;

0 commit comments

Comments
 (0)