@domino-sdk/relay-cli 0.4.0 → 0.6.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/doctor.mjs CHANGED
@@ -5,10 +5,12 @@ import { access, readFile } from "node:fs/promises";
5
5
  import { inspectAgentSkills } from "./agents.mjs";
6
6
  import { request } from "./connection.mjs";
7
7
  import { discoverEntries } from "./discovery.mjs";
8
+ import { packageManager } from "./package-manager.mjs";
8
9
 
9
- export async function diagnose({ project, connection, options }) {
10
+ export async function diagnose({ project, connection, options, scopeSources }) {
10
11
  const checks = [];
11
12
  const root = project ? dirname(project.path) : process.cwd();
13
+ const manager = await packageManager(root);
12
14
  checks.push(
13
15
  project
14
16
  ? { id: "manifest", status: "ok", message: "Project manifest is valid." }
@@ -170,6 +172,13 @@ export async function diagnose({ project, connection, options }) {
170
172
  development: { status: "not-checked" },
171
173
  staging: "not-checked",
172
174
  };
175
+ if (options.remote && !project)
176
+ checks.push({
177
+ id: "remote-access",
178
+ status: "warning",
179
+ message:
180
+ "Remote access was not checked because no relay.json was found. This does not diagnose a failed checkout. Use its checkout error details; do not install skills in the parent directory to repair remote access.",
181
+ });
173
182
  if (options.remote && project) {
174
183
  try {
175
184
  const value = await request(connection, "/access");
@@ -238,6 +247,8 @@ export async function diagnose({ project, connection, options }) {
238
247
  project: connection.project,
239
248
  environment: connection.environment,
240
249
  },
250
+ scopeSources,
251
+ packageManager: manager,
241
252
  agents,
242
253
  readiness,
243
254
  remoteAccess,
@@ -245,6 +256,10 @@ export async function diagnose({ project, connection, options }) {
245
256
  ? "static-app-configured"
246
257
  : "use-existing-app-workflow",
247
258
  checks,
248
- next: "After setup, run the project's checks and verify a real participant action. Setup diagnostics do not prove preview or live readiness.",
259
+ next:
260
+ checks.some((check) => check.id === "sdk" && check.status === "error") &&
261
+ manager.installCommand
262
+ ? `Run ${manager.installCommand} in ${root} and wait for it to finish before rerunning doctor. Then verify a participant action.`
263
+ : "After setup, run the project's checks and verify a real participant action. Setup diagnostics do not prove preview or live readiness.",
249
264
  };
250
265
  }
package/cli/git.mjs CHANGED
@@ -3,6 +3,7 @@ import { access, mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { apiUrl, request, savedToken } from "./connection.mjs";
6
+ import { packageManager } from "./package-manager.mjs";
6
7
 
7
8
  export function runGit(args, { cwd, input, env = {} } = {}) {
8
9
  return new Promise((resolve, reject) => {
@@ -20,11 +21,13 @@ export function runGit(args, { cwd, input, env = {} } = {}) {
20
21
  stderr += data;
21
22
  });
22
23
  child.on("error", reject);
23
- child.on("close", (code) =>
24
- code === 0
25
- ? resolve(stdout.trim())
26
- : reject(new Error(`Git failed (${code}): ${stderr.trim()}`)),
27
- );
24
+ child.on("close", (code) => {
25
+ if (code === 0) return resolve(stdout.trim());
26
+ const error = new Error(`Git failed (${code}): ${stderr.trim()}`);
27
+ error.exitCode = code;
28
+ error.gitCommand = args[0];
29
+ reject(error);
30
+ });
28
31
  child.stdin.end(input);
29
32
  });
30
33
  }
@@ -86,7 +89,7 @@ export async function requireNewDirectory(directory) {
86
89
  export async function checkout(connection, repository, directory) {
87
90
  if (repository.status !== "ready")
88
91
  throw new Error(
89
- "Repository provisioning is incomplete. Retry domino create.",
92
+ `Repository provisioning is incomplete (status: ${repository.status}). Inspect the existing project in Console, then retry domino checkout. Do not create a replacement project.`,
90
93
  );
91
94
  const remote = new URL(repository.remote);
92
95
  if (
@@ -152,14 +155,42 @@ export async function checkout(connection, repository, directory) {
152
155
  environment: "test",
153
156
  }) + "\n",
154
157
  );
158
+ const manager = await packageManager(root);
155
159
  return {
156
160
  directory: root,
157
161
  remote: repository.remote,
158
- next: "Install dependencies with pnpm install, then run domino dev.",
162
+ packageManager: manager,
163
+ scopeFile: resolve(root, ".git/domino.json"),
164
+ next: manager.installCommand
165
+ ? `In ${root}, run ${manager.installCommand} and wait for success. Then run the project's installed domino doctor --remote --json before domino dev.`
166
+ : "Read package.json and install with the project's declared package manager and lockfile. Wait for success before running the installed domino doctor --remote --json.",
159
167
  };
160
168
  } catch (error) {
161
169
  // This directory was created exclusively by this operation and has no user edits.
162
170
  await rm(root, { recursive: true, force: true });
171
+ if (error.gitCommand) {
172
+ error.code = "CHECKOUT_GIT_FAILED";
173
+ error.details = {
174
+ phase: error.gitCommand,
175
+ exitCode: error.exitCode,
176
+ classification: /repository not found/i.test(error.message)
177
+ ? "repository-unavailable"
178
+ : /authentication failed|401|403/i.test(error.message)
179
+ ? "access-denied"
180
+ : "git-failure",
181
+ repositoryStatus: repository.status,
182
+ organization: repository.organization,
183
+ project: repository.project,
184
+ remote: repository.remote,
185
+ };
186
+ error.message += ` Repository API reported ${repository.status}; Git ${error.gitCommand} failed.`;
187
+ error.message +=
188
+ error.gitCommand === "fetch"
189
+ ? " This does not establish whether provisioning, access, or transport caused the failure. Retry checkout once with the same scope; if it repeats, share these diagnostics with the operator."
190
+ : " Resolve the Git error above before retrying checkout.";
191
+ error.message +=
192
+ " Do not recreate the project or install dependencies in its parent directory.";
193
+ }
163
194
  throw error;
164
195
  }
165
196
  }
package/cli/output.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Keep presentation separate from the result contracts consumed with --json.
2
+ const label = (key) => {
3
+ const words = key.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]/g, " ");
4
+ return words.charAt(0).toUpperCase() + words.slice(1);
5
+ };
6
+ const scalar = (value) =>
7
+ value == null
8
+ ? "—"
9
+ : typeof value === "boolean"
10
+ ? value
11
+ ? "yes"
12
+ : "no"
13
+ : String(value);
14
+
15
+ export function formatDetails(value, indent = "") {
16
+ if (value === null || typeof value !== "object")
17
+ return indent + scalar(value);
18
+ if (Array.isArray(value)) {
19
+ if (!value.length) return indent + "None";
20
+ return value
21
+ .map((item) => {
22
+ if (item === null || typeof item !== "object")
23
+ return `${indent}- ${scalar(item)}`;
24
+ return formatDetails(item, indent + " ").replace(
25
+ indent + " ",
26
+ indent + "- ",
27
+ );
28
+ })
29
+ .join("\n");
30
+ }
31
+ const entries = Object.entries(value).filter(
32
+ ([, item]) => item !== undefined,
33
+ );
34
+ if (!entries.length) return indent + "None";
35
+ return entries
36
+ .map(([key, item]) =>
37
+ item !== null && typeof item === "object"
38
+ ? `${indent}${label(key)}:\n${formatDetails(item, indent + " ")}`
39
+ : `${indent}${label(key)}: ${scalar(item)}`,
40
+ )
41
+ .join("\n");
42
+ }
43
+
44
+ export function formatResult(command, result, options = {}) {
45
+ if (options.json || command === "api")
46
+ return JSON.stringify(result, null, options.json ? undefined : 2);
47
+ if (command === "logs") return result.logs.trimEnd();
48
+ if (command === "reference" && result.markdown)
49
+ return result.markdown.trimEnd();
50
+ if (command === "whoami") {
51
+ const identity = formatDetails({
52
+ actor: result.actor,
53
+ organization: result.organization,
54
+ });
55
+ const projects = result.projects.map(
56
+ (item) =>
57
+ ` ${item.name && item.name !== item.project ? `${item.name} (${item.project})` : item.project} [${item.environments.join(", ")}]`,
58
+ );
59
+ return `${identity}\n\nAccessible projects:\n${projects.length ? projects.join("\n") : " None"}\n\nRun domino checkout to choose a hosted project, or domino init to link this app.`;
60
+ }
61
+ if (
62
+ (command === "build" || (command === "deploy" && options.dryRun)) &&
63
+ result.releases
64
+ ) {
65
+ return formatDetails({
66
+ result: "Bundle ready",
67
+ quests: result.releases.length,
68
+ types: result.types.length,
69
+ collections: result.collections.length,
70
+ leaderboards: result.leaderboards.length,
71
+ referrals: result.referrals.length,
72
+ next: "Use --out FILE to save the bundle, or --json to print it.",
73
+ });
74
+ }
75
+ return formatDetails(result);
76
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ // Only generate a runnable command for a validated, exact package-manager pin.
5
+ // Never interpolate arbitrary package.json content into a suggested shell command.
6
+ export async function packageManager(directory) {
7
+ let pkg;
8
+ try {
9
+ pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
10
+ } catch (error) {
11
+ if (error.code === "ENOENT")
12
+ return { declared: null, installCommand: null };
13
+ return {
14
+ declared: null,
15
+ installCommand: null,
16
+ error:
17
+ "Cannot read package.json. Restore valid JSON before installing dependencies.",
18
+ };
19
+ }
20
+ const declared =
21
+ typeof pkg?.packageManager === "string" ? pkg.packageManager : null;
22
+ const pin =
23
+ /^(pnpm|npm)@(\d+\.\d+\.\d+(?:-[\w.-]+)?)(?:\+sha\d+\.[a-fA-F0-9]+)?$/.exec(
24
+ declared ?? "",
25
+ );
26
+ if (!pin) return { declared, installCommand: null };
27
+ const [, name, version] = pin;
28
+ const install = name === "pnpm" ? "install --frozen-lockfile" : "ci";
29
+ const runner = `npx --yes ${name}@${version}`;
30
+ return { declared, installCommand: `${runner} ${install}` };
31
+ }
package/cli/project.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostedAppSchema } from "@domino-sdk/relay-cli";
1
2
  import { readFile, writeFile, mkdir, access } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { z } from "zod";
@@ -41,16 +42,7 @@ export const projectSchema = z
41
42
  })
42
43
  .strict()
43
44
  .optional(),
44
- app: z
45
- .object({
46
- kind: z.literal("static"),
47
- directory: z.string().min(1),
48
- devScript: z.string().min(1),
49
- checkScript: z.string().min(1),
50
- buildScript: z.string().min(1),
51
- })
52
- .strict()
53
- .optional(),
45
+ app: hostedAppSchema.optional(),
54
46
  questTypes: z
55
47
  .array(
56
48
  z
@@ -136,7 +128,14 @@ export async function readJson(path) {
136
128
  }
137
129
 
138
130
  async function loadProject(path) {
139
- const config = projectSchema.parse(await readJson(path));
131
+ const raw = await readJson(path);
132
+ const config = projectSchema.parse(raw);
133
+ const scopeSources = Object.fromEntries(
134
+ ["apiUrl", "organization", "project", "environment"].map((key) => [
135
+ key,
136
+ raw[key] === undefined ? { kind: "default" } : { kind: "manifest", path },
137
+ ]),
138
+ );
140
139
  const dir = dirname(path);
141
140
  // Worktrees keep shared connection metadata in the common Git directory.
142
141
  const gitDir = await runGit(["rev-parse", "--git-common-dir"], {
@@ -154,11 +153,16 @@ async function loadProject(path) {
154
153
  .strict()
155
154
  .parse(await readJson(resolve(dir, gitDir, "domino.json")));
156
155
  Object.assign(config, local);
156
+ for (const key of Object.keys(local))
157
+ scopeSources[key] = {
158
+ kind: "checkout",
159
+ path: resolve(dir, gitDir, "domino.json"),
160
+ };
157
161
  } catch (error) {
158
162
  if (error.code !== "ENOENT") throw error;
159
163
  }
160
164
  }
161
- return { path, config };
165
+ return { path, config, scopeSources };
162
166
  }
163
167
 
164
168
  export async function findProject(path) {
@@ -0,0 +1,92 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export function canPrompt(
4
+ options,
5
+ input = process.stdin,
6
+ output = process.stderr,
7
+ ) {
8
+ return (
9
+ !options.json &&
10
+ options.interactive !== false &&
11
+ !process.env.CI &&
12
+ Boolean(input.isTTY && output.isTTY)
13
+ );
14
+ }
15
+
16
+ export async function promptText(
17
+ message,
18
+ {
19
+ defaultValue,
20
+ validate = (value) => (value ? undefined : "Enter a value."),
21
+ } = {},
22
+ { input = process.stdin, output = process.stderr } = {},
23
+ ) {
24
+ const prompt = createInterface({ input, output });
25
+ const controller = new AbortController();
26
+ const cancel = () => controller.abort();
27
+ prompt.on("SIGINT", cancel);
28
+ prompt.on("close", cancel);
29
+ try {
30
+ for (;;) {
31
+ const answer = (
32
+ await prompt.question(
33
+ `${message}${defaultValue === undefined ? "" : ` [${defaultValue}]`} (q to cancel): `,
34
+ { signal: controller.signal },
35
+ )
36
+ ).trim();
37
+ if (answer.toLowerCase() === "q") throw new Error("Selection cancelled.");
38
+ const value = answer || defaultValue || "";
39
+ const error = await validate(value);
40
+ if (!error) return value;
41
+ output.write(`${error}\n`);
42
+ }
43
+ } catch (error) {
44
+ if (controller.signal.aborted) throw new Error("Selection cancelled.");
45
+ throw error;
46
+ } finally {
47
+ prompt.close();
48
+ }
49
+ }
50
+
51
+ export async function promptSelect(
52
+ title,
53
+ choices,
54
+ { input = process.stdin, output = process.stderr } = {},
55
+ ) {
56
+ if (!choices.length)
57
+ throw new Error(`No choices available for ${title.toLowerCase()}.`);
58
+ output.write(`${title}:\n`);
59
+ choices.forEach((choice, index) =>
60
+ output.write(` ${index + 1}. ${choice.label}\n`),
61
+ );
62
+ const answer = await promptText(
63
+ "Selection number",
64
+ {
65
+ validate: (value) =>
66
+ /^\d+$/.test(value) &&
67
+ Number(value) >= 1 &&
68
+ Number(value) <= choices.length
69
+ ? undefined
70
+ : `Enter a number from 1 to ${choices.length}.`,
71
+ },
72
+ { input, output },
73
+ );
74
+ return choices[Number(answer) - 1].value;
75
+ }
76
+
77
+ export async function confirmAction(options, details, io = {}) {
78
+ if (options.yes || !canPrompt(options, io.input, io.output)) return;
79
+ const output = io.output ?? process.stderr;
80
+ output.write(`${details}\n`);
81
+ const answer = await promptText(
82
+ "Continue?",
83
+ {
84
+ defaultValue: "no",
85
+ validate: (value) =>
86
+ /^(y|yes|n|no)$/i.test(value) ? undefined : "Enter yes or no.",
87
+ },
88
+ io,
89
+ );
90
+ if (!/^y(es)?$/i.test(answer))
91
+ throw new Error("Operation cancelled. Nothing was published.");
92
+ }
package/cli/runtime.mjs CHANGED
@@ -1,18 +1,27 @@
1
1
  import { findProject } from "./project.mjs";
2
+ import { formatResult } from "./output.mjs";
3
+ import { projectConnection } from "./select-project.mjs";
2
4
  import { apiUrl } from "./connection.mjs";
3
5
 
4
6
  // Share scope resolution and output across commands; build and HTTP modules stay independent.
5
- export function action(handler) {
7
+ export function action(handler, { projectScope = false, environment } = {}) {
6
8
  return async (...args) => {
7
9
  const command = args.at(-1);
8
10
  const options = command.optsWithGlobals();
9
11
  const project = await findProject(options.config);
10
- const connection = {};
12
+ let connection = {};
13
+ const scopeSources = {};
11
14
  for (const key of ["apiUrl", "organization", "project", "environment"]) {
12
15
  const env =
13
16
  key === "apiUrl" ? "RELAY_API_URL" : `RELAY_${key.toUpperCase()}`;
14
17
  connection[key] =
15
18
  options[key] ?? process.env[env] ?? project?.config[key];
19
+ scopeSources[key] =
20
+ options[key] !== undefined
21
+ ? { kind: "flag", name: key === "apiUrl" ? "--api-url" : `--${key}` }
22
+ : process.env[env] !== undefined
23
+ ? { kind: "environment", name: env }
24
+ : (project?.scopeSources[key] ?? { kind: "default" });
16
25
  }
17
26
  connection.apiUrl = apiUrl(connection.apiUrl ?? "https://relay.domino.run");
18
27
  if (
@@ -20,19 +29,19 @@ export function action(handler) {
20
29
  !["test", "live"].includes(connection.environment)
21
30
  )
22
31
  throw new Error("Environment must be test or live.");
32
+ if (environment) connection.environment = environment;
33
+ if (projectScope) connection = await projectConnection(connection, options);
23
34
  const result = await handler(
24
- { options, project, connection },
35
+ { options, project, connection, scopeSources },
25
36
  ...command.processedArgs,
26
37
  );
27
- console.log(JSON.stringify(result, null, options.json ? undefined : 2));
38
+ console.log(formatResult(command.name(), result, options));
28
39
  };
29
40
  }
30
41
 
31
42
  export async function readStdin() {
32
43
  if (process.stdin.isTTY)
33
- throw new Error(
34
- "Pipe input to stdin; interactive prompts are not supported.",
35
- );
44
+ throw new Error("Pipe input to stdin for this option.");
36
45
  let input = "";
37
46
  for await (const chunk of process.stdin) input += chunk;
38
47
  return input;
@@ -0,0 +1,64 @@
1
+ import { canPrompt, promptSelect } from "./prompts.mjs";
2
+ import { request } from "./connection.mjs";
3
+
4
+ export function promptProject(projects, io) {
5
+ return promptSelect(
6
+ "Choose a project",
7
+ projects.map((item) => ({
8
+ label: `${item.name && item.name !== item.project ? `${item.name} (${item.project})` : item.project} [${item.environments.join(", ")}]${item.organization ? ` • ${item.organization}` : ""}`,
9
+ value: item,
10
+ })),
11
+ io,
12
+ );
13
+ }
14
+
15
+ export async function projectConnection(connection, options) {
16
+ if (connection.project) return connection;
17
+ const access = await request(connection, "/access");
18
+ const selected = await selectProject(access, connection, options);
19
+ return {
20
+ ...connection,
21
+ project: selected.project,
22
+ organization: selected.organization ?? access.organization,
23
+ };
24
+ }
25
+
26
+ export async function selectProject(
27
+ access,
28
+ connection,
29
+ options,
30
+ { interactive = canPrompt(options), choose = promptProject } = {},
31
+ ) {
32
+ const environment = connection.environment ?? "test";
33
+ const projects = access.projects.filter(
34
+ (item) =>
35
+ item.environments.includes(environment) &&
36
+ (!connection.organization ||
37
+ !item.organization ||
38
+ item.organization === connection.organization),
39
+ );
40
+ if (connection.project) {
41
+ const matches = projects.filter(
42
+ (item) => item.project === connection.project,
43
+ );
44
+ if (matches.length > 1)
45
+ throw new Error(
46
+ "This project identifier exists in multiple organizations. Select one with --organization ID.",
47
+ );
48
+ const selected = matches[0];
49
+ if (!selected)
50
+ throw new Error(
51
+ `No access to project ${connection.project} in ${environment}. Run domino whoami to list accessible projects.`,
52
+ );
53
+ return selected;
54
+ }
55
+ if (!projects.length)
56
+ throw new Error(
57
+ `No accessible projects in ${environment}. Create a project in Console or select another organization with --organization ID.`,
58
+ );
59
+ if (interactive) return choose(projects);
60
+ if (projects.length === 1) return projects[0];
61
+ throw new Error(
62
+ "Select an accessible project with --project ID. Run domino whoami to list projects, or run this command in a terminal to choose interactively.",
63
+ );
64
+ }
@@ -42,7 +42,7 @@ Use `domino login` for browser authorization when remote work is needed. As soon
42
42
 
43
43
  Keep the login process running while the user approves in their browser. The CLI polls for approval and saves the credential itself; never ask the user to paste credentials into chat. Wait for the CLI to confirm success before continuing. If the attempt expires, run `domino login` again and send the new code first, then the new link.
44
44
 
45
- Run `domino doctor --remote --json` to verify access and inspect the effective organization, project, API, and environment before remote operations. Flags and RELAY_* environment variables override relay.json.
45
+ Run `domino doctor --remote --json` after dependency installation finishes to verify access before remote operations. Inspect `scope` and `scopeSources`: flags override `RELAY_*` environment variables, then the Git common directory's `domino.json`, then `relay.json`. Checkout writes the untracked local connection file; starter values such as `local` and `campaign` in the manifest may therefore be valid defaults. Credentials live separately in the user config directory. For scope or setup failures, read [troubleshooting](references/troubleshooting.md).
46
46
 
47
47
  Choose the reference that matches the task:
48
48
 
@@ -51,7 +51,7 @@ Choose the reference that matches the task:
51
51
  - Before authoring a quest, read [authoring](references/authoring.md#find-a-snippet) and check the live snippet catalog for a suitable starting point. The same reference covers reusable types, collections, leaderboards, external points, and publication.
52
52
  - For standings, own rank, nearby participants, or pagination, read [participant API](references/participant-api.md#leaderboard-reads).
53
53
 
54
- Before designing referrals, rankings, milestones, or rewards for another participant, run `domino capabilities --json`. Read `domino capabilities <id> --json` for the support boundary. Treat an unlisted capability as unknown and inspect its contract before promising it.
54
+ Before designing referrals, rankings, milestones, or rewards for another participant, run `domino capabilities --json`. Read `domino capabilities <id> --json` for the support boundary. The output's `provenance` identifies a bundled CLI catalog, not a project-specific server probe. Treat an unlisted capability or a disagreement with the deployed API as unknown and inspect its contract before promising it. Check recurrence, daily limits, social verification, and prize fulfillment separately; points or leaderboards alone do not establish those mechanics.
55
55
 
56
56
  For browser integration and submission recovery, read [participant API](references/participant-api.md). For invitation links, qualification, inviter bonuses, or referral milestones, read [referrals](references/referrals.md). For startup, staging, or version failures, read [troubleshooting](references/troubleshooting.md).
57
57
 
@@ -61,4 +61,4 @@ If no CLI is installed, install `@domino-sdk/relay-cli` and `@domino-sdk/relay`
61
61
 
62
62
  Run the app's checks and exercise the requested participant action through the running app. Verify the resulting progress or completion state. Keep fixture-provider results distinct from real external verification.
63
63
 
64
- Return the working preview or local URL, what participant action you verified, and any remaining launch dependency. A successful build, installed skill, or healthy doctor result is not proof of participant behavior. Live publication requires human review in Console; hosted staging uses test data and real sign-in.
64
+ Return the working preview or local URL, what participant action you verified, and any remaining launch dependency. Report failed or blocked browser checks explicitly, including a missing browser executable. A successful build, installed skill, or healthy doctor result is not proof of participant behavior. Live publication requires human review in Console; hosted staging uses test data and real sign-in.
@@ -25,13 +25,25 @@ curl -fsS https://snippets.domino.run/r/registry.json
25
25
 
26
26
  If no recipe fits or the catalog is unavailable, author with the installed SDK declarations and existing project examples. Report an unavailable catalog as unchecked, rather than claiming no matching snippet exists. Snippets are editable starting points; check compatibility with the project's installed SDK before changing package versions.
27
27
 
28
+ ## Resource and date/time settings
29
+
30
+ Use `settings.resource({ label, source: discord.channels() })` for a Discord channel selector. Import `discord` alongside `settings` from `@domino-sdk/relay/authoring`. The connected project integration supplies the server; never add a guild ID setting. Filter with `discord.channels({ types: ["announcement"] })` when only announcement channels qualify. The default includes text and announcement channels. Resource values are stable IDs, not labels.
31
+
32
+ Omit the resource default until the operator chooses a value in Console. Its empty string represents missing configuration. Code-authored quests publish paused when any resource setting is empty. Publishing a configured value activates the code-authored quest; clearing it pauses the quest again. Console-created quests must have every required resource configured before activating. Existing operation-level pauses remain independent.
33
+
34
+ Use `settings.datetime({ label, default: "2026-09-21T00:00:00+02:00" })` for an exact cutoff instant. Values normalize to UTC ISO timestamps. Console displays date and time with the operator's local timezone explicitly labeled. Offset-free timestamps and invalid calendar dates are rejected. This is not a date-only setting.
35
+
36
+ Resource selections are checked on the backend for Console and CLI publication, including final deployment publication after preview. The selected resource must belong to the current connected integration and be usable by its bot. Lookup caches do not replace publication validation. A temporary provider outage rejects the new publication without changing the existing release. Deleted selections remain visible for the operator to replace.
37
+
38
+ Replacing a text field with a resource or datetime field preserves Console overrides when their values pass the new validation. Keep the field key stable. Invalid overrides must be updated or reset explicitly; they are not silently discarded.
39
+
28
40
  ## Publish the authored resources
29
41
 
30
42
  Source owns reusable quest definitions, collection slots, and deployment defaults. Console owns operator-created quest instances, their placement, and custom setting values. Keep identifiers stable. Registering a new reusable type version does not upgrade existing instances.
31
43
 
32
44
  Entry `settings` supplies deployment defaults. Remote custom values take precedence on subsequent deployments. A removed or incompatible setting can block the batch. Use `settingRenames` for a deliberate rename, or have the operator reset the custom value in Console under **Quests**. Changing source defaults does not clear overrides.
33
45
 
34
- Use `domino build --json` or `domino deploy --dry-run --json` for offline bundling. These do not validate effective remote settings. Use `domino deploy --preview --json` to resolve and validate the remote batch without publishing. Check the selected scope before publication.
46
+ Use `domino build --json` or `domino deploy --dry-run --json` for offline bundling. To save the bundle, use `domino build --out .domino-build/deployment.json --json`; the CLI creates missing parent directories. These checks do not validate effective remote settings. Use `domino deploy --preview --json` to resolve and validate the remote batch without publishing. Check the selected scope before publication.
35
47
 
36
48
  Use `domino deploy --environment test --json` when test publication is part of the task. It publishes configured quests, types, collections, and supported interactions together. After a lost publication response, use the returned `domino deploy --resume <id>` recovery command. Starting another deployment may create another immutable release.
37
49
 
@@ -103,10 +115,55 @@ For `defineReferral`, invitation qualification, fixed rewards, and selective inv
103
115
 
104
116
  ## Verify Discord membership
105
117
 
106
- Use the `discord-membership` snippet for a server membership check, with an optional role requirement. An organization administrator first connects the project's server in Console **Integrations**. Declare `integrations: ["discord"]` on the quest and call `await ctx.discord.member()` inside `evaluate`. There is no server ID setting. The result is `null` for absent membership, or `{ roles, joinedAt, pending }`. Author code decides eligibility and the quest's declared rewards.
118
+ Use the `discord-membership` snippet for a server membership check, with an optional role requirement. An organization administrator first connects the project's server in Console **Integrations**. Declare `integrations: ["discord"]` on the quest and call `await ctx.discord.member()` inside `evaluate`. There is no server ID setting. The result is `null` for absent membership, or `{ userId, roles, joinedAt, pending, nickname, displayName, boostingSince }`. Author code decides eligibility and the quest's declared rewards.
107
119
 
108
120
  The platform supplies a read-only client bound to the project's configured server and the participant's Discord identity. Quest code cannot supply a user ID, server ID, access token, or arbitrary URL. Discord permission failures and outages fail verification; they are not evidence that the participant is ineligible. Bot access problems appear in Console Integrations. Participants signing in with Discord need no extra permission flow; members using another login link their Discord identity once.
109
121
 
110
122
  Successful reads, including absent membership, are stored with the account revision, integration revision, server, and observation time. Recovery reuses captured facts. Changing either connection invalidates unfinished reads. After a failed attempt, start a new submission if a connection changed. Membership screening (`pending`) and role requirements remain author decisions.
111
123
 
112
- The campaign starter includes participant identity controls. Existing apps use the same APIs through `createParticipantBackend`; see [participant API](participant-api.md#connected-accounts). The managed bot is configured by the platform, never by a quest author. Do not invent message, reaction, or write capabilities: `member` is the only Discord operation currently available. X remains unavailable.
124
+ The campaign starter includes participant identity controls. Existing apps use the same APIs through `createParticipantBackend`; see [participant API](participant-api.md#connected-accounts). The managed bot is configured by the platform, never by a quest author. The injected Discord client supports `member()` and `message({ channelId, messageId })`. Automatic reaction additions arrive separately through observations; see the observation-triggered quest guidance below. Discord role grants are declared rewards, never evaluator writes. X remains unavailable.
125
+
126
+ The release's `provider` setting selects the photo-check backend used by `ctx.review.photo`; it does not select the evaluator for Discord, quizzes, or other authored checks. `humanReview` allows quest code to return a review decision. It does not automatically route Discord failures to staff. The Discord membership snippet returns accept or reject decisions and leaves provider outages as errors.
127
+
128
+ ## Observation-triggered and repeatable quests
129
+
130
+ Use `automatic({ source, type })` for authenticated external activity. `automatic()` without arguments retains prerequisite-based triggering. Manual and automatic triggers are exclusive. Automatic quests never expose participant claim or retry actions. Set `visibility: "hidden"` to omit a quest from participant lists while it continues earning rewards.
131
+
132
+ Choose `once()` for one successful completion per participant, or `keyed(ctx => key)` for one per author-defined key. The key is scoped to the project, quest, and participant. It must remain stable across deployments. For announcement reactions use the message ID; for a daily manual quest derive the UTC date from `ctx.eligibleAt`. `completed(quest)` still means ever completed.
133
+
134
+ An observation has `id`, `source`, `type`, `member`, `occurredAt`, `receivedAt`, and JSON `data`. The evaluator reads `ctx.observation`, which is null for manual and prerequisite-triggered work. `ctx.eligibleAt` is fixed when admitted. The source delivery ID deduplicates transport; it is not the completion key. Only an accepted completion consumes a key. Replaying rejected evidence does not reconsider it, but a new observation can try an unconsumed key.
135
+
136
+ Declare budgets with `budget({ id, scope: "member" | "project", period: "day" | "lifetime", limit, cost })`. Use `cost: 1` for completion counts or `cost: { balance: "community" }` for the awarded points amount. Quests can share a named budget, with matching scope, period, and limit. All budget spends, the completion key, and rewards commit together. UTC-day budgets use the admitted eligibility day. An exhausted observation is rejected permanently; it does not roll into tomorrow. Concurrent valid awards commit in whichever order wins the transaction.
137
+
138
+ Admission pins the release and prerequisite result. Pausing stops new admissions while accepted work finishes. Republication does not clear successful completion keys. Observation execution errors retry with capped backoff and retain the original evidence and release. Points accumulate per completion. Tier rewards retain their existing entitlement upgrade behavior; repeating a quest does not create a new consumable handover.
139
+
140
+ Install `discord-announcements` for the first integration example. The source is `discord`, type `reaction.add`; parse its data with `discordReactionSchema`. Filter the configured channel, emoji, and `messagePublishedAt` in quest code. The Gateway captures the reaction addition; do not query current reactions to cancel it. Unseen replayed events with unknown timing are skipped and reported as coverage gaps. Manual Discord membership remains `ctx.discord.member()`. Use `discordRole(roleId)` for a declared role reward; no arbitrary Discord writes or X verification are exposed.
141
+
142
+ A trusted customer backend uses a management key with `observe` permission and calls `management.observations.submit({ id, source: "backend:orders", type: "paid", member, occurredAt, data })`. Use the stable delivery ID on retries. The backend supplies a trusted project member ID and action time, never browser claims. Backend sources must start with `backend:` and cannot impersonate `discord`. This endpoint can grant real rewards through matching quests; keep its credential on the server.
143
+
144
+ ## Discord contribution and recognition snippets
145
+
146
+ Install `discord-contribution` for channel participation and `discord-moderator-recognition` for moderator heart reactions. Use one snippet per capability; customize predicates in code.
147
+
148
+ - `message.create` targets the message author. Parse with `discordMessageSchema`. Fields are `guildId`, `channelId`, `messageId`, `userId`, nullable `content`, nullable `replyTo`, and `publishedAt`. Bots and webhooks are excluded. Content needs the platform bot's privileged Message Content intent and `DISCORD_MESSAGE_CONTENT=true`; treat null as unavailable evidence.
149
+ - `reaction.add` targets the reactor. `reaction.received` targets the message author. Parse either payload with `discordReactionSchema`, which adds nullable `messageAuthorId` and `actorRoles`. Authorization roles come from capture time.
150
+ - `observation.member` is the beneficiary; optional `observation.actor` is `{ provider, subject }` identifying the external actor. The beneficiary owns prerequisites, completion keys, budgets and rewards. A moderator need not link a Relay account. Reject self-tips and key by message ID to prevent repeated recognition rewards.
151
+ - `ctx.discord.message({ channelId, messageId })` is server-scoped and returns null for deleted messages. For manual authorship checks, compare message `userId` to a non-null `userId` from `ctx.discord.member()`. Older captured member facts can return null; fail closed. For automatic events, use the trusted observation identity. Captured reads survive evaluation retries.
152
+
153
+ Daily budgets are caps, not counters or streaks. Multi-action progress remains a separate design proposal.
154
+
155
+ ## Discord role rewards
156
+
157
+ Import `discordRole` from `@domino-sdk/relay/authoring`. Declare `rewards: [discordRole(roleId)]`. Install `discord-role-reward` for a complete prerequisite-gated example. Its resource setting uses `discord.roles({ assignable: true })`; ordinary eligibility roles use `discord.roles()`.
158
+
159
+ The ledger commits one durable delivery per completion and role. Delivery retries separately from quest evaluation using an idempotent role assignment. Never write to Discord inside evaluate. Pending delivery is bound to the original account/server, so reconnecting a different identity cannot redirect it. Restoring the original connection and permissions permits recovery. The bot needs Manage Roles and a role above the target. Managed/everyone roles cannot be reward roles.
160
+
161
+ Use `relay.me.rewardDeliveries()` for the current member or `management.rewardDeliveries(member?)` for operator inspection. Lists contain the latest 500 deliveries in the selected scope. Each record contains quest, completion, role, status, attempts and retry details. Participant UI should show pending/granted status without operator error details. Referral reward definitions still accept points and tier rewards; qualify a role-granting quest if composing them.
162
+
163
+ ## Entry after app-owned pass verification
164
+
165
+ Author the entry quest with `manual({ actor: "staff" })` and no evidence input. The participant app verifies its event pass on its server, resolves the signed-in member, and calls `POST /management/v1/passes/verify` with `{ provider, event, subject, member, quest }`. Use a server-only management key with `confirm` permission in the intended project and environment. Relay records unique pass ownership and submits the entry attempt; it does not decide whether the external ticket is valid. Repeating the same pass for the same member reuses the durable attempt. A different member cannot claim that pass, and one member cannot link a second pass for the same provider and event.
166
+
167
+ ## Hosted server applications
168
+
169
+ Quest discovery and private resource artifacts work the same way for static and Worker apps. Keep quest definitions outside the public asset directory. For server-rendered applications, configure the Worker module output and browser asset output separately in `relay.json`; see [hosted apps](hosted.md). With `app.routing="transparent"`, hosting reserves no paths. Omitting the setting preserves legacy routing. The starter owns its `/relay/*` proxy as ordinary application code; custom handlers can take precedence.