@newly/cli 0.1.1 → 0.1.2

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.
@@ -1,12 +1,250 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { ApiClient, ApiError } from '../api.js';
4
+ import { createSnapshot } from '../archive.js';
5
+ import { readProjectLink, requireCredentials, resolveProject, writeProjectLink } from '../config.js';
1
6
  export function buildPlaceholderMessage(platform) {
2
7
  return (`In-house ${platform} builds are coming in an upcoming release.\n` +
3
8
  'Set up iOS signing first with `newly creds apple` (check readiness with `newly creds apple status`).');
4
9
  }
10
+ function makeClient() {
11
+ const creds = requireCredentials();
12
+ return {
13
+ client: new ApiClient(creds.apiUrl, creds.token),
14
+ };
15
+ }
16
+ function assertDirectory(path, label) {
17
+ if (!existsSync(path))
18
+ throw new Error(`${label} does not exist: ${path}`);
19
+ if (!statSync(path).isDirectory())
20
+ throw new Error(`${label} is not a directory: ${path}`);
21
+ }
22
+ function archiveKib(size) {
23
+ return size < 1024 ? '<1' : Math.round(size / 1024).toString();
24
+ }
25
+ async function ensureRemoteSourceProject(client, sourceDir, explicitProject, log) {
26
+ if (explicitProject)
27
+ return explicitProject;
28
+ const linked = readProjectLink(sourceDir);
29
+ if (linked?.projectId)
30
+ return linked.projectId;
31
+ const name = basename(resolve(sourceDir)) || 'iOS simulator build';
32
+ log('No Newly project link found; creating a build-only project for this local source...');
33
+ const created = (await client.post('/newly_backend/create_project', {
34
+ name,
35
+ provision: false,
36
+ }));
37
+ const projectId = typeof created.project_id === 'string' ? created.project_id : '';
38
+ if (!projectId)
39
+ throw new Error('Backend did not return project_id for build-only project.');
40
+ writeProjectLink(sourceDir, {
41
+ projectId,
42
+ pulledSha: null,
43
+ pulledAt: new Date().toISOString(),
44
+ });
45
+ log(`Linked local source to Newly project ${projectId} for remote simulator builds.`);
46
+ return projectId;
47
+ }
48
+ function parseSseData(frame) {
49
+ const out = [];
50
+ for (const part of frame.split(/\n\n+/)) {
51
+ const data = part
52
+ .split(/\n/)
53
+ .filter((line) => line.startsWith('data:'))
54
+ .map((line) => line.slice(5).trimStart())
55
+ .join('\n');
56
+ if (!data)
57
+ continue;
58
+ try {
59
+ out.push(JSON.parse(data));
60
+ }
61
+ catch {
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ async function waitForBuild(client, buildId, log) {
67
+ let status = 'queued';
68
+ const answeredPrompts = new Set();
69
+ for await (const chunk of client.stream(`/builds/${buildId}/stream`)) {
70
+ for (const event of parseSseData(chunk)) {
71
+ const promptId = typeof event.id === 'string' ? event.id : '';
72
+ if (promptId === 'jenkins-fallback-choice' && !answeredPrompts.has(promptId)) {
73
+ answeredPrompts.add(promptId);
74
+ if (typeof event.message === 'string')
75
+ log(event.message);
76
+ log('Default build system failed; leaving this remote simulator build failed.');
77
+ await client.post(`/builds/${buildId}/respond`, {
78
+ promptId,
79
+ value: 'keep_failed',
80
+ });
81
+ status = 'failed';
82
+ continue;
83
+ }
84
+ if (Array.isArray(event.new_logs)) {
85
+ for (const line of event.new_logs)
86
+ log(String(line));
87
+ }
88
+ if (typeof event.message === 'string')
89
+ log(event.message);
90
+ if (typeof event.status === 'string')
91
+ status = event.status;
92
+ }
93
+ }
94
+ return status;
95
+ }
96
+ function serveSimPrepareUrl(session) {
97
+ if (typeof session.base_url !== 'string' || typeof session.install_url !== 'string')
98
+ return null;
99
+ const installUrl = new URL(session.install_url);
100
+ const prepareUrl = new URL(`${session.base_url.replace(/\/+$/, '')}/api/integration/prepare`);
101
+ const device = installUrl.searchParams.get('device');
102
+ if (device)
103
+ prepareUrl.searchParams.set('device', device);
104
+ prepareUrl.searchParams.set('async', '1');
105
+ return prepareUrl.toString();
106
+ }
107
+ async function prepareServeSimBuildFromCli(session, launchUrl) {
108
+ if (typeof session.token !== 'string') {
109
+ throw new Error('Backend returned a serve-sim session without a token.');
110
+ }
111
+ const prepareUrl = serveSimPrepareUrl(session);
112
+ if (!prepareUrl) {
113
+ throw new Error('Backend returned a serve-sim session without a prepare target.');
114
+ }
115
+ const controller = new AbortController();
116
+ const timeout = setTimeout(() => controller.abort(), 20_000);
117
+ try {
118
+ const res = await fetch(prepareUrl, {
119
+ method: 'POST',
120
+ headers: {
121
+ Authorization: `Bearer ${session.token}`,
122
+ 'Content-Type': 'application/json',
123
+ },
124
+ body: JSON.stringify({
125
+ artifactUrl: typeof session.artifact_url === 'string' ? session.artifact_url : undefined,
126
+ launchUrl,
127
+ }),
128
+ signal: controller.signal,
129
+ });
130
+ if (!res.ok) {
131
+ const body = await res.text().catch(() => '');
132
+ throw new Error(`serve-sim prepare failed: HTTP ${res.status}${body ? ` ${body}` : ''}`);
133
+ }
134
+ }
135
+ catch (error) {
136
+ if (error instanceof DOMException && error.name === 'AbortError') {
137
+ throw new Error('serve-sim prepare timed out after 20000ms');
138
+ }
139
+ throw error;
140
+ }
141
+ finally {
142
+ clearTimeout(timeout);
143
+ }
144
+ }
145
+ export async function runIosSimulatorBuild(client, projectId, opts = {}, log = console.log) {
146
+ let build;
147
+ const metroUrl = opts.metroUrl?.trim() || null;
148
+ if (opts.remoteSource) {
149
+ const sourceDir = opts.sourcePath ?? '.';
150
+ assertDirectory(sourceDir, 'iOS build source directory');
151
+ projectId = await ensureRemoteSourceProject(client, sourceDir, projectId, log);
152
+ log('Packaging iOS source...');
153
+ const archive = await createSnapshot(sourceDir);
154
+ log(`Uploading source snapshot (${archiveKib(archive.length)} KiB) for remote iOS simulator build...`);
155
+ const form = new FormData();
156
+ form.set('request', JSON.stringify({
157
+ project_id: projectId,
158
+ platform: 'ios',
159
+ mode: 'automatic',
160
+ build_profile: 'development-simulator',
161
+ }));
162
+ form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
163
+ build = (await client.uploadForm('/builds/source-upload', form));
164
+ }
165
+ else {
166
+ projectId = resolveProject(process.cwd(), projectId);
167
+ build = (await client.post('/builds', {
168
+ projectId,
169
+ platform: 'ios',
170
+ mode: 'automatic',
171
+ buildProfile: 'development-simulator',
172
+ }));
173
+ }
174
+ log(`Build ${build.id} queued (${build.status})`);
175
+ if (opts.wait) {
176
+ const status = await waitForBuild(client, build.id, log);
177
+ log(`Build ${build.id} ${status}`);
178
+ if (status !== 'completed')
179
+ throw new Error(`iOS simulator build ${status}`);
180
+ }
181
+ if (opts.loadToSimulator) {
182
+ const load = (provider) => {
183
+ const params = new URLSearchParams();
184
+ if (provider)
185
+ params.set('provider', provider);
186
+ if (provider === 'serve-sim' && metroUrl)
187
+ params.set('expo_url', metroUrl);
188
+ const qs = params.size ? `?${params.toString()}` : '';
189
+ return client.post(`/builds/${build.id}/load-to-simulator${qs}`, {});
190
+ };
191
+ let loaded;
192
+ try {
193
+ loaded = await load(opts.provider);
194
+ }
195
+ catch (e) {
196
+ if (opts.provider === 'serve-sim' && e instanceof ApiError && (e.status === 503 || e.status === 500)) {
197
+ log('Cloud iOS simulator unavailable; falling back to Appetize...');
198
+ loaded = await load();
199
+ }
200
+ else {
201
+ throw e;
202
+ }
203
+ }
204
+ if (typeof loaded.appetize_public_key === 'string') {
205
+ log(`Appetize iOS key: ${loaded.appetize_public_key}`);
206
+ }
207
+ else if (loaded.serve_sim && typeof loaded.serve_sim === 'object') {
208
+ const session = loaded.serve_sim;
209
+ await prepareServeSimBuildFromCli(session, metroUrl);
210
+ const url = typeof session.url === 'string' ? session.url : session.base_url;
211
+ log(`Cloud simulator prepared on ${typeof url === 'string' ? url : 'ready'}`);
212
+ }
213
+ else {
214
+ log('Simulator load requested.');
215
+ }
216
+ }
217
+ }
5
218
  export function registerBuild(program) {
6
219
  program
7
220
  .command('build [platform]')
8
- .description('Build the mobile app (coming soon)')
9
- .action((platform = 'ios') => {
10
- console.log(buildPlaceholderMessage(platform));
221
+ .description('Build the mobile app')
222
+ .option('--project <id>', 'project id (defaults to the linked directory)')
223
+ .option('--source-path <path>', 'local source directory to upload for --remote-source', '.')
224
+ .option('--simulator', 'build an iOS simulator artifact', false)
225
+ .option('--remote-source', 'upload local source files and build in Newly cloud', false)
226
+ .option('--wait', 'stream logs until the build finishes', false)
227
+ .option('--load-to-simulator', 'load a completed simulator build into a cloud simulator', false)
228
+ .option('--provider <provider>', 'simulator provider for --load-to-simulator (serve-sim or appetize)')
229
+ .option('--metro-url <url>', 'reachable Metro bundle URL for remote simulator source builds')
230
+ .action(async (platform = 'ios', opts) => {
231
+ try {
232
+ if (platform !== 'ios' || !opts.simulator) {
233
+ console.log(buildPlaceholderMessage(platform));
234
+ return;
235
+ }
236
+ if (opts.provider && !opts.loadToSimulator) {
237
+ throw new Error('--provider requires --load-to-simulator.');
238
+ }
239
+ const { client } = makeClient();
240
+ const projectId = opts.remoteSource
241
+ ? opts.project || readProjectLink(opts.sourcePath ?? '.')?.projectId
242
+ : resolveProject(process.cwd(), opts.project);
243
+ await runIosSimulatorBuild(client, projectId, opts);
244
+ }
245
+ catch (e) {
246
+ console.error(e instanceof Error ? e.message : String(e));
247
+ process.exitCode = 1;
248
+ }
11
249
  });
12
250
  }
@@ -1,7 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { spawn } from 'node:child_process';
4
- import { clearCredentials, saveCredentials } from '../config.js';
4
+ import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
5
5
  const DEFAULT_APP_URL = process.env.NEWLY_APP_URL ?? 'https://newly.app';
6
6
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
7
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
@@ -152,7 +152,7 @@ export function registerLogin(program) {
152
152
  appUrl: opts.appUrl,
153
153
  expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
154
154
  });
155
- console.log('Logged in. Credentials saved to ~/.newly/credentials');
155
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
156
156
  }
157
157
  finally {
158
158
  close();
@@ -0,0 +1,272 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { ApiClient, friendlyApiError } from '../api.js';
5
+ import { requireCredentials } from '../config.js';
6
+ const RUN_CLOUD_SKILL = `---
7
+ name: run-cloud-ios-simulator
8
+ description: Build, install, and inspect iOS apps with run.cloud remote simulator sessions.
9
+ version: 0.1.0
10
+ ---
11
+
12
+ # run.cloud iOS Simulator
13
+
14
+ Use this skill when a user asks an agent to run, inspect, smoke test, or debug an iOS app in a remote simulator through run.cloud.
15
+
16
+ ## Requirements
17
+
18
+ - The user must be authenticated with \`runcloud login\`.
19
+ - The account must have run.cloud access and a positive run.cloud balance.
20
+ - App artifacts should be simulator-compatible builds. Device App Store IPAs may fail to install on iOS Simulator.
21
+
22
+ ## Workflow
23
+
24
+ 1. Check access with \`runcloud account --json\`.
25
+ 2. Create a simulator with \`runcloud ios create --install ./build/MyApp.tar.gz --json\`.
26
+ 3. Save the returned session id and URL.
27
+ 4. Use \`runcloud ios open-url <url> --id <session-id>\` when a URL or deep link must be opened.
28
+ 5. Release the session with \`runcloud ios delete <session-id>\`.
29
+
30
+ ## Embedded Iframes
31
+
32
+ - Use \`runcloud ios create --inactivity-timeout 60s --json\` when the embed should auto-close after user inactivity.
33
+ - Omit \`--inactivity-timeout\` or pass \`none\` when the user needs a metered session without idle auto-close.
34
+ - Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` to the parent window. Verify \`event.source\` before acting.
35
+ - If \`ios-simulator:session-restart-requested\` arrives, create a fresh run.cloud session; do not reuse the ended iframe URL.
36
+
37
+ ## Rules
38
+
39
+ - Prefer \`--json\` for parsed output.
40
+ - Always release sessions you create unless the user asks to keep them open.
41
+ - If installation fails, verify that the artifact is a simulator build before attempting code changes.
42
+ - Do not assume a local tunnel is installed on the user's machine.
43
+ - Do not expose iOS simulator tokens in logs or screenshots.
44
+ `;
45
+ function client() {
46
+ const creds = requireCredentials();
47
+ return new ApiClient(creds.apiUrl, creds.token);
48
+ }
49
+ function print(value, opts = {}) {
50
+ if (opts.json) {
51
+ console.log(JSON.stringify(value, null, 2));
52
+ return;
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value)
56
+ console.log(formatRecord(item));
57
+ return;
58
+ }
59
+ console.log(formatRecord(value));
60
+ }
61
+ function formatRecord(value) {
62
+ if (!value || typeof value !== 'object')
63
+ return String(value);
64
+ const record = value;
65
+ return Object.entries(record)
66
+ .filter(([, v]) => v !== undefined && v !== null)
67
+ .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
68
+ .join('\n');
69
+ }
70
+ function parseLabels(labels) {
71
+ const out = {};
72
+ for (const raw of labels ?? []) {
73
+ const idx = raw.indexOf('=');
74
+ if (idx <= 0)
75
+ throw new Error(`Invalid --label ${raw}; expected key=value`);
76
+ out[raw.slice(0, idx)] = raw.slice(idx + 1);
77
+ }
78
+ return out;
79
+ }
80
+ function fileBlob(path) {
81
+ const resolved = resolve(path);
82
+ if (!existsSync(resolved))
83
+ throw new Error(`File does not exist: ${path}`);
84
+ if (!statSync(resolved).isFile())
85
+ throw new Error(`Not a file: ${path}`);
86
+ const data = readFileSync(resolved);
87
+ return {
88
+ blob: new Blob([new Uint8Array(data)], { type: 'application/octet-stream' }),
89
+ name: basename(resolved),
90
+ };
91
+ }
92
+ function skillBase(agent, scope) {
93
+ if (scope === 'project') {
94
+ if (agent === 'claude')
95
+ return join(process.cwd(), '.claude', 'skills');
96
+ if (agent === 'codex')
97
+ return join(process.cwd(), '.codex', 'skills');
98
+ if (agent === 'cursor')
99
+ return join(process.cwd(), '.agents', 'skills');
100
+ }
101
+ if (agent === 'claude')
102
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'skills');
103
+ if (agent === 'codex')
104
+ return join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'skills');
105
+ if (agent === 'cursor')
106
+ return join(homedir(), '.agents', 'skills');
107
+ throw new Error(`Unsupported agent: ${agent}`);
108
+ }
109
+ function installSkill(opts) {
110
+ const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'cursor'];
111
+ const scope = opts.scope ?? 'project';
112
+ const results = [];
113
+ for (const agent of agents) {
114
+ const target = join(skillBase(agent, scope), 'run-cloud-ios-simulator');
115
+ if (existsSync(target)) {
116
+ if (!opts.force)
117
+ throw new Error(`${target} already exists. Pass --force to overwrite.`);
118
+ rmSync(target, { recursive: true, force: true });
119
+ }
120
+ mkdirSync(target, { recursive: true });
121
+ writeFileSync(join(target, 'SKILL.md'), RUN_CLOUD_SKILL);
122
+ results.push({ agent, path: target, status: 'installed' });
123
+ }
124
+ return results;
125
+ }
126
+ async function pushAsset(path, opts = {}) {
127
+ const { blob, name } = fileBlob(path);
128
+ const form = new FormData();
129
+ form.set('file', blob, name);
130
+ if (opts.name)
131
+ form.set('name', opts.name);
132
+ return (await client().uploadForm('/run-cloud/assets', form));
133
+ }
134
+ async function action(fn) {
135
+ try {
136
+ await fn();
137
+ }
138
+ catch (err) {
139
+ console.error(friendlyApiError(err));
140
+ process.exitCode = 1;
141
+ }
142
+ }
143
+ export function registerRunCloud(program) {
144
+ program
145
+ .command('account')
146
+ .description('Show run.cloud entitlement and metered balance')
147
+ .option('--json', 'output JSON', false)
148
+ .action((opts) => action(async () => {
149
+ print(await client().get('/run-cloud/account'), opts);
150
+ }));
151
+ const ios = program.command('ios').description('Create, list, inspect, and delete iOS simulator sessions');
152
+ ios
153
+ .command('create')
154
+ .description('Create a remote iOS simulator session')
155
+ .option('--model <iphone|ipad>', 'simulator model', 'iphone')
156
+ .option('--region <region>', 'fleet region')
157
+ .option('--display-name <name>', 'human-readable name')
158
+ .option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
159
+ .option('--install <file>', 'local .ipa, .zip, .tar.gz, or simulator .app archive to upload and install', (v, p) => [...p, v], [])
160
+ .option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
161
+ .option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
162
+ .option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
163
+ .option('--rm', 'release the session when this command exits', false)
164
+ .option('--json', 'output JSON', false)
165
+ .action((opts) => action(async () => {
166
+ const uploadedAssets = [];
167
+ for (const file of opts.install ?? [])
168
+ uploadedAssets.push(await pushAsset(file));
169
+ const installAssets = [
170
+ ...(opts.installAsset ?? []),
171
+ ...uploadedAssets
172
+ .map((asset) => asset.id)
173
+ .filter((id) => typeof id === 'string' && id.length > 0),
174
+ ];
175
+ const session = await client().post('/run-cloud/ios', {
176
+ model: opts.model,
177
+ region: opts.region,
178
+ displayName: opts.displayName,
179
+ labels: parseLabels(opts.label),
180
+ installAssets,
181
+ inactivityTimeout: opts.inactivityTimeout,
182
+ hardTimeout: opts.hardTimeout,
183
+ autoDelete: opts.rm,
184
+ });
185
+ print(session, opts);
186
+ }));
187
+ ios
188
+ .command('list')
189
+ .description('List active iOS simulator sessions')
190
+ .option('--all', 'include released sessions', false)
191
+ .option('--json', 'output JSON', false)
192
+ .action((opts) => action(async () => {
193
+ const qs = opts.all ? '?all=1' : '';
194
+ print(await client().get(`/run-cloud/ios${qs}`), opts);
195
+ }));
196
+ ios
197
+ .command('get')
198
+ .description('Show an iOS simulator session')
199
+ .argument('<id>')
200
+ .option('--json', 'output JSON', false)
201
+ .action((id, opts) => action(async () => {
202
+ print(await client().get(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
203
+ }));
204
+ ios
205
+ .command('delete')
206
+ .description('Release an iOS simulator session')
207
+ .argument('<id>')
208
+ .option('--json', 'output JSON', false)
209
+ .action((id, opts) => action(async () => {
210
+ print(await client().delete(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
211
+ }));
212
+ ios
213
+ .command('open-url')
214
+ .description('Open a URL or deep link in an iOS simulator session')
215
+ .argument('<url>')
216
+ .requiredOption('--id <id>', 'session id')
217
+ .option('--json', 'output JSON', false)
218
+ .action((url, opts) => action(async () => {
219
+ print(await client().post(`/run-cloud/ios/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
220
+ }));
221
+ const asset = program
222
+ .command('asset')
223
+ .alias('assets')
224
+ .description('Upload, list, inspect, and delete run.cloud assets');
225
+ asset
226
+ .command('push')
227
+ .description('Upload an app asset for simulator installation')
228
+ .argument('<file>')
229
+ .option('-n, --name <name>', 'asset name')
230
+ .option('--json', 'output JSON', false)
231
+ .action((file, opts) => action(async () => {
232
+ print(await pushAsset(file, opts), opts);
233
+ }));
234
+ asset
235
+ .command('list')
236
+ .description('List uploaded assets')
237
+ .option('--json', 'output JSON', false)
238
+ .action((opts) => action(async () => {
239
+ print(await client().get('/run-cloud/assets'), opts);
240
+ }));
241
+ asset
242
+ .command('pull')
243
+ .description('Download an asset')
244
+ .argument('<id>')
245
+ .option('-o, --output <path>', 'output path')
246
+ .action((id, opts) => action(async () => {
247
+ const body = await client().getBinary(`/run-cloud/assets/${encodeURIComponent(id)}/download`);
248
+ const output = opts.output ?? id;
249
+ writeFileSync(output, body);
250
+ console.log(output);
251
+ }));
252
+ asset
253
+ .command('delete')
254
+ .description('Delete an uploaded asset')
255
+ .argument('<id>')
256
+ .option('--json', 'output JSON', false)
257
+ .action((id, opts) => action(async () => {
258
+ print(await client().delete(`/run-cloud/assets/${encodeURIComponent(id)}`), opts);
259
+ }));
260
+ const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
261
+ skills
262
+ .command('install')
263
+ .description('Install the run.cloud iOS simulator skill')
264
+ .option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
265
+ .option('--scope <project|global>', 'install scope', 'project')
266
+ .option('--force', 'overwrite existing skill', false)
267
+ .option('--json', 'output JSON', false)
268
+ .action((opts) => action(async () => {
269
+ const scope = opts.scope === 'global' ? 'global' : 'project';
270
+ print(installSkill({ ...opts, scope }), opts);
271
+ }));
272
+ }
package/dist/config.js CHANGED
@@ -1,23 +1,49 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- function newlyHome() {
3
+ import { basename, join } from 'node:path';
4
+ function credentialsHome() {
5
+ if (process.env.RUN_CLOUD_HOME)
6
+ return process.env.RUN_CLOUD_HOME;
7
+ if (process.env.NEWLY_HOME)
8
+ return process.env.NEWLY_HOME;
9
+ const invoked = basename(process.argv[1] ?? '');
10
+ if (invoked === 'runcloud' || invoked === 'run-cloud.js')
11
+ return join(homedir(), '.run-cloud');
5
12
  return process.env.NEWLY_HOME ?? join(homedir(), '.newly');
6
13
  }
7
14
  function credentialsPath() {
8
- return join(newlyHome(), 'credentials');
15
+ return join(credentialsHome(), 'credentials');
16
+ }
17
+ export function credentialsDisplayPath() {
18
+ const path = credentialsPath();
19
+ const home = homedir();
20
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
21
+ }
22
+ function loginCommandName() {
23
+ const invoked = basename(process.argv[1] ?? '');
24
+ return invoked === 'runcloud' || invoked === 'run-cloud.js' ? 'runcloud login' : 'newly login';
9
25
  }
10
26
  export function normalizeApiUrl(url) {
11
27
  const trimmed = url.replace(/\/+$/, '');
12
28
  return trimmed.endsWith('/api') ? trimmed : `${trimmed}/api`;
13
29
  }
14
30
  export function saveCredentials(creds) {
15
- mkdirSync(newlyHome(), { recursive: true, mode: 0o700 });
31
+ mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
16
32
  const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
17
33
  writeFileSync(credentialsPath(), JSON.stringify(normalized, null, 2) + '\n', { mode: 0o600 });
18
34
  chmodSync(credentialsPath(), 0o600);
19
35
  }
20
36
  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;
39
+ if (envToken?.trim() && envApiUrl?.trim()) {
40
+ return {
41
+ token: envToken.trim(),
42
+ apiUrl: normalizeApiUrl(envApiUrl.trim()),
43
+ appUrl: process.env.NEWLY_APP_URL ?? 'https://newly.dev',
44
+ expiresAt: Number.MAX_SAFE_INTEGER,
45
+ };
46
+ }
21
47
  if (!existsSync(credentialsPath()))
22
48
  return null;
23
49
  try {
@@ -36,7 +62,7 @@ export function clearCredentials() {
36
62
  export function requireCredentials() {
37
63
  const creds = loadCredentials();
38
64
  if (!creds) {
39
- throw new Error('Not logged in (or session expired). Run: newly login');
65
+ throw new Error(`Not logged in (or session expired). Run: ${loginCommandName()}`);
40
66
  }
41
67
  return creds;
42
68
  }
@@ -0,0 +1,159 @@
1
+ const DEFAULT_URLS = {
2
+ dev: {
3
+ apiOps: 'https://cp-api-ops-dev.newly.app',
4
+ worker: 'https://cp-worker-dev.newly.app',
5
+ },
6
+ prod: {
7
+ apiOps: 'https://cp-api-ops-prod.newly.app',
8
+ worker: 'https://cp-worker-prod.newly.app',
9
+ },
10
+ };
11
+ function stripSlash(url) {
12
+ return url.replace(/\/+$/, '');
13
+ }
14
+ export function resolveTargets(env) {
15
+ if (env !== 'dev' && env !== 'prod') {
16
+ throw new Error(`--env must be dev or prod (got "${env}")`);
17
+ }
18
+ const upper = env.toUpperCase();
19
+ const apiOps = process.env[`DIAGNOSE_CP_API_OPS_URL_${upper}`] ??
20
+ process.env.DIAGNOSE_CP_API_OPS_URL ??
21
+ DEFAULT_URLS[env].apiOps;
22
+ const worker = process.env[`DIAGNOSE_CP_WORKER_URL_${upper}`] ??
23
+ process.env.DIAGNOSE_CP_WORKER_URL ??
24
+ DEFAULT_URLS[env].worker;
25
+ return [
26
+ { service: 'cp-api', url: stripSlash(apiOps) },
27
+ { service: 'cp-worker', url: stripSlash(worker) },
28
+ ];
29
+ }
30
+ function extractAuthorization(headers) {
31
+ if (headers instanceof Headers) {
32
+ return headers.get('authorization') ?? undefined;
33
+ }
34
+ const h = headers;
35
+ return h?.Authorization ?? h?.authorization;
36
+ }
37
+ export async function resolveOpsAuthHeader(env = process.env) {
38
+ const audience = env.DIAGNOSE_OIDC_AUDIENCE ?? 'newly-diagnose-ops';
39
+ try {
40
+ const { GoogleAuth } = await import('google-auth-library');
41
+ const auth = new GoogleAuth();
42
+ const client = await auth.getIdTokenClient(audience);
43
+ const authz = extractAuthorization(await client.getRequestHeaders());
44
+ if (authz) {
45
+ return authz;
46
+ }
47
+ }
48
+ catch {
49
+ }
50
+ const token = env.DIAGNOSE_OPS_TOKEN;
51
+ if (token) {
52
+ return `Bearer ${token}`;
53
+ }
54
+ throw new Error('No ops credential available. Run `gcloud auth application-default login` (ADC/OIDC), ' +
55
+ 'or export DIAGNOSE_OPS_TOKEN with the break-glass ops token.');
56
+ }
57
+ function truncate(value, max = 200) {
58
+ const trimmed = value.trim();
59
+ return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
60
+ }
61
+ export async function fetchDiagnose(target, authHeader) {
62
+ try {
63
+ const res = await fetch(`${target.url}/diagnose`, { headers: { Authorization: authHeader } });
64
+ if (!res.ok) {
65
+ const body = await res.text().catch(() => '');
66
+ return {
67
+ service: target.service,
68
+ error: `HTTP ${res.status}${body ? `: ${truncate(body)}` : ''}`,
69
+ };
70
+ }
71
+ return (await res.json());
72
+ }
73
+ catch (e) {
74
+ return { service: target.service, error: e instanceof Error ? e.message : String(e) };
75
+ }
76
+ }
77
+ function isDoc(result) {
78
+ return !('error' in result);
79
+ }
80
+ export function rollupVerdict(services) {
81
+ const names = Object.keys(services);
82
+ const down = names.filter((name) => !isDoc(services[name]));
83
+ if (down.length) {
84
+ return `DOWN - unreachable: ${down.join(', ')}`;
85
+ }
86
+ const degraded = [];
87
+ for (const name of names) {
88
+ const doc = services[name];
89
+ const bad = Object.entries(doc.health)
90
+ .filter(([, probe]) => !probe.ok)
91
+ .map(([probeName]) => probeName);
92
+ if (bad.length) {
93
+ degraded.push(`${name} (${bad.join(', ')})`);
94
+ }
95
+ }
96
+ if (degraded.length) {
97
+ return `DEGRADED - ${degraded.join('; ')}`;
98
+ }
99
+ return `OK - ${names.length} service(s) healthy`;
100
+ }
101
+ export async function fanoutDiagnose(env) {
102
+ const targets = resolveTargets(env);
103
+ const authHeader = await resolveOpsAuthHeader();
104
+ const results = await Promise.all(targets.map((target) => fetchDiagnose(target, authHeader)));
105
+ const services = {};
106
+ results.forEach((result, index) => {
107
+ services[targets[index].service] = result;
108
+ });
109
+ return { rollup: rollupVerdict(services), services };
110
+ }
111
+ export function renderRollup(result, session) {
112
+ const lines = [result.rollup];
113
+ if (session) {
114
+ lines.push(`session: ${session}`);
115
+ }
116
+ lines.push('');
117
+ for (const [name, service] of Object.entries(result.services)) {
118
+ if (!isDoc(service)) {
119
+ lines.push(`${name}: UNREACHABLE - ${service.error}`);
120
+ continue;
121
+ }
122
+ const sha = service.deploy.git_sha ?? '(sha unknown)';
123
+ const revision = service.deploy.revision ?? '(revision unknown)';
124
+ lines.push(`${name}: ${sha} @ ${revision} [env=${service.env}]`);
125
+ const badProbes = Object.entries(service.health)
126
+ .filter(([, probe]) => !probe.ok)
127
+ .map(([probeName, probe]) => `${probeName}${probe.detail ? ` (${probe.detail})` : ''}`);
128
+ lines.push(` health: ${badProbes.length ? `DEGRADED - ${badProbes.join(', ')}` : 'all ok'}`);
129
+ for (const verdict of service.diagnosis) {
130
+ lines.push(` - ${verdict}`);
131
+ }
132
+ }
133
+ return lines.join('\n');
134
+ }
135
+ export function registerDiagnose(program) {
136
+ program
137
+ .command('diagnose')
138
+ .description('Fan out /diagnose across control-plane services and roll up a verdict (dev/ops only)')
139
+ .option('--env <env>', 'dev or prod', 'dev')
140
+ .option('--session <id>', 'annotate the rollup with a session id you are investigating')
141
+ .option('--json', 'emit the merged JSON document instead of the human rollup')
142
+ .action(async (opts) => {
143
+ try {
144
+ if (opts.env !== 'dev' && opts.env !== 'prod') {
145
+ throw new Error('--env must be dev or prod');
146
+ }
147
+ const result = await fanoutDiagnose(opts.env);
148
+ if (opts.json) {
149
+ console.log(JSON.stringify({ session: opts.session ?? null, ...result }, null, 2));
150
+ return;
151
+ }
152
+ console.log(renderRollup(result, opts.session));
153
+ }
154
+ catch (e) {
155
+ console.error(e instanceof Error ? e.message : String(e));
156
+ process.exitCode = 1;
157
+ }
158
+ });
159
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { registerEmail } from './commands/email.js';
15
15
  import { registerWebsite } from './commands/website.js';
16
16
  import { registerDomains } from './commands/domains.js';
17
17
  import { registerDns } from './commands/dns.js';
18
+ import { registerDiagnose } from './diagnose.js';
18
19
  const program = new Command('newly')
19
20
  .description('Develop, provision, and deploy Newly backends')
20
21
  .version('0.1.0');
@@ -33,4 +34,5 @@ registerEmail(program);
33
34
  registerWebsite(program);
34
35
  registerDomains(program);
35
36
  registerDns(program);
37
+ registerDiagnose(program);
36
38
  await program.parseAsync(process.argv);
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { registerLogin } from './commands/login.js';
4
+ import { registerRunCloud } from './commands/run-cloud.js';
5
+ const program = new Command('runcloud')
6
+ .description('Create and control remote iOS simulator sessions')
7
+ .version('0.1.0');
8
+ registerLogin(program);
9
+ registerRunCloud(program);
10
+ await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,15 +1,24 @@
1
1
  {
2
2
  "name": "@newly/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Develop, provision, and deploy Newly backends from your terminal",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
- "bin": { "newly": "dist/index.js", "newly-dev": "dist/newly-dev.js" },
8
- "files": ["dist"],
9
- "engines": { "node": ">=20" },
7
+ "bin": {
8
+ "newly": "dist/index.js",
9
+ "newly-dev": "dist/newly-dev.js",
10
+ "runcloud": "dist/run-cloud.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
10
18
  "scripts": {
11
19
  "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
12
20
  "build": "tsc",
21
+ "build:run-cloud": "tsc && node dist/run-cloud.js --help >/dev/null",
13
22
  "dev": "tsx src/index.ts",
14
23
  "lint": "eslint src",
15
24
  "test": "vitest run",
@@ -17,6 +26,7 @@
17
26
  },
18
27
  "dependencies": {
19
28
  "commander": "^12.1.0",
29
+ "google-auth-library": "^9.15.0",
20
30
  "tar": "^7.4.3"
21
31
  },
22
32
  "devDependencies": {