@everystack/cli 0.3.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.7",
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>",
@@ -65,22 +65,16 @@
65
65
  "bin": {
66
66
  "everystack": "./src/cli/index.ts"
67
67
  },
68
- "scripts": {
69
- "test": "jest",
70
- "build": "tsc --build",
71
- "prepublishOnly": "tsc --module commonjs --moduleResolution node --target ES2022 --esModuleInterop --skipLibCheck --declaration false --outDir src src/env.ts",
72
- "lint": "tsc --noEmit"
73
- },
74
68
  "dependencies": {
75
69
  "@aws-sdk/client-cloudfront": "3.1053.0",
76
70
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
77
71
  "@aws-sdk/signature-v4a": "3.1048.0",
78
- "@everystack/model": "workspace:*",
79
72
  "glob": "13.0.6",
80
73
  "node-forge": "1.4.0",
81
74
  "structured-headers": "1.0.1",
82
75
  "tsx": "4.21.0",
83
- "typescript": "5.9.3"
76
+ "typescript": "5.9.3",
77
+ "@everystack/model": "0.3.3"
84
78
  },
85
79
  "peerDependencies": {
86
80
  "@everystack/server": ">=0.1.0",
@@ -93,10 +87,14 @@
93
87
  "@aws-sdk/s3-request-presigner": "3.1053.0",
94
88
  "drizzle-orm": "0.41.0",
95
89
  "expo-updates": "55.0.21",
90
+ "postgres": "3.4.9",
96
91
  "react": "19.2.0",
97
92
  "react-native": "0.83.6"
98
93
  },
99
94
  "peerDependenciesMeta": {
95
+ "postgres": {
96
+ "optional": true
97
+ },
100
98
  "@everystack/server": {
101
99
  "optional": true
102
100
  },
@@ -147,5 +145,10 @@
147
145
  "jest": "29.7.0",
148
146
  "react": "19.2.0",
149
147
  "ts-jest": "29.4.9"
148
+ },
149
+ "scripts": {
150
+ "test": "jest",
151
+ "build": "tsc --build",
152
+ "lint": "tsc --noEmit"
150
153
  }
151
- }
154
+ }
@@ -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
- step('Resolving deployed config...');
156
- const config = await resolveConfig(flags.stage);
157
- info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
158
- const runner = lambdaRunner(config.region, opsFunction(config));
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 and renders the TypeScript a Model is written in a starting point you review and
9
- * refine (it flags, as inline comments, anything the field builder can't yet express).
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
- note('Resolving deployed config...');
47
- const config = await resolveConfig(flags.stage);
48
- detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
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(lambdaRunner(config.region, opsFunction(config)));
70
+ current = await introspectSchema(runner);
71
+ await end?.();
51
72
  } catch (err: any) {
52
73
  fail(err.message);
53
- detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
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)