@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 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 **newly-backend-stack** projects (Python api-service + Node auth-service) are CLI-deployable. Run `newly stack` to check. Specular-stack projects must be deployed from the Newly web app.
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
- Deploy the backend.
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 Postgres URL for the backend database. **Dev only.**
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
+ }
@@ -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 up\` when ready). Edit, then \`newly deploy\`.`);
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
+ }
@@ -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/api-service missing).');
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', 'api-service'))) {
70
- throw new Error('No backend/api-service in this directory. Run `newly pull <project-id>` first, ' +
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 { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?env=dev`, snapshot, 'application/gzip');
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
- console.log('Deploying prod from the canonical project code (repo HEAD)…');
83
- const { run_id } = await client.post(`/newly_backend/deploy/${projectId}`, { env: 'prod' });
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('Deploy the backend server-side from your local code (dev only; prod ships via Promote in the web app)')
91
- .option('--env <env>', 'dev (prod is not supported via the CLI yet)', '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 is on the Specular stack (a single Node/drizzle backend), which `newly deploy` does not support. ' +
107
- 'Only newly-backend-stack projects (Python api-service + Node auth-service) are CLI-deployable.');
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));