@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.
- package/README.md +14 -0
- package/cli/agents.mjs +111 -0
- package/cli/commands/agents.mjs +50 -0
- package/cli/commands/api.mjs +34 -0
- package/cli/commands/auth.mjs +60 -0
- package/cli/commands/hosting.mjs +91 -0
- package/cli/commands/inspect.mjs +47 -0
- package/cli/commands/project.mjs +160 -0
- package/cli/commands/staging.mjs +94 -0
- package/cli/connection.mjs +114 -0
- package/cli/dev.mjs +231 -0
- package/cli/device-login.mjs +80 -0
- package/cli/doctor.mjs +156 -0
- package/cli/git.mjs +165 -0
- package/cli/project.mjs +206 -0
- package/cli/runtime.mjs +39 -0
- package/cli/skills/domino/SKILL.md +30 -0
- package/cli/skills/domino/references/authoring.md +15 -0
- package/cli/skills/domino/references/existing-app.md +19 -0
- package/cli/skills/domino/references/hosted.md +17 -0
- package/cli.mjs +112 -0
- package/dist/index.d.ts +5724 -0
- package/dist/index.js +428 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Domino CLI
|
|
2
|
+
|
|
3
|
+
Connect a project to Domino, install agent instructions, and build and deploy quests.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm add -D @domino-sdk/relay-cli
|
|
7
|
+
pnpm exec domino --help
|
|
8
|
+
pnpm exec domino agents install
|
|
9
|
+
pnpm exec domino doctor
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The package includes project skills for Codex and Claude Code. `agents install` preserves existing instructions and customized skill files.
|
|
13
|
+
|
|
14
|
+
Requires Node.js 24. Proprietary software; `UNLICENSED`.
|
package/cli/agents.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const locations = {
|
|
5
|
+
codex: ".agents/skills/domino",
|
|
6
|
+
claude: ".claude/skills/domino",
|
|
7
|
+
};
|
|
8
|
+
const resources = [
|
|
9
|
+
"SKILL.md",
|
|
10
|
+
"references/hosted.md",
|
|
11
|
+
"references/existing-app.md",
|
|
12
|
+
"references/authoring.md",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
async function skillFiles(agent) {
|
|
16
|
+
const agents = agent === "both" ? Object.keys(locations) : [agent];
|
|
17
|
+
if (agents.some((name) => !Object.hasOwn(locations, name)))
|
|
18
|
+
throw new Error("Choose codex, claude, or both.");
|
|
19
|
+
const files = [];
|
|
20
|
+
for (const resource of resources) {
|
|
21
|
+
const content = await readFile(
|
|
22
|
+
new URL(`./skills/domino/${resource}`, import.meta.url),
|
|
23
|
+
"utf8",
|
|
24
|
+
);
|
|
25
|
+
for (const name of agents)
|
|
26
|
+
files.push({ path: `${locations[name]}/${resource}`, content });
|
|
27
|
+
}
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check the whole destination before creating files. Never follow a project's
|
|
32
|
+
// skill-directory symlinks or replace another author's instructions.
|
|
33
|
+
async function existingFile(root, path) {
|
|
34
|
+
const parts = path.split("/");
|
|
35
|
+
let current = root;
|
|
36
|
+
for (const [index, part] of parts.entries()) {
|
|
37
|
+
current = join(current, part);
|
|
38
|
+
let info;
|
|
39
|
+
try {
|
|
40
|
+
info = await lstat(current);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === "ENOENT") return null;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
if (info.isSymbolicLink())
|
|
46
|
+
throw new Error(`Refusing to write through symlink ${current}.`);
|
|
47
|
+
if (index < parts.length - 1 && !info.isDirectory())
|
|
48
|
+
throw new Error(`Expected a directory at ${current}.`);
|
|
49
|
+
if (index === parts.length - 1 && !info.isFile())
|
|
50
|
+
throw new Error(`Expected a file at ${current}.`);
|
|
51
|
+
}
|
|
52
|
+
return readFile(current, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function installAgentSkills(directory, agent = "both") {
|
|
56
|
+
const root = await realpath(resolve(directory));
|
|
57
|
+
const files = await skillFiles(agent);
|
|
58
|
+
const created = [];
|
|
59
|
+
const unchanged = [];
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const existing = await existingFile(root, file.path);
|
|
62
|
+
if (existing === null) created.push(file.path);
|
|
63
|
+
else if (existing === file.content) unchanged.push(file.path);
|
|
64
|
+
else
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Refusing to overwrite ${join(root, file.path)}. Preserve your changes and reconcile this file with the installed CLI's Domino skill before retrying.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (const file of files.filter((file) => created.includes(file.path))) {
|
|
70
|
+
// Recheck before each write so retries can adopt files from another install.
|
|
71
|
+
const existing = await existingFile(root, file.path);
|
|
72
|
+
if (existing === file.content) continue;
|
|
73
|
+
if (existing !== null)
|
|
74
|
+
throw new Error(`Refusing to overwrite ${join(root, file.path)}.`);
|
|
75
|
+
await mkdir(dirname(join(root, file.path)), { recursive: true });
|
|
76
|
+
await writeFile(join(root, file.path), file.content, { flag: "wx" });
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
directory: root,
|
|
80
|
+
agent,
|
|
81
|
+
created,
|
|
82
|
+
unchanged,
|
|
83
|
+
next: "Open your agent in this project and ask it to use the Domino skill. Run domino doctor --json to inspect setup; add --remote to verify project access.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function inspectAgentSkills(directory) {
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const agent of Object.keys(locations)) {
|
|
90
|
+
let found = 0;
|
|
91
|
+
let modified = false;
|
|
92
|
+
for (const file of await skillFiles(agent)) {
|
|
93
|
+
try {
|
|
94
|
+
const content = await readFile(join(directory, file.path), "utf8");
|
|
95
|
+
found++;
|
|
96
|
+
if (content !== file.content) modified = true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code !== "ENOENT") throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
result[agent] =
|
|
102
|
+
found === 0
|
|
103
|
+
? "missing"
|
|
104
|
+
: found !== resources.length
|
|
105
|
+
? "incomplete"
|
|
106
|
+
: modified
|
|
107
|
+
? "modified"
|
|
108
|
+
: "installed";
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { action } from "../runtime.mjs";
|
|
4
|
+
import { installAgentSkills } from "../agents.mjs";
|
|
5
|
+
import { diagnose } from "../doctor.mjs";
|
|
6
|
+
|
|
7
|
+
export function registerAgentCommands(program) {
|
|
8
|
+
program
|
|
9
|
+
.command("agents")
|
|
10
|
+
.description("Set up project instructions for your coding agent")
|
|
11
|
+
.command("install")
|
|
12
|
+
.description(
|
|
13
|
+
"Install Domino skills without replacing existing instructions",
|
|
14
|
+
)
|
|
15
|
+
.option(
|
|
16
|
+
"--directory <path>",
|
|
17
|
+
"Project root (defaults to the linked project or current directory)",
|
|
18
|
+
)
|
|
19
|
+
.addOption(
|
|
20
|
+
new Option("--agent <name>", "Agent to configure")
|
|
21
|
+
.choices(["codex", "claude", "both"])
|
|
22
|
+
.default("both"),
|
|
23
|
+
)
|
|
24
|
+
.action(
|
|
25
|
+
action(({ project, options }) =>
|
|
26
|
+
installAgentSkills(
|
|
27
|
+
options.directory ??
|
|
28
|
+
(project ? dirname(project.path) : process.cwd()),
|
|
29
|
+
options.agent,
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command("doctor")
|
|
36
|
+
.description(
|
|
37
|
+
"Inspect local agent setup without changing files or running app code",
|
|
38
|
+
)
|
|
39
|
+
.option(
|
|
40
|
+
"--remote",
|
|
41
|
+
"Also verify access to the selected project through the management API",
|
|
42
|
+
)
|
|
43
|
+
.action(
|
|
44
|
+
action(async (context) => {
|
|
45
|
+
const result = await diagnose(context);
|
|
46
|
+
if (!result.healthy) process.exitCode = 1;
|
|
47
|
+
return result;
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Argument } from "commander";
|
|
2
|
+
import { action, readStdin } from "../runtime.mjs";
|
|
3
|
+
import { request } from "../connection.mjs";
|
|
4
|
+
import { readJson } from "../project.mjs";
|
|
5
|
+
|
|
6
|
+
export function registerApiCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command("api")
|
|
9
|
+
.description("Call a /management/v1 route")
|
|
10
|
+
.addArgument(
|
|
11
|
+
new Argument("<method>", "HTTP method").choices([
|
|
12
|
+
"GET",
|
|
13
|
+
"POST",
|
|
14
|
+
"PUT",
|
|
15
|
+
"DELETE",
|
|
16
|
+
"PATCH",
|
|
17
|
+
]),
|
|
18
|
+
)
|
|
19
|
+
.argument("<path>", "Path within /management/v1, for example /quests")
|
|
20
|
+
.argument("[file]", "JSON body file, or - for stdin")
|
|
21
|
+
.action(
|
|
22
|
+
action(async ({ connection }, method, path, file) => {
|
|
23
|
+
if (method === "GET" && file)
|
|
24
|
+
throw new Error("GET does not accept a body file.");
|
|
25
|
+
const body =
|
|
26
|
+
file === "-"
|
|
27
|
+
? JSON.parse(await readStdin())
|
|
28
|
+
: file
|
|
29
|
+
? await readJson(file)
|
|
30
|
+
: undefined;
|
|
31
|
+
return request(connection, path, method, body);
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { action, readStdin } from "../runtime.mjs";
|
|
2
|
+
import { request, saveToken, savedToken } from "../connection.mjs";
|
|
3
|
+
import { browserLogin } from "../device-login.mjs";
|
|
4
|
+
|
|
5
|
+
export function registerAuthCommands(program) {
|
|
6
|
+
program
|
|
7
|
+
.command("login")
|
|
8
|
+
.description("Sign in through Console, or verify and save a personal token")
|
|
9
|
+
.option("--no-browser", "Print the sign-in URL without opening a browser")
|
|
10
|
+
.option(
|
|
11
|
+
"--token-stdin",
|
|
12
|
+
"Read the token from stdin instead of RELAY_MANAGEMENT_TOKEN",
|
|
13
|
+
)
|
|
14
|
+
.action(
|
|
15
|
+
action(async ({ connection, options }) => {
|
|
16
|
+
const token = (
|
|
17
|
+
options.tokenStdin
|
|
18
|
+
? await readStdin()
|
|
19
|
+
: process.env.RELAY_MANAGEMENT_TOKEN
|
|
20
|
+
)?.trim();
|
|
21
|
+
if (!token && !options.tokenStdin)
|
|
22
|
+
return browserLogin(connection, {
|
|
23
|
+
...options,
|
|
24
|
+
noBrowser: options.browser === false,
|
|
25
|
+
});
|
|
26
|
+
if (!token) throw new Error("Provide a personal token on stdin.");
|
|
27
|
+
const access = await request({ ...connection, token }, "/access");
|
|
28
|
+
await saveToken(connection.apiUrl, token);
|
|
29
|
+
return {
|
|
30
|
+
loggedIn: true,
|
|
31
|
+
apiUrl: connection.apiUrl,
|
|
32
|
+
actor: access.actor,
|
|
33
|
+
};
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command("logout")
|
|
39
|
+
.description(
|
|
40
|
+
"Sign out and revoke a saved CLI session; remove saved personal tokens",
|
|
41
|
+
)
|
|
42
|
+
.action(
|
|
43
|
+
action(async ({ connection }) => {
|
|
44
|
+
const token = await savedToken(connection.apiUrl);
|
|
45
|
+
if (token?.startsWith("dc_"))
|
|
46
|
+
await request({ ...connection, token }, "/cli/session", "DELETE");
|
|
47
|
+
await saveToken(connection.apiUrl, null);
|
|
48
|
+
return {
|
|
49
|
+
loggedOut: true,
|
|
50
|
+
apiUrl: connection.apiUrl,
|
|
51
|
+
note: "Saved credential removed. Environment tokens remain active. Revoke tokens in Console Settings.",
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command("whoami")
|
|
58
|
+
.description("Show identity and accessible projects")
|
|
59
|
+
.action(action(({ connection }) => request(connection, "/access")));
|
|
60
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { action } from "../runtime.mjs";
|
|
5
|
+
import { request } from "../connection.mjs";
|
|
6
|
+
import { checkout, requireNewDirectory } from "../git.mjs";
|
|
7
|
+
import { dev } from "../dev.mjs";
|
|
8
|
+
import { bundleCatalog, bundleProject } from "../project.mjs";
|
|
9
|
+
|
|
10
|
+
export function registerHostingCommands(program) {
|
|
11
|
+
program
|
|
12
|
+
.command("check")
|
|
13
|
+
.description(
|
|
14
|
+
"Check the local app and bundle quest definitions without publishing",
|
|
15
|
+
)
|
|
16
|
+
.action(
|
|
17
|
+
action(async ({ project }) => {
|
|
18
|
+
if (!project) throw new Error("No relay.json found.");
|
|
19
|
+
if (project.config.app) {
|
|
20
|
+
try {
|
|
21
|
+
await promisify(execFile)(
|
|
22
|
+
"pnpm",
|
|
23
|
+
["run", project.config.app.checkScript],
|
|
24
|
+
{ cwd: dirname(project.path), maxBuffer: 10 * 1024 * 1024 },
|
|
25
|
+
);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(error.stdout || error.stderr || error.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const quests = project.config.quests.length
|
|
31
|
+
? await bundleProject(project)
|
|
32
|
+
: { releases: [] };
|
|
33
|
+
const catalog = await bundleCatalog(project);
|
|
34
|
+
return {
|
|
35
|
+
checked: true,
|
|
36
|
+
quests: quests.releases.length,
|
|
37
|
+
types: catalog.types.length,
|
|
38
|
+
collections: catalog.collections.length,
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
program
|
|
43
|
+
.command("dev")
|
|
44
|
+
.description("Run the campaign and an isolated local Relay runtime")
|
|
45
|
+
.option("--port <port>", "App development server port")
|
|
46
|
+
.action(action(({ project, options }) => dev(project, options)));
|
|
47
|
+
program
|
|
48
|
+
.command("create")
|
|
49
|
+
.description("Create a hosted campaign repository and check it out locally")
|
|
50
|
+
.argument("<name>", "Project identifier")
|
|
51
|
+
.option("--directory <path>", "Local directory (defaults to project name)")
|
|
52
|
+
.action(
|
|
53
|
+
action(async ({ connection, options }, name) => {
|
|
54
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name))
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Use letters, numbers, underscores, or hyphens for the project name.",
|
|
57
|
+
);
|
|
58
|
+
const directory = resolve(options.directory ?? name);
|
|
59
|
+
await requireNewDirectory(directory);
|
|
60
|
+
const access = await request(connection, "/access");
|
|
61
|
+
const organization = connection.organization || access.organization;
|
|
62
|
+
if (!organization)
|
|
63
|
+
throw new Error("Select an organization with --organization.");
|
|
64
|
+
const scoped = {
|
|
65
|
+
...connection,
|
|
66
|
+
organization,
|
|
67
|
+
project: name,
|
|
68
|
+
environment: "test",
|
|
69
|
+
};
|
|
70
|
+
await request(scoped, "/projects", "POST", { project: name, name });
|
|
71
|
+
const repository = await request(scoped, "/repository", "POST", {
|
|
72
|
+
starter: "campaign",
|
|
73
|
+
});
|
|
74
|
+
return checkout(scoped, repository, directory);
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
program
|
|
78
|
+
.command("checkout")
|
|
79
|
+
.description(
|
|
80
|
+
"Recover or clone a hosted campaign with Domino Git authentication",
|
|
81
|
+
)
|
|
82
|
+
.argument("<project>")
|
|
83
|
+
.option("--directory <path>", "Local directory (defaults to project name)")
|
|
84
|
+
.action(
|
|
85
|
+
action(async ({ connection, options }, project) => {
|
|
86
|
+
const scoped = { ...connection, project, environment: "test" };
|
|
87
|
+
const repository = await request(scoped, "/repository");
|
|
88
|
+
return checkout(scoped, repository, options.directory ?? project);
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { InvalidArgumentError } from "commander";
|
|
2
|
+
import { action } from "../runtime.mjs";
|
|
3
|
+
import { request } from "../connection.mjs";
|
|
4
|
+
|
|
5
|
+
function integerBetween(min, max) {
|
|
6
|
+
return (value) => {
|
|
7
|
+
if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max)
|
|
8
|
+
throw new InvalidArgumentError(
|
|
9
|
+
`Expected an integer between ${min} and ${max}.`,
|
|
10
|
+
);
|
|
11
|
+
return Number(value);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerInspectCommands(program) {
|
|
16
|
+
program
|
|
17
|
+
.command("status")
|
|
18
|
+
.description("Show the project overview")
|
|
19
|
+
.action(action(({ connection }) => request(connection, "/overview")));
|
|
20
|
+
|
|
21
|
+
for (const [name, description] of [
|
|
22
|
+
["quests", "List current quests"],
|
|
23
|
+
["releases", "List release history"],
|
|
24
|
+
]) {
|
|
25
|
+
program
|
|
26
|
+
.command(name)
|
|
27
|
+
.description(description)
|
|
28
|
+
.option("--limit <number>", "Page size (1–100)", integerBetween(1, 100))
|
|
29
|
+
.option(
|
|
30
|
+
"--offset <number>",
|
|
31
|
+
"Page offset (0–1000000)",
|
|
32
|
+
integerBetween(0, 1000000),
|
|
33
|
+
)
|
|
34
|
+
.option("--filter <value>", "Filter the collection")
|
|
35
|
+
.option("--q <text>", "Search the collection")
|
|
36
|
+
.action(
|
|
37
|
+
action(({ connection, options }) => {
|
|
38
|
+
const query = new URLSearchParams();
|
|
39
|
+
for (const key of ["limit", "offset", "filter", "q"]) {
|
|
40
|
+
if (options[key] !== undefined)
|
|
41
|
+
query.set(key, String(options[key]));
|
|
42
|
+
}
|
|
43
|
+
return request(connection, `/${name}?${query}`);
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { action } from "../runtime.mjs";
|
|
3
|
+
import { request } from "../connection.mjs";
|
|
4
|
+
import { initialize, bundleProject } from "../project.mjs";
|
|
5
|
+
|
|
6
|
+
function summary(value) {
|
|
7
|
+
return {
|
|
8
|
+
deployment: value.id,
|
|
9
|
+
revision: value.revision ?? value.baseRevision,
|
|
10
|
+
releases: value.releases.map(
|
|
11
|
+
({ id, quest, title, createdAt, configuration, values }) => ({
|
|
12
|
+
id,
|
|
13
|
+
quest,
|
|
14
|
+
title,
|
|
15
|
+
createdAt,
|
|
16
|
+
deploymentDefaults: configuration.defaults,
|
|
17
|
+
customValues: configuration.overrides,
|
|
18
|
+
preservedFromOlderRelease: configuration.preserved,
|
|
19
|
+
effectiveSettings: values,
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
types: value.types.map((t) => ({
|
|
23
|
+
id: t.id,
|
|
24
|
+
version: t.version,
|
|
25
|
+
title: t.definition.title,
|
|
26
|
+
})),
|
|
27
|
+
collections: value.collections,
|
|
28
|
+
supportedInteractions: value.supportedInteractions,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function publish(connection, id) {
|
|
32
|
+
try {
|
|
33
|
+
const result = await request(connection, "/deployments/publish", "POST", {
|
|
34
|
+
id,
|
|
35
|
+
});
|
|
36
|
+
return { status: "published", ...summary(result) };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.status === undefined || error.status >= 500)
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Publication of ${id} was not confirmed. Retry with domino deploy --resume ${id}. ${error.message}`,
|
|
41
|
+
);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function build({ project, connection, options }, deploy) {
|
|
46
|
+
if (!project)
|
|
47
|
+
throw new Error(
|
|
48
|
+
"No relay.json found. Run domino init or pass --config FILE.",
|
|
49
|
+
);
|
|
50
|
+
if (options.resume && (options.preview || options.dryRun || options.out))
|
|
51
|
+
throw new Error(
|
|
52
|
+
"--resume publishes an existing preview; do not combine it with --preview, --dry-run, or --out.",
|
|
53
|
+
);
|
|
54
|
+
if (options.preview && options.dryRun)
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Choose --preview for a server preview or --dry-run for an offline bundle.",
|
|
57
|
+
);
|
|
58
|
+
if (options.out && deploy)
|
|
59
|
+
throw new Error("--out requires build or deploy --dry-run.");
|
|
60
|
+
if (deploy && !options.preview && connection.environment === "live")
|
|
61
|
+
throw new Error(
|
|
62
|
+
"Live publication requires Console approval. Run deploy --dry-run --out deployment.json, then import the project deployment in Console.",
|
|
63
|
+
);
|
|
64
|
+
if (options.resume) return publish(connection, options.resume);
|
|
65
|
+
const payload = await bundleProject(project);
|
|
66
|
+
if (deploy) {
|
|
67
|
+
const preview = await request(
|
|
68
|
+
connection,
|
|
69
|
+
"/deployments/preview",
|
|
70
|
+
"POST",
|
|
71
|
+
payload,
|
|
72
|
+
);
|
|
73
|
+
if (options.preview) return { status: "preview", ...summary(preview) };
|
|
74
|
+
return publish(connection, preview.id);
|
|
75
|
+
}
|
|
76
|
+
if (options.out) {
|
|
77
|
+
await writeFile(options.out, JSON.stringify(payload, null, 2) + "\n");
|
|
78
|
+
return {
|
|
79
|
+
file: options.out,
|
|
80
|
+
quests: payload.releases.length,
|
|
81
|
+
types: payload.types.length,
|
|
82
|
+
collections: payload.collections.length,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerProjectCommands(program) {
|
|
89
|
+
program
|
|
90
|
+
.command("init")
|
|
91
|
+
.description("Link an existing project without generating starter data")
|
|
92
|
+
.argument("[directory]", "Existing project directory", ".")
|
|
93
|
+
.option(
|
|
94
|
+
"--entry <path>",
|
|
95
|
+
"Register an existing quest entry, relative to the target directory (repeatable)",
|
|
96
|
+
(entry, previous = []) => [...previous, entry],
|
|
97
|
+
)
|
|
98
|
+
.action(
|
|
99
|
+
action(async ({ connection, options }, directory) => {
|
|
100
|
+
const access = await request(connection, "/access");
|
|
101
|
+
const selected = connection.project
|
|
102
|
+
? access.projects.find((item) => item.project === connection.project)
|
|
103
|
+
: access.projects.length === 1
|
|
104
|
+
? access.projects[0]
|
|
105
|
+
: null;
|
|
106
|
+
if (!selected)
|
|
107
|
+
throw new Error(
|
|
108
|
+
"Select an accessible project with --project ID. Run domino whoami to list projects; create projects in Console.",
|
|
109
|
+
);
|
|
110
|
+
const environment = connection.environment ?? "test";
|
|
111
|
+
if (!selected.environments.includes(environment))
|
|
112
|
+
throw new Error(`No access to ${environment} for this project.`);
|
|
113
|
+
return initialize(directory, {
|
|
114
|
+
...connection,
|
|
115
|
+
organization: access.organization,
|
|
116
|
+
project: selected.project,
|
|
117
|
+
environment,
|
|
118
|
+
quests: (options.entry ?? []).map((entry) => ({
|
|
119
|
+
entry,
|
|
120
|
+
settings: {},
|
|
121
|
+
provider: "workers-ai",
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
program
|
|
128
|
+
.command("build")
|
|
129
|
+
.description(
|
|
130
|
+
"Bundle all configured quests, reusable types, and collection slots without publishing",
|
|
131
|
+
)
|
|
132
|
+
.option(
|
|
133
|
+
"--out <file>",
|
|
134
|
+
"Write the complete project bundle to a file instead of stdout",
|
|
135
|
+
)
|
|
136
|
+
.action(action((context) => build(context, false)));
|
|
137
|
+
|
|
138
|
+
program
|
|
139
|
+
.command("deploy")
|
|
140
|
+
.description(
|
|
141
|
+
"Atomically deploy quests, reusable types, and collection slots, preserving Console-owned settings and placement",
|
|
142
|
+
)
|
|
143
|
+
.option(
|
|
144
|
+
"--dry-run",
|
|
145
|
+
"Build locally only; does not preview Relay custom values",
|
|
146
|
+
)
|
|
147
|
+
.option(
|
|
148
|
+
"--resume <id>",
|
|
149
|
+
"Publish an existing preview or recover its committed receipt",
|
|
150
|
+
)
|
|
151
|
+
.option(
|
|
152
|
+
"--preview",
|
|
153
|
+
"Resolve the full deployment against Relay without publishing",
|
|
154
|
+
)
|
|
155
|
+
.option(
|
|
156
|
+
"--out <file>",
|
|
157
|
+
"Write the combined bundle for Console approval (requires --dry-run)",
|
|
158
|
+
)
|
|
159
|
+
.action(action((context) => build(context, !context.options.dryRun)));
|
|
160
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { action } from "../runtime.mjs";
|
|
4
|
+
import { request } from "../connection.mjs";
|
|
5
|
+
import { runGit } from "../git.mjs";
|
|
6
|
+
|
|
7
|
+
export function registerStagingCommands(program) {
|
|
8
|
+
program
|
|
9
|
+
.command("stage")
|
|
10
|
+
.description(
|
|
11
|
+
"Build a pushed commit in the cloud and deploy its staging preview",
|
|
12
|
+
)
|
|
13
|
+
.option("--commit <sha>", "Exact pushed commit (defaults to local HEAD)")
|
|
14
|
+
.option(
|
|
15
|
+
"--request-id <uuid>",
|
|
16
|
+
"Resume a previously submitted build request",
|
|
17
|
+
)
|
|
18
|
+
.option("--no-wait", "Return the build ID without waiting")
|
|
19
|
+
.action(
|
|
20
|
+
action(async ({ connection, project, options }) => {
|
|
21
|
+
if (!project)
|
|
22
|
+
throw new Error("Run stage inside a hosted campaign checkout.");
|
|
23
|
+
if (project.config.app?.kind !== "static")
|
|
24
|
+
throw new Error(
|
|
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
|
+
);
|
|
27
|
+
const cwd = dirname(project.path);
|
|
28
|
+
if (
|
|
29
|
+
!options.commit &&
|
|
30
|
+
(await runGit(["status", "--porcelain"], { cwd }))
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Commit your changes before staging. Only pushed commits are built.",
|
|
34
|
+
);
|
|
35
|
+
const commit =
|
|
36
|
+
options.commit ?? (await runGit(["rev-parse", "HEAD"], { cwd }));
|
|
37
|
+
if (!/^[a-f0-9]{40}$/.test(commit))
|
|
38
|
+
throw new Error("Use a full 40-character commit SHA.");
|
|
39
|
+
const scoped = { ...connection, environment: "test" };
|
|
40
|
+
const requestId = options.requestId ?? randomUUID();
|
|
41
|
+
process.stderr.write(`Build request ${requestId}\n`);
|
|
42
|
+
let build;
|
|
43
|
+
try {
|
|
44
|
+
build = await request(scoped, "/builds", "POST", {
|
|
45
|
+
commit,
|
|
46
|
+
requestId,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${error.message}\nRetry with --commit ${commit} --request-id ${requestId}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (options.wait === false) return build;
|
|
54
|
+
let last = "";
|
|
55
|
+
for (;;) {
|
|
56
|
+
const current = await request(scoped, `/builds/${build.id}`);
|
|
57
|
+
const progress = `${current.phase}: ${current.message}`;
|
|
58
|
+
if (progress !== last) {
|
|
59
|
+
process.stderr.write(progress + "\n");
|
|
60
|
+
last = progress;
|
|
61
|
+
}
|
|
62
|
+
if (current.phase === "ready")
|
|
63
|
+
return { id: build.id, commit, stagingUrl: current.stagingUrl };
|
|
64
|
+
if (current.phase === "failed")
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${current.message}\nInspect with domino logs ${build.id}`,
|
|
67
|
+
);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
program
|
|
73
|
+
.command("builds")
|
|
74
|
+
.description("List recent cloud builds for this campaign")
|
|
75
|
+
.action(
|
|
76
|
+
action(({ connection }) =>
|
|
77
|
+
request({ ...connection, environment: "test" }, "/builds"),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
program
|
|
81
|
+
.command("logs")
|
|
82
|
+
.description("Read retained cloud build logs")
|
|
83
|
+
.argument("<id>", "Build ID")
|
|
84
|
+
.action(
|
|
85
|
+
action(({ connection }, id) => {
|
|
86
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
87
|
+
throw new Error("Expected a build UUID.");
|
|
88
|
+
return request(
|
|
89
|
+
{ ...connection, environment: "test" },
|
|
90
|
+
`/builds/${id}/logs`,
|
|
91
|
+
);
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|