virtualmatter 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -33
- package/dist/agentfiles.js +72 -0
- package/dist/api.js +93 -0
- package/dist/auth.js +10 -4
- package/dist/client.js +317 -0
- package/dist/config.js +23 -0
- package/dist/ignore.js +3 -1
- package/dist/index.js +189 -50
- package/dist/mcp.js +114 -22
- package/dist/pull.js +48 -41
- package/dist/resolve.js +65 -0
- package/dist/urls.js +80 -25
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# virtualmatter
|
|
2
2
|
|
|
3
3
|
The command line for building with Virtual Matter - and the MCP server that
|
|
4
|
-
gives coding agents direct hands on
|
|
5
|
-
execution, engine errors, and
|
|
4
|
+
gives coding agents direct hands on your worlds: listing and creating them,
|
|
5
|
+
their live Montage files, Lua execution, engine errors, screenshots, and the
|
|
6
|
+
native desktop client.
|
|
6
7
|
|
|
7
8
|
No install needed:
|
|
8
9
|
|
|
@@ -16,23 +17,35 @@ Requires Node 20 or newer. No native modules.
|
|
|
16
17
|
|
|
17
18
|
## Quickstart
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
npx virtualmatter login
|
|
20
|
+
One command. It signs you in on first use (device code, approved in your
|
|
21
|
+
browser), creates the world, and mirrors its files into `./my-world`:
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
npx virtualmatter
|
|
23
|
+
```bash
|
|
24
|
+
npx virtualmatter create "My world"
|
|
25
25
|
cd my-world
|
|
26
|
-
|
|
27
|
-
# 3. Start the hot loop: local edits push instantly, remote edits pull back.
|
|
28
26
|
npx virtualmatter sync
|
|
29
27
|
```
|
|
30
28
|
|
|
29
|
+
Already have a world? Paste any link to it - `/edit`, `/play`, `/g`, `/p`,
|
|
30
|
+
`/projects`, with or without the readable name in the URL:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx virtualmatter list
|
|
34
|
+
npx virtualmatter pull https://make.virtualmatter.ai/edit/my-world-<framing-id>
|
|
35
|
+
```
|
|
36
|
+
|
|
31
37
|
While `sync` runs, saving a Lua script in your editor deploys it - scripts
|
|
32
38
|
hot-reload in the engine. If someone edits the same file in the live session,
|
|
33
39
|
your copy stays put and the remote version lands next to it as
|
|
34
40
|
`<name>.remote-conflict` for you to merge.
|
|
35
41
|
|
|
42
|
+
Want the native client instead of the browser? This downloads it on first
|
|
43
|
+
use, signs it in with your account, and opens the world:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx virtualmatter open
|
|
47
|
+
```
|
|
48
|
+
|
|
36
49
|
Poke at the running engine from another terminal:
|
|
37
50
|
|
|
38
51
|
```bash
|
|
@@ -43,49 +56,51 @@ npx virtualmatter screenshot -o shot.png
|
|
|
43
56
|
|
|
44
57
|
## Hook up a coding agent (MCP)
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```
|
|
59
|
+
A folder created by `create` or `pull` already carries `.mcp.json` (Claude
|
|
60
|
+
Code), `.cursor/mcp.json` (Cursor), `AGENTS.md` (Codex, Cursor, and most
|
|
61
|
+
agents), and a `CLAUDE.md` that imports it - open the folder in your agent
|
|
62
|
+
and it finds the MCP server and the project briefing on its own.
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
To register the server by hand, from anywhere:
|
|
53
65
|
|
|
54
66
|
```bash
|
|
55
|
-
claude mcp add virtualmatter -- npx -y virtualmatter mcp
|
|
67
|
+
claude mcp add virtualmatter -- npx -y virtualmatter mcp
|
|
56
68
|
```
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
It works before a world is selected: `list_projects`, `create_project`, and
|
|
71
|
+
`select_project` (any link or id, optionally mirroring into a folder) pick
|
|
72
|
+
one, then `list_files`, `read_file`, `write_file`, `run_lua`,
|
|
73
|
+
`get_engine_errors`, `capture_screenshot`, `open_native_client`, and
|
|
74
|
+
`world_info` act on it. Writes handle etag concurrency internally, and
|
|
75
|
+
written Lua hot-reloads in the engine - so for an agent, writing a script is
|
|
76
|
+
deploying it.
|
|
62
77
|
|
|
63
78
|
## Commands
|
|
64
79
|
|
|
65
80
|
| Command | What it does |
|
|
66
81
|
| --- | --- |
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
82
|
+
| `create <name> [dir]` | Create a world (region defaults to the nearest; `--region`, `--track`, `--description`, `--no-pull`, `--open`, `--json`) and mirror it into `./<slug>`. |
|
|
83
|
+
| `list` (`ls`, `projects`) | Your worlds with framing ids and edit/play URLs (`--json`). |
|
|
84
|
+
| `pull [target] [dir]` | Mirror a world's file tree. `target` is any Virtual Matter link or a framing id; omitted, it uses this folder's world or your only one. |
|
|
69
85
|
| `sync [dir]` | Watch + two-way sync with the live session. Ctrl-C to stop. |
|
|
86
|
+
| `open [target]` (`client`) | Open a world in the native desktop client, downloading and signing it in on first use. `--install-only`, `--print` (just the download URL), `--force`. |
|
|
70
87
|
| `run-lua [dir] --code "<lua>" [--target server\|client]` | Execute Lua in the running engine. |
|
|
71
88
|
| `errors [dir]` | Recent engine errors. |
|
|
72
89
|
| `screenshot [dir] [-o out.png]` | Capture the engine's current view. |
|
|
73
|
-
| `client` | Print the native client download link for your OS. |
|
|
74
90
|
| `mcp [dir] [--framing <id>]` | Run the stdio MCP server. |
|
|
75
|
-
|
|
76
|
-
`pull` understands `/edit/<id>`, `/play/<id>`, and `/m/<id>` links from any
|
|
77
|
-
Virtual Matter host, or a bare framing id. Project-level links (`/g/...`,
|
|
78
|
-
`/play/p/...`) point at a whole project - open the project and copy the
|
|
79
|
-
`/edit` link for the framing you want.
|
|
91
|
+
| `login` / `logout` / `whoami` | Device-code sign-in; every other command signs you in automatically when needed. Tokens live in `~/.config/virtualmatter/credentials.json` (mode 0600) and refresh automatically. |
|
|
80
92
|
|
|
81
93
|
## Good to know
|
|
82
94
|
|
|
83
|
-
- **Device sign-in is rolling out.** If `login` reports that device sign-in
|
|
84
|
-
is not enabled yet for this environment, the server-side toggle has not
|
|
85
|
-
reached your environment - it is coming.
|
|
86
95
|
- `VIRTUALMATTER_API_BASE` overrides the platform API base (default
|
|
87
96
|
`https://make.virtualmatter.ai`) for dev/staging environments.
|
|
88
|
-
-
|
|
89
|
-
|
|
97
|
+
- `VIRTUALMATTER_NO_AUTO_LOGIN=1` turns the automatic first-use sign-in into
|
|
98
|
+
an error, for scripts that must never block on a browser.
|
|
99
|
+
- The native client is unpacked under `~/.config/virtualmatter/client/<build>`
|
|
100
|
+
(override with `VIRTUALMATTER_CLIENT_DIR`); each engine build gets its own
|
|
101
|
+
folder, so a newer build never overwrites the one you are running.
|
|
102
|
+
- Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, and the
|
|
103
|
+
harness files the CLI writes (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`,
|
|
104
|
+
`.cursor/`, `.virtualmatter.json`).
|
|
90
105
|
- `pull` may cold-start a session for the framing; the first one can take a
|
|
91
106
|
minute.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files that make a pulled folder self-describing to whichever harness
|
|
3
|
+
* opens it: AGENTS.md (Codex, Cursor, and most others read it), CLAUDE.md
|
|
4
|
+
* (Claude Code reads this one and imports AGENTS.md through it), and the
|
|
5
|
+
* MCP registrations Claude Code (`.mcp.json`) and Cursor (`.cursor/mcp.json`)
|
|
6
|
+
* discover on their own. Existing files are never overwritten - a maker's
|
|
7
|
+
* own notes win - except AGENTS.md, which the CLI owns.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { apiBase } from "./config.js";
|
|
12
|
+
export const AGENTS_MD_STUB = `# Working with Virtual Matter
|
|
13
|
+
|
|
14
|
+
This folder is a live mirror of a Virtual Matter world's Montage files.
|
|
15
|
+
|
|
16
|
+
- \`npx virtualmatter sync\` keeps it in sync with the running session.
|
|
17
|
+
- \`npx virtualmatter run-lua --code "..."\` executes Lua in the engine.
|
|
18
|
+
- \`npx virtualmatter errors\` shows recent engine errors.
|
|
19
|
+
- \`npx virtualmatter screenshot -o shot.png\` captures the current view.
|
|
20
|
+
- \`npx virtualmatter open\` opens this world in the native desktop client.
|
|
21
|
+
- Lua scripts under this tree hot-reload in the engine when saved.
|
|
22
|
+
|
|
23
|
+
Do not edit \`.virtualmatter.json\` - it is sync bookkeeping.
|
|
24
|
+
`;
|
|
25
|
+
export async function fetchAgentsMd(fetchFn = fetch) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetchFn(`${apiBase()}/AGENTS.md`);
|
|
28
|
+
if (res.ok) {
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
if (text.trim().length > 0 && !text.trimStart().startsWith("<"))
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* fall through to the stub */
|
|
36
|
+
}
|
|
37
|
+
return AGENTS_MD_STUB;
|
|
38
|
+
}
|
|
39
|
+
const CLAUDE_MD = `@AGENTS.md
|
|
40
|
+
|
|
41
|
+
This folder mirrors a live Virtual Matter world. Keep \`npx virtualmatter sync\`
|
|
42
|
+
running while you edit so saves hot-reload in the running world, or use the
|
|
43
|
+
\`virtualmatter\` MCP server registered in \`.mcp.json\` to read, write, run Lua,
|
|
44
|
+
check engine errors, and take screenshots directly.
|
|
45
|
+
`;
|
|
46
|
+
function mcpServerConfig() {
|
|
47
|
+
return {
|
|
48
|
+
command: "npx",
|
|
49
|
+
args: ["-y", "virtualmatter", "mcp"],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Write the harness files into a pulled folder. */
|
|
53
|
+
export function writeAgentFiles(dir, agentsMd) {
|
|
54
|
+
const written = [];
|
|
55
|
+
const kept = [];
|
|
56
|
+
const put = (rel, content, overwrite) => {
|
|
57
|
+
const abs = path.join(dir, rel);
|
|
58
|
+
if (!overwrite && fs.existsSync(abs)) {
|
|
59
|
+
kept.push(rel);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
63
|
+
fs.writeFileSync(abs, content);
|
|
64
|
+
written.push(rel);
|
|
65
|
+
};
|
|
66
|
+
put("AGENTS.md", agentsMd, true);
|
|
67
|
+
put("CLAUDE.md", CLAUDE_MD, false);
|
|
68
|
+
const mcp = JSON.stringify({ mcpServers: { virtualmatter: mcpServerConfig() } }, null, 2) + "\n";
|
|
69
|
+
put(".mcp.json", mcp, false);
|
|
70
|
+
put(path.join(".cursor", "mcp.json"), mcp, false);
|
|
71
|
+
return { written, kept };
|
|
72
|
+
}
|
package/dist/api.js
CHANGED
|
@@ -58,3 +58,96 @@ export async function fetchNativeClients(fetchFn = fetch) {
|
|
|
58
58
|
const body = (await res.json());
|
|
59
59
|
return body.clients ?? [];
|
|
60
60
|
}
|
|
61
|
+
async function readError(res, what) {
|
|
62
|
+
let detail = "";
|
|
63
|
+
try {
|
|
64
|
+
const body = (await res.json());
|
|
65
|
+
if (typeof body.detail === "string")
|
|
66
|
+
detail = body.detail;
|
|
67
|
+
else if (body.detail)
|
|
68
|
+
detail = JSON.stringify(body.detail);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* non-JSON */
|
|
72
|
+
}
|
|
73
|
+
return new Error(`${what} failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`);
|
|
74
|
+
}
|
|
75
|
+
export async function listProjects(fetchFn = fetch) {
|
|
76
|
+
const res = await authorizedFetch(`${apiBase()}/api/v1/montages`, {}, fetchFn);
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw await readError(res, "Listing projects");
|
|
79
|
+
return (await res.json());
|
|
80
|
+
}
|
|
81
|
+
export async function getProject(montageId, fetchFn = fetch) {
|
|
82
|
+
const res = await authorizedFetch(`${apiBase()}/api/v1/montages/${encodeURIComponent(montageId)}`, {}, fetchFn);
|
|
83
|
+
if (res.status === 404)
|
|
84
|
+
throw new Error(`Project ${montageId} was not found (or is not yours).`);
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw await readError(res, "Loading the project");
|
|
87
|
+
return (await res.json());
|
|
88
|
+
}
|
|
89
|
+
export async function getProjectByPublicId(publicId, fetchFn = fetch) {
|
|
90
|
+
const res = await authorizedFetch(`${apiBase()}/api/v1/montages/by-public-id/${encodeURIComponent(publicId)}`, {}, fetchFn);
|
|
91
|
+
if (res.status === 404)
|
|
92
|
+
throw new Error(`Project ${publicId} was not found (or is not yours).`);
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
throw await readError(res, "Resolving the project link");
|
|
95
|
+
const identity = (await res.json());
|
|
96
|
+
return getProject(identity.id, fetchFn);
|
|
97
|
+
}
|
|
98
|
+
export async function createProject(input, fetchFn = fetch) {
|
|
99
|
+
const res = await authorizedFetch(`${apiBase()}/api/v1/montages`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
name: input.name,
|
|
104
|
+
description: input.description ?? null,
|
|
105
|
+
region: input.region,
|
|
106
|
+
engine_track: input.engine_track ?? "stable",
|
|
107
|
+
}),
|
|
108
|
+
}, fetchFn);
|
|
109
|
+
if (res.status === 409) {
|
|
110
|
+
throw new Error(`You already have a project named "${input.name}". Pick another name.`);
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw await readError(res, "Creating the project");
|
|
114
|
+
return (await res.json());
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The region a new project should live in when the caller did not say:
|
|
118
|
+
* the one closest to the machine's clock. Coarse on purpose - a wrong
|
|
119
|
+
* guess costs some latency, a prompt costs the whole zero-friction flow.
|
|
120
|
+
*/
|
|
121
|
+
export function defaultRegion(now = new Date()) {
|
|
122
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
|
123
|
+
if (tz.startsWith("America/") || tz.startsWith("US/") || tz.startsWith("Canada/") || tz === "Pacific/Honolulu")
|
|
124
|
+
return "NA";
|
|
125
|
+
if (tz.startsWith("Asia/") || tz.startsWith("Australia/") || tz.startsWith("Pacific/"))
|
|
126
|
+
return "AS";
|
|
127
|
+
if (tz.startsWith("Europe/") || tz.startsWith("Africa/") || tz === "UTC")
|
|
128
|
+
return "EU";
|
|
129
|
+
// No zone name: fall back to the UTC offset (minutes WEST of UTC).
|
|
130
|
+
const offset = now.getTimezoneOffset();
|
|
131
|
+
if (offset >= 180)
|
|
132
|
+
return "NA";
|
|
133
|
+
if (offset <= -300)
|
|
134
|
+
return "AS";
|
|
135
|
+
return "EU";
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------- urls
|
|
138
|
+
/** The readable editor link for a framing: `/edit/<slug>-<id>` or `/edit/<id>`. */
|
|
139
|
+
export function editUrl(framingId, slug) {
|
|
140
|
+
return `${apiBase()}/edit/${slug ? `${slug}-${framingId}` : framingId}`;
|
|
141
|
+
}
|
|
142
|
+
export function playUrl(framingId) {
|
|
143
|
+
return `${apiBase()}/play/${framingId}`;
|
|
144
|
+
}
|
|
145
|
+
/** The full catalog, version-matched to a framing's running engine when given. */
|
|
146
|
+
export async function fetchNativeClientCatalog(framingId, fetchFn = fetch) {
|
|
147
|
+
const q = framingId ? `?framing_id=${encodeURIComponent(framingId)}` : "";
|
|
148
|
+
const res = await fetchFn(`${apiBase()}/api/v1/public/native-clients${q}`);
|
|
149
|
+
if (!res.ok)
|
|
150
|
+
throw new Error(`GET /api/v1/public/native-clients failed: HTTP ${res.status}`);
|
|
151
|
+
const body = (await res.json());
|
|
152
|
+
return { iteration: body.iteration ?? null, clients: body.clients ?? [] };
|
|
153
|
+
}
|
package/dist/auth.js
CHANGED
|
@@ -17,7 +17,7 @@ export class DeviceFlowDisabledError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
export class NotLoggedInError extends Error {
|
|
19
19
|
constructor() {
|
|
20
|
-
super("Not signed in. Run `virtualmatter login` first.");
|
|
20
|
+
super("Not signed in. Run `npx virtualmatter login` first.");
|
|
21
21
|
this.name = "NotLoggedInError";
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -48,7 +48,13 @@ function form(params) {
|
|
|
48
48
|
/** Start the device flow: returns the codes to show the user, plus the token endpoint. */
|
|
49
49
|
export async function startDeviceFlow(fetchFn = fetch) {
|
|
50
50
|
const disco = await fetchDiscovery(fetchFn);
|
|
51
|
-
const res = await fetchFn(disco.device_authorization_endpoint,
|
|
51
|
+
const res = await fetchFn(disco.device_authorization_endpoint,
|
|
52
|
+
// offline_access is what makes this a CLI session rather than a
|
|
53
|
+
// browser tab: without it the refresh token dies with the SSO
|
|
54
|
+
// session's idle timeout (~30 min) and users get signed out
|
|
55
|
+
// overnight. With it, Keycloak issues an offline refresh token
|
|
56
|
+
// (realm default: 30-day idle) that survives browser logout.
|
|
57
|
+
form({ client_id: OAUTH_CLIENT_ID, scope: "openid offline_access" }));
|
|
52
58
|
if (!res.ok) {
|
|
53
59
|
let body = {};
|
|
54
60
|
try {
|
|
@@ -78,7 +84,7 @@ export async function pollForToken(tokenEndpoint, auth, opts = {}) {
|
|
|
78
84
|
const deadline = now() + auth.expires_in * 1000;
|
|
79
85
|
for (;;) {
|
|
80
86
|
if (now() > deadline) {
|
|
81
|
-
throw new Error("Device sign-in timed out before the code was approved. Run `virtualmatter login` again.");
|
|
87
|
+
throw new Error("Device sign-in timed out before the code was approved. Run `npx virtualmatter login` again.");
|
|
82
88
|
}
|
|
83
89
|
await sleep(intervalMs);
|
|
84
90
|
const res = await fetchFn(tokenEndpoint, form({
|
|
@@ -109,7 +115,7 @@ export async function pollForToken(tokenEndpoint, auth, opts = {}) {
|
|
|
109
115
|
intervalMs += 5000;
|
|
110
116
|
continue;
|
|
111
117
|
case "expired_token":
|
|
112
|
-
throw new Error("The sign-in code expired before it was approved. Run `virtualmatter login` again.");
|
|
118
|
+
throw new Error("The sign-in code expired before it was approved. Run `npx virtualmatter login` again.");
|
|
113
119
|
case "access_denied":
|
|
114
120
|
throw new Error("Sign-in was denied.");
|
|
115
121
|
case "unauthorized_client":
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native client, without the human in the loop: pick the build for this
|
|
3
|
+
* OS, download it, unpack it, hand it the world URL, and sign it in with the
|
|
4
|
+
* credentials the CLI already holds.
|
|
5
|
+
*
|
|
6
|
+
* Builds are unpacked under <configDir>/client/<dir_uid>/ so re-running
|
|
7
|
+
* `open` is instant, and a newer build lands next to the old one instead
|
|
8
|
+
* of over it. Archives are removed after extraction.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fetchNativeClientCatalog } from "./api.js";
|
|
15
|
+
import { loadCredentials } from "./auth.js";
|
|
16
|
+
import { OAUTH_CLIENT_ID, clientInstallRoot, oidcDiscoveryUrl } from "./config.js";
|
|
17
|
+
export function detectPlatform(p = process.platform) {
|
|
18
|
+
if (p === "darwin")
|
|
19
|
+
return "macos";
|
|
20
|
+
if (p === "win32")
|
|
21
|
+
return "windows";
|
|
22
|
+
return "linux";
|
|
23
|
+
}
|
|
24
|
+
/** The build the catalog offers for a platform, or null when there is none. */
|
|
25
|
+
export function pickBuild(clients, platform) {
|
|
26
|
+
return clients.find((c) => c.platform.toLowerCase() === platform && c.kind === "download") ?? null;
|
|
27
|
+
}
|
|
28
|
+
/** A stable per-build install directory name derived from the download path. */
|
|
29
|
+
export function buildKey(client) {
|
|
30
|
+
const m = /\/download\/([^/]+)\//.exec(client.url);
|
|
31
|
+
if (m?.[1])
|
|
32
|
+
return m[1];
|
|
33
|
+
const base = client.filename ?? path.basename(client.url);
|
|
34
|
+
return base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.(zip|tgz|tar\.gz|tar\.xz|dmg)$/i, "");
|
|
35
|
+
}
|
|
36
|
+
const MARKER = "installed.json";
|
|
37
|
+
/**
|
|
38
|
+
* Locate the launch target inside an unpacked tree. Mirrors the platform's
|
|
39
|
+
* `_ENTRYPOINT_CANDIDATES`, plus the .app bundle on macOS since `open`
|
|
40
|
+
* wants the bundle, not the Mach-O inside it.
|
|
41
|
+
*/
|
|
42
|
+
export function findEntrypoint(dir, platform) {
|
|
43
|
+
const walk = (root, depth) => {
|
|
44
|
+
if (depth < 0)
|
|
45
|
+
return [];
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
const full = path.join(root, e.name);
|
|
56
|
+
out.push(full);
|
|
57
|
+
if (e.isDirectory() && !e.name.endsWith(".app"))
|
|
58
|
+
out.push(...walk(full, depth - 1));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const all = walk(dir, 2);
|
|
63
|
+
const byName = (names) => {
|
|
64
|
+
for (const n of names) {
|
|
65
|
+
const hit = all.find((p) => path.basename(p).toLowerCase() === n);
|
|
66
|
+
if (hit)
|
|
67
|
+
return hit;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
if (platform === "macos") {
|
|
72
|
+
const app = all.find((p) => p.endsWith(".app") && fs.statSync(p).isDirectory());
|
|
73
|
+
return app ?? null;
|
|
74
|
+
}
|
|
75
|
+
if (platform === "windows")
|
|
76
|
+
return byName(["atomontage client.exe", "client.exe", "studio.exe"]);
|
|
77
|
+
return byName(["run.sh", "client", "studio"]);
|
|
78
|
+
}
|
|
79
|
+
export function loadInstalled(dir) {
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, MARKER), "utf8"));
|
|
82
|
+
if (raw.entrypoint && fs.existsSync(raw.entrypoint))
|
|
83
|
+
return { ...raw, dir };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* not installed */
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
async function download(url, dest, onProgress, fetchFn = fetch) {
|
|
91
|
+
const res = await fetchFn(url);
|
|
92
|
+
if (!res.ok || !res.body)
|
|
93
|
+
throw new Error(`Download failed: HTTP ${res.status} for ${url}`);
|
|
94
|
+
const total = Number(res.headers.get("content-length")) || null;
|
|
95
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
96
|
+
const tmp = `${dest}.part`;
|
|
97
|
+
const out = fs.createWriteStream(tmp);
|
|
98
|
+
let received = 0;
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
try {
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done)
|
|
104
|
+
break;
|
|
105
|
+
received += value.byteLength;
|
|
106
|
+
if (!out.write(value))
|
|
107
|
+
await new Promise((r) => out.once("drain", () => r()));
|
|
108
|
+
onProgress(received, total);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
out.end(() => resolve());
|
|
114
|
+
out.on("error", reject);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
fs.renameSync(tmp, dest);
|
|
118
|
+
}
|
|
119
|
+
/** Unpack with the OS's own tools: ditto keeps bundle symlinks + bits on macOS; bsdtar handles zip + tgz elsewhere. */
|
|
120
|
+
export function extractArchive(archive, dest, platform) {
|
|
121
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
122
|
+
const lower = archive.toLowerCase();
|
|
123
|
+
let cmd;
|
|
124
|
+
let args;
|
|
125
|
+
if (platform === "macos" && lower.endsWith(".zip")) {
|
|
126
|
+
cmd = "ditto";
|
|
127
|
+
args = ["-x", "-k", archive, dest];
|
|
128
|
+
}
|
|
129
|
+
else if (lower.endsWith(".zip")) {
|
|
130
|
+
cmd = "tar";
|
|
131
|
+
args = ["-xf", archive, "-C", dest];
|
|
132
|
+
}
|
|
133
|
+
else if (/\.(tgz|tar\.gz|tar\.xz|tar\.bz2|tar)$/.test(lower)) {
|
|
134
|
+
cmd = "tar";
|
|
135
|
+
args = ["-xf", archive, "-C", dest];
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
throw new Error(`Don't know how to unpack ${path.basename(archive)}.`);
|
|
139
|
+
}
|
|
140
|
+
const res = spawnSync(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
141
|
+
if (res.error)
|
|
142
|
+
throw new Error(`Could not run ${cmd}: ${res.error.message}`);
|
|
143
|
+
if (res.status !== 0) {
|
|
144
|
+
throw new Error(`${cmd} failed (${res.status}): ${res.stderr?.toString().trim()}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function formatMb(bytes) {
|
|
148
|
+
return `${(bytes / 1048576).toFixed(0)} MB`;
|
|
149
|
+
}
|
|
150
|
+
/** Download + unpack the right build unless it is already here. */
|
|
151
|
+
export async function ensureClientInstalled(opts = {}) {
|
|
152
|
+
const platform = opts.platform ?? detectPlatform();
|
|
153
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
154
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
155
|
+
const catalog = await fetchNativeClientCatalog(opts.framingId, fetchFn);
|
|
156
|
+
const build = pickBuild(catalog.clients, platform);
|
|
157
|
+
if (!build) {
|
|
158
|
+
const others = catalog.clients.map((c) => `${c.platform} (${c.kind})`).join(", ");
|
|
159
|
+
throw new Error(`No native client build is published for ${platform} yet. Available: ${others || "none"}.`);
|
|
160
|
+
}
|
|
161
|
+
const dir = path.join(clientInstallRoot(), buildKey(build));
|
|
162
|
+
const existing = opts.force ? null : loadInstalled(dir);
|
|
163
|
+
if (existing)
|
|
164
|
+
return existing;
|
|
165
|
+
const url = build.url.startsWith("http") ? build.url : `${apiBaseFor(build)}${build.url}`;
|
|
166
|
+
const archive = path.join(clientInstallRoot(), build.filename ?? path.basename(build.url));
|
|
167
|
+
log(`Downloading the Virtual Matter client for ${platform}${build.file_size ? ` (${formatMb(build.file_size)})` : ""} ...`);
|
|
168
|
+
let lastPct = -1;
|
|
169
|
+
await download(url, archive, (received, total) => {
|
|
170
|
+
if (!total)
|
|
171
|
+
return;
|
|
172
|
+
const pct = Math.floor((received / total) * 100);
|
|
173
|
+
if (pct !== lastPct && (pct % 10 === 0 || pct === 100)) {
|
|
174
|
+
lastPct = pct;
|
|
175
|
+
log(` ${pct}%`);
|
|
176
|
+
}
|
|
177
|
+
}, fetchFn);
|
|
178
|
+
log(`Unpacking into ${dir} ...`);
|
|
179
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
extractArchive(archive, dir, platform);
|
|
181
|
+
fs.rmSync(archive, { force: true });
|
|
182
|
+
const entrypoint = findEntrypoint(dir, platform);
|
|
183
|
+
if (!entrypoint)
|
|
184
|
+
throw new Error(`Unpacked ${build.filename ?? "the build"} but found nothing to launch in ${dir}.`);
|
|
185
|
+
if (platform !== "windows") {
|
|
186
|
+
// Archives produced on Windows CI can lose the executable bit.
|
|
187
|
+
for (const p of [entrypoint, path.join(dir, "Client"), path.join(dir, "crashpad_handler")]) {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.statSync(p).isFile())
|
|
190
|
+
fs.chmodSync(p, 0o755);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* absent */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const installed = { platform, dir, entrypoint, build };
|
|
198
|
+
fs.writeFileSync(path.join(dir, MARKER), JSON.stringify(installed, null, 2) + "\n");
|
|
199
|
+
return installed;
|
|
200
|
+
}
|
|
201
|
+
function apiBaseFor(_build) {
|
|
202
|
+
// Download paths are relative to the platform origin the catalog came from.
|
|
203
|
+
return (process.env.VIRTUALMATTER_API_BASE ?? "https://make.virtualmatter.ai").replace(/\/+$/, "");
|
|
204
|
+
}
|
|
205
|
+
/** Launch the client into a world, detached from this process. */
|
|
206
|
+
export function launchClient(installed, montageUrl) {
|
|
207
|
+
const arg = `--montage-url=${montageUrl}`;
|
|
208
|
+
let cmd;
|
|
209
|
+
let args;
|
|
210
|
+
if (installed.platform === "macos") {
|
|
211
|
+
cmd = "open";
|
|
212
|
+
args = ["-n", "-a", installed.entrypoint, "--args", arg];
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
cmd = installed.entrypoint;
|
|
216
|
+
args = [arg];
|
|
217
|
+
}
|
|
218
|
+
const child = spawn(cmd, args, {
|
|
219
|
+
cwd: installed.platform === "macos" ? undefined : path.dirname(installed.entrypoint),
|
|
220
|
+
detached: true,
|
|
221
|
+
stdio: "ignore",
|
|
222
|
+
windowsHide: false,
|
|
223
|
+
});
|
|
224
|
+
child.on("error", (err) => {
|
|
225
|
+
console.error(`Could not launch the client: ${err.message}`);
|
|
226
|
+
});
|
|
227
|
+
child.unref();
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------- sign-in hand-over
|
|
230
|
+
/**
|
|
231
|
+
* Where the client keeps its sign-in file. Installer builds use the
|
|
232
|
+
* per-user app-data folder (SDL_GetPrefPath("Atomontage", "Atomontage
|
|
233
|
+
* Studio")); portable builds keep UserData next to the executable. Both
|
|
234
|
+
* are seeded so whichever the build reads, it finds a session.
|
|
235
|
+
*/
|
|
236
|
+
export function nativeUserDataDirs(installed, platform, home = os.homedir()) {
|
|
237
|
+
const dirs = [];
|
|
238
|
+
if (platform === "macos") {
|
|
239
|
+
dirs.push(path.join(home, "Library", "Application Support", "Atomontage", "Atomontage Studio", "UserData"));
|
|
240
|
+
}
|
|
241
|
+
else if (platform === "windows") {
|
|
242
|
+
const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
243
|
+
dirs.push(path.join(appData, "Atomontage", "Atomontage Studio", "UserData"));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const dataHome = process.env.XDG_DATA_HOME ?? path.join(home, ".local", "share");
|
|
247
|
+
dirs.push(path.join(dataHome, "Atomontage", "Atomontage Studio", "UserData"));
|
|
248
|
+
}
|
|
249
|
+
if (installed && platform !== "macos")
|
|
250
|
+
dirs.push(path.join(path.dirname(installed.entrypoint), "UserData"));
|
|
251
|
+
return dirs;
|
|
252
|
+
}
|
|
253
|
+
function decodeJwtClaims(token) {
|
|
254
|
+
try {
|
|
255
|
+
const payload = token.split(".")[1] ?? "";
|
|
256
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** The session file shape `vm_auth.py` (in the engine SDK) reads and refreshes. */
|
|
263
|
+
export function nativeAuthSession(creds) {
|
|
264
|
+
const claims = decodeJwtClaims(creds.access_token);
|
|
265
|
+
const disco = new URL(oidcDiscoveryUrl());
|
|
266
|
+
const m = /^(.*)\/realms\/([^/]+)\//.exec(disco.pathname);
|
|
267
|
+
const authBase = `${disco.origin}${m?.[1] ?? "/auth"}`;
|
|
268
|
+
const realm = m?.[2] ?? "Atomontage";
|
|
269
|
+
return {
|
|
270
|
+
access_token: creds.access_token,
|
|
271
|
+
refresh_token: creds.refresh_token ?? "",
|
|
272
|
+
id_token: "",
|
|
273
|
+
access_expires_at: Math.floor(creds.expires_at / 1000),
|
|
274
|
+
// 0 = "no known expiry": an offline refresh token from the CLI's
|
|
275
|
+
// device flow does not carry one, and a stamped-but-past value would
|
|
276
|
+
// read as expired.
|
|
277
|
+
refresh_expires_at: 0,
|
|
278
|
+
auth_base: authBase,
|
|
279
|
+
realm,
|
|
280
|
+
client_id: OAUTH_CLIENT_ID,
|
|
281
|
+
user: claims.preferred_username || claims.email || claims.sub || "",
|
|
282
|
+
email: claims.email || "",
|
|
283
|
+
sub: claims.sub || "",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Sign the native client in with the CLI's session. Skipped when the
|
|
288
|
+
* client already holds a session for the same account, so a sign-in done
|
|
289
|
+
* inside the app is never clobbered. Returns the files written.
|
|
290
|
+
*/
|
|
291
|
+
export function seedNativeAuth(installed, platform = detectPlatform()) {
|
|
292
|
+
const creds = loadCredentials();
|
|
293
|
+
if (!creds?.refresh_token)
|
|
294
|
+
return [];
|
|
295
|
+
const session = nativeAuthSession(creds);
|
|
296
|
+
const written = [];
|
|
297
|
+
for (const dir of nativeUserDataDirs(installed, platform)) {
|
|
298
|
+
const file = path.join(dir, "vm_auth.json");
|
|
299
|
+
try {
|
|
300
|
+
const existing = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
301
|
+
if (existing.refresh_token && existing.sub === session.sub)
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* absent or unreadable: write */
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
309
|
+
fs.writeFileSync(file, JSON.stringify(session) + "\n", { mode: 0o600 });
|
|
310
|
+
written.push(file);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* a read-only location is not worth failing the launch over */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return written;
|
|
317
|
+
}
|