@domino-sdk/relay-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.
@@ -0,0 +1,114 @@
1
+ import {
2
+ mkdir,
3
+ readFile,
4
+ writeFile,
5
+ chmod,
6
+ rename,
7
+ rm,
8
+ } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+
13
+ export function apiUrl(value) {
14
+ const url = new URL(value);
15
+ if (url.username || url.password || url.search || url.hash)
16
+ throw new Error(
17
+ "API URL must not contain credentials, a query, or a fragment.",
18
+ );
19
+ if (
20
+ url.protocol !== "https:" &&
21
+ !(
22
+ url.protocol === "http:" &&
23
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
24
+ )
25
+ )
26
+ throw new Error(
27
+ "Use HTTPS for remote APIs. HTTP is allowed only on loopback.",
28
+ );
29
+ return url.href.replace(/\/+$/, "");
30
+ }
31
+
32
+ const credentialsPath = () =>
33
+ join(
34
+ process.env.RELAY_CONFIG_DIR ?? join(homedir(), ".config", "domino"),
35
+ "credentials.json",
36
+ );
37
+ async function credentials() {
38
+ try {
39
+ return JSON.parse(await readFile(credentialsPath(), "utf8"));
40
+ } catch (error) {
41
+ if (error.code === "ENOENT") return {};
42
+ throw error;
43
+ }
44
+ }
45
+ export async function savedToken(url) {
46
+ return (await credentials())[url];
47
+ }
48
+ export async function saveToken(url, token) {
49
+ const values = await credentials();
50
+ if (token) values[url] = token;
51
+ else delete values[url];
52
+ const path = credentialsPath();
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ const temporary = `${path}.${randomUUID()}.tmp`;
55
+ try {
56
+ await writeFile(temporary, JSON.stringify(values) + "\n", {
57
+ mode: 0o600,
58
+ flag: "wx",
59
+ });
60
+ await chmod(temporary, 0o600);
61
+ await rename(temporary, path);
62
+ } finally {
63
+ await rm(temporary, { force: true });
64
+ }
65
+ }
66
+
67
+ export async function request(connection, path, method = "GET", body) {
68
+ // Keep the escape hatch inside management/v1, including after URL normalization.
69
+ const base = new URL(`${connection.apiUrl}/management/v1/`);
70
+ const url = new URL(`${connection.apiUrl}/management/v1${path}`);
71
+ if (
72
+ !path.startsWith("/") ||
73
+ path.startsWith("//") ||
74
+ url.origin !== base.origin ||
75
+ !url.pathname.startsWith(base.pathname) ||
76
+ /[\\#]/.test(path) ||
77
+ /%2e|%2f|%5c/i.test(path)
78
+ )
79
+ throw new Error(
80
+ "API path must stay within /management/v1, for example /quests.",
81
+ );
82
+ const token =
83
+ connection.token ??
84
+ process.env.RELAY_MANAGEMENT_TOKEN ??
85
+ (await savedToken(connection.apiUrl));
86
+ if (!token)
87
+ throw new Error(
88
+ "Not logged in. Run domino login, or set RELAY_MANAGEMENT_TOKEN for automation.",
89
+ );
90
+ const headers = new Headers({ Authorization: `Bearer ${token}` });
91
+ for (const key of ["organization", "project", "environment"]) {
92
+ if (connection[key]) headers.set(`X-Relay-${key}`, connection[key]);
93
+ }
94
+ if (body !== undefined) headers.set("Content-Type", "application/json");
95
+ const response = await fetch(url, {
96
+ method,
97
+ headers,
98
+ body: body === undefined ? undefined : JSON.stringify(body),
99
+ redirect: "error",
100
+ signal: AbortSignal.timeout(30_000),
101
+ });
102
+ if (!response.ok) {
103
+ const result = await response.json().catch(() => null);
104
+ const error = new Error(
105
+ typeof result?.error === "string"
106
+ ? result.error
107
+ : `Request failed (${response.status})`,
108
+ );
109
+ error.status = response.status;
110
+ throw error;
111
+ }
112
+ if (response.status === 204) return null;
113
+ return response.json();
114
+ }
package/cli/dev.mjs ADDED
@@ -0,0 +1,231 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { createServer } from "node:http";
4
+ import { watch } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { request } from "./connection.mjs";
9
+ import { bundleProject, bundleCatalog, findProject } from "./project.mjs";
10
+
11
+ async function listen(server, port = 0) {
12
+ await new Promise((resolve, reject) => {
13
+ server.once("error", reject);
14
+ server.listen(port, "127.0.0.1", resolve);
15
+ });
16
+ return server.address().port;
17
+ }
18
+ async function freePort() {
19
+ const server = createServer();
20
+ const port = await listen(server);
21
+ await new Promise((resolve) => server.close(resolve));
22
+ return port;
23
+ }
24
+
25
+ export async function dev(project, options) {
26
+ if (!project?.config.app)
27
+ throw new Error(
28
+ "Configure app scripts in relay.json before running domino dev.",
29
+ );
30
+ const root = dirname(project.path);
31
+ const require = createRequire(join(root, "package.json"));
32
+ const runtime = dirname(
33
+ require.resolve("@domino-sdk/relay-runtime/package.json"),
34
+ );
35
+ const wrangler = join(
36
+ dirname(require.resolve("wrangler/package.json")),
37
+ "bin/wrangler.js",
38
+ );
39
+ const apiPort = await freePort();
40
+ const appPort = Number(options.port ?? (await freePort()));
41
+ if (!Number.isInteger(appPort) || appPort < 1 || appPort > 65535)
42
+ throw new Error("Invalid app port.");
43
+ const origin = `http://127.0.0.1:${appPort}`;
44
+ const connection = {
45
+ apiUrl: `http://127.0.0.1:${apiPort}`,
46
+ organization: "local",
47
+ project: "campaign",
48
+ environment: "test",
49
+ token: randomBytes(32).toString("hex"),
50
+ };
51
+ const children = [];
52
+ let stopping = false;
53
+ let failure;
54
+ let watcher;
55
+ const control = createServer(async (req, res) => {
56
+ res.setHeader("Cache-Control", "no-store");
57
+ if (
58
+ req.method !== "POST" ||
59
+ req.url !== "/__domino/member" ||
60
+ req.headers.origin !== origin ||
61
+ req.headers["x-relay-csrf"] !== "1"
62
+ ) {
63
+ res.writeHead(403).end();
64
+ return;
65
+ }
66
+ req.resume();
67
+ try {
68
+ const response = await fetch(connection.apiUrl + "/v1/auth/exchange", {
69
+ method: "POST",
70
+ headers: {
71
+ Authorization: `Bearer ${connection.token}`,
72
+ "Content-Type": "application/json",
73
+ },
74
+ body: JSON.stringify({
75
+ organization: connection.organization,
76
+ project: connection.project,
77
+ environment: "test",
78
+ identity: { issuer: "local-preview", subject: "preview-member" },
79
+ role: "member",
80
+ }),
81
+ });
82
+ if (!response.ok) throw new Error("Local participant sign-in failed.");
83
+ const session = await response.json();
84
+ res.setHeader(
85
+ "Set-Cookie",
86
+ `relay_session=${session.token}; Path=/; HttpOnly; SameSite=Strict`,
87
+ );
88
+ res.setHeader("Content-Type", "application/json");
89
+ res.end(JSON.stringify({ signedIn: true }));
90
+ } catch {
91
+ res.writeHead(503).end();
92
+ }
93
+ });
94
+ const stop = () => {
95
+ stopping = true;
96
+ watcher?.close();
97
+ control.close();
98
+ for (const child of children) {
99
+ if (!child.pid) continue;
100
+ try {
101
+ if (process.platform === "win32") child.kill("SIGTERM");
102
+ else process.kill(-child.pid, "SIGTERM");
103
+ } catch (error) {
104
+ if (error.code !== "ESRCH") throw error;
105
+ }
106
+ }
107
+ };
108
+ const start = (command, args, extraEnv = {}) => {
109
+ const child = spawn(command, args, {
110
+ cwd: root,
111
+ env: { ...process.env, ...extraEnv },
112
+ stdio: "inherit",
113
+ detached: process.platform !== "win32",
114
+ });
115
+ children.push(child);
116
+ child.on("error", (error) => {
117
+ failure = error;
118
+ stop();
119
+ });
120
+ child.on("exit", (code) => {
121
+ if (!stopping) {
122
+ failure = new Error(`Development process exited (${code}).`);
123
+ stop();
124
+ }
125
+ });
126
+ return child;
127
+ };
128
+ process.once("SIGINT", stop);
129
+ process.once("SIGTERM", stop);
130
+ try {
131
+ const controlPort = await listen(control);
132
+ const vars = {
133
+ LOCAL_DEVELOPMENT: "true",
134
+ TEST_HOOKS: "false",
135
+ API_TOKEN: connection.token,
136
+ AUTH_ORGANIZATION: connection.organization,
137
+ AUTH_PROJECT: connection.project,
138
+ AUTH_ENVIRONMENT: "test",
139
+ AUTH_ORIGINS: origin,
140
+ };
141
+ start(
142
+ process.execPath,
143
+ [
144
+ wrangler,
145
+ "dev",
146
+ "--local",
147
+ "--ip",
148
+ "127.0.0.1",
149
+ "--port",
150
+ String(apiPort),
151
+ "--config",
152
+ join(runtime, "wrangler.jsonc"),
153
+ "--persist-to",
154
+ join(root, ".wrangler/domino"),
155
+ ...Object.entries(vars).flatMap(([key, value]) => [
156
+ "--var",
157
+ `${key}:${value}`,
158
+ ]),
159
+ ],
160
+ { WRANGLER_SEND_METRICS: "false" },
161
+ );
162
+ let ready = false;
163
+ for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
164
+ try {
165
+ ready = (await fetch(connection.apiUrl + "/v1/auth/session")).ok;
166
+ } catch {
167
+ /* Runtime is starting. */
168
+ }
169
+ if (ready) break;
170
+ await delay(200);
171
+ }
172
+ if (!ready) throw failure ?? new Error("Local Relay did not start.");
173
+ await request(connection, "/projects", "POST", {
174
+ project: connection.project,
175
+ name: "Local campaign",
176
+ });
177
+ async function sync() {
178
+ const current = await findProject(project.path);
179
+ if (current.config.quests.length)
180
+ await request(
181
+ connection,
182
+ "/releases/batch",
183
+ "POST",
184
+ await bundleProject(current),
185
+ );
186
+ const catalog = await request(connection, "/catalog");
187
+ await request(connection, "/catalog/deploy", "POST", {
188
+ ...(await bundleCatalog(current)),
189
+ expectedRevision: catalog.revision,
190
+ });
191
+ }
192
+ await sync();
193
+ let dirty = false;
194
+ watcher = watch(root, { recursive: true }, (_, name) => {
195
+ if (
196
+ name &&
197
+ !/(^|[/\\])(node_modules|dist|\.git|\.domino|\.domino-build|\.wrangler)([/\\]|$)/.test(
198
+ name,
199
+ ) &&
200
+ /\.(ts|tsx|mjs|json)$/.test(name)
201
+ )
202
+ dirty = true;
203
+ });
204
+ start("pnpm", ["run", project.config.app.devScript], {
205
+ DOMINO_LOCAL_API: connection.apiUrl,
206
+ DOMINO_DEV_CONTROL: `http://127.0.0.1:${controlPort}`,
207
+ DOMINO_APP_PORT: String(appPort),
208
+ });
209
+ console.error(
210
+ `Local campaign: ${origin}\nPreview data stays in .wrangler/domino. Source changes update local quests.`,
211
+ );
212
+ while (!stopping) {
213
+ await delay(300);
214
+ if (dirty) {
215
+ dirty = false;
216
+ try {
217
+ await sync();
218
+ console.error("Local quests updated.");
219
+ } catch (error) {
220
+ console.error(`Local quest update failed: ${error.message}`);
221
+ }
222
+ }
223
+ }
224
+ if (failure) throw failure;
225
+ return { stopped: true };
226
+ } finally {
227
+ stop();
228
+ process.removeListener("SIGINT", stop);
229
+ process.removeListener("SIGTERM", stop);
230
+ }
231
+ }
@@ -0,0 +1,80 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { saveToken } from "./connection.mjs";
4
+
5
+ export function openBrowser(url) {
6
+ const [command, args] =
7
+ process.platform === "darwin"
8
+ ? ["open", [url]]
9
+ : process.platform === "win32"
10
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
11
+ : ["xdg-open", [url]];
12
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
13
+ child.on("error", () => {});
14
+ child.unref();
15
+ }
16
+
17
+ export async function browserLogin(connection, options) {
18
+ const verifier = randomBytes(32).toString("base64url");
19
+ async function post(path, input) {
20
+ const response = await fetch(`${connection.apiUrl}/cli/${path}`, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify(input),
24
+ redirect: "error",
25
+ signal: AbortSignal.timeout(30_000),
26
+ });
27
+ const value = await response.json();
28
+ if (!response.ok) throw new Error(value.error ?? "Browser sign-in failed");
29
+ return value;
30
+ }
31
+ const device = await post("device", {
32
+ challenge: createHash("sha256").update(verifier).digest("hex"),
33
+ });
34
+ if (
35
+ typeof device.verificationUrl !== "string" ||
36
+ typeof device.code !== "string" ||
37
+ typeof device.expiresAt !== "number"
38
+ )
39
+ throw new Error("Invalid sign-in response");
40
+ const url = new URL(device.verificationUrl);
41
+ if (
42
+ url.username ||
43
+ url.password ||
44
+ (url.protocol !== "https:" &&
45
+ !(
46
+ url.protocol === "http:" &&
47
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
48
+ ))
49
+ )
50
+ throw new Error("Unsafe sign-in URL");
51
+ process.stderr.write(
52
+ options.json
53
+ ? JSON.stringify({
54
+ event: "browser-login",
55
+ url: url.href,
56
+ code: device.code,
57
+ }) + "\n"
58
+ : `Open ${url.href}\nEnter code ${device.code} to connect this CLI.\n`,
59
+ );
60
+ if (!options.noBrowser) openBrowser(url.href);
61
+ while (Date.now() < device.expiresAt) {
62
+ await new Promise((resolve) => setTimeout(resolve, 3000));
63
+ const result = await post("device/token", { id: device.id, verifier });
64
+ if (result.status === "pending") continue;
65
+ if (
66
+ result.status !== "approved" ||
67
+ typeof result.token !== "string" ||
68
+ !result.token.startsWith("dc_")
69
+ )
70
+ throw new Error("Invalid sign-in response");
71
+ await saveToken(connection.apiUrl, result.token);
72
+ return {
73
+ loggedIn: true,
74
+ apiUrl: connection.apiUrl,
75
+ organization: result.organization,
76
+ expiresAt: result.expiresAt,
77
+ };
78
+ }
79
+ throw new Error("Browser sign-in expired. Run domino login again.");
80
+ }
package/cli/doctor.mjs ADDED
@@ -0,0 +1,156 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { access } from "node:fs/promises";
4
+ import { inspectAgentSkills } from "./agents.mjs";
5
+ import { request } from "./connection.mjs";
6
+
7
+ export async function diagnose({ project, connection, options }) {
8
+ const checks = [];
9
+ const root = project ? dirname(project.path) : process.cwd();
10
+ checks.push(
11
+ project
12
+ ? { id: "manifest", status: "ok", message: "Project manifest is valid." }
13
+ : {
14
+ id: "manifest",
15
+ status: "error",
16
+ message:
17
+ "No relay.json found. Use domino checkout for a hosted project, or domino init --project ID in an existing app.",
18
+ },
19
+ );
20
+
21
+ const agents = await inspectAgentSkills(root);
22
+ checks.push(
23
+ Object.values(agents).some((status) =>
24
+ ["installed", "modified"].includes(status),
25
+ )
26
+ ? {
27
+ id: "agent-skills",
28
+ status: "ok",
29
+ message:
30
+ "Project skill files are present. Agent discovery and customized instructions have not been verified.",
31
+ }
32
+ : {
33
+ id: "agent-skills",
34
+ status: "error",
35
+ message:
36
+ "Install project guidance with domino agents install. Rerunning restores missing files without replacing customized instructions.",
37
+ },
38
+ );
39
+
40
+ if (project) {
41
+ const entries = [...project.config.quests, ...project.config.questTypes];
42
+ const directories = new Set(
43
+ entries.map((entry) => dirname(resolve(root, entry.entry))),
44
+ );
45
+ if (!directories.size) directories.add(root);
46
+ for (const directory of directories) {
47
+ try {
48
+ createRequire(join(directory, "package.json")).resolve(
49
+ "@domino-sdk/relay/authoring",
50
+ );
51
+ checks.push({
52
+ id: "sdk",
53
+ status: "ok",
54
+ directory,
55
+ message:
56
+ "SDK imports resolve. Version compatibility and compilation have not been checked.",
57
+ });
58
+ } catch {
59
+ checks.push({
60
+ id: "sdk",
61
+ status: "error",
62
+ directory,
63
+ message:
64
+ "Install @domino-sdk/relay in the package that owns the quest modules using this app's package manager. Use the version supplied by the starter or the published @domino-sdk/relay package.",
65
+ });
66
+ }
67
+ }
68
+ for (const { entry } of entries) {
69
+ try {
70
+ await access(resolve(root, entry));
71
+ checks.push({
72
+ id: "entry",
73
+ status: "ok",
74
+ entry,
75
+ message: "Authored entry exists.",
76
+ });
77
+ } catch {
78
+ checks.push({
79
+ id: "entry",
80
+ status: "error",
81
+ entry,
82
+ message:
83
+ "Entry is missing or unreadable. Correct its path in relay.json or restore the module.",
84
+ });
85
+ }
86
+ }
87
+ if (project.config.app) {
88
+ for (const dependency of [
89
+ "@domino-sdk/relay-runtime/package.json",
90
+ "wrangler/package.json",
91
+ ]) {
92
+ try {
93
+ createRequire(join(root, "package.json")).resolve(dependency);
94
+ checks.push({
95
+ id: "local-preview-dependency",
96
+ status: "ok",
97
+ dependency,
98
+ message: "Local preview dependency resolves.",
99
+ });
100
+ } catch {
101
+ checks.push({
102
+ id: "local-preview-dependency",
103
+ status: "error",
104
+ dependency,
105
+ message:
106
+ "Install the starter dependencies before running domino dev.",
107
+ });
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ let remoteAccess = "not-checked";
114
+ if (options.remote && project) {
115
+ try {
116
+ const value = await request(connection, "/access");
117
+ const selected = value.projects.find(
118
+ (item) => item.project === connection.project,
119
+ );
120
+ if (
121
+ value.organization !== connection.organization ||
122
+ !selected?.environments.includes(connection.environment)
123
+ )
124
+ throw new Error(
125
+ "The signed-in account cannot access the selected organization, project, and environment.",
126
+ );
127
+ remoteAccess = "verified";
128
+ checks.push({
129
+ id: "remote-access",
130
+ status: "ok",
131
+ message:
132
+ "Management API verified project access. This does not verify participant sign-in or publication permission.",
133
+ });
134
+ } catch (error) {
135
+ remoteAccess = "failed";
136
+ checks.push({
137
+ id: "remote-access",
138
+ status: "error",
139
+ message: error.message,
140
+ next: "Check the selected scope and run domino login for this API, then retry domino doctor --remote.",
141
+ });
142
+ }
143
+ }
144
+ return {
145
+ directory: root,
146
+ healthy: checks.every((check) => check.status !== "error"),
147
+ scope: connection,
148
+ agents,
149
+ remoteAccess,
150
+ preview: project?.config.app
151
+ ? "static-app-configured"
152
+ : "use-existing-app-workflow",
153
+ checks,
154
+ next: "After setup, run the project's checks and verify a real participant action. Setup diagnostics do not prove preview or live readiness.",
155
+ };
156
+ }