layero 0.1.7 → 0.2.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
@@ -58,17 +58,23 @@ 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
  }
64
70
  pollLogs(deployId, afterId) {
65
71
  return this.request("GET", `/deploys/${deployId}/logs?after_id=${afterId}`);
66
72
  }
67
- setHandle(value) {
68
- return this.request("POST", "/me/handle", { value });
73
+ setUsername(value) {
74
+ return this.request("POST", "/auth/me/username", { value });
69
75
  }
70
- checkHandle(value) {
71
- return this.request("GET", `/me/handle/check?value=${encodeURIComponent(value)}`);
76
+ checkUsername(value) {
77
+ return this.request("GET", `/auth/me/username/check?value=${encodeURIComponent(value)}`);
72
78
  }
73
79
  }
74
80
  export async function uploadArchive(init, filePath) {
@@ -62,8 +62,17 @@ async function main() {
62
62
  .option("-t, --type <preset>", "framework hint (vite | next | astro | cra | sveltekit | nuxt | gatsby | static)")
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
- .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")
65
+ .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
66
+ .option("--config", "use framework/build settings + env vars from .layero/project.json (skips the browser setup wizard)")
67
+ .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
+ .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
69
+ .addHelpText("after", "\nExamples:\n" +
70
+ " $ layero deploy # preview on CLI pseudo-branch (never replaces prod)\n" +
71
+ " $ layero deploy --prod # production deploy (interactive confirm)\n" +
72
+ " $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
73
+ " $ layero deploy --branch=staging # preview on a specific branch\n" +
74
+ " $ layero deploy --config # uses .layero/project.json end-to-end (CI-friendly)\n" +
75
+ " $ layero deploy --type vite")
67
76
  .action(async (opts) => {
68
77
  await deployCmd(opts);
69
78
  });
@@ -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,26 @@ async function prompt(question, fallback) {
48
52
  rl.close();
49
53
  }
50
54
  }
51
- async function resolveProject(api, cwd, opts) {
55
+ async function confirm(question) {
56
+ const rl = readline.createInterface({
57
+ input: process.stdin,
58
+ output: process.stdout,
59
+ });
60
+ try {
61
+ const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
62
+ return answer === "y" || answer === "yes";
63
+ }
64
+ finally {
65
+ rl.close();
66
+ }
67
+ }
68
+ function deployTargeting(opts) {
69
+ if (opts.branch) {
70
+ return { target: "preview", branch: opts.branch };
71
+ }
72
+ return { target: opts.prod ? "production" : "preview" };
73
+ }
74
+ async function resolveOrCreateProject(api, cwd, opts, existing) {
52
75
  if (opts.project) {
53
76
  const all = await api.listProjects();
54
77
  const match = all.find((p) => p.id === opts.project) ??
@@ -58,7 +81,6 @@ async function resolveProject(api, cwd, opts) {
58
81
  }
59
82
  return { project: match, createdNow: false };
60
83
  }
61
- const existing = await loadProjectConfig(cwd);
62
84
  if (existing) {
63
85
  try {
64
86
  const project = await api.getProject(existing.project_id);
@@ -73,63 +95,175 @@ async function resolveProject(api, cwd, opts) {
73
95
  throw err;
74
96
  }
75
97
  }
76
- // No project_id locally — create one.
77
98
  const me = await api.me();
78
- if (!me.handle) {
79
- throw new Error("no handle set on your account. open https://app.layero.ru/onboarding " +
99
+ if (!me.username) {
100
+ throw new Error("no username set on your account. open https://app.layero.ru/onboarding " +
80
101
  "and pick one, then re-run.");
81
102
  }
82
103
  const fallbackName = path.basename(cwd);
83
- const name = opts.name ?? (opts.yes ? fallbackName : await prompt("project name", fallbackName));
104
+ const name = opts.name ?? (opts.yes || opts.config ? fallbackName : await prompt("project name", fallbackName));
84
105
  const project = await api.createCliProject({
85
106
  name,
86
107
  framework_hint: opts.type,
87
108
  });
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
109
  return { project, createdNow: true };
96
110
  }
111
+ async function packAndUpload(api, cwd, project) {
112
+ console.log(chalk.cyan("packing source..."));
113
+ const pack = await packCwd(cwd, project.slug);
114
+ console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
115
+ console.log(chalk.cyan("requesting upload URL..."));
116
+ const init = await api.initUpload(project.id);
117
+ console.log(chalk.cyan("uploading archive..."));
118
+ await uploadArchive(init, pack.archivePath);
119
+ return {
120
+ archive_key: init.source_archive_key,
121
+ commit_sha: pack.sha256,
122
+ archivePath: pack.archivePath,
123
+ };
124
+ }
125
+ function ensureConfigComplete(cfg) {
126
+ if (!cfg) {
127
+ throw new Error("no .layero/project.json found. run `layero deploy` once without --config " +
128
+ "to create the project, fill in framework_hint/build_cmd/output_dir there, " +
129
+ "then re-run with --config.");
130
+ }
131
+ const missing = [];
132
+ if (!cfg.framework_hint)
133
+ missing.push("framework_hint");
134
+ if (!cfg.build_cmd)
135
+ missing.push("build_cmd");
136
+ if (!cfg.output_dir)
137
+ missing.push("output_dir");
138
+ if (missing.length > 0) {
139
+ throw new Error(`.layero/project.json is missing required fields for --config: ${missing.join(", ")}`);
140
+ }
141
+ }
97
142
  export async function deployCmd(opts) {
98
143
  if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
99
144
  throw new Error(`unknown --type "${opts.type}". valid: ${[...VALID_TYPES].join(", ")}`);
100
145
  }
101
- const cfg = await loadConfig();
102
- if (!cfg.token) {
146
+ const cliCfg = await loadConfig();
147
+ if (!cliCfg.token) {
103
148
  throw new Error("not logged in. run `layero login` first.");
104
149
  }
105
- const api = new ApiClient(cfg);
150
+ const api = new ApiClient(cliCfg);
106
151
  const cwd = process.cwd();
107
- const { project, createdNow } = await resolveProject(api, cwd, opts);
108
- if (project.source_type !== "cli") {
109
- throw new Error(`project "${project.slug}" is a GitHub-source project; ` +
110
- "use the dashboard's Deploy button or push to the linked repo.");
111
- }
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);
152
+ const existing = await loadProjectConfig(cwd);
153
+ const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
154
+ let project = created;
155
+ if (project.cli_deploys_enabled === false) {
156
+ throw new Error(`CLI deploys are disabled on project "${project.slug}". ` +
157
+ "Enable them in project settings or use the dashboard's Deploy button.");
158
+ }
159
+ // Prompt before overwriting production. CLI deploys default to a per-project
160
+ // "cli" pseudo-branch (preview-only); --prod is the explicit opt-in to land
161
+ // on apex_hostname. --yes (CI) or --branch=<default_branch> bypass.
162
+ if (opts.prod && !opts.yes && !opts.branch) {
163
+ const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
164
+ if (!ok) {
165
+ console.log(chalk.yellow("aborted."));
166
+ return;
167
+ }
168
+ }
169
+ // Persist linking metadata only — never touch hand-edited config fields
170
+ // or unknown keys the user may have added (a --config file is the user's
171
+ // source of truth, not ours). `persistProjectLinking` reads the file as
172
+ // raw JSON, overlays just project_id/slug/organization_slug/apex_hostname,
173
+ // and writes it back.
174
+ const persistedCfg = await persistProjectLinking(cwd, {
175
+ project_id: project.id,
176
+ slug: project.slug,
177
+ organization_slug: project.organization.slug,
178
+ apex_hostname: project.apex_hostname,
179
+ }, opts.type ?? null);
116
180
  if (createdNow) {
117
- console.log(chalk.green(`created project ${project.slug} → ${projectUrl}`));
181
+ console.log(chalk.green(`created project ${project.slug}`));
118
182
  }
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)}`));
183
+ // ─────────────────────────────────────────────────────────────────────
184
+ // Path A: --config — fully scripted, runs the setup endpoint with
185
+ // fields from .layero/project.json, then deploys.
186
+ // ─────────────────────────────────────────────────────────────────────
187
+ if (opts.config) {
188
+ ensureConfigComplete(persistedCfg);
189
+ if (project.status === "pending_setup") {
190
+ console.log(chalk.cyan("applying config..."));
191
+ project = await api.completeSetup(project.id, {
192
+ framework_hint: persistedCfg.framework_hint,
193
+ build_cmd: persistedCfg.build_cmd,
194
+ output_dir: persistedCfg.output_dir,
195
+ analytics_enabled: persistedCfg.analytics_enabled ?? false,
196
+ env_vars: persistedCfg.env_vars ?? {},
197
+ });
198
+ }
199
+ let upload = null;
200
+ try {
201
+ upload = await packAndUpload(api, cwd, project);
202
+ console.log(chalk.cyan("triggering deploy..."));
203
+ const targeting = deployTargeting(opts);
204
+ const deploy = await api.triggerDeploy(project.id, {
205
+ source_archive_key: upload.archive_key,
206
+ commit_sha: upload.commit_sha,
207
+ commit_message: "CLI deploy",
208
+ framework_hint: persistedCfg.framework_hint ?? undefined,
209
+ target: targeting.target,
210
+ branch: targeting.branch,
211
+ });
212
+ console.log(chalk.dim(` deploy_id=${deploy.id}`));
213
+ const final = await streamDeployLogs(api, deploy.id);
214
+ if (final.status !== "ready") {
215
+ console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
216
+ process.exitCode = 1;
217
+ return;
218
+ }
219
+ console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
220
+ if (opts.prod && !opts.branch) {
221
+ console.log(chalk.dim(` production: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
222
+ }
223
+ else {
224
+ console.log(chalk.dim(` preview deploy — open the dashboard for the exact URL`));
225
+ }
226
+ }
227
+ finally {
228
+ if (upload) {
229
+ await fs.unlink(upload.archivePath).catch(() => undefined);
230
+ }
231
+ }
232
+ return;
233
+ }
234
+ // ─────────────────────────────────────────────────────────────────────
235
+ // Path B: no flag — upload the source, stash it on the project row,
236
+ // print the setup URL. The user finishes the flow
237
+ // in the browser, exactly like a GitHub import.
238
+ // ─────────────────────────────────────────────────────────────────────
239
+ let upload = null;
122
240
  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);
241
+ upload = await packAndUpload(api, cwd, project);
242
+ await api.finalizeUpload(project.id, {
243
+ source_archive_key: upload.archive_key,
244
+ commit_sha: upload.commit_sha,
245
+ });
246
+ if (project.status === "pending_setup") {
247
+ const url = setupUrl(cliCfg.apiUrl, project.id);
248
+ console.log("");
249
+ console.log(chalk.green("source uploaded — finish setup in the browser:"));
250
+ console.log(` ${chalk.bold(url)}`);
251
+ console.log("");
252
+ console.log(chalk.dim(" pick framework, build command, output dir, env vars and click Deploy."));
253
+ console.log(chalk.dim(" to skip the browser next time, fill .layero/project.json and run `layero deploy --config`."));
254
+ return;
255
+ }
256
+ // Already-active project: behave like the old `layero deploy` — fire
257
+ // a build straight from the freshly-uploaded archive.
127
258
  console.log(chalk.cyan("triggering deploy..."));
259
+ const targeting = deployTargeting(opts);
128
260
  const deploy = await api.triggerDeploy(project.id, {
129
- source_archive_key: init.source_archive_key,
130
- commit_sha: pack.sha256,
261
+ source_archive_key: upload.archive_key,
262
+ commit_sha: upload.commit_sha,
131
263
  commit_message: "CLI deploy",
132
264
  framework_hint: opts.type,
265
+ target: targeting.target,
266
+ branch: targeting.branch,
133
267
  });
134
268
  console.log(chalk.dim(` deploy_id=${deploy.id}`));
135
269
  const final = await streamDeployLogs(api, deploy.id);
@@ -138,11 +272,12 @@ export async function deployCmd(opts) {
138
272
  process.exitCode = 1;
139
273
  return;
140
274
  }
141
- console.log(chalk.green(`deploy ready → ${projectUrl}`));
275
+ console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
142
276
  console.log(chalk.dim(` site: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
143
277
  }
144
278
  finally {
145
- // Always clean up the local archive.
146
- await fs.unlink(pack.archivePath).catch(() => undefined);
279
+ if (upload) {
280
+ await fs.unlink(upload.archivePath).catch(() => undefined);
281
+ }
147
282
  }
148
283
  }
@@ -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
- owner_slug: proj.owner.slug,
32
+ organization_slug: proj.organization.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
  }
@@ -66,7 +66,17 @@ export async function loginCmd(opts) {
66
66
  });
67
67
  server.on("error", reject);
68
68
  server.listen(desiredPort, "127.0.0.1", () => {
69
- const port = server.address().port;
69
+ const addr = server.address();
70
+ // Defence-in-depth: confirm we actually bound to loopback before
71
+ // emitting `callback=…` to the browser. If listen() somehow returned
72
+ // a non-loopback address (Node bug, weird /etc/hosts) we'd otherwise
73
+ // hand the JWT redirect target out to whoever owns that interface.
74
+ if (!addr || addr.address !== "127.0.0.1") {
75
+ server.close();
76
+ reject(new Error(`expected to bind 127.0.0.1, got ${addr?.address ?? "null"}`));
77
+ return;
78
+ }
79
+ const port = addr.port;
70
80
  const callback = `http://127.0.0.1:${port}/callback`;
71
81
  const startUrl = `${cfg.apiUrl.replace(/\/+$/, "")}/auth/cli/start` +
72
82
  `?provider=${provider}` +
@@ -87,10 +97,10 @@ export async function loginCmd(opts) {
87
97
  cfg.token = token;
88
98
  const probe = new ApiClient(cfg);
89
99
  const me = await probe.me();
90
- cfg.user = { id: me.id, handle: me.handle, email: me.email };
100
+ cfg.user = { id: me.id, username: me.username, email: me.email };
91
101
  await saveConfig(cfg);
92
- console.log(chalk.green(`logged in as ${me.handle ?? me.email ?? me.id}`));
93
- if (!me.handle) {
94
- console.log(chalk.yellow("no handle set — open https://app.layero.ru/onboarding to pick one."));
102
+ console.log(chalk.green(`logged in as ${me.username ?? me.email ?? me.id}`));
103
+ if (!me.username) {
104
+ console.log(chalk.yellow("no username set — open https://app.layero.ru/onboarding to pick one."));
95
105
  }
96
106
  }
@@ -8,7 +8,7 @@ export async function tokenSetCmd(jwt) {
8
8
  const probe = new ApiClient(cfg);
9
9
  try {
10
10
  const me = await probe.me();
11
- cfg.user = { id: me.id, handle: me.handle, email: me.email };
11
+ cfg.user = { id: me.id, username: me.username, email: me.email };
12
12
  }
13
13
  catch (err) {
14
14
  console.error(chalk.red(`token rejected by API: ${err.message}`));
@@ -16,9 +16,9 @@ export async function tokenSetCmd(jwt) {
16
16
  return;
17
17
  }
18
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 " +
19
+ console.log(chalk.green(`saved token for ${cfg.user?.username ?? cfg.user?.email ?? cfg.user?.id}`));
20
+ if (!cfg.user?.username) {
21
+ console.log(chalk.yellow("no username set — open https://app.layero.ru/onboarding to pick one " +
22
22
  "before `layero deploy`."));
23
23
  }
24
24
  }
@@ -11,7 +11,7 @@ export async function whoamiCmd() {
11
11
  const api = new ApiClient(cfg);
12
12
  const me = await api.me();
13
13
  console.log(`id: ${me.id}`);
14
- console.log(`handle: ${me.handle ?? chalk.yellow("(not set)")}`);
14
+ console.log(`username: ${me.username ?? chalk.yellow("(not set)")}`);
15
15
  console.log(`email: ${me.email ?? "(none)"}`);
16
16
  if (me.github_login) {
17
17
  console.log(`github: ${me.github_login}`);
@@ -6,7 +6,15 @@ export function projectConfigPath(cwd) {
6
6
  export async function loadProjectConfig(cwd) {
7
7
  try {
8
8
  const raw = await fs.readFile(projectConfigPath(cwd), "utf-8");
9
- return JSON.parse(raw);
9
+ const parsed = JSON.parse(raw);
10
+ // Backward-read: configs written before V050 stored the field as
11
+ // `owner_slug`. Promote it to `organization_slug` in-memory so the
12
+ // rest of the CLI doesn't have to handle both names. Disk file gets
13
+ // rewritten on the next persistProjectLinking() call.
14
+ if (!parsed.organization_slug && parsed.owner_slug) {
15
+ parsed.organization_slug = parsed.owner_slug;
16
+ }
17
+ return parsed;
10
18
  }
11
19
  catch (err) {
12
20
  if (err?.code === "ENOENT") {
@@ -20,3 +28,52 @@ export async function saveProjectConfig(cwd, cfg) {
20
28
  await fs.mkdir(dir, { recursive: true });
21
29
  await fs.writeFile(projectConfigPath(cwd), JSON.stringify(cfg, null, 2), "utf-8");
22
30
  }
31
+ /**
32
+ * Persist linking fields without disturbing user-managed config.
33
+ *
34
+ * `layero deploy` runs every time the user ships code, but the only thing
35
+ * it should ever write back to .layero/project.json is the linkage —
36
+ * project_id / slug / organization_slug / apex_hostname. Hand-edited
37
+ * fields (build_cmd, output_dir, env_vars, analytics_enabled,
38
+ * framework_hint) and any unknown keys the user added must be preserved
39
+ * verbatim, or we silently break the user's --config file on every
40
+ * interactive deploy.
41
+ *
42
+ * Reads the file as raw JSON, overlays the linking subset, and writes it
43
+ * back. Drops any legacy `owner_slug` key once the new name has been
44
+ * written, so future reads see only `organization_slug`. Returns the
45
+ * merged config the caller should treat as the new source of truth.
46
+ */
47
+ export async function persistProjectLinking(cwd, linking, fallbackHint) {
48
+ const file = projectConfigPath(cwd);
49
+ let raw = {};
50
+ try {
51
+ const text = await fs.readFile(file, "utf-8");
52
+ raw = JSON.parse(text);
53
+ }
54
+ catch (err) {
55
+ if (err?.code !== "ENOENT") {
56
+ throw err;
57
+ }
58
+ }
59
+ // Strip the legacy owner_slug if it sneaks in from an older CLI write.
60
+ delete raw.owner_slug;
61
+ const merged = {
62
+ ...raw,
63
+ project_id: linking.project_id,
64
+ slug: linking.slug,
65
+ organization_slug: linking.organization_slug,
66
+ apex_hostname: linking.apex_hostname,
67
+ };
68
+ if (linking.api_url !== undefined)
69
+ merged.api_url = linking.api_url;
70
+ // Only seed framework_hint if the user hasn't set one yet — once it's in
71
+ // the file (manually or from a prior --type), leave it alone.
72
+ if (fallbackHint != null &&
73
+ (raw.framework_hint === undefined || raw.framework_hint === null)) {
74
+ merged.framework_hint = fallbackHint;
75
+ }
76
+ await fs.mkdir(path.join(cwd, ".layero"), { recursive: true });
77
+ await fs.writeFile(file, JSON.stringify(merged, null, 2), "utf-8");
78
+ return merged;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Layero CLI — publish a local site with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",