@layoutplatforms/cli 0.1.1 → 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/README.md +9 -6
- package/dist/args.js +13 -2
- package/dist/commands/api.js +127 -0
- package/dist/commands/listen.js +17 -12
- package/dist/commands/resources.js +14 -7
- package/dist/commands/session.js +22 -9
- package/dist/commands/test-order.js +18 -9
- package/dist/context.js +45 -3
- package/dist/credentials.js +1 -1
- package/dist/http.js +4 -1
- package/dist/listen.js +3 -3
- package/dist/main.js +37 -9
- package/dist/update.js +38 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Layout CLI
|
|
2
2
|
|
|
3
|
-
Sign in to your Layout developer account
|
|
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,27 @@ 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 login [--live]` | Sign in with a code you approve in the console. `--live` adds 12 hours of production reads. |
|
|
17
17
|
| `layout logout` | Revoke this CLI session and forget it here. |
|
|
18
18
|
| `layout whoami` | Your developer account and this CLI session. |
|
|
19
19
|
| `layout apps list`, `layout apps create` | Your applications. |
|
|
20
20
|
| `layout keys list`, `layout keys create`, `layout keys revoke` | Your sandbox secret key. |
|
|
21
21
|
| `layout webhooks list` | Your webhook endpoints. Add them in the console. |
|
|
22
|
-
| `layout listen --forward-to <url>` | Forward
|
|
22
|
+
| `layout listen --forward-to <url>` | Forward events to your machine, signed like real webhooks. |
|
|
23
23
|
| `layout trigger <event>` | Send a test event to your endpoints. |
|
|
24
|
+
| `layout api get /v1/places?query=coffee` | Call any REST endpoint and print the answer. |
|
|
24
25
|
| `layout test-order` | Run a whole order against a sandbox test restaurant. |
|
|
25
26
|
| `layout usage` | Your limits and what you have used today. |
|
|
26
|
-
| `layout deliveries` | Recent
|
|
27
|
+
| `layout deliveries` | Recent webhook deliveries. |
|
|
27
28
|
| `layout request-live-access` | Ask Layout to review production access. |
|
|
28
29
|
| `layout docs` | Open the docs. |
|
|
29
30
|
|
|
30
|
-
Every command takes `--json`, so a coding agent can drive it.
|
|
31
|
+
Every command takes `--json`, so a coding agent can drive it. `listen`, `deliveries`, `keys list` and `webhooks list` take `--live`.
|
|
32
|
+
|
|
33
|
+
In CI, set `LAYOUT_SECRET_KEY` to a sandbox key and `layout api` and `layout test-order` run without signing in.
|
|
31
34
|
|
|
32
35
|
## What a CLI session can do
|
|
33
36
|
|
|
34
|
-
|
|
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.
|
|
35
38
|
|
|
36
39
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/listen.js
CHANGED
|
@@ -3,12 +3,13 @@ import { bool, str } from "../args.js";
|
|
|
3
3
|
import { configDir, hostKey, readJson, writePrivateJson } from "../config.js";
|
|
4
4
|
import { 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
|
-
|
|
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
|
|
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,13 +42,13 @@ 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}` : ""}`);
|
|
42
47
|
const secret = mintLocalSecret();
|
|
43
48
|
if (out.json)
|
|
44
|
-
out.data({ event: "listening", app, forwardTo, secret, resumed: Boolean(saved) && !bool(ctx.args, "from-start") });
|
|
49
|
+
out.data({ event: "listening", app, environment: live ? "live" : "sandbox", forwardTo, secret, resumed: Boolean(saved) && !bool(ctx.args, "from-start") });
|
|
45
50
|
else {
|
|
46
|
-
out.line(`Forwarding sandbox events for ${app} to ${forwardTo}`);
|
|
51
|
+
out.line(`Forwarding ${live ? out.accent("production") : "sandbox"} events for ${app} to ${forwardTo}`);
|
|
47
52
|
out.line(`Signing secret for this run ${out.accent(secret)}`);
|
|
48
53
|
out.line(saved && !bool(ctx.args, "from-start") ? "Resuming after the last event forwarded." : "Starting from now. Press Ctrl-C to stop.");
|
|
49
54
|
}
|
|
@@ -70,8 +75,8 @@ export async function listen(ctx) {
|
|
|
70
75
|
}
|
|
71
76
|
};
|
|
72
77
|
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 }),
|
|
78
|
+
await runListener(start, { forwardTo, secret, events, pollMs: 2_000, maxBackoffMs: 30_000, environment: live ? "live" : "sandbox" }, {
|
|
79
|
+
fetchPage: (cursor) => authed(ctx, `/v1/cli/apps/${enc}/events?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${envq ? `&${envq}` : ""}`, { retry: false }),
|
|
75
80
|
forward: async (url, body, headers) => {
|
|
76
81
|
const res = await p.http.fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(10_000), redirect: "manual" });
|
|
77
82
|
await res.arrayBuffer().catch(() => undefined);
|
|
@@ -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,
|
|
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;
|
|
@@ -116,10 +123,10 @@ export async function deliveries(ctx) {
|
|
|
116
123
|
const limit = Number(str(ctx.args, "limit") ?? "20");
|
|
117
124
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
118
125
|
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}`);
|
|
126
|
+
const data = await authed(ctx, withEnv(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/deliveries?limit=${limit}`));
|
|
120
127
|
ctx.out.result({ app, ...data }, () => {
|
|
121
128
|
if (data.deliveries.length === 0)
|
|
122
|
-
return ctx.out.line(
|
|
129
|
+
return ctx.out.line(`No ${envName(ctx)} deliveries yet.`);
|
|
123
130
|
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
131
|
});
|
|
125
132
|
return EXIT.ok;
|
package/dist/commands/session.js
CHANGED
|
@@ -4,19 +4,23 @@ import { chooseStore, fileStore, storeOfKind } from "../credentials.js";
|
|
|
4
4
|
import { CliError, EXIT } from "../errors.js";
|
|
5
5
|
import { authed, forget } 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
|
|
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,13 +94,18 @@ 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);
|
|
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
|
+
}
|
|
92
103
|
const who = await authed(ctx, "/v1/cli/whoami");
|
|
93
|
-
|
|
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"}
|
|
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
|
});
|
|
97
110
|
return EXIT.ok;
|
|
98
111
|
}
|
|
@@ -123,7 +136,7 @@ export async function whoami(ctx) {
|
|
|
123
136
|
ctx.out.result(who, () => {
|
|
124
137
|
ctx.out.line(`${who.account.name ?? "Developer account"} (${who.account.id})`);
|
|
125
138
|
ctx.out.line(`Session ${who.session.deviceName ? `on ${who.session.deviceName}, ` : ""}expires ${who.session.expiresAt.slice(0, 10)}`);
|
|
126
|
-
ctx.out.line("Sandbox
|
|
139
|
+
ctx.out.line(who.session.liveUntil ? `Production read access until ${localTime(who.session.liveUntil)}` : "Sandbox. Run layout login --live to read production.");
|
|
127
140
|
});
|
|
128
141
|
return EXIT.ok;
|
|
129
142
|
}
|
|
@@ -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 {
|
|
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",
|
|
@@ -26,7 +26,11 @@ export async function testOrder(ctx) {
|
|
|
26
26
|
const expected = EXPECTED[place];
|
|
27
27
|
if (!expected)
|
|
28
28
|
throw usageError(`test-order runs a test restaurant that builds a cart on its own: ${Object.keys(EXPECTED).join(", ")}.`);
|
|
29
|
-
const
|
|
29
|
+
const fromEnv = envSecret(ctx);
|
|
30
|
+
if (fromEnv?.startsWith("sk_live_"))
|
|
31
|
+
throw usageError(`test-order runs in sandbox only, and ${SECRET_ENV} is a live secret.`);
|
|
32
|
+
const app = fromEnv ? null : await pickApp(ctx);
|
|
33
|
+
const seed = app ?? createHash("sha256").update(fromEnv).digest("hex").slice(0, 16);
|
|
30
34
|
const steps = [];
|
|
31
35
|
const run = `cli-${randomUUID()}`;
|
|
32
36
|
let current = out.spinner("Reading the sandbox key");
|
|
@@ -58,15 +62,20 @@ export async function testOrder(ctx) {
|
|
|
58
62
|
await p.sleep(1_500);
|
|
59
63
|
}
|
|
60
64
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
done("Read the sandbox key", `${key.clientId}, held in memory for this run`);
|
|
64
|
-
const sk = key.secret;
|
|
65
|
+
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");
|
|
65
67
|
next("Provisioning a sandbox test user");
|
|
66
|
-
const phone = testPhone(
|
|
67
|
-
const
|
|
68
|
+
const phone = testPhone(seed);
|
|
69
|
+
const provision = () => api.request("/v1/users", {
|
|
68
70
|
method: "POST", auth: sk, idempotent: true, retry: true, body: { firstName: "Layout", lastName: "CLI test", phone },
|
|
69
|
-
}).then((r) => r.data)
|
|
71
|
+
}).then((r) => r.data);
|
|
72
|
+
const user = await provision().catch(async (err) => {
|
|
73
|
+
if (!(err instanceof CliError) || err.exit !== EXIT.auth || !app)
|
|
74
|
+
throw err;
|
|
75
|
+
dropSandboxSecret(ctx, app);
|
|
76
|
+
sk = await sandboxSecret(ctx, app, true);
|
|
77
|
+
return provision();
|
|
78
|
+
}).catch((err) => fail("Provision a test user", err));
|
|
70
79
|
done("Provision a test user", `${user.id} on ${phone}`);
|
|
71
80
|
if (user.next === "verify") {
|
|
72
81
|
next("Verifying with the sandbox code");
|
package/dist/context.js
CHANGED
|
@@ -30,15 +30,57 @@ export function signedIn(ctx) {
|
|
|
30
30
|
if (!record)
|
|
31
31
|
throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
|
|
32
32
|
const token = storeOfKind(record.store, ctx.env, ctx.p.run).get(hostKey(ctx.base));
|
|
33
|
-
if (!token)
|
|
33
|
+
if (!token?.startsWith("lcli_"))
|
|
34
34
|
throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
|
|
35
35
|
return { token, record };
|
|
36
36
|
}
|
|
37
37
|
export function forget(ctx, record) {
|
|
38
|
-
if (record)
|
|
39
|
-
storeOfKind(record.store, ctx.env, ctx.p.run)
|
|
38
|
+
if (record) {
|
|
39
|
+
const store = storeOfKind(record.store, ctx.env, ctx.p.run);
|
|
40
|
+
store.remove(hostKey(ctx.base));
|
|
41
|
+
for (const app of record.keys ?? [])
|
|
42
|
+
store.remove(keyAccount(ctx.base, app));
|
|
43
|
+
}
|
|
40
44
|
writeSession(ctx.env, ctx.base, null);
|
|
41
45
|
}
|
|
46
|
+
const APP_ID = /^lyt_app_[A-Za-z0-9]{1,64}$/;
|
|
47
|
+
function keyAccount(base, app) {
|
|
48
|
+
return `${hostKey(base)}:sandbox-key:${app}`;
|
|
49
|
+
}
|
|
50
|
+
/** The application's sandbox secret, from this computer's credential store or read once from Layout and kept there. Never a live secret. */
|
|
51
|
+
export async function sandboxSecret(ctx, app, fresh = false) {
|
|
52
|
+
const s = signedIn(ctx);
|
|
53
|
+
const store = storeOfKind(s.record.store, ctx.env, ctx.p.run);
|
|
54
|
+
const cacheable = APP_ID.test(app);
|
|
55
|
+
if (cacheable && !fresh) {
|
|
56
|
+
const cached = store.get(keyAccount(ctx.base, app));
|
|
57
|
+
if (cached?.startsWith("sk_test_"))
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
const key = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/reveal`, { method: "POST", body: {}, retry: true });
|
|
61
|
+
if (!key.secret?.startsWith("sk_test_"))
|
|
62
|
+
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))
|
|
66
|
+
writeSession(ctx.env, ctx.base, { ...record, keys: [...(record.keys ?? []), app] });
|
|
67
|
+
}
|
|
68
|
+
return key.secret;
|
|
69
|
+
}
|
|
70
|
+
export function dropSandboxSecret(ctx, app) {
|
|
71
|
+
const record = readSession(ctx.env, ctx.base);
|
|
72
|
+
if (record)
|
|
73
|
+
storeOfKind(record.store, ctx.env, ctx.p.run).remove(keyAccount(ctx.base, app));
|
|
74
|
+
}
|
|
75
|
+
export const SECRET_ENV = "LAYOUT_SECRET_KEY";
|
|
76
|
+
export function envSecret(ctx) {
|
|
77
|
+
const v = ctx.env[SECRET_ENV]?.trim();
|
|
78
|
+
if (!v)
|
|
79
|
+
return null;
|
|
80
|
+
if (!/^sk_(?:test|live)_[A-Za-z0-9_-]{20,128}$/.test(v))
|
|
81
|
+
throw usageError(`${SECRET_ENV} is not a Layout secret key (sk_test_... or sk_live_...).`);
|
|
82
|
+
return v;
|
|
83
|
+
}
|
|
42
84
|
export async function authed(ctx, path, opts = {}) {
|
|
43
85
|
const s = signedIn(ctx);
|
|
44
86
|
try {
|
package/dist/credentials.js
CHANGED
|
@@ -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":
|
|
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,39 @@ 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 { api } from "./commands/api.js";
|
|
11
|
+
import { checkForUpdate } from "./update.js";
|
|
10
12
|
const s = (name) => ({ name, type: "string" });
|
|
11
13
|
const b = (name) => ({ name, type: "boolean" });
|
|
14
|
+
const LIVE = ["--live", "Production instead of sandbox. Needs layout login --live; read-only."];
|
|
12
15
|
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 },
|
|
16
|
+
{ 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
|
+
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 },
|
|
15
18
|
{ path: ["logout"], usage: "layout logout", summary: "Revoke this CLI session and forget it here.", flags: [], run: logout },
|
|
16
19
|
{ path: ["whoami"], usage: "layout whoami", summary: "The developer account and this CLI session.", flags: [], run: whoami },
|
|
17
20
|
{ path: ["apps", "list"], usage: "layout apps list", summary: "Your applications.", flags: [], run: appsList },
|
|
18
21
|
{ 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
|
|
22
|
+
{ 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
23
|
{ path: ["keys", "create"], usage: "layout keys create [--replace]", summary: "A new sandbox key, printed once.", flags: [b("replace")],
|
|
21
24
|
flagHelp: [["--replace", "Retire the current sandbox key and make another."]], run: keysCreate },
|
|
22
25
|
{ path: ["keys", "revoke"], usage: "layout keys revoke --yes", summary: "Revoke the sandbox key and its sandbox build grants.", flags: [b("yes")],
|
|
23
26
|
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
|
|
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 },
|
|
27
|
+
{ 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 },
|
|
28
|
+
{ 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")],
|
|
29
|
+
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
30
|
{ path: ["trigger"], usage: "layout trigger <event> [--order <ord_id>]", summary: "Fire a signed test event at your endpoints.", flags: [s("order")], run: trigger },
|
|
28
31
|
{ path: ["usage"], usage: "layout usage", summary: "Today's allowances and counts.", flags: [], run: usage },
|
|
29
|
-
{ path: ["deliveries"], usage: "layout deliveries [--limit <n>]", summary: "Recent
|
|
32
|
+
{ path: ["deliveries"], usage: "layout deliveries [--limit <n>] [--live]", summary: "Recent webhook deliveries.", flags: [s("limit"), b("live")],
|
|
33
|
+
flagHelp: [["--limit <n>", "1 to 100. Default 20."], LIVE], run: deliveries },
|
|
34
|
+
{ 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.",
|
|
35
|
+
flags: [{ name: "field", type: "list", short: "d" }, s("data"), s("token"), s("idempotency-key"), b("live")],
|
|
36
|
+
flagHelp: [
|
|
37
|
+
["-d name=value", "A body field; repeat it. JSON values (numbers, true, arrays) are kept as JSON."],
|
|
38
|
+
["--data <json|@file>", "The whole body as a JSON object."],
|
|
39
|
+
["--token <token>", "Send this bearer instead, such as a build grant (lgb_) or an OAuth access token."],
|
|
40
|
+
["--idempotency-key <key>", "Default: a new one for each POST."],
|
|
41
|
+
["--live", `Production, GET only, with your own sk_live_ in LAYOUT_SECRET_KEY.`],
|
|
42
|
+
], run: api },
|
|
30
43
|
{ 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
44
|
flagHelp: [["--place <id>", "A sandbox test restaurant. Default: test_place_placed."]], run: testOrder },
|
|
32
45
|
{ 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 +88,7 @@ function help(o, cmd, sub) {
|
|
|
75
88
|
const list = sub.length ? sub : COMMANDS;
|
|
76
89
|
o.line(o.wordmark());
|
|
77
90
|
o.line();
|
|
78
|
-
o.line("
|
|
91
|
+
o.line("Build against the Layout sandbox, and read production with --live. The CLI never changes production or moves money.");
|
|
79
92
|
o.line();
|
|
80
93
|
o.line("Commands:");
|
|
81
94
|
for (const l of pad(list.map((c) => [c.path.join(" "), c.summary])))
|
|
@@ -85,11 +98,26 @@ function help(o, cmd, sub) {
|
|
|
85
98
|
for (const l of pad([...GLOBAL_HELP]))
|
|
86
99
|
o.line(l);
|
|
87
100
|
o.line();
|
|
101
|
+
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."]]))
|
|
103
|
+
o.line(l);
|
|
104
|
+
o.line();
|
|
88
105
|
o.line("Docs: https://developer.layout.link/reference/cli");
|
|
89
106
|
}
|
|
90
107
|
export async function main(argv, p) {
|
|
91
108
|
const json = argv.includes("--json");
|
|
92
109
|
const fallback = new Output(p.streams, json);
|
|
110
|
+
const update = json ? null : checkForUpdate(p);
|
|
111
|
+
try {
|
|
112
|
+
return await dispatch(argv, p, json, fallback);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
const notice = update ? await update : null;
|
|
116
|
+
if (notice)
|
|
117
|
+
fallback.note(notice);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function dispatch(argv, p, json, fallback) {
|
|
93
121
|
try {
|
|
94
122
|
const loose = parseArgs(argv, COMMANDS.flatMap((c) => c.flags));
|
|
95
123
|
const positionals = loose.positionals;
|
|
@@ -113,7 +141,7 @@ export async function main(argv, p) {
|
|
|
113
141
|
return EXIT.ok;
|
|
114
142
|
}
|
|
115
143
|
const extra = args.positionals.slice(cmd.path.length);
|
|
116
|
-
const takesArgs = ["apps create", "trigger", "docs"].includes(cmd.path.join(" "));
|
|
144
|
+
const takesArgs = ["apps create", "trigger", "docs", "api"].includes(cmd.path.join(" "));
|
|
117
145
|
if (!takesArgs && extra.length > 0)
|
|
118
146
|
throw usageError(`Unexpected argument: ${extra[0]}. Run layout ${cmd.path.join(" ")} --help.`);
|
|
119
147
|
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
|
+
export const VERSION = "0.2.0";
|