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