@layoutplatforms/cli 0.1.1 → 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
@@ -1,6 +1,6 @@
1
1
  # Layout CLI
2
2
 
3
- Sign in to your Layout developer account and build against the sandbox from your terminal. It never touches production.
3
+ Sign in to your Layout developer account, build against the sandbox, and read production from your terminal. It never changes production or moves money.
4
4
 
5
5
  ```
6
6
  npm i -g @layoutplatforms/cli
@@ -13,24 +13,37 @@ 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 login` | Sign in with a code you approve in the console. |
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. |
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 sandbox 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`. |
25
+ | `layout api get /v1/places?query=coffee` | Call any REST endpoint and print the answer. |
24
26
  | `layout test-order` | Run a whole order against a sandbox test restaurant. |
25
27
  | `layout usage` | Your limits and what you have used today. |
26
- | `layout deliveries` | Recent sandbox webhook deliveries. |
28
+ | `layout deliveries` | Recent webhook deliveries. |
27
29
  | `layout request-live-access` | Ask Layout to review production access. |
28
30
  | `layout docs` | Open the docs. |
29
31
 
30
- Every command takes `--json`, so a coding agent can drive it.
32
+ Every command takes `--json`, so a coding agent can drive it. `listen`, `deliveries`, `keys list` and `webhooks list` take `--live`.
33
+
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.
31
44
 
32
45
  ## What a CLI session can do
33
46
 
34
- A CLI session works in sandbox only. It cannot create or read live keys, change live settings, or place a real order. Live access is requested from the CLI and approved by a person in the console.
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.
35
48
 
36
49
  Full reference: https://developer.layout.link/reference/cli
package/dist/args.js CHANGED
@@ -11,8 +11,12 @@ export function parseArgs(argv, specs) {
11
11
  all.set(s.name, s);
12
12
  const positionals = [];
13
13
  const flags = {};
14
+ const shorts = new Map();
15
+ for (const s of all.values())
16
+ if (s.short)
17
+ shorts.set(`-${s.short}`, `--${s.name}`);
14
18
  for (let i = 0; i < argv.length; i++) {
15
- const arg = argv[i];
19
+ const arg = shorts.get(argv[i]) ?? argv[i];
16
20
  if (arg === "--") {
17
21
  positionals.push(...argv.slice(i + 1));
18
22
  break;
@@ -40,7 +44,10 @@ export function parseArgs(argv, specs) {
40
44
  const value = eq !== -1 ? arg.slice(eq + 1) : argv[++i];
41
45
  if (value === undefined || (eq === -1 && value.startsWith("--")))
42
46
  throw usageError(`--${name} needs a value.`);
43
- flags[spec.name] = value;
47
+ if (spec.type === "list")
48
+ flags[spec.name] = [...(flags[spec.name] ?? []), value];
49
+ else
50
+ flags[spec.name] = value;
44
51
  }
45
52
  return { positionals, flags };
46
53
  }
@@ -48,6 +55,10 @@ export function str(p, name) {
48
55
  const v = p.flags[name];
49
56
  return typeof v === "string" ? v : undefined;
50
57
  }
58
+ export function list(p, name) {
59
+ const v = p.flags[name];
60
+ return Array.isArray(v) ? v : [];
61
+ }
51
62
  export function bool(p, name) {
52
63
  return p.flags[name] === true;
53
64
  }
@@ -0,0 +1,127 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { bool, list, str } from "../args.js";
3
+ import { CliError, EXIT, exitForStatus, usageError } from "../errors.js";
4
+ import { dropSandboxSecret, envSecret, pickApp, sandboxSecret, SECRET_ENV } from "../context.js";
5
+ const METHODS = new Set(["GET", "POST", "DELETE"]);
6
+ export function apiPath(raw) {
7
+ if (!raw)
8
+ throw usageError("Name a path: layout api get /v1/places?query=coffee.");
9
+ const p = raw.startsWith("/") ? raw : `/${raw}`;
10
+ const withV1 = p.startsWith("/v1/") || p === "/v1" ? p : `/v1${p}`;
11
+ const pathPart = withV1.split("?")[0];
12
+ if (/\/\/|[\s\\%]|\/\.\.?(?:\/|$)|:/.test(pathPart) || /\s/.test(withV1))
13
+ throw usageError("The path must be a Layout API path such as /v1/carts/<id>.");
14
+ if (/^\/v1\/cli(?:\/|$)/.test(pathPart))
15
+ throw usageError("The /v1/cli routes are the CLI's own; use the commands instead.");
16
+ return withV1;
17
+ }
18
+ function fieldValue(v) {
19
+ try {
20
+ return JSON.parse(v);
21
+ }
22
+ catch {
23
+ return v;
24
+ }
25
+ }
26
+ export function apiBody(data, fields) {
27
+ let body;
28
+ if (data !== undefined) {
29
+ let text = data;
30
+ if (data.startsWith("@")) {
31
+ try {
32
+ text = readFileSync(data.slice(1), "utf8");
33
+ }
34
+ catch {
35
+ throw usageError(`Could not read ${data.slice(1)}.`);
36
+ }
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(text);
41
+ }
42
+ catch {
43
+ throw usageError("--data must be a JSON object, or @file holding one.");
44
+ }
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
46
+ throw usageError("--data must be a JSON object.");
47
+ body = parsed;
48
+ }
49
+ for (const f of fields) {
50
+ const eq = f.indexOf("=");
51
+ if (eq < 1)
52
+ throw usageError(`-d takes name=value, not ${f}.`);
53
+ body = { ...(body ?? {}), [f.slice(0, eq)]: fieldValue(f.slice(eq + 1)) };
54
+ }
55
+ return body;
56
+ }
57
+ async function credential(ctx, live) {
58
+ const token = str(ctx.args, "token")?.trim();
59
+ if (token)
60
+ return { bearer: token, source: "token", app: null };
61
+ const key = envSecret(ctx);
62
+ if (key) {
63
+ if (key.startsWith("sk_live_") && !live)
64
+ throw usageError(`${SECRET_ENV} is a live secret. Add --live to read production.`);
65
+ if (key.startsWith("sk_test_") && live)
66
+ throw usageError(`${SECRET_ENV} is a sandbox secret, so --live cannot use it.`);
67
+ return { bearer: key, source: "env", app: null };
68
+ }
69
+ if (live)
70
+ throw usageError(`Production requests use your own live secret: set ${SECRET_ENV}=sk_live_... The CLI never reads live secrets.`);
71
+ const app = await pickApp(ctx);
72
+ return { bearer: await sandboxSecret(ctx, app), source: "cli", app };
73
+ }
74
+ /**
75
+ * THE CLI NEVER CHANGES PRODUCTION. A write runs only after Layout says, for this very credential, that it
76
+ * is sandbox; a credential Layout cannot place is refused rather than trusted.
77
+ */
78
+ async function assertSandbox(ctx, bearer) {
79
+ const res = await ctx.api.request("/v1/whoami", { auth: bearer, anyStatus: true });
80
+ if (res.status === 200 && res.data?.environment === "sandbox")
81
+ return;
82
+ if (res.status === 200 && res.data?.environment === "live") {
83
+ throw new CliError("live_write_refused", "This credential is for production. The CLI reads production but never changes it.", EXIT.refused);
84
+ }
85
+ throw new CliError("environment_unknown", `Layout could not say this credential is sandbox (${res.status}), so nothing was sent.`, res.status === 401 ? EXIT.auth : EXIT.refused);
86
+ }
87
+ export async function api(ctx) {
88
+ const { out } = ctx;
89
+ const method = (ctx.args.positionals[1] ?? "").toUpperCase();
90
+ if (!METHODS.has(method))
91
+ throw usageError("Name a method and a path: layout api get /v1/places?query=coffee. Methods: get, post, delete.");
92
+ const path = apiPath(ctx.args.positionals[2]);
93
+ if (ctx.args.positionals.length > 3)
94
+ throw usageError(`Unexpected argument: ${ctx.args.positionals[3]}. Quote a path that holds & or ?.`);
95
+ const live = bool(ctx.args, "live");
96
+ const body = apiBody(str(ctx.args, "data"), list(ctx.args, "field"));
97
+ if (body !== undefined && method !== "POST")
98
+ throw usageError("Only a POST takes a body.");
99
+ if (live && method !== "GET")
100
+ throw new CliError("live_write_refused", "The CLI reads production but never changes it. Only get works with --live.", EXIT.refused);
101
+ let cred = await credential(ctx, live);
102
+ if (method !== "GET")
103
+ await assertSandbox(ctx, cred.bearer);
104
+ const send = (bearer) => ctx.api.request(path, {
105
+ method: method,
106
+ auth: bearer,
107
+ ...(body === undefined ? (method === "POST" ? { body: {} } : {}) : { body }),
108
+ anyStatus: true,
109
+ retry: method === "GET" || method === "POST",
110
+ ...(method === "POST" ? { idempotencyKey: str(ctx.args, "idempotency-key") ?? undefined, idempotent: true } : {}),
111
+ });
112
+ let res = await send(cred.bearer);
113
+ if (res.status === 401 && cred.source === "cli" && cred.app) {
114
+ dropSandboxSecret(ctx, cred.app);
115
+ cred = { ...cred, bearer: await sandboxSecret(ctx, cred.app, true) };
116
+ res = await send(cred.bearer);
117
+ }
118
+ const requestId = res.headers.get("x-request-id");
119
+ if (out.json) {
120
+ out.data({ status: res.status, requestId, replayed: res.headers.get("idempotent-replayed") === "true", body: res.data });
121
+ }
122
+ else {
123
+ out.note(`${res.status}${requestId ? ` ${requestId}` : ""}${res.headers.get("idempotent-replayed") === "true" ? " replayed" : ""}`);
124
+ ctx.p.streams.out.write(`${JSON.stringify(res.data, null, 2)}\n`);
125
+ }
126
+ return res.status >= 200 && res.status < 300 ? EXIT.ok : exitForStatus(res.status);
127
+ }
@@ -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,14 +1,15 @@
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
- import { WEBHOOKS_PAGE } from "./resources.js";
6
+ import { isLive, WEBHOOKS_PAGE } from "./resources.js";
7
7
  import { mintLocalSecret, runListener } from "../listen.js";
8
- export function cursorFile(env, base, app) {
9
- return join(configDir(env), "listen", hostKey(base).replace(/[^A-Za-z0-9.-]/g, "_"), `${app.replace(/[^A-Za-z0-9_]/g, "_")}.json`);
8
+ export function cursorFile(env, base, app, live = false) {
9
+ return join(configDir(env), "listen", hostKey(base).replace(/[^A-Za-z0-9.-]/g, "_"), `${app.replace(/[^A-Za-z0-9_]/g, "_")}${live ? ".live" : ""}.json`);
10
10
  }
11
- function forwardUrl(raw) {
11
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
12
+ function forwardUrl(raw, live = false) {
12
13
  if (!raw)
13
14
  throw usageError("Give --forward-to, for example --forward-to http://localhost:3000/webhooks/layout.");
14
15
  let url;
@@ -20,16 +21,20 @@ function forwardUrl(raw) {
20
21
  }
21
22
  if (url.protocol !== "http:" && url.protocol !== "https:")
22
23
  throw usageError("--forward-to must be an http or https URL.");
24
+ if (live && url.protocol === "http:" && !LOOPBACK.has(url.hostname))
25
+ throw usageError("With --live, --forward-to must be https or this computer (localhost), so production data is never sent in the clear.");
23
26
  return url.toString();
24
27
  }
25
28
  export async function listen(ctx) {
26
29
  const { out, p } = ctx;
27
- const forwardTo = forwardUrl(str(ctx.args, "forward-to"));
30
+ const forwardTo = forwardUrl(str(ctx.args, "forward-to"), isLive(ctx));
28
31
  const filter = str(ctx.args, "events");
29
32
  const events = filter ? new Set(filter.split(",").map((s) => s.trim()).filter(Boolean)) : null;
30
33
  const app = await pickApp(ctx);
34
+ const live = isLive(ctx);
31
35
  const enc = encodeURIComponent(app);
32
- const stateFile = cursorFile(ctx.env, ctx.base, app);
36
+ const envq = live ? "environment=live" : "";
37
+ const stateFile = cursorFile(ctx.env, ctx.base, app, live);
33
38
  const saved = readJson(stateFile)?.cursor ?? null;
34
39
  let start;
35
40
  if (bool(ctx.args, "from-start"))
@@ -37,17 +42,18 @@ export async function listen(ctx) {
37
42
  else if (saved)
38
43
  start = saved;
39
44
  else
40
- start = (await authed(ctx, `/v1/cli/apps/${enc}/events/cursor`)).cursor;
41
- const hooks = await authed(ctx, `/v1/cli/apps/${enc}/webhooks`);
45
+ start = (await authed(ctx, `/v1/cli/apps/${enc}/events/cursor${envq ? `?${envq}` : ""}`)).cursor;
46
+ const hooks = await authed(ctx, `/v1/cli/apps/${enc}/webhooks${envq ? `?${envq}` : ""}`);
47
+ const session = live ? null : await openListen(ctx, enc);
42
48
  const secret = mintLocalSecret();
43
49
  if (out.json)
44
- out.data({ event: "listening", app, 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) });
45
51
  else {
46
- out.line(`Forwarding sandbox events for ${app} to ${forwardTo}`);
52
+ out.line(`Forwarding ${live ? out.accent("production") : "sandbox"} events for ${app} to ${forwardTo}`);
47
53
  out.line(`Signing secret for this run ${out.accent(secret)}`);
48
54
  out.line(saved && !bool(ctx.args, "from-start") ? "Resuming after the last event forwarded." : "Starting from now. Press Ctrl-C to stop.");
49
55
  }
50
- if (!hooks.subscriptions.some((s) => s.enabled)) {
56
+ if (!session && !hooks.subscriptions.some((s) => s.enabled)) {
51
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}`);
52
58
  }
53
59
  let stopped = false;
@@ -70,8 +76,8 @@ export async function listen(ctx) {
70
76
  }
71
77
  };
72
78
  try {
73
- await runListener(start, { forwardTo, secret, events, pollMs: 2_000, maxBackoffMs: 30_000 }, {
74
- fetchPage: (cursor) => authed(ctx, `/v1/cli/apps/${enc}/events?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, { retry: false }),
79
+ await runListener(start, { forwardTo, secret, events, pollMs: 2_000, maxBackoffMs: 30_000, environment: live ? "live" : "sandbox" }, {
80
+ fetchPage: (cursor) => authed(ctx, `/v1/cli/apps/${enc}/events?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${envq ? `&${envq}` : ""}`, { retry: false }),
75
81
  forward: async (url, body, headers) => {
76
82
  const res = await p.http.fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(10_000), redirect: "manual" });
77
83
  await res.arrayBuffer().catch(() => undefined);
@@ -79,6 +85,7 @@ export async function listen(ctx) {
79
85
  },
80
86
  saveCursor: (cursor) => writePrivateJson(stateFile, { cursor, updatedAt: new Date(p.now()).toISOString() }),
81
87
  sleep: async (ms) => {
88
+ await session?.keepAlive().catch(() => undefined);
82
89
  const until = p.now() + ms;
83
90
  while (!stopped && p.now() < until)
84
91
  await p.sleep(Math.min(250, until - p.now()));
@@ -91,6 +98,41 @@ export async function listen(ctx) {
91
98
  finally {
92
99
  spin.stop();
93
100
  off();
101
+ await session?.close();
94
102
  }
95
103
  return stopped ? EXIT.interrupted : EXIT.ok;
96
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
+ }
@@ -1,7 +1,10 @@
1
1
  import { bool, str } from "../args.js";
2
2
  import { DOCS_BASE } from "../config.js";
3
3
  import { CliError, EXIT, usageError } from "../errors.js";
4
- import { authed, pickApp } from "../context.js";
4
+ import { authed, dropSandboxSecret, pickApp } from "../context.js";
5
+ export const isLive = (ctx) => bool(ctx.args, "live");
6
+ const envName = (ctx) => (isLive(ctx) ? "production" : "sandbox");
7
+ const withEnv = (ctx, path) => (isLive(ctx) ? `${path}${path.includes("?") ? "&" : "?"}environment=live` : path);
5
8
  export const WEBHOOKS_PAGE = `${DOCS_BASE}/console/webhooks/subscriptions`;
6
9
  const SHOWN_ONCE = "Shown once. Store it in your secret manager, never in source control.";
7
10
  export async function appsList(ctx) {
@@ -30,11 +33,11 @@ export async function appsCreate(ctx) {
30
33
  }
31
34
  export async function keysList(ctx) {
32
35
  const app = await pickApp(ctx);
33
- const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys`);
36
+ const data = await authed(ctx, withEnv(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys`));
34
37
  ctx.out.result({ app, ...data }, () => {
35
38
  if (data.keys.length === 0)
36
- return ctx.out.line("No sandbox key. Create one with layout keys create.");
37
- ctx.out.table(data.keys.map((k) => [k.clientId, `sk_test_...${k.last4}`, k.createdAt.slice(0, 10), k.lastUsedAt ? k.lastUsedAt.slice(0, 10) : "never"]), ["CLIENT ID", "SECRET", "CREATED", "LAST USED"]);
39
+ return ctx.out.line(isLive(ctx) ? "No production key." : "No sandbox key. Create one with layout keys create.");
40
+ ctx.out.table(data.keys.map((k) => [k.clientId, `${isLive(ctx) ? "sk_live_" : "sk_test_"}...${k.last4}`, k.createdAt.slice(0, 10), k.lastUsedAt ? k.lastUsedAt.slice(0, 10) : "never"]), ["CLIENT ID", "SECRET", "CREATED", "LAST USED"]);
38
41
  });
39
42
  return EXIT.ok;
40
43
  }
@@ -48,6 +51,7 @@ export async function keysCreate(ctx) {
48
51
  }
49
52
  throw err;
50
53
  });
54
+ dropSandboxSecret(ctx, app);
51
55
  ctx.out.result({ app, ...data }, () => {
52
56
  ctx.out.line(data.replaced ? "New sandbox key. The old one no longer works." : "New sandbox key.");
53
57
  ctx.out.line(`Client id ${data.clientId}`);
@@ -64,6 +68,7 @@ export async function keysRevoke(ctx) {
64
68
  const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/revoke`, {
65
69
  method: "POST", body: {}, idempotent: true, retry: true,
66
70
  });
71
+ dropSandboxSecret(ctx, app);
67
72
  ctx.out.result({ app, ...data }, () => {
68
73
  ctx.out.line(data.revoked > 0 ? "Sandbox key revoked." : "There was no sandbox key to revoke.");
69
74
  });
@@ -71,7 +76,7 @@ export async function keysRevoke(ctx) {
71
76
  }
72
77
  export async function webhooksList(ctx) {
73
78
  const app = await pickApp(ctx);
74
- const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/webhooks`);
79
+ const data = await authed(ctx, withEnv(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/webhooks`));
75
80
  ctx.out.result({ app, ...data }, () => {
76
81
  if (data.subscriptions.length === 0)
77
82
  ctx.out.line(`No webhook endpoints. Add one once in the console: ${WEBHOOKS_PAGE}`);
@@ -79,6 +84,8 @@ export async function webhooksList(ctx) {
79
84
  ctx.out.table(data.subscriptions.map((s) => [s.id, s.enabled ? "on" : "off", s.url, s.events.join(",")]), ["ID", "STATE", "URL", "EVENTS"]);
80
85
  if (data.signingSecret)
81
86
  ctx.out.line(`Sandbox signing secret ${data.signingSecret}`);
87
+ else if (isLive(ctx))
88
+ ctx.out.line(`The production signing secret is shown only in the console.`);
82
89
  ctx.out.line(`Endpoints are managed in the console: ${WEBHOOKS_PAGE}`);
83
90
  });
84
91
  return EXIT.ok;
@@ -93,6 +100,8 @@ export async function trigger(ctx) {
93
100
  method: "POST", body: { event, ...(order ? { orderId: order } : {}) }, idempotent: true, retry: true,
94
101
  });
95
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.`);
96
105
  ctx.out.line(`Sent ${data.event} to ${data.deliveries.length} endpoint${data.deliveries.length === 1 ? "" : "s"}${data.order ? `, describing ${data.order.id}` : ""}.`);
97
106
  ctx.out.table(data.deliveries.map((d) => [d.id, d.outcome, d.url]));
98
107
  });
@@ -116,10 +125,10 @@ export async function deliveries(ctx) {
116
125
  const limit = Number(str(ctx.args, "limit") ?? "20");
117
126
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
118
127
  throw usageError("--limit is a whole number from 1 to 100.");
119
- const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/deliveries?limit=${limit}`);
128
+ const data = await authed(ctx, withEnv(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/deliveries?limit=${limit}`));
120
129
  ctx.out.result({ app, ...data }, () => {
121
130
  if (data.deliveries.length === 0)
122
- return ctx.out.line("No sandbox deliveries yet.");
131
+ return ctx.out.line(`No ${envName(ctx)} deliveries yet.`);
123
132
  ctx.out.table(data.deliveries.map((d) => [d.createdAt.replace("T", " ").slice(0, 19), d.event, d.status, d.responseStatus === null ? "" : String(d.responseStatus), d.url]), ["WHEN", "EVENT", "STATUS", "HTTP", "URL"]);
124
133
  });
125
134
  return EXIT.ok;
@@ -2,21 +2,25 @@ 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
+ function localTime(iso) {
8
+ const d = new Date(iso);
9
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
10
+ }
7
11
  export async function login(ctx) {
8
12
  const { out, api, p } = ctx;
9
- const existing = readSession(ctx.env, ctx.base);
10
- if (existing)
11
- forget(ctx, existing);
13
+ const live = bool(ctx.args, "live");
12
14
  const deviceName = str(ctx.args, "device-name") ?? p.hostname() ?? null;
13
- const code = (await api.request("/v1/cli/device/code", { method: "POST", body: { device_name: deviceName || null }, retry: true })).data;
15
+ const code = (await api.request("/v1/cli/device/code", { method: "POST", body: { device_name: deviceName || null, ...(live ? { live: true } : {}) }, retry: true })).data;
14
16
  if (out.json) {
15
- out.data({ event: "code", userCode: code.user_code, verificationUri: code.verification_uri, verificationUriComplete: code.verification_uri_complete, expiresIn: code.expires_in });
17
+ out.data({ event: "code", userCode: code.user_code, verificationUri: code.verification_uri, verificationUriComplete: code.verification_uri_complete, expiresIn: code.expires_in, live });
16
18
  }
17
19
  else {
18
20
  out.line(out.wordmark());
19
21
  out.line();
22
+ if (live)
23
+ out.line(`This sign-in asks for ${out.accent("production read access")} for 12 hours. The console asks you to sign in again first.`);
20
24
  out.line(`Your sign-in code is ${out.accent(code.user_code)}`);
21
25
  out.line(`Approve it at ${code.verification_uri_complete}`);
22
26
  out.line("Check the code matches before you approve.");
@@ -75,6 +79,10 @@ export async function login(ctx) {
75
79
  }
76
80
  async function finishLogin(ctx, tok) {
77
81
  const { out } = ctx;
82
+ const previous = readSession(ctx.env, ctx.base);
83
+ const previousToken = previous ? storeOfKind(previous.store, ctx.env, ctx.p.run).get(hostKey(ctx.base)) : null;
84
+ if (previous)
85
+ forget(ctx, previous);
78
86
  const choice = chooseStore(ctx.env, ctx.p.platform, ctx.p.run);
79
87
  let store = choice.store;
80
88
  let warning = choice.warning;
@@ -86,18 +94,27 @@ async function finishLogin(ctx, tok) {
86
94
  warning = `The ${choice.store.kind} store refused the CLI token, so it is stored in ${file.path} (readable only by you).`;
87
95
  }
88
96
  const expiresAt = new Date(ctx.p.now() + tok.expires_in * 1000).toISOString();
89
- writeSession(ctx.env, ctx.base, { sessionId: tok.session_id, accountId: "", accountName: null, store: store.kind, expiresAt });
97
+ writeSession(ctx.env, ctx.base, { sessionId: tok.session_id, accountId: "", accountName: null, store: store.kind, expiresAt, liveUntil: tok.live_until ?? null });
90
98
  if (warning)
91
99
  out.note(warning);
92
- const who = await authed(ctx, "/v1/cli/whoami");
93
- writeSession(ctx.env, ctx.base, { sessionId: tok.session_id, accountId: who.account.id, accountName: who.account.name, store: store.kind, expiresAt: who.session.expiresAt });
100
+ if (previousToken?.startsWith("lcli_") && previous?.sessionId !== tok.session_id) {
101
+ await ctx.api.request("/v1/cli/logout", { method: "POST", body: {}, auth: previousToken, retry: false }).catch(() => undefined);
102
+ }
103
+ const who = await authed(ctx.env[TOKEN_ENV] ? { ...ctx, env: { ...ctx.env, [TOKEN_ENV]: "" } } : ctx, "/v1/cli/whoami");
104
+ const liveUntil = who.session.liveUntil ?? null;
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 });
94
106
  out.result({ event: "signed_in", account: who.account, session: who.session }, () => {
95
- out.line(`Signed in to ${who.account.name ?? "your developer account"}. This CLI works in sandbox only.`);
107
+ out.line(`Signed in to ${who.account.name ?? "your developer account"}.`);
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.");
96
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.`);
97
112
  return EXIT.ok;
98
113
  }
99
114
  export async function logout(ctx) {
100
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);
101
118
  const record = readSession(ctx.env, ctx.base);
102
119
  let revoked = false;
103
120
  if (record) {
@@ -122,8 +139,9 @@ export async function whoami(ctx) {
122
139
  const who = await authed(ctx, "/v1/cli/whoami");
123
140
  ctx.out.result(who, () => {
124
141
  ctx.out.line(`${who.account.name ?? "Developer account"} (${who.account.id})`);
125
- ctx.out.line(`Session ${who.session.deviceName ? `on ${who.session.deviceName}, ` : ""}expires ${who.session.expiresAt.slice(0, 10)}`);
126
- ctx.out.line("Sandbox only.");
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)}`);
144
+ ctx.out.line(who.session.liveUntil ? `Production read access until ${localTime(who.session.liveUntil)}` : "Sandbox. Run layout login --live to read production.");
127
145
  });
128
146
  return EXIT.ok;
129
147
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { str } from "../args.js";
3
3
  import { CliError, EXIT, usageError } from "../errors.js";
4
- import { authed, pickApp } from "../context.js";
4
+ import { dropSandboxSecret, envSecret, pickApp, sandboxSecret, SECRET_ENV } from "../context.js";
5
5
  export const EXPECTED = {
6
6
  test_place_carted: "placed",
7
7
  test_place_placed: "placed",
@@ -20,14 +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(", ")}.`);
29
- const app = await pickApp(ctx);
27
+ return place;
28
+ }
29
+ export async function testOrder(ctx) {
30
+ const place = placeOf(ctx);
31
+ const fromEnv = envSecret(ctx);
32
+ if (fromEnv?.startsWith("sk_live_"))
33
+ throw usageError(`test-order runs in sandbox only, and ${SECRET_ENV} is a live secret.`);
34
+ const app = fromEnv ? null : await pickApp(ctx);
30
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);
31
54
  const run = `cli-${randomUUID()}`;
32
55
  let current = out.spinner("Reading the sandbox key");
33
56
  const done = (step, detail) => {
@@ -41,9 +64,7 @@ export async function testOrder(ctx) {
41
64
  const e = err instanceof CliError ? err : new CliError("error", String(err), EXIT.error);
42
65
  steps.push({ step, status: "failed", detail: e.message });
43
66
  current.stop();
44
- if (out.json)
45
- out.data({ app, place, steps, order: null, ok: false });
46
- else
67
+ if (!out.json)
47
68
  out.line(`failed ${step} ${e.message}`);
48
69
  throw new CliError(e.code, e.message, e.exit, { step });
49
70
  };
@@ -58,15 +79,20 @@ export async function testOrder(ctx) {
58
79
  await p.sleep(1_500);
59
80
  }
60
81
  };
61
- const key = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/reveal`, { method: "POST", body: {}, retry: true })
62
- .catch((err) => fail("Read the sandbox key", err));
63
- done("Read the sandbox key", `${key.clientId}, held in memory for this run`);
64
- const sk = key.secret;
82
+ let sk = fromEnv ?? await sandboxSecret(ctx, app).catch((err) => fail("Read the sandbox key", err));
83
+ done("Read the sandbox key", o.secret?.from ?? "from this computer's credential store");
65
84
  next("Provisioning a sandbox test user");
66
- const phone = testPhone(app);
67
- const user = await api.request("/v1/users", {
85
+ const phone = testPhone(seed);
86
+ const provision = () => api.request("/v1/users", {
68
87
  method: "POST", auth: sk, idempotent: true, retry: true, body: { firstName: "Layout", lastName: "CLI test", phone },
69
- }).then((r) => r.data).catch((err) => fail("Provision a test user", err));
88
+ }).then((r) => r.data);
89
+ const user = await provision().catch(async (err) => {
90
+ if (!(err instanceof CliError) || err.exit !== EXIT.auth || !app || o.secret)
91
+ throw err;
92
+ dropSandboxSecret(ctx, app);
93
+ sk = await sandboxSecret(ctx, app, true);
94
+ return provision();
95
+ }).catch((err) => fail("Provision a test user", err));
70
96
  done("Provision a test user", `${user.id} on ${phone}`);
71
97
  if (user.next === "verify") {
72
98
  next("Verifying with the sandbox code");
@@ -98,8 +124,7 @@ export async function testOrder(ctx) {
98
124
  const code = cart.failure?.code ?? "UNKNOWN";
99
125
  if (expected === "failed" && place === "test_place_closed") {
100
126
  done("Build a cart", `failed with ${code}, as ${place} scripts`);
101
- out.result({ app, place, steps, order: null, ok: true }, () => out.line(`Done. ${place} ended as scripted.`));
102
- return EXIT.ok;
127
+ return { app, place, steps, order: null, ok: true };
103
128
  }
104
129
  fail("Build a cart", new CliError("cart_failed", `The cart failed with ${code}.`, EXIT.error));
105
130
  }
@@ -123,6 +148,5 @@ export async function testOrder(ctx) {
123
148
  if (!okOutcome)
124
149
  fail("Wait for the outcome", new CliError("unexpected_outcome", `The order ended ${final.state}; ${place} scripts ${expected}.`, EXIT.error));
125
150
  done("Wait for the outcome", `${final.state}${final.failure_code ? ` (${final.failure_code})` : ""}, as ${place} scripts`);
126
- 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."));
127
- return EXIT.ok;
151
+ return { app, place, steps, order: { id: order.id, status: final.state }, ok: true };
128
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,26 +25,95 @@ 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
- if (!token)
34
- throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
35
- return { token, record };
49
+ if (!token?.startsWith("lcli_"))
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
- if (record)
39
- storeOfKind(record.store, ctx.env, ctx.p.run).remove(hostKey(ctx.base));
54
+ if (record) {
55
+ const store = storeOfKind(record.store, ctx.env, ctx.p.run);
56
+ store.remove(hostKey(ctx.base));
57
+ for (const app of record.keys ?? [])
58
+ store.remove(keyAccount(ctx.base, app));
59
+ }
40
60
  writeSession(ctx.env, ctx.base, null);
41
61
  }
62
+ const APP_ID = /^lyt_app_[A-Za-z0-9]{1,64}$/;
63
+ function keyAccount(base, app) {
64
+ return `${hostKey(base)}:sandbox-key:${app}`;
65
+ }
66
+ /** The application's sandbox secret, from this computer's credential store or read once from Layout and kept there. Never a live secret. */
67
+ export async function sandboxSecret(ctx, app, fresh = false) {
68
+ const s = signedIn(ctx);
69
+ const store = s.record ? storeOfKind(s.record.store, ctx.env, ctx.p.run) : null;
70
+ const cacheable = store !== null && APP_ID.test(app);
71
+ if (cacheable && !fresh) {
72
+ const cached = store.get(keyAccount(ctx.base, app));
73
+ if (cached?.startsWith("sk_test_"))
74
+ return cached;
75
+ }
76
+ const key = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/reveal`, { method: "POST", body: {}, retry: true });
77
+ if (!key.secret?.startsWith("sk_test_"))
78
+ throw new CliError("unexpected_key", "Layout returned something other than a sandbox key, so it was not used.", EXIT.error);
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))
91
+ writeSession(ctx.env, ctx.base, { ...record, keys: [...(record.keys ?? []), app] });
92
+ }
93
+ }
94
+ export function dropSandboxSecret(ctx, app) {
95
+ const record = readSession(ctx.env, ctx.base);
96
+ if (record)
97
+ storeOfKind(record.store, ctx.env, ctx.p.run).remove(keyAccount(ctx.base, app));
98
+ }
99
+ export const SECRET_ENV = "LAYOUT_SECRET_KEY";
100
+ export function envSecret(ctx) {
101
+ const v = ctx.env[SECRET_ENV]?.trim();
102
+ if (!v)
103
+ return null;
104
+ if (!/^sk_(?:test|live)_[A-Za-z0-9_-]{20,128}$/.test(v))
105
+ throw usageError(`${SECRET_ENV} is not a Layout secret key (sk_test_... or sk_live_...).`);
106
+ return v;
107
+ }
42
108
  export async function authed(ctx, path, opts = {}) {
43
109
  const s = signedIn(ctx);
44
110
  try {
45
111
  return (await ctx.api.request(path, { ...opts, auth: s.token })).data;
46
112
  }
47
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
+ }
48
117
  if (err instanceof CliError && err.exit === EXIT.auth) {
49
118
  forget(ctx, s.record);
50
119
  throw new CliError(err.code, `${err.message.replace(/\s*Sign in with layout login\.?$/, "")} Run layout login.`.trim(), EXIT.auth);
@@ -8,7 +8,7 @@ export const spawnRunner = (cmd, args, input) => {
8
8
  };
9
9
  const SERVICE = "layout-cli";
10
10
  const SAFE = /^[A-Za-z0-9_.:\-[\]]+$/;
11
- const TOKEN = /^lcli_[A-Za-z0-9_-]{43}$/;
11
+ const TOKEN = /^(?:lcli_[A-Za-z0-9_-]{43}|sk_test_[A-Za-z0-9_-]{20,128})$/;
12
12
  export function keychainStore(run) {
13
13
  return {
14
14
  kind: "keychain",
package/dist/http.js CHANGED
@@ -34,7 +34,7 @@ export class Api {
34
34
  async request(path, opts = {}) {
35
35
  const method = opts.method ?? "GET";
36
36
  const retry = opts.retry ?? method === "GET";
37
- const idemKey = method === "POST" && opts.idempotent ? randomUUID() : null;
37
+ const idemKey = method === "POST" && (opts.idempotent || opts.idempotencyKey) ? (opts.idempotencyKey ?? randomUUID()) : null;
38
38
  const headers = { accept: "application/json", "user-agent": userAgent() };
39
39
  if (opts.auth)
40
40
  headers.authorization = `Bearer ${opts.auth}`;
@@ -69,6 +69,9 @@ export class Api {
69
69
  catch {
70
70
  data = null;
71
71
  }
72
+ const transientStatus = res.status === 503 || res.status === 502 || res.status === 504 || (res.status === 429 && method === "GET");
73
+ if (opts.anyStatus && !(transientStatus && retry && attempt + 1 < attempts))
74
+ return { status: res.status, data: data, headers: res.headers };
72
75
  if (res.ok || opts.okStatuses?.includes(res.status))
73
76
  return { status: res.status, data: data, headers: res.headers };
74
77
  last = errorFrom(res.status, data);
package/dist/listen.js CHANGED
@@ -13,14 +13,14 @@ function field(payload, key) {
13
13
  const v = payload && typeof payload === "object" ? payload[key] : undefined;
14
14
  return typeof v === "string" ? v : null;
15
15
  }
16
- export function forwardHeaders(secret, ev, body, nowSec) {
16
+ export function forwardHeaders(secret, ev, body, nowSec, environment = "sandbox") {
17
17
  return {
18
18
  "content-type": "application/json",
19
19
  "user-agent": "Layout-Webhooks/1 (layout-cli)",
20
20
  "x-layout-signature": signatureHeader(secret, nowSec, body),
21
21
  "x-layout-event": ev.event,
22
22
  "x-layout-delivery": field(ev.payload, "delivery_id") ?? ev.cursor,
23
- "x-layout-environment": "sandbox",
23
+ "x-layout-environment": environment,
24
24
  "x-layout-forwarded-by": "layout-cli",
25
25
  };
26
26
  }
@@ -65,7 +65,7 @@ export async function runListener(start, opts, deps) {
65
65
  attempts++;
66
66
  let res;
67
67
  try {
68
- res = await deps.forward(opts.forwardTo, body, forwardHeaders(opts.secret, ev, body, deps.nowSec()));
68
+ res = await deps.forward(opts.forwardTo, body, forwardHeaders(opts.secret, ev, body, deps.nowSec(), opts.environment));
69
69
  }
70
70
  catch {
71
71
  res = { ok: false, status: 0 };
package/dist/main.js CHANGED
@@ -7,26 +7,43 @@ 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";
11
+ import { api } from "./commands/api.js";
12
+ import { checkForUpdate } from "./update.js";
10
13
  const s = (name) => ({ name, type: "string" });
11
14
  const b = (name) => ({ name, type: "boolean" });
15
+ const LIVE = ["--live", "Production instead of sandbox. Needs layout login --live; read-only."];
12
16
  export const COMMANDS = [
13
- { path: ["login"], usage: "layout login [--device-name <name>] [--no-browser]", summary: "Sign in with a code you approve in the console.", flags: [s("device-name"), b("no-browser")],
14
- flagHelp: [["--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 },
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")],
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 },
15
22
  { path: ["logout"], usage: "layout logout", summary: "Revoke this CLI session and forget it here.", flags: [], run: logout },
16
23
  { path: ["whoami"], usage: "layout whoami", summary: "The developer account and this CLI session.", flags: [], run: whoami },
17
24
  { path: ["apps", "list"], usage: "layout apps list", summary: "Your applications.", flags: [], run: appsList },
18
25
  { path: ["apps", "create"], usage: "layout apps create <name>", summary: "A new application. Prints its sandbox key once.", flags: [], run: appsCreate },
19
- { path: ["keys", "list"], usage: "layout keys list", summary: "The application's sandbox key.", flags: [], run: keysList },
26
+ { path: ["keys", "list"], usage: "layout keys list [--live]", summary: "The application's key: client id and last four.", flags: [b("live")], flagHelp: [LIVE], run: keysList },
20
27
  { path: ["keys", "create"], usage: "layout keys create [--replace]", summary: "A new sandbox key, printed once.", flags: [b("replace")],
21
28
  flagHelp: [["--replace", "Retire the current sandbox key and make another."]], run: keysCreate },
22
29
  { path: ["keys", "revoke"], usage: "layout keys revoke --yes", summary: "Revoke the sandbox key and its sandbox build grants.", flags: [b("yes")],
23
30
  flagHelp: [["--yes", "Confirm. Nothing is revoked without it."]], run: keysRevoke },
24
- { path: ["webhooks", "list"], usage: "layout webhooks list", summary: "Endpoints (managed in the console) and the sandbox signing secret.", flags: [], run: webhooksList },
25
- { path: ["listen"], usage: "layout listen --forward-to <url> [--events <a,b>] [--from-start]", summary: "Forward sandbox events to a local URL.", flags: [s("forward-to"), s("events"), b("from-start")],
26
- flagHelp: [["--forward-to <url>", "Where to POST each event, signed with a secret made for this run."], ["--events <a,b>", "Only these events."], ["--from-start", "Forward the whole feed, not only what happens from now."]], run: listen },
31
+ { path: ["webhooks", "list"], usage: "layout webhooks list [--live]", summary: "Endpoints (managed in the console) and the sandbox signing secret.", flags: [b("live")], flagHelp: [LIVE], run: webhooksList },
32
+ { path: ["listen"], usage: "layout listen --forward-to <url> [--events <a,b>] [--from-start] [--live]", summary: "Forward events to a local URL.", flags: [s("forward-to"), s("events"), b("from-start"), b("live")],
33
+ flagHelp: [["--forward-to <url>", "Where to POST each event, signed with a secret made for this run."], ["--events <a,b>", "Only these events."], ["--from-start", "Forward the whole feed, not only what happens from now."], LIVE], run: listen },
27
34
  { path: ["trigger"], usage: "layout trigger <event> [--order <ord_id>]", summary: "Fire a signed test event at your endpoints.", flags: [s("order")], run: trigger },
28
35
  { path: ["usage"], usage: "layout usage", summary: "Today's allowances and counts.", flags: [], run: usage },
29
- { path: ["deliveries"], usage: "layout deliveries [--limit <n>]", summary: "Recent sandbox webhook deliveries.", flags: [s("limit")], run: deliveries },
36
+ { path: ["deliveries"], usage: "layout deliveries [--limit <n>] [--live]", summary: "Recent webhook deliveries.", flags: [s("limit"), b("live")],
37
+ flagHelp: [["--limit <n>", "1 to 100. Default 20."], LIVE], run: deliveries },
38
+ { path: ["api"], usage: "layout api <get|post|delete> <path> [-d name=value]... [--data <json|@file>] [--live]", summary: "Call any REST endpoint with your key and print the answer.",
39
+ flags: [{ name: "field", type: "list", short: "d" }, s("data"), s("token"), s("idempotency-key"), b("live")],
40
+ flagHelp: [
41
+ ["-d name=value", "A body field; repeat it. JSON values (numbers, true, arrays) are kept as JSON."],
42
+ ["--data <json|@file>", "The whole body as a JSON object."],
43
+ ["--token <token>", "Send this bearer instead, such as a build grant (lgb_) or an OAuth access token."],
44
+ ["--idempotency-key <key>", "Default: a new one for each POST."],
45
+ ["--live", `Production, GET only, with your own sk_live_ in LAYOUT_SECRET_KEY.`],
46
+ ], run: api },
30
47
  { path: ["test-order"], usage: "layout test-order [--place <test_place_id>]", summary: "Run a whole sandbox order and print each step.", flags: [s("place")],
31
48
  flagHelp: [["--place <id>", "A sandbox test restaurant. Default: test_place_placed."]], run: testOrder },
32
49
  { path: ["request-live-access"], usage: "layout request-live-access [--note <text>]", summary: "Ask Layout to review production access.", flags: [s("note")], run: requestLiveAccess },
@@ -75,7 +92,7 @@ function help(o, cmd, sub) {
75
92
  const list = sub.length ? sub : COMMANDS;
76
93
  o.line(o.wordmark());
77
94
  o.line();
78
- o.line("Sign in, then build against the Layout sandbox. Never touches production.");
95
+ o.line("Build against the Layout sandbox, and read production with --live. The CLI never changes production or moves money.");
79
96
  o.line();
80
97
  o.line("Commands:");
81
98
  for (const l of pad(list.map((c) => [c.path.join(" "), c.summary])))
@@ -85,11 +102,31 @@ function help(o, cmd, sub) {
85
102
  for (const l of pad([...GLOBAL_HELP]))
86
103
  o.line(l);
87
104
  o.line();
105
+ o.line("Environment:");
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
+ ]))
112
+ o.line(l);
113
+ o.line();
88
114
  o.line("Docs: https://developer.layout.link/reference/cli");
89
115
  }
90
116
  export async function main(argv, p) {
91
117
  const json = argv.includes("--json");
92
118
  const fallback = new Output(p.streams, json);
119
+ const update = json ? null : checkForUpdate(p);
120
+ try {
121
+ return await dispatch(argv, p, json, fallback);
122
+ }
123
+ finally {
124
+ const notice = update ? await update : null;
125
+ if (notice)
126
+ fallback.note(notice);
127
+ }
128
+ }
129
+ async function dispatch(argv, p, json, fallback) {
93
130
  try {
94
131
  const loose = parseArgs(argv, COMMANDS.flatMap((c) => c.flags));
95
132
  const positionals = loose.positionals;
@@ -113,7 +150,7 @@ export async function main(argv, p) {
113
150
  return EXIT.ok;
114
151
  }
115
152
  const extra = args.positionals.slice(cmd.path.length);
116
- const takesArgs = ["apps create", "trigger", "docs"].includes(cmd.path.join(" "));
153
+ const takesArgs = ["apps create", "trigger", "docs", "api"].includes(cmd.path.join(" "));
117
154
  if (!takesArgs && extra.length > 0)
118
155
  throw usageError(`Unexpected argument: ${extra[0]}. Run layout ${cmd.path.join(" ")} --help.`);
119
156
  return await cmd.run(makeCtx(p, args));
package/dist/update.js ADDED
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { configDir, readJson, writePrivateJson } from "./config.js";
3
+ import { VERSION } from "./version.js";
4
+ const REGISTRY = "https://registry.npmjs.org/@layoutplatforms/cli/latest";
5
+ const EVERY_MS = 24 * 60 * 60 * 1000;
6
+ const WAIT_MS = 1_500;
7
+ export function newer(latest, current) {
8
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number(n));
9
+ const a = parse(latest);
10
+ const b = parse(current);
11
+ if (a.length !== 3 || b.length !== 3 || [...a, ...b].some((n) => !Number.isInteger(n) || n < 0))
12
+ return false;
13
+ for (let i = 0; i < 3; i++)
14
+ if (a[i] !== b[i])
15
+ return a[i] > b[i];
16
+ return false;
17
+ }
18
+ /** One quiet look a day at npm for a newer CLI, on a terminal only. Never delays a command by more than its own wait, and never fails one. */
19
+ export async function checkForUpdate(p) {
20
+ try {
21
+ const env = p.streams.env;
22
+ if (!p.streams.err.isTTY || env.CI || env.LAYOUT_NO_UPDATE_CHECK)
23
+ return null;
24
+ const file = join(configDir(env), "update-check.json");
25
+ const cached = readJson(file);
26
+ let latest = cached?.latest ?? null;
27
+ if (!cached?.checkedAt || p.now() - cached.checkedAt > EVERY_MS) {
28
+ const res = await p.http.fetch(REGISTRY, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(WAIT_MS), redirect: "error" });
29
+ const body = res.ok ? (await res.json()) : null;
30
+ latest = typeof body?.version === "string" ? body.version : latest;
31
+ writePrivateJson(file, { checkedAt: p.now(), latest });
32
+ }
33
+ return latest && newer(latest, VERSION) ? `Layout CLI ${latest} is out (you have ${VERSION}). Update: npm i -g @layoutplatforms/cli` : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.1";
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.1.1",
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",