runcloud 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.
- package/dist/api.js +139 -0
- package/dist/archive.js +113 -0
- package/dist/commands/build.js +250 -0
- package/dist/commands/create.js +60 -0
- package/dist/commands/creds.js +103 -0
- package/dist/commands/deploy.js +155 -0
- package/dist/commands/dns.js +185 -0
- package/dist/commands/domains.js +201 -0
- package/dist/commands/email.js +86 -0
- package/dist/commands/link.js +17 -0
- package/dist/commands/login.js +173 -0
- package/dist/commands/misc.js +175 -0
- package/dist/commands/pull.js +53 -0
- package/dist/commands/run-cloud.js +272 -0
- package/dist/commands/services.js +39 -0
- package/dist/commands/stack.js +31 -0
- package/dist/commands/status.js +110 -0
- package/dist/commands/website.js +218 -0
- package/dist/config.js +95 -0
- package/dist/diagnose.js +159 -0
- package/dist/index.js +38 -0
- package/dist/newly-dev.js +10 -0
- package/dist/run-cloud.js +10 -0
- package/dist/services-manifest.js +63 -0
- package/dist/sse.js +19 -0
- package/package.json +18 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
detail;
|
|
4
|
+
constructor(status, detail) {
|
|
5
|
+
super(`API ${status}: ${detail}`);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.detail = detail;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class ApiClient {
|
|
11
|
+
baseUrl;
|
|
12
|
+
token;
|
|
13
|
+
constructor(baseUrl, token) {
|
|
14
|
+
this.baseUrl = baseUrl;
|
|
15
|
+
this.token = token;
|
|
16
|
+
}
|
|
17
|
+
async request(method, path, body) {
|
|
18
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${this.token}`,
|
|
22
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
23
|
+
},
|
|
24
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
let detail = await res.text();
|
|
28
|
+
try {
|
|
29
|
+
detail = JSON.parse(detail).detail ?? detail;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
}
|
|
33
|
+
throw new ApiError(res.status, detail);
|
|
34
|
+
}
|
|
35
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
36
|
+
return contentType.includes('application/json') ? res.json() : res;
|
|
37
|
+
}
|
|
38
|
+
get(path) {
|
|
39
|
+
return this.request('GET', path);
|
|
40
|
+
}
|
|
41
|
+
post(path, body) {
|
|
42
|
+
return this.request('POST', path, body);
|
|
43
|
+
}
|
|
44
|
+
patch(path, body) {
|
|
45
|
+
return this.request('PATCH', path, body);
|
|
46
|
+
}
|
|
47
|
+
delete(path, body) {
|
|
48
|
+
return this.request('DELETE', path, body);
|
|
49
|
+
}
|
|
50
|
+
async upload(path, body, contentType) {
|
|
51
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${this.token}`,
|
|
55
|
+
'Content-Type': contentType,
|
|
56
|
+
},
|
|
57
|
+
body: body,
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
let detail = await res.text();
|
|
61
|
+
try {
|
|
62
|
+
detail = JSON.parse(detail).detail ?? detail;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
}
|
|
66
|
+
throw new ApiError(res.status, detail);
|
|
67
|
+
}
|
|
68
|
+
const contentType_ = res.headers.get('content-type') ?? '';
|
|
69
|
+
return contentType_.includes('application/json') ? res.json() : res;
|
|
70
|
+
}
|
|
71
|
+
async uploadForm(path, form) {
|
|
72
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${this.token}`,
|
|
76
|
+
},
|
|
77
|
+
body: form,
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
let detail = await res.text();
|
|
81
|
+
try {
|
|
82
|
+
detail = JSON.parse(detail).detail ?? detail;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
}
|
|
86
|
+
throw new ApiError(res.status, detail);
|
|
87
|
+
}
|
|
88
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
89
|
+
return contentType.includes('application/json') ? res.json() : res;
|
|
90
|
+
}
|
|
91
|
+
async getBinary(path) {
|
|
92
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const detail = await res.text().catch(() => '');
|
|
98
|
+
throw new ApiError(res.status, detail || res.statusText);
|
|
99
|
+
}
|
|
100
|
+
return Buffer.from(await res.arrayBuffer());
|
|
101
|
+
}
|
|
102
|
+
async *stream(path) {
|
|
103
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
let detail = await res.text().catch(() => '');
|
|
109
|
+
try {
|
|
110
|
+
detail = JSON.parse(detail).detail ?? detail;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
}
|
|
114
|
+
throw new ApiError(res.status, detail || res.statusText);
|
|
115
|
+
}
|
|
116
|
+
const decoder = new TextDecoder();
|
|
117
|
+
for await (const chunk of res.body) {
|
|
118
|
+
yield decoder.decode(chunk, { stream: true });
|
|
119
|
+
}
|
|
120
|
+
const tail = decoder.decode();
|
|
121
|
+
if (tail)
|
|
122
|
+
yield tail;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function friendlyApiError(err) {
|
|
126
|
+
if (!(err instanceof ApiError))
|
|
127
|
+
return String(err);
|
|
128
|
+
if (err.status === 401)
|
|
129
|
+
return 'Session expired or invalid — run `newly login`.';
|
|
130
|
+
if (err.status === 403)
|
|
131
|
+
return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
|
|
132
|
+
if (err.status === 404)
|
|
133
|
+
return `Not found: ${err.detail}`;
|
|
134
|
+
if (err.status === 409)
|
|
135
|
+
return err.detail;
|
|
136
|
+
if (err.status === 503)
|
|
137
|
+
return 'The platform is at capacity right now — please try again in a few minutes.';
|
|
138
|
+
return err.message;
|
|
139
|
+
}
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { create as tarCreate, extract as tarExtract, list as tarList } from 'tar';
|
|
4
|
+
import { Buffer } from 'node:buffer';
|
|
5
|
+
export const SNAPSHOT_EXCLUDE = new Set([
|
|
6
|
+
'.git',
|
|
7
|
+
'node_modules',
|
|
8
|
+
'.venv',
|
|
9
|
+
'.expo',
|
|
10
|
+
'.next',
|
|
11
|
+
'.turbo',
|
|
12
|
+
'.vercel',
|
|
13
|
+
'.cache',
|
|
14
|
+
'dist',
|
|
15
|
+
'build',
|
|
16
|
+
'web-build',
|
|
17
|
+
'.natively',
|
|
18
|
+
'android',
|
|
19
|
+
'ios',
|
|
20
|
+
'.local',
|
|
21
|
+
'.npm',
|
|
22
|
+
'.pnpm-store',
|
|
23
|
+
'.yarn',
|
|
24
|
+
'.bun',
|
|
25
|
+
'__pycache__',
|
|
26
|
+
'agent_activity',
|
|
27
|
+
'branches',
|
|
28
|
+
'secrets',
|
|
29
|
+
]);
|
|
30
|
+
export const SNAPSHOT_EXCLUDE_FILE_GLOBS = [
|
|
31
|
+
'.env',
|
|
32
|
+
'.env.*',
|
|
33
|
+
'.env*.local',
|
|
34
|
+
'*.pem',
|
|
35
|
+
'*.key',
|
|
36
|
+
'*.p8',
|
|
37
|
+
'*.p12',
|
|
38
|
+
'*.jks',
|
|
39
|
+
'*.mobileprovision',
|
|
40
|
+
'.DS_Store',
|
|
41
|
+
'*.orig.*',
|
|
42
|
+
'*.tsbuildinfo',
|
|
43
|
+
];
|
|
44
|
+
const FILE_GLOB_REGEXPS = SNAPSHOT_EXCLUDE_FILE_GLOBS.map((glob) => new RegExp(`^${glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*')}$`));
|
|
45
|
+
function isExcluded(path) {
|
|
46
|
+
return path
|
|
47
|
+
.replace(/^\.\//, '')
|
|
48
|
+
.split('/')
|
|
49
|
+
.some((part) => SNAPSHOT_EXCLUDE.has(part));
|
|
50
|
+
}
|
|
51
|
+
function isExcludedFile(basename) {
|
|
52
|
+
return FILE_GLOB_REGEXPS.some((re) => re.test(basename));
|
|
53
|
+
}
|
|
54
|
+
function basenameOf(path) {
|
|
55
|
+
const parts = path.split('/');
|
|
56
|
+
return parts[parts.length - 1];
|
|
57
|
+
}
|
|
58
|
+
export async function createSnapshot(dir) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
const stream = tarCreate({
|
|
61
|
+
gzip: true,
|
|
62
|
+
cwd: dir,
|
|
63
|
+
filter: (p) => !isExcluded(p) && !isExcludedFile(basenameOf(p)),
|
|
64
|
+
portable: true,
|
|
65
|
+
}, ['.']);
|
|
66
|
+
for await (const chunk of stream) {
|
|
67
|
+
chunks.push(Buffer.from(chunk));
|
|
68
|
+
}
|
|
69
|
+
return Buffer.concat(chunks);
|
|
70
|
+
}
|
|
71
|
+
export async function extractSnapshot(data, dir) {
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
const stream = tarExtract({ cwd: dir });
|
|
75
|
+
stream.on('error', reject);
|
|
76
|
+
stream.on('finish', resolve);
|
|
77
|
+
stream.end(data);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function snapshotTopLevelEntries(data) {
|
|
81
|
+
const top = new Set();
|
|
82
|
+
for (const name of await listSnapshot(data)) {
|
|
83
|
+
const rel = name.replace(/^\.?\//, '');
|
|
84
|
+
const first = rel.split('/')[0];
|
|
85
|
+
if (first && first !== '.')
|
|
86
|
+
top.add(first);
|
|
87
|
+
}
|
|
88
|
+
return top;
|
|
89
|
+
}
|
|
90
|
+
export function pruneStaleTopLevel(dir, keep) {
|
|
91
|
+
const removed = [];
|
|
92
|
+
for (const entry of readdirSync(dir)) {
|
|
93
|
+
if (entry === '.newly')
|
|
94
|
+
continue;
|
|
95
|
+
if (keep.has(entry))
|
|
96
|
+
continue;
|
|
97
|
+
if (isExcluded(entry) || isExcludedFile(entry))
|
|
98
|
+
continue;
|
|
99
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
100
|
+
removed.push(entry);
|
|
101
|
+
}
|
|
102
|
+
return removed;
|
|
103
|
+
}
|
|
104
|
+
export async function listSnapshot(data) {
|
|
105
|
+
const names = [];
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
const stream = tarList({ onentry: (e) => names.push(e.path) });
|
|
108
|
+
stream.on('error', reject);
|
|
109
|
+
stream.on('finish', resolve);
|
|
110
|
+
stream.end(data);
|
|
111
|
+
});
|
|
112
|
+
return names;
|
|
113
|
+
}
|
|
@@ -0,0 +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';
|
|
6
|
+
export function buildPlaceholderMessage(platform) {
|
|
7
|
+
return (`In-house ${platform} builds are coming in an upcoming release.\n` +
|
|
8
|
+
'Set up iOS signing first with `newly creds apple` (check readiness with `newly creds apple status`).');
|
|
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
|
+
}
|
|
218
|
+
export function registerBuild(program) {
|
|
219
|
+
program
|
|
220
|
+
.command('build [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
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
4
|
+
import { requireCredentials, writeProjectLink } from '../config.js';
|
|
5
|
+
import { extractSnapshot } from '../archive.js';
|
|
6
|
+
export async function runCreate(client, dir, opts) {
|
|
7
|
+
if (existsSync(join(dir, 'backend'))) {
|
|
8
|
+
throw new Error('A `backend/` directory already exists here. Run `newly create` in a directory without one.');
|
|
9
|
+
}
|
|
10
|
+
const { project_id, status } = await client.post('/newly_backend/create_project', {
|
|
11
|
+
name: opts.name,
|
|
12
|
+
provision: opts.provision ?? true,
|
|
13
|
+
});
|
|
14
|
+
writeProjectLink(dir, {
|
|
15
|
+
projectId: project_id,
|
|
16
|
+
pulledSha: null,
|
|
17
|
+
pulledAt: new Date().toISOString(),
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const tar = await client.getBinary('/newly_backend/backend_template');
|
|
21
|
+
await extractSnapshot(tar, dir);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
25
|
+
throw new Error(`Project ${project_id} was created and linked, but scaffolding the backend template failed: ${msg}. The project exists server-side.`);
|
|
26
|
+
}
|
|
27
|
+
if (status === 'provisioning') {
|
|
28
|
+
console.log(`Created backend project ${project_id} and scaffolded backend/ here. ` +
|
|
29
|
+
`Provisioning the dev backend… check with \`newly status\`. Edit, then \`newly deploy\`.`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
|
|
33
|
+
`(not provisioned — run \`newly deploy\` when ready).`);
|
|
34
|
+
}
|
|
35
|
+
return project_id;
|
|
36
|
+
}
|
|
37
|
+
export function registerCreate(program) {
|
|
38
|
+
program
|
|
39
|
+
.command('create')
|
|
40
|
+
.description('Create a new backend-only Newly project here (scaffold + provision)')
|
|
41
|
+
.argument('[name]', 'project name (generated if omitted)')
|
|
42
|
+
.option('--no-provision', 'create without provisioning the backend')
|
|
43
|
+
.action(async (name, opts) => {
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(join(process.cwd(), '.newly', 'project.json'))) {
|
|
46
|
+
throw new Error('This directory is already linked to a project. Use a fresh directory for `newly create`.');
|
|
47
|
+
}
|
|
48
|
+
const creds = requireCredentials();
|
|
49
|
+
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
50
|
+
await runCreate(client, process.cwd(), {
|
|
51
|
+
name,
|
|
52
|
+
provision: opts.provision,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
4
|
+
const TEAM_BUNDLE_CMD = 'newly creds apple --team-id <TEAM_ID> --bundle-id <BUNDLE_ID>';
|
|
5
|
+
const ASC_KEY_CMD = 'newly creds apple --asc-key <key.p8> --key-id <KEY_ID> --issuer-id <ISSUER_ID>';
|
|
6
|
+
export function buildReadiness(s) {
|
|
7
|
+
const missing = [];
|
|
8
|
+
const remediations = [];
|
|
9
|
+
if (!s.team_id)
|
|
10
|
+
missing.push('team id');
|
|
11
|
+
if (!s.bundle_id)
|
|
12
|
+
missing.push('bundle id');
|
|
13
|
+
if (!s.team_id || !s.bundle_id)
|
|
14
|
+
remediations.push(TEAM_BUNDLE_CMD);
|
|
15
|
+
if (!s.asc_api_key?.has_private_key) {
|
|
16
|
+
missing.push('ASC API key');
|
|
17
|
+
remediations.push(ASC_KEY_CMD);
|
|
18
|
+
}
|
|
19
|
+
return { ready: missing.length === 0, missing, remediations };
|
|
20
|
+
}
|
|
21
|
+
export function renderAppleStatus(s) {
|
|
22
|
+
const r = buildReadiness(s);
|
|
23
|
+
const lines = [
|
|
24
|
+
`team id: ${s.team_id ?? '— not set'}`,
|
|
25
|
+
`bundle id: ${s.bundle_id ?? '— not set'}`,
|
|
26
|
+
`ASC API key: ${s.asc_api_key?.has_private_key ? 'configured' : '— not set'}`,
|
|
27
|
+
];
|
|
28
|
+
if (r.ready) {
|
|
29
|
+
lines.push('', '✓ iOS build-ready.');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
lines.push('', `Missing: ${r.missing.join(', ')}. To fix:`);
|
|
33
|
+
for (const cmd of r.remediations)
|
|
34
|
+
lines.push(` ${cmd}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
export function planAppleCreds(opts) {
|
|
39
|
+
const setTeamInfo = Boolean(opts.teamId || opts.bundleId);
|
|
40
|
+
if (setTeamInfo && !(opts.teamId && opts.bundleId)) {
|
|
41
|
+
throw new Error('--team-id and --bundle-id must be provided together.');
|
|
42
|
+
}
|
|
43
|
+
const uploadAscKey = Boolean(opts.ascKey);
|
|
44
|
+
if (uploadAscKey && !opts.keyId) {
|
|
45
|
+
throw new Error('--asc-key requires --key-id.');
|
|
46
|
+
}
|
|
47
|
+
if (!setTeamInfo && !uploadAscKey) {
|
|
48
|
+
throw new Error('Nothing to do. Pass --team-id/--bundle-id and/or --asc-key. See `newly creds apple status`.');
|
|
49
|
+
}
|
|
50
|
+
return { setTeamInfo, uploadAscKey };
|
|
51
|
+
}
|
|
52
|
+
export async function runAppleCreds(opts, client, projectId, log = console.log) {
|
|
53
|
+
const plan = planAppleCreds(opts);
|
|
54
|
+
if (plan.setTeamInfo) {
|
|
55
|
+
await client.post(`/newly_backend/apple/team_info/${projectId}`, {
|
|
56
|
+
team_id: opts.teamId,
|
|
57
|
+
bundle_id: opts.bundleId,
|
|
58
|
+
});
|
|
59
|
+
log(`Set team id ${opts.teamId} and bundle id ${opts.bundleId}.`);
|
|
60
|
+
}
|
|
61
|
+
if (plan.uploadAscKey) {
|
|
62
|
+
const privateKeyBase64 = readFileSync(opts.ascKey).toString('base64');
|
|
63
|
+
await client.post(`/newly_backend/apple/asc_key/${projectId}`, {
|
|
64
|
+
key_id: opts.keyId,
|
|
65
|
+
issuer_id: opts.issuerId ?? '',
|
|
66
|
+
private_key_base64: privateKeyBase64,
|
|
67
|
+
});
|
|
68
|
+
log('Uploaded ASC API key.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function registerCreds(program) {
|
|
72
|
+
const creds = program.command('creds').description('Manage project credentials');
|
|
73
|
+
const apple = creds
|
|
74
|
+
.command('apple')
|
|
75
|
+
.argument('[action]', 'use "status" to show Apple credential readiness')
|
|
76
|
+
.description('Set Apple iOS build credentials (project owner only)')
|
|
77
|
+
.option('--team-id <id>', 'Apple Developer team ID')
|
|
78
|
+
.option('--bundle-id <id>', 'iOS bundle identifier')
|
|
79
|
+
.option('--asc-key <file>', 'path to an App Store Connect API key (.p8)')
|
|
80
|
+
.option('--key-id <id>', 'ASC API key ID (required with --asc-key)')
|
|
81
|
+
.option('--issuer-id <id>', 'ASC API issuer ID')
|
|
82
|
+
.option('--project <id>', 'project id (defaults to the linked directory)');
|
|
83
|
+
apple.action(async (action, opts) => {
|
|
84
|
+
try {
|
|
85
|
+
const creds = requireCredentials();
|
|
86
|
+
const projectId = resolveProject(process.cwd(), opts.project);
|
|
87
|
+
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
88
|
+
if (action === 'status') {
|
|
89
|
+
const status = await client.get(`/newly_backend/apple/status/${projectId}`);
|
|
90
|
+
console.log(renderAppleStatus(status));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action) {
|
|
94
|
+
throw new Error(`Unknown creds apple action "${action}". Use "status" or pass credential options.`);
|
|
95
|
+
}
|
|
96
|
+
await runAppleCreds(opts, client, projectId);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.error(friendlyApiError(e));
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|