runcloud 0.1.2 → 0.1.4

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/dist/config.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
+ function isRunCloud() {
5
+ const invoked = basename(process.argv[1] ?? '');
6
+ return invoked === 'runcloud' || invoked === 'run-cloud.js';
7
+ }
4
8
  function credentialsHome() {
5
9
  if (process.env.RUN_CLOUD_HOME)
6
10
  return process.env.RUN_CLOUD_HOME;
7
11
  if (process.env.NEWLY_HOME)
8
12
  return process.env.NEWLY_HOME;
9
- const invoked = basename(process.argv[1] ?? '');
10
- if (invoked === 'runcloud' || invoked === 'run-cloud.js')
13
+ if (isRunCloud())
11
14
  return join(homedir(), '.run-cloud');
12
15
  return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
13
16
  }
@@ -20,12 +23,10 @@ export function credentialsDisplayPath() {
20
23
  return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
21
24
  }
22
25
  function loginCommandName() {
23
- const invoked = basename(process.argv[1] ?? '');
24
- return invoked === 'runcloud' || invoked === 'run-cloud.js' ? 'runcloud login' : 'newly login';
26
+ return isRunCloud() ? 'runcloud login' : 'newly login';
25
27
  }
26
28
  export function normalizeApiUrl(url) {
27
- const trimmed = url.replace(/\/+$/, '');
28
- return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
29
+ return url.replace(/\/+$/, '');
29
30
  }
30
31
  export function saveCredentials(creds) {
31
32
  mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
@@ -34,13 +35,19 @@ export function saveCredentials(creds) {
34
35
  chmodSync(credentialsPath(), 0o600);
35
36
  }
36
37
  export function loadCredentials() {
37
- const envToken = process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
38
- const envApiUrl = process.env.NEWLY_API_URL ?? process.env.NEWLY_API_HOST ?? process.env.NEXT_PUBLIC_API_HOST;
38
+ const envToken = isRunCloud()
39
+ ? process.env.RUN_CLOUD_API_TOKEN ?? process.env.RUN_CLOUD_API_KEY
40
+ : process.env.NEWLY_API_TOKEN ?? process.env.NEWLY_CLI_TOKEN;
41
+ const envApiUrl = isRunCloud()
42
+ ? process.env.RUN_CLOUD_API_URL
43
+ : process.env.NEWLY_API_URL;
39
44
  if (envToken?.trim() && envApiUrl?.trim()) {
40
45
  return {
41
46
  token: envToken.trim(),
42
47
  apiUrl: normalizeApiUrl(envApiUrl.trim()),
43
- appUrl: process.env.NEWLY_APP_URL ?? 'https://newly.dev',
48
+ appUrl: isRunCloud()
49
+ ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
50
+ : process.env.NEWLY_APP_URL ?? 'https://newly.app',
44
51
  expiresAt: Number.MAX_SAFE_INTEGER,
45
52
  };
46
53
  }
@@ -56,6 +63,40 @@ export function loadCredentials() {
56
63
  return null;
57
64
  }
58
65
  }
66
+ function environmentSelectionPath(dir) {
67
+ return join(dir, '.newly', 'environment.json');
68
+ }
69
+ export function writeEnvironmentSelection(dir, selection) {
70
+ const selectionDir = join(dir, '.newly');
71
+ mkdirSync(selectionDir, { recursive: true });
72
+ const gitignorePath = join(selectionDir, '.gitignore');
73
+ if (!existsSync(gitignorePath))
74
+ writeFileSync(gitignorePath, '*\n');
75
+ writeFileSync(environmentSelectionPath(dir), JSON.stringify(selection, null, 2) + '\n');
76
+ }
77
+ export function readEnvironmentSelection(dir) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(environmentSelectionPath(dir), 'utf8'));
80
+ if (!parsed.id || !parsed.name || !parsed.projectId)
81
+ return null;
82
+ return parsed;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export function clearEnvironmentSelection(dir) {
89
+ rmSync(environmentSelectionPath(dir), { force: true });
90
+ }
91
+ export function requireEnvironmentSelection(dir, projectId) {
92
+ const selection = readEnvironmentSelection(dir);
93
+ if (!selection)
94
+ throw new Error('No environment selected. Run `newly env use <name>` first.');
95
+ if (selection.projectId !== projectId) {
96
+ throw new Error('The selected environment belongs to a different linked project. Run `newly env clear`.');
97
+ }
98
+ return selection;
99
+ }
59
100
  export function clearCredentials() {
60
101
  rmSync(credentialsPath(), { force: true });
61
102
  }
@@ -0,0 +1,70 @@
1
+ import WebSocket from 'ws';
2
+ export async function runEnvironmentDatabaseShell(input) {
3
+ const stdin = input.stdin ?? process.stdin;
4
+ const stdout = input.stdout ?? process.stdout;
5
+ const stderr = input.stderr ?? process.stderr;
6
+ const url = new URL(`/environment-databases/${encodeURIComponent(input.databaseId)}/shell`, input.apiUrl);
7
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
8
+ const createWebSocket = input.createWebSocket ?? ((address, options) => new WebSocket(address, options));
9
+ const socket = createWebSocket(url.toString(), {
10
+ headers: { Authorization: `Bearer ${input.token}` },
11
+ });
12
+ const ttyInput = stdin;
13
+ const ttyOutput = stdout;
14
+ const wasRaw = ttyInput.isRaw;
15
+ let exitCode = 1;
16
+ const sendResize = () => {
17
+ if (socket.readyState === WebSocket.OPEN && ttyOutput.rows && ttyOutput.columns) {
18
+ socket.send(JSON.stringify({ stream: 'resize', rows: ttyOutput.rows, cols: ttyOutput.columns }));
19
+ }
20
+ };
21
+ const onInput = (data) => {
22
+ if (socket.readyState === WebSocket.OPEN) {
23
+ socket.send(JSON.stringify({ stream: 'stdin', data: Buffer.from(data).toString('base64') }));
24
+ }
25
+ };
26
+ const restore = () => {
27
+ stdin.off('data', onInput);
28
+ ttyOutput.off?.('resize', sendResize);
29
+ if (ttyInput.isTTY && typeof ttyInput.setRawMode === 'function')
30
+ ttyInput.setRawMode(Boolean(wasRaw));
31
+ stdin.pause();
32
+ };
33
+ return await new Promise((resolve, reject) => {
34
+ socket.once('open', () => {
35
+ if (ttyInput.isTTY && typeof ttyInput.setRawMode === 'function')
36
+ ttyInput.setRawMode(true);
37
+ stdin.resume();
38
+ stdin.on('data', onInput);
39
+ ttyOutput.on?.('resize', sendResize);
40
+ sendResize();
41
+ });
42
+ socket.on('message', (raw) => {
43
+ try {
44
+ const frame = JSON.parse(raw.toString());
45
+ if ((frame.stream === 'stdout' || frame.stream === 'stderr') && typeof frame.data === 'string') {
46
+ const target = frame.stream === 'stderr' ? stderr : stdout;
47
+ target.write(Buffer.from(frame.data, 'base64'));
48
+ }
49
+ else if (frame.stream === 'exit' && typeof frame.exit_code === 'number') {
50
+ exitCode = frame.exit_code;
51
+ }
52
+ }
53
+ catch {
54
+ }
55
+ });
56
+ socket.once('error', (error) => {
57
+ restore();
58
+ reject(error);
59
+ });
60
+ socket.once('close', (code, reason) => {
61
+ restore();
62
+ if (code >= 4400) {
63
+ reject(new Error(reason.toString() || `Database shell rejected (${code})`));
64
+ }
65
+ else {
66
+ resolve(exitCode);
67
+ }
68
+ });
69
+ });
70
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import { registerWebsite } from './commands/website.js';
16
16
  import { registerDomains } from './commands/domains.js';
17
17
  import { registerDns } from './commands/dns.js';
18
18
  import { registerDiagnose } from './diagnose.js';
19
+ import { registerDesktop } from './commands/desktop.js';
20
+ import { registerEnvironment } from './commands/environment.js';
19
21
  const program = new Command('newly')
20
22
  .description('Develop, provision, and deploy Newly backends')
21
23
  .version('0.1.0');
@@ -35,4 +37,6 @@ registerWebsite(program);
35
37
  registerDomains(program);
36
38
  registerDns(program);
37
39
  registerDiagnose(program);
40
+ registerDesktop(program);
41
+ registerEnvironment(program);
38
42
  await program.parseAsync(process.argv);
package/dist/run-cloud.js CHANGED
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { Command } from 'commander';
3
4
  import { registerLogin } from './commands/login.js';
4
5
  import { registerRunCloud } from './commands/run-cloud.js';
6
+ import { registerSandbox } from './commands/sandbox.js';
7
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
5
8
  const program = new Command('runcloud')
6
- .description('Create and control remote iOS simulator sessions')
7
- .version('0.1.0');
9
+ .description('Create and control remote mobile simulators and microVM sandboxes')
10
+ .version(version);
8
11
  registerLogin(program);
9
12
  registerRunCloud(program);
13
+ registerSandbox(program);
10
14
  await program.parseAsync(process.argv);
@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  export const DEFAULT_BACKEND_SERVICES = [
4
4
  { name: 'api', path: 'api-service', type: 'worker', schema: 'api', secrets: 'api', public: true },
5
- { name: 'auth', path: 'auth-service', type: 'worker', schema: 'auth', secrets: 'auth', public: true },
6
5
  ];
7
6
  const SERVICE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
8
7
  export function readBackendServices(dir) {
@@ -33,9 +32,39 @@ export function readBackendServices(dir) {
33
32
  schema: entry.schema === undefined ? undefined : entry.schema === null ? null : String(entry.schema),
34
33
  secrets: entry.secrets === undefined ? undefined : String(entry.secrets),
35
34
  public: entry.public === undefined ? true : Boolean(entry.public),
35
+ bindings: parseBindings(name, entry.bindings),
36
36
  };
37
37
  });
38
38
  }
39
+ function parseBindings(serviceName, value) {
40
+ if (value === undefined)
41
+ return undefined;
42
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
43
+ throw new Error(`Service ${serviceName} bindings must be an object`);
44
+ }
45
+ const bindings = {};
46
+ for (const [bindingName, raw] of Object.entries(value)) {
47
+ if (!/^[A-Z][A-Z0-9_]{0,127}$/.test(bindingName)) {
48
+ throw new Error(`Invalid binding name for service ${serviceName}: ${bindingName}`);
49
+ }
50
+ if (bindingName.startsWith('NEWLY_')) {
51
+ throw new Error(`Service ${serviceName} cannot override reserved binding ${bindingName}`);
52
+ }
53
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
54
+ throw new Error(`Binding ${bindingName} for service ${serviceName} must be an object`);
55
+ }
56
+ const entries = Object.entries(raw);
57
+ if (entries.length !== 1 || !['database', 'secret', 'storage'].includes(entries[0][0])) {
58
+ throw new Error(`Binding ${bindingName} for service ${serviceName} must declare exactly one database, secret, or storage`);
59
+ }
60
+ const [kind, logicalName] = entries[0];
61
+ if (typeof logicalName !== 'string' || logicalName.length === 0) {
62
+ throw new Error(`Binding ${bindingName} for service ${serviceName} has an invalid logical name`);
63
+ }
64
+ bindings[bindingName] = { [kind]: logicalName };
65
+ }
66
+ return bindings;
67
+ }
39
68
  export function parseServiceFilter(value) {
40
69
  if (!value)
41
70
  return [];
@@ -0,0 +1,30 @@
1
+ # Eight-device mosaic
2
+
3
+ Start eight run.cloud iOS simulator sessions, send each simulator to one tile
4
+ of a synchronized animated canvas, and embed the signed sessions in a local
5
+ 4-by-2 mosaic viewer. The demo has one fixed ten-minute lease and releases every
6
+ session when that lease ends. The same server-side cap protects against an
7
+ interrupted local process.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js 20 or newer
12
+ - `runcloud` 0.1.3 or newer: `npm install -g runcloud`
13
+ - A simulator account authenticated either with `runcloud login` or with both
14
+ `RUN_CLOUD_API_KEY` and `RUN_CLOUD_API_URL`
15
+ - run.cloud simulator access, capacity, and enough balance for eight sessions
16
+
17
+ The local viewer binds only to `127.0.0.1`. Signed simulator URLs remain in the
18
+ viewer process and are not printed to the terminal.
19
+
20
+ ## Run
21
+
22
+ ```bash
23
+ npm run demo -- --open
24
+ ```
25
+
26
+ Press Ctrl+C or select **End demo** in the viewer to release the sessions early.
27
+ Omit `--open` when you only want the local viewer URL printed.
28
+
29
+ For a local or preview tile host, set `RUN_CLOUD_DEMO_ORIGIN` to its absolute
30
+ HTTP or HTTPS origin before starting the demo.
@@ -0,0 +1,329 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createServer } from 'node:http';
3
+
4
+ const DEVICE_COUNT = 8;
5
+ const COLUMNS = 4;
6
+ const ROWS = 2;
7
+ const DEMO_LEASE_MS = 10 * 60 * 1000;
8
+
9
+ export function parseOptions(args) {
10
+ const options = { open: false };
11
+ for (let index = 0; index < args.length; index += 1) {
12
+ const argument = args[index];
13
+ if (argument === '--open') {
14
+ options.open = true;
15
+ continue;
16
+ }
17
+ if (argument === '--help' || argument === '-h') {
18
+ options.help = true;
19
+ continue;
20
+ }
21
+ throw new Error(`Unknown option: ${argument}`);
22
+ }
23
+
24
+ return options;
25
+ }
26
+
27
+ function runCloud(args) {
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn(process.env.RUNCLOUD_BIN || 'runcloud', args, {
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ });
32
+ let stdout = '';
33
+ let stderr = '';
34
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
35
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
36
+ child.on('error', reject);
37
+ child.on('close', (code) => {
38
+ if (code !== 0) {
39
+ reject(new Error(stderr.trim() || stdout.trim() || `runcloud exited with code ${code}`));
40
+ return;
41
+ }
42
+ try {
43
+ resolve(JSON.parse(stdout));
44
+ } catch {
45
+ reject(new Error(`runcloud returned invalid JSON: ${stdout.trim()}`));
46
+ }
47
+ });
48
+ });
49
+ }
50
+
51
+ export function browserCommand(url, platform = process.platform) {
52
+ return platform === 'darwin'
53
+ ? ['open', [url]]
54
+ : platform === 'win32'
55
+ ? ['cmd', ['/c', 'start', '', url]]
56
+ : ['xdg-open', [url]];
57
+ }
58
+
59
+ function openBrowser(url) {
60
+ const command = browserCommand(url);
61
+ const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore' });
62
+ child.on('error', () => undefined);
63
+ child.unref();
64
+ }
65
+
66
+ function escapeHtml(value) {
67
+ return String(value)
68
+ .replaceAll('&', '&amp;')
69
+ .replaceAll('"', '&quot;')
70
+ .replaceAll('<', '&lt;')
71
+ .replaceAll('>', '&gt;');
72
+ }
73
+
74
+ function embedUrl(value) {
75
+ const url = new URL(value);
76
+ url.searchParams.set('loadingGuard', '1');
77
+ return url.toString();
78
+ }
79
+
80
+ export function mosaicViewerHtml(sessions, deadline = Date.now() + DEMO_LEASE_MS) {
81
+ const frames = sessions.map((session, index) => `
82
+ <section class="tile" data-tile="${index}">
83
+ <iframe title="Mosaic device ${index + 1}" src="${escapeHtml(embedUrl(session.url))}" allow="clipboard-read; clipboard-write"></iframe>
84
+ <span class="tile-number">${String(index + 1).padStart(2, '0')}</span>
85
+ </section>`).join('');
86
+ return `<!doctype html>
87
+ <html lang="en">
88
+ <head>
89
+ <meta charset="utf-8">
90
+ <meta name="viewport" content="width=device-width, initial-scale=1">
91
+ <title>run.cloud eight-device mosaic</title>
92
+ <style>
93
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #030404; color: #f8fafc; }
94
+ * { box-sizing: border-box; }
95
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
96
+ body { display: grid; grid-template-rows: 58px minmax(0, 1fr); background: #030404; }
97
+ header { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid rgba(255,255,255,.12); padding: 0 18px; background: #070909; }
98
+ .brand, .status, .actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
99
+ .brand-mark { width: 22px; height: 14px; border: 2px solid #67e8f9; transform: skewX(-18deg); }
100
+ .brand strong { font-size: 14px; font-weight: 700; }
101
+ .brand span, .status { color: #94a3b8; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
102
+ .status b { color: #bef264; font-weight: 600; }
103
+ #timer { width: 48px; color: #e2e8f0; text-align: right; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
104
+ button { height: 34px; border: 1px solid rgba(255,255,255,.18); border-radius: 4px; padding: 0 12px; background: transparent; color: #f8fafc; cursor: pointer; font: 600 12px ui-sans-serif, system-ui, sans-serif; }
105
+ button:hover { border-color: rgba(251,113,133,.7); color: #fda4af; }
106
+ main { min-height: 0; display: grid; place-items: center; padding: 8px; }
107
+ .mosaic { display: grid; grid-template-columns: repeat(${COLUMNS}, minmax(0, 1fr)); grid-template-rows: repeat(${ROWS}, minmax(0, 1fr)); gap: 5px; width: min(calc(100vw - 16px), calc((100vh - 74px) * 0.924)); height: min(calc(100vh - 74px), calc((100vw - 16px) / 0.924)); background: #111; }
108
+ .tile { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #080b0b; }
109
+ iframe { display: block; width: 100%; height: 100%; border: 0; }
110
+ .tile-number { position: absolute; top: 6px; left: 7px; z-index: 2; color: rgba(255,255,255,.55); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; pointer-events: none; transition: opacity 300ms ease; }
111
+ .tile[data-ready="true"] .tile-number { opacity: .18; }
112
+ .tile[data-ended="true"]::after { content: "ENDED"; position: absolute; inset: 0; display: grid; place-items: center; background: rgba(3,4,4,.82); color: #fda4af; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
113
+ @media (max-width: 680px) {
114
+ header { padding: 0 10px; }
115
+ .brand span { display: none; }
116
+ .status { font-size: 10px; }
117
+ button { width: 34px; padding: 0; font-size: 0; }
118
+ button::after { content: "×"; font-size: 20px; }
119
+ }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <header>
124
+ <div class="brand"><i class="brand-mark"></i><strong>run.cloud</strong><span>MOSAIC 08</span></div>
125
+ <div class="status"><b id="ready-count">0/${DEVICE_COUNT} LIVE</b><span id="timer">--:--</span></div>
126
+ <div class="actions"><button id="finish" type="button">End demo</button></div>
127
+ </header>
128
+ <main><div class="mosaic">${frames}</div></main>
129
+ <script>
130
+ const deadline = ${deadline};
131
+ const tiles = [...document.querySelectorAll('.tile')];
132
+ const frames = [...document.querySelectorAll('iframe')];
133
+ const ready = new Set();
134
+ const count = document.querySelector('#ready-count');
135
+ const timer = document.querySelector('#timer');
136
+ const tick = () => {
137
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
138
+ timer.textContent = String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
139
+ };
140
+ tick();
141
+ setInterval(tick, 250);
142
+ window.addEventListener('message', (event) => {
143
+ const index = frames.findIndex((frame) => frame.contentWindow === event.source);
144
+ if (index < 0 || !event.data || typeof event.data !== 'object') return;
145
+ if (event.data.type === 'ios-simulator:status' && event.data.streaming === true) {
146
+ ready.add(index);
147
+ tiles[index].dataset.ready = 'true';
148
+ count.textContent = ready.size + '/${DEVICE_COUNT} LIVE';
149
+ }
150
+ if (event.data.type === 'ios-simulator:session-ended') tiles[index].dataset.ended = 'true';
151
+ });
152
+ document.querySelector('#finish').addEventListener('click', async () => {
153
+ document.querySelector('#finish').disabled = true;
154
+ await fetch('/finish', { method: 'POST' }).catch(() => undefined);
155
+ });
156
+ </script>
157
+ </body>
158
+ </html>`;
159
+ }
160
+
161
+ export async function startMosaicViewer(sessions, deadline = Date.now() + DEMO_LEASE_MS) {
162
+ let finish;
163
+ const finished = new Promise((resolve) => { finish = resolve; });
164
+ const page = mosaicViewerHtml(sessions, deadline);
165
+ const server = createServer((request, response) => {
166
+ if (request.method === 'GET' && request.url === '/') {
167
+ response.writeHead(200, {
168
+ 'Cache-Control': 'no-store',
169
+ 'Content-Type': 'text/html; charset=utf-8',
170
+ });
171
+ response.end(page);
172
+ return;
173
+ }
174
+ if (request.method === 'POST' && request.url === '/finish') {
175
+ response.writeHead(204);
176
+ response.end();
177
+ finish('viewer');
178
+ return;
179
+ }
180
+ response.writeHead(404);
181
+ response.end('Not found');
182
+ });
183
+
184
+ await new Promise((resolve, reject) => {
185
+ server.once('error', reject);
186
+ server.listen(0, '127.0.0.1', resolve);
187
+ });
188
+ const address = server.address();
189
+ if (!address || typeof address === 'string') throw new Error('Could not start the local mosaic viewer.');
190
+
191
+ return {
192
+ url: `http://127.0.0.1:${address.port}/`,
193
+ finished,
194
+ close: () => new Promise((resolve, reject) => {
195
+ server.close((error) => error ? reject(error) : resolve());
196
+ }),
197
+ };
198
+ }
199
+
200
+ function stopSignal() {
201
+ let stop;
202
+ let stopped = false;
203
+ const promise = new Promise((resolve) => { stop = resolve; });
204
+ const interrupt = () => {
205
+ stopped = true;
206
+ stop('interrupt');
207
+ };
208
+ process.once('SIGINT', interrupt);
209
+ process.once('SIGTERM', interrupt);
210
+ return {
211
+ promise,
212
+ get stopped() { return stopped; },
213
+ dispose() {
214
+ process.removeListener('SIGINT', interrupt);
215
+ process.removeListener('SIGTERM', interrupt);
216
+ },
217
+ };
218
+ }
219
+
220
+ function waitForDemo(deadline, signals) {
221
+ let timer;
222
+ const timeout = new Promise((resolve) => {
223
+ timer = setTimeout(() => resolve('lease'), Math.max(0, deadline - Date.now()));
224
+ });
225
+ return Promise.race([timeout, ...signals]).finally(() => clearTimeout(timer));
226
+ }
227
+
228
+ async function releaseSessions(sessions) {
229
+ if (sessions.length === 0) return;
230
+ console.log('\nReleasing eight-device mosaic sessions...');
231
+ const results = await Promise.allSettled(
232
+ sessions.map((session) => runCloud(['ios', 'delete', session.id, '--json'])),
233
+ );
234
+ results.forEach((result, index) => {
235
+ const session = sessions[index];
236
+ if (result.status === 'fulfilled') console.log(` released ${session.id}`);
237
+ else console.error(` failed to release ${session.id}: ${result.reason.message}`);
238
+ });
239
+ }
240
+
241
+ function tileUrl(origin, tile) {
242
+ let url;
243
+ try {
244
+ url = new URL('/demos/eight-device-mosaic/', origin);
245
+ } catch {
246
+ throw new Error('RUN_CLOUD_DEMO_ORIGIN must be a valid absolute URL.');
247
+ }
248
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
249
+ throw new Error('RUN_CLOUD_DEMO_ORIGIN must use HTTP or HTTPS.');
250
+ }
251
+ url.searchParams.set('tile', String(tile));
252
+ return url.toString();
253
+ }
254
+
255
+ export async function runDemo(args = process.argv.slice(2), runtime = {}) {
256
+ const options = parseOptions(args);
257
+ if (options.help) {
258
+ console.log('Usage: npm run demo -- [--open]');
259
+ return;
260
+ }
261
+
262
+ const leaseMs = runtime.leaseMs ?? DEMO_LEASE_MS;
263
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('The demo lease must be positive.');
264
+ const deadline = Date.now() + leaseMs;
265
+ const origin = process.env.RUN_CLOUD_DEMO_ORIGIN || 'https://run.cloud';
266
+ const sessions = [];
267
+ const stop = stopSignal();
268
+ let viewer;
269
+
270
+ try {
271
+ console.log('Starting eight iOS simulator sessions for the mosaic...');
272
+ const starts = await Promise.allSettled(
273
+ Array.from({ length: DEVICE_COUNT }, async (_, index) => {
274
+ const session = await runCloud([
275
+ 'ios',
276
+ 'create',
277
+ '--display-name',
278
+ `mosaic-${String(index + 1).padStart(2, '0')}`,
279
+ '--label',
280
+ 'demo=eight-device-mosaic',
281
+ '--label',
282
+ `tile=${index}`,
283
+ '--hard-timeout',
284
+ '10m',
285
+ '--json',
286
+ ]);
287
+ sessions[index] = session;
288
+ await runCloud([
289
+ 'ios',
290
+ 'open-url',
291
+ tileUrl(origin, index),
292
+ '--id',
293
+ session.id,
294
+ '--json',
295
+ ]);
296
+ return session;
297
+ }),
298
+ );
299
+
300
+ const failures = starts
301
+ .map((result, index) => ({ result, index }))
302
+ .filter(({ result }) => result.status === 'rejected');
303
+ failures.forEach(({ result, index }) => {
304
+ console.error(` tile ${index + 1} failed: ${result.reason.message}`);
305
+ });
306
+ if (failures.length > 0 || stop.stopped) {
307
+ throw new Error(failures.length > 0
308
+ ? `The mosaic requires all ${DEVICE_COUNT} devices; ${failures.length} did not start.`
309
+ : 'The mosaic was interrupted during setup.');
310
+ }
311
+
312
+ viewer = await startMosaicViewer(sessions, deadline);
313
+ console.log(`\nEight-device mosaic: ${viewer.url}`);
314
+ if (options.open) openBrowser(viewer.url);
315
+ console.log('The mosaic lease is 10 minutes. Press Ctrl+C or select End demo to finish early.');
316
+ await waitForDemo(deadline, [stop.promise, viewer.finished]);
317
+ } finally {
318
+ stop.dispose();
319
+ if (viewer) await viewer.close();
320
+ await releaseSessions(sessions.filter(Boolean));
321
+ }
322
+ }
323
+
324
+ if (import.meta.url === new URL(process.argv[1], 'file:').href) {
325
+ runDemo().catch((error) => {
326
+ console.error(error instanceof Error ? error.message : String(error));
327
+ process.exitCode = 1;
328
+ });
329
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "run-cloud-eight-device-mosaic-demo",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "demo": "node demo.mjs",
8
+ "test": "node --test verify.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ }
13
+ }