layero 0.1.6 → 0.1.8

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
@@ -58,6 +58,12 @@ export class ApiClient {
58
58
  initUpload(projectId) {
59
59
  return this.request("POST", `/projects/${projectId}/uploads`);
60
60
  }
61
+ finalizeUpload(projectId, input) {
62
+ return this.request("POST", `/projects/${projectId}/uploads/finalize`, input);
63
+ }
64
+ completeSetup(projectId, input) {
65
+ return this.request("POST", `/projects/${projectId}/setup`, input);
66
+ }
61
67
  triggerDeploy(projectId, input) {
62
68
  return this.request("POST", `/projects/${projectId}/deploy`, input);
63
69
  }
@@ -63,7 +63,12 @@ async function main() {
63
63
  .option("--name <name>", "project name (only used on first deploy)")
64
64
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
65
65
  .option("-y, --yes", "non-interactive: accept defaults, fail if anything is missing")
66
- .addHelpText("after", "\nExamples:\n $ layero deploy\n $ layero deploy --type vite\n $ layero deploy --project my-site --yes")
66
+ .option("--config", "use framework/build settings + env vars from .layero/project.json (skips the browser setup wizard)")
67
+ .addHelpText("after", "\nExamples:\n" +
68
+ " $ layero deploy # uploads source, opens setup wizard in the browser\n" +
69
+ " $ layero deploy --config # uses .layero/project.json end-to-end (CI-friendly)\n" +
70
+ " $ layero deploy --type vite\n" +
71
+ " $ layero deploy --project my-site --yes")
67
72
  .action(async (opts) => {
68
73
  await deployCmd(opts);
69
74
  });
@@ -4,7 +4,7 @@ import readline from "node:readline/promises";
4
4
  import chalk from "chalk";
5
5
  import { ApiClient, ApiError, uploadArchive } from "../api.js";
6
6
  import { loadConfig } from "../config.js";
7
- import { loadProjectConfig, projectConfigPath, saveProjectConfig, } from "../project-config.js";
7
+ import { loadProjectConfig, persistProjectLinking, projectConfigPath, } from "../project-config.js";
8
8
  import { packCwd } from "../pack.js";
9
9
  import { streamDeployLogs } from "../logs.js";
10
10
  const VALID_TYPES = new Set([
@@ -18,23 +18,27 @@ const VALID_TYPES = new Set([
18
18
  "nuxt",
19
19
  "gatsby",
20
20
  "static",
21
+ "generic",
21
22
  ]);
22
- function dashboardUrl(apiUrl, projectId) {
23
- // api.layero.ru → app.layero.ru. Falls back to app.layero.ru for any
24
- // unrecognized api base (incl. localhost) — the user can override via
25
- // LAYERO_DASHBOARD_URL if they're pointed at a non-standard env.
23
+ function dashboardOrigin(apiUrl) {
26
24
  const override = process.env.LAYERO_DASHBOARD_URL;
27
25
  if (override)
28
- return `${override.replace(/\/+$/, "")}/projects/${projectId}`;
26
+ return override.replace(/\/+$/, "");
29
27
  try {
30
28
  const u = new URL(apiUrl);
31
29
  u.hostname = u.hostname.replace(/^api\./, "app.");
32
- return `${u.origin}/projects/${projectId}`;
30
+ return u.origin;
33
31
  }
34
32
  catch {
35
- return `https://app.layero.ru/projects/${projectId}`;
33
+ return "https://app.layero.ru";
36
34
  }
37
35
  }
36
+ function setupUrl(apiUrl, projectId) {
37
+ return `${dashboardOrigin(apiUrl)}/projects/${projectId}/setup`;
38
+ }
39
+ function projectUrl(apiUrl, projectId) {
40
+ return `${dashboardOrigin(apiUrl)}/projects/${projectId}`;
41
+ }
38
42
  async function prompt(question, fallback) {
39
43
  const rl = readline.createInterface({
40
44
  input: process.stdin,
@@ -48,7 +52,7 @@ async function prompt(question, fallback) {
48
52
  rl.close();
49
53
  }
50
54
  }
51
- async function resolveProject(api, cwd, opts) {
55
+ async function resolveOrCreateProject(api, cwd, opts, existing) {
52
56
  if (opts.project) {
53
57
  const all = await api.listProjects();
54
58
  const match = all.find((p) => p.id === opts.project) ??
@@ -58,7 +62,6 @@ async function resolveProject(api, cwd, opts) {
58
62
  }
59
63
  return { project: match, createdNow: false };
60
64
  }
61
- const existing = await loadProjectConfig(cwd);
62
65
  if (existing) {
63
66
  try {
64
67
  const project = await api.getProject(existing.project_id);
@@ -73,61 +76,152 @@ async function resolveProject(api, cwd, opts) {
73
76
  throw err;
74
77
  }
75
78
  }
76
- // No project_id locally — create one.
77
79
  const me = await api.me();
78
80
  if (!me.handle) {
79
81
  throw new Error("no handle set on your account. open https://app.layero.ru/onboarding " +
80
82
  "and pick one, then re-run.");
81
83
  }
82
84
  const fallbackName = path.basename(cwd);
83
- const name = opts.name ?? (opts.yes ? fallbackName : await prompt("project name", fallbackName));
85
+ const name = opts.name ?? (opts.yes || opts.config ? fallbackName : await prompt("project name", fallbackName));
84
86
  const project = await api.createCliProject({
85
87
  name,
86
88
  framework_hint: opts.type,
87
89
  });
88
- await saveProjectConfig(cwd, {
89
- project_id: project.id,
90
- slug: project.slug,
91
- owner_slug: project.owner.slug,
92
- apex_hostname: project.apex_hostname,
93
- framework_hint: opts.type ?? null,
94
- });
95
90
  return { project, createdNow: true };
96
91
  }
92
+ async function packAndUpload(api, cwd, project) {
93
+ console.log(chalk.cyan("packing source..."));
94
+ const pack = await packCwd(cwd, project.slug);
95
+ console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
96
+ console.log(chalk.cyan("requesting upload URL..."));
97
+ const init = await api.initUpload(project.id);
98
+ console.log(chalk.cyan("uploading archive..."));
99
+ await uploadArchive(init, pack.archivePath);
100
+ return {
101
+ archive_key: init.source_archive_key,
102
+ commit_sha: pack.sha256,
103
+ archivePath: pack.archivePath,
104
+ };
105
+ }
106
+ function ensureConfigComplete(cfg) {
107
+ if (!cfg) {
108
+ throw new Error("no .layero/project.json found. run `layero deploy` once without --config " +
109
+ "to create the project, fill in framework_hint/build_cmd/output_dir there, " +
110
+ "then re-run with --config.");
111
+ }
112
+ const missing = [];
113
+ if (!cfg.framework_hint)
114
+ missing.push("framework_hint");
115
+ if (!cfg.build_cmd)
116
+ missing.push("build_cmd");
117
+ if (!cfg.output_dir)
118
+ missing.push("output_dir");
119
+ if (missing.length > 0) {
120
+ throw new Error(`.layero/project.json is missing required fields for --config: ${missing.join(", ")}`);
121
+ }
122
+ }
97
123
  export async function deployCmd(opts) {
98
124
  if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
99
125
  throw new Error(`unknown --type "${opts.type}". valid: ${[...VALID_TYPES].join(", ")}`);
100
126
  }
101
- const cfg = await loadConfig();
102
- if (!cfg.token) {
127
+ const cliCfg = await loadConfig();
128
+ if (!cliCfg.token) {
103
129
  throw new Error("not logged in. run `layero login` first.");
104
130
  }
105
- const api = new ApiClient(cfg);
131
+ const api = new ApiClient(cliCfg);
106
132
  const cwd = process.cwd();
107
- const { project, createdNow } = await resolveProject(api, cwd, opts);
133
+ const existing = await loadProjectConfig(cwd);
134
+ const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
135
+ let project = created;
108
136
  if (project.source_type !== "cli") {
109
137
  throw new Error(`project "${project.slug}" is a GitHub-source project; ` +
110
138
  "use the dashboard's Deploy button or push to the linked repo.");
111
139
  }
112
- // Project page on the dashboard. CDN propagation lags by ~30-60s after a
113
- // fresh deploy, so a link to the project page is more useful as the
114
- // immediate "where to look next" target than the live site URL.
115
- const projectUrl = dashboardUrl(cfg.apiUrl, project.id);
140
+ // Persist linking metadata only never touch hand-edited config fields
141
+ // or unknown keys the user may have added (a --config file is the user's
142
+ // source of truth, not ours). `persistProjectLinking` reads the file as
143
+ // raw JSON, overlays just project_id/slug/owner_slug/apex_hostname, and
144
+ // writes it back.
145
+ const persistedCfg = await persistProjectLinking(cwd, {
146
+ project_id: project.id,
147
+ slug: project.slug,
148
+ owner_slug: project.owner.slug,
149
+ apex_hostname: project.apex_hostname,
150
+ }, opts.type ?? null);
116
151
  if (createdNow) {
117
- console.log(chalk.green(`created project ${project.slug} → ${projectUrl}`));
152
+ console.log(chalk.green(`created project ${project.slug}`));
118
153
  }
119
- console.log(chalk.cyan("packing source..."));
120
- const pack = await packCwd(cwd, project.slug);
121
- console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
154
+ // ─────────────────────────────────────────────────────────────────────
155
+ // Path A: --config — fully scripted, runs the setup endpoint with
156
+ // fields from .layero/project.json, then deploys.
157
+ // ─────────────────────────────────────────────────────────────────────
158
+ if (opts.config) {
159
+ ensureConfigComplete(persistedCfg);
160
+ if (project.status === "pending_setup") {
161
+ console.log(chalk.cyan("applying config..."));
162
+ project = await api.completeSetup(project.id, {
163
+ framework_hint: persistedCfg.framework_hint,
164
+ build_cmd: persistedCfg.build_cmd,
165
+ output_dir: persistedCfg.output_dir,
166
+ analytics_enabled: persistedCfg.analytics_enabled ?? false,
167
+ env_vars: persistedCfg.env_vars ?? {},
168
+ });
169
+ }
170
+ let upload = null;
171
+ try {
172
+ upload = await packAndUpload(api, cwd, project);
173
+ console.log(chalk.cyan("triggering deploy..."));
174
+ const deploy = await api.triggerDeploy(project.id, {
175
+ source_archive_key: upload.archive_key,
176
+ commit_sha: upload.commit_sha,
177
+ commit_message: "CLI deploy",
178
+ framework_hint: persistedCfg.framework_hint ?? undefined,
179
+ });
180
+ console.log(chalk.dim(` deploy_id=${deploy.id}`));
181
+ const final = await streamDeployLogs(api, deploy.id);
182
+ if (final.status !== "ready") {
183
+ console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
188
+ console.log(chalk.dim(` site: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
189
+ }
190
+ finally {
191
+ if (upload) {
192
+ await fs.unlink(upload.archivePath).catch(() => undefined);
193
+ }
194
+ }
195
+ return;
196
+ }
197
+ // ─────────────────────────────────────────────────────────────────────
198
+ // Path B: no flag — upload the source, stash it on the project row,
199
+ // print the setup URL. The user finishes the flow
200
+ // in the browser, exactly like a GitHub import.
201
+ // ─────────────────────────────────────────────────────────────────────
202
+ let upload = null;
122
203
  try {
123
- console.log(chalk.cyan("requesting upload URL..."));
124
- const init = await api.initUpload(project.id);
125
- console.log(chalk.cyan("uploading archive..."));
126
- await uploadArchive(init, pack.archivePath);
204
+ upload = await packAndUpload(api, cwd, project);
205
+ await api.finalizeUpload(project.id, {
206
+ source_archive_key: upload.archive_key,
207
+ commit_sha: upload.commit_sha,
208
+ });
209
+ if (project.status === "pending_setup") {
210
+ const url = setupUrl(cliCfg.apiUrl, project.id);
211
+ console.log("");
212
+ console.log(chalk.green("source uploaded — finish setup in the browser:"));
213
+ console.log(` ${chalk.bold(url)}`);
214
+ console.log("");
215
+ console.log(chalk.dim(" pick framework, build command, output dir, env vars and click Deploy."));
216
+ console.log(chalk.dim(" to skip the browser next time, fill .layero/project.json and run `layero deploy --config`."));
217
+ return;
218
+ }
219
+ // Already-active project: behave like the old `layero deploy` — fire
220
+ // a build straight from the freshly-uploaded archive.
127
221
  console.log(chalk.cyan("triggering deploy..."));
128
222
  const deploy = await api.triggerDeploy(project.id, {
129
- source_archive_key: init.source_archive_key,
130
- commit_sha: pack.sha256,
223
+ source_archive_key: upload.archive_key,
224
+ commit_sha: upload.commit_sha,
131
225
  commit_message: "CLI deploy",
132
226
  framework_hint: opts.type,
133
227
  });
@@ -138,11 +232,12 @@ export async function deployCmd(opts) {
138
232
  process.exitCode = 1;
139
233
  return;
140
234
  }
141
- console.log(chalk.green(`deploy ready → ${projectUrl}`));
235
+ console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
142
236
  console.log(chalk.dim(` site: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
143
237
  }
144
238
  finally {
145
- // Always clean up the local archive.
146
- await fs.unlink(pack.archivePath).catch(() => undefined);
239
+ if (upload) {
240
+ await fs.unlink(upload.archivePath).catch(() => undefined);
241
+ }
147
242
  }
148
243
  }
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { ApiClient } from "../api.js";
3
3
  import { loadConfig } from "../config.js";
4
- import { saveProjectConfig } from "../project-config.js";
4
+ import { persistProjectLinking } from "../project-config.js";
5
5
  export async function linkCmd(idOrSlug) {
6
6
  const cfg = await loadConfig();
7
7
  if (!cfg.token) {
@@ -26,12 +26,15 @@ export async function linkCmd(idOrSlug) {
26
26
  }
27
27
  proj = match;
28
28
  }
29
- await saveProjectConfig(process.cwd(), {
29
+ await persistProjectLinking(process.cwd(), {
30
30
  project_id: proj.id,
31
31
  slug: proj.slug,
32
32
  owner_slug: proj.owner.slug,
33
33
  apex_hostname: proj.apex_hostname,
34
- framework_hint: proj.framework_hint,
35
- });
34
+ }, proj.framework_hint ?? null);
36
35
  console.log(chalk.green(`linked ${proj.slug} (${proj.id}) → ./.layero/project.json`));
36
+ if (proj.status === "pending_setup") {
37
+ console.log(chalk.yellow(" note: project is in pending_setup. finish the setup wizard in the dashboard, " +
38
+ "or run `layero deploy` to upload source and get the wizard URL."));
39
+ }
37
40
  }
@@ -20,3 +20,49 @@ export async function saveProjectConfig(cwd, cfg) {
20
20
  await fs.mkdir(dir, { recursive: true });
21
21
  await fs.writeFile(projectConfigPath(cwd), JSON.stringify(cfg, null, 2), "utf-8");
22
22
  }
23
+ /**
24
+ * Persist linking fields without disturbing user-managed config.
25
+ *
26
+ * `layero deploy` runs every time the user ships code, but the only thing
27
+ * it should ever write back to .layero/project.json is the linkage —
28
+ * project_id / slug / owner_slug / apex_hostname. Hand-edited fields
29
+ * (build_cmd, output_dir, env_vars, analytics_enabled, framework_hint)
30
+ * and any unknown keys the user added must be preserved verbatim, or we
31
+ * silently break the user's --config file on every interactive deploy.
32
+ *
33
+ * Reads the file as raw JSON, overlays the linking subset, and writes it
34
+ * back. Returns the merged config the caller should treat as the new
35
+ * source of truth (matters for first-deploy where there's no existing
36
+ * file).
37
+ */
38
+ export async function persistProjectLinking(cwd, linking, fallbackHint) {
39
+ const file = projectConfigPath(cwd);
40
+ let raw = {};
41
+ try {
42
+ const text = await fs.readFile(file, "utf-8");
43
+ raw = JSON.parse(text);
44
+ }
45
+ catch (err) {
46
+ if (err?.code !== "ENOENT") {
47
+ throw err;
48
+ }
49
+ }
50
+ const merged = {
51
+ ...raw,
52
+ project_id: linking.project_id,
53
+ slug: linking.slug,
54
+ owner_slug: linking.owner_slug,
55
+ apex_hostname: linking.apex_hostname,
56
+ };
57
+ if (linking.api_url !== undefined)
58
+ merged.api_url = linking.api_url;
59
+ // Only seed framework_hint if the user hasn't set one yet — once it's in
60
+ // the file (manually or from a prior --type), leave it alone.
61
+ if (fallbackHint != null &&
62
+ (raw.framework_hint === undefined || raw.framework_hint === null)) {
63
+ merged.framework_hint = fallbackHint;
64
+ }
65
+ await fs.mkdir(path.join(cwd, ".layero"), { recursive: true });
66
+ await fs.writeFile(file, JSON.stringify(merged, null, 2), "utf-8");
67
+ return merged;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Layero CLI — publish a local site with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // Print a quick-start banner after `npm install -g layero`. We write to
3
- // stderr because npm 10+ buffers postinstall stdout by default
4
- // (`foreground-scripts=false`) and only surfaces it on script failure —
5
- // stderr is passed through verbatim, which is how other CLIs (vite,
6
- // cypress, etc.) deliver post-install messages.
2
+ // Print a quick-start banner after `npm install -g layero`.
3
+ //
4
+ // npm 10+ buffers postinstall stdout AND stderr (foreground-scripts=false
5
+ // by default), so writing to either is invisible unless the script fails
6
+ // or the user passes --foreground-scripts. Workaround: write directly to
7
+ // the controlling terminal at /dev/tty, which bypasses npm's pipe.
8
+ //
9
+ // On Windows there's no /dev/tty; we fall back to stderr (which Windows
10
+ // npm doesn't buffer the same way) and silently skip on any error.
7
11
 
8
12
  if (process.env.CI) return;
9
13
  if (process.env.LAYERO_SKIP_POSTINSTALL) return;
@@ -14,17 +18,15 @@ const isGlobal = process.env.npm_config_global === "true";
14
18
  const isDirect = process.env.npm_package_name === "layero";
15
19
  if (!isGlobal && !isDirect) return;
16
20
 
17
- // Best-effort color: stderr is usually a TTY, but stay safe.
18
- const useColor = process.stderr.isTTY && (process.stderr.hasColors?.() ?? true);
19
- const c = useColor
20
- ? {
21
- reset: "\x1b[0m",
22
- bold: "\x1b[1m",
23
- dim: "\x1b[2m",
24
- cyan: "\x1b[36m",
25
- green: "\x1b[32m",
26
- }
27
- : { reset: "", bold: "", dim: "", cyan: "", green: "" };
21
+ const fs = require("node:fs");
22
+
23
+ const c = {
24
+ reset: "\x1b[0m",
25
+ bold: "\x1b[1m",
26
+ dim: "\x1b[2m",
27
+ cyan: "\x1b[36m",
28
+ green: "\x1b[32m",
29
+ };
28
30
 
29
31
  const lines = [
30
32
  "",
@@ -37,4 +39,17 @@ const lines = [
37
39
  ` ${c.dim}Docs: https://layero.ru${c.reset}`,
38
40
  "",
39
41
  ];
40
- process.stderr.write(lines.join("\n"));
42
+ const banner = lines.join("\n");
43
+
44
+ try {
45
+ if (process.platform === "win32") {
46
+ process.stderr.write(banner);
47
+ } else {
48
+ // Open the controlling tty directly. This bypasses npm's stdio pipes.
49
+ const fd = fs.openSync("/dev/tty", "w");
50
+ fs.writeSync(fd, banner);
51
+ fs.closeSync(fd);
52
+ }
53
+ } catch {
54
+ // Headless install (no tty), or perms issue — give up silently.
55
+ }