@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.
Files changed (184) hide show
  1. package/.github/workflows/release.yml +90 -0
  2. package/AGENTS.md +113 -0
  3. package/README.md +41 -0
  4. package/dist/commands/add.js +318 -45
  5. package/dist/commands/add.js.map +1 -1
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +68 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/docs.d.ts +2 -0
  10. package/dist/commands/docs.js +13 -0
  11. package/dist/commands/docs.js.map +1 -0
  12. package/dist/commands/domain.d.ts +9 -0
  13. package/dist/commands/domain.js +195 -0
  14. package/dist/commands/domain.js.map +1 -0
  15. package/dist/commands/git.js +175 -36
  16. package/dist/commands/git.js.map +1 -1
  17. package/dist/commands/init.d.ts +24 -0
  18. package/dist/commands/init.js +128 -86
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/link.d.ts +7 -0
  21. package/dist/commands/link.js +104 -33
  22. package/dist/commands/link.js.map +1 -1
  23. package/dist/commands/login.js +4 -3
  24. package/dist/commands/login.js.map +1 -1
  25. package/dist/commands/logs.js +223 -30
  26. package/dist/commands/logs.js.map +1 -1
  27. package/dist/commands/open.js +3 -2
  28. package/dist/commands/open.js.map +1 -1
  29. package/dist/commands/port.d.ts +7 -0
  30. package/dist/commands/port.js +49 -0
  31. package/dist/commands/port.js.map +1 -0
  32. package/dist/commands/projects.js +36 -6
  33. package/dist/commands/projects.js.map +1 -1
  34. package/dist/commands/ps.js +32 -39
  35. package/dist/commands/ps.js.map +1 -1
  36. package/dist/commands/redeploy.js +48 -8
  37. package/dist/commands/redeploy.js.map +1 -1
  38. package/dist/commands/regions.js +2 -5
  39. package/dist/commands/regions.js.map +1 -1
  40. package/dist/commands/restart.js +84 -10
  41. package/dist/commands/restart.js.map +1 -1
  42. package/dist/commands/run.d.ts +9 -0
  43. package/dist/commands/run.js +61 -22
  44. package/dist/commands/run.js.map +1 -1
  45. package/dist/commands/scale.d.ts +10 -0
  46. package/dist/commands/scale.js +166 -0
  47. package/dist/commands/scale.js.map +1 -0
  48. package/dist/commands/secrets.js +200 -89
  49. package/dist/commands/secrets.js.map +1 -1
  50. package/dist/commands/service-set.d.ts +49 -0
  51. package/dist/commands/service-set.js +552 -0
  52. package/dist/commands/service-set.js.map +1 -0
  53. package/dist/commands/service-show.d.ts +11 -0
  54. package/dist/commands/service-show.js +44 -0
  55. package/dist/commands/service-show.js.map +1 -0
  56. package/dist/commands/service.d.ts +8 -0
  57. package/dist/commands/service.js +262 -0
  58. package/dist/commands/service.js.map +1 -0
  59. package/dist/commands/skill.d.ts +2 -0
  60. package/dist/commands/skill.js +146 -0
  61. package/dist/commands/skill.js.map +1 -0
  62. package/dist/commands/ssh.d.ts +2 -0
  63. package/dist/commands/ssh.js +161 -0
  64. package/dist/commands/ssh.js.map +1 -0
  65. package/dist/commands/status.d.ts +7 -0
  66. package/dist/commands/status.js +49 -38
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/unlink.d.ts +5 -0
  69. package/dist/commands/unlink.js +18 -0
  70. package/dist/commands/unlink.js.map +1 -0
  71. package/dist/commands/up.d.ts +9 -0
  72. package/dist/commands/up.js +417 -0
  73. package/dist/commands/up.js.map +1 -0
  74. package/dist/commands/upgrade.d.ts +2 -0
  75. package/dist/commands/upgrade.js +79 -0
  76. package/dist/commands/upgrade.js.map +1 -0
  77. package/dist/commands/whoami.js +26 -6
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/commands/workspace.d.ts +8 -0
  80. package/dist/commands/workspace.js +36 -0
  81. package/dist/commands/workspace.js.map +1 -0
  82. package/dist/index.js +209 -82
  83. package/dist/index.js.map +1 -1
  84. package/dist/lib/api.d.ts +17 -2
  85. package/dist/lib/api.js +85 -51
  86. package/dist/lib/api.js.map +1 -1
  87. package/dist/lib/auth.d.ts +3 -11
  88. package/dist/lib/auth.js +16 -36
  89. package/dist/lib/auth.js.map +1 -1
  90. package/dist/lib/config.d.ts +36 -15
  91. package/dist/lib/config.js +71 -58
  92. package/dist/lib/config.js.map +1 -1
  93. package/dist/lib/format.d.ts +1 -0
  94. package/dist/lib/format.js +17 -4
  95. package/dist/lib/format.js.map +1 -1
  96. package/dist/lib/name.d.ts +11 -0
  97. package/dist/lib/name.js +26 -0
  98. package/dist/lib/name.js.map +1 -0
  99. package/dist/lib/picker.d.ts +32 -0
  100. package/dist/lib/picker.js +91 -0
  101. package/dist/lib/picker.js.map +1 -0
  102. package/dist/lib/resolve.d.ts +85 -0
  103. package/dist/lib/resolve.js +203 -0
  104. package/dist/lib/resolve.js.map +1 -0
  105. package/dist/lib/updater.d.ts +16 -0
  106. package/dist/lib/updater.js +102 -0
  107. package/dist/lib/updater.js.map +1 -0
  108. package/lizard-wrapper.sh +2 -0
  109. package/package.json +11 -3
  110. package/skill-data/core/SKILL.md +239 -0
  111. package/src/commands/add.ts +388 -56
  112. package/src/commands/config.ts +80 -0
  113. package/src/commands/docs.ts +15 -0
  114. package/src/commands/domain.ts +248 -0
  115. package/src/commands/git.ts +201 -40
  116. package/src/commands/init.ts +149 -100
  117. package/src/commands/link.ts +127 -35
  118. package/src/commands/login.ts +4 -3
  119. package/src/commands/logs.ts +283 -27
  120. package/src/commands/open.ts +3 -2
  121. package/src/commands/port.ts +57 -0
  122. package/src/commands/projects.ts +43 -6
  123. package/src/commands/ps.ts +39 -60
  124. package/src/commands/redeploy.ts +51 -10
  125. package/src/commands/regions.ts +2 -6
  126. package/src/commands/restart.ts +84 -10
  127. package/src/commands/run.ts +68 -24
  128. package/src/commands/scale.ts +216 -0
  129. package/src/commands/secrets.ts +277 -100
  130. package/src/commands/service-set.ts +669 -0
  131. package/src/commands/service-show.ts +52 -0
  132. package/src/commands/service.ts +298 -0
  133. package/src/commands/skill.ts +157 -0
  134. package/src/commands/ssh.ts +176 -0
  135. package/src/commands/status.ts +51 -46
  136. package/src/commands/unlink.ts +17 -0
  137. package/src/commands/up.ts +461 -0
  138. package/src/commands/upgrade.ts +87 -0
  139. package/src/commands/whoami.ts +34 -6
  140. package/src/commands/workspace.ts +44 -0
  141. package/src/index.ts +219 -85
  142. package/src/lib/api.ts +114 -51
  143. package/src/lib/auth.ts +22 -46
  144. package/src/lib/config.ts +100 -65
  145. package/src/lib/format.ts +18 -4
  146. package/src/lib/name.ts +27 -0
  147. package/src/lib/picker.ts +133 -0
  148. package/src/lib/resolve.ts +285 -0
  149. package/src/lib/updater.ts +106 -0
  150. package/test/cli.test.ts +491 -0
  151. package/test/fixtures/hello-app/Dockerfile +5 -0
  152. package/test/fixtures/hello-app/index.js +5 -0
  153. package/test/unit/api.test.ts +66 -0
  154. package/test/unit/config.test.ts +94 -0
  155. package/test/unit/init.test.ts +211 -0
  156. package/test/unit/json.test.ts +208 -0
  157. package/test/unit/picker.test.ts +161 -0
  158. package/test/unit/resolve.test.ts +124 -0
  159. package/test/unit/service-set.test.ts +355 -0
  160. package/vitest.config.ts +10 -0
  161. package/dist/commands/connect.d.ts +0 -2
  162. package/dist/commands/connect.js +0 -117
  163. package/dist/commands/connect.js.map +0 -1
  164. package/dist/commands/context.d.ts +0 -2
  165. package/dist/commands/context.js +0 -71
  166. package/dist/commands/context.js.map +0 -1
  167. package/dist/commands/deploy.d.ts +0 -2
  168. package/dist/commands/deploy.js +0 -120
  169. package/dist/commands/deploy.js.map +0 -1
  170. package/dist/commands/destroy.d.ts +0 -2
  171. package/dist/commands/destroy.js +0 -51
  172. package/dist/commands/destroy.js.map +0 -1
  173. package/dist/commands/update.d.ts +0 -2
  174. package/dist/commands/update.js +0 -41
  175. package/dist/commands/update.js.map +0 -1
  176. package/dist/commands/version.d.ts +0 -2
  177. package/dist/commands/version.js +0 -37
  178. package/dist/commands/version.js.map +0 -1
  179. package/src/commands/connect.ts +0 -145
  180. package/src/commands/context.ts +0 -93
  181. package/src/commands/deploy.ts +0 -153
  182. package/src/commands/destroy.ts +0 -51
  183. package/src/commands/update.ts +0 -44
  184. package/src/commands/version.ts +0 -37
@@ -1,12 +1,12 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
1
  import chalk from "chalk";
2
+ import path from "node:path";
4
3
  import * as p from "@clack/prompts";
5
4
  import { Command } from "commander";
6
- import { api } from "../lib/api.js";
5
+ import { api, withQuery, type Workspace } from "../lib/api.js";
7
6
  import {
8
- findProjectConfig,
9
- saveProjectConfig,
7
+ getProjectLink,
8
+ setProjectLink,
9
+ type ProjectLink,
10
10
  } from "../lib/config.js";
11
11
  import {
12
12
  success,
@@ -15,125 +15,174 @@ import {
15
15
  printJSON,
16
16
  isTTY,
17
17
  } from "../lib/format.js";
18
+ import { fetchWorkspaces, pickWorkspace } from "../lib/picker.js";
18
19
 
19
20
  interface Project {
20
21
  id: string;
21
22
  name: string;
22
23
  slug: string;
24
+ workspaceId?: string | null;
23
25
  }
24
26
 
25
- /** Detect framework from files in cwd */
26
- function detectFramework(): {
27
- name: string;
28
- port: number;
29
- buildCmd: string;
30
- startCmd: string;
31
- } | null {
32
- const cwd = process.cwd();
33
- const has = (f: string) => fs.existsSync(path.join(cwd, f));
27
+ /**
28
+ * Ensure the current directory is linked to a project. If already linked and
29
+ * `force` is false, returns the existing link. Otherwise runs the
30
+ * create-or-select flow.
31
+ *
32
+ * Flow (mirrors Railway's `link` wizard):
33
+ * 1. Workspace — selected by --workspace, single workspace auto-pick,
34
+ * or interactive select. Skipped when only one workspace exists.
35
+ * 2. Project matched by --name within the workspace, picked from the
36
+ * workspace's project list, or created.
37
+ *
38
+ * `projectName` (from --name / --project) is matched against existing
39
+ * projects inside the resolved workspace; if not found, a new project
40
+ * with that name is created in the resolved workspace.
41
+ */
42
+ export async function ensureLinked(opts: {
43
+ projectName?: string;
44
+ workspaceFlag?: string;
45
+ force?: boolean;
46
+ relinkPrompt?: boolean;
47
+ } = {}): Promise<ProjectLink> {
48
+ const existing = getProjectLink();
34
49
 
35
- if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts"))
36
- return { name: "Next.js", port: 3000, buildCmd: "npm run build", startCmd: "npm start" };
37
- if (has("nuxt.config.ts") || has("nuxt.config.js"))
38
- return { name: "Nuxt", port: 3000, buildCmd: "npm run build", startCmd: "npm start" };
39
- if (has("remix.config.js") || has("remix.config.ts"))
40
- return { name: "Remix", port: 3000, buildCmd: "npm run build", startCmd: "npm start" };
41
- if (has("astro.config.mjs") || has("astro.config.ts"))
42
- return { name: "Astro", port: 4321, buildCmd: "npm run build", startCmd: "npm start" };
43
- if (has("vite.config.ts") || has("vite.config.js"))
44
- return { name: "Vite", port: 3000, buildCmd: "npm run build", startCmd: "npm run preview" };
45
- if (has("Dockerfile"))
46
- return { name: "Docker", port: 8080, buildCmd: "", startCmd: "" };
47
- if (has("go.mod"))
48
- return { name: "Go", port: 8080, buildCmd: "go build -o app .", startCmd: "./app" };
49
- if (has("requirements.txt") || has("pyproject.toml"))
50
- return { name: "Python", port: 8000, buildCmd: "pip install -r requirements.txt", startCmd: "python app.py" };
51
- if (has("package.json"))
52
- return { name: "Node.js", port: 3000, buildCmd: "npm run build", startCmd: "npm start" };
53
- return null;
54
- }
50
+ if (existing && !opts.force) {
51
+ if (!opts.relinkPrompt) return existing;
52
+
53
+ if (!isTTY()) {
54
+ throw new Error(
55
+ `Already linked to ${existing.projectName || existing.projectId}. Use --force to relink.`,
56
+ );
57
+ }
58
+ const proceed = await p.confirm({
59
+ message: `Directory is linked to "${existing.projectName || existing.projectId}". Relink?`,
60
+ initialValue: false,
61
+ });
62
+ if (p.isCancel(proceed) || !proceed) return existing;
63
+ }
55
64
 
56
- function writeLizardToml(
57
- framework: { name: string; port: number; buildCmd: string; startCmd: string } | null,
58
- ) {
59
- const port = framework?.port ?? 3000;
60
- const build = framework?.buildCmd ?? "";
61
- const start = framework?.startCmd ?? "";
65
+ // 1. Workspace
66
+ const workspaces = await fetchWorkspaces();
67
+ const workspace = await pickWorkspace({
68
+ flag: opts.workspaceFlag,
69
+ projectNameHint: opts.projectName,
70
+ workspaces,
71
+ });
62
72
 
63
- let toml = `# lizard.toml\n\n`;
64
- if (build) toml += `[build]\ncommand = "${build}"\n\n`;
65
- toml += `[deploy]\nport = ${port}\n`;
66
- if (start) toml += `start_command = "${start}"\n`;
67
- toml += `\n[resources]\ncpu = 1\nmemory = 512\n`;
73
+ // 2. Project pulled from the chosen workspace only
74
+ const projects = await api.get<Project[]>(
75
+ withQuery("/api/projects", { workspaceId: workspace.id }),
76
+ );
68
77
 
69
- const tomlPath = path.join(process.cwd(), "lizard.toml");
70
- if (!fs.existsSync(tomlPath)) {
71
- fs.writeFileSync(tomlPath, toml);
78
+ let project: Project;
79
+
80
+ if (opts.projectName) {
81
+ const lower = opts.projectName.toLowerCase();
82
+ const match = projects.find(
83
+ (pr) =>
84
+ pr.name.toLowerCase() === lower ||
85
+ pr.slug.toLowerCase() === lower ||
86
+ pr.id.toLowerCase() === lower,
87
+ );
88
+ project =
89
+ match ??
90
+ (await api.post<Project>("/api/projects", {
91
+ name: opts.projectName,
92
+ workspaceId: workspace.id,
93
+ }));
94
+ } else if (!isTTY()) {
95
+ project = await api.post<Project>("/api/projects", {
96
+ name: path.basename(process.cwd()),
97
+ workspaceId: workspace.id,
98
+ });
99
+ } else {
100
+ let action: "create" | "select" = "create";
101
+ if (projects.length > 0) {
102
+ const choice = await p.select({
103
+ message: `Link a project in ${workspace.name}`,
104
+ options: [
105
+ { value: "create", label: "Create new project" },
106
+ { value: "select", label: "Select existing project" },
107
+ ],
108
+ });
109
+ if (p.isCancel(choice)) process.exit(5);
110
+ action = choice as "create" | "select";
111
+ }
112
+
113
+ if (action === "create") {
114
+ const nameRes = await p.text({
115
+ message: "Project name",
116
+ defaultValue: path.basename(process.cwd()),
117
+ placeholder: path.basename(process.cwd()),
118
+ });
119
+ if (p.isCancel(nameRes)) process.exit(5);
120
+ project = await api.post<Project>("/api/projects", {
121
+ name: nameRes as string,
122
+ workspaceId: workspace.id,
123
+ });
124
+ } else {
125
+ const selected = await p.select({
126
+ message: "Select project",
127
+ options: projects.map((pr) => ({
128
+ value: pr.id,
129
+ label: pr.name,
130
+ hint: pr.id,
131
+ })),
132
+ });
133
+ if (p.isCancel(selected)) process.exit(5);
134
+ project = projects.find((pr) => pr.id === selected)!;
135
+ }
72
136
  }
137
+
138
+ const link: ProjectLink = {
139
+ projectId: project.id,
140
+ projectName: project.name,
141
+ workspaceId: workspace.id,
142
+ workspaceName: workspace.name,
143
+ };
144
+ setProjectLink(link);
145
+ return link;
73
146
  }
74
147
 
75
148
  export function registerInit(program: Command) {
76
149
  program
77
150
  .command("init")
78
- .description("Create a new project and link current directory")
79
- .option("--name <name>", "Project name")
151
+ .description(
152
+ "Create or select a project and link it to the current directory",
153
+ )
154
+ .option("-n, --name <name>", "Project name (use existing or create if missing)")
155
+ .option("--project <name>", "Alias for --name (kept for backwards compat)")
156
+ .option("-w, --workspace <ws>", "Workspace id, slug, or name")
157
+ .option("--force", "Relink even if this directory is already linked")
80
158
  .action(async (opts) => {
81
- // Check if already initialized
82
- const existing = findProjectConfig();
83
- if (existing) {
84
- throw new Error(
85
- "Already initialized. Run `lizard link` to change project.",
86
- );
87
- }
88
-
89
- // Detect framework
90
- const framework = detectFramework();
91
- if (framework && !isJSONMode()) {
92
- info(`Detected framework: ${chalk.cyan(framework.name)}`);
93
- }
94
-
95
- // Get project name
96
- let projectName = opts.name;
97
- if (!projectName) {
98
- if (!isTTY()) {
99
- throw new Error("--name is required in non-interactive mode");
100
- }
101
- const result = await p.text({
102
- message: "Project name",
103
- defaultValue: path.basename(process.cwd()),
104
- placeholder: path.basename(process.cwd()),
105
- });
106
- if (p.isCancel(result)) process.exit(5);
107
- projectName = result as string;
108
- }
109
-
110
- // Create project via API
111
- const project = await api.post<Project>("/api/projects", {
112
- name: projectName,
113
- });
114
-
115
- // Save local config
116
- saveProjectConfig({
117
- projectId: project.id,
118
- projectName: project.name,
159
+ const projectName = opts.name || opts.project;
160
+ const link = await ensureLinked({
161
+ projectName,
162
+ workspaceFlag: opts.workspace,
163
+ force: opts.force,
164
+ relinkPrompt: true,
119
165
  });
120
166
 
121
- // Write lizard.toml
122
- writeLizardToml(framework);
123
-
124
167
  if (isJSONMode()) {
125
168
  printJSON({
126
- projectId: project.id,
127
- name: project.name,
128
- framework: framework?.name,
169
+ projectId: link.projectId,
170
+ name: link.projectName,
171
+ workspaceId: link.workspaceId,
172
+ workspaceName: link.workspaceName,
129
173
  });
130
174
  } else {
131
- success(`Project "${chalk.bold(project.name)}" created`);
132
- info(chalk.dim(" Linked to current directory"));
133
- info(chalk.dim(" Config saved to .lizard/config.json"));
134
- if (framework) {
135
- info(chalk.dim(` lizard.toml created for ${framework.name}`));
136
- }
175
+ const wsLabel = link.workspaceName
176
+ ? chalk.dim(` (${link.workspaceName})`)
177
+ : "";
178
+ success(
179
+ `Linked to ${chalk.bold(link.projectName || link.projectId)}${wsLabel}`,
180
+ );
181
+ info(chalk.dim(" Saved to ~/.lizard/config.json"));
137
182
  }
138
183
  });
139
184
  }
185
+
186
+ // Re-export Workspace so consumers can import the type from this module if
187
+ // they ever want to (kept for older imports, harmless if unused).
188
+ export type { Workspace };
@@ -1,63 +1,155 @@
1
1
  import chalk from "chalk";
2
2
  import * as p from "@clack/prompts";
3
3
  import { Command } from "commander";
4
- import { api } from "../lib/api.js";
5
- import { saveProjectConfig, findProjectConfig } from "../lib/config.js";
6
- import { success, isJSONMode, printJSON, isTTY } from "../lib/format.js";
4
+ import { api, withQuery } from "../lib/api.js";
5
+ import { setProjectLink, type ProjectLink } from "../lib/config.js";
6
+ import { isJSONMode, printJSON, isTTY, success } from "../lib/format.js";
7
+ import { fetchWorkspaces, pickWorkspace } from "../lib/picker.js";
7
8
 
8
9
  interface Project {
9
10
  id: string;
10
11
  name: string;
11
12
  slug: string;
13
+ workspaceId?: string | null;
12
14
  }
13
15
 
16
+ interface ServiceInfo {
17
+ id: string;
18
+ name: string;
19
+ status?: string;
20
+ }
21
+
22
+ interface ServicesResponse {
23
+ apps?: ServiceInfo[];
24
+ addons?: ServiceInfo[];
25
+ }
26
+
27
+ /**
28
+ * `lizard link` — associates the current directory with an existing
29
+ * project and (optionally) service. Each piece can be passed via flags
30
+ * or selected interactively.
31
+ *
32
+ * Order matches Railway's wizard: workspace → project → service.
33
+ */
14
34
  export function registerLink(program: Command) {
15
35
  program
16
36
  .command("link")
17
- .description("Link current directory to an existing project")
18
- .option("--project <id>", "Project ID")
19
- .action(async (opts) => {
20
- let projectId = opts.project;
21
- let projectName: string | undefined;
22
-
23
- if (!projectId) {
24
- // Fetch projects and let user pick
25
- const projects = await api.get<Project[]>("/api/projects");
26
- if (projects.length === 0) {
27
- throw new Error("No projects found. Run `lizard init` to create one.");
28
- }
37
+ .description("Associate the current directory with an existing project")
38
+ .argument("[service]", "Service name or ID (optional)")
39
+ .option("-p, --project <id>", "Project name, slug, or ID")
40
+ .option("-w, --workspace <ws>", "Workspace id, slug, or name")
41
+ .option("-s, --service <name>", "Service name or ID (optional)")
42
+ .action(async (serviceArg: string | undefined, opts) => {
43
+ const projectFlag = opts.project;
44
+ const serviceFlag = serviceArg || opts.service;
45
+
46
+ // 1. Workspace
47
+ const workspaces = await fetchWorkspaces();
48
+ const workspace = await pickWorkspace({
49
+ flag: opts.workspace,
50
+ projectNameHint: projectFlag,
51
+ workspaces,
52
+ });
29
53
 
30
- if (!isTTY()) {
54
+ // 2. Project (within workspace)
55
+ const projects = await api.get<Project[]>(
56
+ withQuery("/api/projects", { workspaceId: workspace.id }),
57
+ );
58
+ if (projects.length === 0) {
59
+ throw new Error(
60
+ `No projects in ${workspace.name}. Run \`lizard init\` to create one.`,
61
+ );
62
+ }
63
+ let project: Project;
64
+ if (projectFlag) {
65
+ const lower = projectFlag.toLowerCase();
66
+ const match = projects.find(
67
+ (pr) =>
68
+ pr.id.toLowerCase() === lower ||
69
+ pr.slug?.toLowerCase() === lower ||
70
+ pr.name.toLowerCase() === lower,
71
+ );
72
+ if (!match) {
31
73
  throw new Error(
32
- "Use --project <id> in non-interactive mode. Available: " +
33
- projects.map((p) => `${p.name} (${p.id})`).join(", "),
74
+ `Project "${projectFlag}" not found in ${workspace.name}. Available: ${projects.map((pr) => pr.name).join(", ")}`,
34
75
  );
35
76
  }
77
+ project = match;
78
+ } else if (projects.length === 1) {
79
+ project = projects[0];
80
+ } else {
81
+ if (!isTTY()) throw new Error("--project required in non-interactive mode");
82
+ const sel = await p.select({
83
+ message: `Select a project in ${workspace.name}`,
84
+ options: projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id })),
85
+ });
86
+ if (p.isCancel(sel)) process.exit(5);
87
+ project = projects.find((pr) => pr.id === sel)!;
88
+ }
36
89
 
37
- const selected = await p.select({
38
- message: "Select project",
39
- options: projects.map((proj) => ({
40
- value: proj.id,
41
- label: proj.name,
42
- hint: proj.id,
43
- })),
90
+ // 3. Service (optional)
91
+ const services = await api
92
+ .get<ServicesResponse>(
93
+ withQuery(`/api/projects/${project.id}/services`, {
94
+ workspaceId: workspace.id,
95
+ }),
96
+ )
97
+ .catch(() => ({ apps: [], addons: [] }) as ServicesResponse);
98
+ const allServices = [
99
+ ...(services.apps || []),
100
+ ...(services.addons || []),
101
+ ];
102
+
103
+ let service: ServiceInfo | null = null;
104
+ if (serviceFlag) {
105
+ const lower = serviceFlag.toLowerCase();
106
+ service =
107
+ allServices.find(
108
+ (s) => s.id.toLowerCase() === lower || s.name?.toLowerCase() === lower,
109
+ ) || null;
110
+ if (!service) {
111
+ throw new Error(
112
+ `Service "${serviceFlag}" not found. Available: ${allServices.map((s) => s.name).join(", ")}`,
113
+ );
114
+ }
115
+ } else if (allServices.length > 0 && isTTY()) {
116
+ const choices = [
117
+ { value: "", label: "(none — link only project)" },
118
+ ...allServices.map((s) => ({ value: s.id, label: s.name, hint: s.status })),
119
+ ];
120
+ const sel = await p.select({
121
+ message: "Select a service (optional)",
122
+ options: choices,
44
123
  });
45
- if (p.isCancel(selected)) process.exit(5);
46
- projectId = selected as string;
47
- projectName = projects.find((p) => p.id === projectId)?.name;
124
+ if (p.isCancel(sel)) process.exit(5);
125
+ if (sel) service = allServices.find((s) => s.id === sel)!;
48
126
  }
49
127
 
50
- const old = findProjectConfig();
51
- saveProjectConfig({ projectId, projectName });
128
+ // Save
129
+ const link: ProjectLink = {
130
+ projectId: project.id,
131
+ projectName: project.name,
132
+ workspaceId: workspace.id,
133
+ workspaceName: workspace.name,
134
+ serviceId: service?.id,
135
+ serviceName: service?.name,
136
+ };
137
+ setProjectLink(link);
52
138
 
53
139
  if (isJSONMode()) {
54
- printJSON({ projectId, projectName });
55
- } else if (old?.projectId && old.projectId !== projectId) {
140
+ printJSON({
141
+ projectId: link.projectId,
142
+ projectName: link.projectName,
143
+ workspaceId: link.workspaceId,
144
+ workspaceName: link.workspaceName,
145
+ serviceId: link.serviceId,
146
+ serviceName: link.serviceName,
147
+ });
148
+ } else {
149
+ const wsLabel = link.workspaceName ? chalk.dim(` (${link.workspaceName})`) : "";
56
150
  success(
57
- `Relinked to ${chalk.bold(projectName || projectId)} (was ${old.projectName || old.projectId})`,
151
+ `Linked to ${chalk.bold(project.name)}${service ? ` / ${chalk.bold(service.name)}` : ""}${wsLabel}`,
58
152
  );
59
- } else {
60
- success(`Linked to ${chalk.bold(projectName || projectId)}`);
61
153
  }
62
154
  });
63
155
  }
@@ -131,15 +131,16 @@ export function registerLogin(program: Command) {
131
131
  .description("Log in to Lizard")
132
132
  .option("--token <token>", "Authenticate with an API token")
133
133
  .action(async (opts) => {
134
- if (opts.token) {
134
+ const token = opts.token;
135
+ if (token) {
135
136
  // Direct token auth — validate it
136
137
  const res = await fetch(`${getBaseURL()}/api/auth/me`, {
137
- headers: { Authorization: `Bearer ${opts.token}` },
138
+ headers: { Authorization: `Bearer ${token}` },
138
139
  });
139
140
  if (!res.ok) throw new Error("Invalid token");
140
141
  const user = (await res.json()) as any;
141
142
  saveCredentials({
142
- accessToken: opts.token,
143
+ accessToken: token,
143
144
  userId: user.id,
144
145
  username: user.username,
145
146
  email: user.email,