runcloud 0.2.162 → 0.2.164
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/appStoreEvaluate.js +29 -10
- package/dist/commands/database.js +192 -0
- package/dist/commands/misc.js +20 -5
- package/dist/database-authorization.js +23 -0
- package/dist/migration-plan.js +74 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runcloud-0.2.164.tgz +0 -0
- package/runcloud-0.2.162.tgz +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const PREVIEW_APP_ID = 'app.newly.preview';
|
|
4
|
+
const SCAFFOLD_SCHEME = 'expobase';
|
|
5
|
+
const INBOUND_LINK = /Linking\.createURL|makeRedirectUri|emailRedirectTo|\bredirectTo\s*:|expobase:\/\//;
|
|
4
6
|
const PLACEHOLDER_COPY = /lorem ipsum|example\.com|\bTBD\b|\bTODO\b|\bplaceholder\b/i;
|
|
5
7
|
const INPUT_PLACEHOLDER = /\bplaceholder\??\s*[=:]\s*(\{[^}]*\}|"[^"]*"|'[^']*'|`[^`]*`|[\w.$]+)/gi;
|
|
6
8
|
const PLACEHOLDER_NAME = /\.placeholder\b|\bplaceholder\b(?=\s*[,})\]])/gi;
|
|
@@ -45,6 +47,9 @@ const LIVE_STORE_IDS = new Set([
|
|
|
45
47
|
'apple_content_rights',
|
|
46
48
|
'apple_age_rating',
|
|
47
49
|
'apple_privacy_label',
|
|
50
|
+
'apple_review_notes',
|
|
51
|
+
'apple_review_contact',
|
|
52
|
+
'apple_review_recording',
|
|
48
53
|
'apple_eu_trader_status',
|
|
49
54
|
'google_play_title',
|
|
50
55
|
'google_play_short_description',
|
|
@@ -87,6 +92,7 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
87
92
|
'.swift',
|
|
88
93
|
'.ts',
|
|
89
94
|
'.tsx',
|
|
95
|
+
'.xcprivacy',
|
|
90
96
|
'.xml',
|
|
91
97
|
]);
|
|
92
98
|
const SKIP_DIRS = new Set([
|
|
@@ -155,6 +161,8 @@ function activeItems(manifest) {
|
|
|
155
161
|
return manifest.items.filter((item) => !item.deferred && item.resolvedBy !== 'card');
|
|
156
162
|
}
|
|
157
163
|
function evaluateLocalItem(root, appJson, item) {
|
|
164
|
+
if (item.id === 'scheme')
|
|
165
|
+
return evaluateScheme(root, appJson, item);
|
|
158
166
|
const rule = CONFIG_RULES[item.id];
|
|
159
167
|
if (rule)
|
|
160
168
|
return evaluateConfigItem(appJson, item, rule);
|
|
@@ -201,7 +209,7 @@ function evaluateLocalItem(root, appJson, item) {
|
|
|
201
209
|
case 'permission_descriptions':
|
|
202
210
|
return evaluatePermissionDescriptions(root, appJson, item);
|
|
203
211
|
case 'privacy_manifest':
|
|
204
|
-
return evaluatePrivacyManifest(root, item);
|
|
212
|
+
return evaluatePrivacyManifest(root, appJson, item);
|
|
205
213
|
default:
|
|
206
214
|
if (STORE_REMINDER_IDS.has(item.id)) {
|
|
207
215
|
return makeItem(item, 'info', 'Complete this declaration in Google Play Console.', 'Play Console questionnaire');
|
|
@@ -226,6 +234,14 @@ function evaluateConfigItem(appJson, item, rule) {
|
|
|
226
234
|
return makeItem(item, 'pass', `${item.label} is set.`, evidence);
|
|
227
235
|
return makeItem(item, rule.absent, `${item.label} is not set.`, evidence);
|
|
228
236
|
}
|
|
237
|
+
function evaluateScheme(root, appJson, item) {
|
|
238
|
+
const value = getPath(appJson, ['expo', 'scheme']);
|
|
239
|
+
const schemes = typeof value === 'string' ? [value] : arrayOfStrings(value);
|
|
240
|
+
const shared = schemes.includes(SCAFFOLD_SCHEME) ? scan(root, INBOUND_LINK, { dirs: ['app', 'src', 'components'] }) : [];
|
|
241
|
+
if (!shared.length)
|
|
242
|
+
return evaluateConfigItem(appJson, item, CONFIG_RULES['scheme']);
|
|
243
|
+
return makeItem(item, 'warning', `This app still uses the link name every new Newly app starts with (${SCAFFOLD_SCHEME}). A link back into the app, such as a sign-in email, can open another Newly-built app on the same phone. Give it its own scheme, and update any redirect address saved with your sign-in provider. The next preview then takes several minutes to rebuild.`, `app.json expo.scheme = ${JSON.stringify(value)}\n${evidence(shared)}`);
|
|
244
|
+
}
|
|
229
245
|
function evaluateEncryption(appJson, item) {
|
|
230
246
|
const plist = getPath(appJson, ['expo', 'ios', 'infoPlist', 'ITSAppUsesNonExemptEncryption']);
|
|
231
247
|
const config = getPath(appJson, ['expo', 'ios', 'config', 'usesNonExemptEncryption']);
|
|
@@ -289,16 +305,19 @@ function evaluatePermissionDescriptions(root, appJson, item) {
|
|
|
289
305
|
return makeItem(item, 'pass', 'Used iOS capabilities have usage descriptions.', 'app.json ios.infoPlist cross-check');
|
|
290
306
|
return makeItem(item, 'fail', 'Used iOS capability is missing an NS*UsageDescription.', missing.join('; '));
|
|
291
307
|
}
|
|
292
|
-
function evaluatePrivacyManifest(root, item) {
|
|
293
|
-
|
|
294
|
-
const requiredReasonApi = scan(root, /NSUserDefaults|systemUptime|fileModificationDate|diskSpace|activeInputModes/i);
|
|
295
|
-
const tracking = scan(root, TRACKING_SDK);
|
|
296
|
-
if (hasManifest)
|
|
308
|
+
function evaluatePrivacyManifest(root, appJson, item) {
|
|
309
|
+
if (findByName(root, 'PrivacyInfo.xcprivacy').length > 0)
|
|
297
310
|
return makeItem(item, 'pass', 'PrivacyInfo.xcprivacy is present.', 'scan: PrivacyInfo.xcprivacy found');
|
|
298
|
-
if (
|
|
299
|
-
return makeItem(item, '
|
|
300
|
-
|
|
301
|
-
|
|
311
|
+
if (Object.keys(objectValue(getPath(appJson, ['expo', 'ios', 'privacyManifests']))).length > 0)
|
|
312
|
+
return makeItem(item, 'pass', 'app.json declares the privacy manifest.', 'app.json expo.ios.privacyManifests is set');
|
|
313
|
+
const tracking = scan(root, TRACKING_SDK);
|
|
314
|
+
if (tracking.length) {
|
|
315
|
+
return makeItem(item, 'warning', 'This app uses a tracking SDK and app.json declares no privacy manifest. Add expo.ios.privacyManifests with NSPrivacyTracking and the domains the SDK tracks through, so the manifest matches your App Privacy answers.', evidence(tracking));
|
|
316
|
+
}
|
|
317
|
+
const requiredReasonApi = scan(root, /NSUserDefaults|systemUptime|fileModificationDate|diskSpace|activeInputModes/i);
|
|
318
|
+
if (requiredReasonApi.length) {
|
|
319
|
+
return makeItem(item, 'warning', 'This code uses an API Apple wants a reason for, and app.json declares none. Add the reason under expo.ios.privacyManifests (NSPrivacyAccessedAPITypes).', evidence(requiredReasonApi));
|
|
320
|
+
}
|
|
302
321
|
return makeItem(item, 'pass', 'No required-reason API patterns found and no tracking SDK patterns found.', 'scan: no required privacy manifest signals found');
|
|
303
322
|
}
|
|
304
323
|
function patternItem(root, item, regex, statusOnHit, messageOnHit, messageOnMiss, options = {}) {
|
|
@@ -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/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';
|
|
@@ -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
|
+
}
|
|
@@ -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.164';
|
package/package.json
CHANGED
|
Binary file
|
package/runcloud-0.2.162.tgz
DELETED
|
Binary file
|