@lizard-build/cli 4.0.36 → 4.0.38
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 +14 -12
- package/dist/commands/add.js +2 -2
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/git.js +1 -1
- package/dist/commands/git.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +18 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +5 -2
- package/dist/commands/login.js +17 -10
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/restart.js +4 -2
- package/dist/commands/restart.js.map +1 -1
- package/dist/commands/sandbox.js +15 -2
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/ssh.js +1 -1
- package/dist/commands/ssh.js.map +1 -1
- package/dist/commands/up.js +2 -2
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/upgrade.js +26 -12
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/lib/config.js +7 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/default-project.d.ts +17 -0
- package/dist/lib/default-project.js +31 -0
- package/dist/lib/default-project.js.map +1 -0
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +9 -1
- package/dist/lib/updater.js +15 -1
- package/dist/lib/updater.js.map +1 -1
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +63 -45
- package/src/commands/add.ts +2 -2
- package/src/commands/git.ts +1 -1
- package/src/commands/init.ts +21 -0
- package/src/commands/login.ts +18 -10
- package/src/commands/restart.ts +5 -2
- package/src/commands/sandbox.ts +19 -3
- package/src/commands/ssh.ts +1 -1
- package/src/commands/up.ts +2 -2
- package/src/commands/upgrade.ts +37 -11
- package/src/lib/config.ts +7 -0
- package/src/lib/default-project.ts +47 -0
- package/src/lib/updater.ts +17 -1
- package/test/unit/default-project.test.ts +104 -0
- package/test/unit/login.test.ts +7 -10
- package/test/unit/sandbox.test.ts +37 -1
package/src/commands/git.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function registerGit(program: Command) {
|
|
|
81
81
|
|
|
82
82
|
if (newStatus.installed) {
|
|
83
83
|
success("GitHub connected! You can now deploy private repositories.");
|
|
84
|
-
info(chalk.dim(" Run `lizard
|
|
84
|
+
info(chalk.dim(" Run `lizard add -r owner/repo` to deploy a repository, or `lizard redeploy` if a service already tracks it."));
|
|
85
85
|
} else {
|
|
86
86
|
fail("GitHub App not detected. Please try again or connect via the dashboard.");
|
|
87
87
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -44,6 +44,8 @@ export async function ensureLinked(opts: {
|
|
|
44
44
|
workspaceFlag?: string;
|
|
45
45
|
force?: boolean;
|
|
46
46
|
relinkPrompt?: boolean;
|
|
47
|
+
/** Link an unlinked folder to the account's default project instead of asking (`lizard up`). */
|
|
48
|
+
useDefault?: boolean;
|
|
47
49
|
} = {}): Promise<ProjectLink> {
|
|
48
50
|
const existing = getProjectLink();
|
|
49
51
|
|
|
@@ -62,6 +64,25 @@ export async function ensureLinked(opts: {
|
|
|
62
64
|
if (p.isCancel(proceed) || !proceed) return existing;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// `lizard up` in a folder that was never linked: link it to the default
|
|
68
|
+
// project rather than ask, so a first deploy needs no `lizard init`. The link
|
|
69
|
+
// is what keeps the next `up` from this folder on the same service.
|
|
70
|
+
if (!existing && opts.useDefault && !opts.projectName && !opts.workspaceFlag) {
|
|
71
|
+
const { defaultProject } = await import("../lib/default-project.js");
|
|
72
|
+
const fallback = await defaultProject();
|
|
73
|
+
if (fallback) {
|
|
74
|
+
const link: ProjectLink = {
|
|
75
|
+
projectId: fallback.id,
|
|
76
|
+
projectName: fallback.name,
|
|
77
|
+
workspaceId: fallback.workspaceId,
|
|
78
|
+
workspaceName: fallback.workspaceName,
|
|
79
|
+
};
|
|
80
|
+
setProjectLink(link);
|
|
81
|
+
info(chalk.dim(`Linked this folder to ${fallback.name}, your default project. Run \`lizard init --force\` to pick another.`));
|
|
82
|
+
return link;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
// 1. Workspace
|
|
66
87
|
const workspaces = await fetchWorkspaces();
|
|
67
88
|
const workspace = await pickWorkspace({
|
package/src/commands/login.ts
CHANGED
|
@@ -97,29 +97,37 @@ export function accountInstruction(state: AccountState, sub: Subscription | null
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Commands an agent can run next. Sandboxes first. Neither needs `lizard init`:
|
|
102
|
+
* an unlinked folder uses the account's default project.
|
|
103
|
+
*/
|
|
101
104
|
export function nextSteps(linked: boolean): Array<{ command: string; why: string }> {
|
|
102
105
|
return [
|
|
103
106
|
{
|
|
104
107
|
command: "lizard skills get core",
|
|
105
108
|
why: "The guide to every command, matched to this CLI version. Read it before running others.",
|
|
106
109
|
},
|
|
107
|
-
...(linked
|
|
108
|
-
? []
|
|
109
|
-
: [{ command: "lizard init", why: "Create a project and link the current folder to it. Sandboxes and deploys both need one." }]),
|
|
110
110
|
{
|
|
111
111
|
command: "lizard sandbox create",
|
|
112
|
-
why:
|
|
112
|
+
why:
|
|
113
|
+
(linked
|
|
114
|
+
? "Start a sandbox in the linked project"
|
|
115
|
+
: "Start a sandbox in the account's default project (--project picks another)") +
|
|
116
|
+
", then run commands in it with `lizard sandbox exec <id> -- <cmd>`.",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
command: "lizard up",
|
|
120
|
+
why: linked
|
|
121
|
+
? "Or deploy the linked folder."
|
|
122
|
+
: "Or deploy this folder. It is linked to the default project first; `lizard init` picks another.",
|
|
113
123
|
},
|
|
114
|
-
{ command: "lizard up", why: "Or deploy the linked folder." },
|
|
115
124
|
];
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
/** The same steps for a person, as aligned lines for the closing note. */
|
|
119
|
-
export function nextStepsNote(
|
|
128
|
+
export function nextStepsNote(): string {
|
|
120
129
|
const rows: Array<[string, string]> = [
|
|
121
|
-
|
|
122
|
-
["lizard sandbox create", linked ? "Start a sandbox in this project" : "Start a sandbox in it"],
|
|
130
|
+
["lizard sandbox create", "Start a sandbox"],
|
|
123
131
|
["lizard up", "Or deploy this folder"],
|
|
124
132
|
];
|
|
125
133
|
const width = Math.max(...rows.map(([cmd]) => cmd.length)) + 3;
|
|
@@ -221,7 +229,7 @@ async function loginInteractive(flag?: AuthProvider): Promise<void> {
|
|
|
221
229
|
|
|
222
230
|
await offerPlan(canAsk);
|
|
223
231
|
|
|
224
|
-
p.note(nextStepsNote(
|
|
232
|
+
p.note(nextStepsNote(), "Next");
|
|
225
233
|
p.outro("You're all set");
|
|
226
234
|
}
|
|
227
235
|
|
package/src/commands/restart.ts
CHANGED
|
@@ -57,6 +57,9 @@ export function registerRestart(program: Command) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// `ps` takes no service argument: it lists every service in the project.
|
|
61
|
+
const psHint = `lizard ps${opts.project ? ` --project ${opts.project}` : ""}`;
|
|
62
|
+
|
|
60
63
|
const timeoutSeconds = parseInt(opts.timeout, 10);
|
|
61
64
|
if (!(timeoutSeconds > 0)) {
|
|
62
65
|
fail(`--timeout must be a positive number of seconds, got ${JSON.stringify(opts.timeout)}`, 1, "INVALID_ARGUMENT");
|
|
@@ -104,7 +107,7 @@ export function registerRestart(program: Command) {
|
|
|
104
107
|
printJSON({ id, status: "restarting" });
|
|
105
108
|
} else {
|
|
106
109
|
success("Restart started");
|
|
107
|
-
info(chalk.dim(` Check status:
|
|
110
|
+
info(chalk.dim(` Check status: ${psHint}`));
|
|
108
111
|
}
|
|
109
112
|
return;
|
|
110
113
|
}
|
|
@@ -138,7 +141,7 @@ export function registerRestart(program: Command) {
|
|
|
138
141
|
} else if (finalStatus) {
|
|
139
142
|
error("Restart failed");
|
|
140
143
|
} else {
|
|
141
|
-
info(chalk.dim(
|
|
144
|
+
info(chalk.dim(`Still restarting — check status with: ${psHint}`));
|
|
142
145
|
}
|
|
143
146
|
});
|
|
144
147
|
}
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -9,7 +9,7 @@ import open from "open";
|
|
|
9
9
|
import { api, APIError, getBaseURL, getRawBytes, apiErrorFrom, clientHeaders, getRequestToken, withQuery, withScope, type ResourceScope } from "../lib/api.js";
|
|
10
10
|
import { getToken } from "../lib/auth.js";
|
|
11
11
|
import { resolveProjectScope } from "../lib/resolve.js";
|
|
12
|
-
import { resolveProjectId } from "../lib/config.js";
|
|
12
|
+
import { getProjectLink, resolveProjectId } from "../lib/config.js";
|
|
13
13
|
import { resolveVolume } from "../lib/volume.js";
|
|
14
14
|
import { sandboxShell } from "./sandbox-ssh.js";
|
|
15
15
|
import { startVncTunnel, vncTarget } from "./sandbox-vnc.js";
|
|
@@ -45,6 +45,9 @@ interface SandboxRecord {
|
|
|
45
45
|
endAt?: number | string;
|
|
46
46
|
expiresAt?: number | string;
|
|
47
47
|
projectId?: string | null;
|
|
48
|
+
/** Set by the platform when nothing named a project and it used the account's default. */
|
|
49
|
+
projectName?: string;
|
|
50
|
+
projectDefaulted?: boolean;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function parseIntOption(v: string): number {
|
|
@@ -117,7 +120,13 @@ export function registerSandbox(program: Command) {
|
|
|
117
120
|
// its workspaceId, which the create endpoint looks up itself anyway, overlapped
|
|
118
121
|
// with auth so it costs the server nothing. Sending it bought nothing and cost the
|
|
119
122
|
// client a round trip — 113ms of a 1083ms create measured from EU.
|
|
120
|
-
|
|
123
|
+
//
|
|
124
|
+
// Unlinked and no --project: send no project, and the platform puts the sandbox in
|
|
125
|
+
// the account's default project. Asking for that project first would cost every
|
|
126
|
+
// such create a round trip. A volume name is only unique within a project, so
|
|
127
|
+
// --volume still resolves one here.
|
|
128
|
+
const named = Boolean(opts.project || getProjectLink()?.projectId || opts.volume);
|
|
129
|
+
const projectId = named ? await resolveProjectId(opts.project) : undefined;
|
|
121
130
|
const scope = { workspaceId: null };
|
|
122
131
|
|
|
123
132
|
// Hand the volume to the create call instead of resolving it first.
|
|
@@ -173,8 +182,12 @@ export function registerSandbox(program: Command) {
|
|
|
173
182
|
} catch (e: any) {
|
|
174
183
|
// Only an id-shaped guess can be wrong this way, and only by 404. Anything
|
|
175
184
|
// else (409 already-attached, 400 wrong scope) is a real answer — rethrow it.
|
|
185
|
+
// An older platform, or a workspace without any project.
|
|
186
|
+
if (e instanceof APIError && e.code === "PROJECT_REQUIRED") {
|
|
187
|
+
throw new Error("No project linked. Run `lizard init` or pass --project <id>.");
|
|
188
|
+
}
|
|
176
189
|
if (!volumeId || e?.status !== 404) throw e;
|
|
177
|
-
const resolved = await resolveVolume(projectId
|
|
190
|
+
const resolved = await resolveVolume(projectId!, scope, opts.volume);
|
|
178
191
|
sandbox = await api.post<SandboxRecord>("/api/sandboxes", {
|
|
179
192
|
...body, volumeId: resolved.id, volumeName: undefined,
|
|
180
193
|
});
|
|
@@ -190,6 +203,9 @@ export function registerSandbox(program: Command) {
|
|
|
190
203
|
return;
|
|
191
204
|
}
|
|
192
205
|
success(`Sandbox ${chalk.bold(sandbox.id)} created`);
|
|
206
|
+
if (sandbox.projectDefaulted && sandbox.projectName) {
|
|
207
|
+
info(chalk.dim(` Project: ${sandbox.projectName} (your default; pass --project for another)`));
|
|
208
|
+
}
|
|
193
209
|
info(chalk.dim(` Template: ${sandbox.template} Region: ${sandbox.region}`));
|
|
194
210
|
const machine = `${sandbox.cpus} vCPU / ${sandbox.memoryMb} MB`;
|
|
195
211
|
info(chalk.dim(sandbox.size
|
package/src/commands/ssh.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as http from "node:http";
|
|
|
11
11
|
export function registerSSH(program: Command) {
|
|
12
12
|
program
|
|
13
13
|
.command("ssh")
|
|
14
|
-
.description("Execute a command inside a running service container")
|
|
14
|
+
.description("Execute a command inside a running service container (disabled: the API answers 403 \"Shell access to services is disabled\"; for sandboxes use `lizard sandbox ssh`)")
|
|
15
15
|
.argument("[cmd...]", "Command and args to run inside the container (required; pass after `--` to stop flag parsing, e.g. `-- ls -la /app`)")
|
|
16
16
|
.option("-s, --service <id>", "Service name or ID")
|
|
17
17
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
package/src/commands/up.ts
CHANGED
|
@@ -59,8 +59,8 @@ export function registerUp(program: Command) {
|
|
|
59
59
|
const projectFlag = merged.project;
|
|
60
60
|
const region: string | undefined = opts.region;
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
await ensureLinked({ projectName: projectFlag });
|
|
62
|
+
// An unlinked folder goes to the default project (or --project, or init's questions)
|
|
63
|
+
await ensureLinked({ projectName: projectFlag, useDefault: true });
|
|
64
64
|
|
|
65
65
|
// Resolve target service: --service flag → linked → first-in-project → prompt-or-fail
|
|
66
66
|
const ctx = await resolveContext({
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { info, success, isJSONMode, printJSON } from "../lib/format.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CURRENT_VERSION,
|
|
6
|
+
canSelfUpdate,
|
|
7
|
+
getLatestVersion,
|
|
8
|
+
installerCommand,
|
|
9
|
+
isStandaloneBinary,
|
|
10
|
+
selfUpdate,
|
|
11
|
+
} from "../lib/updater.js";
|
|
5
12
|
|
|
6
13
|
export function registerUpgrade(program: Command) {
|
|
7
14
|
program
|
|
@@ -87,21 +94,40 @@ export function registerUpgrade(program: Command) {
|
|
|
87
94
|
return;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
// No binary to swap in (the Windows build): say so and fail, instead of
|
|
98
|
+
// reporting an upgrade that never happened.
|
|
99
|
+
if (!canSelfUpdate()) {
|
|
100
|
+
throw notInstalled(`v${latest} is available, but this copy of Lizard CLI can't update itself.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
info(`Upgrading v${CURRENT_VERSION} → ${chalk.green("v" + latest)}...`);
|
|
91
104
|
|
|
105
|
+
let upgraded: boolean;
|
|
92
106
|
try {
|
|
93
|
-
await selfUpdate((msg) => info(chalk.dim(msg)));
|
|
94
|
-
if (isJSONMode()) {
|
|
95
|
-
printJSON({
|
|
96
|
-
previousVersion: CURRENT_VERSION,
|
|
97
|
-
latestVersion: latest,
|
|
98
|
-
upgraded: true,
|
|
99
|
-
});
|
|
100
|
-
} else {
|
|
101
|
-
success(`Upgraded to v${latest}`);
|
|
102
|
-
}
|
|
107
|
+
upgraded = await selfUpdate((msg) => info(chalk.dim(msg)));
|
|
103
108
|
} catch (e: any) {
|
|
104
109
|
throw new Error(`Upgrade failed: ${e.message}`);
|
|
105
110
|
}
|
|
111
|
+
if (!upgraded) {
|
|
112
|
+
throw notInstalled(`Nothing was replaced: v${CURRENT_VERSION} is still installed.`);
|
|
113
|
+
}
|
|
114
|
+
if (isJSONMode()) {
|
|
115
|
+
printJSON({
|
|
116
|
+
previousVersion: CURRENT_VERSION,
|
|
117
|
+
latestVersion: latest,
|
|
118
|
+
upgraded: true,
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
success(`Upgraded to v${latest}`);
|
|
122
|
+
}
|
|
106
123
|
});
|
|
107
124
|
}
|
|
125
|
+
|
|
126
|
+
/** Exit 1 with the installer command that does the upgrade instead. */
|
|
127
|
+
function notInstalled(reason: string): Error {
|
|
128
|
+
const shell = process.platform === "win32" ? " in PowerShell" : "";
|
|
129
|
+
return Object.assign(
|
|
130
|
+
new Error(`${reason} Re-run the installer${shell}:\n ${installerCommand()}`),
|
|
131
|
+
{ code: "UPGRADE_NOT_INSTALLED" },
|
|
132
|
+
);
|
|
133
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -132,6 +132,13 @@ export async function resolveProjectId(flagValue?: string): Promise<string> {
|
|
|
132
132
|
if (!flagValue) {
|
|
133
133
|
const link = getProjectLink();
|
|
134
134
|
if (link?.projectId) return link.projectId;
|
|
135
|
+
// Not linked: the account's default project, the one it got at sign-up.
|
|
136
|
+
const { defaultProject, noteDefaultProject } = await import("./default-project.js");
|
|
137
|
+
const fallback = await defaultProject();
|
|
138
|
+
if (fallback) {
|
|
139
|
+
noteDefaultProject(fallback.name);
|
|
140
|
+
return fallback.id;
|
|
141
|
+
}
|
|
135
142
|
throw new Error("No project linked. Run `lizard init` or pass --project <id>.");
|
|
136
143
|
}
|
|
137
144
|
// A value that already looks like a project id needs no lookup. Project ids are
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { api, APIError } from "./api.js";
|
|
3
|
+
import { info } from "./format.js";
|
|
4
|
+
|
|
5
|
+
// The project a command uses when the folder is not linked and no --project is
|
|
6
|
+
// given: the platform's default for this account (GET /api/projects/default),
|
|
7
|
+
// the oldest project in the personal workspace. Every account gets one at
|
|
8
|
+
// sign-up, so `lizard sandbox create` or `lizard up` from any folder works
|
|
9
|
+
// without `lizard init` first.
|
|
10
|
+
|
|
11
|
+
export interface DefaultProject {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
workspaceName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let lookup: Promise<DefaultProject | null> | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The default project, asked once per run. Null when the account has none, or
|
|
23
|
+
* when the platform predates the route; callers then fail as they always did.
|
|
24
|
+
* 401 still throws: that is a sign-in problem, not a missing project.
|
|
25
|
+
*/
|
|
26
|
+
export function defaultProject(): Promise<DefaultProject | null> {
|
|
27
|
+
lookup ??= api.get<DefaultProject>("/api/projects/default").catch((err) => {
|
|
28
|
+
if (err instanceof APIError && err.status !== 401 && err.status >= 400 && err.status < 500) return null;
|
|
29
|
+
throw err;
|
|
30
|
+
});
|
|
31
|
+
return lookup;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let noted = false;
|
|
35
|
+
|
|
36
|
+
/** Say once per run which project an unlinked folder ended up in, and how to choose another. */
|
|
37
|
+
export function noteDefaultProject(name: string): void {
|
|
38
|
+
if (noted) return;
|
|
39
|
+
noted = true;
|
|
40
|
+
info(chalk.dim(`Project: ${name} (your default). Pass --project for another, or run \`lizard init\` to link this folder.`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** For tests: forget the cached answer. */
|
|
44
|
+
export function resetDefaultProject(): void {
|
|
45
|
+
lookup = undefined;
|
|
46
|
+
noted = false;
|
|
47
|
+
}
|
package/src/lib/updater.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
|
|
8
|
-
export const CURRENT_VERSION = "4.0.
|
|
8
|
+
export const CURRENT_VERSION = "4.0.38";
|
|
9
9
|
const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
|
|
10
10
|
const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
|
|
11
11
|
|
|
@@ -22,6 +22,22 @@ function getBinaryName(): string | null {
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* True when selfUpdate() has a release asset to swap in for this platform.
|
|
27
|
+
* False on Windows: a running .exe can't be replaced in place, so the
|
|
28
|
+
* PowerShell installer does the update there.
|
|
29
|
+
*/
|
|
30
|
+
export function canSelfUpdate(): boolean {
|
|
31
|
+
return getBinaryName() !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The installer that puts the latest standalone binary over this one. */
|
|
35
|
+
export function installerCommand(): string {
|
|
36
|
+
return process.platform === "win32"
|
|
37
|
+
? "irm https://lizard.build/api/cli/install.ps1 | iex"
|
|
38
|
+
: "curl -fsSL https://lizard.build/install.sh | bash";
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* True only when running as the Bun-compiled standalone binary. Under
|
|
27
43
|
* npm/node, `process.execPath` is the *node* executable — self-update would
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveProjectId, getProjectLink, setProjectLink } from "../../src/lib/config.js";
|
|
6
|
+
import { resetDefaultProject } from "../../src/lib/default-project.js";
|
|
7
|
+
import { ensureLinked } from "../../src/commands/init.js";
|
|
8
|
+
import { setBaseURL, setAccessToken } from "../../src/lib/api.js";
|
|
9
|
+
import { setJSONMode } from "../../src/lib/format.js";
|
|
10
|
+
|
|
11
|
+
const fetchMock = vi.fn();
|
|
12
|
+
let tmpHome: string;
|
|
13
|
+
let tmpCwd: string;
|
|
14
|
+
let cwd: string;
|
|
15
|
+
const savedHome = process.env.LIZARD_HOME;
|
|
16
|
+
|
|
17
|
+
const project = { id: "p-default", name: "ada's Project", slug: "ada", workspaceId: "ws-1", workspaceName: "ada" };
|
|
18
|
+
|
|
19
|
+
function reply(status: number, body: unknown) {
|
|
20
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function routes(table: Record<string, () => Response>) {
|
|
24
|
+
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
|
25
|
+
const key = `${init?.method ?? "GET"} ${new URL(url).pathname}`;
|
|
26
|
+
return table[key]?.() ?? reply(404, { error: `no route ${key}` });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const requested = () => fetchMock.mock.calls.map(([url, init]) => `${init?.method ?? "GET"} ${new URL(url).pathname}`);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "lizard-default-home-"));
|
|
34
|
+
tmpCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lizard-default-cwd-")));
|
|
35
|
+
process.env.LIZARD_HOME = tmpHome;
|
|
36
|
+
cwd = process.cwd();
|
|
37
|
+
process.chdir(tmpCwd);
|
|
38
|
+
fetchMock.mockReset();
|
|
39
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
40
|
+
setBaseURL("https://lizard.build");
|
|
41
|
+
setAccessToken("tok");
|
|
42
|
+
setJSONMode(true);
|
|
43
|
+
resetDefaultProject();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
process.chdir(cwd);
|
|
48
|
+
if (savedHome === undefined) delete process.env.LIZARD_HOME;
|
|
49
|
+
else process.env.LIZARD_HOME = savedHome;
|
|
50
|
+
vi.unstubAllGlobals();
|
|
51
|
+
setJSONMode(false);
|
|
52
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
53
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveProjectId in an unlinked folder", () => {
|
|
57
|
+
test("uses the account's default project, asking the platform once per run", async () => {
|
|
58
|
+
routes({ "GET /api/projects/default": () => reply(200, project) });
|
|
59
|
+
expect(await resolveProjectId()).toBe("p-default");
|
|
60
|
+
expect(await resolveProjectId()).toBe("p-default");
|
|
61
|
+
expect(requested()).toEqual(["GET /api/projects/default"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a linked folder or --project never asks for it", async () => {
|
|
65
|
+
setProjectLink({ projectId: "p-linked" });
|
|
66
|
+
expect(await resolveProjectId()).toBe("p-linked");
|
|
67
|
+
expect(await resolveProjectId("abcdefghijklmnopqrstu")).toBe("abcdefghijklmnopqrstu");
|
|
68
|
+
expect(requested()).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("no default project, or a platform without the route: the old error", async () => {
|
|
72
|
+
routes({ "GET /api/projects/default": () => reply(404, { error: "none", code: "NO_DEFAULT_PROJECT" }) });
|
|
73
|
+
await expect(resolveProjectId()).rejects.toThrow("No project linked. Run `lizard init` or pass --project <id>.");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a sign-in problem is not mistaken for a missing project", async () => {
|
|
77
|
+
routes({ "GET /api/projects/default": () => reply(401, { error: "Unauthorized" }) });
|
|
78
|
+
await expect(resolveProjectId()).rejects.toMatchObject({ status: 401 });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("lizard up in an unlinked folder", () => {
|
|
83
|
+
test("links the folder to the default project without asking", async () => {
|
|
84
|
+
routes({ "GET /api/projects/default": () => reply(200, project) });
|
|
85
|
+
const link = await ensureLinked({ useDefault: true });
|
|
86
|
+
|
|
87
|
+
expect(link).toEqual({ projectId: "p-default", projectName: "ada's Project", workspaceId: "ws-1", workspaceName: "ada" });
|
|
88
|
+
expect(getProjectLink()).toMatchObject({ projectId: "p-default" });
|
|
89
|
+
expect(requested()).toEqual(["GET /api/projects/default"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("--project, or lizard init itself, still goes through the old flow", async () => {
|
|
93
|
+
routes({});
|
|
94
|
+
await expect(ensureLinked({ useDefault: true, projectName: "api" })).rejects.toThrow();
|
|
95
|
+
await expect(ensureLinked({})).rejects.toThrow();
|
|
96
|
+
expect(requested()).not.toContain("GET /api/projects/default");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an already linked folder stays as it is", async () => {
|
|
100
|
+
setProjectLink({ projectId: "p-linked" });
|
|
101
|
+
expect((await ensureLinked({ useDefault: true })).projectId).toBe("p-linked");
|
|
102
|
+
expect(requested()).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/test/unit/login.test.ts
CHANGED
|
@@ -186,20 +186,17 @@ describe("account state", () => {
|
|
|
186
186
|
|
|
187
187
|
test("the closing note leads with sandboxes, aligned in two columns", () => {
|
|
188
188
|
const plain = (t: string) => t.replace(/\x1b\[[0-9;]*m/g, "");
|
|
189
|
-
expect(plain(nextStepsNote(
|
|
190
|
-
"lizard
|
|
191
|
-
"lizard sandbox create Start a sandbox in it",
|
|
192
|
-
"lizard up Or deploy this folder",
|
|
193
|
-
]);
|
|
194
|
-
expect(plain(nextStepsNote(true)).split("\n")).toEqual([
|
|
195
|
-
"lizard sandbox create Start a sandbox in this project",
|
|
189
|
+
expect(plain(nextStepsNote()).split("\n")).toEqual([
|
|
190
|
+
"lizard sandbox create Start a sandbox",
|
|
196
191
|
"lizard up Or deploy this folder",
|
|
197
192
|
]);
|
|
198
193
|
});
|
|
199
194
|
|
|
200
|
-
test("an unlinked folder
|
|
201
|
-
expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard
|
|
195
|
+
test("no lizard init needed: an unlinked folder uses the default project", () => {
|
|
196
|
+
expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard sandbox create", "lizard up"]);
|
|
197
|
+
expect(nextSteps(false)[1].why).toContain("default project");
|
|
202
198
|
expect(nextSteps(true).map((s) => s.command)).toEqual(["lizard skills get core", "lizard sandbox create", "lizard up"]);
|
|
199
|
+
expect(nextSteps(true)[1].why).toContain("linked project");
|
|
203
200
|
});
|
|
204
201
|
});
|
|
205
202
|
|
|
@@ -239,7 +236,7 @@ describe("lizard login --json", () => {
|
|
|
239
236
|
expect(out).toMatchObject({ status: "complete", username: "ada", email: "ada@example.com", source: "browser" });
|
|
240
237
|
expect(out.account.state).toBe("trial_available");
|
|
241
238
|
expect(out.account.billingUrl).toBe("https://lizard.build/profile/account-billing");
|
|
242
|
-
expect(out.next.map((s: { command: string }) => s.command)).toContain("lizard
|
|
239
|
+
expect(out.next.map((s: { command: string }) => s.command)).toContain("lizard sandbox create");
|
|
243
240
|
expect(loadConfig().pendingAuth).toBeUndefined();
|
|
244
241
|
expect(loadConfig().credentials?.username).toBe("ada");
|
|
245
242
|
});
|
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { registerSandbox } from "../../src/commands/sandbox.js";
|
|
4
4
|
import { api, APIError } from "../../src/lib/api.js";
|
|
5
5
|
import { printJSON } from "../../src/lib/format.js";
|
|
6
|
+
import { getProjectLink, resolveProjectId } from "../../src/lib/config.js";
|
|
6
7
|
|
|
7
8
|
vi.mock("../../src/lib/api.js", () => {
|
|
8
9
|
class APIError extends Error {
|
|
@@ -13,8 +14,8 @@ vi.mock("../../src/lib/api.js", () => {
|
|
|
13
14
|
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
|
|
14
15
|
vi.mock("../../src/lib/config.js", () => ({
|
|
15
16
|
resolveProjectId: vi.fn().mockResolvedValue("project-test"),
|
|
17
|
+
getProjectLink: vi.fn(() => ({ projectId: "project-test" })),
|
|
16
18
|
}));
|
|
17
|
-
vi.mock("../../src/lib/config.js", () => ({ resolveProjectId: vi.fn().mockResolvedValue("project-test") }));
|
|
18
19
|
vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
|
|
19
20
|
|
|
20
21
|
function create(args: string[]) {
|
|
@@ -46,6 +47,41 @@ describe("sandbox create timeout", () => {
|
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
describe("sandbox create without a project", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
vi.mocked(api.post).mockResolvedValue({ id: "sandbox-test" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("an unlinked folder sends no project and leaves the choice to the platform", async () => {
|
|
57
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
58
|
+
await create([]);
|
|
59
|
+
expect(resolveProjectId).not.toHaveBeenCalled();
|
|
60
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: undefined }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("a linked folder or --project still sends its project", async () => {
|
|
64
|
+
await create([]);
|
|
65
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: "project-test" }));
|
|
66
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
67
|
+
await create(["--project", "web"]);
|
|
68
|
+
expect(resolveProjectId).toHaveBeenLastCalledWith("web");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("an unlinked --volume still resolves a project, since a volume name lives in one", async () => {
|
|
72
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
73
|
+
await create(["--volume", "data"]);
|
|
74
|
+
expect(resolveProjectId).toHaveBeenCalledWith(undefined);
|
|
75
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: "project-test", volumeName: "data" }));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a platform that still wants a project gets the old message", async () => {
|
|
79
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
80
|
+
vi.mocked(api.post).mockRejectedValueOnce(new APIError(400, "A project is required", "PROJECT_REQUIRED"));
|
|
81
|
+
await expect(create([])).rejects.toThrow("No project linked. Run `lizard init` or pass --project <id>.");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
49
85
|
describe("unsupported sandbox commands", () => {
|
|
50
86
|
it.each(["fork", "snapshot-fork", "logs"])(
|
|
51
87
|
"rejects %s without making an API request", async (name) => {
|