@newly/cli 0.1.0 → 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/README.md +69 -10
- package/dist/api.js +26 -0
- package/dist/commands/build.js +250 -0
- package/dist/commands/create.js +1 -1
- package/dist/commands/creds.js +103 -0
- package/dist/commands/deploy.js +55 -16
- package/dist/commands/dns.js +185 -0
- package/dist/commands/domains.js +201 -0
- package/dist/commands/email.js +86 -0
- package/dist/commands/login.js +27 -3
- package/dist/commands/misc.js +38 -11
- package/dist/commands/run-cloud.js +272 -0
- package/dist/commands/services.js +39 -0
- package/dist/commands/stack.js +13 -11
- package/dist/commands/status.js +10 -10
- package/dist/commands/website.js +218 -0
- package/dist/config.js +31 -5
- package/dist/diagnose.js +159 -0
- package/dist/index.js +16 -2
- package/dist/newly-dev.js +10 -0
- package/dist/run-cloud.js +10 -0
- package/dist/services-manifest.js +63 -0
- package/package.json +15 -4
- package/dist/commands/up.js +0 -85
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ newly pull <project-id> # download project code + link this directory
|
|
|
18
18
|
# edit code…
|
|
19
19
|
newly deploy # deploy to dev
|
|
20
20
|
newly deploy --env prod # ship to prod
|
|
21
|
+
newly services list # inspect backend services
|
|
22
|
+
newly email status # inspect Newly Email setup
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
## Auth
|
|
@@ -47,7 +49,7 @@ Most commands target the project linked to the current directory, so you don't p
|
|
|
47
49
|
|
|
48
50
|
Prod actions prompt for confirmation. Pass `--yes` to skip it in scripts.
|
|
49
51
|
|
|
50
|
-
> Only
|
|
52
|
+
> Only projects on the Newly backend stack are CLI-deployable. Run `newly stack` to check. Other projects must be deployed from the Newly web app.
|
|
51
53
|
|
|
52
54
|
## Commands
|
|
53
55
|
|
|
@@ -73,19 +75,20 @@ Show dev and prod status, service URLs, deployed SHA, and last error.
|
|
|
73
75
|
Show which backend stack the project uses and whether it's CLI-deployable.
|
|
74
76
|
- `--project <id>`
|
|
75
77
|
|
|
76
|
-
### `newly up`
|
|
77
|
-
Provision the backend (if not already) and deploy.
|
|
78
|
-
- `--env <dev|prod>` (default `dev`)
|
|
79
|
-
- `--region <region>` — region for dev provisioning (default `us-central1`)
|
|
80
|
-
- `--project <id>`
|
|
81
|
-
- `--yes` — skip the prod confirmation prompt
|
|
82
|
-
|
|
83
78
|
### `newly deploy`
|
|
84
|
-
|
|
79
|
+
Provision the backend if needed, then deploy.
|
|
85
80
|
- `--env <dev|prod>` (default `dev`)
|
|
81
|
+
- `--region <region>` — region for first-time dev provisioning (default `auto`)
|
|
86
82
|
- `--project <id>`
|
|
83
|
+
- `--service <names>` — comma-separated service names from `backend/newly.services.json`
|
|
84
|
+
- `--git-sha <sha>` — prod git revision to deploy (default `HEAD`)
|
|
87
85
|
- `--yes` — skip the prod confirmation prompt
|
|
88
86
|
|
|
87
|
+
### `newly services list`
|
|
88
|
+
List backend services from `backend/newly.services.json`.
|
|
89
|
+
- `--env <dev|prod>` (default `dev`) — used for rendered public URLs
|
|
90
|
+
- `--project <id>` — include expected public URLs for this project
|
|
91
|
+
|
|
89
92
|
### `newly logs`
|
|
90
93
|
Tail backend service logs. **Dev only.**
|
|
91
94
|
- `--service <api|auth>` (default `api`)
|
|
@@ -105,10 +108,66 @@ Set a backend secret. **Dev only.** Redeploy to apply.
|
|
|
105
108
|
- `--project <id>`
|
|
106
109
|
|
|
107
110
|
### `newly db url`
|
|
108
|
-
Print a readonly
|
|
111
|
+
Print a readonly URL for the Newly DB. **Dev only.**
|
|
112
|
+
- `--project <id>`
|
|
113
|
+
|
|
114
|
+
### `newly email status`
|
|
115
|
+
Show Newly Email configuration for the API service. **Dev only.**
|
|
109
116
|
- `--project <id>`
|
|
110
117
|
|
|
118
|
+
### `newly email test`
|
|
119
|
+
Send a test email through the deployed API service. **Dev only.**
|
|
120
|
+
- `--to <email>` — recipient address
|
|
121
|
+
- `--from <email>` — optional sender; defaults to the backend sender setting
|
|
122
|
+
- `--subject <subject>`
|
|
123
|
+
- `--text <text>`
|
|
124
|
+
- `--html <html>`
|
|
125
|
+
- `--project <id>`
|
|
126
|
+
|
|
127
|
+
## Dev mode
|
|
128
|
+
|
|
129
|
+
`newly-dev` is a convenience wrapper that points credentials at `~/.newly-dev` so dev and prod credentials never collide. The prod `newly` binary still defaults to `https://newly.app` and `~/.newly/credentials`, untouched.
|
|
130
|
+
|
|
131
|
+
### Headless login
|
|
132
|
+
|
|
133
|
+
For CI and dev modal-backend deploys where no web app is running to mint a CLI token, skip the browser entirely:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
newly-dev login \
|
|
137
|
+
--api-url https://your-dev-modal.modal.run/api \
|
|
138
|
+
--token <jwt> \
|
|
139
|
+
--expires-in 86400
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`--expires-in` is seconds. The same flags work on the prod `newly` binary.
|
|
143
|
+
|
|
144
|
+
### Env vars
|
|
145
|
+
|
|
146
|
+
| Var | Effect |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `NEWLY_HOME` | Credentials directory. Explicit value always wins. |
|
|
149
|
+
| `NEWLY_DEV_HOME` | Credentials directory used by `newly-dev` (default: `~/.newly-dev`). |
|
|
150
|
+
| `NEWLY_APP_URL` | Default for `--app-url` at module load. |
|
|
151
|
+
| `NEWLY_DEV_APP_URL` | When set, `newly-dev` surfaces this as `NEWLY_APP_URL`. |
|
|
152
|
+
|
|
153
|
+
### Typical dev workflow
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# 1. Deploy a personal dev modal-backend.
|
|
157
|
+
cd modal-backend
|
|
158
|
+
uv run ./deploy.sh --terminate
|
|
159
|
+
|
|
160
|
+
# 2. Grab the deployed URL, then log the dev CLI in headlessly.
|
|
161
|
+
newly-dev login --api-url https://your-dev-modal.modal.run/api --token <jwt>
|
|
162
|
+
|
|
163
|
+
# 3. Use newly-dev the same way as newly.
|
|
164
|
+
newly-dev create my-test
|
|
165
|
+
newly-dev status
|
|
166
|
+
newly-dev deploy
|
|
167
|
+
```
|
|
168
|
+
|
|
111
169
|
## Files
|
|
112
170
|
|
|
113
171
|
- `~/.newly/credentials` — session token (mode `600`). Override the directory with `NEWLY_HOME`.
|
|
172
|
+
- `~/.newly-dev/credentials` — `newly-dev` session token. Override the directory with `NEWLY_DEV_HOME`.
|
|
114
173
|
- `.newly/project.json` — per-directory project link.
|
package/dist/api.js
CHANGED
|
@@ -41,6 +41,12 @@ export class ApiClient {
|
|
|
41
41
|
post(path, body) {
|
|
42
42
|
return this.request('POST', path, body);
|
|
43
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
|
+
}
|
|
44
50
|
async upload(path, body, contentType) {
|
|
45
51
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
46
52
|
method: 'POST',
|
|
@@ -62,6 +68,26 @@ export class ApiClient {
|
|
|
62
68
|
const contentType_ = res.headers.get('content-type') ?? '';
|
|
63
69
|
return contentType_.includes('application/json') ? res.json() : res;
|
|
64
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
|
+
}
|
|
65
91
|
async getBinary(path) {
|
|
66
92
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
67
93
|
method: 'GET',
|
|
@@ -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
|
+
}
|
package/dist/commands/create.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function runCreate(client, dir, opts) {
|
|
|
30
30
|
}
|
|
31
31
|
else {
|
|
32
32
|
console.log(`Created backend project ${project_id} and scaffolded backend/ here ` +
|
|
33
|
-
`(not provisioned — run \`newly
|
|
33
|
+
`(not provisioned — run \`newly deploy\` when ready).`);
|
|
34
34
|
}
|
|
35
35
|
return project_id;
|
|
36
36
|
}
|
|
@@ -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
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
|
4
4
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
5
5
|
import { createSnapshot } from '../archive.js';
|
|
6
6
|
import { parseSseFrames } from '../sse.js';
|
|
7
|
+
import { assertKnownServices, parseServiceFilter, readBackendServices } from '../services-manifest.js';
|
|
7
8
|
const POLL_INTERVAL_MS = 5_000;
|
|
8
9
|
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
9
10
|
export async function pollRun(client, runId, opts = {}) {
|
|
@@ -61,52 +62,90 @@ export function finishOrThrow(run, label) {
|
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
if (run.failure_reason === 'no_backend_code') {
|
|
64
|
-
throw new Error('No backend code to deploy (backend/
|
|
65
|
+
throw new Error('No backend code to deploy (backend/ missing).');
|
|
65
66
|
}
|
|
66
67
|
throw new Error(`${label} deploy failed (${run.failure_reason ?? 'unknown'}):\n${run.output_tail ?? ''}`);
|
|
67
68
|
}
|
|
68
|
-
export async function runDevDeploy(projectId, client, dir) {
|
|
69
|
-
if (!existsSync(join(dir, 'backend'
|
|
70
|
-
throw new Error('No backend/
|
|
69
|
+
export async function runDevDeploy(projectId, client, dir, services = []) {
|
|
70
|
+
if (!existsSync(join(dir, 'backend'))) {
|
|
71
|
+
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
71
72
|
'or cd into a pulled project.');
|
|
72
73
|
}
|
|
73
74
|
console.log('Packaging local backend…');
|
|
74
75
|
const snapshot = await createSnapshot(dir);
|
|
75
76
|
const kib = snapshot.length < 1024 ? '<1' : Math.round(snapshot.length / 1024).toString();
|
|
76
77
|
console.log(`Uploading snapshot (${kib} KiB) and deploying server-side…`);
|
|
77
|
-
const
|
|
78
|
+
const params = new URLSearchParams({ env: 'dev' });
|
|
79
|
+
for (const service of services)
|
|
80
|
+
params.append('services', service);
|
|
81
|
+
const { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?${params}`, snapshot, 'application/gzip');
|
|
78
82
|
const run = await streamRun(client, run_id);
|
|
79
83
|
finishOrThrow(run, 'dev');
|
|
80
84
|
}
|
|
81
|
-
export async function runProdDeploy(projectId, client) {
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
export async function runProdDeploy(projectId, client, gitSha = 'HEAD', services = []) {
|
|
86
|
+
const label = gitSha === 'HEAD' ? 'repo HEAD' : gitSha;
|
|
87
|
+
console.log(`Deploying prod from the canonical project code (${label})…`);
|
|
88
|
+
const { run_id } = await client.post(`/newly_backend/deploy/${projectId}`, {
|
|
89
|
+
env: 'prod',
|
|
90
|
+
git_sha: gitSha,
|
|
91
|
+
services,
|
|
92
|
+
});
|
|
84
93
|
const run = await streamRun(client, run_id);
|
|
85
94
|
finishOrThrow(run, 'prod');
|
|
86
95
|
}
|
|
96
|
+
export async function ensureBackendProvisioned(client, projectId, env, opts = {}) {
|
|
97
|
+
const print = opts.print ?? console.log;
|
|
98
|
+
const status = await client.get(`/newly_backend/status/${projectId}`);
|
|
99
|
+
if (status[env])
|
|
100
|
+
return;
|
|
101
|
+
print(`Provisioning ${env} backend…`);
|
|
102
|
+
if (env === 'dev') {
|
|
103
|
+
await client.post(`/newly_backend/provision_dev/${projectId}`, {
|
|
104
|
+
dev_region: opts.region ?? 'auto',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
await client.post(`/newly_backend/provision_prod/${projectId}`);
|
|
109
|
+
}
|
|
110
|
+
print(`Provisioned ${env} backend.`);
|
|
111
|
+
}
|
|
87
112
|
export function registerDeploy(program) {
|
|
88
113
|
program
|
|
89
114
|
.command('deploy')
|
|
90
|
-
.description('
|
|
91
|
-
.option('--env <env>', 'dev
|
|
115
|
+
.description('Provision if needed, then deploy the backend server-side')
|
|
116
|
+
.option('--env <env>', 'dev or prod', 'dev')
|
|
117
|
+
.option('--region <region>', "Newly server region hint for first-time dev provisioning (defaults to 'auto')", 'auto')
|
|
92
118
|
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
119
|
+
.option('--service <names>', 'comma-separated service names from backend/newly.services.json')
|
|
120
|
+
.option('--git-sha <sha>', 'prod git revision to deploy from canonical project code', 'HEAD')
|
|
93
121
|
.option('--yes', 'skip the prod confirmation prompt', false)
|
|
94
122
|
.action(async (opts) => {
|
|
95
123
|
try {
|
|
96
124
|
if (opts.env !== 'dev' && opts.env !== 'prod')
|
|
97
125
|
throw new Error('--env must be dev or prod');
|
|
98
|
-
if (opts.env === 'prod') {
|
|
99
|
-
throw new Error('Prod deploys are not supported via the CLI yet — use Promote in the Newly web app.');
|
|
100
|
-
}
|
|
101
126
|
const creds = requireCredentials();
|
|
102
127
|
const projectId = resolveProject(process.cwd(), opts.project);
|
|
103
128
|
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
129
|
+
const services = parseServiceFilter(opts.service);
|
|
130
|
+
assertKnownServices(readBackendServices(process.cwd()), services);
|
|
104
131
|
const stackInfo = await client.get(`/newly_backend/stack/${projectId}`);
|
|
105
132
|
if (!stackInfo.cli_deployable) {
|
|
106
|
-
throw new Error('This project
|
|
107
|
-
|
|
133
|
+
throw new Error('This project does not support CLI deploys yet. Use the Newly web app to deploy it.');
|
|
134
|
+
}
|
|
135
|
+
if (opts.env === 'dev' && !existsSync(join(process.cwd(), 'backend'))) {
|
|
136
|
+
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
137
|
+
'or cd into a pulled project.');
|
|
138
|
+
}
|
|
139
|
+
await ensureBackendProvisioned(client, projectId, opts.env, {
|
|
140
|
+
region: opts.region,
|
|
141
|
+
print: console.log,
|
|
142
|
+
});
|
|
143
|
+
if (opts.env === 'prod') {
|
|
144
|
+
await runProdDeploy(projectId, client, opts.gitSha, services);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await runDevDeploy(projectId, client, process.cwd(), services);
|
|
108
148
|
}
|
|
109
|
-
await runDevDeploy(projectId, client, process.cwd());
|
|
110
149
|
}
|
|
111
150
|
catch (e) {
|
|
112
151
|
console.error(e instanceof ApiError ? friendlyApiError(e) : String(e instanceof Error ? e.message : e));
|