@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { api, withQuery, type Workspace } from "./api.js";
|
|
3
|
+
import { isTTY } from "./format.js";
|
|
4
|
+
|
|
5
|
+
export async function fetchWorkspaces(): Promise<Workspace[]> {
|
|
6
|
+
return api.get<Workspace[]>("/api/workspaces");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function matchWorkspace(
|
|
10
|
+
workspaces: Workspace[],
|
|
11
|
+
idOrSlugOrName: string,
|
|
12
|
+
): Workspace | undefined {
|
|
13
|
+
const lower = idOrSlugOrName.toLowerCase();
|
|
14
|
+
return workspaces.find(
|
|
15
|
+
(w) =>
|
|
16
|
+
w.id.toLowerCase() === lower ||
|
|
17
|
+
w.slug.toLowerCase() === lower ||
|
|
18
|
+
w.name.toLowerCase() === lower,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a workspace by flag value. Fetches the list once; throws with the
|
|
24
|
+
* available names when the value doesn't match anything the user belongs to.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveWorkspace(flagValue: string): Promise<Workspace> {
|
|
27
|
+
const workspaces = await fetchWorkspaces();
|
|
28
|
+
const match = matchWorkspace(workspaces, flagValue);
|
|
29
|
+
if (!match) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Workspace "${flagValue}" not found. Available: ${
|
|
32
|
+
workspaces.map((w) => w.name).join(", ") || "(none)"
|
|
33
|
+
}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return match;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick a workspace for project creation / linking.
|
|
41
|
+
*
|
|
42
|
+
* 1. --workspace flag wins.
|
|
43
|
+
* 2. If only one workspace exists, auto-select it.
|
|
44
|
+
* 3. Non-TTY → prefer personal, else first.
|
|
45
|
+
* 4. TTY → prompt.
|
|
46
|
+
*
|
|
47
|
+
* `projectNameHint` lets the caller short-circuit when --name X uniquely
|
|
48
|
+
* identifies a workspace (see init flow case 5).
|
|
49
|
+
*/
|
|
50
|
+
export async function pickWorkspace(opts: {
|
|
51
|
+
flag?: string;
|
|
52
|
+
projectNameHint?: string;
|
|
53
|
+
workspaces?: Workspace[];
|
|
54
|
+
}): Promise<Workspace> {
|
|
55
|
+
if (opts.flag) return resolveWorkspace(opts.flag);
|
|
56
|
+
|
|
57
|
+
const list = opts.workspaces ?? (await fetchWorkspaces());
|
|
58
|
+
if (list.length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"No workspaces available. The backend should always return a personal workspace — please report this.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (list.length === 1) return list[0];
|
|
64
|
+
|
|
65
|
+
// Hint-based auto-pick: a --name match that is unique across workspaces.
|
|
66
|
+
if (opts.projectNameHint) {
|
|
67
|
+
const matches = await findWorkspacesContainingProject(
|
|
68
|
+
list,
|
|
69
|
+
opts.projectNameHint,
|
|
70
|
+
);
|
|
71
|
+
if (matches.length === 1) return matches[0].workspace;
|
|
72
|
+
if (matches.length > 1) {
|
|
73
|
+
const detail = matches
|
|
74
|
+
.map((m) => ` • ${m.project.name} in ${m.workspace.name} (--workspace ${m.workspace.slug})`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Multiple projects named "${opts.projectNameHint}" found:\n${detail}\nPass --workspace, or run \`lizard init\` without --name to choose interactively.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!isTTY()) {
|
|
83
|
+
// Non-TTY fallback: personal first, else first ws.
|
|
84
|
+
const personal = list.find((w) => w.isPersonal);
|
|
85
|
+
return personal ?? list[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sel = await p.select({
|
|
89
|
+
message: "Workspace",
|
|
90
|
+
options: list.map((w) => ({
|
|
91
|
+
value: w.id,
|
|
92
|
+
label: w.name,
|
|
93
|
+
hint: `${w.role}${w.isPersonal ? " · personal" : ""} · ${w.projectCount ?? 0} project${(w.projectCount ?? 0) === 1 ? "" : "s"}`,
|
|
94
|
+
})),
|
|
95
|
+
});
|
|
96
|
+
if (p.isCancel(sel)) process.exit(5);
|
|
97
|
+
return list.find((w) => w.id === sel)!;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ProjectLite {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
slug: string;
|
|
104
|
+
workspaceId?: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function findWorkspacesContainingProject(
|
|
108
|
+
workspaces: Workspace[],
|
|
109
|
+
nameOrSlugOrId: string,
|
|
110
|
+
): Promise<Array<{ workspace: Workspace; project: ProjectLite }>> {
|
|
111
|
+
const all = await api.get<ProjectLite[]>("/api/projects");
|
|
112
|
+
const lower = nameOrSlugOrId.toLowerCase();
|
|
113
|
+
const matches: Array<{ workspace: Workspace; project: ProjectLite }> = [];
|
|
114
|
+
for (const w of workspaces) {
|
|
115
|
+
const inWs = all.find(
|
|
116
|
+
(pr) =>
|
|
117
|
+
pr.workspaceId === w.id &&
|
|
118
|
+
(pr.id.toLowerCase() === lower ||
|
|
119
|
+
pr.slug.toLowerCase() === lower ||
|
|
120
|
+
pr.name.toLowerCase() === lower),
|
|
121
|
+
);
|
|
122
|
+
if (inWs) matches.push({ workspace: w, project: inWs });
|
|
123
|
+
}
|
|
124
|
+
return matches;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function listProjectsInWorkspace(
|
|
128
|
+
workspaceId: string,
|
|
129
|
+
): Promise<ProjectLite[]> {
|
|
130
|
+
return api.get<ProjectLite[]>(
|
|
131
|
+
withQuery("/api/projects", { workspaceId }),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { api, withScope, type ResourceScope } from "./api.js";
|
|
2
|
+
import {
|
|
3
|
+
getProjectLink,
|
|
4
|
+
resolveProjectId,
|
|
5
|
+
updateProjectLink,
|
|
6
|
+
} from "./config.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a ResourceScope for an arbitrary project. When the project matches the
|
|
10
|
+
* cwd link, reuse its workspaceId (no extra round-trip). Otherwise fetch the
|
|
11
|
+
* project's workspace once so cross-project commands (`-p other-project`) still
|
|
12
|
+
* get `?workspaceId=…` on the request — without it the backend can 404 a
|
|
13
|
+
* project the user reaches via workspace membership.
|
|
14
|
+
*/
|
|
15
|
+
export async function scopeForProject(projectId: string): Promise<ResourceScope> {
|
|
16
|
+
const link = getProjectLink();
|
|
17
|
+
if (link?.projectId === projectId && link.workspaceId) {
|
|
18
|
+
return { workspaceId: link.workspaceId };
|
|
19
|
+
}
|
|
20
|
+
const fetched = await lookupProjectWorkspace(projectId);
|
|
21
|
+
return { workspaceId: fetched?.workspaceId ?? null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AppLite {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
status?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AddonLite {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
addonType?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ServicesResponse {
|
|
38
|
+
apps?: AppLite[];
|
|
39
|
+
addons?: AddonLite[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a service (app or addon) within a project. Match by ID or name.
|
|
44
|
+
* Throws with a helpful list of available services when not found.
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveService(
|
|
47
|
+
projectId: string,
|
|
48
|
+
nameOrId: string,
|
|
49
|
+
): Promise<{ id: string; name: string; kind: "app" | "addon" }> {
|
|
50
|
+
const data = await api.get<ServicesResponse>(
|
|
51
|
+
withScope(`/api/projects/${projectId}/services`, await scopeForProject(projectId)),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const apps = data.apps || [];
|
|
55
|
+
const addons = data.addons || [];
|
|
56
|
+
|
|
57
|
+
const lower = nameOrId.toLowerCase();
|
|
58
|
+
|
|
59
|
+
const app = apps.find(
|
|
60
|
+
(a) => a.id.toLowerCase() === lower || a.name?.toLowerCase() === lower,
|
|
61
|
+
);
|
|
62
|
+
if (app) return { id: app.id, name: app.name, kind: "app" };
|
|
63
|
+
|
|
64
|
+
const addon = addons.find(
|
|
65
|
+
(a) =>
|
|
66
|
+
a.id.toLowerCase() === lower ||
|
|
67
|
+
a.name?.toLowerCase() === lower ||
|
|
68
|
+
a.addonType?.toLowerCase() === lower,
|
|
69
|
+
);
|
|
70
|
+
if (addon) {
|
|
71
|
+
return { id: addon.id, name: addon.name || addon.addonType || "", kind: "addon" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const available = [
|
|
75
|
+
...apps.map((a) => a.name),
|
|
76
|
+
...addons.map((a) => a.name || a.addonType),
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Service "${nameOrId}" not found in project. ` +
|
|
81
|
+
(available.length ? `Available: ${available.join(", ")}` : "No services exist."),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pick the active service for a command:
|
|
87
|
+
* 1. --service flag (resolve by name/id)
|
|
88
|
+
* 2. linked service in cwd
|
|
89
|
+
* 3. throw with hint to pass --service
|
|
90
|
+
*/
|
|
91
|
+
export async function getActiveService(
|
|
92
|
+
serviceFlag: string | undefined,
|
|
93
|
+
projectId: string,
|
|
94
|
+
): Promise<{ id: string; name: string }> {
|
|
95
|
+
if (serviceFlag) {
|
|
96
|
+
const resolved = await resolveService(projectId, serviceFlag);
|
|
97
|
+
return { id: resolved.id, name: resolved.name };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const link = getProjectLink();
|
|
101
|
+
if (link?.serviceId) {
|
|
102
|
+
return {
|
|
103
|
+
id: link.serviceId,
|
|
104
|
+
name: link.serviceName || link.serviceId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw new Error(
|
|
109
|
+
"No service specified. Pass --service <name> or run `lizard service link <name>`.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Same as `getActiveService`, but also returns whether the target is an app
|
|
115
|
+
* or an addon. Costs one extra `/services` lookup when the service is taken
|
|
116
|
+
* from the cwd link (which otherwise resolves locally), so use this only when
|
|
117
|
+
* the caller branches on kind (e.g. `lizard scale`).
|
|
118
|
+
*/
|
|
119
|
+
export async function getActiveServiceWithKind(
|
|
120
|
+
serviceFlag: string | undefined,
|
|
121
|
+
projectId: string,
|
|
122
|
+
): Promise<{ id: string; name: string; kind: "app" | "addon" }> {
|
|
123
|
+
if (serviceFlag) {
|
|
124
|
+
return resolveService(projectId, serviceFlag);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const link = getProjectLink();
|
|
128
|
+
if (link?.serviceId) {
|
|
129
|
+
const data = await api.get<ServicesResponse>(
|
|
130
|
+
withScope(`/api/projects/${projectId}/services`, await scopeForProject(projectId)),
|
|
131
|
+
);
|
|
132
|
+
const app = data.apps?.find((a) => a.id === link.serviceId);
|
|
133
|
+
if (app) return { id: app.id, name: app.name, kind: "app" };
|
|
134
|
+
const addon = data.addons?.find((a) => a.id === link.serviceId);
|
|
135
|
+
if (addon) {
|
|
136
|
+
return {
|
|
137
|
+
id: addon.id,
|
|
138
|
+
name: addon.name || addon.addonType || link.serviceName || link.serviceId,
|
|
139
|
+
kind: "addon",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Linked service "${link.serviceName || link.serviceId}" no longer exists in this project.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(
|
|
148
|
+
"No service specified. Pass --service <name> or run `lizard service link <name>`.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Look up the workspace id for a project. Used to lazy-fill legacy links
|
|
154
|
+
* that were saved before workspaces existed. Returns null if the project
|
|
155
|
+
* isn't accessible to the current user.
|
|
156
|
+
*/
|
|
157
|
+
export async function lookupProjectWorkspace(
|
|
158
|
+
projectId: string,
|
|
159
|
+
): Promise<{ workspaceId?: string | null; workspaceName?: string | null } | null> {
|
|
160
|
+
try {
|
|
161
|
+
const proj = await api.get<{
|
|
162
|
+
workspaceId?: string | null;
|
|
163
|
+
workspaceName?: string | null;
|
|
164
|
+
}>(`/api/projects/${projectId}`);
|
|
165
|
+
return {
|
|
166
|
+
workspaceId: proj.workspaceId ?? null,
|
|
167
|
+
workspaceName: proj.workspaceName ?? null,
|
|
168
|
+
};
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a project flag → `{ projectId, scope }`. Scope carries
|
|
176
|
+
* workspaceId for `withScope(url, scope)` queries.
|
|
177
|
+
*
|
|
178
|
+
* For the linked project, lazy-fills missing workspaceId into the cwd link.
|
|
179
|
+
* For a different project (`-p other-project`), fetches the target's
|
|
180
|
+
* workspace so the scope reflects the target — not whatever the cwd link
|
|
181
|
+
* happened to be pointing at.
|
|
182
|
+
*/
|
|
183
|
+
export async function resolveProjectScope(
|
|
184
|
+
projectFlag?: string,
|
|
185
|
+
): Promise<{ projectId: string; scope: ResourceScope }> {
|
|
186
|
+
const projectId = await resolveProjectId(projectFlag);
|
|
187
|
+
const link = getProjectLink();
|
|
188
|
+
|
|
189
|
+
// Same project as the cwd link — reuse cached workspaceId, lazy-fill if missing.
|
|
190
|
+
if (link?.projectId === projectId) {
|
|
191
|
+
let workspaceId: string | null | undefined = link.workspaceId ?? null;
|
|
192
|
+
let workspaceName: string | undefined = link.workspaceName;
|
|
193
|
+
if (!workspaceId) {
|
|
194
|
+
const fetched = await lookupProjectWorkspace(projectId);
|
|
195
|
+
if (fetched?.workspaceId) {
|
|
196
|
+
workspaceId = fetched.workspaceId;
|
|
197
|
+
workspaceName = fetched.workspaceName ?? undefined;
|
|
198
|
+
try {
|
|
199
|
+
updateProjectLink({ workspaceId, workspaceName });
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { projectId, scope: { workspaceId: workspaceId ?? null } };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Different project — never reuse the link's workspaceId. Look up the
|
|
207
|
+
// target project's workspace directly. Mirrors `scopeForProject`.
|
|
208
|
+
const scope = await scopeForProject(projectId);
|
|
209
|
+
return { projectId, scope };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ResolvedContext {
|
|
213
|
+
projectId: string;
|
|
214
|
+
workspaceId?: string;
|
|
215
|
+
workspaceName?: string;
|
|
216
|
+
service?: { id: string; name: string };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Build the scope object for `withScope(url, scope)` API calls. */
|
|
220
|
+
export function getScope(ctx: ResolvedContext): ResourceScope {
|
|
221
|
+
return {
|
|
222
|
+
workspaceId: ctx.workspaceId ?? null,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Convenience: resolve project + active service in one go.
|
|
228
|
+
*
|
|
229
|
+
* Lazily backfills `workspaceId` into the cwd link when missing (legacy
|
|
230
|
+
* configs written before workspaces existed). Once filled, subsequent
|
|
231
|
+
* commands get the scope param for free.
|
|
232
|
+
*/
|
|
233
|
+
export async function resolveContext(opts: {
|
|
234
|
+
projectFlag?: string;
|
|
235
|
+
serviceFlag?: string;
|
|
236
|
+
workspaceFlag?: string;
|
|
237
|
+
requireService?: boolean;
|
|
238
|
+
}): Promise<ResolvedContext> {
|
|
239
|
+
const projectId = await resolveProjectId(opts.projectFlag);
|
|
240
|
+
|
|
241
|
+
let service: { id: string; name: string } | undefined;
|
|
242
|
+
const link = getProjectLink();
|
|
243
|
+
if (opts.serviceFlag || opts.requireService) {
|
|
244
|
+
service = await getActiveService(opts.serviceFlag, projectId);
|
|
245
|
+
} else if (link?.serviceId) {
|
|
246
|
+
service = {
|
|
247
|
+
id: link.serviceId,
|
|
248
|
+
name: link.serviceName || link.serviceId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Workspace resolution: only reuse the link's workspaceId when it
|
|
253
|
+
// describes the same project. For cross-project commands we fetch the
|
|
254
|
+
// target project's workspace so the scope is correct — using the link's
|
|
255
|
+
// ws here would scope into the wrong workspace.
|
|
256
|
+
let workspaceId: string | undefined;
|
|
257
|
+
let workspaceName: string | undefined;
|
|
258
|
+
if (link?.projectId === projectId) {
|
|
259
|
+
workspaceId = link.workspaceId;
|
|
260
|
+
workspaceName = link.workspaceName;
|
|
261
|
+
if (!workspaceId) {
|
|
262
|
+
const fetched = await lookupProjectWorkspace(projectId);
|
|
263
|
+
if (fetched?.workspaceId) {
|
|
264
|
+
workspaceId = fetched.workspaceId;
|
|
265
|
+
workspaceName = fetched.workspaceName ?? undefined;
|
|
266
|
+
try {
|
|
267
|
+
updateProjectLink({ workspaceId, workspaceName });
|
|
268
|
+
} catch {
|
|
269
|
+
// Non-fatal: link may not exist for this cwd (e.g. --project flag).
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
const fetched = await lookupProjectWorkspace(projectId);
|
|
275
|
+
workspaceId = fetched?.workspaceId ?? undefined;
|
|
276
|
+
workspaceName = fetched?.workspaceName ?? undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
projectId,
|
|
281
|
+
workspaceId,
|
|
282
|
+
workspaceName,
|
|
283
|
+
service,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, renameSync, chmodSync } from "node:fs";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export const CURRENT_VERSION = "0.3.30";
|
|
9
|
+
const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
|
|
10
|
+
const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
|
|
11
|
+
|
|
12
|
+
function getBinaryName(): string | null {
|
|
13
|
+
const os = process.platform;
|
|
14
|
+
const arch = process.arch;
|
|
15
|
+
if (os === "darwin" && arch === "arm64") return "lizard-darwin-arm64";
|
|
16
|
+
if (os === "darwin" && arch === "x64") return "lizard-darwin-x64";
|
|
17
|
+
if (os === "linux" && arch === "x64") return "lizard-linux-x64";
|
|
18
|
+
if (os === "linux" && arch === "arm64") return "lizard-linux-arm64";
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LatestVersionResult =
|
|
23
|
+
| { kind: "ok"; version: string }
|
|
24
|
+
| { kind: "rate-limited"; resetAt: number }
|
|
25
|
+
| { kind: "error" };
|
|
26
|
+
|
|
27
|
+
export async function getLatestVersion(): Promise<LatestVersionResult> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(RELEASES_API, {
|
|
30
|
+
headers: { "User-Agent": "lizard-cli" },
|
|
31
|
+
signal: AbortSignal.timeout(5000),
|
|
32
|
+
});
|
|
33
|
+
if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
|
|
34
|
+
const reset = Number(res.headers.get("x-ratelimit-reset"));
|
|
35
|
+
return { kind: "rate-limited", resetAt: Number.isFinite(reset) ? reset : 0 };
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok) return { kind: "error" };
|
|
38
|
+
const data = (await res.json()) as { tag_name?: string };
|
|
39
|
+
const version = data.tag_name?.replace(/^v/, "");
|
|
40
|
+
return version ? { kind: "ok", version } : { kind: "error" };
|
|
41
|
+
} catch {
|
|
42
|
+
return { kind: "error" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function selfUpdate(onProgress?: (msg: string) => void): Promise<boolean> {
|
|
47
|
+
const binaryName = getBinaryName();
|
|
48
|
+
if (!binaryName) return false;
|
|
49
|
+
|
|
50
|
+
// Find current executable path
|
|
51
|
+
const currentBin = process.execPath;
|
|
52
|
+
if (!existsSync(currentBin)) return false;
|
|
53
|
+
|
|
54
|
+
const url = `${RELEASE_BASE}/${binaryName}`;
|
|
55
|
+
const tmp = join(tmpdir(), `lizard-update-${Date.now()}`);
|
|
56
|
+
|
|
57
|
+
onProgress?.(`Downloading ${binaryName}...`);
|
|
58
|
+
|
|
59
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
|
60
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
61
|
+
|
|
62
|
+
const writer = createWriteStream(tmp);
|
|
63
|
+
await pipeline(Readable.fromWeb(res.body as any), writer);
|
|
64
|
+
chmodSync(tmp, 0o755);
|
|
65
|
+
|
|
66
|
+
onProgress?.("Installing...");
|
|
67
|
+
renameSync(tmp, currentBin);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Check for a newer version and auto-install it in the background.
|
|
72
|
+
* Prints a one-line notice on exit — either "Updated to vX.Y.Z" or nothing on failure.
|
|
73
|
+
* Never blocks or crashes the current command. */
|
|
74
|
+
export function checkForUpdateInBackground(): void {
|
|
75
|
+
// Only auto-update in TTY; skip CI / piped output
|
|
76
|
+
if (!process.stdout.isTTY) return;
|
|
77
|
+
|
|
78
|
+
let updateMessage: string | null = null;
|
|
79
|
+
|
|
80
|
+
const promise = getLatestVersion().then(async (r) => {
|
|
81
|
+
if (r.kind !== "ok") return;
|
|
82
|
+
const latest = r.version;
|
|
83
|
+
if (latest === CURRENT_VERSION) return;
|
|
84
|
+
const [maj, min, pat] = latest.split(".").map(Number);
|
|
85
|
+
const [cmaj, cmin, cpat] = CURRENT_VERSION.split(".").map(Number);
|
|
86
|
+
const isNewer = maj > cmaj || (maj === cmaj && min > cmin) || (maj === cmaj && min === cmin && pat > cpat);
|
|
87
|
+
if (!isNewer) return;
|
|
88
|
+
try {
|
|
89
|
+
const ok = await selfUpdate();
|
|
90
|
+
if (ok) {
|
|
91
|
+
updateMessage =
|
|
92
|
+
`\n Updating lizard v${CURRENT_VERSION} → v${latest}...\n` +
|
|
93
|
+
` lizard updated to v${latest}\n`;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// silent — don't interrupt the current command
|
|
97
|
+
}
|
|
98
|
+
}).catch(() => {});
|
|
99
|
+
|
|
100
|
+
process.on("exit", () => {
|
|
101
|
+
if (updateMessage) process.stderr.write(updateMessage);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Don't block process exit
|
|
105
|
+
if (typeof (promise as any).unref === "function") (promise as any).unref();
|
|
106
|
+
}
|