layero 0.2.0 → 0.4.0

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/dist/api.js CHANGED
@@ -44,6 +44,9 @@ export class ApiClient {
44
44
  listProjects() {
45
45
  return this.request("GET", "/projects");
46
46
  }
47
+ listOrganizations() {
48
+ return this.request("GET", "/organizations");
49
+ }
47
50
  getProject(idOrSlug) {
48
51
  return this.request("GET", `/projects/${idOrSlug}`);
49
52
  }
@@ -53,6 +56,7 @@ export class ApiClient {
53
56
  slug: input.slug,
54
57
  source_type: "cli",
55
58
  framework_hint: input.framework_hint,
59
+ organization_slug: input.organization_slug,
56
60
  });
57
61
  }
58
62
  initUpload(projectId) {
@@ -70,6 +74,13 @@ export class ApiClient {
70
74
  pollLogs(deployId, afterId) {
71
75
  return this.request("GET", `/deploys/${deployId}/logs?after_id=${afterId}`);
72
76
  }
77
+ listProjectDeploys(projectId, branch) {
78
+ const qs = branch ? `?branch=${encodeURIComponent(branch)}` : "";
79
+ return this.request("GET", `/projects/${projectId}/deploys${qs}`);
80
+ }
81
+ rollbackProject(projectId, input) {
82
+ return this.request("POST", `/projects/${projectId}/rollback`, input);
83
+ }
73
84
  setUsername(value) {
74
85
  return this.request("POST", "/auth/me/username", { value });
75
86
  }
@@ -10,7 +10,9 @@ import { projectsListCmd } from "../commands/projects.js";
10
10
  import { linkCmd } from "../commands/link.js";
11
11
  import { tokenSetCmd } from "../commands/token.js";
12
12
  import { deployCmd } from "../commands/deploy.js";
13
+ import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
13
14
  import { loginCmd } from "../commands/login.js";
15
+ import { orgsListCmd } from "../commands/orgs.js";
14
16
  // Read version from the shipped package.json (two levels up from dist/bin/).
15
17
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
16
18
  const VERSION = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
@@ -44,6 +46,40 @@ async function main() {
44
46
  .command("list")
45
47
  .description("List your projects.")
46
48
  .action(projectsListCmd);
49
+ const orgs = program
50
+ .command("orgs")
51
+ .description("Layero organizations on your account (personal + teams).");
52
+ orgs
53
+ .command("list")
54
+ .description("Show every Layero organization you belong to.")
55
+ .action(orgsListCmd);
56
+ const deploys = program
57
+ .command("deploys")
58
+ .description("List and inspect deploys for the linked project.");
59
+ deploys
60
+ .command("list")
61
+ .description("List recent deploys for the project's default branch (or --branch).")
62
+ .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
63
+ .option("--branch <name>", "branch to list deploys from (default: project's default_branch)")
64
+ .option("--limit <n>", "max entries to show (default 20)", (v) => Number(v))
65
+ .action(async (opts) => {
66
+ await deploysListCmd(opts);
67
+ });
68
+ program
69
+ .command("rollback")
70
+ .description("Re-activate the previous successful deploy on the project's default branch.")
71
+ .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
72
+ .option("--branch <name>", "branch to roll back (default: project's default_branch)")
73
+ .option("--deploy <id_or_sha>", "explicit deploy id or commit sha prefix to roll back to")
74
+ .option("-y, --yes", "skip the confirmation prompt (CI)")
75
+ .addHelpText("after", "\nExamples:\n" +
76
+ " $ layero rollback # roll back default branch to previous ready deploy\n" +
77
+ " $ layero rollback --branch=staging # roll back the staging branch\n" +
78
+ " $ layero rollback --deploy=a3f9c2b # roll back to a specific commit/deploy\n" +
79
+ " $ layero rollback --yes # CI-friendly, no prompt")
80
+ .action(async (opts) => {
81
+ await rollbackCmd(opts);
82
+ });
47
83
  program
48
84
  .command("link <id_or_slug>")
49
85
  .description("Link the current directory to an existing project.")
@@ -66,6 +102,7 @@ async function main() {
66
102
  .option("--config", "use framework/build settings + env vars from .layero/project.json (skips the browser setup wizard)")
67
103
  .option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
68
104
  .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
105
+ .option("--org <slug>", "Layero organization slug for first-time project creation. Defaults to personal; required when you're a member of multiple orgs and want a non-personal home.")
69
106
  .addHelpText("after", "\nExamples:\n" +
70
107
  " $ layero deploy # preview on CLI pseudo-branch (never replaces prod)\n" +
71
108
  " $ layero deploy --prod # production deploy (interactive confirm)\n" +
@@ -100,11 +100,51 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
100
100
  throw new Error("no username set on your account. open https://app.layero.ru/onboarding " +
101
101
  "and pick one, then re-run.");
102
102
  }
103
+ // Resolve target Layero organization. Three paths:
104
+ // * --org=slug explicit → verify membership, use it
105
+ // * single org → silent default (the personal account)
106
+ // * multiple orgs → prompt unless --yes
107
+ let organizationSlug = opts.org;
108
+ if (organizationSlug) {
109
+ const orgs = await api.listOrganizations();
110
+ if (!orgs.some((o) => o.slug === organizationSlug)) {
111
+ throw new Error(`you're not a member of organization "${organizationSlug}". ` +
112
+ `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
113
+ }
114
+ }
115
+ else {
116
+ const orgs = await api.listOrganizations();
117
+ if (orgs.length === 0) {
118
+ throw new Error("no organization found on your account. finish onboarding at https://app.layero.ru/onboarding");
119
+ }
120
+ else if (orgs.length === 1) {
121
+ organizationSlug = orgs[0].slug;
122
+ }
123
+ else if (opts.yes || opts.config) {
124
+ // CI without --org: prefer personal, fall back to first.
125
+ organizationSlug =
126
+ orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
127
+ }
128
+ else {
129
+ console.log(chalk.cyan("which organization?"));
130
+ orgs.forEach((o, i) => {
131
+ const tag = o.kind === "personal" ? "personal" : "team";
132
+ console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
133
+ });
134
+ const choiceRaw = await prompt("choose number", "1");
135
+ const idx = Number(choiceRaw) - 1;
136
+ if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
137
+ throw new Error(`invalid choice "${choiceRaw}"`);
138
+ }
139
+ organizationSlug = orgs[idx].slug;
140
+ }
141
+ }
103
142
  const fallbackName = path.basename(cwd);
104
143
  const name = opts.name ?? (opts.yes || opts.config ? fallbackName : await prompt("project name", fallbackName));
105
144
  const project = await api.createCliProject({
106
145
  name,
107
146
  framework_hint: opts.type,
147
+ organization_slug: organizationSlug,
108
148
  });
109
149
  return { project, createdNow: true };
110
150
  }
@@ -0,0 +1,146 @@
1
+ import readline from "node:readline/promises";
2
+ import chalk from "chalk";
3
+ import { ApiClient, ApiError } from "../api.js";
4
+ import { loadConfig } from "../config.js";
5
+ import { loadProjectConfig } from "../project-config.js";
6
+ async function resolveProjectId(api, cwd, override) {
7
+ if (override) {
8
+ // Accept slug or id; resolve via list to keep consistent error UX.
9
+ const all = await api.listProjects();
10
+ const match = all.find((p) => p.id === override) ?? all.find((p) => p.slug === override);
11
+ if (!match)
12
+ throw new Error(`no project with id/slug "${override}"`);
13
+ return match.id;
14
+ }
15
+ const cfg = await loadProjectConfig(cwd);
16
+ if (!cfg) {
17
+ throw new Error("no .layero/project.json found. run `layero deploy` to create one, or pass --project <slug>.");
18
+ }
19
+ return cfg.project_id;
20
+ }
21
+ function shortSha(sha) {
22
+ return (sha ?? "").slice(0, 7);
23
+ }
24
+ function statusBadge(status) {
25
+ switch (status) {
26
+ case "ready":
27
+ return chalk.green("● ready");
28
+ case "building":
29
+ case "queued":
30
+ return chalk.cyan(`● ${status}`);
31
+ case "failed":
32
+ return chalk.red("● failed");
33
+ default:
34
+ return chalk.dim(`● ${status}`);
35
+ }
36
+ }
37
+ function fmtTime(iso) {
38
+ if (!iso)
39
+ return "—";
40
+ const d = new Date(iso);
41
+ // YYYY-MM-DD HH:mm
42
+ const pad = (n) => String(n).padStart(2, "0");
43
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
44
+ }
45
+ function fmtSource(d) {
46
+ const t = d.source_type ?? "github";
47
+ if (t === "cli")
48
+ return chalk.dim("(cli)");
49
+ if (d.triggered_by_user_id)
50
+ return chalk.dim("(manual)");
51
+ return chalk.dim("(push)");
52
+ }
53
+ export async function deploysListCmd(opts) {
54
+ const cliCfg = await loadConfig();
55
+ if (!cliCfg.token)
56
+ throw new Error("not logged in. run `layero login` first.");
57
+ const api = new ApiClient(cliCfg);
58
+ const projectId = await resolveProjectId(api, process.cwd(), opts.project);
59
+ const deploys = await api.listProjectDeploys(projectId, opts.branch);
60
+ const limit = opts.limit ?? 20;
61
+ const rows = deploys.slice(0, limit);
62
+ if (rows.length === 0) {
63
+ console.log(chalk.dim("no deploys yet."));
64
+ return;
65
+ }
66
+ for (const d of rows) {
67
+ const line = [
68
+ statusBadge(d.status),
69
+ chalk.bold(shortSha(d.commit_sha).padEnd(7)),
70
+ fmtTime(d.created_at).padEnd(16),
71
+ fmtSource(d),
72
+ d.commit_message
73
+ ? chalk.dim(((d.commit_message ?? "").split("\n")[0] ?? "").slice(0, 60))
74
+ : "",
75
+ ].join(" ");
76
+ console.log(line);
77
+ }
78
+ if (deploys.length > rows.length) {
79
+ console.log(chalk.dim(` …${deploys.length - rows.length} more (use --limit to show)`));
80
+ }
81
+ }
82
+ async function confirm(question) {
83
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
84
+ try {
85
+ const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
86
+ return answer === "y" || answer === "yes";
87
+ }
88
+ finally {
89
+ rl.close();
90
+ }
91
+ }
92
+ export async function rollbackCmd(opts) {
93
+ const cliCfg = await loadConfig();
94
+ if (!cliCfg.token)
95
+ throw new Error("not logged in. run `layero login` first.");
96
+ const api = new ApiClient(cliCfg);
97
+ const cwd = process.cwd();
98
+ const projectId = await resolveProjectId(api, cwd, opts.project);
99
+ // Show what we're rolling back from/to so the user can sanity-check
100
+ // before confirming. The dashboard does this visually; the CLI shows
101
+ // the equivalent: current deploy + the candidate target.
102
+ const deploys = await api.listProjectDeploys(projectId, opts.branch);
103
+ if (deploys.length === 0) {
104
+ throw new Error("no deploys for this project/branch");
105
+ }
106
+ const current = deploys.find((d) => d.status === "ready");
107
+ let target;
108
+ if (opts.deploy) {
109
+ target = deploys.find((d) => d.id === opts.deploy || d.commit_sha.startsWith(opts.deploy));
110
+ if (!target)
111
+ throw new Error(`no deploy matching "${opts.deploy}"`);
112
+ }
113
+ else {
114
+ // Find the latest ready deploy that isn't the current active one.
115
+ const ready = deploys.filter((d) => d.status === "ready");
116
+ target = ready.find((d) => d.id !== current?.id);
117
+ if (!target)
118
+ throw new Error("no eligible deploy to roll back to");
119
+ }
120
+ console.log(chalk.cyan("rollback plan:"));
121
+ if (current) {
122
+ console.log(` from: ${shortSha(current.commit_sha)} ${fmtTime(current.created_at)} ${chalk.dim(current.commit_message?.split("\n")[0] ?? "")}`);
123
+ }
124
+ console.log(` to: ${shortSha(target.commit_sha)} ${fmtTime(target.created_at)} ${chalk.dim(target.commit_message?.split("\n")[0] ?? "")}`);
125
+ if (!opts.yes) {
126
+ const ok = await confirm("proceed with rollback?");
127
+ if (!ok) {
128
+ console.log(chalk.yellow("aborted."));
129
+ return;
130
+ }
131
+ }
132
+ try {
133
+ const out = await api.rollbackProject(projectId, {
134
+ branch: opts.branch,
135
+ deploy_id: target.id,
136
+ });
137
+ console.log(chalk.green(`rolled back to ${shortSha(out.commit_sha)}.`));
138
+ console.log(chalk.dim(" CDN cache purged; new requests serve the rolled-back artifact."));
139
+ }
140
+ catch (err) {
141
+ if (err instanceof ApiError) {
142
+ throw new Error(`rollback failed (${err.status}): ${err.body.slice(0, 200)}`);
143
+ }
144
+ throw err;
145
+ }
146
+ }
@@ -0,0 +1,24 @@
1
+ import chalk from "chalk";
2
+ import { ApiClient } from "../api.js";
3
+ import { loadConfig } from "../config.js";
4
+ /** `layero orgs list` — show every Layero organization the caller belongs to.
5
+ *
6
+ * Useful before `layero deploy --org=<slug>` so the user can see which
7
+ * slugs to pass without leaving the terminal.
8
+ */
9
+ export async function orgsListCmd() {
10
+ const cfg = await loadConfig();
11
+ if (!cfg.token)
12
+ throw new Error("not logged in. run `layero login` first.");
13
+ const api = new ApiClient(cfg);
14
+ const orgs = await api.listOrganizations();
15
+ if (orgs.length === 0) {
16
+ console.log(chalk.dim("no organizations on this account."));
17
+ return;
18
+ }
19
+ for (const o of orgs) {
20
+ const kindBadge = o.kind === "personal" ? chalk.dim("personal") : chalk.cyan("team");
21
+ const roleBadge = chalk.dim(`(${o.my_role})`);
22
+ console.log(` ${chalk.bold(o.slug.padEnd(20))} ${kindBadge} ${roleBadge}`);
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Layero CLI — publish a local site with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",