@layoutplatforms/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/args.js ADDED
@@ -0,0 +1,53 @@
1
+ import { usageError } from "./errors.js";
2
+ export const GLOBAL_FLAGS = [
3
+ { name: "json", type: "boolean" },
4
+ { name: "help", type: "boolean" },
5
+ { name: "version", type: "boolean" },
6
+ { name: "app", type: "string" },
7
+ ];
8
+ export function parseArgs(argv, specs) {
9
+ const all = new Map();
10
+ for (const s of [...GLOBAL_FLAGS, ...specs])
11
+ all.set(s.name, s);
12
+ const positionals = [];
13
+ const flags = {};
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i];
16
+ if (arg === "--") {
17
+ positionals.push(...argv.slice(i + 1));
18
+ break;
19
+ }
20
+ if (arg === "-h") {
21
+ flags.help = true;
22
+ continue;
23
+ }
24
+ if (!arg.startsWith("--") || arg === "--") {
25
+ positionals.push(arg);
26
+ continue;
27
+ }
28
+ const eq = arg.indexOf("=");
29
+ const name = arg.slice(2, eq === -1 ? undefined : eq);
30
+ const negated = name.startsWith("no-") && all.get(name.slice(3))?.type === "boolean";
31
+ const spec = all.get(negated ? name.slice(3) : name) ?? all.get(name);
32
+ if (!spec)
33
+ throw usageError(`Unknown flag --${name}. Run with --help to see the flags.`);
34
+ if (spec.type === "boolean") {
35
+ if (eq !== -1)
36
+ throw usageError(`--${name} takes no value.`);
37
+ flags[spec.name] = !negated;
38
+ continue;
39
+ }
40
+ const value = eq !== -1 ? arg.slice(eq + 1) : argv[++i];
41
+ if (value === undefined || (eq === -1 && value.startsWith("--")))
42
+ throw usageError(`--${name} needs a value.`);
43
+ flags[spec.name] = value;
44
+ }
45
+ return { positionals, flags };
46
+ }
47
+ export function str(p, name) {
48
+ const v = p.flags[name];
49
+ return typeof v === "string" ? v : undefined;
50
+ }
51
+ export function bool(p, name) {
52
+ return p.flags[name] === true;
53
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { hostname } from "node:os";
4
+ import { defaultPlatform } from "./context.js";
5
+ import { main } from "./main.js";
6
+ const [major] = process.versions.node.split(".").map(Number);
7
+ if ((major ?? 0) < 20) {
8
+ process.stderr.write("The Layout CLI needs Node 20 or later.\n");
9
+ process.exit(1);
10
+ }
11
+ function openUrl(url) {
12
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
13
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
14
+ try {
15
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
16
+ child.on("error", () => { });
17
+ child.unref();
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ const platform = {
25
+ ...defaultPlatform({ out: process.stdout, err: process.stderr, env: process.env }),
26
+ openUrl,
27
+ hostname: () => hostname().replace(/\.local$/, ""),
28
+ };
29
+ main(process.argv.slice(2), platform).then((code) => { process.exitCode = code; }, () => { process.exitCode = 1; });
@@ -0,0 +1,96 @@
1
+ import { join } from "node:path";
2
+ import { bool, str } from "../args.js";
3
+ import { configDir, hostKey, readJson, writePrivateJson } from "../config.js";
4
+ import { EXIT, usageError } from "../errors.js";
5
+ import { authed, pickApp } from "../context.js";
6
+ import { WEBHOOKS_PAGE } from "./resources.js";
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`);
10
+ }
11
+ function forwardUrl(raw) {
12
+ if (!raw)
13
+ throw usageError("Give --forward-to, for example --forward-to http://localhost:3000/webhooks/layout.");
14
+ let url;
15
+ try {
16
+ url = new URL(raw);
17
+ }
18
+ catch {
19
+ throw usageError("--forward-to must be a full http or https URL.");
20
+ }
21
+ if (url.protocol !== "http:" && url.protocol !== "https:")
22
+ throw usageError("--forward-to must be an http or https URL.");
23
+ return url.toString();
24
+ }
25
+ export async function listen(ctx) {
26
+ const { out, p } = ctx;
27
+ const forwardTo = forwardUrl(str(ctx.args, "forward-to"));
28
+ const filter = str(ctx.args, "events");
29
+ const events = filter ? new Set(filter.split(",").map((s) => s.trim()).filter(Boolean)) : null;
30
+ const app = await pickApp(ctx);
31
+ const enc = encodeURIComponent(app);
32
+ const stateFile = cursorFile(ctx.env, ctx.base, app);
33
+ const saved = readJson(stateFile)?.cursor ?? null;
34
+ let start;
35
+ if (bool(ctx.args, "from-start"))
36
+ start = null;
37
+ else if (saved)
38
+ start = saved;
39
+ else
40
+ start = (await authed(ctx, `/v1/cli/apps/${enc}/events/cursor`)).cursor;
41
+ const hooks = await authed(ctx, `/v1/cli/apps/${enc}/webhooks`);
42
+ const secret = mintLocalSecret();
43
+ if (out.json)
44
+ out.data({ event: "listening", app, forwardTo, secret, resumed: Boolean(saved) && !bool(ctx.args, "from-start") });
45
+ else {
46
+ out.line(`Forwarding sandbox events for ${app} to ${forwardTo}`);
47
+ out.line(`Signing secret for this run ${out.accent(secret)}`);
48
+ out.line(saved && !bool(ctx.args, "from-start") ? "Resuming after the last event forwarded." : "Starting from now. Press Ctrl-C to stop.");
49
+ }
50
+ if (!hooks.subscriptions.some((s) => s.enabled)) {
51
+ 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
+ }
53
+ let stopped = false;
54
+ const off = p.onInterrupt(() => { stopped = true; });
55
+ const spin = out.spinner("Waiting for events");
56
+ const report = (r) => {
57
+ if (r.kind === "forwarded") {
58
+ if (out.json)
59
+ out.data({ cursor: r.cursor, event: r.event, eventId: r.eventId, status: r.status, forwardedAt: new Date(p.now()).toISOString() });
60
+ else {
61
+ spin.stop();
62
+ out.line(`${new Date(p.now()).toISOString().slice(11, 19)} ${r.event.padEnd(18)} ${out.accent(String(r.status))} ${r.eventId ?? ""}`.trimEnd());
63
+ }
64
+ }
65
+ else if (r.kind === "forward_failed") {
66
+ out.note(`${r.event} not accepted by ${forwardTo} (${r.status === 0 ? "no answer" : r.status}). Retrying in ${Math.round(r.retryInMs / 1000)}s.`);
67
+ }
68
+ else if (r.kind === "feed_unavailable") {
69
+ out.note(`Layout could not be reached. Retrying in ${Math.round(r.retryInMs / 1000)}s.`);
70
+ }
71
+ };
72
+ 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 }),
75
+ forward: async (url, body, headers) => {
76
+ const res = await p.http.fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(10_000), redirect: "manual" });
77
+ await res.arrayBuffer().catch(() => undefined);
78
+ return { ok: res.status >= 200 && res.status < 300, status: res.status };
79
+ },
80
+ saveCursor: (cursor) => writePrivateJson(stateFile, { cursor, updatedAt: new Date(p.now()).toISOString() }),
81
+ sleep: async (ms) => {
82
+ const until = p.now() + ms;
83
+ while (!stopped && p.now() < until)
84
+ await p.sleep(Math.min(250, until - p.now()));
85
+ },
86
+ stopped: () => stopped,
87
+ report,
88
+ nowSec: () => Math.floor(p.now() / 1000),
89
+ });
90
+ }
91
+ finally {
92
+ spin.stop();
93
+ off();
94
+ }
95
+ return stopped ? EXIT.interrupted : EXIT.ok;
96
+ }
@@ -0,0 +1,161 @@
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, pickApp } from "../context.js";
5
+ export const WEBHOOKS_PAGE = `${DOCS_BASE}/console/webhooks/subscriptions`;
6
+ const SHOWN_ONCE = "Shown once. Store it in your secret manager, never in source control.";
7
+ export async function appsList(ctx) {
8
+ const data = await authed(ctx, "/v1/cli/apps");
9
+ ctx.out.result(data, () => {
10
+ if (data.apps.length === 0)
11
+ return ctx.out.line("No applications yet. Create one with layout apps create <name>.");
12
+ ctx.out.table(data.apps.map((a) => [a.id, a.name, a.status, a.createdAt.slice(0, 10)]), ["ID", "NAME", "STATUS", "CREATED"]);
13
+ });
14
+ return EXIT.ok;
15
+ }
16
+ export async function appsCreate(ctx) {
17
+ const name = ctx.args.positionals.slice(2).join(" ").trim();
18
+ if (!name)
19
+ throw usageError("Name the application: layout apps create <name>.");
20
+ const data = await authed(ctx, "/v1/cli/apps", {
21
+ method: "POST", body: { name }, idempotent: true, retry: true,
22
+ });
23
+ ctx.out.result(data, () => {
24
+ ctx.out.line(`Created ${data.app.name} (${data.app.id}). Production is reviewed by a person; sandbox works now.`);
25
+ ctx.out.line(`Sandbox client id ${data.sandboxKey.clientId}`);
26
+ ctx.out.line(`Sandbox secret ${ctx.out.accent(data.sandboxKey.secret)}`);
27
+ ctx.out.line(SHOWN_ONCE);
28
+ });
29
+ return EXIT.ok;
30
+ }
31
+ export async function keysList(ctx) {
32
+ const app = await pickApp(ctx);
33
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys`);
34
+ ctx.out.result({ app, ...data }, () => {
35
+ 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"]);
38
+ });
39
+ return EXIT.ok;
40
+ }
41
+ export async function keysCreate(ctx) {
42
+ const app = await pickApp(ctx);
43
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys`, {
44
+ method: "POST", body: { replace: bool(ctx.args, "replace") }, idempotent: true, retry: true,
45
+ }).catch((err) => {
46
+ if (err instanceof CliError && err.code === "key_exists") {
47
+ throw new CliError("key_exists", "This application already has a sandbox key. Run layout keys create --replace to retire it and make another.", EXIT.conflict);
48
+ }
49
+ throw err;
50
+ });
51
+ ctx.out.result({ app, ...data }, () => {
52
+ ctx.out.line(data.replaced ? "New sandbox key. The old one no longer works." : "New sandbox key.");
53
+ ctx.out.line(`Client id ${data.clientId}`);
54
+ ctx.out.line(`Secret ${ctx.out.accent(data.secret)}`);
55
+ ctx.out.line(SHOWN_ONCE);
56
+ });
57
+ return EXIT.ok;
58
+ }
59
+ export async function keysRevoke(ctx) {
60
+ const app = await pickApp(ctx);
61
+ if (!bool(ctx.args, "yes")) {
62
+ throw usageError("Revoking stops the sandbox key and its sandbox build grants at once. Run again with --yes to confirm.");
63
+ }
64
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/keys/revoke`, {
65
+ method: "POST", body: {}, idempotent: true, retry: true,
66
+ });
67
+ ctx.out.result({ app, ...data }, () => {
68
+ ctx.out.line(data.revoked > 0 ? "Sandbox key revoked." : "There was no sandbox key to revoke.");
69
+ });
70
+ return EXIT.ok;
71
+ }
72
+ export async function webhooksList(ctx) {
73
+ const app = await pickApp(ctx);
74
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/webhooks`);
75
+ ctx.out.result({ app, ...data }, () => {
76
+ if (data.subscriptions.length === 0)
77
+ ctx.out.line(`No webhook endpoints. Add one once in the console: ${WEBHOOKS_PAGE}`);
78
+ else
79
+ ctx.out.table(data.subscriptions.map((s) => [s.id, s.enabled ? "on" : "off", s.url, s.events.join(",")]), ["ID", "STATE", "URL", "EVENTS"]);
80
+ if (data.signingSecret)
81
+ ctx.out.line(`Sandbox signing secret ${data.signingSecret}`);
82
+ ctx.out.line(`Endpoints are managed in the console: ${WEBHOOKS_PAGE}`);
83
+ });
84
+ return EXIT.ok;
85
+ }
86
+ export async function trigger(ctx) {
87
+ const event = ctx.args.positionals[1];
88
+ if (!event)
89
+ throw usageError("Name the event: layout trigger order.placed.");
90
+ const app = await pickApp(ctx);
91
+ const order = str(ctx.args, "order");
92
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/trigger`, {
93
+ method: "POST", body: { event, ...(order ? { orderId: order } : {}) }, idempotent: true, retry: true,
94
+ });
95
+ ctx.out.result({ app, ...data }, () => {
96
+ ctx.out.line(`Sent ${data.event} to ${data.deliveries.length} endpoint${data.deliveries.length === 1 ? "" : "s"}${data.order ? `, describing ${data.order.id}` : ""}.`);
97
+ ctx.out.table(data.deliveries.map((d) => [d.id, d.outcome, d.url]));
98
+ });
99
+ return EXIT.ok;
100
+ }
101
+ export async function usage(ctx) {
102
+ const app = await pickApp(ctx);
103
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/usage`);
104
+ ctx.out.result({ app, usage: data }, () => {
105
+ for (const env of ["sandbox", "live"]) {
106
+ const u = data[env];
107
+ if (u && typeof u.perDay === "number")
108
+ ctx.out.line(`${env === "live" ? "Production" : "Sandbox"} builds today ${u.usedToday ?? 0} of ${u.perDay}`);
109
+ }
110
+ ctx.out.line(`Every allowance: ${DOCS_BASE}/console/usage`);
111
+ });
112
+ return EXIT.ok;
113
+ }
114
+ export async function deliveries(ctx) {
115
+ const app = await pickApp(ctx);
116
+ const limit = Number(str(ctx.args, "limit") ?? "20");
117
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
118
+ 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}`);
120
+ ctx.out.result({ app, ...data }, () => {
121
+ if (data.deliveries.length === 0)
122
+ return ctx.out.line("No sandbox deliveries yet.");
123
+ 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
+ });
125
+ return EXIT.ok;
126
+ }
127
+ export async function requestLiveAccess(ctx) {
128
+ const app = await pickApp(ctx);
129
+ const note = str(ctx.args, "note");
130
+ const data = await authed(ctx, `/v1/cli/apps/${encodeURIComponent(app)}/live-access`, {
131
+ method: "POST", body: note ? { note } : {}, idempotent: true, retry: true,
132
+ });
133
+ ctx.out.result({ app, ...data }, () => {
134
+ if (data.status === "approved")
135
+ ctx.out.line("This application is already approved for production.");
136
+ else if (data.status === "already_requested")
137
+ ctx.out.line("A review is already open. Layout emails you when it is decided.");
138
+ else
139
+ ctx.out.line("Review requested. A person at Layout decides it and emails you. Sandbox keeps working.");
140
+ });
141
+ return EXIT.ok;
142
+ }
143
+ export const DOC_TOPICS = {
144
+ cli: "/reference/cli",
145
+ quickstart: "/reference/quickstart",
146
+ sandbox: "/reference/sandbox",
147
+ webhooks: "/reference/webhooks",
148
+ events: "/reference/events",
149
+ api: "/reference/api",
150
+ };
151
+ export async function docs(ctx) {
152
+ const topic = ctx.args.positionals[1] ?? "cli";
153
+ const path = DOC_TOPICS[topic];
154
+ if (!path)
155
+ throw usageError(`Unknown topic ${topic}. Topics: ${Object.keys(DOC_TOPICS).join(", ")}.`);
156
+ const url = `${DOCS_BASE}${path}`;
157
+ const markdown = `${url}.md`;
158
+ const opened = !ctx.out.json && Boolean(ctx.p.streams.out.isTTY) && ctx.p.openUrl(url);
159
+ ctx.out.result({ topic, url, markdown }, () => ctx.out.line(opened ? `Opened ${url}` : url));
160
+ return EXIT.ok;
161
+ }
@@ -0,0 +1,129 @@
1
+ import { bool, str } from "../args.js";
2
+ import { hostKey, readSession, writeSession } from "../config.js";
3
+ import { chooseStore, fileStore, storeOfKind } from "../credentials.js";
4
+ import { CliError, EXIT } from "../errors.js";
5
+ import { authed, forget } from "../context.js";
6
+ const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
7
+ export async function login(ctx) {
8
+ const { out, api, p } = ctx;
9
+ const existing = readSession(ctx.env, ctx.base);
10
+ if (existing)
11
+ forget(ctx, existing);
12
+ 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;
14
+ 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 });
16
+ }
17
+ else {
18
+ out.line(out.wordmark());
19
+ out.line();
20
+ out.line(`Your sign-in code is ${out.accent(code.user_code)}`);
21
+ out.line(`Approve it at ${code.verification_uri_complete}`);
22
+ out.line("Check the code matches before you approve.");
23
+ out.line();
24
+ }
25
+ const opened = !bool(ctx.args, "no-browser") && !ctx.env.CI && p.openUrl(code.verification_uri_complete);
26
+ if (!out.json && !opened)
27
+ out.line("Open that link in a browser signed in to the Layout console.");
28
+ const spin = out.spinner("Waiting for approval in the console");
29
+ let interval = Math.max(1, code.interval);
30
+ const deadline = p.now() + code.expires_in * 1000;
31
+ let cancelled = false;
32
+ const off = p.onInterrupt(() => { cancelled = true; });
33
+ try {
34
+ while (p.now() < deadline) {
35
+ await p.sleep(interval * 1000);
36
+ if (cancelled)
37
+ throw new CliError("interrupted", "Sign-in cancelled.", EXIT.interrupted);
38
+ let res;
39
+ try {
40
+ res = await api.request("/v1/cli/device/token", {
41
+ method: "POST",
42
+ body: { grant_type: GRANT, device_code: code.device_code },
43
+ retry: true,
44
+ okStatuses: [400],
45
+ });
46
+ }
47
+ catch (err) {
48
+ if (err instanceof CliError && err.exit === EXIT.unavailable)
49
+ continue;
50
+ throw err;
51
+ }
52
+ if (res.status === 200) {
53
+ spin.stop();
54
+ return await finishLogin(ctx, res.data);
55
+ }
56
+ const e = res.data;
57
+ if (e.error === "authorization_pending")
58
+ continue;
59
+ if (e.error === "slow_down") {
60
+ interval = Math.max(interval + 5, e.interval ?? 0);
61
+ continue;
62
+ }
63
+ if (e.error === "access_denied")
64
+ throw new CliError("access_denied", "Sign-in was denied in the console.", EXIT.refused);
65
+ if (e.error === "expired_token")
66
+ throw new CliError("expired_token", "The sign-in code expired. Run layout login again.", EXIT.auth);
67
+ throw new CliError(e.error || "invalid_grant", e.error_description ?? "Sign-in failed. Run layout login again.", EXIT.auth);
68
+ }
69
+ throw new CliError("expired_token", "The sign-in code expired. Run layout login again.", EXIT.auth);
70
+ }
71
+ finally {
72
+ spin.stop();
73
+ off();
74
+ }
75
+ }
76
+ async function finishLogin(ctx, tok) {
77
+ const { out } = ctx;
78
+ const choice = chooseStore(ctx.env, ctx.p.platform, ctx.p.run);
79
+ let store = choice.store;
80
+ let warning = choice.warning;
81
+ if (!store.set(hostKey(ctx.base), tok.access_token)) {
82
+ const file = fileStore(ctx.env);
83
+ if (!file.set(hostKey(ctx.base), tok.access_token))
84
+ throw new CliError("store_failed", "The CLI token could not be saved.", EXIT.error);
85
+ store = file;
86
+ warning = `The ${choice.store.kind} store refused the CLI token, so it is stored in ${file.path} (readable only by you).`;
87
+ }
88
+ 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 });
90
+ if (warning)
91
+ 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 });
94
+ 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.`);
96
+ });
97
+ return EXIT.ok;
98
+ }
99
+ export async function logout(ctx) {
100
+ const { out } = ctx;
101
+ const record = readSession(ctx.env, ctx.base);
102
+ let revoked = false;
103
+ if (record) {
104
+ const token = storeOfKind(record.store, ctx.env, ctx.p.run).get(hostKey(ctx.base));
105
+ if (token) {
106
+ try {
107
+ await ctx.api.request("/v1/cli/logout", { method: "POST", body: {}, auth: token, retry: true });
108
+ revoked = true;
109
+ }
110
+ catch (err) {
111
+ if (!(err instanceof CliError) || err.exit !== EXIT.auth) {
112
+ throw new CliError(err instanceof CliError ? err.code : "error", "Could not revoke this session on Layout, so it is still signed in here. Retry layout logout.", err instanceof CliError ? err.exit : EXIT.error);
113
+ }
114
+ }
115
+ }
116
+ forget(ctx, record);
117
+ }
118
+ out.result({ signedOut: true, revoked }, () => out.line(record ? (revoked ? "Signed out. This session is revoked on Layout." : "Signed out.") : "Not signed in."));
119
+ return EXIT.ok;
120
+ }
121
+ export async function whoami(ctx) {
122
+ const who = await authed(ctx, "/v1/cli/whoami");
123
+ ctx.out.result(who, () => {
124
+ 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.");
127
+ });
128
+ return EXIT.ok;
129
+ }
@@ -0,0 +1,128 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { str } from "../args.js";
3
+ import { CliError, EXIT, usageError } from "../errors.js";
4
+ import { authed, pickApp } from "../context.js";
5
+ export const EXPECTED = {
6
+ test_place_carted: "placed",
7
+ test_place_placed: "placed",
8
+ test_place_branch_north: "placed",
9
+ test_place_branch_south: "placed",
10
+ test_place_challenge: "placed",
11
+ test_place_slow: "placed",
12
+ test_place_price_changed: "placed",
13
+ test_place_failed: "failed",
14
+ test_place_unconfirmed: "unconfirmed",
15
+ test_place_closed: "failed",
16
+ };
17
+ const TERMINAL = new Set(["placed", "failed", "unconfirmed", "canceled", "cancelled", "expired", "refunded"]);
18
+ export function testPhone(appId) {
19
+ const n = parseInt(createHash("sha256").update(appId).digest("hex").slice(0, 8), 16) % 1900;
20
+ return `+1500555${String(100 + n).padStart(4, "0")}`;
21
+ }
22
+ const money = (minor) => (typeof minor === "number" ? `$${(minor / 100).toFixed(2)}` : "unknown");
23
+ export async function testOrder(ctx) {
24
+ const { out, api, p } = ctx;
25
+ const place = str(ctx.args, "place") ?? "test_place_placed";
26
+ const expected = EXPECTED[place];
27
+ if (!expected)
28
+ 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);
30
+ const steps = [];
31
+ const run = `cli-${randomUUID()}`;
32
+ let current = out.spinner("Reading the sandbox key");
33
+ const done = (step, detail) => {
34
+ steps.push({ step, status: "ok", detail });
35
+ current.stop();
36
+ if (!out.json)
37
+ out.line(`${out.accent("done")} ${step} ${detail}`);
38
+ };
39
+ const next = (text) => { current = out.spinner(text); };
40
+ const fail = (step, err) => {
41
+ const e = err instanceof CliError ? err : new CliError("error", String(err), EXIT.error);
42
+ steps.push({ step, status: "failed", detail: e.message });
43
+ current.stop();
44
+ if (out.json)
45
+ out.data({ app, place, steps, order: null, ok: false });
46
+ else
47
+ out.line(`failed ${step} ${e.message}`);
48
+ throw new CliError(e.code, e.message, e.exit, { step });
49
+ };
50
+ const until = async (step, read, settled, timeoutMs) => {
51
+ const deadline = p.now() + timeoutMs;
52
+ for (;;) {
53
+ const v = await read().catch((err) => fail(step, err));
54
+ if (settled(v))
55
+ return v;
56
+ if (p.now() > deadline)
57
+ fail(step, new CliError("timeout", "Timed out waiting. Read it again later.", EXIT.unavailable));
58
+ await p.sleep(1_500);
59
+ }
60
+ };
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;
65
+ next("Provisioning a sandbox test user");
66
+ const phone = testPhone(app);
67
+ const user = await api.request("/v1/users", {
68
+ 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));
70
+ done("Provision a test user", `${user.id} on ${phone}`);
71
+ if (user.next === "verify") {
72
+ next("Verifying with the sandbox code");
73
+ await api.request(`/v1/users/${encodeURIComponent(user.id)}/verify/start`, { method: "POST", auth: sk, idempotent: true, retry: true, body: {} })
74
+ .catch((err) => fail("Verify the test user", err));
75
+ const verified = await api.request(`/v1/users/${encodeURIComponent(user.id)}/verify`, {
76
+ method: "POST", auth: sk, idempotent: true, retry: true, body: { code: "000000" },
77
+ }).then((r) => r.data).catch((err) => fail("Verify the test user", err));
78
+ if (!verified.verified || verified.authorized === false)
79
+ fail("Verify the test user", new CliError("not_verified", "The sandbox code was not accepted.", EXIT.error));
80
+ done("Verify the test user", "000000 accepted");
81
+ }
82
+ else if (user.next !== "connected") {
83
+ fail("Provision a test user", new CliError("not_connected", `This test user needs ${user.next} first.`, EXIT.error));
84
+ }
85
+ next("Minting a build grant");
86
+ const grant = await api.request(`/v1/users/${encodeURIComponent(user.id)}/grant`, {
87
+ method: "POST", auth: sk, idempotent: true, retry: true, body: {},
88
+ }).then((r) => r.data).catch((err) => fail("Mint a build grant", err));
89
+ done("Mint a build grant", `expires ${grant.expiresAt}`);
90
+ next(`Building a cart at ${place}`);
91
+ const started = await api.request("/v1/carts", {
92
+ method: "POST", auth: grant.token, idempotent: true, retry: true,
93
+ body: { placeId: place, items: ["latte", "blueberry muffin"], idempotencyKey: `${run}-cart` },
94
+ }).then((r) => r.data.cart).catch((err) => fail("Build a cart", err));
95
+ const readCart = () => api.request(`/v1/carts/${encodeURIComponent(started.id)}`, { auth: grant.token }).then((r) => r.data.cart);
96
+ const cart = await until("Build a cart", readCart, (c) => c.status !== "building", 90_000);
97
+ if (cart.status === "failed") {
98
+ const code = cart.failure?.code ?? "UNKNOWN";
99
+ if (expected === "failed" && place === "test_place_closed") {
100
+ 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;
103
+ }
104
+ fail("Build a cart", new CliError("cart_failed", `The cart failed with ${code}.`, EXIT.error));
105
+ }
106
+ if (cart.status !== "ready")
107
+ fail("Build a cart", new CliError("cart_not_ready", `The cart is ${cart.status}, not ready.`, EXIT.error));
108
+ done("Build a cart", `${started.id} ready, ${money(cart.totalMinor)}`);
109
+ next("Confirming (simulated)");
110
+ const confirm = (total, attempt) => api.request(`/v1/carts/${encodeURIComponent(started.id)}/confirm`, { method: "POST", auth: grant.token, idempotent: true, retry: true, body: { expectedTotalMinor: total, idempotencyKey: `${run}-confirm-${attempt}` } }).then((r) => r.data.order);
111
+ let order = await confirm(cart.totalMinor, 1).catch(async (err) => {
112
+ if (err instanceof CliError && err.code === "price_changed") {
113
+ const moved = await readCart().catch((e) => fail("Confirm", e));
114
+ return confirm(moved.totalMinor, 2).catch((e) => fail("Confirm", e));
115
+ }
116
+ return fail("Confirm", err);
117
+ });
118
+ done("Confirm", `${order.id} ${order.status}${order.simulated ? ", simulated" : ""}`);
119
+ next("Waiting for the outcome");
120
+ const final = await until("Wait for the outcome", () => api.request(`/v1/orders/${encodeURIComponent(order.id)}`, { auth: sk }).then((r) => r.data), (o) => TERMINAL.has(o.state), 90_000);
121
+ order = { ...order, status: final.state };
122
+ const okOutcome = final.state === expected;
123
+ if (!okOutcome)
124
+ fail("Wait for the outcome", new CliError("unexpected_outcome", `The order ended ${final.state}; ${place} scripts ${expected}.`, EXIT.error));
125
+ 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;
128
+ }
package/dist/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { usageError } from "./errors.js";
5
+ export const DEFAULT_API_BASE = "https://api.layout.link";
6
+ export const DOCS_BASE = "https://developer.layout.link";
7
+ export function apiBase(env) {
8
+ const raw = (env.LAYOUT_API_BASE ?? DEFAULT_API_BASE).trim().replace(/\/+$/, "");
9
+ let url;
10
+ try {
11
+ url = new URL(raw);
12
+ }
13
+ catch {
14
+ throw usageError("LAYOUT_API_BASE is not a URL.");
15
+ }
16
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
17
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
18
+ throw usageError("LAYOUT_API_BASE must be https, or http on localhost.");
19
+ }
20
+ return `${url.protocol}//${url.host}${url.pathname === "/" ? "" : url.pathname}`;
21
+ }
22
+ export function configDir(env) {
23
+ const xdg = env.XDG_CONFIG_HOME?.trim();
24
+ return join(xdg ? xdg : join(env.HOME ?? homedir(), ".config"), "layout");
25
+ }
26
+ export function ensurePrivateDir(dir) {
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ }
30
+ export function readJson(file) {
31
+ try {
32
+ return JSON.parse(readFileSync(file, "utf8"));
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ export function writePrivateJson(file, value) {
39
+ ensurePrivateDir(dirname(file));
40
+ const tmp = `${file}.${process.pid}.tmp`;
41
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
42
+ chmodSync(tmp, 0o600);
43
+ renameSync(tmp, file);
44
+ }
45
+ const configFile = (env) => join(configDir(env), "config.json");
46
+ export function hostKey(base) {
47
+ return base.replace(/^https?:\/\//, "");
48
+ }
49
+ export function readSession(env, base) {
50
+ return readJson(configFile(env))?.sessions?.[hostKey(base)] ?? null;
51
+ }
52
+ export function writeSession(env, base, record) {
53
+ const cfg = readJson(configFile(env)) ?? {};
54
+ const sessions = { ...(cfg.sessions ?? {}) };
55
+ if (record)
56
+ sessions[hostKey(base)] = record;
57
+ else
58
+ delete sessions[hostKey(base)];
59
+ writePrivateJson(configFile(env), { ...cfg, sessions });
60
+ }