runcloud 0.2.183 → 0.2.189

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.
@@ -481,7 +481,7 @@ export function registerBuild(program) {
481
481
  }
482
482
  const { client } = makeClient();
483
483
  const projectId = opts.remoteSource
484
- ? opts.project || readProjectLink(opts.sourcePath ?? '.')?.projectId
484
+ ? (opts.project ? resolveProject(process.cwd(), opts.project) : readProjectLink(opts.sourcePath ?? '.')?.projectId)
485
485
  : resolveProject(process.cwd(), opts.project);
486
486
  if (platform === 'android') {
487
487
  await runAndroidSimulatorBuild(client, projectId, opts);
@@ -0,0 +1,435 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { promisify } from 'node:util';
7
+ import { ApiClient, friendlyApiError } from '../api.js';
8
+ import { requireCredentials } from '../config.js';
9
+ const execFileAsync = promisify(execFile);
10
+ export const DOGFOOD_SECRET_GROUP = 'newly-dogfood';
11
+ export const DOGFOOD_AGENT_ACCOUNT = 'newly-dogfood-agent@newly-v3-dev.iam.gserviceaccount.com';
12
+ export const DEFAULT_DOGFOOD_MODEL = 'deepseek-v4-1-flash';
13
+ export const CREDS_REFRESH_MS = 40 * 60 * 1000;
14
+ function dogfoodStatePath() {
15
+ return join(process.env.NEWLY_HOME ?? join(homedir(), '.newly'), 'dogfood.json');
16
+ }
17
+ function readState() {
18
+ try {
19
+ return JSON.parse(readFileSync(dogfoodStatePath(), 'utf8'));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeState(state) {
26
+ const path = dogfoodStatePath();
27
+ mkdirSync(join(path, '..'), { recursive: true });
28
+ writeFileSync(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
29
+ }
30
+ function dogfoodSessionPath() {
31
+ return join(process.env.NEWLY_HOME ?? join(homedir(), '.newly'), 'dogfood-session');
32
+ }
33
+ function readDogfoodSession() {
34
+ try {
35
+ return JSON.parse(readFileSync(dogfoodSessionPath(), 'utf8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function writeDogfoodSession(session) {
42
+ const path = dogfoodSessionPath();
43
+ mkdirSync(join(path, '..'), { recursive: true });
44
+ writeFileSync(path, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 });
45
+ chmodSync(path, 0o600);
46
+ }
47
+ function dogfoodApiUrl() {
48
+ return (process.env.NEWLY_AUTH_API_URL ?? requireCredentials().apiUrl).replace(/\/$/, '');
49
+ }
50
+ function api() {
51
+ const apiUrl = dogfoodApiUrl();
52
+ const session = readDogfoodSession();
53
+ if (!session || session.apiUrl !== apiUrl) {
54
+ throw new Error(`not signed in to ${apiUrl} for dogfood: run \`newly dogfood login\` (scripts/dogfood.sh login)`);
55
+ }
56
+ return { client: new ApiClient(apiUrl, session.token), apiUrl };
57
+ }
58
+ const postJson = (fetchFn, url, body) => fetchFn(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
59
+ export async function sendSignInCode(apiUrl, email, fetchFn = fetch) {
60
+ const sent = await postJson(fetchFn, `${apiUrl}/api/auth/email-otp/send-verification-otp`, { email, type: 'sign-in' });
61
+ if (!sent.ok)
62
+ throw new Error(`could not send a sign-in code to ${email} (HTTP ${sent.status})`);
63
+ }
64
+ export async function verifySignInCode(apiUrl, email, code, fetchFn = fetch) {
65
+ const otp = code.trim();
66
+ if (!otp)
67
+ throw new Error('no code entered');
68
+ const signedIn = await postJson(fetchFn, `${apiUrl}/api/auth/sign-in/email-otp`, { email, otp });
69
+ if (!signedIn.ok)
70
+ throw new Error(`sign-in refused (HTTP ${signedIn.status}); check the code and try again`);
71
+ const token = signedIn.headers.get('set-auth-token');
72
+ if (!token)
73
+ throw new Error('sign-in succeeded but no session token came back');
74
+ return token;
75
+ }
76
+ function ask(question) {
77
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
78
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer); }));
79
+ }
80
+ function projectIdFrom(explicit, apiUrl) {
81
+ if (explicit)
82
+ return explicit;
83
+ const state = readState();
84
+ if (state && state.apiUrl === apiUrl)
85
+ return state.projectId;
86
+ throw new Error('no dogfood project yet: run `newly dogfood setup` (or pass --project <id>)');
87
+ }
88
+ async function run(fn) {
89
+ try {
90
+ await fn();
91
+ }
92
+ catch (err) {
93
+ console.error(friendlyApiError(err));
94
+ process.exitCode = 1;
95
+ }
96
+ }
97
+ const defaultExec = (file, args, env) => execFileAsync(file, args, { env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
98
+ export async function mintDogfoodGcpToken(exec = defaultExec) {
99
+ try {
100
+ const { stdout } = await exec('gcloud', [
101
+ 'auth',
102
+ 'print-access-token',
103
+ `--impersonate-service-account=${DOGFOOD_AGENT_ACCOUNT}`,
104
+ ]);
105
+ const token = stdout.trim();
106
+ if (!token)
107
+ throw new Error('gcloud printed no token');
108
+ return token;
109
+ }
110
+ catch (err) {
111
+ throw new Error(`could not impersonate ${DOGFOOD_AGENT_ACCOUNT}: ${errorText(err)}\n` +
112
+ 'You need roles/iam.serviceAccountTokenCreator on it (eng-debuggers@natively.dev members have it) ' +
113
+ 'and a current `gcloud auth login`.');
114
+ }
115
+ }
116
+ export async function readGithubToken(opts, exec = defaultExec) {
117
+ if (opts.tokenFile) {
118
+ const token = readFileSync(opts.tokenFile, 'utf8').trim();
119
+ if (!token)
120
+ throw new Error(`${opts.tokenFile} is empty`);
121
+ return { token, source: 'file' };
122
+ }
123
+ const env = opts.env ?? process.env;
124
+ const fromEnv = (env.GH_TOKEN ?? env.GITHUB_TOKEN ?? '').trim();
125
+ if (fromEnv)
126
+ return { token: fromEnv, source: 'env' };
127
+ try {
128
+ const { stdout } = await exec('gh', ['auth', 'token']);
129
+ const token = stdout.trim();
130
+ if (token)
131
+ return { token, source: 'gh-cli' };
132
+ }
133
+ catch {
134
+ }
135
+ throw new Error('no GitHub token: pass --github-token-file <path> (a fine-grained token on newly-app/newly with ' +
136
+ 'Contents and Pull requests read/write is recommended), set GH_TOKEN, or `gh auth login`.');
137
+ }
138
+ export async function verifySopsBoundary(repoRoot, gcpToken, exec = defaultExec) {
139
+ const env = { ...process.env, GOOGLE_OAUTH_ACCESS_TOKEN: gcpToken };
140
+ delete env.GOOGLE_CREDENTIALS;
141
+ delete env.GOOGLE_APPLICATION_CREDENTIALS;
142
+ const attempt = async (file) => {
143
+ try {
144
+ await exec('sops', ['decrypt', '--output', '/dev/null', join(repoRoot, file)], env);
145
+ return { ok: true, error: '' };
146
+ }
147
+ catch (err) {
148
+ return { ok: false, error: errorText(err) };
149
+ }
150
+ };
151
+ const dev = await attempt('secrets/dev.yaml');
152
+ const prod = await attempt('secrets/prod.yaml');
153
+ const detail = [
154
+ dev.ok ? 'secrets/dev.yaml: decrypts' : `secrets/dev.yaml: FAILED (${dev.error.split('\n')[0]})`,
155
+ prod.ok ? 'secrets/prod.yaml: DECRYPTS — this must not happen' : 'secrets/prod.yaml: refused',
156
+ ].join('\n');
157
+ return { dev: dev.ok, prodRefused: !prod.ok, detail };
158
+ }
159
+ function errorText(err) {
160
+ if (err && typeof err === 'object' && 'stderr' in err && typeof err.stderr === 'string' && err.stderr.trim()) {
161
+ return err.stderr.trim();
162
+ }
163
+ return err instanceof Error ? err.message : String(err);
164
+ }
165
+ async function projectOrgId(client, projectId) {
166
+ const project = await client.get(`/projects/${encodeURIComponent(projectId)}`);
167
+ const orgId = project?.org_id ?? project?.orgId ?? project?.project?.org_id ?? project?.project?.orgId;
168
+ if (typeof orgId !== 'string' || !orgId)
169
+ throw new Error(`could not read the org of project ${projectId}`);
170
+ return orgId;
171
+ }
172
+ export async function pushDogfoodCreds(client, projectId, entries) {
173
+ const orgId = await projectOrgId(client, projectId);
174
+ await client.put(`/orgs/${encodeURIComponent(orgId)}/secret-groups/${DOGFOOD_SECRET_GROUP}`, {
175
+ entries,
176
+ exposeToSandbox: true,
177
+ force: true,
178
+ });
179
+ }
180
+ async function credsOnce(opts) {
181
+ const { client, apiUrl } = api();
182
+ const projectId = projectIdFrom(opts.project, apiUrl);
183
+ const entries = {};
184
+ const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
185
+ entries.GH_TOKEN = github.token;
186
+ if (github.source === 'gh-cli') {
187
+ console.log('GitHub: using your gh CLI token (all of its scopes). A fine-grained token is safer: --github-token-file.');
188
+ }
189
+ if (opts.gcp)
190
+ entries.GOOGLE_OAUTH_ACCESS_TOKEN = await mintDogfoodGcpToken();
191
+ await pushDogfoodCreds(client, projectId, entries);
192
+ console.log(`${new Date().toISOString()} credentials pushed to project ${projectId}: ${Object.keys(entries).join(', ')}` +
193
+ (opts.gcp ? ' (GCP token valid ~1h)' : ''));
194
+ }
195
+ export function dogfoodSessionBody(opts) {
196
+ const body = {
197
+ kind: 'interactive',
198
+ harness: 'claude-code',
199
+ dogfood: { baseRef: opts.baseRef },
200
+ agentModel: opts.model,
201
+ };
202
+ const prompt = opts.prompt.trim();
203
+ if (prompt)
204
+ body.initialPrompt = prompt;
205
+ return body;
206
+ }
207
+ export function renderEvent(raw) {
208
+ const type = raw.type ?? raw.event?.type;
209
+ const data = (raw.data ?? raw.event?.data ?? {});
210
+ switch (type) {
211
+ case 'text_delta':
212
+ return typeof data.delta === 'string' ? data.delta : null;
213
+ case 'user_message':
214
+ return typeof data.text === 'string' ? `\n\n› ${data.text}\n` : null;
215
+ case 'tool_call': {
216
+ const input = data.input ?? data.args;
217
+ const summary = input === undefined ? '' : JSON.stringify(input);
218
+ return `\n ▸ ${String(data.name ?? 'tool')} ${summary.length > 160 ? `${summary.slice(0, 157)}...` : summary}\n`;
219
+ }
220
+ case 'tool_result':
221
+ return data.isError === true || data.ok === false ? ` ✗ ${String(data.output ?? 'tool failed').slice(0, 300)}\n` : null;
222
+ case 'ask_user':
223
+ return `\n? ${String(data.question ?? 'The agent has a question')}\n`;
224
+ case 'error':
225
+ return `\n! ${String(data.message ?? 'error')}\n`;
226
+ case 'turn_complete': {
227
+ const cost = typeof data.costUsd === 'number' ? ` · $${data.costUsd.toFixed(2)}` : '';
228
+ return `\n— turn complete${cost}\n`;
229
+ }
230
+ case 'session_started':
231
+ return '— session started\n';
232
+ case 'run_parked':
233
+ return '— session parked (send a message to wake it)\n';
234
+ default:
235
+ return null;
236
+ }
237
+ }
238
+ function eventsFrom(body) {
239
+ if (Array.isArray(body))
240
+ return body;
241
+ const events = body?.events;
242
+ return Array.isArray(events) ? events : [];
243
+ }
244
+ async function attach(client, sessionId, opts) {
245
+ let afterSeq = 0;
246
+ let stopped = false;
247
+ const rl = createInterface({ input: process.stdin });
248
+ rl.on('line', (line) => {
249
+ const text = line.trim();
250
+ if (!text)
251
+ return;
252
+ if (text === '/quit' || text === '/exit') {
253
+ stopped = true;
254
+ rl.close();
255
+ return;
256
+ }
257
+ if (text === '/cancel') {
258
+ void client.post(`/sessions/${sessionId}/cancel`, {}).catch((err) => console.error(friendlyApiError(err)));
259
+ return;
260
+ }
261
+ const body = { text };
262
+ if (opts.model)
263
+ body.agentModel = opts.model;
264
+ void client.post(`/sessions/${sessionId}/messages`, body).catch((err) => console.error(friendlyApiError(err)));
265
+ });
266
+ rl.on('close', () => {
267
+ stopped = true;
268
+ });
269
+ console.log(`attached to ${sessionId} — type a message and Enter to send; /cancel stops the turn; /quit detaches.`);
270
+ while (!stopped) {
271
+ try {
272
+ const body = await client.get(`/sessions/${sessionId}/events?after_seq=${afterSeq}&limit=500`);
273
+ for (const event of eventsFrom(body)) {
274
+ if (typeof event.seq === 'number')
275
+ afterSeq = Math.max(afterSeq, event.seq);
276
+ const text = renderEvent(event);
277
+ if (text)
278
+ process.stdout.write(text);
279
+ }
280
+ }
281
+ catch (err) {
282
+ console.error(friendlyApiError(err));
283
+ }
284
+ await new Promise((resolve) => setTimeout(resolve, 1000));
285
+ }
286
+ }
287
+ async function repoRoot() {
288
+ try {
289
+ const { stdout } = await defaultExec('git', ['rev-parse', '--show-toplevel']);
290
+ const root = stdout.trim();
291
+ return existsSync(join(root, 'secrets', 'dev.yaml')) ? root : null;
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ export function registerDogfood(program) {
298
+ const dogfood = program
299
+ .command('dogfood')
300
+ .description('Newly agent sessions that work on the Newly monorepo itself (staff, feature-flagged)');
301
+ dogfood
302
+ .command('login')
303
+ .description('Sign in to the control plane for dogfood commands (email code; separate from `newly login`)')
304
+ .option('--email <email>', 'your staff email (default: the `newly login` account)')
305
+ .option('--code <code>', 'finish a sign-in with the emailed code (when no terminal can prompt)')
306
+ .action((opts) => run(async () => {
307
+ const apiUrl = dogfoodApiUrl();
308
+ let email = opts.email;
309
+ if (!email) {
310
+ try {
311
+ email = requireCredentials().account;
312
+ }
313
+ catch {
314
+ }
315
+ }
316
+ if (!email)
317
+ email = (await ask('Email: ')).trim();
318
+ if (!email)
319
+ throw new Error('an email is required');
320
+ let code = opts.code;
321
+ if (!code) {
322
+ await sendSignInCode(apiUrl, email);
323
+ console.log(`Sent a sign-in code to ${email} (${apiUrl}).`);
324
+ if (!process.stdin.isTTY) {
325
+ console.log('Finish with: scripts/dogfood.sh login --code <code from the email>');
326
+ return;
327
+ }
328
+ code = await ask('Code from the email: ');
329
+ }
330
+ const token = await verifySignInCode(apiUrl, email, code);
331
+ writeDogfoodSession({ apiUrl, token, account: email });
332
+ await new ApiClient(apiUrl, token).get('/projects');
333
+ console.log(`Signed in as ${email}. Next: \`newly dogfood setup\`.`);
334
+ }));
335
+ dogfood
336
+ .command('setup')
337
+ .description('Create or adopt your dogfood project and verify credentials end to end')
338
+ .option('--project <id>', 'adopt an existing project instead of creating one')
339
+ .option('--name <name>', 'name for a new project', 'Newly (dogfood)')
340
+ .option('--github-token-file <path>', 'GitHub token to give sessions (default: GH_TOKEN, then gh CLI)')
341
+ .action((opts) => run(async () => {
342
+ const { client, apiUrl } = api();
343
+ console.log(`control plane: ${apiUrl}`);
344
+ console.log('1/4 GCP: minting a dogfood-agent token by impersonation...');
345
+ const gcpToken = await mintDogfoodGcpToken();
346
+ console.log(' ok');
347
+ console.log('2/4 SOPS: dev must decrypt, prod must be refused...');
348
+ const root = await repoRoot();
349
+ if (root) {
350
+ const result = await verifySopsBoundary(root, gcpToken);
351
+ console.log(result.detail.replace(/^/gm, ' '));
352
+ if (!result.prodRefused)
353
+ throw new Error('the dogfood token can decrypt prod — stop and report this');
354
+ if (!result.dev)
355
+ throw new Error('the dogfood token cannot decrypt dev; see the error above');
356
+ }
357
+ else {
358
+ console.log(' skipped: run setup from a monorepo checkout to verify (needs secrets/*.yaml and sops >= 3.10)');
359
+ }
360
+ console.log('3/4 GitHub: reading a token...');
361
+ const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
362
+ console.log(` ok (${github.source})`);
363
+ console.log('4/4 Project...');
364
+ let projectId = opts.project ?? (readState()?.apiUrl === apiUrl ? readState()?.projectId : undefined);
365
+ if (!projectId) {
366
+ const listing = await client.get('/projects');
367
+ const orgId = listing?.defaultOrgId;
368
+ if (typeof orgId !== 'string' || !orgId)
369
+ throw new Error('could not find your workspace to create the project in');
370
+ const created = await client.post(`/orgs/${encodeURIComponent(orgId)}/projects`, { name: opts.name });
371
+ projectId = (created?.id ?? created?.projectId ?? created?.project?.id);
372
+ if (!projectId)
373
+ throw new Error('project created but no id came back');
374
+ console.log(` created ${projectId}`);
375
+ }
376
+ else {
377
+ console.log(` using ${projectId}`);
378
+ }
379
+ writeState({ projectId, apiUrl });
380
+ await pushDogfoodCreds(client, projectId, { GH_TOKEN: github.token, GOOGLE_OAUTH_ACCESS_TOKEN: gcpToken });
381
+ console.log(' credentials stored as the project\'s "newly-dogfood" secret group (dogfood sessions only)');
382
+ console.log('\nNext: `newly dogfood creds --watch` in a spare terminal (keeps the GCP token fresh),');
383
+ console.log('then `newly dogfood start "<what to do>"`.');
384
+ }));
385
+ dogfood
386
+ .command('creds')
387
+ .description('Mint fresh credentials and give them to your dogfood sessions')
388
+ .option('--project <id>', 'dogfood project (default: the one setup created)')
389
+ .option('--github-token-file <path>', 'GitHub token file (default: GH_TOKEN, then gh CLI)')
390
+ .option('--no-gcp', 'do not mint a GCP token (sessions then cannot use sops)')
391
+ .option('--watch', `keep refreshing every ${CREDS_REFRESH_MS / 60_000} minutes`, false)
392
+ .action((opts) => run(async () => {
393
+ await credsOnce(opts);
394
+ if (!opts.watch)
395
+ return;
396
+ for (;;) {
397
+ await new Promise((resolve) => setTimeout(resolve, CREDS_REFRESH_MS));
398
+ try {
399
+ await credsOnce(opts);
400
+ }
401
+ catch (err) {
402
+ console.error(`${new Date().toISOString()} refresh failed: ${friendlyApiError(err)}`);
403
+ }
404
+ }
405
+ }));
406
+ dogfood
407
+ .command('start')
408
+ .description('Start a dogfood session on the Newly monorepo')
409
+ .argument('[prompt...]', 'what the agent should do')
410
+ .option('--project <id>', 'dogfood project (default: the one setup created)')
411
+ .option('--model <id>', 'agent model', DEFAULT_DOGFOOD_MODEL)
412
+ .option('--base-ref <ref>', 'branch of newly-app/newly to start from', 'main')
413
+ .option('--no-attach', 'print the session id and exit')
414
+ .action((promptWords, opts) => run(async () => {
415
+ const { client, apiUrl } = api();
416
+ const projectId = projectIdFrom(opts.project, apiUrl);
417
+ const body = dogfoodSessionBody({ model: opts.model, baseRef: opts.baseRef, prompt: promptWords.join(' ') });
418
+ const created = await client.post(`/projects/${encodeURIComponent(projectId)}/sessions`, body);
419
+ const sessionId = (created?.id ?? created?.sessionId ?? created?.session?.id);
420
+ if (!sessionId)
421
+ throw new Error('session created but no id came back');
422
+ console.log(`session ${sessionId} (project ${projectId}, model ${opts.model})`);
423
+ if (opts.attach)
424
+ await attach(client, sessionId, { model: opts.model });
425
+ }));
426
+ dogfood
427
+ .command('attach')
428
+ .description('Follow a session in the terminal and send it messages')
429
+ .argument('<sessionId>', 'session id')
430
+ .option('--model <id>', 'agent model for the messages you send')
431
+ .action((sessionId, opts) => run(async () => {
432
+ const { client } = api();
433
+ await attach(client, sessionId, opts);
434
+ }));
435
+ }
@@ -1,11 +1,12 @@
1
- import { readProjectLink, writeProjectLink } from '../config.js';
1
+ import { readProjectLink, resolveProject, writeProjectLink } from '../config.js';
2
2
  export function registerLink(program) {
3
3
  program
4
4
  .command('link')
5
5
  .argument('<project-id>', 'Newly project id')
6
6
  .description('Associate the current directory with a project (no download) so deploy/status/logs work here')
7
- .action((projectId) => {
7
+ .action((projectRef) => {
8
8
  const dir = process.cwd();
9
+ const projectId = resolveProject(dir, projectRef);
9
10
  const existing = readProjectLink(dir);
10
11
  writeProjectLink(dir, {
11
12
  projectId,
@@ -29,7 +29,7 @@ export function registerPull(program) {
29
29
  const client = new ApiClient(creds.apiUrl, creds.token);
30
30
  const query = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
31
31
  console.log(`Downloading project ${projectId}${opts.branch ? ` (branch ${opts.branch})` : ''}…`);
32
- const res = (await client.get(`/newly_backend/repo_archive/${projectId}${query}`));
32
+ const res = (await client.get(`/newly_backend/repo_archive/${encodeURIComponent(projectId)}${query}`));
33
33
  const data = Buffer.from(await res.arrayBuffer());
34
34
  if (opts.force) {
35
35
  const keep = await snapshotTopLevelEntries(data);
@@ -1,4 +1,4 @@
1
- import { Option } from 'commander';
1
+ import { InvalidArgumentError, Option } from 'commander';
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { basename, dirname, extname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
@@ -7,6 +7,22 @@ import { createHash, randomUUID } from 'node:crypto';
7
7
  import { ApiClient, ApiError, ApiTransportError, friendlyApiError } from '../api.js';
8
8
  import { requireCredentials } from '../config.js';
9
9
  import { recordSimulatorAudio } from './run-cloud-audio.js';
10
+ export const IOS_SIMULATOR_CAPABILITIES = ['HealthKit', 'HomeKit', 'WidgetKit', 'SpeechSynthesis', 'Contacts', 'Calendars'];
11
+ export const IOS_PLATFORM_VERSIONS = ['26.5'];
12
+ export const IOS_DEVICE_NAMES = ['iPhone 17e'];
13
+ export function runCloudIosOptions(opts) {
14
+ const capabilities = [...new Set(opts.capability ?? [])];
15
+ if (!capabilities.length && !opts.platformVersion && !opts.deviceName)
16
+ return {};
17
+ return {
18
+ platformName: 'iOS',
19
+ 'runcloud:options': {
20
+ ...(capabilities.length ? { capabilities } : {}),
21
+ ...(opts.platformVersion ? { platformVersion: opts.platformVersion } : {}),
22
+ ...(opts.deviceName ? { deviceName: opts.deviceName } : {}),
23
+ },
24
+ };
25
+ }
10
26
  export const SIMULATOR_KEYS = [
11
27
  'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
12
28
  'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
@@ -962,6 +978,7 @@ async function createSimulatorSession(platform, opts) {
962
978
  autoDelete: opts.rm,
963
979
  codec: requestedCodec(opts.codec),
964
980
  ...(opts.devHost ? { devHost: opts.devHost } : {}),
981
+ ...(platform === 'ios' ? runCloudIosOptions(opts) : {}),
965
982
  });
966
983
  }
967
984
  async function openSimulatorSessionUrl(platform, sessionId, url) {
@@ -1041,6 +1058,19 @@ function registerSimulatorCommands(program, platform) {
1041
1058
  .option('--rm', 'release the session when this command exits', false)
1042
1059
  .option('--json', 'output JSON', false)
1043
1060
  .addOption(new Option('--dev-host <host>', 'dev-only: target a specific simulator fleet host').hideHelp());
1061
+ if (platform === 'ios') {
1062
+ createCommand
1063
+ .addOption(new Option('--capability <name>', 'iOS capability to turn on (repeatable); see run.cloud/docs/ios/capabilities')
1064
+ .choices(IOS_SIMULATOR_CAPABILITIES)
1065
+ .argParser((value, previous = []) => {
1066
+ if (!IOS_SIMULATOR_CAPABILITIES.includes(value)) {
1067
+ throw new InvalidArgumentError(`Allowed choices are ${IOS_SIMULATOR_CAPABILITIES.join(', ')}.`);
1068
+ }
1069
+ return [...previous, value];
1070
+ }))
1071
+ .addOption(new Option('--platform-version <version>', 'iOS runtime version').choices(IOS_PLATFORM_VERSIONS))
1072
+ .addOption(new Option('--device-name <name>', 'simulator device type').choices(IOS_DEVICE_NAMES));
1073
+ }
1044
1074
  simulator.command('audio')
1045
1075
  .description('Capture simulator speaker output as a WAV file')
1046
1076
  .argument('<id>', 'session id')
package/dist/config.js CHANGED
@@ -31,6 +31,9 @@ export function credentialsDisplayPath() {
31
31
  return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
32
32
  }
33
33
  export function loginCommandName() {
34
+ const override = process.env.NEWLY_LOGIN_COMMAND?.trim();
35
+ if (override)
36
+ return override;
34
37
  return isRunCloud() ? 'runcloud login' : 'newly login';
35
38
  }
36
39
  export function normalizeApiUrl(url) {
@@ -123,22 +126,33 @@ export function writeProjectLink(dir, link) {
123
126
  const gitignorePath = join(linkDir, '.gitignore');
124
127
  if (!existsSync(gitignorePath))
125
128
  writeFileSync(gitignorePath, '*\n');
126
- writeFileSync(join(linkDir, 'project.json'), JSON.stringify(link, null, 2) + '\n');
129
+ const clean = { ...link, projectId: projectIdFrom(link.projectId) };
130
+ writeFileSync(join(linkDir, 'project.json'), JSON.stringify(clean, null, 2) + '\n');
127
131
  }
128
132
  export function readProjectLink(dir) {
129
133
  const path = join(dir, '.newly', 'project.json');
130
134
  if (!existsSync(path))
131
135
  return null;
132
136
  try {
133
- return JSON.parse(readFileSync(path, 'utf8'));
137
+ const link = JSON.parse(readFileSync(path, 'utf8'));
138
+ return typeof link.projectId === 'string' ? { ...link, projectId: projectIdFrom(link.projectId) } : link;
134
139
  }
135
140
  catch {
136
141
  return null;
137
142
  }
138
143
  }
144
+ export function projectIdFrom(ref) {
145
+ const parts = ref.trim().split(/[?#]/)[0].split('/').filter(Boolean);
146
+ const at = parts.indexOf('projects');
147
+ return (at >= 0 ? parts[at + 1] : parts.at(-1)) ?? '';
148
+ }
139
149
  export function resolveProject(dir, explicit) {
140
- if (explicit)
141
- return explicit;
150
+ if (explicit) {
151
+ const id = projectIdFrom(explicit);
152
+ if (!id)
153
+ throw new Error(`Not a project id: ${explicit}`);
154
+ return id;
155
+ }
142
156
  const managedSessionProjectId = process.env['NEWLY_PROJECT_ID'];
143
157
  if (managedSessionProjectId)
144
158
  return managedSessionProjectId;
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { registerEnvironment } from './commands/environment.js';
24
24
  import { registerAppStore } from './commands/appStore.js';
25
25
  import { registerUpdate } from './commands/update.js';
26
26
  import { registerToken } from './commands/token.js';
27
+ import { registerDogfood } from './commands/dogfood.js';
27
28
  import { CLI_VERSION } from './version.js';
28
29
  const program = new Command('newly')
29
30
  .description('Develop, provision, and deploy Newly backends')
@@ -52,4 +53,5 @@ registerEnvironment(program);
52
53
  registerAppStore(program);
53
54
  registerUpdate(program);
54
55
  registerToken(program);
56
+ registerDogfood(program);
55
57
  await program.parseAsync(process.argv);
@@ -3,7 +3,7 @@ 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
5
  ];
6
- const SERVICE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
6
+ const SERVICE_NAME = /^(?!prod-)[a-z][a-z0-9-]{0,31}$/;
7
7
  export function readBackendServices(dir) {
8
8
  const manifestPath = join(dir, 'backend', 'newly.services.json');
9
9
  if (!existsSync(manifestPath))
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.183';
1
+ export const CLI_VERSION = '0.2.189';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.183",
3
+ "version": "0.2.189",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
@@ -364,6 +364,7 @@ warm-parked and resumable is the most expensive mistake available here.
364
364
  | `host-state-reconcile` | paused | the host had already parked it and the control plane caught up. The pause happened earlier than this event says |
365
365
  | `host-reconcile` `reap-sweep` | destroyed | the platform reclaimed it |
366
366
  | `boot-failure` `placement-timeout` `request-timeout` `scheduler` | never ran | capacity or image, not the workload |
367
+ | `orphaned-placement` | never ran | the control plane restarted while placing it (stuck in pending/starting); create it again |
367
368
  | `image-built` `image-build-failure` | varies | an async image build finished or failed |
368
369
  | `startup-recover` `volume-lease-invalid` `replica-fork-resume-failure` | varies | platform recovery paths |
369
370
 
Binary file