@newly/cli 0.2.163 → 0.2.165
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 +133 -1
- package/dist/commands/database.js +192 -0
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/link.js +1 -0
- package/dist/commands/misc.js +20 -5
- package/dist/commands/pull.js +6 -2
- package/dist/database-authorization.js +23 -0
- package/dist/deploy-bundle.js +37 -7
- package/dist/migration-plan.js +74 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,9 +157,141 @@ Set a backend secret. **Dev only.** Redeploy to apply.
|
|
|
157
157
|
- `--project <id>`
|
|
158
158
|
|
|
159
159
|
### `newly db url`
|
|
160
|
-
Print a
|
|
160
|
+
Print a direct PostgreSQL connection URL for the selected database.
|
|
161
|
+
- `--env <dev|prod>` (default `dev`)
|
|
162
|
+
- `--role <readonly|owner>` (default `readonly`)
|
|
161
163
|
- `--project <id>`
|
|
162
164
|
|
|
165
|
+
```bash
|
|
166
|
+
newly db url --env prod --role owner --project PROJECT_ID
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Owner access allows direct SQL, migrations, exports and logical backups using
|
|
170
|
+
PostgreSQL tools such as `psql` and `pg_dump`. Save the URL in your own secret
|
|
171
|
+
manager before an outage. Once saved, it connects directly to the database;
|
|
172
|
+
Newly's API, worker and CLI do not need to be running. The database provider and
|
|
173
|
+
network must remain available. Retrieving a URL still requires Newly to be
|
|
174
|
+
available. This exports the existing app owner credential; changing its password
|
|
175
|
+
also affects the deployed API/Auth services. It does not grant a provider account
|
|
176
|
+
or provider snapshot controls.
|
|
177
|
+
|
|
178
|
+
The URL contains a password. New credential exports require the project owner's
|
|
179
|
+
user credential in either environment; managed agent credentials cannot export
|
|
180
|
+
them. Responses are not cached and credentials do not enter operation results.
|
|
181
|
+
Readonly access retains the role's existing grants; it does not add access to
|
|
182
|
+
custom app schemas. The legacy dev readonly command keeps its existing access
|
|
183
|
+
rules.
|
|
184
|
+
|
|
185
|
+
### Database inspection, backups and restore
|
|
186
|
+
|
|
187
|
+
These commands keep database provider credentials on the platform. Production
|
|
188
|
+
requires the project owner's user credential from `newly login`; managed agent
|
|
189
|
+
sessions remain dev-only. Dev requires an admin or owner. All commands accept
|
|
190
|
+
`--project` and `--json`; `--env` defaults to `dev`.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
newly db migrations --env prod --json
|
|
194
|
+
newly db migrations --env prod --services api,auth
|
|
195
|
+
newly db backup status --env prod
|
|
196
|
+
newly db backup list --env prod
|
|
197
|
+
newly db backup create --env prod --name before-catalog --retention-hours 48
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Migration inspection compares live ledgers, read in a read-only transaction,
|
|
201
|
+
with the selected services' local journals and SQL hashes. It reports applied
|
|
202
|
+
and pending migrations, edited SQL, missing local entries and skipped
|
|
203
|
+
migrations. A discrepancy returns a nonzero exit code. This is a comparison
|
|
204
|
+
with the current checkout, not a deployment dry run or an application
|
|
205
|
+
compatibility check; it does not apply or select migrations.
|
|
206
|
+
|
|
207
|
+
Manual snapshots expire after 24 hours by default; `--retention-hours` accepts
|
|
208
|
+
1 to 168. At most five manual snapshots and three restore previews can exist per
|
|
209
|
+
database. Newly does not manage backup schedules or take automatic predeployment
|
|
210
|
+
snapshots. `backup status` reports `managed_backup_scheduling: false` and
|
|
211
|
+
`automatic_predeploy_snapshot: false`.
|
|
212
|
+
|
|
213
|
+
Users can automate the manual command from their own cron or CI job:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
newly db backup create --env prod --project PROJECT_ID \
|
|
217
|
+
--name owner-backup-job --retention-hours 24
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The command runs without an interactive prompt. Supply `NEWLY_API_URL` and an
|
|
221
|
+
owner user credential in `NEWLY_API_TOKEN` through the job's configuration and
|
|
222
|
+
secret store, or use the owner's authenticated CLI profile. Managed agent
|
|
223
|
+
credentials cannot operate on production. Users own the job, its schedule,
|
|
224
|
+
credentials, retention, and monitoring. Leave room below the five-snapshot limit
|
|
225
|
+
for expiration cleanup and extra manual snapshots; alert on nonzero exit codes
|
|
226
|
+
and expired credentials. Inspect a pending or uncertain operation before retrying.
|
|
227
|
+
This automation requires Newly and the database provider to be available. For
|
|
228
|
+
independent access during a Newly outage, save the owner connection URL beforehand
|
|
229
|
+
and run SQL clients or `pg_dump` from infrastructure you control.
|
|
230
|
+
|
|
231
|
+
See [Database backups](https://docs.newly.app/backend/database-backups) for token
|
|
232
|
+
setup, a GitHub Actions example, failure handling, and independent database exports.
|
|
233
|
+
|
|
234
|
+
Restore starts with a separate copy. Its result includes the preview operation
|
|
235
|
+
ID, migration ledgers and whether current API/Auth/read-only database
|
|
236
|
+
credentials work against the copy. Inspect that result before deciding to apply:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
newly db restore prepare SNAPSHOT_ID --env prod --schemas api,auth
|
|
240
|
+
newly db operation PREVIEW_OPERATION_ID --env prod
|
|
241
|
+
newly db restore apply PREVIEW_OPERATION_ID --env prod \
|
|
242
|
+
--confirm-project PROJECT_ID --accept-data-loss
|
|
243
|
+
# Or remove an unapplied copy and its preview compute:
|
|
244
|
+
newly db restore discard PREVIEW_OPERATION_ID --env prod
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Applying switches the whole database, including Auth and every app schema, to
|
|
248
|
+
the snapshot state. Writes after the snapshot can be lost and connections
|
|
249
|
+
restart. App traffic is not paused. The deployed application, secrets, object
|
|
250
|
+
storage and queues are not rolled back. The owner controls when to apply and
|
|
251
|
+
validates application compatibility afterward.
|
|
252
|
+
|
|
253
|
+
An existing deployment blocks restore. Database operations hold the backend row
|
|
254
|
+
lock to block new deployments or teardown during the switch. After verifying the
|
|
255
|
+
switch, Newly attempts to delete the previous branch; snapshots remain listed.
|
|
256
|
+
If `cleanup_required` is reported, discard remaining unapplied previews and run
|
|
257
|
+
`newly db restore cleanup PREVIEW_OPERATION_ID --env prod` to remove the old
|
|
258
|
+
branch. Discard can remove stale or failed previews and never the live branch.
|
|
259
|
+
Pause user-owned
|
|
260
|
+
automation and coordinate application writes before applying a restore, then
|
|
261
|
+
validate the restored application before resuming them. If an external provider
|
|
262
|
+
backup schedule exists, restore refuses to switch until it is disabled through
|
|
263
|
+
its original management tool. Newly does not copy, disable, or configure that
|
|
264
|
+
schedule; the owner manages it after the restore.
|
|
265
|
+
|
|
266
|
+
Operations run asynchronously. A timeout or lost response does not cancel a
|
|
267
|
+
provider operation; inspect the printed operation ID with `newly db operation`
|
|
268
|
+
and check database state before submitting another request. A successful restore
|
|
269
|
+
confirms the provider database switch, not the application's behavior.
|
|
270
|
+
|
|
271
|
+
Production URL export (including read-only), owner export in either environment,
|
|
272
|
+
production restore apply and previous-branch cleanup require
|
|
273
|
+
the project owner's CLI key plus a fresh email verification code. The CLI
|
|
274
|
+
prompts for a six-digit code sent to the account's verified email. Each code is
|
|
275
|
+
bound to one key, project, environment, action and request, expires after five
|
|
276
|
+
minutes and can be consumed once. Web/desktop sessions cannot perform these
|
|
277
|
+
actions. Never share a code with an agent. Manual snapshot creation stays
|
|
278
|
+
noninteractive for user-owned automation.
|
|
279
|
+
|
|
280
|
+
To invalidate saved owner and read-only URLs and update API/Auth database credentials:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
newly db credentials rotate --env prod --project PROJECT_ID \
|
|
284
|
+
--confirm-project PROJECT_ID --accept-connection-restart
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Rotation also requires fresh email authorization. It resets both passwords and
|
|
288
|
+
closes existing connections using these roles; export new URLs afterward and
|
|
289
|
+
update external jobs. A snapshot retains
|
|
290
|
+
its original database credentials, so pre-rotation snapshots can fail restore
|
|
291
|
+
compatibility checks. Export and rotation events have durable audit records
|
|
292
|
+
without URLs or verification codes. One database operation can be queued or
|
|
293
|
+
running per project; wait and inspect results before retrying a busy request.
|
|
294
|
+
|
|
163
295
|
### `newly email`
|
|
164
296
|
Retired. It never had a server to talk to; send mail from your API service with
|
|
165
297
|
your own provider. Running it prints that and exits non-zero.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
|
+
import { requireCredentials, resolveProject } from '../config.js';
|
|
4
|
+
import { readLocalMigrations, compareMigrations } from '../migration-plan.js';
|
|
5
|
+
import { authorizeDatabaseAction } from '../database-authorization.js';
|
|
6
|
+
export async function runDatabaseOperation(client, projectId, env, operation, options = {}) {
|
|
7
|
+
const operationId = options.operationId ?? randomUUID();
|
|
8
|
+
const root = `/newly_backend/database/${encodeURIComponent(projectId)}/operations`;
|
|
9
|
+
options.onStarted?.(operationId);
|
|
10
|
+
await client.post(root, { ...operation, env, operation_id: operationId });
|
|
11
|
+
const deadline = Date.now() + (options.timeoutMs ?? 300_000);
|
|
12
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
13
|
+
for (;;) {
|
|
14
|
+
const result = await client.get(`${root}/${operationId}?env=${env}`);
|
|
15
|
+
if (result.status === 'succeeded' && result.data)
|
|
16
|
+
return result.data;
|
|
17
|
+
if (result.status === 'failed')
|
|
18
|
+
throw new Error(result.message ?? 'Database operation failed');
|
|
19
|
+
if (result.status !== 'pending')
|
|
20
|
+
throw new Error('Unexpected database operation response');
|
|
21
|
+
if (Date.now() >= deadline)
|
|
22
|
+
throw new Error(`Database operation ${operationId} is still pending. Check it with newly db operation ${operationId} --env ${env} --project ${projectId}`);
|
|
23
|
+
await sleep(2000);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function connection(opts) {
|
|
27
|
+
if (opts.env !== 'dev' && opts.env !== 'prod')
|
|
28
|
+
throw new Error('--env must be dev or prod');
|
|
29
|
+
const creds = requireCredentials();
|
|
30
|
+
return { client: new ApiClient(creds.apiUrl, creds.token), projectId: resolveProject(process.cwd(), opts.project) };
|
|
31
|
+
}
|
|
32
|
+
function common(command) {
|
|
33
|
+
return command.option('--env <env>', 'dev or prod (production requires the owner’s user credential)', 'dev').option('--project <id>', 'project id').option('--json', 'print structured JSON');
|
|
34
|
+
}
|
|
35
|
+
const handleError = (error) => { console.error(friendlyApiError(error)); process.exitCode = 1; };
|
|
36
|
+
export function registerDatabaseHelpers(db) {
|
|
37
|
+
common(db.command('migrations').description('Compare live migration ledgers with this checkout; does not apply SQL'))
|
|
38
|
+
.option('--services <names>', 'comma-separated services from backend/newly.services.json')
|
|
39
|
+
.action(async (opts) => {
|
|
40
|
+
try {
|
|
41
|
+
const { client, projectId } = connection(opts);
|
|
42
|
+
const local = readLocalMigrations(process.cwd(), opts.services);
|
|
43
|
+
if (!local.length)
|
|
44
|
+
throw new Error('No selected service has a Drizzle migration journal and schema');
|
|
45
|
+
const data = await runDatabaseOperation(client, projectId, opts.env, { action: 'migrations', schemas: local.map((set) => set.schema) });
|
|
46
|
+
const ledgers = data.ledgers;
|
|
47
|
+
const services = local.map((set) => {
|
|
48
|
+
if (!ledgers?.[set.schema])
|
|
49
|
+
throw new Error(`Inspection did not return ledger ${set.schema}`);
|
|
50
|
+
return compareMigrations(set, ledgers[set.schema]);
|
|
51
|
+
});
|
|
52
|
+
if (opts.json)
|
|
53
|
+
console.log(JSON.stringify({ observed_at: data.observed_at, env: opts.env, services }, null, 2));
|
|
54
|
+
else {
|
|
55
|
+
console.log(`Migration inspection (${opts.env}), observed ${String(data.observed_at)}`);
|
|
56
|
+
for (const service of services) {
|
|
57
|
+
console.log(`\n${service.service} (${service.schema}): applied ${service.applied.length}, pending ${service.pending.length}`);
|
|
58
|
+
console.log(`Applied: ${service.applied.map((row) => row.tag ?? row.created_at).join(', ') || 'none'}`);
|
|
59
|
+
console.log(`Would execute from this checkout: ${service.would_execute?.join(', ') || (service.problems.length ? 'cannot determine — ledger differs' : 'none')}`);
|
|
60
|
+
for (const problem of service.problems)
|
|
61
|
+
console.log(`Problem: ${problem}`);
|
|
62
|
+
}
|
|
63
|
+
console.log('\nThis is a ledger comparison, not a deployment dry run or a compatibility check. Deployment uses the files submitted at that time.');
|
|
64
|
+
}
|
|
65
|
+
if (services.some((service) => service.problems.length))
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
handleError(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
common(db.command('operation').argument('<id>', 'database operation id').description('Read the status or result of a database operation'))
|
|
73
|
+
.action(async (id, opts) => {
|
|
74
|
+
try {
|
|
75
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))
|
|
76
|
+
throw new Error('Operation id must be a lowercase UUID');
|
|
77
|
+
const { client, projectId } = connection(opts);
|
|
78
|
+
const result = await client.get(`/newly_backend/database/${encodeURIComponent(projectId)}/operations/${id}?env=${opts.env}`);
|
|
79
|
+
console.log(JSON.stringify(result, null, 2));
|
|
80
|
+
if (result.status === 'failed')
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
handleError(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const backup = db.command('backup').description('Inspect and create expiring database snapshots')
|
|
88
|
+
.addHelpText('after', '\nSnapshots are created on demand. Newly does not schedule backups or create predeployment snapshots.\nYou can run backup create from your own scheduler; production requires an owner user credential and Newly availability.');
|
|
89
|
+
for (const action of ['status', 'list']) {
|
|
90
|
+
common(backup.command(action).description(action === 'status' ? 'Show recovery history, snapshot availability and automation support' : 'List available database snapshots, including previous branches'))
|
|
91
|
+
.action(async (opts) => {
|
|
92
|
+
try {
|
|
93
|
+
const { client, projectId } = connection(opts);
|
|
94
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: `backup-${action}` }), null, 2));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
handleError(error);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
common(backup.command('create').description('Create a snapshot and wait for the provider to finish'))
|
|
102
|
+
.option('--name <name>', 'snapshot label (up to 80 characters)')
|
|
103
|
+
.option('--retention-hours <hours>', 'expires after 1 to 168 hours', '24')
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
try {
|
|
106
|
+
const hours = Number(opts.retentionHours);
|
|
107
|
+
if (!Number.isInteger(hours) || hours < 1 || hours > 168)
|
|
108
|
+
throw new Error('--retention-hours must be an integer from 1 to 168');
|
|
109
|
+
if (opts.name !== undefined && (!opts.name.trim() || opts.name.length > 80 || [...opts.name].some((char) => char.charCodeAt(0) < 32)))
|
|
110
|
+
throw new Error('--name must be 1 to 80 characters without control characters');
|
|
111
|
+
const { client, projectId } = connection(opts);
|
|
112
|
+
const data = await runDatabaseOperation(client, projectId, opts.env, { action: 'backup-create', name: opts.name, retention_seconds: hours * 3600 }, { onStarted: (id) => console.error(`Backup operation: ${id}`) });
|
|
113
|
+
console.log(JSON.stringify(data, null, 2));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
handleError(error);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const restore = db.command('restore').description('Prepare a restored copy, inspect it, and explicitly switch the database');
|
|
120
|
+
common(restore.command('prepare').argument('<snapshot-id>').description('Restore a snapshot into an isolated copy; keeps the live database in place'))
|
|
121
|
+
.option('--schemas <schemas>', 'comma-separated schemas whose migration ledgers to inspect', 'api,auth')
|
|
122
|
+
.action(async (snapshotId, opts) => {
|
|
123
|
+
try {
|
|
124
|
+
if (!/^[a-z0-9-]{1,60}$/.test(snapshotId))
|
|
125
|
+
throw new Error('Invalid snapshot id');
|
|
126
|
+
const { client, projectId } = connection(opts);
|
|
127
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-prepare', snapshot_id: snapshotId, schemas: opts.schemas.split(',').map((schema) => schema.trim()) }, { onStarted: (id) => console.error(`Restore preview operation: ${id}`) }), null, 2));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
handleError(error);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
common(restore.command('apply').argument('<preview-operation-id>').description('Switch the whole database to the prepared copy; discards later writes and restarts connections'))
|
|
134
|
+
.requiredOption('--confirm-project <id>', 'type the exact project id to confirm the database switch')
|
|
135
|
+
.option('--accept-data-loss', 'confirm that all writes since the snapshot can be lost')
|
|
136
|
+
.action(async (previewId, opts) => {
|
|
137
|
+
try {
|
|
138
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(previewId))
|
|
139
|
+
throw new Error('Invalid restore preview operation id');
|
|
140
|
+
const { client, projectId } = connection(opts);
|
|
141
|
+
if (opts.confirmProject !== projectId || opts.acceptDataLoss !== true)
|
|
142
|
+
throw new Error('Applying a restore requires --confirm-project matching the project id and --accept-data-loss');
|
|
143
|
+
const operationId = randomUUID();
|
|
144
|
+
const stepUp = opts.env === 'prod' ? await authorizeDatabaseAction(client, projectId, opts.env, 'restore-apply', operationId) : undefined;
|
|
145
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-apply', preview_operation_id: previewId, confirm_project: opts.confirmProject, accept_data_loss: true, step_up: stepUp }, { operationId, onStarted: (id) => console.error(`Restore apply operation: ${id}`) }), null, 2));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
handleError(error);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
common(restore.command('discard').argument('<preview-operation-id>').description('Delete an unapplied restored copy and its preview compute'))
|
|
152
|
+
.action(async (previewId, opts) => {
|
|
153
|
+
try {
|
|
154
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(previewId))
|
|
155
|
+
throw new Error('Invalid restore preview operation id');
|
|
156
|
+
const { client, projectId } = connection(opts);
|
|
157
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-discard', preview_operation_id: previewId }, { onStarted: (id) => console.error(`Restore discard operation: ${id}`) }), null, 2));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
handleError(error);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
common(restore.command('cleanup').argument('<preview-operation-id>').description('Remove the previous branch if an applied restore reported cleanup_required'))
|
|
164
|
+
.action(async (previewId, opts) => {
|
|
165
|
+
try {
|
|
166
|
+
const { client, projectId } = connection(opts);
|
|
167
|
+
const operationId = randomUUID();
|
|
168
|
+
const stepUp = await authorizeDatabaseAction(client, projectId, opts.env, 'restore-cleanup', operationId);
|
|
169
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-cleanup', preview_operation_id: previewId, step_up: stepUp }, { operationId }), null, 2));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
handleError(error);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const credentials = db.command('credentials').description('Manage the runtime owner credential shared by API and Auth');
|
|
176
|
+
common(credentials.command('rotate').description('Invalidate saved owner URLs and publish a new database password to API and Auth'))
|
|
177
|
+
.requiredOption('--confirm-project <id>', 'exact project id')
|
|
178
|
+
.option('--accept-connection-restart', 'accept that resetting the password restarts database connections')
|
|
179
|
+
.action(async (opts) => {
|
|
180
|
+
try {
|
|
181
|
+
const { client, projectId } = connection(opts);
|
|
182
|
+
if (opts.confirmProject !== projectId || !opts.acceptConnectionRestart)
|
|
183
|
+
throw new Error('Rotation requires --confirm-project matching the project and --accept-connection-restart');
|
|
184
|
+
const operationId = randomUUID();
|
|
185
|
+
const stepUp = await authorizeDatabaseAction(client, projectId, opts.env, 'credentials-rotate', operationId);
|
|
186
|
+
console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'credentials-rotate', confirm_project: projectId, accept_connection_restart: true, step_up: stepUp }, { operationId, onStarted: (id) => console.error(`Credential rotation operation: ${id}`) }), null, 2));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
handleError(error);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -106,6 +106,7 @@ export async function runBundleDeploy(projectId, client, dir, env, allServices,
|
|
|
106
106
|
secrets: s.secrets,
|
|
107
107
|
}));
|
|
108
108
|
const bundle = await buildDeployBundle({
|
|
109
|
+
projectId,
|
|
109
110
|
projectDir: dir,
|
|
110
111
|
services: bundleServices,
|
|
111
112
|
run: runCmd,
|
|
@@ -144,7 +145,7 @@ export function registerDeploy(program) {
|
|
|
144
145
|
.option('--region <region>', "Newly server region hint for first-time dev provisioning (defaults to 'auto')", 'auto')
|
|
145
146
|
.option('--project <id>', 'project id (defaults to the linked directory)')
|
|
146
147
|
.option('--service <names>', 'comma-separated service names from backend/newly.services.json')
|
|
147
|
-
.option('--git-sha <sha>', 'prod
|
|
148
|
+
.option('--git-sha <sha>', 'legacy prod deploys only; ignored when the project deploys your local backend/ folder', 'HEAD')
|
|
148
149
|
.option('--yes', 'skip the prod confirmation prompt', false)
|
|
149
150
|
.action(async (opts) => {
|
|
150
151
|
try {
|
package/dist/commands/link.js
CHANGED
|
@@ -10,6 +10,7 @@ export function registerLink(program) {
|
|
|
10
10
|
writeProjectLink(dir, {
|
|
11
11
|
projectId,
|
|
12
12
|
pulledSha: existing?.projectId === projectId ? existing.pulledSha : null,
|
|
13
|
+
pulledBackendHash: existing?.projectId === projectId ? existing.pulledBackendHash : undefined,
|
|
13
14
|
pulledAt: new Date().toISOString(),
|
|
14
15
|
});
|
|
15
16
|
console.log(`Linked ${dir} to project ${projectId}. You can now run \`newly deploy\`, \`newly status\`, etc. here.`);
|
package/dist/commands/misc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
2
2
|
import { requireCredentials, resolveProject } from '../config.js';
|
|
3
|
+
import { registerDatabaseHelpers } from './database.js';
|
|
3
4
|
export function assertDevOnly(env, command) {
|
|
4
5
|
if (env !== 'dev') {
|
|
5
6
|
throw new Error(`\`newly ${command}\` is dev-only from the CLI. Prod ${command} requires the Newly web app (a browser session).`);
|
|
@@ -85,8 +86,16 @@ export async function setSecret(client, projectId, env, service, key, value) {
|
|
|
85
86
|
value,
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
|
-
export async function getDbUrl(client, projectId, env) {
|
|
89
|
-
|
|
89
|
+
export async function getDbUrl(client, projectId, env, role = 'readonly') {
|
|
90
|
+
if (role === 'owner' || env === 'prod') {
|
|
91
|
+
const exportId = randomUUID();
|
|
92
|
+
const proof = await authorizeDatabaseAction(client, projectId, env, `${role}-export`, exportId);
|
|
93
|
+
const out = await client.post(`/newly_backend/database/${encodeURIComponent(projectId)}/connection`, { env, role, export_id: exportId, step_up: proof });
|
|
94
|
+
return out.database_url;
|
|
95
|
+
}
|
|
96
|
+
const out = await client.get(env === 'dev' && role === 'readonly'
|
|
97
|
+
? `/newly_backend/readonly_db_url?project_id=${encodeURIComponent(projectId)}&env=${env}`
|
|
98
|
+
: `/newly_backend/database/${encodeURIComponent(projectId)}/connection?env=${env}&role=${role}`);
|
|
90
99
|
return out.database_url ?? out.url ?? JSON.stringify(out);
|
|
91
100
|
}
|
|
92
101
|
function makeClient(project) {
|
|
@@ -162,15 +171,19 @@ export function registerMisc(program) {
|
|
|
162
171
|
}
|
|
163
172
|
});
|
|
164
173
|
const db = program.command('db').description('Database helpers');
|
|
174
|
+
registerDatabaseHelpers(db);
|
|
165
175
|
db.command('url')
|
|
166
|
-
.description('Print a
|
|
176
|
+
.description('Print a direct database connection URL; owner role permits database management')
|
|
167
177
|
.option('--env <env>', 'dev or prod', 'dev')
|
|
178
|
+
.option('--role <role>', 'readonly or owner (owner export requires the project owner’s user credential)', 'readonly')
|
|
168
179
|
.option('--project <id>', 'project id')
|
|
169
180
|
.action(async (opts) => {
|
|
170
181
|
try {
|
|
171
|
-
|
|
182
|
+
assertKnownEnv(opts.env, 'db url');
|
|
183
|
+
if (opts.role !== 'readonly' && opts.role !== 'owner')
|
|
184
|
+
throw new Error('--role must be readonly or owner');
|
|
172
185
|
const { client, projectId } = makeClient(opts.project);
|
|
173
|
-
console.log(await getDbUrl(client, projectId, opts.env));
|
|
186
|
+
console.log(await getDbUrl(client, projectId, opts.env, opts.role));
|
|
174
187
|
}
|
|
175
188
|
catch (e) {
|
|
176
189
|
console.error(friendlyApiError(e));
|
|
@@ -178,3 +191,5 @@ export function registerMisc(program) {
|
|
|
178
191
|
}
|
|
179
192
|
});
|
|
180
193
|
}
|
|
194
|
+
import { randomUUID } from 'node:crypto';
|
|
195
|
+
import { authorizeDatabaseAction } from '../database-authorization.js';
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { ApiClient, friendlyApiError } from '../api.js';
|
|
3
4
|
import { requireCredentials, resolveProject, writeProjectLink } from '../config.js';
|
|
4
|
-
import { extractSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
|
|
5
|
+
import { extractSnapshot, listSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
|
|
6
|
+
import { hashBackendSource } from '../deploy-bundle.js';
|
|
5
7
|
export function assertPullTarget(dir, force) {
|
|
6
8
|
const entries = readdirSync(dir).filter((e) => e !== '.newly');
|
|
7
9
|
if (entries.length > 0 && !force) {
|
|
@@ -38,10 +40,12 @@ export function registerPull(program) {
|
|
|
38
40
|
}
|
|
39
41
|
await extractTarball(data, dir);
|
|
40
42
|
const pulledBranch = res.headers.get('x-newly-branch');
|
|
43
|
+
const pulledPaths = new Set((await listSnapshot(data)).map((name) => name.replace(/^\.?\//, '')));
|
|
41
44
|
writeProjectLink(dir, {
|
|
42
45
|
projectId,
|
|
43
46
|
pulledSha: res.headers.get('x-newly-head-sha'),
|
|
44
47
|
pulledAt: new Date().toISOString(),
|
|
48
|
+
pulledBackendHash: existsSync(join(dir, 'backend')) ? hashBackendSource(dir, pulledPaths) : undefined,
|
|
45
49
|
});
|
|
46
50
|
console.log(`Pulled${pulledBranch ? ` branch ${pulledBranch}` : ''} into ${dir} and linked to project ${projectId}.`);
|
|
47
51
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
export async function authorizeDatabaseAction(client, projectId, env, action, resourceId, readCode) {
|
|
3
|
+
const response = await client.post(`/newly_backend/database/${encodeURIComponent(projectId)}/step-up`, { env, action, resource_id: resourceId });
|
|
4
|
+
if (typeof response.challenge_id !== 'string')
|
|
5
|
+
throw new Error('Database authorization did not return a challenge id');
|
|
6
|
+
console.error(`Database authorization: ${response.challenge_id}`);
|
|
7
|
+
let code;
|
|
8
|
+
if (readCode)
|
|
9
|
+
code = await readCode();
|
|
10
|
+
else {
|
|
11
|
+
const input = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
|
|
12
|
+
try {
|
|
13
|
+
code = await input.question('Enter the verification code sent to your account email: ');
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
input.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
code = code.trim();
|
|
20
|
+
if (!/^\d{6}$/.test(code))
|
|
21
|
+
throw new Error('A six-digit email code is required. Credential export and production restore require fresh authorization.');
|
|
22
|
+
return { challenge_id: response.challenge_id, code };
|
|
23
|
+
}
|
package/dist/deploy-bundle.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { Buffer } from 'node:buffer';
|
|
6
7
|
import { create as tarCreate } from 'tar';
|
|
8
|
+
import { readProjectLink } from './config.js';
|
|
7
9
|
export const NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE = 'application/vnd.newly.deploy-bundle+gzip';
|
|
8
10
|
export const NEWLY_BACKEND_DEPLOY_MODE_BUNDLE = 'bundle';
|
|
9
11
|
const MANIFEST_PATH = 'newly-deploy.json';
|
|
@@ -17,7 +19,7 @@ export class DeployBuildError extends Error {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
export async function buildDeployBundle(options) {
|
|
20
|
-
const { projectDir, services, run, log } = options;
|
|
22
|
+
const { projectId, projectDir, services, run, log } = options;
|
|
21
23
|
const bunVersion = await run('bun', ['--version'], projectDir);
|
|
22
24
|
if (bunVersion.code !== 0) {
|
|
23
25
|
throw new DeployBuildError('newly deploy needs bun on your PATH (https://bun.sh)');
|
|
@@ -26,11 +28,11 @@ export async function buildDeployBundle(options) {
|
|
|
26
28
|
const servicesDir = join(bundleDir, 'services');
|
|
27
29
|
mkdirSync(servicesDir, { recursive: true });
|
|
28
30
|
try {
|
|
31
|
+
const gitSha = await resolveGitSha(run, projectDir, projectId);
|
|
29
32
|
const manifestServices = [];
|
|
30
33
|
for (const service of services) {
|
|
31
34
|
manifestServices.push(await buildService(service, projectDir, servicesDir, run, log));
|
|
32
35
|
}
|
|
33
|
-
const gitSha = await resolveGitSha(run, projectDir);
|
|
34
36
|
const manifest = { version: 1, git_sha: gitSha, services: manifestServices };
|
|
35
37
|
writeFileSync(join(bundleDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2));
|
|
36
38
|
const bundle = await tarBundle(bundleDir);
|
|
@@ -110,12 +112,40 @@ function stageMigrations(dir, serviceDir, name) {
|
|
|
110
112
|
}
|
|
111
113
|
return `services/${name}/drizzle`;
|
|
112
114
|
}
|
|
113
|
-
async function resolveGitSha(run, projectDir) {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
115
|
+
async function resolveGitSha(run, projectDir, projectId) {
|
|
116
|
+
const link = readProjectLink(projectDir);
|
|
117
|
+
if (link?.projectId === projectId && link.pulledSha && link.pulledBackendHash) {
|
|
118
|
+
try {
|
|
119
|
+
if (link.pulledBackendHash === hashBackendSource(projectDir))
|
|
120
|
+
return link.pulledSha;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const head = await run('git', ['rev-parse', 'HEAD'], projectDir);
|
|
126
|
+
const sha = head.code === 0 ? head.output.trim() : '';
|
|
127
|
+
if (!sha)
|
|
128
|
+
return null;
|
|
129
|
+
const tracked = await run('git', ['ls-files', '--error-unmatch', '--', 'backend'], projectDir);
|
|
130
|
+
if (tracked.code !== 0)
|
|
116
131
|
return null;
|
|
117
|
-
const
|
|
118
|
-
return sha
|
|
132
|
+
const changes = await run('git', ['--no-optional-locks', 'status', '--porcelain', '--', 'backend'], projectDir);
|
|
133
|
+
return changes.code === 0 && changes.output.trim() === '' ? sha : null;
|
|
134
|
+
}
|
|
135
|
+
export function hashBackendSource(projectDir, only) {
|
|
136
|
+
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
137
|
+
const files = backendSourceFiles(projectDir, 'backend').filter((path) => !only || only.has(path));
|
|
138
|
+
return digest(files.sort().map((path) => `${path}\0${digest(readFileSync(join(projectDir, path)))}`).join('\n'));
|
|
139
|
+
}
|
|
140
|
+
function backendSourceFiles(projectDir, rel) {
|
|
141
|
+
return readdirSync(join(projectDir, rel), { withFileTypes: true }).flatMap((entry) => {
|
|
142
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
143
|
+
return [];
|
|
144
|
+
const path = `${rel}/${entry.name}`;
|
|
145
|
+
if (entry.isDirectory())
|
|
146
|
+
return backendSourceFiles(projectDir, path);
|
|
147
|
+
return entry.isFile() ? [path] : [];
|
|
148
|
+
});
|
|
119
149
|
}
|
|
120
150
|
async function tarBundle(bundleDir) {
|
|
121
151
|
const chunks = [];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { readBackendServices, assertKnownServices, parseServiceFilter } from './services-manifest.js';
|
|
5
|
+
export function readLocalMigrations(dir, servicesFilter) {
|
|
6
|
+
const services = readBackendServices(dir);
|
|
7
|
+
const selected = parseServiceFilter(servicesFilter);
|
|
8
|
+
assertKnownServices(services, selected);
|
|
9
|
+
const root = resolve(dir, 'backend');
|
|
10
|
+
const seenSchemas = new Set();
|
|
11
|
+
return services.filter((service) => !selected.length || selected.includes(service.name)).flatMap((service) => {
|
|
12
|
+
const serviceDir = resolve(root, service.path);
|
|
13
|
+
const path = relative(root, serviceDir);
|
|
14
|
+
if (path.startsWith('..') || path === '')
|
|
15
|
+
throw new Error(`Service ${service.name} must have a path inside backend/`);
|
|
16
|
+
const drizzle = join(serviceDir, 'drizzle');
|
|
17
|
+
const journal = join(drizzle, 'meta', '_journal.json');
|
|
18
|
+
if (!existsSync(journal)) {
|
|
19
|
+
if (service.schema)
|
|
20
|
+
throw new Error(`Service ${service.name} declares a schema but has no migration journal`);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
if (!service.schema || !/^[a-z_][a-z0-9_]{0,62}$/.test(service.schema) || service.schema.startsWith('pg_') || service.schema === 'information_schema')
|
|
24
|
+
throw new Error(`Service ${service.name} needs a valid migration schema`);
|
|
25
|
+
if (seenSchemas.has(service.schema))
|
|
26
|
+
throw new Error(`More than one migration journal targets schema ${service.schema}`);
|
|
27
|
+
seenSchemas.add(service.schema);
|
|
28
|
+
const parsed = JSON.parse(readFileSync(journal, 'utf8'));
|
|
29
|
+
if (!Array.isArray(parsed.entries))
|
|
30
|
+
throw new Error(`${journal} has no entries array`);
|
|
31
|
+
let previous = -1;
|
|
32
|
+
const tags = new Set();
|
|
33
|
+
const migrations = parsed.entries.map((entry) => {
|
|
34
|
+
if (typeof entry.tag !== 'string' || !/^[A-Za-z0-9_-]+$/.test(entry.tag) || tags.has(entry.tag) || typeof entry.when !== 'number' || !Number.isSafeInteger(entry.when) || entry.when <= previous)
|
|
35
|
+
throw new Error(`Invalid or unordered migration journal for ${service.name}`);
|
|
36
|
+
tags.add(entry.tag);
|
|
37
|
+
previous = entry.when;
|
|
38
|
+
const sql = readFileSync(join(drizzle, `${entry.tag}.sql`), 'utf8');
|
|
39
|
+
return { tag: entry.tag, when: entry.when, hash: createHash('sha256').update(sql).digest('hex') };
|
|
40
|
+
});
|
|
41
|
+
return [{ service: service.name, schema: service.schema, migrations }];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function compareMigrations(local, ledger) {
|
|
45
|
+
const expected = new Map(local.migrations.map((migration) => [migration.when, migration]));
|
|
46
|
+
const applied = new Set(ledger.rows.map((row) => Number(row.created_at)));
|
|
47
|
+
const head = ledger.rows.length ? Math.max(...applied) : -1;
|
|
48
|
+
const problems = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const rows = ledger.rows.map((row) => {
|
|
51
|
+
const when = Number(row.created_at);
|
|
52
|
+
if (!Number.isSafeInteger(when) || when < 0)
|
|
53
|
+
problems.push(`Invalid ledger timestamp: ${row.created_at}`);
|
|
54
|
+
if (seen.has(when))
|
|
55
|
+
problems.push(`Duplicate ledger timestamp: ${row.created_at}`);
|
|
56
|
+
seen.add(when);
|
|
57
|
+
const migration = expected.get(when);
|
|
58
|
+
if (!migration)
|
|
59
|
+
problems.push(`Applied migration is absent from this checkout: ${row.created_at}`);
|
|
60
|
+
else if (migration.hash !== row.hash)
|
|
61
|
+
problems.push(`Applied SQL hash differs: ${migration.tag}`);
|
|
62
|
+
return { ...row, tag: migration?.tag ?? null, hash_matches: migration ? migration.hash === row.hash : null };
|
|
63
|
+
});
|
|
64
|
+
const pending = local.migrations.filter((migration) => !applied.has(migration.when));
|
|
65
|
+
for (const migration of pending) {
|
|
66
|
+
if (migration.when <= head)
|
|
67
|
+
problems.push(`Migration was skipped before the applied head: ${migration.tag}`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
service: local.service, schema: local.schema, ledger_exists: ledger.exists,
|
|
71
|
+
applied: rows, pending: pending.map((migration) => migration.tag), problems,
|
|
72
|
+
would_execute: problems.length ? null : pending.map((migration) => migration.tag),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.2.
|
|
1
|
+
export const CLI_VERSION = '0.2.165';
|