@layoutplatforms/cli 0.2.0 → 0.3.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 CHANGED
@@ -13,14 +13,15 @@ Or run it once without installing: `npx @layoutplatforms/cli login`. Node 20 or
13
13
 
14
14
  | Command | What it does |
15
15
  |---|---|
16
+ | `layout init [--name <name>]` | Pick or create an application, read its sandbox key, run a test order, and print `LAYOUT_APP` and `LAYOUT_SECRET_KEY` to keep. Safe to run again. |
16
17
  | `layout login [--live]` | Sign in with a code you approve in the console. `--live` adds 12 hours of production reads. |
17
18
  | `layout logout` | Revoke this CLI session and forget it here. |
18
19
  | `layout whoami` | Your developer account and this CLI session. |
19
20
  | `layout apps list`, `layout apps create` | Your applications. |
20
21
  | `layout keys list`, `layout keys create`, `layout keys revoke` | Your sandbox secret key. |
21
22
  | `layout webhooks list` | Your webhook endpoints. Add them in the console. |
22
- | `layout listen --forward-to <url>` | Forward events to your machine, signed like real webhooks. |
23
- | `layout trigger <event>` | Send a test event to your endpoints. |
23
+ | `layout listen --forward-to <url>` | Forward sandbox events to your machine, signed like real webhooks. No endpoint needed: while it runs, Layout records the application's sandbox events for it. |
24
+ | `layout trigger <event>` | Send a test event to your endpoints, or to a running `layout listen`. |
24
25
  | `layout api get /v1/places?query=coffee` | Call any REST endpoint and print the answer. |
25
26
  | `layout test-order` | Run a whole order against a sandbox test restaurant. |
26
27
  | `layout usage` | Your limits and what you have used today. |
@@ -30,10 +31,19 @@ Or run it once without installing: `npx @layoutplatforms/cli login`. Node 20 or
30
31
 
31
32
  Every command takes `--json`, so a coding agent can drive it. `listen`, `deliveries`, `keys list` and `webhooks list` take `--live`.
32
33
 
33
- In CI, set `LAYOUT_SECRET_KEY` to a sandbox key and `layout api` and `layout test-order` run without signing in.
34
+ ## Without a browser: agents and CI
35
+
36
+ Make a CLI token in the console at https://developer.layout.link/console/cli (90 days by default, at most a year, shown once) and set it as `LAYOUT_CLI_TOKEN`. Every command then works with no `layout login`, in sandbox only. If both are present, `LAYOUT_CLI_TOKEN` wins. It is sent only to `https://api.layout.link`, so it is refused when `LAYOUT_API_BASE` points anywhere else. `layout logout` does not revoke it; revoke it in the console.
37
+
38
+ ```
39
+ export LAYOUT_CLI_TOKEN=lcli_...
40
+ layout init --name "My app" --json
41
+ ```
42
+
43
+ `layout init --json` prints one object with `env.LAYOUT_APP`, `env.LAYOUT_SECRET_KEY` (a sandbox key) and `docs`. With only `LAYOUT_SECRET_KEY` set, `layout api` and `layout test-order` run too.
34
44
 
35
45
  ## What a CLI session can do
36
46
 
37
- In sandbox, everything you need to build. In production, with `layout login --live` approved from a fresh console sign-in, it reads live events, deliveries and key details for 12 hours. It never creates or shows live keys, changes production settings or places an order.
47
+ In sandbox, everything you need to build. A `LAYOUT_CLI_TOKEN` is sandbox only, always. In production, with `layout login --live` approved from a fresh console sign-in, it reads live events, deliveries and key details for 12 hours. It never creates or shows live keys, changes production settings or places an order.
38
48
 
39
49
  Full reference: https://developer.layout.link/reference/cli
@@ -0,0 +1,90 @@
1
+ import { bool, str } from "../args.js";
2
+ import { DOCS_BASE } from "../config.js";
3
+ import { CliError, EXIT, usageError } from "../errors.js";
4
+ import { authed, remember, sandboxSecret, SECRET_ENV, signedIn } from "../context.js";
5
+ import { placeOf, runTestOrder } from "./test-order.js";
6
+ export const QUICKSTART = `${DOCS_BASE}/reference/quickstart`;
7
+ async function chooseApp(ctx) {
8
+ const name = str(ctx.args, "name")?.trim();
9
+ const flag = str(ctx.args, "app")?.trim();
10
+ const chosen = flag ?? (name ? undefined : ctx.env.LAYOUT_APP?.trim() || undefined);
11
+ const { apps } = await authed(ctx, "/v1/cli/apps");
12
+ if (chosen) {
13
+ const app = apps.find((a) => a.id === chosen);
14
+ if (!app)
15
+ throw new CliError("not_found", `No application ${chosen} in this account. Run layout apps list.`, EXIT.notFound);
16
+ if (name && app.name !== name)
17
+ throw usageError(`--app ${chosen} is named ${app.name}, not ${name}. Give one of --app or --name.`);
18
+ return { app, created: false, secret: null };
19
+ }
20
+ if (name) {
21
+ const same = apps.filter((a) => a.name === name);
22
+ if (same.length > 1)
23
+ throw usageError(`${same.length} applications are named ${name}. Pick one with --app:\n${same.map((a) => ` ${a.id}`).join("\n")}`);
24
+ if (same[0])
25
+ return { app: same[0], created: false, secret: null };
26
+ const made = await authed(ctx, "/v1/cli/apps", { method: "POST", body: { name }, idempotent: true, retry: true });
27
+ return { app: made.app, created: true, secret: made.sandboxKey.secret };
28
+ }
29
+ if (apps.length === 1)
30
+ return { app: apps[0], created: false, secret: null };
31
+ if (apps.length === 0)
32
+ throw usageError("This account has no applications. Run layout init --name <name> to create one.");
33
+ throw usageError(`This account has ${apps.length} applications. Pick one with --app or --name:\n${apps.map((a) => ` ${a.id} ${a.name}`).join("\n")}`);
34
+ }
35
+ async function keyFor(ctx, app) {
36
+ try {
37
+ return await sandboxSecret(ctx, app);
38
+ }
39
+ catch (err) {
40
+ if (!(err instanceof CliError) || err.code !== "not_found")
41
+ throw err;
42
+ }
43
+ const made = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys`, { method: "POST", body: { replace: false }, idempotent: true, retry: true });
44
+ return made.secret;
45
+ }
46
+ export async function init(ctx) {
47
+ const { out } = ctx;
48
+ const place = placeOf(ctx);
49
+ const skip = bool(ctx.args, "skip-test");
50
+ signedIn(ctx);
51
+ const { app, created, secret: minted } = await chooseApp(ctx);
52
+ if (app.status === "suspended")
53
+ throw new CliError("app_suspended", `${app.name} is suspended. Reply to your welcome email.`, EXIT.refused);
54
+ const secret = minted ?? await keyFor(ctx, app.id);
55
+ if (!secret.startsWith("sk_test_"))
56
+ throw new CliError("unexpected_key", "Layout returned something other than a sandbox key, so it was not used.", EXIT.error);
57
+ remember(ctx, app.id, secret);
58
+ const env = { LAYOUT_APP: app.id, [SECRET_ENV]: secret };
59
+ const summary = { app: { id: app.id, name: app.name, status: app.status }, created, env, docs: QUICKSTART };
60
+ if (!out.json) {
61
+ out.line(`${created ? "Created" : "Using"} ${app.name} (${app.id}).`);
62
+ out.line();
63
+ out.line("Add these to your environment. The key is sandbox only; keep it out of source control.");
64
+ for (const [k, v] of Object.entries(env))
65
+ out.line(` ${k}=${k === SECRET_ENV ? out.accent(v) : v}`);
66
+ out.line();
67
+ out.line(`Docs: ${QUICKSTART}`);
68
+ }
69
+ let testOrder = null;
70
+ if (!skip) {
71
+ if (!out.json) {
72
+ out.line();
73
+ out.line(`Running a sandbox test order at ${place}.`);
74
+ }
75
+ const steps = [];
76
+ try {
77
+ testOrder = await runTestOrder(ctx, { app: app.id, place, steps, secret: { value: secret, from: "for layout init" } });
78
+ }
79
+ catch (err) {
80
+ if (out.json)
81
+ out.data({ ...summary, testOrder: { place, steps, order: null, ok: false }, ok: false });
82
+ throw err;
83
+ }
84
+ }
85
+ out.result({ ...summary, testOrder, ok: true }, () => {
86
+ if (testOrder)
87
+ out.line("Done. Nothing reached a restaurant and no money moved.");
88
+ });
89
+ return EXIT.ok;
90
+ }
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { bool, str } from "../args.js";
3
3
  import { configDir, hostKey, readJson, writePrivateJson } from "../config.js";
4
- import { EXIT, usageError } from "../errors.js";
4
+ import { CliError, EXIT, usageError } from "../errors.js";
5
5
  import { authed, pickApp } from "../context.js";
6
6
  import { isLive, WEBHOOKS_PAGE } from "./resources.js";
7
7
  import { mintLocalSecret, runListener } from "../listen.js";
@@ -44,15 +44,16 @@ export async function listen(ctx) {
44
44
  else
45
45
  start = (await authed(ctx, `/v1/cli/apps/${enc}/events/cursor${envq ? `?${envq}` : ""}`)).cursor;
46
46
  const hooks = await authed(ctx, `/v1/cli/apps/${enc}/webhooks${envq ? `?${envq}` : ""}`);
47
+ const session = live ? null : await openListen(ctx, enc);
47
48
  const secret = mintLocalSecret();
48
49
  if (out.json)
49
- out.data({ event: "listening", app, environment: live ? "live" : "sandbox", forwardTo, secret, resumed: Boolean(saved) && !bool(ctx.args, "from-start") });
50
+ out.data({ event: "listening", app, environment: live ? "live" : "sandbox", forwardTo, secret, resumed: Boolean(saved) && !bool(ctx.args, "from-start"), recordsWithoutEndpoint: Boolean(session) });
50
51
  else {
51
52
  out.line(`Forwarding ${live ? out.accent("production") : "sandbox"} events for ${app} to ${forwardTo}`);
52
53
  out.line(`Signing secret for this run ${out.accent(secret)}`);
53
54
  out.line(saved && !bool(ctx.args, "from-start") ? "Resuming after the last event forwarded." : "Starting from now. Press Ctrl-C to stop.");
54
55
  }
55
- if (!hooks.subscriptions.some((s) => s.enabled)) {
56
+ if (!session && !hooks.subscriptions.some((s) => s.enabled)) {
56
57
  out.note(`This application has no enabled webhook endpoint, so Layout records no events for it. Add one once in the console: ${WEBHOOKS_PAGE}`);
57
58
  }
58
59
  let stopped = false;
@@ -84,6 +85,7 @@ export async function listen(ctx) {
84
85
  },
85
86
  saveCursor: (cursor) => writePrivateJson(stateFile, { cursor, updatedAt: new Date(p.now()).toISOString() }),
86
87
  sleep: async (ms) => {
88
+ await session?.keepAlive().catch(() => undefined);
87
89
  const until = p.now() + ms;
88
90
  while (!stopped && p.now() < until)
89
91
  await p.sleep(Math.min(250, until - p.now()));
@@ -96,6 +98,41 @@ export async function listen(ctx) {
96
98
  finally {
97
99
  spin.stop();
98
100
  off();
101
+ await session?.close();
99
102
  }
100
103
  return stopped ? EXIT.interrupted : EXIT.ok;
101
104
  }
105
+ const RENEW_EVERY_MS = 45_000;
106
+ /** Null before migration 0326, and listen then needs an enabled endpoint as before. */
107
+ async function openListen(ctx, enc) {
108
+ const open = () => authed(ctx, `/v1/cli/apps/${enc}/listen`, { method: "POST", body: {}, idempotent: true, retry: true }).then((r) => r.listen.id);
109
+ let id;
110
+ try {
111
+ id = await open();
112
+ }
113
+ catch (err) {
114
+ if (err instanceof CliError && err.exit === EXIT.auth)
115
+ throw err;
116
+ return null;
117
+ }
118
+ let renewedAt = ctx.p.now();
119
+ return {
120
+ async keepAlive() {
121
+ if (ctx.p.now() - renewedAt < RENEW_EVERY_MS)
122
+ return;
123
+ try {
124
+ await authed(ctx, `/v1/cli/apps/${enc}/listen/${encodeURIComponent(id)}/renew`, { method: "POST", body: {}, idempotent: true, retry: true });
125
+ renewedAt = ctx.p.now();
126
+ }
127
+ catch (err) {
128
+ if (err instanceof CliError && err.code === "listen_expired") {
129
+ id = await open().catch(() => id);
130
+ renewedAt = ctx.p.now();
131
+ }
132
+ }
133
+ },
134
+ async close() {
135
+ await authed(ctx, `/v1/cli/apps/${enc}/listen/${encodeURIComponent(id)}/close`, { method: "POST", body: {}, idempotent: true, retry: false, timeoutMs: 5_000 }).catch(() => undefined);
136
+ },
137
+ };
138
+ }
@@ -100,6 +100,8 @@ export async function trigger(ctx) {
100
100
  method: "POST", body: { event, ...(order ? { orderId: order } : {}) }, idempotent: true, retry: true,
101
101
  });
102
102
  ctx.out.result({ app, ...data }, () => {
103
+ if (data.recorded)
104
+ return ctx.out.line(`Recorded ${data.event} for layout listen${data.order ? `, describing ${data.order.id}` : ""}. No endpoint was sent anything.`);
103
105
  ctx.out.line(`Sent ${data.event} to ${data.deliveries.length} endpoint${data.deliveries.length === 1 ? "" : "s"}${data.order ? `, describing ${data.order.id}` : ""}.`);
104
106
  ctx.out.table(data.deliveries.map((d) => [d.id, d.outcome, d.url]));
105
107
  });
@@ -2,7 +2,7 @@ import { bool, str } from "../args.js";
2
2
  import { hostKey, readSession, writeSession } from "../config.js";
3
3
  import { chooseStore, fileStore, storeOfKind } from "../credentials.js";
4
4
  import { CliError, EXIT } from "../errors.js";
5
- import { authed, forget } from "../context.js";
5
+ import { authed, envToken, forget, TOKEN_ENV } from "../context.js";
6
6
  const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
7
7
  function localTime(iso) {
8
8
  const d = new Date(iso);
@@ -100,17 +100,21 @@ async function finishLogin(ctx, tok) {
100
100
  if (previousToken?.startsWith("lcli_") && previous?.sessionId !== tok.session_id) {
101
101
  await ctx.api.request("/v1/cli/logout", { method: "POST", body: {}, auth: previousToken, retry: false }).catch(() => undefined);
102
102
  }
103
- const who = await authed(ctx, "/v1/cli/whoami");
103
+ const who = await authed(ctx.env[TOKEN_ENV] ? { ...ctx, env: { ...ctx.env, [TOKEN_ENV]: "" } } : ctx, "/v1/cli/whoami");
104
104
  const liveUntil = who.session.liveUntil ?? null;
105
105
  writeSession(ctx.env, ctx.base, { sessionId: tok.session_id, accountId: who.account.id, accountName: who.account.name, store: store.kind, expiresAt: who.session.expiresAt, liveUntil });
106
106
  out.result({ event: "signed_in", account: who.account, session: who.session }, () => {
107
107
  out.line(`Signed in to ${who.account.name ?? "your developer account"}.`);
108
108
  out.line(liveUntil ? `Production read access until ${localTime(liveUntil)}. Add --live to a command to use it.` : "Sandbox. Run layout login --live to read production.");
109
109
  });
110
+ if (ctx.env[TOKEN_ENV]?.trim())
111
+ out.note(`${TOKEN_ENV} is set, and commands use it instead of this sign-in until it is unset.`);
110
112
  return EXIT.ok;
111
113
  }
112
114
  export async function logout(ctx) {
113
115
  const { out } = ctx;
116
+ if (envToken(ctx))
117
+ throw new CliError("token_in_env", `${TOKEN_ENV} is set, and logout does not revoke it. Revoke it in the console at https://developer.layout.link/console/cli, or unset it.`, EXIT.usage);
114
118
  const record = readSession(ctx.env, ctx.base);
115
119
  let revoked = false;
116
120
  if (record) {
@@ -135,7 +139,8 @@ export async function whoami(ctx) {
135
139
  const who = await authed(ctx, "/v1/cli/whoami");
136
140
  ctx.out.result(who, () => {
137
141
  ctx.out.line(`${who.account.name ?? "Developer account"} (${who.account.id})`);
138
- ctx.out.line(`Session ${who.session.deviceName ? `on ${who.session.deviceName}, ` : ""}expires ${who.session.expiresAt.slice(0, 10)}`);
142
+ const label = who.session.kind === "token" ? `${TOKEN_ENV} ${who.session.deviceName ?? ""}`.trim() : `Session${who.session.deviceName ? ` on ${who.session.deviceName}` : ""}`;
143
+ ctx.out.line(`${label}, expires ${who.session.expiresAt.slice(0, 10)}`);
139
144
  ctx.out.line(who.session.liveUntil ? `Production read access until ${localTime(who.session.liveUntil)}` : "Sandbox. Run layout login --live to read production.");
140
145
  });
141
146
  return EXIT.ok;
@@ -20,18 +20,37 @@ export function testPhone(appId) {
20
20
  return `+1500555${String(100 + n).padStart(4, "0")}`;
21
21
  }
22
22
  const money = (minor) => (typeof minor === "number" ? `$${(minor / 100).toFixed(2)}` : "unknown");
23
- export async function testOrder(ctx) {
24
- const { out, api, p } = ctx;
23
+ export function placeOf(ctx) {
25
24
  const place = str(ctx.args, "place") ?? "test_place_placed";
26
- const expected = EXPECTED[place];
27
- if (!expected)
25
+ if (!EXPECTED[place])
28
26
  throw usageError(`test-order runs a test restaurant that builds a cart on its own: ${Object.keys(EXPECTED).join(", ")}.`);
27
+ return place;
28
+ }
29
+ export async function testOrder(ctx) {
30
+ const place = placeOf(ctx);
29
31
  const fromEnv = envSecret(ctx);
30
32
  if (fromEnv?.startsWith("sk_live_"))
31
33
  throw usageError(`test-order runs in sandbox only, and ${SECRET_ENV} is a live secret.`);
32
34
  const app = fromEnv ? null : await pickApp(ctx);
33
- const seed = app ?? createHash("sha256").update(fromEnv).digest("hex").slice(0, 16);
34
35
  const steps = [];
36
+ try {
37
+ const outcome = await runTestOrder(ctx, { app, place, steps, secret: fromEnv ? { value: fromEnv, from: `from ${SECRET_ENV}` } : null });
38
+ ctx.out.result(outcome, () => ctx.out.line(outcome.order ? "Done. Nothing reached a restaurant and no money moved." : `Done. ${place} ended as scripted.`));
39
+ return EXIT.ok;
40
+ }
41
+ catch (err) {
42
+ if (ctx.out.json && err instanceof CliError && typeof err.detail.step === "string")
43
+ ctx.out.data({ app, place, steps, order: null, ok: false });
44
+ throw err;
45
+ }
46
+ }
47
+ /** A failed step throws with `detail.step` and leaves the steps so far in `steps`. */
48
+ export async function runTestOrder(ctx, o) {
49
+ const { out, api, p } = ctx;
50
+ const { app, place, steps } = o;
51
+ const expected = EXPECTED[place];
52
+ const fromEnv = o.secret?.value ?? null;
53
+ const seed = app ?? createHash("sha256").update(fromEnv).digest("hex").slice(0, 16);
35
54
  const run = `cli-${randomUUID()}`;
36
55
  let current = out.spinner("Reading the sandbox key");
37
56
  const done = (step, detail) => {
@@ -45,9 +64,7 @@ export async function testOrder(ctx) {
45
64
  const e = err instanceof CliError ? err : new CliError("error", String(err), EXIT.error);
46
65
  steps.push({ step, status: "failed", detail: e.message });
47
66
  current.stop();
48
- if (out.json)
49
- out.data({ app, place, steps, order: null, ok: false });
50
- else
67
+ if (!out.json)
51
68
  out.line(`failed ${step} ${e.message}`);
52
69
  throw new CliError(e.code, e.message, e.exit, { step });
53
70
  };
@@ -63,14 +80,14 @@ export async function testOrder(ctx) {
63
80
  }
64
81
  };
65
82
  let sk = fromEnv ?? await sandboxSecret(ctx, app).catch((err) => fail("Read the sandbox key", err));
66
- done("Read the sandbox key", fromEnv ? `from ${SECRET_ENV}` : "from this computer's credential store");
83
+ done("Read the sandbox key", o.secret?.from ?? "from this computer's credential store");
67
84
  next("Provisioning a sandbox test user");
68
85
  const phone = testPhone(seed);
69
86
  const provision = () => api.request("/v1/users", {
70
87
  method: "POST", auth: sk, idempotent: true, retry: true, body: { firstName: "Layout", lastName: "CLI test", phone },
71
88
  }).then((r) => r.data);
72
89
  const user = await provision().catch(async (err) => {
73
- if (!(err instanceof CliError) || err.exit !== EXIT.auth || !app)
90
+ if (!(err instanceof CliError) || err.exit !== EXIT.auth || !app || o.secret)
74
91
  throw err;
75
92
  dropSandboxSecret(ctx, app);
76
93
  sk = await sandboxSecret(ctx, app, true);
@@ -107,8 +124,7 @@ export async function testOrder(ctx) {
107
124
  const code = cart.failure?.code ?? "UNKNOWN";
108
125
  if (expected === "failed" && place === "test_place_closed") {
109
126
  done("Build a cart", `failed with ${code}, as ${place} scripts`);
110
- out.result({ app, place, steps, order: null, ok: true }, () => out.line(`Done. ${place} ended as scripted.`));
111
- return EXIT.ok;
127
+ return { app, place, steps, order: null, ok: true };
112
128
  }
113
129
  fail("Build a cart", new CliError("cart_failed", `The cart failed with ${code}.`, EXIT.error));
114
130
  }
@@ -132,6 +148,5 @@ export async function testOrder(ctx) {
132
148
  if (!okOutcome)
133
149
  fail("Wait for the outcome", new CliError("unexpected_outcome", `The order ended ${final.state}; ${place} scripts ${expected}.`, EXIT.error));
134
150
  done("Wait for the outcome", `${final.state}${final.failure_code ? ` (${final.failure_code})` : ""}, as ${place} scripts`);
135
- out.result({ app, place, steps, order: { id: order.id, status: final.state }, ok: true }, () => out.line("Done. Nothing reached a restaurant and no money moved."));
136
- return EXIT.ok;
151
+ return { app, place, steps, order: { id: order.id, status: final.state }, ok: true };
137
152
  }
package/dist/context.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Api, defaultHttpDeps } from "./http.js";
2
2
  import { Output } from "./output.js";
3
- import { apiBase, hostKey, readSession, writeSession } from "./config.js";
3
+ import { apiBase, DEFAULT_API_BASE, hostKey, readSession, writeSession } from "./config.js";
4
4
  import { chooseStore, spawnRunner, storeOfKind } from "./credentials.js";
5
5
  import { CliError, EXIT, usageError } from "./errors.js";
6
6
  import { str } from "./args.js";
@@ -25,14 +25,30 @@ export function defaultPlatform(streams) {
25
25
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
26
26
  };
27
27
  }
28
+ export const TOKEN_ENV = "LAYOUT_CLI_TOKEN";
29
+ /** LAYOUT_CLI_TOKEN, checked: it is minted by the console for the Layout API, so it is sent there and nowhere else. */
30
+ export function envToken(ctx) {
31
+ const v = ctx.env[TOKEN_ENV]?.trim();
32
+ if (!v)
33
+ return null;
34
+ if (!/^lcli_[A-Za-z0-9_-]{43}$/.test(v))
35
+ throw usageError(`${TOKEN_ENV} is not a Layout CLI token (lcli_...). Make one in the console: https://developer.layout.link/console/cli`);
36
+ if (ctx.base !== DEFAULT_API_BASE) {
37
+ throw usageError(`${TOKEN_ENV} is sent only to ${DEFAULT_API_BASE}, the API that issues it, and LAYOUT_API_BASE is ${ctx.base}. Unset one of them, or run layout login for ${hostKey(ctx.base)}.`);
38
+ }
39
+ return v;
40
+ }
28
41
  export function signedIn(ctx) {
42
+ const fromEnv = envToken(ctx);
43
+ if (fromEnv)
44
+ return { token: fromEnv, record: null, source: "env" };
29
45
  const record = readSession(ctx.env, ctx.base);
30
46
  if (!record)
31
- throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
47
+ throw new CliError("not_signed_in", `Not signed in. Run layout login, or set ${TOKEN_ENV}.`, EXIT.auth);
32
48
  const token = storeOfKind(record.store, ctx.env, ctx.p.run).get(hostKey(ctx.base));
33
49
  if (!token?.startsWith("lcli_"))
34
- throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
35
- return { token, record };
50
+ throw new CliError("not_signed_in", `Not signed in. Run layout login, or set ${TOKEN_ENV}.`, EXIT.auth);
51
+ return { token, record, source: "login" };
36
52
  }
37
53
  export function forget(ctx, record) {
38
54
  if (record) {
@@ -50,8 +66,8 @@ function keyAccount(base, app) {
50
66
  /** The application's sandbox secret, from this computer's credential store or read once from Layout and kept there. Never a live secret. */
51
67
  export async function sandboxSecret(ctx, app, fresh = false) {
52
68
  const s = signedIn(ctx);
53
- const store = storeOfKind(s.record.store, ctx.env, ctx.p.run);
54
- const cacheable = APP_ID.test(app);
69
+ const store = s.record ? storeOfKind(s.record.store, ctx.env, ctx.p.run) : null;
70
+ const cacheable = store !== null && APP_ID.test(app);
55
71
  if (cacheable && !fresh) {
56
72
  const cached = store.get(keyAccount(ctx.base, app));
57
73
  if (cached?.startsWith("sk_test_"))
@@ -60,12 +76,20 @@ export async function sandboxSecret(ctx, app, fresh = false) {
60
76
  const key = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/reveal`, { method: "POST", body: {}, retry: true });
61
77
  if (!key.secret?.startsWith("sk_test_"))
62
78
  throw new CliError("unexpected_key", "Layout returned something other than a sandbox key, so it was not used.", EXIT.error);
63
- if (cacheable && store.set(keyAccount(ctx.base, app), key.secret)) {
64
- const record = readSession(ctx.env, ctx.base);
65
- if (record && !(record.keys ?? []).includes(app))
79
+ remember(ctx, app, key.secret);
80
+ return key.secret;
81
+ }
82
+ /** Keeps a sandbox secret in this computer's credential store for a `layout login` session; with LAYOUT_CLI_TOKEN nothing is stored. */
83
+ export function remember(ctx, app, secret) {
84
+ if (envToken(ctx) || !secret.startsWith("sk_test_") || !APP_ID.test(app))
85
+ return;
86
+ const record = readSession(ctx.env, ctx.base);
87
+ if (!record)
88
+ return;
89
+ if (storeOfKind(record.store, ctx.env, ctx.p.run).set(keyAccount(ctx.base, app), secret)) {
90
+ if (!(record.keys ?? []).includes(app))
66
91
  writeSession(ctx.env, ctx.base, { ...record, keys: [...(record.keys ?? []), app] });
67
92
  }
68
- return key.secret;
69
93
  }
70
94
  export function dropSandboxSecret(ctx, app) {
71
95
  const record = readSession(ctx.env, ctx.base);
@@ -87,6 +111,9 @@ export async function authed(ctx, path, opts = {}) {
87
111
  return (await ctx.api.request(path, { ...opts, auth: s.token })).data;
88
112
  }
89
113
  catch (err) {
114
+ if (err instanceof CliError && err.exit === EXIT.auth && s.source === "env") {
115
+ throw new CliError(err.code, `${TOKEN_ENV} is expired or revoked. Make another in the console: https://developer.layout.link/console/cli`, EXIT.auth);
116
+ }
90
117
  if (err instanceof CliError && err.exit === EXIT.auth) {
91
118
  forget(ctx, s.record);
92
119
  throw new CliError(err.code, `${err.message.replace(/\s*Sign in with layout login\.?$/, "")} Run layout login.`.trim(), EXIT.auth);
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { login, logout, whoami } from "./commands/session.js";
7
7
  import { appsCreate, appsList, deliveries, docs, keysCreate, keysList, keysRevoke, requestLiveAccess, trigger, usage, webhooksList, } from "./commands/resources.js";
8
8
  import { listen } from "./commands/listen.js";
9
9
  import { testOrder } from "./commands/test-order.js";
10
+ import { init } from "./commands/init.js";
10
11
  import { api } from "./commands/api.js";
11
12
  import { checkForUpdate } from "./update.js";
12
13
  const s = (name) => ({ name, type: "string" });
@@ -15,6 +16,9 @@ const LIVE = ["--live", "Production instead of sandbox. Needs layout login --liv
15
16
  export const COMMANDS = [
16
17
  { path: ["login"], usage: "layout login [--live] [--device-name <name>] [--no-browser]", summary: "Sign in with a code you approve in the console.", flags: [s("device-name"), b("no-browser"), b("live")],
17
18
  flagHelp: [["--live", "Also read production for 12 hours. The console asks you to sign in again first."], ["--device-name <name>", "What the console shows as the computer asking. Default: this computer's name."], ["--no-browser", "Print the link without opening a browser."]], run: login },
19
+ { path: ["init"], usage: "layout init [--app <id> | --name <name>] [--skip-test] [--place <test_place_id>]", summary: "Set up an application: pick or create it, read its sandbox key, run a test order, print the env vars.",
20
+ flags: [s("name"), b("skip-test"), s("place")],
21
+ flagHelp: [["--name <name>", "The application with this name, created if there is none."], ["--skip-test", "Do not run the sandbox test order."], ["--place <id>", "The sandbox test restaurant. Default: test_place_placed."]], run: init },
18
22
  { path: ["logout"], usage: "layout logout", summary: "Revoke this CLI session and forget it here.", flags: [], run: logout },
19
23
  { path: ["whoami"], usage: "layout whoami", summary: "The developer account and this CLI session.", flags: [], run: whoami },
20
24
  { path: ["apps", "list"], usage: "layout apps list", summary: "Your applications.", flags: [], run: appsList },
@@ -99,7 +103,12 @@ function help(o, cmd, sub) {
99
103
  o.line(l);
100
104
  o.line();
101
105
  o.line("Environment:");
102
- for (const l of pad([["LAYOUT_SECRET_KEY", "A key for api and test-order without signing in, as in CI."], ["LAYOUT_APP", "The application, instead of --app."], ["NO_COLOR", "No colour."]]))
106
+ for (const l of pad([
107
+ ["LAYOUT_CLI_TOKEN", "A CLI token from the console, for agents and CI. Wins over layout login; sent only to api.layout.link."],
108
+ ["LAYOUT_SECRET_KEY", "A key for api and test-order without signing in, as in CI."],
109
+ ["LAYOUT_APP", "The application, instead of --app."],
110
+ ["NO_COLOR", "No colour."],
111
+ ]))
103
112
  o.line(l);
104
113
  o.line();
105
114
  o.line("Docs: https://developer.layout.link/reference/cli");
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.2.0";
1
+ export const VERSION = "0.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@layoutplatforms/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "The Layout developer CLI: sign in, sandbox apps and keys, forward webhooks, run test orders.",
6
6
  "type": "module",