layero 0.1.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/README.md +61 -0
- package/dist/api.js +94 -0
- package/dist/bin/layero.js +70 -0
- package/dist/commands/deploy.js +117 -0
- package/dist/commands/link.js +37 -0
- package/dist/commands/login.js +93 -0
- package/dist/commands/logout.js +6 -0
- package/dist/commands/projects.js +21 -0
- package/dist/commands/token.js +24 -0
- package/dist/commands/whoami.js +19 -0
- package/dist/config.js +50 -0
- package/dist/logs.js +34 -0
- package/dist/pack.js +111 -0
- package/dist/project-config.js +22 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# layero
|
|
2
|
+
|
|
3
|
+
CLI for [Layero](https://layero.ru) — publish a local site with one command.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g layero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js ≥ 20.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
layero login # opens browser for OAuth (GitHub / Google / Yandex)
|
|
17
|
+
cd my-site
|
|
18
|
+
layero deploy # packs the current dir, uploads, builds and publishes
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The first `layero deploy` in a directory creates a project and links it via
|
|
22
|
+
`./.layero/project.json`. Subsequent runs reuse the same project.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
| Command | Description |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `layero login` | Authenticate via browser. |
|
|
29
|
+
| `layero logout` | Remove the saved auth token. |
|
|
30
|
+
| `layero whoami` | Show current account. |
|
|
31
|
+
| `layero projects list` | List projects on your account. |
|
|
32
|
+
| `layero link <id_or_slug>` | Link cwd to an existing project. |
|
|
33
|
+
| `layero deploy` | Pack cwd and deploy. |
|
|
34
|
+
| `layero token` | Manage the auth token directly. |
|
|
35
|
+
|
|
36
|
+
Run `layero <cmd> --help` for full options.
|
|
37
|
+
|
|
38
|
+
## `layero deploy` flags
|
|
39
|
+
|
|
40
|
+
- `--type <preset>` — framework preset: `vite`, `next`, `astro`, `cra`,
|
|
41
|
+
`sveltekit`, `nuxt`, `gatsby`, `static`.
|
|
42
|
+
- `--name <name>` — project name (only on first deploy).
|
|
43
|
+
- `--project <id_or_slug>` — deploy into an existing project, ignoring
|
|
44
|
+
`./.layero/project.json` (useful for CI).
|
|
45
|
+
- `--yes` / `-y` — non-interactive mode.
|
|
46
|
+
|
|
47
|
+
## Ignore rules
|
|
48
|
+
|
|
49
|
+
`layero deploy` honours `.gitignore` and `.layeroignore`. The following are
|
|
50
|
+
always excluded: `node_modules`, `.git`, `dist`, `build`, `.next`, `.env*`,
|
|
51
|
+
`.DS_Store`. Maximum archive size is 200 MB.
|
|
52
|
+
|
|
53
|
+
## Config
|
|
54
|
+
|
|
55
|
+
- Auth token: `~/.layero/config.json` (chmod 600).
|
|
56
|
+
- Per-project link: `./.layero/project.json`.
|
|
57
|
+
|
|
58
|
+
## Links
|
|
59
|
+
|
|
60
|
+
- Website: https://layero.ru
|
|
61
|
+
- Issues: https://github.com/layero/layero/issues
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
body;
|
|
4
|
+
constructor(message, status, body) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.body = body;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class ApiClient {
|
|
11
|
+
cfg;
|
|
12
|
+
constructor(cfg) {
|
|
13
|
+
this.cfg = cfg;
|
|
14
|
+
}
|
|
15
|
+
headers(extra) {
|
|
16
|
+
const h = { ...extra };
|
|
17
|
+
if (this.cfg.token) {
|
|
18
|
+
h.Authorization = `Bearer ${this.cfg.token}`;
|
|
19
|
+
}
|
|
20
|
+
return h;
|
|
21
|
+
}
|
|
22
|
+
async request(method, path, body) {
|
|
23
|
+
const url = `${this.cfg.apiUrl.replace(/\/+$/, "")}${path}`;
|
|
24
|
+
const init = {
|
|
25
|
+
method,
|
|
26
|
+
headers: this.headers(body !== undefined ? { "Content-Type": "application/json" } : undefined),
|
|
27
|
+
};
|
|
28
|
+
if (body !== undefined) {
|
|
29
|
+
init.body = JSON.stringify(body);
|
|
30
|
+
}
|
|
31
|
+
const resp = await fetch(url, init);
|
|
32
|
+
const text = await resp.text();
|
|
33
|
+
if (!resp.ok) {
|
|
34
|
+
throw new ApiError(`API ${method} ${path} → ${resp.status}: ${text.slice(0, 500)}`, resp.status, text);
|
|
35
|
+
}
|
|
36
|
+
if (!text) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
me() {
|
|
42
|
+
return this.request("GET", "/auth/me");
|
|
43
|
+
}
|
|
44
|
+
listProjects() {
|
|
45
|
+
return this.request("GET", "/projects");
|
|
46
|
+
}
|
|
47
|
+
getProject(idOrSlug) {
|
|
48
|
+
return this.request("GET", `/projects/${idOrSlug}`);
|
|
49
|
+
}
|
|
50
|
+
createCliProject(input) {
|
|
51
|
+
return this.request("POST", "/projects", {
|
|
52
|
+
name: input.name,
|
|
53
|
+
slug: input.slug,
|
|
54
|
+
source_type: "cli",
|
|
55
|
+
framework_hint: input.framework_hint,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
initUpload(projectId) {
|
|
59
|
+
return this.request("POST", `/projects/${projectId}/uploads`);
|
|
60
|
+
}
|
|
61
|
+
triggerDeploy(projectId, input) {
|
|
62
|
+
return this.request("POST", `/projects/${projectId}/deploy`, input);
|
|
63
|
+
}
|
|
64
|
+
pollLogs(deployId, afterId) {
|
|
65
|
+
return this.request("GET", `/deploys/${deployId}/logs?after_id=${afterId}`);
|
|
66
|
+
}
|
|
67
|
+
setHandle(value) {
|
|
68
|
+
return this.request("POST", "/me/handle", { value });
|
|
69
|
+
}
|
|
70
|
+
checkHandle(value) {
|
|
71
|
+
return this.request("GET", `/me/handle/check?value=${encodeURIComponent(value)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function uploadArchive(init, filePath) {
|
|
75
|
+
const fs = await import("node:fs");
|
|
76
|
+
const stat = await fs.promises.stat(filePath);
|
|
77
|
+
// Use Node fetch with a stream body. Duplex 'half' is required when the
|
|
78
|
+
// body is a stream — Node refuses otherwise.
|
|
79
|
+
const stream = fs.createReadStream(filePath);
|
|
80
|
+
const resp = await fetch(init.upload_url, {
|
|
81
|
+
method: "PUT",
|
|
82
|
+
headers: {
|
|
83
|
+
...init.headers,
|
|
84
|
+
"Content-Length": String(stat.size),
|
|
85
|
+
},
|
|
86
|
+
// @ts-expect-error duplex is a Node-specific option for streamed bodies
|
|
87
|
+
duplex: "half",
|
|
88
|
+
body: stream,
|
|
89
|
+
});
|
|
90
|
+
if (!resp.ok) {
|
|
91
|
+
const text = await resp.text();
|
|
92
|
+
throw new Error(`S3 PUT ${resp.status}: ${text.slice(0, 500)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { whoamiCmd } from "../commands/whoami.js";
|
|
5
|
+
import { logoutCmd } from "../commands/logout.js";
|
|
6
|
+
import { projectsListCmd } from "../commands/projects.js";
|
|
7
|
+
import { linkCmd } from "../commands/link.js";
|
|
8
|
+
import { tokenSetCmd } from "../commands/token.js";
|
|
9
|
+
import { deployCmd } from "../commands/deploy.js";
|
|
10
|
+
import { loginCmd } from "../commands/login.js";
|
|
11
|
+
const VERSION = "0.1.0";
|
|
12
|
+
async function main() {
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name("layero")
|
|
16
|
+
.description("Layero CLI — publish a local site with one command.")
|
|
17
|
+
.version(VERSION);
|
|
18
|
+
program
|
|
19
|
+
.command("login")
|
|
20
|
+
.description("Authenticate via browser (GitHub / Google / Yandex).")
|
|
21
|
+
.option("-p, --provider <provider>", "OAuth provider to use (github | google | yandex)", "github")
|
|
22
|
+
.option("--port <port>", "fixed loopback port (default: random)", (v) => Number(v))
|
|
23
|
+
.addHelpText("after", "\nExamples:\n $ layero login\n $ layero login --provider google")
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
await loginCmd({ provider: opts.provider, port: opts.port });
|
|
26
|
+
});
|
|
27
|
+
program
|
|
28
|
+
.command("logout")
|
|
29
|
+
.description("Remove the saved auth token.")
|
|
30
|
+
.action(logoutCmd);
|
|
31
|
+
program
|
|
32
|
+
.command("whoami")
|
|
33
|
+
.description("Show the currently logged-in account.")
|
|
34
|
+
.action(whoamiCmd);
|
|
35
|
+
const projects = program
|
|
36
|
+
.command("projects")
|
|
37
|
+
.description("Inspect projects on your account.");
|
|
38
|
+
projects
|
|
39
|
+
.command("list")
|
|
40
|
+
.description("List your projects.")
|
|
41
|
+
.action(projectsListCmd);
|
|
42
|
+
program
|
|
43
|
+
.command("link <id_or_slug>")
|
|
44
|
+
.description("Link the current directory to an existing project.")
|
|
45
|
+
.action(linkCmd);
|
|
46
|
+
const token = program
|
|
47
|
+
.command("token")
|
|
48
|
+
.description("Manage the auth token directly (advanced).");
|
|
49
|
+
token
|
|
50
|
+
.command("set <jwt>")
|
|
51
|
+
.description("Persist a JWT obtained out-of-band (e.g. from the web UI). " +
|
|
52
|
+
"Use this until `layero login` is fully wired up.")
|
|
53
|
+
.action(tokenSetCmd);
|
|
54
|
+
program
|
|
55
|
+
.command("deploy")
|
|
56
|
+
.description("Pack the current directory and deploy it.")
|
|
57
|
+
.option("-t, --type <preset>", "framework hint (vite | next | astro | cra | sveltekit | nuxt | gatsby | static)")
|
|
58
|
+
.option("--name <name>", "project name (only used on first deploy)")
|
|
59
|
+
.option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
|
|
60
|
+
.option("-y, --yes", "non-interactive: accept defaults, fail if anything is missing")
|
|
61
|
+
.addHelpText("after", "\nExamples:\n $ layero deploy\n $ layero deploy --type vite\n $ layero deploy --project my-site --yes")
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
await deployCmd(opts);
|
|
64
|
+
});
|
|
65
|
+
await program.parseAsync(process.argv);
|
|
66
|
+
}
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
console.error(chalk.red(err.message ?? String(err)));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { ApiClient, uploadArchive } from "../api.js";
|
|
6
|
+
import { loadConfig } from "../config.js";
|
|
7
|
+
import { loadProjectConfig, saveProjectConfig, } from "../project-config.js";
|
|
8
|
+
import { packCwd } from "../pack.js";
|
|
9
|
+
import { streamDeployLogs } from "../logs.js";
|
|
10
|
+
const VALID_TYPES = new Set([
|
|
11
|
+
"vite",
|
|
12
|
+
"next",
|
|
13
|
+
"nextjs",
|
|
14
|
+
"astro",
|
|
15
|
+
"cra",
|
|
16
|
+
"sveltekit",
|
|
17
|
+
"svelte",
|
|
18
|
+
"nuxt",
|
|
19
|
+
"gatsby",
|
|
20
|
+
"static",
|
|
21
|
+
]);
|
|
22
|
+
async function prompt(question, fallback) {
|
|
23
|
+
const rl = readline.createInterface({
|
|
24
|
+
input: process.stdin,
|
|
25
|
+
output: process.stdout,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
const answer = (await rl.question(`${question} [${fallback}]: `)).trim();
|
|
29
|
+
return answer || fallback;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function resolveProject(api, cwd, opts) {
|
|
36
|
+
if (opts.project) {
|
|
37
|
+
const all = await api.listProjects();
|
|
38
|
+
const match = all.find((p) => p.id === opts.project) ??
|
|
39
|
+
all.find((p) => p.slug === opts.project);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`no project with id/slug "${opts.project}"`);
|
|
42
|
+
}
|
|
43
|
+
return { project: match, createdNow: false };
|
|
44
|
+
}
|
|
45
|
+
const existing = await loadProjectConfig(cwd);
|
|
46
|
+
if (existing) {
|
|
47
|
+
const project = await api.getProject(existing.project_id);
|
|
48
|
+
return { project, createdNow: false };
|
|
49
|
+
}
|
|
50
|
+
// No project_id locally — create one.
|
|
51
|
+
const me = await api.me();
|
|
52
|
+
if (!me.handle) {
|
|
53
|
+
throw new Error("no handle set on your account. open https://app.layero.ru/onboarding " +
|
|
54
|
+
"and pick one, then re-run.");
|
|
55
|
+
}
|
|
56
|
+
const fallbackName = path.basename(cwd);
|
|
57
|
+
const name = opts.name ?? (opts.yes ? fallbackName : await prompt("project name", fallbackName));
|
|
58
|
+
const project = await api.createCliProject({
|
|
59
|
+
name,
|
|
60
|
+
framework_hint: opts.type,
|
|
61
|
+
});
|
|
62
|
+
await saveProjectConfig(cwd, {
|
|
63
|
+
project_id: project.id,
|
|
64
|
+
slug: project.slug,
|
|
65
|
+
owner_slug: project.owner.slug,
|
|
66
|
+
apex_hostname: project.apex_hostname,
|
|
67
|
+
framework_hint: opts.type ?? null,
|
|
68
|
+
});
|
|
69
|
+
return { project, createdNow: true };
|
|
70
|
+
}
|
|
71
|
+
export async function deployCmd(opts) {
|
|
72
|
+
if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
|
|
73
|
+
throw new Error(`unknown --type "${opts.type}". valid: ${[...VALID_TYPES].join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
const cfg = await loadConfig();
|
|
76
|
+
if (!cfg.token) {
|
|
77
|
+
throw new Error("not logged in. run `layero login` first.");
|
|
78
|
+
}
|
|
79
|
+
const api = new ApiClient(cfg);
|
|
80
|
+
const cwd = process.cwd();
|
|
81
|
+
const { project, createdNow } = await resolveProject(api, cwd, opts);
|
|
82
|
+
if (project.source_type !== "cli") {
|
|
83
|
+
throw new Error(`project "${project.slug}" is a GitHub-source project; ` +
|
|
84
|
+
"use the dashboard's Deploy button or push to the linked repo.");
|
|
85
|
+
}
|
|
86
|
+
if (createdNow) {
|
|
87
|
+
console.log(chalk.green(`created project ${project.slug} → https://${project.apex_hostname}`));
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.cyan("packing source..."));
|
|
90
|
+
const pack = await packCwd(cwd, project.slug);
|
|
91
|
+
console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
|
|
92
|
+
try {
|
|
93
|
+
console.log(chalk.cyan("requesting upload URL..."));
|
|
94
|
+
const init = await api.initUpload(project.id);
|
|
95
|
+
console.log(chalk.cyan("uploading archive..."));
|
|
96
|
+
await uploadArchive(init, pack.archivePath);
|
|
97
|
+
console.log(chalk.cyan("triggering deploy..."));
|
|
98
|
+
const deploy = await api.triggerDeploy(project.id, {
|
|
99
|
+
source_archive_key: init.source_archive_key,
|
|
100
|
+
commit_sha: pack.sha256,
|
|
101
|
+
commit_message: "CLI deploy",
|
|
102
|
+
framework_hint: opts.type,
|
|
103
|
+
});
|
|
104
|
+
console.log(chalk.dim(` deploy_id=${deploy.id}`));
|
|
105
|
+
const final = await streamDeployLogs(api, deploy.id);
|
|
106
|
+
if (final.status !== "ready") {
|
|
107
|
+
console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
console.log(chalk.green(`deploy ready → https://${project.apex_hostname}`));
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
// Always clean up the local archive.
|
|
115
|
+
await fs.unlink(pack.archivePath).catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { ApiClient } from "../api.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { saveProjectConfig } from "../project-config.js";
|
|
5
|
+
export async function linkCmd(idOrSlug) {
|
|
6
|
+
const cfg = await loadConfig();
|
|
7
|
+
if (!cfg.token) {
|
|
8
|
+
console.error(chalk.yellow("not logged in. run `layero login` first."));
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const api = new ApiClient(cfg);
|
|
13
|
+
// The backend accepts UUIDs only on /projects/{id}; for slug we fall back
|
|
14
|
+
// to listing — this is fine because users typically have <100 projects.
|
|
15
|
+
let proj;
|
|
16
|
+
try {
|
|
17
|
+
proj = await api.getProject(idOrSlug);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
const all = await api.listProjects();
|
|
21
|
+
const match = all.find((p) => p.slug === idOrSlug);
|
|
22
|
+
if (!match) {
|
|
23
|
+
console.error(chalk.red(`no project with id/slug "${idOrSlug}"`));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
proj = match;
|
|
28
|
+
}
|
|
29
|
+
await saveProjectConfig(process.cwd(), {
|
|
30
|
+
project_id: proj.id,
|
|
31
|
+
slug: proj.slug,
|
|
32
|
+
owner_slug: proj.owner.slug,
|
|
33
|
+
apex_hostname: proj.apex_hostname,
|
|
34
|
+
framework_hint: proj.framework_hint,
|
|
35
|
+
});
|
|
36
|
+
console.log(chalk.green(`linked ${proj.slug} (${proj.id}) → ./.layero/project.json`));
|
|
37
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import open from "open";
|
|
5
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
6
|
+
import { ApiClient } from "../api.js";
|
|
7
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* Browser-based OAuth login. Runs a one-shot loopback HTTP server on
|
|
10
|
+
* 127.0.0.1, opens the browser at /auth/cli/start, waits for the
|
|
11
|
+
* frontend's CliBridge page to POST the JWT back. Server stops as soon
|
|
12
|
+
* as a valid token arrives or LOGIN_TIMEOUT_MS elapses.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: this command depends on backend endpoints that may not be live yet
|
|
15
|
+
* (`/auth/cli/start`, frontend `/oauth/cli-bridge`). If you can already
|
|
16
|
+
* obtain a JWT through the web UI, use `layero token set <jwt>` as a
|
|
17
|
+
* temporary alternative.
|
|
18
|
+
*/
|
|
19
|
+
export async function loginCmd(opts) {
|
|
20
|
+
const cfg = await loadConfig();
|
|
21
|
+
const state = randomBytes(16).toString("hex");
|
|
22
|
+
const provider = opts.provider ?? "github";
|
|
23
|
+
const desiredPort = opts.port ?? Number(process.env.LAYERO_LOGIN_PORT ?? 0);
|
|
24
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
25
|
+
const server = http.createServer(async (req, res) => {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
27
|
+
if (url.pathname !== "/callback") {
|
|
28
|
+
res.writeHead(404).end("not found");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Accept token via POST body (preferred) or GET query (fallback).
|
|
32
|
+
let body = "";
|
|
33
|
+
if (req.method === "POST") {
|
|
34
|
+
for await (const chunk of req)
|
|
35
|
+
body += chunk;
|
|
36
|
+
}
|
|
37
|
+
let token;
|
|
38
|
+
let receivedState;
|
|
39
|
+
if (body) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(body);
|
|
42
|
+
token = parsed.token;
|
|
43
|
+
receivedState = parsed.state;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// ignore; fall through to query
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
token ??= url.searchParams.get("token") ?? undefined;
|
|
50
|
+
receivedState ??= url.searchParams.get("state") ?? undefined;
|
|
51
|
+
if (!token || receivedState !== state) {
|
|
52
|
+
res.writeHead(400, { "Access-Control-Allow-Origin": "*" }).end("bad state");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
res
|
|
56
|
+
.writeHead(200, {
|
|
57
|
+
"Content-Type": "text/plain",
|
|
58
|
+
"Access-Control-Allow-Origin": "*",
|
|
59
|
+
})
|
|
60
|
+
.end("ok — you can close this tab");
|
|
61
|
+
server.close();
|
|
62
|
+
resolve(token);
|
|
63
|
+
});
|
|
64
|
+
server.on("error", reject);
|
|
65
|
+
server.listen(desiredPort, "127.0.0.1", () => {
|
|
66
|
+
const port = server.address().port;
|
|
67
|
+
const callback = `http://127.0.0.1:${port}/callback`;
|
|
68
|
+
const startUrl = `${cfg.apiUrl.replace(/\/+$/, "")}/auth/cli/start` +
|
|
69
|
+
`?provider=${provider}` +
|
|
70
|
+
`&callback=${encodeURIComponent(callback)}` +
|
|
71
|
+
`&state=${state}`;
|
|
72
|
+
console.log(chalk.cyan(`opening browser for ${provider} login...`));
|
|
73
|
+
console.log(chalk.dim(`if it doesn't open, paste this URL: ${startUrl}`));
|
|
74
|
+
open(startUrl).catch(() => {
|
|
75
|
+
// open failure is non-fatal — user can paste the URL manually.
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
server.close();
|
|
80
|
+
reject(new Error(`login timed out after ${LOGIN_TIMEOUT_MS / 1000}s`));
|
|
81
|
+
}, LOGIN_TIMEOUT_MS);
|
|
82
|
+
});
|
|
83
|
+
const token = await tokenPromise;
|
|
84
|
+
cfg.token = token;
|
|
85
|
+
const probe = new ApiClient(cfg);
|
|
86
|
+
const me = await probe.me();
|
|
87
|
+
cfg.user = { id: me.id, handle: me.handle, email: me.email };
|
|
88
|
+
await saveConfig(cfg);
|
|
89
|
+
console.log(chalk.green(`logged in as ${me.handle ?? me.email ?? me.id}`));
|
|
90
|
+
if (!me.handle) {
|
|
91
|
+
console.log(chalk.yellow("no handle set — open https://app.layero.ru/onboarding to pick one."));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { ApiClient } from "../api.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
export async function projectsListCmd() {
|
|
5
|
+
const cfg = await loadConfig();
|
|
6
|
+
if (!cfg.token) {
|
|
7
|
+
console.error(chalk.yellow("not logged in. run `layero login` first."));
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const api = new ApiClient(cfg);
|
|
12
|
+
const list = await api.listProjects();
|
|
13
|
+
if (list.length === 0) {
|
|
14
|
+
console.log("no projects yet — `layero deploy` to create one.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const p of list) {
|
|
18
|
+
const tag = p.source_type === "cli" ? chalk.magenta("[cli]") : chalk.blue("[gh] ");
|
|
19
|
+
console.log(`${tag} ${chalk.bold(p.slug)} ${chalk.dim(p.id)} https://${p.apex_hostname}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
3
|
+
import { ApiClient } from "../api.js";
|
|
4
|
+
export async function tokenSetCmd(jwt) {
|
|
5
|
+
const cfg = await loadConfig();
|
|
6
|
+
cfg.token = jwt.trim();
|
|
7
|
+
// Probe /auth/me to validate the token before persisting.
|
|
8
|
+
const probe = new ApiClient(cfg);
|
|
9
|
+
try {
|
|
10
|
+
const me = await probe.me();
|
|
11
|
+
cfg.user = { id: me.id, handle: me.handle, email: me.email };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
console.error(chalk.red(`token rejected by API: ${err.message}`));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await saveConfig(cfg);
|
|
19
|
+
console.log(chalk.green(`saved token for ${cfg.user?.handle ?? cfg.user?.email ?? cfg.user?.id}`));
|
|
20
|
+
if (!cfg.user?.handle) {
|
|
21
|
+
console.log(chalk.yellow("no handle set — open https://app.layero.ru/onboarding to pick one " +
|
|
22
|
+
"before `layero deploy`."));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { ApiClient } from "../api.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
export async function whoamiCmd() {
|
|
5
|
+
const cfg = await loadConfig();
|
|
6
|
+
if (!cfg.token) {
|
|
7
|
+
console.error(chalk.yellow("not logged in. run `layero login` first."));
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const api = new ApiClient(cfg);
|
|
12
|
+
const me = await api.me();
|
|
13
|
+
console.log(`id: ${me.id}`);
|
|
14
|
+
console.log(`handle: ${me.handle ?? chalk.yellow("(not set)")}`);
|
|
15
|
+
console.log(`email: ${me.email ?? "(none)"}`);
|
|
16
|
+
if (me.github_login) {
|
|
17
|
+
console.log(`github: ${me.github_login}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const CONFIG_DIR = path.join(homedir(), ".layero");
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
6
|
+
const DEFAULT_API_URL = process.env.LAYERO_API_URL ?? "https://api.layero.ru";
|
|
7
|
+
async function ensureDir() {
|
|
8
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
export async function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return {
|
|
15
|
+
apiUrl: parsed.apiUrl ?? DEFAULT_API_URL,
|
|
16
|
+
token: parsed.token,
|
|
17
|
+
user: parsed.user,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err?.code === "ENOENT") {
|
|
22
|
+
return { apiUrl: DEFAULT_API_URL };
|
|
23
|
+
}
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function saveConfig(cfg) {
|
|
28
|
+
await ensureDir();
|
|
29
|
+
const data = JSON.stringify(cfg, null, 2);
|
|
30
|
+
await fs.writeFile(CONFIG_FILE, data, { encoding: "utf-8", mode: 0o600 });
|
|
31
|
+
// Re-chmod in case the file already existed with looser perms. On Windows
|
|
32
|
+
// this is a no-op (NTFS ignores POSIX bits) — file ACLs default to the
|
|
33
|
+
// user's profile, which is the right scope.
|
|
34
|
+
if (process.platform !== "win32") {
|
|
35
|
+
await fs.chmod(CONFIG_FILE, 0o600);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function clearConfig() {
|
|
39
|
+
try {
|
|
40
|
+
await fs.unlink(CONFIG_FILE);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err?.code !== "ENOENT") {
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function configPath() {
|
|
49
|
+
return CONFIG_FILE;
|
|
50
|
+
}
|
package/dist/logs.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
const POLL_INTERVAL_MS = 1500;
|
|
3
|
+
function colorForStream(stream) {
|
|
4
|
+
switch (stream) {
|
|
5
|
+
case "meta":
|
|
6
|
+
return chalk.cyan;
|
|
7
|
+
case "stderr":
|
|
8
|
+
return chalk.red;
|
|
9
|
+
default:
|
|
10
|
+
return (s) => s;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function streamDeployLogs(api, deployId) {
|
|
14
|
+
let afterId = 0;
|
|
15
|
+
let lastStage = null;
|
|
16
|
+
// Loop until terminal — the server's per-stage timeouts bound duration.
|
|
17
|
+
// eslint-disable-next-line no-constant-condition
|
|
18
|
+
while (true) {
|
|
19
|
+
const poll = await api.pollLogs(deployId, afterId);
|
|
20
|
+
if (poll.current_stage && poll.current_stage !== lastStage) {
|
|
21
|
+
lastStage = poll.current_stage;
|
|
22
|
+
process.stderr.write(chalk.bold.blue(`▶ stage: ${poll.current_stage}\n`));
|
|
23
|
+
}
|
|
24
|
+
for (const line of poll.lines) {
|
|
25
|
+
afterId = Math.max(afterId, line.id);
|
|
26
|
+
const paint = colorForStream(line.stream);
|
|
27
|
+
process.stdout.write(paint(line.line) + "\n");
|
|
28
|
+
}
|
|
29
|
+
if (poll.terminal) {
|
|
30
|
+
return poll;
|
|
31
|
+
}
|
|
32
|
+
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/pack.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs, createReadStream } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { create as tarCreate } from "tar";
|
|
6
|
+
import ignore from "ignore";
|
|
7
|
+
const MAX_BYTES = 200 * 1024 * 1024;
|
|
8
|
+
const DEFAULT_IGNORE = [
|
|
9
|
+
"node_modules",
|
|
10
|
+
".git",
|
|
11
|
+
".svn",
|
|
12
|
+
".hg",
|
|
13
|
+
"dist",
|
|
14
|
+
"build",
|
|
15
|
+
".next",
|
|
16
|
+
".nuxt",
|
|
17
|
+
".output",
|
|
18
|
+
".cache",
|
|
19
|
+
".turbo",
|
|
20
|
+
".vercel",
|
|
21
|
+
".netlify",
|
|
22
|
+
".env",
|
|
23
|
+
".env.*",
|
|
24
|
+
"*.log",
|
|
25
|
+
".DS_Store",
|
|
26
|
+
"Thumbs.db",
|
|
27
|
+
".layero",
|
|
28
|
+
];
|
|
29
|
+
async function readIgnoreFile(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const text = await fs.readFile(filePath, "utf-8");
|
|
32
|
+
return text
|
|
33
|
+
.replace(/\r\n/g, "\n")
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((l) => l.trim())
|
|
36
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err?.code === "ENOENT")
|
|
40
|
+
return [];
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function buildIgnore(cwd) {
|
|
45
|
+
const ig = ignore();
|
|
46
|
+
ig.add(DEFAULT_IGNORE);
|
|
47
|
+
ig.add(await readIgnoreFile(path.join(cwd, ".gitignore")));
|
|
48
|
+
ig.add(await readIgnoreFile(path.join(cwd, ".layeroignore")));
|
|
49
|
+
return ig;
|
|
50
|
+
}
|
|
51
|
+
async function walk(cwd, ig) {
|
|
52
|
+
const out = [];
|
|
53
|
+
async function visit(rel) {
|
|
54
|
+
const abs = rel === "" ? cwd : path.join(cwd, rel);
|
|
55
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const childRel = rel === "" ? e.name : path.join(rel, e.name);
|
|
58
|
+
// The ignore package wants forward slashes regardless of platform.
|
|
59
|
+
const childPosix = childRel.split(path.sep).join("/");
|
|
60
|
+
const isDir = e.isDirectory();
|
|
61
|
+
const probe = isDir ? `${childPosix}/` : childPosix;
|
|
62
|
+
if (ig.ignores(probe))
|
|
63
|
+
continue;
|
|
64
|
+
if (isDir) {
|
|
65
|
+
await visit(childRel);
|
|
66
|
+
}
|
|
67
|
+
else if (e.isFile() || e.isSymbolicLink()) {
|
|
68
|
+
out.push(childRel);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await visit("");
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
async function sha256OfFile(p) {
|
|
76
|
+
const hash = createHash("sha256");
|
|
77
|
+
const stream = createReadStream(p);
|
|
78
|
+
for await (const chunk of stream) {
|
|
79
|
+
hash.update(chunk);
|
|
80
|
+
}
|
|
81
|
+
return hash.digest("hex");
|
|
82
|
+
}
|
|
83
|
+
export async function packCwd(cwd, projectName) {
|
|
84
|
+
const ig = await buildIgnore(cwd);
|
|
85
|
+
const files = await walk(cwd, ig);
|
|
86
|
+
if (files.length === 0) {
|
|
87
|
+
throw new Error("no files to upload after applying .gitignore/.layeroignore — " +
|
|
88
|
+
"check that you're in the right directory");
|
|
89
|
+
}
|
|
90
|
+
const stamp = Date.now();
|
|
91
|
+
const safeName = projectName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
92
|
+
const archivePath = path.join(tmpdir(), `layero-${safeName}-${stamp}.tgz`);
|
|
93
|
+
// Single root directory inside the archive — matches the github_archive
|
|
94
|
+
// contract on the builder side, which strips one root prefix.
|
|
95
|
+
const rootName = `layero-${safeName}-${stamp}`;
|
|
96
|
+
await tarCreate({
|
|
97
|
+
file: archivePath,
|
|
98
|
+
gzip: { level: 6 },
|
|
99
|
+
cwd,
|
|
100
|
+
portable: true,
|
|
101
|
+
prefix: rootName,
|
|
102
|
+
}, files);
|
|
103
|
+
const stat = await fs.stat(archivePath);
|
|
104
|
+
if (stat.size > MAX_BYTES) {
|
|
105
|
+
await fs.unlink(archivePath).catch(() => undefined);
|
|
106
|
+
throw new Error(`archive is ${(stat.size / (1024 * 1024)).toFixed(1)}MB — over the 200MB limit. ` +
|
|
107
|
+
"Add large directories to .layeroignore.");
|
|
108
|
+
}
|
|
109
|
+
const sha256 = await sha256OfFile(archivePath);
|
|
110
|
+
return { archivePath, sha256, size: stat.size, fileCount: files.length };
|
|
111
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function projectConfigPath(cwd) {
|
|
4
|
+
return path.join(cwd, ".layero", "project.json");
|
|
5
|
+
}
|
|
6
|
+
export async function loadProjectConfig(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const raw = await fs.readFile(projectConfigPath(cwd), "utf-8");
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
if (err?.code === "ENOENT") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function saveProjectConfig(cwd, cfg) {
|
|
19
|
+
const dir = path.join(cwd, ".layero");
|
|
20
|
+
await fs.mkdir(dir, { recursive: true });
|
|
21
|
+
await fs.writeFile(projectConfigPath(cwd), JSON.stringify(cfg, null, 2), "utf-8");
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "layero",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Layero CLI — publish a local site with one command.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"layero": "./dist/bin/layero.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"prepublishOnly": "npm run build",
|
|
20
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"commander": "^12.1.0",
|
|
25
|
+
"ignore": "^5.3.2",
|
|
26
|
+
"open": "^10.1.0",
|
|
27
|
+
"tar": "^7.4.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.14.10",
|
|
31
|
+
"typescript": "^5.5.4"
|
|
32
|
+
}
|
|
33
|
+
}
|