@domino-sdk/relay-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/agents.mjs CHANGED
@@ -10,6 +10,9 @@ const resources = [
10
10
  "references/hosted.md",
11
11
  "references/existing-app.md",
12
12
  "references/authoring.md",
13
+ "references/participant-api.md",
14
+ "references/referrals.md",
15
+ "references/troubleshooting.md",
13
16
  ];
14
17
 
15
18
  async function skillFiles(agent) {
@@ -0,0 +1,82 @@
1
+ {
2
+ "format": 1,
3
+ "capabilities": [
4
+ {
5
+ "id": "identity",
6
+ "support": "native",
7
+ "description": "Real email and configured OAuth sign-in; identities and sessions are scoped to a project and environment.",
8
+ "entrypoints": [
9
+ "auth.providers",
10
+ "auth.start",
11
+ "auth.emailStart",
12
+ "auth.emailVerify",
13
+ "auth.session"
14
+ ],
15
+ "evidence": ["tests/auth.test.mjs", "tests/email-auth.test.mjs"]
16
+ },
17
+ {
18
+ "id": "quests",
19
+ "support": "native",
20
+ "description": "Claim, photo, quiz, staff and automatic quests; immutable releases pin attempts and promised rewards.",
21
+ "entrypoints": ["quests.experience", "quests.observe", "quests.open"],
22
+ "evidence": ["tests/quest-catalog.test.mjs", "tests/submissions.test.mjs"]
23
+ },
24
+ {
25
+ "id": "points",
26
+ "support": "native",
27
+ "description": "Award points to the participant completing a quest. The server applies completion and rewards; the browser cannot write balances.",
28
+ "entrypoints": ["points", "decision.accept", "me.observeProgress"],
29
+ "evidence": ["tests/journey.test.mjs"]
30
+ },
31
+ {
32
+ "id": "review",
33
+ "support": "native",
34
+ "description": "Queue a quest attempt for an authorized human reviewer. Review decisions and reward execution remain server-owned.",
35
+ "entrypoints": ["decision.review"],
36
+ "evidence": ["tests/photo-review.test.mjs"]
37
+ },
38
+ {
39
+ "id": "milestones",
40
+ "support": "composed",
41
+ "description": "Compose same-participant completion prerequisites with requires: [completed(quest)]. Arbitrary aggregate thresholds and cross-participant conditions need application logic.",
42
+ "entrypoints": ["completed", "automatic", "defineQuest"],
43
+ "evidence": ["tests/availability.test.mjs"]
44
+ },
45
+ {
46
+ "id": "referrals",
47
+ "support": "own-backend",
48
+ "description": "No native referral attribution service. Your backend must issue invite codes, attribute the authenticated invitee, deduplicate claims and prevent self-referrals. A URL parameter alone is not verified attribution.",
49
+ "entrypoints": [],
50
+ "reference": "referrals",
51
+ "evidence": [
52
+ "packages/sdk/src/authoring.ts",
53
+ "packages/sdk/src/schema.ts"
54
+ ]
55
+ },
56
+ {
57
+ "id": "cross-participant-rewards",
58
+ "support": "unsupported",
59
+ "description": "Quest rewards target the completing participant. There is no supported inviter-targeted reward operation. Do not promise automatic inviter points; an application-owned reward ledger requires its own implementation.",
60
+ "entrypoints": [],
61
+ "reference": "referrals",
62
+ "evidence": [
63
+ "packages/sdk/src/authoring.ts",
64
+ "packages/sdk/src/schema.ts"
65
+ ]
66
+ },
67
+ {
68
+ "id": "rankings",
69
+ "support": "own-backend",
70
+ "description": "No participant leaderboard or referral queue ranking API. Build an authorized backend projection with explicit privacy and tie-breaking rules; never expose management credentials in a browser.",
71
+ "entrypoints": [],
72
+ "evidence": ["packages/sdk/src/index.ts"]
73
+ },
74
+ {
75
+ "id": "wallet-verification",
76
+ "support": "native",
77
+ "description": "Connect a wallet and verify supported Solana balances. This reads a real network; it does not send assets or execute payouts.",
78
+ "entrypoints": ["wallet.connection", "wallet.challenge", "wallet.verify"],
79
+ "evidence": ["tests/wallet.test.mjs"]
80
+ }
81
+ ]
82
+ }
@@ -36,6 +36,7 @@ export function registerAgentCommands(program) {
36
36
  .description(
37
37
  "Inspect local agent setup without changing files or running app code",
38
38
  )
39
+ .option("--offline", "Check dependencies for the isolated local runtime")
39
40
  .option(
40
41
  "--remote",
41
42
  "Also verify access to the selected project through the management API",
@@ -0,0 +1,45 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { action } from "../runtime.mjs";
3
+
4
+ const references = ["participant-api", "referrals", "troubleshooting"];
5
+ export function registerDiscoveryCommands(program) {
6
+ program
7
+ .command("capabilities")
8
+ .description("Discover supported Domino features and their limitations")
9
+ .argument("[id]", "Capability identifier")
10
+ .action(
11
+ action(async (_, id) => {
12
+ const catalog = JSON.parse(
13
+ await readFile(
14
+ new URL("../capabilities.json", import.meta.url),
15
+ "utf8",
16
+ ),
17
+ );
18
+ if (!id) return catalog;
19
+ const capability = catalog.capabilities.find((item) => item.id === id);
20
+ if (!capability)
21
+ throw new Error(
22
+ `Unknown capability. Choose: ${catalog.capabilities.map((item) => item.id).join(", ")}`,
23
+ );
24
+ return capability;
25
+ }),
26
+ );
27
+ program
28
+ .command("reference")
29
+ .description("Read a compact task or SDK reference bundled with this CLI")
30
+ .argument("[topic]", references.join(", "))
31
+ .action(
32
+ action(async (_, topic) => {
33
+ if (!topic) return { topics: references };
34
+ if (!references.includes(topic))
35
+ throw new Error(`Choose: ${references.join(", ")}`);
36
+ return {
37
+ topic,
38
+ markdown: await readFile(
39
+ new URL(`../skills/domino/references/${topic}.md`, import.meta.url),
40
+ "utf8",
41
+ ),
42
+ };
43
+ }),
44
+ );
45
+ }
@@ -1,3 +1,5 @@
1
+ import { Option } from "commander";
2
+ import { connectedDev } from "../connected-dev.mjs";
1
3
  import { dirname, resolve } from "node:path";
2
4
  import { execFile } from "node:child_process";
3
5
  import { promisify } from "node:util";
@@ -41,9 +43,30 @@ export function registerHostingCommands(program) {
41
43
  );
42
44
  program
43
45
  .command("dev")
44
- .description("Run the campaign and an isolated local Relay runtime")
46
+ .description(
47
+ "Run your app with hosted test data and a shareable HTTPS preview",
48
+ )
49
+ .option("--offline", "Use an isolated local runtime without shared hosting")
50
+ .addOption(
51
+ new Option(
52
+ "--review-mode <mode>",
53
+ "Explicit photo-review simulation for --offline only",
54
+ ).choices([
55
+ "fixture-pass",
56
+ "fixture-fail",
57
+ "fixture-unclear",
58
+ "fixture-error",
59
+ ]),
60
+ )
61
+ .option("--cloudflared <path>", "Path to the Cloudflare Tunnel connector")
45
62
  .option("--port <port>", "App development server port")
46
- .action(action(({ project, options }) => dev(project, options)));
63
+ .action(
64
+ action(({ project, connection, options }) =>
65
+ options.offline
66
+ ? dev(project, options)
67
+ : connectedDev(project, connection, options),
68
+ ),
69
+ );
47
70
  program
48
71
  .command("create")
49
72
  .description("Create a hosted campaign repository and check it out locally")
@@ -24,6 +24,15 @@ export function registerStagingCommands(program) {
24
24
  throw new Error(
25
25
  "Cloud staging currently supports static campaign checkouts with app.kind=static in relay.json. This project is not configured for cloud staging. SSR Worker apps use the Cloudflare Worker deployment workflow.",
26
26
  );
27
+ const scoped = { ...connection, environment: "test" };
28
+ const preflight = await request(scoped, "/preflight");
29
+ if (preflight.staging !== "configured")
30
+ throw new Error(
31
+ preflight.checks
32
+ .filter((check) => check.status === "error")
33
+ .map((check) => check.message)
34
+ .join("\n"),
35
+ );
27
36
  const cwd = dirname(project.path);
28
37
  if (
29
38
  !options.commit &&
@@ -36,7 +45,6 @@ export function registerStagingCommands(program) {
36
45
  options.commit ?? (await runGit(["rev-parse", "HEAD"], { cwd }));
37
46
  if (!/^[a-f0-9]{40}$/.test(commit))
38
47
  throw new Error("Use a full 40-character commit SHA.");
39
- const scoped = { ...connection, environment: "test" };
40
48
  const requestId = options.requestId ?? randomUUID();
41
49
  process.stderr.write(`Build request ${requestId}\n`);
42
50
  let build;
@@ -0,0 +1,223 @@
1
+ import { createInterface } from "node:readline";
2
+ import { previewConnector } from "./connector.mjs";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { watch } from "node:fs";
6
+ import { createServer } from "node:net";
7
+ import { dirname, join } from "node:path";
8
+ import { randomUUID } from "node:crypto";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import { request } from "./connection.mjs";
11
+ import { developmentSync } from "./development-sync.mjs";
12
+
13
+ export async function connectedDev(project, connection, options) {
14
+ if (options.reviewMode)
15
+ throw new Error(
16
+ "--review-mode requires --offline. Hosted test uses real providers.",
17
+ );
18
+ if (!project?.config.app)
19
+ throw new Error("Configure the app's dev script in relay.json first.");
20
+ if (connection.environment !== "test")
21
+ throw new Error("domino dev uses test data. Select --environment test.");
22
+ const preflight = await request(connection, "/preflight");
23
+ if (preflight.development?.status !== "configured")
24
+ throw new Error(
25
+ "Hosted development is not configured. Run domino doctor --remote --json; a Domino operator must enable previews and test sign-in.",
26
+ );
27
+ const root = dirname(project.path);
28
+ const connector = await previewConnector(options.cloudflared);
29
+ await new Promise((resolve, reject) => {
30
+ const check = spawn(connector, ["--version"], { stdio: "ignore" });
31
+ check.on("error", () =>
32
+ reject(
33
+ new Error(
34
+ "Install cloudflared to run a shared preview, or pass --cloudflared PATH.",
35
+ ),
36
+ ),
37
+ );
38
+ check.on("exit", (code) =>
39
+ code === 0 ? resolve() : reject(new Error("cloudflared could not run.")),
40
+ );
41
+ });
42
+ // Stored in ignored runtime state, distinct for each checkout/worktree.
43
+ const stateDirectory = join(root, ".wrangler", "domino");
44
+ await mkdir(stateDirectory, { recursive: true });
45
+ const workspaceFile = join(stateDirectory, "workspace-id");
46
+ try {
47
+ await writeFile(workspaceFile, randomUUID(), { flag: "wx", mode: 0o600 });
48
+ } catch (error) {
49
+ if (error.code !== "EEXIST") throw error;
50
+ }
51
+ const workspace = (await readFile(workspaceFile, "utf8")).trim();
52
+ if (!/^[a-f0-9-]{36}$/.test(workspace))
53
+ throw new Error("Invalid development workspace identity.");
54
+ const socket = createServer();
55
+ await new Promise((resolve, reject) => {
56
+ socket.once("error", reject);
57
+ socket.listen(0, "127.0.0.1", resolve);
58
+ });
59
+ const port = Number(options.port ?? socket.address().port);
60
+ await new Promise((resolve) => socket.close(resolve));
61
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
62
+ throw new Error("Invalid app port.");
63
+ const generation = randomUUID();
64
+ const lease = { workspace, generation };
65
+ const children = [];
66
+ let stopping = false,
67
+ failure,
68
+ watcher,
69
+ session;
70
+ const stop = () => {
71
+ stopping = true;
72
+ watcher?.close();
73
+ for (const child of children) {
74
+ if (!child.pid) continue;
75
+ try {
76
+ process.platform === "win32"
77
+ ? child.kill("SIGTERM")
78
+ : process.kill(-child.pid, "SIGTERM");
79
+ } catch (error) {
80
+ if (error.code !== "ESRCH") failure ??= error;
81
+ }
82
+ }
83
+ };
84
+ const start = (command, args, extraEnv = {}) => {
85
+ const safeEnv = Object.fromEntries(
86
+ Object.entries(process.env).filter(
87
+ ([key]) =>
88
+ !key.startsWith("RELAY_") &&
89
+ !key.startsWith("CLOUDFLARE_") &&
90
+ key !== "TUNNEL_TOKEN",
91
+ ),
92
+ );
93
+ const child = spawn(command, args, {
94
+ cwd: root,
95
+ env: { ...safeEnv, ...extraEnv },
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ detached: process.platform !== "win32",
98
+ });
99
+ for (const stream of [child.stdout, child.stderr]) {
100
+ createInterface({ input: stream }).on("line", (line) => {
101
+ const publicLine =
102
+ session && extraEnv.DOMINO_APP_PORT
103
+ ? line
104
+ .replaceAll(`http://127.0.0.1:${port}`, session.url)
105
+ .replaceAll(`http://localhost:${port}`, session.url)
106
+ : line;
107
+ process.stderr.write(publicLine + "\n");
108
+ });
109
+ }
110
+ children.push(child);
111
+ child.on("error", (error) => {
112
+ failure = error;
113
+ stop();
114
+ });
115
+ child.on("exit", (code) => {
116
+ if (!stopping) {
117
+ failure = new Error(`Development process exited (${code}).`);
118
+ stop();
119
+ }
120
+ });
121
+ return child;
122
+ };
123
+ process.once("SIGINT", stop);
124
+ process.once("SIGTERM", stop);
125
+ try {
126
+ session = await request(connection, "/development/session", "POST", {
127
+ ...lease,
128
+ port,
129
+ });
130
+ const sync = developmentSync(connection, project.path);
131
+ await sync();
132
+ let dirty = false;
133
+ watcher = watch(root, { recursive: true }, (_, name) => {
134
+ if (
135
+ name &&
136
+ !/(^|[/\\])(node_modules|dist|\.git|\.domino|\.domino-build|\.wrangler)([/\\]|$)/.test(
137
+ name,
138
+ ) &&
139
+ /\.(ts|tsx|mjs|json)$/.test(name)
140
+ )
141
+ dirty = true;
142
+ });
143
+ start("pnpm", ["run", project.config.app.devScript], {
144
+ DOMINO_APP_PORT: String(port),
145
+ DOMINO_PREVIEW_ORIGIN: session.url,
146
+ });
147
+ let appReady = false;
148
+ for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
149
+ try {
150
+ const response = await fetch(`http://127.0.0.1:${port}`, {
151
+ signal: AbortSignal.timeout(1000),
152
+ });
153
+ appReady = response.ok;
154
+ await response.body?.cancel();
155
+ } catch {}
156
+ if (appReady) break;
157
+ await delay(200);
158
+ }
159
+ if (!appReady)
160
+ throw (
161
+ failure ?? new Error("The app did not start. Check its dev script.")
162
+ );
163
+ // Token is inherited only by the connector, never the app or command arguments.
164
+ start(
165
+ connector,
166
+ ["tunnel", "--no-autoupdate", "--loglevel", "warn", "run"],
167
+ {
168
+ TUNNEL_TOKEN: session.token,
169
+ },
170
+ );
171
+ let reachable = false;
172
+ for (let attempt = 0; attempt < 60 && !stopping; attempt++) {
173
+ if (attempt % 15 === 0)
174
+ await request(connection, "/development/session", "PUT", lease);
175
+ try {
176
+ const response = await fetch(session.url, {
177
+ signal: AbortSignal.timeout(2000),
178
+ });
179
+ reachable = response.ok;
180
+ await response.body?.cancel();
181
+ } catch {}
182
+ if (reachable) break;
183
+ await delay(1000);
184
+ }
185
+ if (!reachable)
186
+ throw (
187
+ failure ??
188
+ new Error(
189
+ "The shared preview could not be reached. Check tunnel connectivity and preview DNS/TLS configuration.",
190
+ )
191
+ );
192
+ process.stderr.write(
193
+ `Preview: ${session.url}\nUses this project's hosted test data and real sign-in. Keep this terminal running. Use domino stage for a preview that stays online.\n`,
194
+ );
195
+ let renewed = Date.now();
196
+ while (!stopping) {
197
+ await delay(300);
198
+ if (Date.now() - renewed > 25_000) {
199
+ await request(connection, "/development/session", "PUT", lease);
200
+ renewed = Date.now();
201
+ }
202
+ if (dirty) {
203
+ dirty = false;
204
+ try {
205
+ if (await sync()) console.error("Hosted test quests updated.");
206
+ } catch (error) {
207
+ console.error(`Quest update failed: ${error.message}`);
208
+ }
209
+ }
210
+ }
211
+ if (failure) throw failure;
212
+ return { stopped: true, previewUrl: session.url };
213
+ } finally {
214
+ stop();
215
+ if (session)
216
+ await request(connection, "/development/session", "DELETE", lease).catch(
217
+ () =>
218
+ console.error("Preview cleanup will finish when its lease expires."),
219
+ );
220
+ process.removeListener("SIGINT", stop);
221
+ process.removeListener("SIGTERM", stop);
222
+ }
223
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import {
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ writeFile,
9
+ chmod,
10
+ rename,
11
+ rm,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const version = "2026.9.1";
17
+ const distributions = {
18
+ "darwin-x64": [
19
+ "cloudflared-darwin-amd64.tgz",
20
+ "ff0d3b51d5ff70eceef89d6b32145fee985018a2174596a5dbe405e2766e2ac4",
21
+ ],
22
+ "darwin-arm64": [
23
+ "cloudflared-darwin-arm64.tgz",
24
+ "c27ab8fd0aa489449e3d201eb02f957ef460a13b613662928b1b23394bf1bcfe",
25
+ ],
26
+ "linux-x64": [
27
+ "cloudflared-linux-amd64",
28
+ "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc",
29
+ ],
30
+ "linux-arm64": [
31
+ "cloudflared-linux-arm64",
32
+ "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3",
33
+ ],
34
+ "win32-x64": [
35
+ "cloudflared-windows-amd64.exe",
36
+ "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712",
37
+ ],
38
+ };
39
+ export async function previewConnector(override) {
40
+ if (override) return override;
41
+ const platform = `${process.platform}-${process.arch}`;
42
+ const distribution = distributions[platform];
43
+ if (!distribution)
44
+ throw new Error(
45
+ `No bundled connector for ${platform}. Install cloudflared and pass --cloudflared PATH.`,
46
+ );
47
+ const directory = join(
48
+ process.env.RELAY_CONFIG_DIR ?? join(homedir(), ".config/domino"),
49
+ "connectors",
50
+ version,
51
+ platform,
52
+ );
53
+ await mkdir(directory, { recursive: true, mode: 0o700 });
54
+ const executable = join(
55
+ directory,
56
+ process.platform === "win32" ? "cloudflared.exe" : "cloudflared",
57
+ );
58
+ try {
59
+ const expected = (await readFile(executable + ".sha256", "utf8")).trim();
60
+ if (
61
+ createHash("sha256")
62
+ .update(await readFile(executable))
63
+ .digest("hex") === expected
64
+ )
65
+ return executable;
66
+ } catch (error) {
67
+ if (error.code !== "ENOENT") throw error;
68
+ }
69
+ process.stderr.write(`Preparing preview connector ${version}...\n`);
70
+ const temporary = await mkdtemp(join(directory, ".install-"));
71
+ try {
72
+ const [asset, digest] = distribution;
73
+ const response = await fetch(
74
+ `https://github.com/cloudflare/cloudflared/releases/download/${version}/${asset}`,
75
+ { signal: AbortSignal.timeout(120000) },
76
+ );
77
+ if (!response.ok)
78
+ throw new Error(
79
+ `Connector download failed (${response.status}). Retry domino dev.`,
80
+ );
81
+ const archive = Buffer.from(await response.arrayBuffer());
82
+ if (createHash("sha256").update(archive).digest("hex") !== digest)
83
+ throw new Error(
84
+ "Connector checksum mismatch. Download was not installed.",
85
+ );
86
+ const downloaded = join(temporary, asset);
87
+ await writeFile(downloaded, archive, { mode: 0o600 });
88
+ let binary = downloaded;
89
+ if (asset.endsWith(".tgz")) {
90
+ await promisify(execFile)("tar", [
91
+ "-xzf",
92
+ downloaded,
93
+ "-C",
94
+ temporary,
95
+ "cloudflared",
96
+ ]);
97
+ binary = join(temporary, "cloudflared");
98
+ }
99
+ await chmod(binary, 0o700);
100
+ const binaryHash = createHash("sha256")
101
+ .update(await readFile(binary))
102
+ .digest("hex");
103
+ await rename(binary, executable);
104
+ await writeFile(executable + ".sha256", binaryHash, { mode: 0o600 });
105
+ return executable;
106
+ } finally {
107
+ await rm(temporary, { recursive: true, force: true });
108
+ }
109
+ }
package/cli/dev.mjs CHANGED
@@ -6,7 +6,7 @@ import { dirname, join } from "node:path";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { setTimeout as delay } from "node:timers/promises";
8
8
  import { request } from "./connection.mjs";
9
- import { bundleProject, bundleCatalog, findProject } from "./project.mjs";
9
+ import { developmentSync } from "./development-sync.mjs";
10
10
 
11
11
  async function listen(server, port = 0) {
12
12
  await new Promise((resolve, reject) => {
@@ -27,6 +27,10 @@ export async function dev(project, options) {
27
27
  throw new Error(
28
28
  "Configure app scripts in relay.json before running domino dev.",
29
29
  );
30
+ if (options.reviewMode)
31
+ console.error(
32
+ `Offline photo review uses ${options.reviewMode}; no external verification is performed.`,
33
+ );
30
34
  const root = dirname(project.path);
31
35
  const require = createRequire(join(root, "package.json"));
32
36
  const runtime = dirname(
@@ -174,21 +178,7 @@ export async function dev(project, options) {
174
178
  project: connection.project,
175
179
  name: "Local campaign",
176
180
  });
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
- }
181
+ const sync = developmentSync(connection, project.path, options.reviewMode);
192
182
  await sync();
193
183
  let dirty = false;
194
184
  watcher = watch(root, { recursive: true }, (_, name) => {
@@ -202,6 +192,7 @@ export async function dev(project, options) {
202
192
  dirty = true;
203
193
  });
204
194
  start("pnpm", ["run", project.config.app.devScript], {
195
+ VITE_DOMINO_OFFLINE: "true",
205
196
  DOMINO_LOCAL_API: connection.apiUrl,
206
197
  DOMINO_DEV_CONTROL: `http://127.0.0.1:${controlPort}`,
207
198
  DOMINO_APP_PORT: String(appPort),
@@ -214,8 +205,7 @@ export async function dev(project, options) {
214
205
  if (dirty) {
215
206
  dirty = false;
216
207
  try {
217
- await sync();
218
- console.error("Local quests updated.");
208
+ if (await sync()) console.error("Local quests updated.");
219
209
  } catch (error) {
220
210
  console.error(`Local quest update failed: ${error.message}`);
221
211
  }
@@ -0,0 +1,28 @@
1
+ import { request } from "./connection.mjs";
2
+ import { bundleProject, findProject } from "./project.mjs";
3
+
4
+ /** Frontend edits must not create new quest releases in the shared test project. */
5
+ export function developmentSync(connection, projectPath, reviewMode) {
6
+ let published;
7
+ return async () => {
8
+ const project = await findProject(projectPath);
9
+ const payload = await bundleProject(project);
10
+ if (reviewMode) {
11
+ for (const release of payload.releases) release.provider = reviewMode;
12
+ for (const type of payload.types) type.provider = reviewMode;
13
+ }
14
+ const signature = JSON.stringify(payload);
15
+ if (signature === published) return false;
16
+ const preview = await request(
17
+ connection,
18
+ "/deployments/preview",
19
+ "POST",
20
+ payload,
21
+ );
22
+ await request(connection, "/deployments/publish", "POST", {
23
+ id: preview.id,
24
+ });
25
+ published = signature;
26
+ return true;
27
+ };
28
+ }