@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 +53 -0
- package/dist/bin.js +29 -0
- package/dist/commands/listen.js +96 -0
- package/dist/commands/resources.js +161 -0
- package/dist/commands/session.js +129 -0
- package/dist/commands/test-order.js +128 -0
- package/dist/config.js +60 -0
- package/dist/context.js +66 -0
- package/dist/credentials.js +109 -0
- package/dist/errors.js +44 -0
- package/dist/http.js +85 -0
- package/dist/listen.js +89 -0
- package/dist/main.js +126 -0
- package/dist/output.js +113 -0
- package/dist/version.js +1 -0
- package/package.json +29 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Api, defaultHttpDeps } from "./http.js";
|
|
2
|
+
import { Output } from "./output.js";
|
|
3
|
+
import { apiBase, hostKey, readSession, writeSession } from "./config.js";
|
|
4
|
+
import { chooseStore, spawnRunner, storeOfKind } from "./credentials.js";
|
|
5
|
+
import { CliError, EXIT, usageError } from "./errors.js";
|
|
6
|
+
import { str } from "./args.js";
|
|
7
|
+
export function makeCtx(p, args) {
|
|
8
|
+
const env = p.streams.env;
|
|
9
|
+
const base = apiBase(env);
|
|
10
|
+
return { p, args, out: new Output(p.streams, args.flags.json === true), env, base, api: new Api(base, p.http) };
|
|
11
|
+
}
|
|
12
|
+
export function defaultPlatform(streams) {
|
|
13
|
+
return {
|
|
14
|
+
streams,
|
|
15
|
+
platform: process.platform,
|
|
16
|
+
run: spawnRunner,
|
|
17
|
+
http: defaultHttpDeps(),
|
|
18
|
+
openUrl: () => false,
|
|
19
|
+
hostname: () => "",
|
|
20
|
+
onInterrupt: (fn) => {
|
|
21
|
+
process.once("SIGINT", fn);
|
|
22
|
+
return () => process.off("SIGINT", fn);
|
|
23
|
+
},
|
|
24
|
+
now: () => Date.now(),
|
|
25
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function signedIn(ctx) {
|
|
29
|
+
const record = readSession(ctx.env, ctx.base);
|
|
30
|
+
if (!record)
|
|
31
|
+
throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
|
|
32
|
+
const token = storeOfKind(record.store, ctx.env, ctx.p.run).get(hostKey(ctx.base));
|
|
33
|
+
if (!token)
|
|
34
|
+
throw new CliError("not_signed_in", "Not signed in. Run layout login.", EXIT.auth);
|
|
35
|
+
return { token, record };
|
|
36
|
+
}
|
|
37
|
+
export function forget(ctx, record) {
|
|
38
|
+
if (record)
|
|
39
|
+
storeOfKind(record.store, ctx.env, ctx.p.run).remove(hostKey(ctx.base));
|
|
40
|
+
writeSession(ctx.env, ctx.base, null);
|
|
41
|
+
}
|
|
42
|
+
export async function authed(ctx, path, opts = {}) {
|
|
43
|
+
const s = signedIn(ctx);
|
|
44
|
+
try {
|
|
45
|
+
return (await ctx.api.request(path, { ...opts, auth: s.token })).data;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof CliError && err.exit === EXIT.auth) {
|
|
49
|
+
forget(ctx, s.record);
|
|
50
|
+
throw new CliError(err.code, `${err.message.replace(/\s*Sign in with layout login\.?$/, "")} Run layout login.`.trim(), EXIT.auth);
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function pickApp(ctx) {
|
|
56
|
+
const chosen = str(ctx.args, "app") ?? ctx.env.LAYOUT_APP?.trim();
|
|
57
|
+
if (chosen)
|
|
58
|
+
return chosen;
|
|
59
|
+
const { apps } = await authed(ctx, "/v1/cli/apps");
|
|
60
|
+
if (apps.length === 1)
|
|
61
|
+
return apps[0].id;
|
|
62
|
+
if (apps.length === 0)
|
|
63
|
+
throw new CliError("no_apps", "This account has no applications. Create one with layout apps create <name>.", EXIT.notFound);
|
|
64
|
+
throw usageError(`This account has ${apps.length} applications. Pick one with --app:\n${apps.map((a) => ` ${a.id} ${a.name}`).join("\n")}`);
|
|
65
|
+
}
|
|
66
|
+
export { chooseStore };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configDir, readJson, writePrivateJson } from "./config.js";
|
|
5
|
+
export const spawnRunner = (cmd, args, input) => {
|
|
6
|
+
const r = spawnSync(cmd, args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 15_000 });
|
|
7
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
|
|
8
|
+
};
|
|
9
|
+
const SERVICE = "layout-cli";
|
|
10
|
+
const SAFE = /^[A-Za-z0-9_.:\-[\]]+$/;
|
|
11
|
+
const TOKEN = /^lcli_[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
export function keychainStore(run) {
|
|
13
|
+
return {
|
|
14
|
+
kind: "keychain",
|
|
15
|
+
get(account) {
|
|
16
|
+
if (!SAFE.test(account))
|
|
17
|
+
return null;
|
|
18
|
+
const r = run("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"]);
|
|
19
|
+
const v = r.stdout.trim();
|
|
20
|
+
return r.status === 0 && TOKEN.test(v) ? v : null;
|
|
21
|
+
},
|
|
22
|
+
set(account, token) {
|
|
23
|
+
if (!SAFE.test(account) || !TOKEN.test(token))
|
|
24
|
+
return false;
|
|
25
|
+
const r = run("security", ["-i"], `add-generic-password -U -a ${account} -s ${SERVICE} -l "Layout CLI" -w ${token}\n`);
|
|
26
|
+
return r.status === 0 && this.get(account) === token;
|
|
27
|
+
},
|
|
28
|
+
remove(account) {
|
|
29
|
+
if (SAFE.test(account))
|
|
30
|
+
run("security", ["delete-generic-password", "-a", account, "-s", SERVICE]);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function secretToolStore(run) {
|
|
35
|
+
return {
|
|
36
|
+
kind: "secret-tool",
|
|
37
|
+
get(account) {
|
|
38
|
+
const r = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
|
|
39
|
+
const v = r.stdout.trim();
|
|
40
|
+
return r.status === 0 && TOKEN.test(v) ? v : null;
|
|
41
|
+
},
|
|
42
|
+
set(account, token) {
|
|
43
|
+
if (!TOKEN.test(token))
|
|
44
|
+
return false;
|
|
45
|
+
const r = run("secret-tool", ["store", "--label=Layout CLI", "service", SERVICE, "account", account], token);
|
|
46
|
+
return r.status === 0 && this.get(account) === token;
|
|
47
|
+
},
|
|
48
|
+
remove(account) {
|
|
49
|
+
run("secret-tool", ["clear", "service", SERVICE, "account", account]);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function fileStore(env) {
|
|
54
|
+
const path = join(configDir(env), "credentials.json");
|
|
55
|
+
const read = () => readJson(path) ?? {};
|
|
56
|
+
return {
|
|
57
|
+
kind: "file",
|
|
58
|
+
path,
|
|
59
|
+
get(account) {
|
|
60
|
+
try {
|
|
61
|
+
if ((statSync(path).mode & 0o077) !== 0)
|
|
62
|
+
chmodSync(path, 0o600);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const v = read()[account];
|
|
68
|
+
return typeof v === "string" && TOKEN.test(v) ? v : null;
|
|
69
|
+
},
|
|
70
|
+
set(account, token) {
|
|
71
|
+
if (!TOKEN.test(token))
|
|
72
|
+
return false;
|
|
73
|
+
writePrivateJson(path, { ...read(), [account]: token });
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
remove(account) {
|
|
77
|
+
const all = read();
|
|
78
|
+
if (!(account in all))
|
|
79
|
+
return;
|
|
80
|
+
delete all[account];
|
|
81
|
+
writePrivateJson(path, all);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function available(run, cmd, args) {
|
|
86
|
+
return run(cmd, args).status !== null;
|
|
87
|
+
}
|
|
88
|
+
export function chooseStore(env, platform, run = spawnRunner) {
|
|
89
|
+
const forced = env.LAYOUT_CREDENTIAL_STORE;
|
|
90
|
+
const file = fileStore(env);
|
|
91
|
+
if (forced === "file")
|
|
92
|
+
return { store: file, warning: null };
|
|
93
|
+
if (forced === "keychain")
|
|
94
|
+
return { store: keychainStore(run), warning: null };
|
|
95
|
+
if (forced === "secret-tool")
|
|
96
|
+
return { store: secretToolStore(run), warning: null };
|
|
97
|
+
if (platform === "darwin" && available(run, "security", ["help"]))
|
|
98
|
+
return { store: keychainStore(run), warning: null };
|
|
99
|
+
if (platform === "linux" && available(run, "secret-tool", ["--version"]))
|
|
100
|
+
return { store: secretToolStore(run), warning: null };
|
|
101
|
+
return { store: file, warning: `No OS keychain found, so the CLI token is stored in ${file.path} (readable only by you).` };
|
|
102
|
+
}
|
|
103
|
+
export function storeOfKind(kind, env, run = spawnRunner) {
|
|
104
|
+
if (kind === "keychain")
|
|
105
|
+
return keychainStore(run);
|
|
106
|
+
if (kind === "secret-tool")
|
|
107
|
+
return secretToolStore(run);
|
|
108
|
+
return fileStore(env);
|
|
109
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
ok: 0,
|
|
3
|
+
error: 1,
|
|
4
|
+
usage: 2,
|
|
5
|
+
auth: 3,
|
|
6
|
+
refused: 4,
|
|
7
|
+
notFound: 5,
|
|
8
|
+
rateLimited: 6,
|
|
9
|
+
unavailable: 7,
|
|
10
|
+
conflict: 8,
|
|
11
|
+
interrupted: 130,
|
|
12
|
+
};
|
|
13
|
+
export class CliError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
exit;
|
|
16
|
+
detail;
|
|
17
|
+
constructor(code, message, exit = EXIT.error, detail = {}) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.exit = exit;
|
|
21
|
+
this.detail = detail;
|
|
22
|
+
this.name = "CliError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function usageError(message) {
|
|
26
|
+
return new CliError("usage", message, EXIT.usage);
|
|
27
|
+
}
|
|
28
|
+
export function exitForStatus(status) {
|
|
29
|
+
if (status === 400 || status === 422)
|
|
30
|
+
return EXIT.usage;
|
|
31
|
+
if (status === 401)
|
|
32
|
+
return EXIT.auth;
|
|
33
|
+
if (status === 403)
|
|
34
|
+
return EXIT.refused;
|
|
35
|
+
if (status === 404)
|
|
36
|
+
return EXIT.notFound;
|
|
37
|
+
if (status === 409)
|
|
38
|
+
return EXIT.conflict;
|
|
39
|
+
if (status === 429)
|
|
40
|
+
return EXIT.rateLimited;
|
|
41
|
+
if (status === 0 || status >= 500)
|
|
42
|
+
return EXIT.unavailable;
|
|
43
|
+
return EXIT.error;
|
|
44
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CliError, EXIT, exitForStatus } from "./errors.js";
|
|
3
|
+
import { VERSION } from "./version.js";
|
|
4
|
+
export const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
export const defaultHttpDeps = () => ({ fetch: globalThis.fetch, sleep: realSleep, attempts: 4 });
|
|
6
|
+
export function userAgent() {
|
|
7
|
+
return `layout-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
|
|
8
|
+
}
|
|
9
|
+
function backoffMs(attempt, retryAfter) {
|
|
10
|
+
const hinted = retryAfter ? Number(retryAfter) : NaN;
|
|
11
|
+
if (Number.isFinite(hinted) && hinted >= 0)
|
|
12
|
+
return Math.min(hinted, 10) * 1000;
|
|
13
|
+
return Math.min(500 * 2 ** attempt, 8_000) + Math.floor(Math.random() * 250);
|
|
14
|
+
}
|
|
15
|
+
function errorFrom(status, data) {
|
|
16
|
+
const body = (data ?? {});
|
|
17
|
+
if (body.error && typeof body.error === "object") {
|
|
18
|
+
const e = body.error;
|
|
19
|
+
const { code: _c, message: _m, ...rest } = e;
|
|
20
|
+
return new CliError(String(e.code ?? "error"), String(e.message ?? `Layout answered ${status}.`), exitForStatus(status), rest);
|
|
21
|
+
}
|
|
22
|
+
if (typeof body.error === "string") {
|
|
23
|
+
return new CliError(body.error, String(body.error_description ?? `Layout answered ${status}.`), exitForStatus(status));
|
|
24
|
+
}
|
|
25
|
+
return new CliError(status >= 500 ? "unavailable" : "error", `Layout answered ${status}.`, exitForStatus(status));
|
|
26
|
+
}
|
|
27
|
+
export class Api {
|
|
28
|
+
base;
|
|
29
|
+
deps;
|
|
30
|
+
constructor(base, deps = defaultHttpDeps()) {
|
|
31
|
+
this.base = base;
|
|
32
|
+
this.deps = deps;
|
|
33
|
+
}
|
|
34
|
+
async request(path, opts = {}) {
|
|
35
|
+
const method = opts.method ?? "GET";
|
|
36
|
+
const retry = opts.retry ?? method === "GET";
|
|
37
|
+
const idemKey = method === "POST" && opts.idempotent ? randomUUID() : null;
|
|
38
|
+
const headers = { accept: "application/json", "user-agent": userAgent() };
|
|
39
|
+
if (opts.auth)
|
|
40
|
+
headers.authorization = `Bearer ${opts.auth}`;
|
|
41
|
+
if (opts.body !== undefined)
|
|
42
|
+
headers["content-type"] = "application/json";
|
|
43
|
+
if (idemKey)
|
|
44
|
+
headers["idempotency-key"] = idemKey;
|
|
45
|
+
const attempts = retry ? Math.max(1, this.deps.attempts) : 1;
|
|
46
|
+
let last = null;
|
|
47
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await this.deps.fetch(`${this.base}${path}`, {
|
|
51
|
+
method,
|
|
52
|
+
headers,
|
|
53
|
+
...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
|
|
54
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
|
|
55
|
+
redirect: "error",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
last = new CliError("network", `Could not reach ${new URL(this.base).host}. Check your connection and retry.`, EXIT.unavailable);
|
|
60
|
+
if (attempt + 1 < attempts)
|
|
61
|
+
await this.deps.sleep(backoffMs(attempt, null));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let data = null;
|
|
66
|
+
try {
|
|
67
|
+
data = text ? JSON.parse(text) : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
data = null;
|
|
71
|
+
}
|
|
72
|
+
if (res.ok || opts.okStatuses?.includes(res.status))
|
|
73
|
+
return { status: res.status, data: data, headers: res.headers };
|
|
74
|
+
last = errorFrom(res.status, data);
|
|
75
|
+
const transient = res.status === 503 || res.status === 502 || res.status === 504 || (res.status === 429 && method === "GET")
|
|
76
|
+
|| (idemKey !== null && res.status === 409 && last.code === "in_progress");
|
|
77
|
+
if (transient && attempt + 1 < attempts) {
|
|
78
|
+
await this.deps.sleep(backoffMs(attempt, res.headers.get("retry-after")));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
throw last;
|
|
82
|
+
}
|
|
83
|
+
throw last ?? new CliError("network", "Layout could not be reached.", EXIT.unavailable);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/listen.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { CliError, EXIT } from "./errors.js";
|
|
3
|
+
export function mintLocalSecret() {
|
|
4
|
+
return `whsec_cli_${randomBytes(24).toString("hex")}`;
|
|
5
|
+
}
|
|
6
|
+
export function signWebhook(secret, timestampSec, rawBody) {
|
|
7
|
+
return createHmac("sha256", secret).update(`${timestampSec}.${rawBody}`, "utf8").digest("hex");
|
|
8
|
+
}
|
|
9
|
+
export function signatureHeader(secret, timestampSec, rawBody) {
|
|
10
|
+
return `t=${timestampSec},v1=${signWebhook(secret, timestampSec, rawBody)}`;
|
|
11
|
+
}
|
|
12
|
+
function field(payload, key) {
|
|
13
|
+
const v = payload && typeof payload === "object" ? payload[key] : undefined;
|
|
14
|
+
return typeof v === "string" ? v : null;
|
|
15
|
+
}
|
|
16
|
+
export function forwardHeaders(secret, ev, body, nowSec) {
|
|
17
|
+
return {
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
"user-agent": "Layout-Webhooks/1 (layout-cli)",
|
|
20
|
+
"x-layout-signature": signatureHeader(secret, nowSec, body),
|
|
21
|
+
"x-layout-event": ev.event,
|
|
22
|
+
"x-layout-delivery": field(ev.payload, "delivery_id") ?? ev.cursor,
|
|
23
|
+
"x-layout-environment": "sandbox",
|
|
24
|
+
"x-layout-forwarded-by": "layout-cli",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const backoff = (attempt, max) => Math.min(1_000 * 2 ** attempt, max);
|
|
28
|
+
export async function runListener(start, opts, deps) {
|
|
29
|
+
let cursor = start;
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
let feedFailures = 0;
|
|
32
|
+
while (!deps.stopped()) {
|
|
33
|
+
let page;
|
|
34
|
+
try {
|
|
35
|
+
page = await deps.fetchPage(cursor);
|
|
36
|
+
feedFailures = 0;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof CliError && (err.exit === EXIT.auth || err.exit === EXIT.refused || err.exit === EXIT.notFound || err.exit === EXIT.usage))
|
|
40
|
+
throw err;
|
|
41
|
+
const wait = backoff(feedFailures++, opts.maxBackoffMs);
|
|
42
|
+
deps.report({ kind: "feed_unavailable", retryInMs: wait });
|
|
43
|
+
await deps.sleep(wait);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const ev of page.events) {
|
|
47
|
+
if (deps.stopped())
|
|
48
|
+
return cursor;
|
|
49
|
+
if (seen.has(ev.cursor)) {
|
|
50
|
+
cursor = ev.cursor;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (opts.events && !opts.events.has(ev.event)) {
|
|
54
|
+
seen.add(ev.cursor);
|
|
55
|
+
cursor = ev.cursor;
|
|
56
|
+
deps.saveCursor(cursor);
|
|
57
|
+
deps.report({ kind: "skipped", cursor, event: ev.event });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const body = JSON.stringify(ev.payload);
|
|
61
|
+
let attempts = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (deps.stopped())
|
|
64
|
+
return cursor;
|
|
65
|
+
attempts++;
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await deps.forward(opts.forwardTo, body, forwardHeaders(opts.secret, ev, body, deps.nowSec()));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
res = { ok: false, status: 0 };
|
|
72
|
+
}
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
seen.add(ev.cursor);
|
|
75
|
+
cursor = ev.cursor;
|
|
76
|
+
deps.saveCursor(cursor);
|
|
77
|
+
deps.report({ kind: "forwarded", cursor, event: ev.event, eventId: field(ev.payload, "event_id"), status: res.status, attempts });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const wait = backoff(attempts - 1, opts.maxBackoffMs);
|
|
81
|
+
deps.report({ kind: "forward_failed", event: ev.event, status: res.status, retryInMs: wait });
|
|
82
|
+
await deps.sleep(wait);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!page.nextCursor)
|
|
86
|
+
await deps.sleep(opts.pollMs);
|
|
87
|
+
}
|
|
88
|
+
return cursor;
|
|
89
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { parseArgs } from "./args.js";
|
|
2
|
+
import { CliError, EXIT, usageError } from "./errors.js";
|
|
3
|
+
import { makeCtx } from "./context.js";
|
|
4
|
+
import { Output } from "./output.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
import { login, logout, whoami } from "./commands/session.js";
|
|
7
|
+
import { appsCreate, appsList, deliveries, docs, keysCreate, keysList, keysRevoke, requestLiveAccess, trigger, usage, webhooksList, } from "./commands/resources.js";
|
|
8
|
+
import { listen } from "./commands/listen.js";
|
|
9
|
+
import { testOrder } from "./commands/test-order.js";
|
|
10
|
+
const s = (name) => ({ name, type: "string" });
|
|
11
|
+
const b = (name) => ({ name, type: "boolean" });
|
|
12
|
+
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 },
|
|
15
|
+
{ path: ["logout"], usage: "layout logout", summary: "Revoke this CLI session and forget it here.", flags: [], run: logout },
|
|
16
|
+
{ path: ["whoami"], usage: "layout whoami", summary: "The developer account and this CLI session.", flags: [], run: whoami },
|
|
17
|
+
{ path: ["apps", "list"], usage: "layout apps list", summary: "Your applications.", flags: [], run: appsList },
|
|
18
|
+
{ path: ["apps", "create"], usage: "layout apps create <name>", summary: "A new application. Prints its sandbox key once.", flags: [], run: appsCreate },
|
|
19
|
+
{ path: ["keys", "list"], usage: "layout keys list", summary: "The application's sandbox key.", flags: [], run: keysList },
|
|
20
|
+
{ path: ["keys", "create"], usage: "layout keys create [--replace]", summary: "A new sandbox key, printed once.", flags: [b("replace")],
|
|
21
|
+
flagHelp: [["--replace", "Retire the current sandbox key and make another."]], run: keysCreate },
|
|
22
|
+
{ path: ["keys", "revoke"], usage: "layout keys revoke --yes", summary: "Revoke the sandbox key and its sandbox build grants.", flags: [b("yes")],
|
|
23
|
+
flagHelp: [["--yes", "Confirm. Nothing is revoked without it."]], run: keysRevoke },
|
|
24
|
+
{ path: ["webhooks", "list"], usage: "layout webhooks list", summary: "Endpoints (managed in the console) and the sandbox signing secret.", flags: [], run: webhooksList },
|
|
25
|
+
{ path: ["listen"], usage: "layout listen --forward-to <url> [--events <a,b>] [--from-start]", summary: "Forward sandbox events to a local URL.", flags: [s("forward-to"), s("events"), b("from-start")],
|
|
26
|
+
flagHelp: [["--forward-to <url>", "Where to POST each event, signed with a secret made for this run."], ["--events <a,b>", "Only these events."], ["--from-start", "Forward the whole feed, not only what happens from now."]], run: listen },
|
|
27
|
+
{ path: ["trigger"], usage: "layout trigger <event> [--order <ord_id>]", summary: "Fire a signed test event at your endpoints.", flags: [s("order")], run: trigger },
|
|
28
|
+
{ path: ["usage"], usage: "layout usage", summary: "Today's allowances and counts.", flags: [], run: usage },
|
|
29
|
+
{ path: ["deliveries"], usage: "layout deliveries [--limit <n>]", summary: "Recent sandbox webhook deliveries.", flags: [s("limit")], run: deliveries },
|
|
30
|
+
{ 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
|
+
flagHelp: [["--place <id>", "A sandbox test restaurant. Default: test_place_placed."]], run: testOrder },
|
|
32
|
+
{ path: ["request-live-access"], usage: "layout request-live-access [--note <text>]", summary: "Ask Layout to review production access.", flags: [s("note")], run: requestLiveAccess },
|
|
33
|
+
{ path: ["docs"], usage: "layout docs [cli|quickstart|sandbox|webhooks|events|api]", summary: "Open the docs.", flags: [], run: docs },
|
|
34
|
+
];
|
|
35
|
+
const GLOBAL_HELP = [
|
|
36
|
+
["--json", "Machine output: one JSON object on stdout, errors as JSON on stderr."],
|
|
37
|
+
["--app <id>", "The application (or LAYOUT_APP). With one application it is picked for you."],
|
|
38
|
+
["--help", "Help for any command."],
|
|
39
|
+
["--version", "The CLI version."],
|
|
40
|
+
];
|
|
41
|
+
function find(positionals) {
|
|
42
|
+
let best = null;
|
|
43
|
+
for (const c of COMMANDS) {
|
|
44
|
+
if (c.path.every((part, i) => positionals[i] === part) && (!best || c.path.length > best.path.length))
|
|
45
|
+
best = c;
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
function group(positionals) {
|
|
50
|
+
return positionals[0] ? COMMANDS.filter((c) => c.path[0] === positionals[0] && c.path.length > 1) : [];
|
|
51
|
+
}
|
|
52
|
+
function pad(rows) {
|
|
53
|
+
const w = Math.max(...rows.map(([a]) => a.length));
|
|
54
|
+
return rows.map(([a, d]) => ` ${a.padEnd(w)} ${d}`);
|
|
55
|
+
}
|
|
56
|
+
function help(o, cmd, sub) {
|
|
57
|
+
if (cmd) {
|
|
58
|
+
o.line(o.wordmark());
|
|
59
|
+
o.line();
|
|
60
|
+
o.line(`${cmd.summary}`);
|
|
61
|
+
o.line();
|
|
62
|
+
o.line(`Usage: ${cmd.usage}`);
|
|
63
|
+
o.line();
|
|
64
|
+
if (cmd.flagHelp?.length) {
|
|
65
|
+
o.line("Flags:");
|
|
66
|
+
for (const l of pad(cmd.flagHelp))
|
|
67
|
+
o.line(l);
|
|
68
|
+
o.line();
|
|
69
|
+
}
|
|
70
|
+
o.line("Global flags:");
|
|
71
|
+
for (const l of pad([...GLOBAL_HELP]))
|
|
72
|
+
o.line(l);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const list = sub.length ? sub : COMMANDS;
|
|
76
|
+
o.line(o.wordmark());
|
|
77
|
+
o.line();
|
|
78
|
+
o.line("Sign in, then build against the Layout sandbox. Never touches production.");
|
|
79
|
+
o.line();
|
|
80
|
+
o.line("Commands:");
|
|
81
|
+
for (const l of pad(list.map((c) => [c.path.join(" "), c.summary])))
|
|
82
|
+
o.line(l);
|
|
83
|
+
o.line();
|
|
84
|
+
o.line("Global flags:");
|
|
85
|
+
for (const l of pad([...GLOBAL_HELP]))
|
|
86
|
+
o.line(l);
|
|
87
|
+
o.line();
|
|
88
|
+
o.line("Docs: https://developer.layout.link/reference/cli");
|
|
89
|
+
}
|
|
90
|
+
export async function main(argv, p) {
|
|
91
|
+
const json = argv.includes("--json");
|
|
92
|
+
const fallback = new Output(p.streams, json);
|
|
93
|
+
try {
|
|
94
|
+
const loose = parseArgs(argv, COMMANDS.flatMap((c) => c.flags));
|
|
95
|
+
const positionals = loose.positionals;
|
|
96
|
+
const cmd = find(positionals);
|
|
97
|
+
const sub = cmd ? [] : group(positionals);
|
|
98
|
+
const args = cmd ? parseArgs(argv, cmd.flags) : loose;
|
|
99
|
+
if (args.flags.version) {
|
|
100
|
+
fallback.result({ version: VERSION }, () => fallback.line(VERSION));
|
|
101
|
+
return EXIT.ok;
|
|
102
|
+
}
|
|
103
|
+
if (args.flags.help || !cmd) {
|
|
104
|
+
if (json) {
|
|
105
|
+
fallback.data({ commands: (sub.length ? sub : cmd ? [cmd] : COMMANDS).map((c) => ({ command: c.path.join(" "), usage: c.usage, summary: c.summary })) });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
help(new Output({ ...p.streams, out: p.streams.out }, false), cmd, sub);
|
|
109
|
+
}
|
|
110
|
+
if (!cmd && sub.length === 0 && positionals.length > 0 && !args.flags.help) {
|
|
111
|
+
throw usageError(`Unknown command: ${positionals.join(" ")}.`);
|
|
112
|
+
}
|
|
113
|
+
return EXIT.ok;
|
|
114
|
+
}
|
|
115
|
+
const extra = args.positionals.slice(cmd.path.length);
|
|
116
|
+
const takesArgs = ["apps create", "trigger", "docs"].includes(cmd.path.join(" "));
|
|
117
|
+
if (!takesArgs && extra.length > 0)
|
|
118
|
+
throw usageError(`Unexpected argument: ${extra[0]}. Run layout ${cmd.path.join(" ")} --help.`);
|
|
119
|
+
return await cmd.run(makeCtx(p, args));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const e = err instanceof CliError ? err : new CliError("error", err instanceof Error ? err.message : String(err), EXIT.error);
|
|
123
|
+
fallback.error(e);
|
|
124
|
+
return e.exit;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const BLUE = { truecolor: "\x1b[38;2;40;107;254m", "256": "\x1b[38;5;27m", basic: "\x1b[34m" };
|
|
2
|
+
const BOLD = "\x1b[1m";
|
|
3
|
+
const RESET = "\x1b[0m";
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
export function colorDepth(stream, env) {
|
|
6
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
|
|
7
|
+
return "none";
|
|
8
|
+
if (!stream.isTTY)
|
|
9
|
+
return "none";
|
|
10
|
+
if (env.TERM === "dumb")
|
|
11
|
+
return "none";
|
|
12
|
+
const ct = (env.COLORTERM ?? "").toLowerCase();
|
|
13
|
+
if (ct === "truecolor" || ct === "24bit")
|
|
14
|
+
return "truecolor";
|
|
15
|
+
if ((env.TERM ?? "").includes("256"))
|
|
16
|
+
return "256";
|
|
17
|
+
return "basic";
|
|
18
|
+
}
|
|
19
|
+
export class Output {
|
|
20
|
+
streams;
|
|
21
|
+
json;
|
|
22
|
+
depth;
|
|
23
|
+
errDepth;
|
|
24
|
+
constructor(streams, json) {
|
|
25
|
+
this.streams = streams;
|
|
26
|
+
this.json = json;
|
|
27
|
+
this.depth = colorDepth(streams.out, streams.env);
|
|
28
|
+
this.errDepth = colorDepth(streams.err, streams.env);
|
|
29
|
+
}
|
|
30
|
+
accent(text, stream = "out") {
|
|
31
|
+
const d = stream === "out" ? this.depth : this.errDepth;
|
|
32
|
+
return d === "none" ? text : `${BLUE[d]}${text}${RESET}`;
|
|
33
|
+
}
|
|
34
|
+
bold(text) {
|
|
35
|
+
return this.depth === "none" ? text : `${BOLD}${text}${RESET}`;
|
|
36
|
+
}
|
|
37
|
+
wordmark() {
|
|
38
|
+
return this.depth === "none" ? "Layout" : `${BOLD}${BLUE[this.depth]}Layout${RESET}`;
|
|
39
|
+
}
|
|
40
|
+
line(text = "") {
|
|
41
|
+
if (!this.json)
|
|
42
|
+
this.streams.out.write(`${text}\n`);
|
|
43
|
+
}
|
|
44
|
+
note(text) {
|
|
45
|
+
this.streams.err.write(`${text}\n`);
|
|
46
|
+
}
|
|
47
|
+
data(value) {
|
|
48
|
+
this.streams.out.write(`${JSON.stringify(value)}\n`);
|
|
49
|
+
}
|
|
50
|
+
result(value, human) {
|
|
51
|
+
if (this.json)
|
|
52
|
+
this.data(value);
|
|
53
|
+
else
|
|
54
|
+
human();
|
|
55
|
+
}
|
|
56
|
+
table(rows, header) {
|
|
57
|
+
if (this.json)
|
|
58
|
+
return;
|
|
59
|
+
const all = header ? [header, ...rows] : rows;
|
|
60
|
+
const widths = [];
|
|
61
|
+
for (const r of all)
|
|
62
|
+
r.forEach((c, i) => { widths[i] = Math.max(widths[i] ?? 0, Array.from(c).length); });
|
|
63
|
+
all.forEach((r, idx) => {
|
|
64
|
+
const text = r.map((c, i) => (i === r.length - 1 ? c : c + " ".repeat((widths[i] ?? 0) - Array.from(c).length))).join(" ");
|
|
65
|
+
this.line(header && idx === 0 ? this.bold(text) : text);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
error(err) {
|
|
69
|
+
if (this.json) {
|
|
70
|
+
this.streams.err.write(`${JSON.stringify({ error: { code: err.code, message: err.message, ...err.detail } })}\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.streams.err.write(`${err.message}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
spinner(text) {
|
|
77
|
+
const live = !this.json && Boolean(this.streams.err.isTTY) && this.errDepth !== "none";
|
|
78
|
+
return new Spinner(this, text, live);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class Spinner {
|
|
82
|
+
o;
|
|
83
|
+
text;
|
|
84
|
+
live;
|
|
85
|
+
timer = null;
|
|
86
|
+
frame = 0;
|
|
87
|
+
constructor(o, text, live) {
|
|
88
|
+
this.o = o;
|
|
89
|
+
this.text = text;
|
|
90
|
+
this.live = live;
|
|
91
|
+
if (live) {
|
|
92
|
+
this.render();
|
|
93
|
+
this.timer = setInterval(() => this.render(), 80);
|
|
94
|
+
this.timer.unref();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
update(text) {
|
|
98
|
+
this.text = text;
|
|
99
|
+
}
|
|
100
|
+
render() {
|
|
101
|
+
const glyph = this.o.accent(FRAMES[this.frame++ % FRAMES.length], "err");
|
|
102
|
+
this.o.streams.err.write(`\r\x1b[2K${glyph} ${this.text}`);
|
|
103
|
+
}
|
|
104
|
+
stop(final) {
|
|
105
|
+
if (this.timer)
|
|
106
|
+
clearInterval(this.timer);
|
|
107
|
+
this.timer = null;
|
|
108
|
+
if (this.live)
|
|
109
|
+
this.o.streams.err.write("\r\x1b[2K");
|
|
110
|
+
if (final)
|
|
111
|
+
this.o.line(final);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@layoutplatforms/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "The Layout developer CLI: sign in, sandbox apps and keys, forward webhooks, run test orders.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"layout": "dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "echo \"(lint not yet configured)\"",
|
|
20
|
+
"test": "tsx --test src/*.test.ts",
|
|
21
|
+
"prepublishOnly": "pnpm run build && pnpm run test"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://developer.layout.link/reference/cli",
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"author": "Layout, Inc."
|
|
29
|
+
}
|