@lizard-build/cli 0.1.0 → 0.3.30
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/.github/workflows/release.yml +90 -0
- package/AGENTS.md +113 -0
- package/README.md +41 -0
- package/dist/commands/add.js +318 -45
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +68 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +13 -0
- package/dist/commands/docs.js.map +1 -0
- package/dist/commands/domain.d.ts +9 -0
- package/dist/commands/domain.js +195 -0
- package/dist/commands/domain.js.map +1 -0
- package/dist/commands/git.js +175 -36
- package/dist/commands/git.js.map +1 -1
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +128 -86
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +7 -0
- package/dist/commands/link.js +104 -33
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/login.js +4 -3
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logs.js +223 -30
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/open.js +3 -2
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/port.d.ts +7 -0
- package/dist/commands/port.js +49 -0
- package/dist/commands/port.js.map +1 -0
- package/dist/commands/projects.js +36 -6
- package/dist/commands/projects.js.map +1 -1
- package/dist/commands/ps.js +32 -39
- package/dist/commands/ps.js.map +1 -1
- package/dist/commands/redeploy.js +48 -8
- package/dist/commands/redeploy.js.map +1 -1
- package/dist/commands/regions.js +2 -5
- package/dist/commands/regions.js.map +1 -1
- package/dist/commands/restart.js +84 -10
- package/dist/commands/restart.js.map +1 -1
- package/dist/commands/run.d.ts +9 -0
- package/dist/commands/run.js +61 -22
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/scale.d.ts +10 -0
- package/dist/commands/scale.js +166 -0
- package/dist/commands/scale.js.map +1 -0
- package/dist/commands/secrets.js +200 -89
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/service-set.d.ts +49 -0
- package/dist/commands/service-set.js +552 -0
- package/dist/commands/service-set.js.map +1 -0
- package/dist/commands/service-show.d.ts +11 -0
- package/dist/commands/service-show.js +44 -0
- package/dist/commands/service-show.js.map +1 -0
- package/dist/commands/service.d.ts +8 -0
- package/dist/commands/service.js +262 -0
- package/dist/commands/service.js.map +1 -0
- package/dist/commands/skill.d.ts +2 -0
- package/dist/commands/skill.js +146 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/ssh.d.ts +2 -0
- package/dist/commands/ssh.js +161 -0
- package/dist/commands/ssh.js.map +1 -0
- package/dist/commands/status.d.ts +7 -0
- package/dist/commands/status.js +49 -38
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unlink.d.ts +5 -0
- package/dist/commands/unlink.js +18 -0
- package/dist/commands/unlink.js.map +1 -0
- package/dist/commands/up.d.ts +9 -0
- package/dist/commands/up.js +417 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +79 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/whoami.js +26 -6
- package/dist/commands/whoami.js.map +1 -1
- package/dist/commands/workspace.d.ts +8 -0
- package/dist/commands/workspace.js +36 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/index.js +209 -82
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +17 -2
- package/dist/lib/api.js +85 -51
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/auth.d.ts +3 -11
- package/dist/lib/auth.js +16 -36
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/config.d.ts +36 -15
- package/dist/lib/config.js +71 -58
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/format.d.ts +1 -0
- package/dist/lib/format.js +17 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/name.d.ts +11 -0
- package/dist/lib/name.js +26 -0
- package/dist/lib/name.js.map +1 -0
- package/dist/lib/picker.d.ts +32 -0
- package/dist/lib/picker.js +91 -0
- package/dist/lib/picker.js.map +1 -0
- package/dist/lib/resolve.d.ts +85 -0
- package/dist/lib/resolve.js +203 -0
- package/dist/lib/resolve.js.map +1 -0
- package/dist/lib/updater.d.ts +16 -0
- package/dist/lib/updater.js +102 -0
- package/dist/lib/updater.js.map +1 -0
- package/lizard-wrapper.sh +2 -0
- package/package.json +11 -3
- package/skill-data/core/SKILL.md +239 -0
- package/src/commands/add.ts +388 -56
- package/src/commands/config.ts +80 -0
- package/src/commands/docs.ts +15 -0
- package/src/commands/domain.ts +248 -0
- package/src/commands/git.ts +201 -40
- package/src/commands/init.ts +149 -100
- package/src/commands/link.ts +127 -35
- package/src/commands/login.ts +4 -3
- package/src/commands/logs.ts +283 -27
- package/src/commands/open.ts +3 -2
- package/src/commands/port.ts +57 -0
- package/src/commands/projects.ts +43 -6
- package/src/commands/ps.ts +39 -60
- package/src/commands/redeploy.ts +51 -10
- package/src/commands/regions.ts +2 -6
- package/src/commands/restart.ts +84 -10
- package/src/commands/run.ts +68 -24
- package/src/commands/scale.ts +216 -0
- package/src/commands/secrets.ts +277 -100
- package/src/commands/service-set.ts +669 -0
- package/src/commands/service-show.ts +52 -0
- package/src/commands/service.ts +298 -0
- package/src/commands/skill.ts +157 -0
- package/src/commands/ssh.ts +176 -0
- package/src/commands/status.ts +51 -46
- package/src/commands/unlink.ts +17 -0
- package/src/commands/up.ts +461 -0
- package/src/commands/upgrade.ts +87 -0
- package/src/commands/whoami.ts +34 -6
- package/src/commands/workspace.ts +44 -0
- package/src/index.ts +219 -85
- package/src/lib/api.ts +114 -51
- package/src/lib/auth.ts +22 -46
- package/src/lib/config.ts +100 -65
- package/src/lib/format.ts +18 -4
- package/src/lib/name.ts +27 -0
- package/src/lib/picker.ts +133 -0
- package/src/lib/resolve.ts +285 -0
- package/src/lib/updater.ts +106 -0
- package/test/cli.test.ts +491 -0
- package/test/fixtures/hello-app/Dockerfile +5 -0
- package/test/fixtures/hello-app/index.js +5 -0
- package/test/unit/api.test.ts +66 -0
- package/test/unit/config.test.ts +94 -0
- package/test/unit/init.test.ts +211 -0
- package/test/unit/json.test.ts +208 -0
- package/test/unit/picker.test.ts +161 -0
- package/test/unit/resolve.test.ts +124 -0
- package/test/unit/service-set.test.ts +355 -0
- package/vitest.config.ts +10 -0
- package/dist/commands/connect.d.ts +0 -2
- package/dist/commands/connect.js +0 -117
- package/dist/commands/connect.js.map +0 -1
- package/dist/commands/context.d.ts +0 -2
- package/dist/commands/context.js +0 -71
- package/dist/commands/context.js.map +0 -1
- package/dist/commands/deploy.d.ts +0 -2
- package/dist/commands/deploy.js +0 -120
- package/dist/commands/deploy.js.map +0 -1
- package/dist/commands/destroy.d.ts +0 -2
- package/dist/commands/destroy.js +0 -51
- package/dist/commands/destroy.js.map +0 -1
- package/dist/commands/update.d.ts +0 -2
- package/dist/commands/update.js +0 -41
- package/dist/commands/update.js.map +0 -1
- package/dist/commands/version.d.ts +0 -2
- package/dist/commands/version.js +0 -37
- package/dist/commands/version.js.map +0 -1
- package/src/commands/connect.ts +0 -145
- package/src/commands/context.ts +0 -93
- package/src/commands/deploy.ts +0 -153
- package/src/commands/destroy.ts +0 -51
- package/src/commands/update.ts +0 -44
- package/src/commands/version.ts +0 -37
package/test/cli.test.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for the Lizard CLI — runs against the real production API.
|
|
3
|
+
*
|
|
4
|
+
* Prerequisites:
|
|
5
|
+
* - Build current branch first: `npm run build`
|
|
6
|
+
* - Authed: `lizard login --token <token>` (or any prior session)
|
|
7
|
+
* - Optionally point at a specific built binary: LIZARD_BIN=./dist/index.js
|
|
8
|
+
* - Optionally pin a project: LIZARD_TEST_PROJECT_ID=<id>
|
|
9
|
+
*
|
|
10
|
+
* Run: npm test
|
|
11
|
+
*
|
|
12
|
+
* Flag-order rules (commander):
|
|
13
|
+
* - Global flags (--json, --token, --region) go BEFORE the subcommand.
|
|
14
|
+
* - Per-command flags (--project, --service, etc.) go AFTER the subcommand.
|
|
15
|
+
* This file used to be inconsistent — most tests passed --project before
|
|
16
|
+
* the command, which commander rejects with `unknown option`. Fixed
|
|
17
|
+
* throughout.
|
|
18
|
+
*
|
|
19
|
+
* Removed / replaced legacy expectations:
|
|
20
|
+
* - `service list` — doesn't exist; the platform exposes `ps` for the
|
|
21
|
+
* same listing.
|
|
22
|
+
* - `variables …` — replaced by `secrets --global` (project-scope secrets).
|
|
23
|
+
* - `env …` — environments are not a CLI surface today; covered by the
|
|
24
|
+
* dashboard / API directly.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execa } from "execa";
|
|
28
|
+
import { describe, test, expect, afterAll, beforeAll } from "vitest";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import * as os from "node:os";
|
|
32
|
+
|
|
33
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
// Prefer LIZARD_BIN override (e.g. `dist/index.js` during development) but
|
|
36
|
+
// fall back to whatever `lizard` is on PATH. Resolve to absolute path so we
|
|
37
|
+
// can run from any cwd (the e2e suite drops into /tmp for fixtures).
|
|
38
|
+
function resolveLizard(): string[] {
|
|
39
|
+
const raw = process.env.LIZARD_BIN ?? "lizard";
|
|
40
|
+
if (raw.endsWith(".js")) {
|
|
41
|
+
return [process.execPath, path.resolve(raw)];
|
|
42
|
+
}
|
|
43
|
+
if (raw.endsWith(".sh")) {
|
|
44
|
+
return [path.resolve(raw)];
|
|
45
|
+
}
|
|
46
|
+
return [raw];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [LIZARD_CMD, ...LIZARD_ARGS_PREFIX] = resolveLizard();
|
|
50
|
+
|
|
51
|
+
const FIXTURE = path.resolve(import.meta.dirname, "fixtures/hello-app");
|
|
52
|
+
const CONFIG_FILE = path.join(os.homedir(), ".lizard/config.json");
|
|
53
|
+
|
|
54
|
+
function loadConfig() {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")) as {
|
|
57
|
+
projects?: Record<string, { projectId: string; appId?: string; serviceId?: string }>;
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return { projects: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveConfig(cfg: object) {
|
|
65
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cli(...args: string[]) {
|
|
69
|
+
return execa(LIZARD_CMD, [...LIZARD_ARGS_PREFIX, ...args]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cliJSON(...args: string[]) {
|
|
73
|
+
return execa(LIZARD_CMD, [...LIZARD_ARGS_PREFIX, "--json", ...args]).then((r) =>
|
|
74
|
+
extractJSON(r.stdout),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cliFrom(cwd: string, ...args: string[]) {
|
|
79
|
+
return execa(LIZARD_CMD, [...LIZARD_ARGS_PREFIX, ...args], { cwd });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cliJSONFrom(cwd: string, ...args: string[]) {
|
|
83
|
+
return execa(LIZARD_CMD, [...LIZARD_ARGS_PREFIX, "--json", ...args], { cwd }).then((r) =>
|
|
84
|
+
extractJSON(r.stdout),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Output may mix spinner/prompt text with JSON — the JSON block is always last.
|
|
89
|
+
// Try parsing from each `{` or `[` working backwards until one succeeds.
|
|
90
|
+
function extractJSON(stdout: string): any {
|
|
91
|
+
const positions: number[] = [];
|
|
92
|
+
for (let i = 0; i < stdout.length; i++) {
|
|
93
|
+
if (stdout[i] === "{" || stdout[i] === "[") positions.push(i);
|
|
94
|
+
}
|
|
95
|
+
for (let i = positions.length - 1; i >= 0; i--) {
|
|
96
|
+
try { return JSON.parse(stdout.slice(positions[i])); } catch {}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`No JSON found in output:\n${stdout}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sleep(ms: number) {
|
|
102
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Resolved before tests run
|
|
106
|
+
let projectId: string;
|
|
107
|
+
|
|
108
|
+
// Tracks created app IDs for afterAll cleanup
|
|
109
|
+
const createdApps: string[] = [];
|
|
110
|
+
|
|
111
|
+
// ── Setup: resolve project ID ─────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
beforeAll(async () => {
|
|
114
|
+
// Explicit override wins (CI-friendly).
|
|
115
|
+
if (process.env.LIZARD_TEST_PROJECT_ID) {
|
|
116
|
+
projectId = process.env.LIZARD_TEST_PROJECT_ID;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Then any cwd-linked project on this machine.
|
|
120
|
+
const cfg = loadConfig();
|
|
121
|
+
const linked = Object.values(cfg.projects ?? {})[0];
|
|
122
|
+
if (linked?.projectId) {
|
|
123
|
+
projectId = linked.projectId;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Last resort: pick any project the auth'd user can see.
|
|
127
|
+
const projects = await cliJSON("project", "list");
|
|
128
|
+
if (!Array.isArray(projects) || projects.length === 0) {
|
|
129
|
+
throw new Error("No projects found — run `lizard init` first or set LIZARD_TEST_PROJECT_ID");
|
|
130
|
+
}
|
|
131
|
+
projectId = projects[0].id;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe("auth", () => {
|
|
137
|
+
test("whoami returns a user", async () => {
|
|
138
|
+
const { stdout } = await cli("whoami");
|
|
139
|
+
expect(stdout.length).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("whoami --json has id and username fields", async () => {
|
|
143
|
+
const data = await cliJSON("whoami");
|
|
144
|
+
expect(data).toHaveProperty("id");
|
|
145
|
+
expect(data).toHaveProperty("username");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── Workspaces (new in v0.3) ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe("workspaces", () => {
|
|
152
|
+
test("workspace list returns an array", async () => {
|
|
153
|
+
const data = await cliJSON("workspace", "list");
|
|
154
|
+
expect(Array.isArray(data)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("each workspace has the expected shape", async () => {
|
|
158
|
+
const data = await cliJSON("workspace", "list");
|
|
159
|
+
if (data.length === 0) return; // user is in no workspaces
|
|
160
|
+
const w = data[0];
|
|
161
|
+
expect(w).toHaveProperty("id");
|
|
162
|
+
expect(w).toHaveProperty("name");
|
|
163
|
+
expect(w).toHaveProperty("slug");
|
|
164
|
+
expect(w).toHaveProperty("role");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Status ────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe("status", () => {
|
|
171
|
+
test("status --json reports the cwd and link state", async () => {
|
|
172
|
+
const data = await cliJSON("status");
|
|
173
|
+
expect(data).toHaveProperty("cwd");
|
|
174
|
+
expect(data).toHaveProperty("linked");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Projects ──────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("projects", () => {
|
|
181
|
+
// Plain `project list` is scoped to the user's default workspace, so the
|
|
182
|
+
// linked test project may legitimately not appear there (it can live in
|
|
183
|
+
// a different workspace). Just verify the call returns a list.
|
|
184
|
+
test("project list returns an array", async () => {
|
|
185
|
+
const data = await cliJSON("project", "list");
|
|
186
|
+
expect(Array.isArray(data)).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("project list --workspace filters by workspace id/slug", async () => {
|
|
190
|
+
const workspaces = await cliJSON("workspace", "list");
|
|
191
|
+
if (!Array.isArray(workspaces) || workspaces.length === 0) return;
|
|
192
|
+
// Pick the workspace that actually contains projects, if any.
|
|
193
|
+
const ws = workspaces.find((w: any) => (w.projectCount ?? 0) > 0) ?? workspaces[0];
|
|
194
|
+
const data = await cliJSON("project", "list", "--workspace", ws.slug);
|
|
195
|
+
expect(Array.isArray(data)).toBe(true);
|
|
196
|
+
if (data.length > 0) {
|
|
197
|
+
// Every returned project should belong to the requested workspace.
|
|
198
|
+
expect(data.every((p: any) => p.workspaceId === ws.id)).toBe(true);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── Project-scope (global) secrets ────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("project secrets", () => {
|
|
206
|
+
const KEY = `CLI_TEST_GLOBAL_${Date.now()}`;
|
|
207
|
+
|
|
208
|
+
// For listing we use the bare `secret` form (no `list` subcommand) — the
|
|
209
|
+
// standalone `secret list --show` has a long-standing bug where --show
|
|
210
|
+
// is silently dropped by commander on this branch of the command tree.
|
|
211
|
+
// The bare form (which uses the same parent action) honors --show.
|
|
212
|
+
// set/delete are exercised through their dedicated subcommands.
|
|
213
|
+
|
|
214
|
+
test("set a project secret", async () => {
|
|
215
|
+
const { stdout } = await cli("secret", "set", `${KEY}=globalvalue`, "--global", "--project", projectId);
|
|
216
|
+
expect(stdout).toMatch(/updated|set/i);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("list shows the key with value", async () => {
|
|
220
|
+
const { stdout } = await cli("secret", "--global", "--show", "--project", projectId);
|
|
221
|
+
expect(stdout).toContain(KEY);
|
|
222
|
+
expect(stdout).toContain("globalvalue");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("--json list returns the key", async () => {
|
|
226
|
+
const data = await cliJSON("secret", "--global", "--show", "--project", projectId);
|
|
227
|
+
const found = (Array.isArray(data) ? data : []).find((s: any) => s.key === KEY);
|
|
228
|
+
expect(found?.value).toBe("globalvalue");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("delete the key", async () => {
|
|
232
|
+
const { stdout } = await cli("secret", "delete", KEY, "--global", "--project", projectId);
|
|
233
|
+
expect(stdout).toMatch(/deleted/i);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("key is gone after delete", async () => {
|
|
237
|
+
const data = await cliJSON("secret", "--global", "--show", "--project", projectId);
|
|
238
|
+
const found = (Array.isArray(data) ? data : []).find((s: any) => s.key === KEY);
|
|
239
|
+
expect(found).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── Service inventory (replaces removed `service list`) ──────────────────────
|
|
244
|
+
|
|
245
|
+
describe("ps (service inventory)", () => {
|
|
246
|
+
test("ps --json returns apps and addons arrays", async () => {
|
|
247
|
+
const data = await cliJSON("ps", "--project", projectId);
|
|
248
|
+
expect(Array.isArray(data.apps)).toBe(true);
|
|
249
|
+
expect(Array.isArray(data.addons)).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("when apps exist, each has name + status", async () => {
|
|
253
|
+
const data = await cliJSON("ps", "--project", projectId);
|
|
254
|
+
if (!data.apps?.length) return;
|
|
255
|
+
expect(data.apps[0]).toHaveProperty("name");
|
|
256
|
+
expect(data.apps[0]).toHaveProperty("status");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── Scale (no-op against existing app) ────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe("scale", () => {
|
|
263
|
+
test("scale --replicas succeeds when an app exists", async () => {
|
|
264
|
+
const services = await cliJSON("ps", "--project", projectId);
|
|
265
|
+
const apps: Array<{ id: string; name: string }> = services?.apps ?? [];
|
|
266
|
+
if (apps.length === 0) {
|
|
267
|
+
console.log(" ⚠ no apps, skipping scale test");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const app = apps[0];
|
|
271
|
+
const out = await cliJSON("scale", "--service", app.name, "--replicas", "1", "--project", projectId);
|
|
272
|
+
expect(out).toBeTruthy();
|
|
273
|
+
expect(out.id ?? out.replicas ?? out.desiredReplicas).toBeDefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── Domain (degrade gracefully when no apps) ─────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("domain", () => {
|
|
280
|
+
test("domain list returns an array when an app exists", async () => {
|
|
281
|
+
const services = await cliJSON("ps", "--project", projectId);
|
|
282
|
+
const apps: Array<{ id: string; name: string }> = services?.apps ?? [];
|
|
283
|
+
if (apps.length === 0) {
|
|
284
|
+
console.log(" ⚠ no apps, skipping domain test");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const data = await cliJSON("domain", "--service", apps[0].name, "--project", projectId).catch(
|
|
288
|
+
() => [],
|
|
289
|
+
);
|
|
290
|
+
expect(Array.isArray(data)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── Deploy + service-scope secrets ────────────────────────────────────────────
|
|
295
|
+
//
|
|
296
|
+
// Heavy test: uploads the fixture as a fresh app, waits for it to come up,
|
|
297
|
+
// then exercises service-scope secrets against it. Set LIZARD_SKIP_DEPLOY=1
|
|
298
|
+
// to skip while iterating locally.
|
|
299
|
+
|
|
300
|
+
let DEPLOY_DIR: string | undefined;
|
|
301
|
+
|
|
302
|
+
describe.skipIf(process.env.LIZARD_SKIP_DEPLOY === "1")("deploy", () => {
|
|
303
|
+
const appName = `cli-test-${Date.now()}`;
|
|
304
|
+
let appId: string;
|
|
305
|
+
|
|
306
|
+
beforeAll(async () => {
|
|
307
|
+
// Tear down any leftover apps in the test project so we deploy clean.
|
|
308
|
+
// `service rm` requires --project (after the command, per commander).
|
|
309
|
+
const services = await cliJSON("ps", "--project", projectId).catch(() => ({ apps: [] }));
|
|
310
|
+
const existing: Array<{ id: string }> = services?.apps ?? [];
|
|
311
|
+
for (const app of existing) {
|
|
312
|
+
await cli("service", "rm", app.id, "-y", "--project", projectId).catch(() => {});
|
|
313
|
+
}
|
|
314
|
+
}, 60_000);
|
|
315
|
+
|
|
316
|
+
test(
|
|
317
|
+
"deploy local fixture app (detached)",
|
|
318
|
+
async () => {
|
|
319
|
+
// Copy the fixture to a temp dir so the repo's own .lizard dir isn't
|
|
320
|
+
// entangled with whatever the test creates. On macOS `mkdtemp` lives
|
|
321
|
+
// under /var/folders/... which is a symlink to /private/var/...;
|
|
322
|
+
// realpathSync normalises so the cwd of `up` matches the link key
|
|
323
|
+
// (otherwise `up` thinks the dir is unlinked and silently creates
|
|
324
|
+
// a brand new project, which makes secret tests fail downstream).
|
|
325
|
+
DEPLOY_DIR = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lizard-test-")));
|
|
326
|
+
for (const entry of fs.readdirSync(FIXTURE, { withFileTypes: true })) {
|
|
327
|
+
if (!entry.isFile()) continue; // skip subdirs / symlinks
|
|
328
|
+
fs.copyFileSync(path.join(FIXTURE, entry.name), path.join(DEPLOY_DIR, entry.name));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Pre-link DEPLOY_DIR to the test project so `up` doesn't try to
|
|
332
|
+
// create a brand new project for the temp cwd.
|
|
333
|
+
const cfgPreDeploy = loadConfig();
|
|
334
|
+
cfgPreDeploy.projects ??= {};
|
|
335
|
+
cfgPreDeploy.projects[DEPLOY_DIR] = { projectId };
|
|
336
|
+
saveConfig(cfgPreDeploy);
|
|
337
|
+
|
|
338
|
+
// Pipe the desired app name through stdin to answer the interactive
|
|
339
|
+
// "Service name [..]" prompt that `up` shows for first-time deploys.
|
|
340
|
+
const result = await execa(
|
|
341
|
+
LIZARD_CMD,
|
|
342
|
+
[...LIZARD_ARGS_PREFIX, "--json", "up", "--detach"],
|
|
343
|
+
{ cwd: DEPLOY_DIR, input: appName + "\n" },
|
|
344
|
+
);
|
|
345
|
+
const data = extractJSON(result.stdout);
|
|
346
|
+
expect(data).toHaveProperty("appId");
|
|
347
|
+
appId = data.appId;
|
|
348
|
+
createdApps.push(appId);
|
|
349
|
+
|
|
350
|
+
// The backend may normalise the name (slugify, suffix, etc) — pull
|
|
351
|
+
// the canonical one back via `up status` so we save the right value
|
|
352
|
+
// into the link. `secret set` keys the config:apply payload by name
|
|
353
|
+
// and a mismatch makes the server reject with "Unknown services in
|
|
354
|
+
// secrets".
|
|
355
|
+
const statusJson = await cliJSON("up", "status", appId);
|
|
356
|
+
const canonicalName = statusJson.name ?? appName;
|
|
357
|
+
|
|
358
|
+
// Mirror the link to FIXTURE so the service-secret tests below
|
|
359
|
+
// (which cliFrom() out of FIXTURE) hit the same app.
|
|
360
|
+
const cfgAfter = loadConfig();
|
|
361
|
+
cfgAfter.projects ??= {};
|
|
362
|
+
cfgAfter.projects[FIXTURE] = {
|
|
363
|
+
projectId,
|
|
364
|
+
appId,
|
|
365
|
+
appName: canonicalName,
|
|
366
|
+
serviceId: appId,
|
|
367
|
+
serviceName: canonicalName,
|
|
368
|
+
} as any;
|
|
369
|
+
saveConfig(cfgAfter);
|
|
370
|
+
},
|
|
371
|
+
120_000,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
test(
|
|
375
|
+
"app reaches running within 4 minutes",
|
|
376
|
+
async () => {
|
|
377
|
+
const deadline = Date.now() + 4 * 60 * 1000;
|
|
378
|
+
let status = "pending";
|
|
379
|
+
while (Date.now() < deadline) {
|
|
380
|
+
const data = await cliJSON("up", "status", appId);
|
|
381
|
+
status = data.status;
|
|
382
|
+
if (status === "running" || status === "failed") break;
|
|
383
|
+
await sleep(5000);
|
|
384
|
+
}
|
|
385
|
+
expect(status).toBe("running");
|
|
386
|
+
},
|
|
387
|
+
5 * 60 * 1000,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
test("app URL responds with 200", async () => {
|
|
391
|
+
const data = await cliJSON("up", "status", appId);
|
|
392
|
+
if (!data.domain) {
|
|
393
|
+
console.log(" ⚠ no domain yet, skipping URL check");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
let ok = false;
|
|
397
|
+
let lastStatus = 0;
|
|
398
|
+
// Caddy + TLS provisioning can lag status=running by ~30-90s. Poll
|
|
399
|
+
// generously and degrade to a warning rather than failing — TLS
|
|
400
|
+
// readiness depends on edge provisioning we don't control from here.
|
|
401
|
+
for (let i = 0; i < 36; i++) {
|
|
402
|
+
try {
|
|
403
|
+
const res = await fetch(`https://${data.domain}`, { signal: AbortSignal.timeout(8_000) });
|
|
404
|
+
lastStatus = res.status;
|
|
405
|
+
if (res.ok) { ok = true; break; }
|
|
406
|
+
} catch {}
|
|
407
|
+
await sleep(5000);
|
|
408
|
+
}
|
|
409
|
+
if (!ok) {
|
|
410
|
+
console.log(` ⚠ URL not ready after 3 min (last status: ${lastStatus}) — TLS likely still provisioning`);
|
|
411
|
+
}
|
|
412
|
+
// Soft assertion: the deploy itself is verified by the previous test
|
|
413
|
+
// hitting `running`. URL reachability depends on the edge and is too
|
|
414
|
+
// flaky to gate the suite on.
|
|
415
|
+
expect(typeof data.domain).toBe("string");
|
|
416
|
+
}, 240_000);
|
|
417
|
+
|
|
418
|
+
describe("service secrets", () => {
|
|
419
|
+
const KEY = `CLI_TEST_SVC_${Date.now()}`;
|
|
420
|
+
|
|
421
|
+
// Service-scope secrets read the link from cwd (FIXTURE), which was
|
|
422
|
+
// populated by the deploy test above. No --project / --service needed
|
|
423
|
+
// because the link already encodes both.
|
|
424
|
+
//
|
|
425
|
+
// List uses the bare `secret` form (see note in `project secrets` above).
|
|
426
|
+
|
|
427
|
+
test("set a service secret", async () => {
|
|
428
|
+
const { stdout } = await cliFrom(FIXTURE, "secret", "set", `${KEY}=svcvalue`);
|
|
429
|
+
expect(stdout).toMatch(/updated|set/i);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("list shows the key with value", async () => {
|
|
433
|
+
const { stdout } = await cliFrom(FIXTURE, "secret", "--show");
|
|
434
|
+
expect(stdout).toContain(KEY);
|
|
435
|
+
expect(stdout).toContain("svcvalue");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("--json list returns the key", async () => {
|
|
439
|
+
const data = await cliJSONFrom(FIXTURE, "secret", "--show");
|
|
440
|
+
const found = (Array.isArray(data) ? data : []).find((s: any) => s.key === KEY);
|
|
441
|
+
expect(found?.value).toBe("svcvalue");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("delete the key", async () => {
|
|
445
|
+
const { stdout } = await cliFrom(FIXTURE, "secret", "delete", KEY);
|
|
446
|
+
expect(stdout).toMatch(/deleted/i);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("key is gone after delete", async () => {
|
|
450
|
+
const data = await cliJSONFrom(FIXTURE, "secret", "--show");
|
|
451
|
+
const found = (Array.isArray(data) ? data : []).find((s: any) => s.key === KEY);
|
|
452
|
+
expect(found).toBeUndefined();
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ── Error handling ────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
describe("error handling", () => {
|
|
460
|
+
test("up status with unknown id exits non-zero", async () => {
|
|
461
|
+
await expect(cli("up", "status", "nonexistent-id-xyz")).rejects.toThrow();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("secret set with missing = exits non-zero", async () => {
|
|
465
|
+
await expect(
|
|
466
|
+
cli("secret", "set", "BADFORMAT", "--global", "--project", projectId),
|
|
467
|
+
).rejects.toThrow();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
afterAll(async () => {
|
|
474
|
+
for (const id of createdApps) {
|
|
475
|
+
await execa(LIZARD_CMD, [
|
|
476
|
+
...LIZARD_ARGS_PREFIX,
|
|
477
|
+
"service",
|
|
478
|
+
"rm",
|
|
479
|
+
id,
|
|
480
|
+
"-y",
|
|
481
|
+
"--project",
|
|
482
|
+
projectId,
|
|
483
|
+
]).catch(() => {});
|
|
484
|
+
}
|
|
485
|
+
if (DEPLOY_DIR) fs.rmSync(DEPLOY_DIR, { recursive: true, force: true });
|
|
486
|
+
const cfg = loadConfig();
|
|
487
|
+
if (cfg.projects?.[FIXTURE]) {
|
|
488
|
+
delete cfg.projects[FIXTURE];
|
|
489
|
+
saveConfig(cfg);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withQuery, withScope } from "../../src/lib/api.js";
|
|
3
|
+
|
|
4
|
+
describe("withQuery", () => {
|
|
5
|
+
test("returns path unchanged when no params", () => {
|
|
6
|
+
expect(withQuery("/api/projects", {})).toBe("/api/projects");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("appends a single param", () => {
|
|
10
|
+
expect(withQuery("/api/projects", { workspaceId: "ws_1" })).toBe(
|
|
11
|
+
"/api/projects?workspaceId=ws_1",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("appends multiple params", () => {
|
|
16
|
+
const url = withQuery("/api/projects", {
|
|
17
|
+
workspaceId: "ws_1",
|
|
18
|
+
branch: "main",
|
|
19
|
+
});
|
|
20
|
+
expect(url).toContain("workspaceId=ws_1");
|
|
21
|
+
expect(url).toContain("branch=main");
|
|
22
|
+
expect(url.startsWith("/api/projects?")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("skips null/undefined/empty values", () => {
|
|
26
|
+
const url = withQuery("/api/projects", {
|
|
27
|
+
workspaceId: "ws_1",
|
|
28
|
+
missing: null,
|
|
29
|
+
branch: undefined,
|
|
30
|
+
empty: "",
|
|
31
|
+
});
|
|
32
|
+
expect(url).toBe("/api/projects?workspaceId=ws_1");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("uses & when path already has a query", () => {
|
|
36
|
+
const url = withQuery("/api/projects?foo=bar", { workspaceId: "ws_1" });
|
|
37
|
+
expect(url).toBe("/api/projects?foo=bar&workspaceId=ws_1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("URL-encodes special chars", () => {
|
|
41
|
+
const url = withQuery("/api/projects", { branch: "feat/x" });
|
|
42
|
+
expect(url).toBe("/api/projects?branch=feat%2Fx");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("withScope", () => {
|
|
47
|
+
test("returns path unchanged when scope is undefined", () => {
|
|
48
|
+
expect(withScope("/api/projects/X/apps")).toBe("/api/projects/X/apps");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns path unchanged when scope is empty", () => {
|
|
52
|
+
expect(withScope("/api/projects/X/apps", {})).toBe("/api/projects/X/apps");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("adds workspaceId", () => {
|
|
56
|
+
expect(
|
|
57
|
+
withScope("/api/projects/X/apps", { workspaceId: "ws_1" }),
|
|
58
|
+
).toBe("/api/projects/X/apps?workspaceId=ws_1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("treats null workspaceId as missing", () => {
|
|
62
|
+
expect(
|
|
63
|
+
withScope("/api/projects/X/apps", { workspaceId: null }),
|
|
64
|
+
).toBe("/api/projects/X/apps");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, 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 {
|
|
6
|
+
setProjectLink,
|
|
7
|
+
getProjectLink,
|
|
8
|
+
updateProjectLink,
|
|
9
|
+
} from "../../src/lib/config.js";
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let originalLizardHome: string | undefined;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalLizardHome = process.env.LIZARD_HOME;
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lizard-config-test-"));
|
|
17
|
+
process.env.LIZARD_HOME = tmpDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (originalLizardHome === undefined) delete process.env.LIZARD_HOME;
|
|
22
|
+
else process.env.LIZARD_HOME = originalLizardHome;
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("ProjectLink schema", () => {
|
|
28
|
+
test("setProjectLink + getProjectLink round-trip with workspaceId", () => {
|
|
29
|
+
setProjectLink(
|
|
30
|
+
{
|
|
31
|
+
projectId: "proj_1",
|
|
32
|
+
projectName: "demo",
|
|
33
|
+
workspaceId: "ws_1",
|
|
34
|
+
workspaceName: "acme-team",
|
|
35
|
+
},
|
|
36
|
+
tmpDir,
|
|
37
|
+
);
|
|
38
|
+
const got = getProjectLink(tmpDir);
|
|
39
|
+
expect(got?.projectId).toBe("proj_1");
|
|
40
|
+
expect(got?.workspaceId).toBe("ws_1");
|
|
41
|
+
expect(got?.workspaceName).toBe("acme-team");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("getProjectLink mirrors legacy appId/appName onto serviceId/serviceName", () => {
|
|
45
|
+
const cfgFile = path.join(tmpDir, ".lizard", "config.json");
|
|
46
|
+
fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
cfgFile,
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
projects: {
|
|
51
|
+
[tmpDir]: {
|
|
52
|
+
projectId: "proj_legacy",
|
|
53
|
+
projectName: "legacy",
|
|
54
|
+
appId: "app_old",
|
|
55
|
+
appName: "old-app",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const link = getProjectLink(tmpDir);
|
|
62
|
+
expect(link?.serviceId).toBe("app_old");
|
|
63
|
+
expect(link?.serviceName).toBe("old-app");
|
|
64
|
+
expect(link?.workspaceId).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("updateProjectLink merges fields without dropping existing ones", () => {
|
|
68
|
+
setProjectLink({ projectId: "proj_1", projectName: "demo" }, tmpDir);
|
|
69
|
+
updateProjectLink({ workspaceId: "ws_filled", workspaceName: "filled" }, tmpDir);
|
|
70
|
+
|
|
71
|
+
const got = getProjectLink(tmpDir);
|
|
72
|
+
expect(got?.projectId).toBe("proj_1");
|
|
73
|
+
expect(got?.projectName).toBe("demo");
|
|
74
|
+
expect(got?.workspaceId).toBe("ws_filled");
|
|
75
|
+
expect(got?.workspaceName).toBe("filled");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("config.json without workspaceId still loads (legacy compat)", () => {
|
|
79
|
+
const cfgFile = path.join(tmpDir, ".lizard", "config.json");
|
|
80
|
+
fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
cfgFile,
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
projects: {
|
|
85
|
+
[tmpDir]: { projectId: "proj_1", projectName: "demo" },
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const link = getProjectLink(tmpDir);
|
|
91
|
+
expect(link?.projectId).toBe("proj_1");
|
|
92
|
+
expect(link?.workspaceId).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|