@newly/cli 0.1.132 → 0.2.138
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 +27 -42
- package/dist/commands/deploy.js +40 -6
- package/dist/commands/email.js +10 -83
- package/dist/commands/environment.js +3 -3
- package/dist/commands/login.js +6 -11
- package/dist/commands/token.js +4 -9
- package/dist/commands/website.js +57 -9
- package/dist/config.js +2 -5
- package/dist/deploy-bundle.js +148 -0
- package/dist/runCloudAuth.js +0 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,6 @@ newly env use checkout-fix # select it for this working directory
|
|
|
33
33
|
newly deploy # deploy all declared services to the selected environment
|
|
34
34
|
newly deploy api # deploy only the api service
|
|
35
35
|
newly services list # inspect backend services
|
|
36
|
-
newly email status # inspect Newly Email setup
|
|
37
36
|
```
|
|
38
37
|
|
|
39
38
|
## Auth
|
|
@@ -46,8 +45,8 @@ newly whoami # show the stored credential: account, API, expiry, file
|
|
|
46
45
|
newly logout # revoke the credential and delete it locally
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
Sign-in goes through Better Auth on the Newly control plane (`https://api.newly.app`)
|
|
50
|
-
`--email` finishes entirely in the terminal, so it works over SSH and in
|
|
48
|
+
Sign-in goes through Better Auth on the Newly control plane (`https://api.newly.app`),
|
|
49
|
+
and every command goes to that same host. `--email` finishes entirely in the terminal, so it works over SSH and in
|
|
51
50
|
containers. Google and GitHub use the device flow: the CLI prints a short code,
|
|
52
51
|
you confirm it at `newly.app/cli-auth`, and the credential is minted server-side —
|
|
53
52
|
it never passes through the browser and nothing is posted to localhost.
|
|
@@ -65,16 +64,14 @@ non-zero when there is none or it has expired:
|
|
|
65
64
|
|
|
66
65
|
```
|
|
67
66
|
Account you@example.com
|
|
68
|
-
API https://
|
|
69
|
-
Issued by https://api.newly.app
|
|
67
|
+
API https://api.newly.app
|
|
70
68
|
Expires never
|
|
71
69
|
Credential ~/.newly/credentials
|
|
72
70
|
```
|
|
73
71
|
|
|
74
|
-
`API` is where commands go
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
issuer.
|
|
72
|
+
`API` is where commands go and where the credential came from. A credential
|
|
73
|
+
file written by an older CLI names the legacy backend as its API and the control
|
|
74
|
+
plane as `authApiUrl`; it is read as the control plane, so no re-login is needed.
|
|
78
75
|
|
|
79
76
|
It reports whatever would actually be sent, including a credential supplied
|
|
80
77
|
through `NEWLY_API_TOKEN`, which takes precedence over the saved file. The
|
|
@@ -89,15 +86,13 @@ Most commands target the project linked to the current directory, so you don't p
|
|
|
89
86
|
- `newly link <project-id>` links a directory without downloading anything.
|
|
90
87
|
- Pass `--project <id>` to any command to override the link.
|
|
91
88
|
|
|
92
|
-
##
|
|
89
|
+
## Deploys
|
|
93
90
|
|
|
94
|
-
`newly deploy`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
Service names are positional. With no names, every service in
|
|
100
|
-
`backend/newly.services.json` is deployed.
|
|
91
|
+
`newly deploy --env dev|prod` (default `dev`) deploys the local `backend/` code.
|
|
92
|
+
`newly env use` does not change which environment it targets. A deploy is an
|
|
93
|
+
immutable attempt; a failed one leaves the previous active deployment and route
|
|
94
|
+
untouched. With no `--service`, every service in `backend/newly.services.json`
|
|
95
|
+
is deployed.
|
|
101
96
|
|
|
102
97
|
## Commands
|
|
103
98
|
|
|
@@ -132,9 +127,11 @@ Show which backend stack the project uses and whether it's CLI-deployable.
|
|
|
132
127
|
- `--project <id>`
|
|
133
128
|
|
|
134
129
|
### `newly deploy`
|
|
135
|
-
Deploy local backend services to the
|
|
130
|
+
Deploy local backend services to dev or prod, provisioning the environment first if needed.
|
|
131
|
+
- `--env <env>` — `dev` (default) or `prod`
|
|
132
|
+
- `--service <names>` — comma-separated service names from `backend/newly.services.json`
|
|
136
133
|
- `--project <id>`
|
|
137
|
-
- `
|
|
134
|
+
- `--yes` — skip the prod confirmation prompt
|
|
138
135
|
|
|
139
136
|
### `newly services list`
|
|
140
137
|
List backend services from `backend/newly.services.json`.
|
|
@@ -163,18 +160,9 @@ Set a backend secret. **Dev only.** Redeploy to apply.
|
|
|
163
160
|
Print a readonly URL for the Newly DB. **Dev only.**
|
|
164
161
|
- `--project <id>`
|
|
165
162
|
|
|
166
|
-
### `newly email
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
### `newly email test`
|
|
171
|
-
Send a test email through the deployed API service. **Dev only.**
|
|
172
|
-
- `--to <email>` — recipient address
|
|
173
|
-
- `--from <email>` — optional sender; defaults to the backend sender setting
|
|
174
|
-
- `--subject <subject>`
|
|
175
|
-
- `--text <text>`
|
|
176
|
-
- `--html <html>`
|
|
177
|
-
- `--project <id>`
|
|
163
|
+
### `newly email`
|
|
164
|
+
Retired. It never had a server to talk to; send mail from your API service with
|
|
165
|
+
your own provider. Running it prints that and exits non-zero.
|
|
178
166
|
|
|
179
167
|
## Dev mode
|
|
180
168
|
|
|
@@ -182,12 +170,13 @@ Send a test email through the deployed API service. **Dev only.**
|
|
|
182
170
|
|
|
183
171
|
### Headless login
|
|
184
172
|
|
|
185
|
-
For CI
|
|
173
|
+
For CI, or a dev control plane where no browser is at hand, skip it entirely
|
|
174
|
+
(`newly token create` mints a portable credential):
|
|
186
175
|
|
|
187
176
|
```bash
|
|
188
177
|
newly-dev login \
|
|
189
|
-
--api-url https://
|
|
190
|
-
--token <
|
|
178
|
+
--api-url https://cp-api-dev-97683904813.us-east4.run.app \
|
|
179
|
+
--token <nl_live_…> \
|
|
191
180
|
--expires-in 86400
|
|
192
181
|
```
|
|
193
182
|
|
|
@@ -201,19 +190,15 @@ newly-dev login \
|
|
|
201
190
|
| `NEWLY_DEV_HOME` | Credentials directory used by `newly-dev` (default: `~/.newly-dev`). |
|
|
202
191
|
| `NEWLY_APP_URL` | Default for `--app-url` at module load. |
|
|
203
192
|
| `NEWLY_DEV_APP_URL` | When set, `newly-dev` surfaces this as `NEWLY_APP_URL`. |
|
|
204
|
-
| `NEWLY_AUTH_API_URL` | Control plane to sign in against (default `https://api.newly.app`). Point it at a dev control plane to log in there.
|
|
193
|
+
| `NEWLY_AUTH_API_URL` | Control plane to sign in against and send commands to (default `https://api.newly.app`). Point it at a dev control plane to log in there. |
|
|
205
194
|
|
|
206
195
|
### Typical dev workflow
|
|
207
196
|
|
|
208
197
|
```bash
|
|
209
|
-
# 1.
|
|
210
|
-
|
|
211
|
-
uv run ./deploy.sh --terminate
|
|
212
|
-
|
|
213
|
-
# 2. Grab the deployed URL, then log the dev CLI in headlessly.
|
|
214
|
-
newly-dev login --api-url https://your-dev-modal.modal.run/api --token <jwt>
|
|
198
|
+
# 1. Log the dev CLI in against the dev control plane.
|
|
199
|
+
NEWLY_AUTH_API_URL=https://cp-api-dev-97683904813.us-east4.run.app newly-dev login --email
|
|
215
200
|
|
|
216
|
-
#
|
|
201
|
+
# 2. Use newly-dev the same way as newly.
|
|
217
202
|
newly-dev create my-test
|
|
218
203
|
newly-dev status
|
|
219
204
|
newly-dev deploy
|
package/dist/commands/deploy.js
CHANGED
|
@@ -4,7 +4,8 @@ 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
|
+
import { assertKnownServices, parseServiceFilter, readBackendServices, } from '../services-manifest.js';
|
|
8
|
+
import { buildDeployBundle, spawnRun, NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE, NEWLY_BACKEND_DEPLOY_MODE_BUNDLE, } from '../deploy-bundle.js';
|
|
8
9
|
const POLL_INTERVAL_MS = 5_000;
|
|
9
10
|
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
10
11
|
export async function pollRun(client, runId, opts = {}) {
|
|
@@ -93,10 +94,36 @@ export async function runProdDeploy(projectId, client, gitSha = 'HEAD', services
|
|
|
93
94
|
const run = await streamRun(client, run_id);
|
|
94
95
|
finishOrThrow(run, 'prod');
|
|
95
96
|
}
|
|
97
|
+
export function isBundleDeployMode(stackInfo) {
|
|
98
|
+
return stackInfo.deploy_mode === NEWLY_BACKEND_DEPLOY_MODE_BUNDLE;
|
|
99
|
+
}
|
|
100
|
+
export async function runBundleDeploy(projectId, client, dir, env, allServices, selected = [], runCmd = spawnRun) {
|
|
101
|
+
const targets = selected.length ? allServices.filter((s) => selected.includes(s.name)) : allServices;
|
|
102
|
+
const bundleServices = targets.map((s) => ({
|
|
103
|
+
name: s.name,
|
|
104
|
+
path: s.path,
|
|
105
|
+
schema: s.schema ?? undefined,
|
|
106
|
+
secrets: s.secrets,
|
|
107
|
+
}));
|
|
108
|
+
const bundle = await buildDeployBundle({
|
|
109
|
+
projectDir: dir,
|
|
110
|
+
services: bundleServices,
|
|
111
|
+
run: runCmd,
|
|
112
|
+
log: (line) => console.log(line),
|
|
113
|
+
});
|
|
114
|
+
const kib = Math.max(1, Math.round(bundle.length / 1024));
|
|
115
|
+
console.log(`Uploading deploy bundle (${kib} KiB)…`);
|
|
116
|
+
const params = new URLSearchParams({ env });
|
|
117
|
+
for (const service of selected)
|
|
118
|
+
params.append('services', service);
|
|
119
|
+
const { run_id } = await client.upload(`/newly_backend/deploy/${projectId}?${params}`, bundle, NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE);
|
|
120
|
+
const run = await streamRun(client, run_id);
|
|
121
|
+
finishOrThrow(run, env);
|
|
122
|
+
}
|
|
96
123
|
export async function ensureBackendProvisioned(client, projectId, env, opts = {}) {
|
|
97
124
|
const print = opts.print ?? console.log;
|
|
98
125
|
const status = await client.get(`/newly_backend/status/${projectId}`);
|
|
99
|
-
if (status[env])
|
|
126
|
+
if (status[env] && status[env].status !== 'failed')
|
|
100
127
|
return;
|
|
101
128
|
print(`Provisioning ${env} backend…`);
|
|
102
129
|
if (env === 'dev') {
|
|
@@ -127,12 +154,14 @@ export function registerDeploy(program) {
|
|
|
127
154
|
const projectId = resolveProject(process.cwd(), opts.project);
|
|
128
155
|
const client = new ApiClient(creds.apiUrl, creds.token);
|
|
129
156
|
const services = parseServiceFilter(opts.service);
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
const manifestServices = readBackendServices(process.cwd());
|
|
158
|
+
assertKnownServices(manifestServices, services);
|
|
159
|
+
const stackInfo = (await client.get(`/newly_backend/stack/${projectId}`));
|
|
132
160
|
if (!stackInfo.cli_deployable) {
|
|
133
161
|
throw new Error('This project does not support CLI deploys yet. Use the Newly web app to deploy it.');
|
|
134
162
|
}
|
|
135
|
-
|
|
163
|
+
const bundleMode = isBundleDeployMode(stackInfo);
|
|
164
|
+
if ((opts.env === 'dev' || bundleMode) && !existsSync(join(process.cwd(), 'backend'))) {
|
|
136
165
|
throw new Error('No backend/ in this directory. Run `newly pull <project-id>` first, ' +
|
|
137
166
|
'or cd into a pulled project.');
|
|
138
167
|
}
|
|
@@ -140,7 +169,12 @@ export function registerDeploy(program) {
|
|
|
140
169
|
region: opts.region,
|
|
141
170
|
print: console.log,
|
|
142
171
|
});
|
|
143
|
-
if (
|
|
172
|
+
if (bundleMode) {
|
|
173
|
+
if (opts.gitSha !== 'HEAD')
|
|
174
|
+
console.log('Note: --git-sha is ignored; this project deploys the local backend code.');
|
|
175
|
+
await runBundleDeploy(projectId, client, process.cwd(), opts.env, manifestServices, services);
|
|
176
|
+
}
|
|
177
|
+
else if (opts.env === 'prod') {
|
|
144
178
|
await runProdDeploy(projectId, client, opts.gitSha, services);
|
|
145
179
|
}
|
|
146
180
|
else {
|
package/dist/commands/email.js
CHANGED
|
@@ -1,86 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { assertDevOnly } from './misc.js';
|
|
4
|
-
function makeClient(project) {
|
|
5
|
-
const creds = requireCredentials();
|
|
6
|
-
return {
|
|
7
|
-
client: new ApiClient(creds.apiUrl, creds.token),
|
|
8
|
-
projectId: resolveProject(process.cwd(), project),
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
export async function getEmailStatus(client, projectId, env) {
|
|
12
|
-
return await client.get(`/newly_backend/email/status?project_id=${encodeURIComponent(projectId)}&env=${env}`);
|
|
13
|
-
}
|
|
14
|
-
export function formatEmailStatus(status) {
|
|
15
|
-
const lines = [
|
|
16
|
-
`email: ${status.configured ? 'configured' : 'not configured'}`,
|
|
17
|
-
`from: ${status.from ?? '(not set)'}`,
|
|
18
|
-
];
|
|
19
|
-
if (status.requires_deploy)
|
|
20
|
-
lines.push('requires deploy: yes');
|
|
21
|
-
if (status.notes)
|
|
22
|
-
lines.push(`notes: ${status.notes}`);
|
|
23
|
-
return lines.join('\n');
|
|
24
|
-
}
|
|
25
|
-
export async function sendTestEmail(client, opts) {
|
|
26
|
-
return await client.post('/newly_backend/email/test', {
|
|
27
|
-
project_id: opts.projectId,
|
|
28
|
-
env: opts.env,
|
|
29
|
-
to: opts.to,
|
|
30
|
-
from: opts.from,
|
|
31
|
-
subject: opts.subject,
|
|
32
|
-
text: opts.text,
|
|
33
|
-
html: opts.html,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
1
|
+
export const EMAIL_RETIRED_MESSAGE = '`newly email` was retired: it never had a server to talk to. ' +
|
|
2
|
+
'There is no CLI replacement; send mail from your API service with your own provider.';
|
|
36
3
|
export function registerEmail(program) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
assertDevOnly(opts.env, 'email status');
|
|
46
|
-
const { client, projectId } = makeClient(opts.project);
|
|
47
|
-
console.log(formatEmailStatus(await getEmailStatus(client, projectId, opts.env)));
|
|
48
|
-
}
|
|
49
|
-
catch (e) {
|
|
50
|
-
console.error(friendlyApiError(e));
|
|
51
|
-
process.exitCode = 1;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
email
|
|
55
|
-
.command('test')
|
|
56
|
-
.description('Send a test email through the deployed API service')
|
|
57
|
-
.requiredOption('--to <email>', 'recipient email address')
|
|
58
|
-
.option('--from <email>', 'sender email address; defaults to the backend sender setting')
|
|
59
|
-
.option('--subject <subject>', 'email subject', 'Newly email test')
|
|
60
|
-
.option('--text <text>', 'plain-text body', 'This is a test email from your Newly backend.')
|
|
61
|
-
.option('--html <html>', 'optional HTML body')
|
|
62
|
-
.option('--env <env>', 'dev or prod', 'dev')
|
|
63
|
-
.option('--project <id>', 'project id')
|
|
64
|
-
.action(async (opts) => {
|
|
65
|
-
try {
|
|
66
|
-
assertDevOnly(opts.env, 'email test');
|
|
67
|
-
const { client, projectId } = makeClient(opts.project);
|
|
68
|
-
const result = await sendTestEmail(client, {
|
|
69
|
-
projectId,
|
|
70
|
-
env: opts.env,
|
|
71
|
-
to: opts.to,
|
|
72
|
-
from: opts.from,
|
|
73
|
-
subject: opts.subject,
|
|
74
|
-
text: opts.text,
|
|
75
|
-
html: opts.html,
|
|
76
|
-
});
|
|
77
|
-
console.log(result.message_id
|
|
78
|
-
? `Email sent. message_id=${result.message_id}`
|
|
79
|
-
: 'Email sent.');
|
|
80
|
-
}
|
|
81
|
-
catch (e) {
|
|
82
|
-
console.error(friendlyApiError(e));
|
|
83
|
-
process.exitCode = 1;
|
|
84
|
-
}
|
|
4
|
+
program
|
|
5
|
+
.command('email')
|
|
6
|
+
.description('Retired: send mail from your API service instead')
|
|
7
|
+
.allowUnknownOption()
|
|
8
|
+
.allowExcessArguments()
|
|
9
|
+
.action(() => {
|
|
10
|
+
console.error(EMAIL_RETIRED_MESSAGE);
|
|
11
|
+
process.exitCode = 1;
|
|
85
12
|
});
|
|
86
13
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
3
|
import { runEnvironmentDatabaseShell } from '../shellSession.js';
|
|
4
|
-
import { clearEnvironmentSelection, readEnvironmentSelection,
|
|
4
|
+
import { clearEnvironmentSelection, readEnvironmentSelection, requireCredentials, requireEnvironmentSelection, resolveProject, writeEnvironmentSelection, } from '../config.js';
|
|
5
5
|
const POLL_INTERVAL_MS = 2_000;
|
|
6
6
|
const POLL_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
7
7
|
function client() {
|
|
8
8
|
const credentials = requireCredentials();
|
|
9
|
-
return new ApiClient(
|
|
9
|
+
return new ApiClient(credentials.apiUrl, credentials.token);
|
|
10
10
|
}
|
|
11
11
|
async function projectEnvironments(api, projectId) {
|
|
12
12
|
const response = await api.get(`/projects/${encodeURIComponent(projectId)}/environments`);
|
|
@@ -248,7 +248,7 @@ export function registerEnvironment(program) {
|
|
|
248
248
|
const projectId = resolveProject(process.cwd(), opts.project);
|
|
249
249
|
const selected = requireEnvironmentSelection(process.cwd(), projectId);
|
|
250
250
|
const credentials = requireCredentials();
|
|
251
|
-
const environment = await new ApiClient(
|
|
251
|
+
const environment = await new ApiClient(credentials.apiUrl, credentials.token).get(`/environments/${encodeURIComponent(selected.id)}`);
|
|
252
252
|
const resource = resolveNamedResource(environment.databases, value, 'database');
|
|
253
253
|
if (resource.status !== 'ready')
|
|
254
254
|
throw new Error(`Database is not ready (${resource.status}).`);
|
package/dist/commands/login.js
CHANGED
|
@@ -49,9 +49,8 @@ export function openBrowser(url) {
|
|
|
49
49
|
});
|
|
50
50
|
child.unref();
|
|
51
51
|
}
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
return { apiUrl, ...(same ? {} : { authApiUrl }) };
|
|
52
|
+
export function commandApiUrl(isRunCloud, authApiUrl, offered) {
|
|
53
|
+
return isRunCloud && offered ? offered : authApiUrl;
|
|
55
54
|
}
|
|
56
55
|
export async function emailLogin(apiUrl, presetEmail, io) {
|
|
57
56
|
const email = presetEmail?.trim()
|
|
@@ -135,7 +134,6 @@ export function whoamiReport(creds) {
|
|
|
135
134
|
const rows = [
|
|
136
135
|
['Account', creds.account ?? 'not recorded (browser sign-in never sees an address)'],
|
|
137
136
|
['API', creds.apiUrl],
|
|
138
|
-
...(creds.authApiUrl ? [['Issued by', creds.authApiUrl]] : []),
|
|
139
137
|
['Expires', expiresAtLabel(creds) ?? 'never'],
|
|
140
138
|
['Credential', credentialsDisplayPath()],
|
|
141
139
|
];
|
|
@@ -223,10 +221,8 @@ export function registerLogin(program) {
|
|
|
223
221
|
let method = methodFromFlags(opts);
|
|
224
222
|
const terminal = method === 'email' || !method ? acquirePromptIO() : null;
|
|
225
223
|
try {
|
|
226
|
-
let providers = null;
|
|
227
224
|
if (!method && terminal) {
|
|
228
|
-
|
|
229
|
-
const choices = loginChoices(providers);
|
|
225
|
+
const choices = loginChoices(await fetchProviders(authApiUrl));
|
|
230
226
|
if (choices.length === 0) {
|
|
231
227
|
throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
|
|
232
228
|
}
|
|
@@ -234,15 +230,14 @@ export function registerLogin(program) {
|
|
|
234
230
|
}
|
|
235
231
|
if (method === 'email') {
|
|
236
232
|
const preset = typeof opts.email === 'string' ? opts.email : undefined;
|
|
237
|
-
providers ??= await fetchProviders(authApiUrl);
|
|
238
233
|
const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
|
|
239
234
|
saveCredentials({
|
|
240
235
|
token,
|
|
236
|
+
apiUrl: authApiUrl,
|
|
241
237
|
appUrl: opts.appUrl,
|
|
242
238
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
243
239
|
revocable: true,
|
|
244
240
|
account: email,
|
|
245
|
-
...commandApiUrls(providers.cliApiUrl ?? authApiUrl, authApiUrl),
|
|
246
241
|
});
|
|
247
242
|
console.log(`\n Signed in as ${email}`);
|
|
248
243
|
console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
|
|
@@ -274,11 +269,11 @@ export function registerLogin(program) {
|
|
|
274
269
|
await verifyRunCloudCredential(authApiUrl, payload.token);
|
|
275
270
|
saveCredentials({
|
|
276
271
|
token: payload.token,
|
|
272
|
+
apiUrl: commandApiUrl(isRunCloud, authApiUrl, payload.apiUrl),
|
|
277
273
|
appUrl: opts.appUrl,
|
|
278
274
|
expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
|
|
279
275
|
revocable: true,
|
|
280
276
|
...(payload.account ? { account: payload.account } : {}),
|
|
281
|
-
...commandApiUrls(payload.apiUrl, authApiUrl),
|
|
282
277
|
});
|
|
283
278
|
console.log(`Logged in${payload.account ? ` as ${payload.account}` : ''}. ` +
|
|
284
279
|
`Credentials saved to ${credentialsDisplayPath()}`);
|
|
@@ -313,7 +308,7 @@ export function registerLogin(program) {
|
|
|
313
308
|
.action(async () => {
|
|
314
309
|
const creds = loadCredentials();
|
|
315
310
|
if (creds?.revocable) {
|
|
316
|
-
console.log(await revokeRunCloudCredential(creds.
|
|
311
|
+
console.log(await revokeRunCloudCredential(creds.apiUrl, creds.token));
|
|
317
312
|
}
|
|
318
313
|
clearCredentials();
|
|
319
314
|
console.log('Logged out.');
|
package/dist/commands/token.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { acquirePromptIO, select, PromptAbortedError } from '../prompt.js';
|
|
2
2
|
import { fetchProviders } from '../runCloudAuth.js';
|
|
3
|
-
import { authApiUrlFor,
|
|
3
|
+
import { authApiUrlFor, commandApiUrl, emailLogin, loginChoices, methodFromFlags, openBrowser, verifyRunCloudCredential, } from './login.js';
|
|
4
4
|
export function tokenReport(isRunCloud, minted) {
|
|
5
5
|
const bin = isRunCloud ? 'runcloud' : 'newly';
|
|
6
6
|
const tokenVar = isRunCloud ? 'RUN_CLOUD_API_TOKEN' : 'NEWLY_API_TOKEN';
|
|
@@ -49,10 +49,8 @@ export function registerToken(program) {
|
|
|
49
49
|
let method = methodFromFlags(opts);
|
|
50
50
|
const terminal = method === 'email' || !method ? acquirePromptIO() : null;
|
|
51
51
|
try {
|
|
52
|
-
let providers = null;
|
|
53
52
|
if (!method && terminal) {
|
|
54
|
-
|
|
55
|
-
const choices = loginChoices(providers);
|
|
53
|
+
const choices = loginChoices(await fetchProviders(authApiUrl));
|
|
56
54
|
if (choices.length === 0) {
|
|
57
55
|
throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
|
|
58
56
|
}
|
|
@@ -60,11 +58,9 @@ export function registerToken(program) {
|
|
|
60
58
|
}
|
|
61
59
|
if (method === 'email') {
|
|
62
60
|
const preset = typeof opts.email === 'string' ? opts.email : undefined;
|
|
63
|
-
providers ??= await fetchProviders(authApiUrl);
|
|
64
61
|
const { email, token: minted } = await emailLogin(authApiUrl, preset, terminal?.io);
|
|
65
62
|
await verifyRunCloudCredential(authApiUrl, minted);
|
|
66
|
-
|
|
67
|
-
console.log(tokenReport(isRunCloud, { token: minted, apiUrl: urls.apiUrl, account: email }));
|
|
63
|
+
console.log(tokenReport(isRunCloud, { token: minted, apiUrl: authApiUrl, account: email }));
|
|
68
64
|
return;
|
|
69
65
|
}
|
|
70
66
|
}
|
|
@@ -82,10 +78,9 @@ export function registerToken(program) {
|
|
|
82
78
|
openBrowser,
|
|
83
79
|
});
|
|
84
80
|
await verifyRunCloudCredential(authApiUrl, payload.token);
|
|
85
|
-
const urls = commandApiUrls(payload.apiUrl, authApiUrl);
|
|
86
81
|
console.log(tokenReport(isRunCloud, {
|
|
87
82
|
token: payload.token,
|
|
88
|
-
apiUrl:
|
|
83
|
+
apiUrl: commandApiUrl(isRunCloud, authApiUrl, payload.apiUrl),
|
|
89
84
|
...(payload.account ? { account: payload.account } : {}),
|
|
90
85
|
}));
|
|
91
86
|
}
|
package/dist/commands/website.js
CHANGED
|
@@ -4,6 +4,7 @@ import { create as tarCreate } from 'tar';
|
|
|
4
4
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
5
5
|
import { createSnapshot } from '../archive.js';
|
|
6
6
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
7
|
+
import { isBundleDeployMode } from './deploy.js';
|
|
7
8
|
function makeClient(project) {
|
|
8
9
|
const creds = requireCredentials();
|
|
9
10
|
return {
|
|
@@ -12,6 +13,8 @@ function makeClient(project) {
|
|
|
12
13
|
};
|
|
13
14
|
}
|
|
14
15
|
const BUILD_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
const PUBLISH_POLL_INTERVAL_MS = 3_000;
|
|
17
|
+
const PUBLISH_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
15
18
|
export function renderDeployment(d) {
|
|
16
19
|
const at = d.published_at ?? d.created_at ?? '';
|
|
17
20
|
const suffix = d.error_message ? ` (${d.error_message})` : '';
|
|
@@ -100,8 +103,39 @@ export async function createStaticSiteArchive(dir) {
|
|
|
100
103
|
}
|
|
101
104
|
return Buffer.concat(chunks);
|
|
102
105
|
}
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
function assertBundleModeExport(dir) {
|
|
107
|
+
if (!existsSync(`${resolve(dir)}/index.html`)) {
|
|
108
|
+
throw new Error(`No static export at ${dir}. Build it first, then run \`newly website publish ${dir}\`.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function finishPublish(client, projectId, result, log, pollOpts = {}) {
|
|
112
|
+
if (result.deployment.status !== 'building') {
|
|
113
|
+
log(`Published ${result.deployment.id}`);
|
|
114
|
+
log(`URL: ${result.default_url}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const intervalMs = pollOpts.intervalMs ?? PUBLISH_POLL_INTERVAL_MS;
|
|
118
|
+
const timeoutMs = pollOpts.timeoutMs ?? PUBLISH_POLL_TIMEOUT_MS;
|
|
119
|
+
log(`Publishing ${result.deployment.id}...`);
|
|
120
|
+
const deadline = Date.now() + timeoutMs;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { deployments = [] } = (await client.get(`/projects/${projectId}/website/deployments`));
|
|
123
|
+
const match = deployments.find((d) => d.id === result.deployment.id);
|
|
124
|
+
if (match?.status === 'active') {
|
|
125
|
+
log(`Published ${match.id}`);
|
|
126
|
+
log(`URL: ${result.default_url}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (match?.status === 'failed') {
|
|
130
|
+
throw new Error(match.error_message ?? `Publish ${result.deployment.id} failed.`);
|
|
131
|
+
}
|
|
132
|
+
if (Date.now() > deadline) {
|
|
133
|
+
throw new Error(`Publish ${result.deployment.id} timed out after ${timeoutMs / 1000}s`);
|
|
134
|
+
}
|
|
135
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function runWebsitePublish(client, projectId, opts = {}, log = console.log, pollOpts = {}) {
|
|
105
139
|
if (opts.remoteBuild) {
|
|
106
140
|
const sourceDir = opts.localPath ?? '.';
|
|
107
141
|
assertDirectory(sourceDir, 'Website source directory');
|
|
@@ -115,19 +149,33 @@ export async function runWebsitePublish(client, projectId, opts = {}, log = cons
|
|
|
115
149
|
output_dir: opts.outputDir,
|
|
116
150
|
}));
|
|
117
151
|
form.set('source', new Blob([new Uint8Array(archive)], { type: 'application/gzip' }), 'source.tar.gz');
|
|
118
|
-
result = (await client.uploadForm(`/projects/${projectId}/website/build-upload`, form));
|
|
152
|
+
const result = (await client.uploadForm(`/projects/${projectId}/website/build-upload`, form));
|
|
153
|
+
log(`Published ${result.deployment.id}`);
|
|
154
|
+
log(`URL: ${result.default_url}`);
|
|
155
|
+
return;
|
|
119
156
|
}
|
|
120
|
-
|
|
157
|
+
if (opts.localPath) {
|
|
121
158
|
log('Packaging static site...');
|
|
122
159
|
const archive = await createStaticSiteArchive(opts.localPath);
|
|
123
160
|
log(`Uploading static site (${archiveKib(archive.length)} KiB)...`);
|
|
124
|
-
result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
161
|
+
const result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
162
|
+
await finishPublish(client, projectId, result, log, pollOpts);
|
|
163
|
+
return;
|
|
125
164
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
const stackInfo = (await client.get(`/newly_backend/stack/${projectId}`));
|
|
166
|
+
if (isBundleDeployMode(stackInfo)) {
|
|
167
|
+
const dir = opts.sourcePath ?? 'website/out';
|
|
168
|
+
assertBundleModeExport(dir);
|
|
169
|
+
log('Packaging static site...');
|
|
170
|
+
const archive = await createStaticSiteArchive(dir);
|
|
171
|
+
log(`Uploading static site (${archiveKib(archive.length)} KiB)...`);
|
|
172
|
+
const result = (await client.upload(`/projects/${projectId}/website/publish-upload`, archive, 'application/gzip'));
|
|
173
|
+
await finishPublish(client, projectId, result, log, pollOpts);
|
|
174
|
+
return;
|
|
130
175
|
}
|
|
176
|
+
const result = (await client.post(`/projects/${projectId}/website/publish`, {
|
|
177
|
+
source_path: opts.sourcePath ?? 'website/out',
|
|
178
|
+
}));
|
|
131
179
|
log(`Published ${result.deployment.id}`);
|
|
132
180
|
log(`URL: ${result.default_url}`);
|
|
133
181
|
}
|
package/dist/config.js
CHANGED
|
@@ -36,9 +36,6 @@ export function loginCommandName() {
|
|
|
36
36
|
export function normalizeApiUrl(url) {
|
|
37
37
|
return url.replace(/\/+$/, '');
|
|
38
38
|
}
|
|
39
|
-
export function controlPlaneUrl(creds) {
|
|
40
|
-
return normalizeApiUrl(creds.authApiUrl ?? creds.apiUrl);
|
|
41
|
-
}
|
|
42
39
|
export function saveCredentials(creds) {
|
|
43
40
|
mkdirSync(credentialsHome(), { recursive: true, mode: 0o700 });
|
|
44
41
|
const normalized = { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
|
|
@@ -67,10 +64,10 @@ export function loadCredentials() {
|
|
|
67
64
|
if (!existsSync(credentialsPath()))
|
|
68
65
|
return null;
|
|
69
66
|
try {
|
|
70
|
-
const creds = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
|
|
67
|
+
const { authApiUrl, ...creds } = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
|
|
71
68
|
if (!creds.token || creds.expiresAt < Date.now() / 1000)
|
|
72
69
|
return null;
|
|
73
|
-
return { ...creds, apiUrl: normalizeApiUrl(creds.apiUrl) };
|
|
70
|
+
return { ...creds, apiUrl: normalizeApiUrl(authApiUrl ?? creds.apiUrl) };
|
|
74
71
|
}
|
|
75
72
|
catch {
|
|
76
73
|
return null;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { create as tarCreate } from 'tar';
|
|
7
|
+
export const NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE = 'application/vnd.newly.deploy-bundle+gzip';
|
|
8
|
+
export const NEWLY_BACKEND_DEPLOY_MODE_BUNDLE = 'bundle';
|
|
9
|
+
const MANIFEST_PATH = 'newly-deploy.json';
|
|
10
|
+
const MAX_BUNDLE_BYTES = 25 * 1024 * 1024;
|
|
11
|
+
const REPLACE_TOKEN = /REPLACE_[A-Z_]+/g;
|
|
12
|
+
const OUTPUT_CAP_BYTES = 64 * 1024;
|
|
13
|
+
export class DeployBuildError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'DeployBuildError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function buildDeployBundle(options) {
|
|
20
|
+
const { projectDir, services, run, log } = options;
|
|
21
|
+
const bunVersion = await run('bun', ['--version'], projectDir);
|
|
22
|
+
if (bunVersion.code !== 0) {
|
|
23
|
+
throw new DeployBuildError('newly deploy needs bun on your PATH (https://bun.sh)');
|
|
24
|
+
}
|
|
25
|
+
const bundleDir = mkdtempSync(join(tmpdir(), 'newly-deploy-bundle-'));
|
|
26
|
+
const servicesDir = join(bundleDir, 'services');
|
|
27
|
+
mkdirSync(servicesDir, { recursive: true });
|
|
28
|
+
try {
|
|
29
|
+
const manifestServices = [];
|
|
30
|
+
for (const service of services) {
|
|
31
|
+
manifestServices.push(await buildService(service, projectDir, servicesDir, run, log));
|
|
32
|
+
}
|
|
33
|
+
const gitSha = await resolveGitSha(run, projectDir);
|
|
34
|
+
const manifest = { version: 1, git_sha: gitSha, services: manifestServices };
|
|
35
|
+
writeFileSync(join(bundleDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2));
|
|
36
|
+
const bundle = await tarBundle(bundleDir);
|
|
37
|
+
if (bundle.length > MAX_BUNDLE_BYTES) {
|
|
38
|
+
const mib = (bundle.length / (1024 * 1024)).toFixed(1);
|
|
39
|
+
throw new DeployBuildError(`Deploy bundle is too large (${mib} MiB, max 25 MiB).`);
|
|
40
|
+
}
|
|
41
|
+
return bundle;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function buildService(service, projectDir, servicesDir, run, log) {
|
|
48
|
+
const dir = join(projectDir, 'backend', service.path);
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
throw new DeployBuildError(`No backend code to deploy (backend/${service.path} missing)`);
|
|
51
|
+
}
|
|
52
|
+
const configPath = join(dir, 'wrangler.toml');
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
throw new DeployBuildError(`No wrangler.toml in backend/${service.path}`);
|
|
55
|
+
}
|
|
56
|
+
log(`Building ${service.name} (bun install, wrangler bundle)…`);
|
|
57
|
+
const install = await run('bun', ['install', '--ignore-scripts'], dir);
|
|
58
|
+
if (install.code !== 0) {
|
|
59
|
+
throw new DeployBuildError(`bun install failed for ${service.name}:\n${install.output}`);
|
|
60
|
+
}
|
|
61
|
+
const originalConfig = readFileSync(configPath, 'utf8');
|
|
62
|
+
const script = await wranglerBundle(service, dir, originalConfig, run);
|
|
63
|
+
const serviceDir = join(servicesDir, service.name);
|
|
64
|
+
mkdirSync(serviceDir, { recursive: true });
|
|
65
|
+
writeFileSync(join(serviceDir, 'index.js'), script);
|
|
66
|
+
writeFileSync(join(serviceDir, 'wrangler.toml'), originalConfig);
|
|
67
|
+
const migrations = stageMigrations(dir, serviceDir, service.name);
|
|
68
|
+
return {
|
|
69
|
+
name: service.name,
|
|
70
|
+
schema: service.schema ?? null,
|
|
71
|
+
secrets: service.secrets ?? null,
|
|
72
|
+
script: `services/${service.name}/index.js`,
|
|
73
|
+
config: `services/${service.name}/wrangler.toml`,
|
|
74
|
+
migrations,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function wranglerBundle(service, dir, originalConfig, run) {
|
|
78
|
+
const deployConfigName = 'wrangler.newly-deploy.toml';
|
|
79
|
+
const deployConfigPath = join(dir, deployConfigName);
|
|
80
|
+
const outDir = mkdtempSync(join(tmpdir(), 'newly-deploy-wrangler-'));
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(deployConfigPath, originalConfig.replace(REPLACE_TOKEN, 'newly-placeholder'));
|
|
83
|
+
const dryRun = await run('bunx', ['wrangler', 'deploy', '--dry-run', '--outdir', outDir, '--config', deployConfigName], dir);
|
|
84
|
+
if (dryRun.code !== 0) {
|
|
85
|
+
throw new DeployBuildError(`wrangler bundle failed for ${service.name}:\n${dryRun.output}`);
|
|
86
|
+
}
|
|
87
|
+
const jsFiles = readdirSync(outDir).filter((f) => f.endsWith('.js'));
|
|
88
|
+
if (jsFiles.length !== 1) {
|
|
89
|
+
throw new DeployBuildError(`wrangler produced ${jsFiles.length} script file(s) for ${service.name}, expected exactly one`);
|
|
90
|
+
}
|
|
91
|
+
return readFileSync(join(outDir, jsFiles[0]));
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(deployConfigPath, { force: true });
|
|
95
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function stageMigrations(dir, serviceDir, name) {
|
|
99
|
+
const drizzleDir = join(dir, 'drizzle');
|
|
100
|
+
const journalPath = join(drizzleDir, 'meta', '_journal.json');
|
|
101
|
+
if (!existsSync(journalPath))
|
|
102
|
+
return null;
|
|
103
|
+
const outMetaDir = join(serviceDir, 'drizzle', 'meta');
|
|
104
|
+
mkdirSync(outMetaDir, { recursive: true });
|
|
105
|
+
writeFileSync(join(outMetaDir, '_journal.json'), readFileSync(journalPath));
|
|
106
|
+
for (const entry of readdirSync(drizzleDir)) {
|
|
107
|
+
if (entry.endsWith('.sql')) {
|
|
108
|
+
writeFileSync(join(serviceDir, 'drizzle', entry), readFileSync(join(drizzleDir, entry)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return `services/${name}/drizzle`;
|
|
112
|
+
}
|
|
113
|
+
async function resolveGitSha(run, projectDir) {
|
|
114
|
+
const result = await run('git', ['rev-parse', 'HEAD'], projectDir);
|
|
115
|
+
if (result.code !== 0)
|
|
116
|
+
return null;
|
|
117
|
+
const sha = result.output.trim();
|
|
118
|
+
return sha || null;
|
|
119
|
+
}
|
|
120
|
+
async function tarBundle(bundleDir) {
|
|
121
|
+
const chunks = [];
|
|
122
|
+
const stream = tarCreate({ gzip: true, cwd: bundleDir, portable: true }, [MANIFEST_PATH, 'services']);
|
|
123
|
+
for await (const chunk of stream) {
|
|
124
|
+
chunks.push(Buffer.from(chunk));
|
|
125
|
+
}
|
|
126
|
+
return Buffer.concat(chunks);
|
|
127
|
+
}
|
|
128
|
+
export function spawnRun(command, args, cwd) {
|
|
129
|
+
const shell = process.platform === 'win32';
|
|
130
|
+
const argv = shell ? args.map((arg) => (/[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg)) : args;
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const child = spawn(command, argv, {
|
|
133
|
+
cwd,
|
|
134
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
135
|
+
shell,
|
|
136
|
+
});
|
|
137
|
+
let output = '';
|
|
138
|
+
const append = (chunk) => {
|
|
139
|
+
output += chunk.toString('utf8');
|
|
140
|
+
if (output.length > OUTPUT_CAP_BYTES)
|
|
141
|
+
output = output.slice(-OUTPUT_CAP_BYTES);
|
|
142
|
+
};
|
|
143
|
+
child.stdout?.on('data', append);
|
|
144
|
+
child.stderr?.on('data', append);
|
|
145
|
+
child.on('error', (error) => resolve({ code: 127, output: error.message }));
|
|
146
|
+
child.on('close', (code) => resolve({ code: code ?? 1, output }));
|
|
147
|
+
});
|
|
148
|
+
}
|
package/dist/runCloudAuth.js
CHANGED
|
@@ -41,13 +41,11 @@ export async function fetchProviders(apiUrl) {
|
|
|
41
41
|
if (!response.ok)
|
|
42
42
|
throw new AuthError(await readError(response), response.status);
|
|
43
43
|
const payload = (await response.json());
|
|
44
|
-
const cliApiUrl = typeof payload.cliApiUrl === 'string' ? payload.cliApiUrl.trim() : '';
|
|
45
44
|
return {
|
|
46
45
|
emailOtp: payload.emailOtp === true,
|
|
47
46
|
google: payload.google === true,
|
|
48
47
|
github: payload.github === true,
|
|
49
48
|
apple: payload.apple === true,
|
|
50
|
-
...(cliApiUrl ? { cliApiUrl } : {}),
|
|
51
49
|
};
|
|
52
50
|
}
|
|
53
51
|
export async function sendEmailCode(apiUrl, email) {
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.
|
|
1
|
+
export const CLI_VERSION = '0.2.138';
|