@everystack/cli 0.3.8 → 0.3.9
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/package.json +5 -1
- package/src/cli/commands/db-generate.ts +15 -4
- package/src/cli/commands/db-pull.ts +34 -8
- package/src/cli/db-source.ts +77 -0
- package/src/cli/index.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -87,10 +87,14 @@
|
|
|
87
87
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
88
88
|
"drizzle-orm": "0.41.0",
|
|
89
89
|
"expo-updates": "55.0.21",
|
|
90
|
+
"postgres": "3.4.9",
|
|
90
91
|
"react": "19.2.0",
|
|
91
92
|
"react-native": "0.83.6"
|
|
92
93
|
},
|
|
93
94
|
"peerDependenciesMeta": {
|
|
95
|
+
"postgres": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
94
98
|
"@everystack/server": {
|
|
95
99
|
"optional": true
|
|
96
100
|
},
|
|
@@ -27,6 +27,7 @@ import { compileModuleMigration } from '../migration-compile.js';
|
|
|
27
27
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
28
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
30
31
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
31
32
|
import { invokeAction } from '../aws.js';
|
|
32
33
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -152,17 +153,27 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
152
153
|
// updates it. The drift-guard test asserts this file matches the models.
|
|
153
154
|
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
154
155
|
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const dbSource: DbSource = resolveDbSource(flags);
|
|
157
|
+
let runner: QueryRunner;
|
|
158
|
+
let end: (() => Promise<void>) | undefined;
|
|
159
|
+
if (dbSource.kind === 'url') {
|
|
160
|
+
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
161
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
162
|
+
} else {
|
|
163
|
+
step('Resolving deployed config...');
|
|
164
|
+
const config = await resolveConfig(flags.stage);
|
|
165
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
166
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
167
|
+
}
|
|
159
168
|
step('Introspecting live database (columns + constraints)...');
|
|
160
169
|
current = await introspectSchema(runner);
|
|
161
170
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
162
171
|
liveAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
172
|
+
await end?.();
|
|
163
173
|
} catch (err: any) {
|
|
164
174
|
fail(err.message);
|
|
165
175
|
info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
176
|
+
info('Diffing against a local database instead? Pass --database-url or set DATABASE_URL.');
|
|
166
177
|
process.exit(1);
|
|
167
178
|
}
|
|
168
179
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
3
|
*
|
|
4
|
-
* db:pull --stage <name> [--schema public] [--out models/index.ts]
|
|
4
|
+
* db:pull [--stage <name> | --database-url <url>] [--schema public] [--out models/index.ts]
|
|
5
5
|
*
|
|
6
6
|
* The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
|
|
7
7
|
* database. It introspects the deployed schema through the read-only ops Lambda `db:query`
|
|
8
|
-
* action
|
|
9
|
-
*
|
|
8
|
+
* action — or, for a database no Lambda can reach (the brownfield case where the target
|
|
9
|
+
* schema exists only locally), directly via `--database-url` / an inherited `DATABASE_URL`
|
|
10
|
+
* (see db-source.ts for precedence) — and renders the TypeScript a Model is written in — a
|
|
11
|
+
* starting point you review and refine (it flags, as inline comments, anything the field
|
|
12
|
+
* builder can't yet express).
|
|
10
13
|
*
|
|
11
14
|
* The rendered source is the artifact: it goes to stdout (so `db:pull > models.ts` works) or
|
|
12
15
|
* to `--out`. Every progress/diagnostic line goes to stderr, so the stdout pipe stays pure
|
|
@@ -18,6 +21,7 @@ import path from 'node:path';
|
|
|
18
21
|
import { introspectSchema } from '../schema-introspect.js';
|
|
19
22
|
import { renderModelSource } from '../model-render.js';
|
|
20
23
|
import type { QueryRunner } from '../authz-contract.js';
|
|
24
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
21
25
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
22
26
|
import { invokeAction } from '../aws.js';
|
|
23
27
|
import { fail } from '../output.js';
|
|
@@ -41,16 +45,38 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
41
45
|
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
42
46
|
const schema = flags.schema || 'public';
|
|
43
47
|
|
|
48
|
+
let dbSource: DbSource;
|
|
49
|
+
try {
|
|
50
|
+
dbSource = resolveDbSource(flags);
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
fail(err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
let current;
|
|
45
57
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
let runner: QueryRunner;
|
|
59
|
+
let end: (() => Promise<void>) | undefined;
|
|
60
|
+
if (dbSource.kind === 'url') {
|
|
61
|
+
note(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
62
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
63
|
+
} else {
|
|
64
|
+
note('Resolving deployed config...');
|
|
65
|
+
const config = await resolveConfig(flags.stage);
|
|
66
|
+
detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
67
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
68
|
+
}
|
|
49
69
|
note(`Introspecting live database (schema: ${schema})...`);
|
|
50
|
-
current = await introspectSchema(
|
|
70
|
+
current = await introspectSchema(runner);
|
|
71
|
+
await end?.();
|
|
51
72
|
} catch (err: any) {
|
|
52
73
|
fail(err.message);
|
|
53
|
-
|
|
74
|
+
if (dbSource.kind === 'url') {
|
|
75
|
+
detail('Check the connection string and that the database is accepting connections.');
|
|
76
|
+
} else {
|
|
77
|
+
detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
78
|
+
detail('Introspecting a local database instead? Pass --database-url or set DATABASE_URL.');
|
|
79
|
+
}
|
|
54
80
|
process.exit(1);
|
|
55
81
|
}
|
|
56
82
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-source — where db:pull / db:generate find the database.
|
|
3
|
+
*
|
|
4
|
+
* Two paths to the same QueryRunner contract:
|
|
5
|
+
*
|
|
6
|
+
* - **Deployed** (the default): introspection SQL runs through the read-only ops Lambda
|
|
7
|
+
* `db:query` action. Credentials never leave AWS; works for any deployed stage.
|
|
8
|
+
* - **Direct** (`--database-url` or an inherited `DATABASE_URL`): a postgres.js
|
|
9
|
+
* connection from this process. This is the brownfield on-ramp's missing half — when
|
|
10
|
+
* the target schema exists only on a local Postgres (migrations applied locally, no
|
|
11
|
+
* interim deploy), there is no Lambda to ask.
|
|
12
|
+
*
|
|
13
|
+
* Precedence is explicit-over-ambient: `--database-url` beats an explicit `--stage`,
|
|
14
|
+
* which beats an inherited `DATABASE_URL`, which beats the default stage. An explicit
|
|
15
|
+
* `--stage` deliberately wins over the env var so a shell with DATABASE_URL exported
|
|
16
|
+
* still introspects the stage you named.
|
|
17
|
+
*
|
|
18
|
+
* The driver is loaded lazily: `postgres` ships with @everystack/server (every deployed
|
|
19
|
+
* consumer has it hoisted), so the deployed path never pays the import and a missing
|
|
20
|
+
* driver only surfaces — with instructions — when the direct path is actually used.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
24
|
+
|
|
25
|
+
export type DbSource =
|
|
26
|
+
| { kind: 'url'; url: string; from: 'flag' | 'env' }
|
|
27
|
+
| { kind: 'stage' };
|
|
28
|
+
|
|
29
|
+
/** Decide which path serves this invocation. Pure; env injectable for tests. */
|
|
30
|
+
export function resolveDbSource(
|
|
31
|
+
flags: Record<string, string>,
|
|
32
|
+
env: Record<string, string | undefined> = process.env,
|
|
33
|
+
): DbSource {
|
|
34
|
+
const flagUrl = flags['database-url'];
|
|
35
|
+
if (flagUrl) {
|
|
36
|
+
// parseFlags encodes a value-less flag as 'true'
|
|
37
|
+
if (flagUrl === 'true') {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'--database-url needs a connection string (or omit the flag and set DATABASE_URL in the environment).',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { kind: 'url', url: flagUrl, from: 'flag' };
|
|
43
|
+
}
|
|
44
|
+
if (flags.stage) return { kind: 'stage' };
|
|
45
|
+
if (env.DATABASE_URL) return { kind: 'url', url: env.DATABASE_URL, from: 'env' };
|
|
46
|
+
return { kind: 'stage' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UrlRunner {
|
|
50
|
+
runner: QueryRunner;
|
|
51
|
+
/** Close the client so the process can exit cleanly. */
|
|
52
|
+
end: () => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A QueryRunner over a direct postgres.js connection. One connection is enough —
|
|
57
|
+
* introspection is a handful of sequential catalog queries.
|
|
58
|
+
*/
|
|
59
|
+
export async function createUrlRunner(
|
|
60
|
+
url: string,
|
|
61
|
+
load: () => Promise<any> = () => import('postgres'),
|
|
62
|
+
): Promise<UrlRunner> {
|
|
63
|
+
let mod: any;
|
|
64
|
+
try {
|
|
65
|
+
mod = await load();
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'The direct-connection path needs the "postgres" driver. It ships with @everystack/server; in a project without it: pnpm add -D postgres',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const postgres = mod.default ?? mod;
|
|
72
|
+
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
|
73
|
+
return {
|
|
74
|
+
runner: async (query: string) => Array.from(await sql.unsafe(query)),
|
|
75
|
+
end: () => sql.end({ timeout: 5 }),
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -95,6 +95,13 @@ async function main() {
|
|
|
95
95
|
|
|
96
96
|
const flags = parseFlags(args.slice(1));
|
|
97
97
|
|
|
98
|
+
// `everystack <command> --help` shows help instead of running the command
|
|
99
|
+
// (a db command falling through to the default stage is a footgun).
|
|
100
|
+
if ('help' in flags || 'h' in flags) {
|
|
101
|
+
printHelp();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
98
105
|
// Auto-detect deployed URL from SST outputs
|
|
99
106
|
await loadSstOutputs(flags.stage);
|
|
100
107
|
|
|
@@ -280,8 +287,9 @@ Usage:
|
|
|
280
287
|
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
281
288
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
282
289
|
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
283
|
-
everystack db:generate [--stage <name>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
284
|
-
everystack db:pull [--stage <name>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
|
|
290
|
+
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
291
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
|
|
292
|
+
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
285
293
|
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
286
294
|
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
287
295
|
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|