@domino-sdk/relay-cli 0.5.0 → 0.7.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,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,13 +1,15 @@
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 = {};
11
13
  const scopeSources = {};
12
14
  for (const key of ["apiUrl", "organization", "project", "environment"]) {
13
15
  const env =
@@ -27,19 +29,19 @@ export function action(handler) {
27
29
  !["test", "live"].includes(connection.environment)
28
30
  )
29
31
  throw new Error("Environment must be test or live.");
32
+ if (environment) connection.environment = environment;
33
+ if (projectScope) connection = await projectConnection(connection, options);
30
34
  const result = await handler(
31
35
  { options, project, connection, scopeSources },
32
36
  ...command.processedArgs,
33
37
  );
34
- console.log(JSON.stringify(result, null, options.json ? undefined : 2));
38
+ console.log(formatResult(command.name(), result, options));
35
39
  };
36
40
  }
37
41
 
38
42
  export async function readStdin() {
39
43
  if (process.stdin.isTTY)
40
- throw new Error(
41
- "Pipe input to stdin; interactive prompts are not supported.",
42
- );
44
+ throw new Error("Pipe input to stdin for this option.");
43
45
  let input = "";
44
46
  for await (const chunk of process.stdin) input += chunk;
45
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
+ }
@@ -25,6 +25,24 @@ 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
+ ## Point ledger settings
29
+
30
+ Use `settings.pointLedger({ label: "Point ledger", default: "community" })` for a points balance setting. The value is a stable ledger ID, passed to `points(settings.balance, settings.points)`. Console renders a searchable ledger picker. Create and edit names and descriptions in **Rewards → Point ledgers**, or use `management.pointLedgers.save({ id, name, description })` from a trusted backend. Names need not be unique. Never use a display name as an accounting key.
31
+
32
+ Existing balance keys retain their IDs and history. Existing `settings.text` declarations should be changed to `settings.pointLedger` and deployed as a new type version; Console supports preserving values when upgrading from text to a ledger setting. Upgrade existing instances to that version to show the picker. Ledger metadata is project/environment-scoped and is not part of a deployment bundle; save the same ID in each target environment when sharing authored definitions.
33
+
34
+ ## Resource and date/time settings
35
+
36
+ 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.
37
+
38
+ 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.
39
+
40
+ 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.
41
+
42
+ 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.
43
+
44
+ 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.
45
+
28
46
  ## Publish the authored resources
29
47
 
30
48
  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.
@@ -103,16 +121,51 @@ For `defineReferral`, invitation qualification, fixed rewards, and selective inv
103
121
 
104
122
  ## Verify Discord membership
105
123
 
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.
124
+ 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
125
 
108
126
  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
127
 
110
128
  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
129
 
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.
130
+ 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.
113
131
 
114
132
  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.
115
133
 
134
+ ## Observation-triggered and repeatable quests
135
+
136
+ 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.
137
+
138
+ 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.
139
+
140
+ 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.
141
+
142
+ 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.
143
+
144
+ 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.
145
+
146
+ 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.
147
+
148
+ 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.
149
+
150
+ ## Discord contribution and recognition snippets
151
+
152
+ Install `discord-contribution` for channel participation and `discord-moderator-recognition` for moderator heart reactions. Use one snippet per capability; customize predicates in code.
153
+
154
+ - `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.
155
+ - `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.
156
+ - `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.
157
+ - `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.
158
+
159
+ Daily budgets are caps, not counters or streaks. Multi-action progress remains a separate design proposal.
160
+
161
+ ## Discord role rewards
162
+
163
+ 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()`.
164
+
165
+ 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.
166
+
167
+ 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.
168
+
116
169
  ## Entry after app-owned pass verification
117
170
 
118
171
  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.
@@ -21,3 +21,5 @@ An organization administrator creates an identity integration in Console Setting
21
21
  Mount the adapter at `/relay/*` and use `createBrowserRelayClient({ baseUrl: "/relay" })`. Verify a signed-in session, one participant action, and that logging out of the application removes access. The adapter checks application authentication on every request and derives project scope from the integration key. No custom fetch wrapper or browser project headers are needed.
22
22
 
23
23
  Read [authoring](authoring.md) when adding quests or collection slots. Run the app's normal checks and development server. Use its existing preview deployment process when available. `domino deploy` publishes Domino behavior; it does not deploy the app. `domino stage` requires a supported Domino-hosted static repository, so it is not a general deployment command for existing apps.
24
+
25
+ For configurable point rewards, declare the balance setting with `settings.pointLedger` and pass its value to `points`. This gives operators a ledger picker in Console. Ledger IDs stay stable when display names change. Create ledger metadata under **Rewards → Point ledgers**, and use the same ID in each environment targeted by the app's definitions. Existing types using `settings.text` need a new type version and an instance upgrade to adopt the picker.
@@ -106,12 +106,23 @@ To choose a different identity, first call `relay.auth.disconnect("discord")`. T
106
106
 
107
107
  The campaign starter includes these account controls. Apps using `createParticipantBackend` retain their own login and use the same APIs. The adapter preserves OAuth binding cookies and its session during return navigation; the application's own login cookie must also support top-level OAuth returns. If the app session changes during consent, start connection again.
108
108
 
109
- After changing an account or integration during a failed attempt, use the quest controller's `discard()` to clear the saved failed submission, then submit again. The starter labels this **Start a new submission**. Discard is allowed only after a confirmed failed or rejected attempt. It keeps server history and cannot discard an active or accepted attempt. Use `resume()` when the connection has not changed to preserve captured facts.
109
+ After changing an account or integration during a failed attempt, use the quest controller's `discard()` to clear the saved failed submission, then submit again. The starter labels this **Start a new submission**. Discard is allowed after a confirmed failed or rejected attempt. A keyed quest controller can also discard a confirmed accepted browser submission to begin another occurrence. Server history and completion keys remain intact. Active attempts cannot be discarded. Use `resume()` when the connection has not changed to preserve captured facts.
110
110
 
111
111
  ## Resource IDs
112
112
 
113
113
  Treat resource IDs as opaque strings and store the complete value. New generated IDs keep their resource prefix, such as `member_`, `session_`, or `attempt_`, followed by 22 random alphanumeric characters. Existing IDs remain valid. Do not parse the suffix as a UUID.
114
114
 
115
+
116
+ ## Repeatable and hidden quests
117
+
118
+ Quest views include `completion`, `completionCount`, and `visibility`. For keyed quests, `completed` means at least one past completion; it is not terminal. Use `availability` for the current action and display the count separately. Filter hidden quests from participant lists and collections, including direct quest routes. The campaign starter does this. Visibility is not an authorization boundary.
119
+
120
+ Automatic quests use status-only controllers. Never offer a manual claim or retry button, including after a previous completion or execution failure. Their admitted observations recover on the server. Keyed manual controllers may start another submission after a confirmed success; uncertain submissions still resume their original action ID.
121
+
122
+ ## Discord reward delivery
123
+
124
+ Call `relay.me.rewardDeliveries()` to read role deliveries for the signed-in member. The backend route is `GET /v1/reward-deliveries`; it is available through the participant proxy and existing-app identity integration. The response contains the latest 500 deliveries for this member. Records include `quest`, `completion`, `roleId`, and `status` of `pending` or `delivered`. Display delivery independently of quest completion, since Discord can be temporarily unavailable after points have been awarded. Do not display internal `lastError` details to participants.
125
+
115
126
  ## Removed demo shortcuts
116
127
 
117
128
  `auth.demoSignIn` and the campaign template endpoint are no longer supported. Use managed sign-in or a registered identity integration. The platform participant `me.linkPass` route remains unconfigured. An app can handle that route itself and verify a pass on its server, then call `POST /management/v1/passes/verify` with a server-only management key carrying `confirm` permission. Send `{ provider, event, subject, member, quest }` and the usual organization, project, and environment headers. Resolve the member from the authenticated participant session, never from browser input. The quest must be staff-confirmed with no evidence. Relay binds ownership uniquely per provider/event in that project, preserves the authenticated management actor, and retries the same entry attempt. There is no built-in event-code verifier.
package/cli.mjs CHANGED
@@ -62,6 +62,7 @@ const program = new Command()
62
62
  "--config <file>",
63
63
  "Use this manifest instead of the nearest relay.json",
64
64
  )
65
+ .option("--no-interactive", "Disable terminal prompts")
65
66
  .option("--json", "Emit machine-readable JSON results and errors")
66
67
  .addHelpCommand("help [command]", "Display help for a command")
67
68
  .configureHelp({ showGlobalOptions: true })
@@ -87,7 +88,7 @@ Run domino doctor --json to see effective scope values and their sources.
87
88
  API default: https://relay.domino.run. Init defaults to test.
88
89
  Credentials: RELAY_MANAGEMENT_TOKEN > saved token for the API origin.
89
90
  Run domino login for browser sign-in. Live publication requires Console.
90
- Commands never prompt. Results go to stdout; errors go to stderr. Exit 1 on failure.`,
91
+ Missing choices are prompted in a terminal. Deploy and stage ask for confirmation; --yes skips it. Use --no-interactive or --json to disable prompts. Results go to stdout; errors go to stderr. Exit 1 on failure.`,
91
92
  )
92
93
  .action(() => program.outputHelp());
93
94