@everystack/cli 0.3.12 → 0.3.13
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
|
@@ -12,6 +12,14 @@ import path from 'node:path';
|
|
|
12
12
|
|
|
13
13
|
export type Tier = 'V1' | 'V2' | 'V3';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How this app's migrations actually happen — detection, not aspiration.
|
|
17
|
+
* The discriminator for 'everystack' is the db:generate artifact
|
|
18
|
+
* (db/schema.generated.ts), not the presence of drizzle.config.ts: the
|
|
19
|
+
* reference app keeps legacy drizzle-kit tooling alongside the generated flow.
|
|
20
|
+
*/
|
|
21
|
+
export type MigrationMode = 'everystack' | 'transitional' | 'hand-authored';
|
|
22
|
+
|
|
15
23
|
export interface ProjectReality {
|
|
16
24
|
root: string;
|
|
17
25
|
/** package.json name. */
|
|
@@ -36,6 +44,10 @@ export interface ProjectReality {
|
|
|
36
44
|
hasExpoApp: boolean;
|
|
37
45
|
/** Relative migrations dir ('drizzle' or 'db/migrations') or null. */
|
|
38
46
|
migrationsDir: string | null;
|
|
47
|
+
/** The detected migration story, or null when the app has none yet. */
|
|
48
|
+
migrationMode: MigrationMode | null;
|
|
49
|
+
/** package.json scripts, in declaration order. */
|
|
50
|
+
scripts: Record<string, string>;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
const V2_PACKAGES = new Set(['api', 'auth', 'admin', 'logging', 'security', 'query']);
|
|
@@ -111,6 +123,24 @@ export function detectProjectReality(root: string): ProjectReality {
|
|
|
111
123
|
? 'db/migrations'
|
|
112
124
|
: null;
|
|
113
125
|
|
|
126
|
+
const hasModels = models.files.length > 0 || models.hasIndex;
|
|
127
|
+
const hasGeneratedSchema = isFile(path.join(abs, 'db', 'schema.generated.ts'));
|
|
128
|
+
const hasHandTooling = isFile(path.join(abs, 'drizzle.config.ts'));
|
|
129
|
+
const migrationMode: MigrationMode | null = hasModels
|
|
130
|
+
? hasGeneratedSchema || (!migrationsDir && !hasHandTooling)
|
|
131
|
+
? 'everystack'
|
|
132
|
+
: 'transitional'
|
|
133
|
+
: migrationsDir || hasHandTooling
|
|
134
|
+
? 'hand-authored'
|
|
135
|
+
: null;
|
|
136
|
+
|
|
137
|
+
const scripts: Record<string, string> = {};
|
|
138
|
+
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
139
|
+
for (const [name, cmd] of Object.entries(pkg.scripts)) {
|
|
140
|
+
if (typeof cmd === 'string') scripts[name] = cmd;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
114
144
|
return {
|
|
115
145
|
root: abs,
|
|
116
146
|
name: typeof pkg.name === 'string' && pkg.name ? pkg.name : path.basename(abs),
|
|
@@ -123,5 +153,7 @@ export function detectProjectReality(root: string): ProjectReality {
|
|
|
123
153
|
hasSstConfig: isFile(path.join(abs, 'sst.config.ts')),
|
|
124
154
|
hasExpoApp: isDir(path.join(abs, 'app')),
|
|
125
155
|
migrationsDir,
|
|
156
|
+
migrationMode,
|
|
157
|
+
scripts,
|
|
126
158
|
};
|
|
127
159
|
}
|
|
@@ -80,7 +80,73 @@ function relationLine(name: string, rel: Relation): string {
|
|
|
80
80
|
return `- \`${name}\` — ${shape} ${target} (via \`${rel.column}\`)`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export
|
|
83
|
+
export interface DataModelOptions {
|
|
84
|
+
/** Above this many models the section renders as a summary. Default 16. */
|
|
85
|
+
threshold?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DEFAULT_THRESHOLD = 16;
|
|
89
|
+
|
|
90
|
+
function nameList(tables: string[], cap = 15): string {
|
|
91
|
+
const shown = tables.slice(0, cap).map((t) => `\`${t}\``);
|
|
92
|
+
const rest = tables.length - cap;
|
|
93
|
+
return rest > 0 ? `${shown.join(', ')} (+${rest} more)` : shown.join(', ');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* At scale (a 199-model brownfield pull) the full per-field render is reference
|
|
98
|
+
* material, not operations — ~5k lines nobody reads. Above the threshold we
|
|
99
|
+
* summarize per schema and keep the security-relevant signals (owner scoping,
|
|
100
|
+
* tables nobody can reach, operational tables), pointing at the real reference.
|
|
101
|
+
*/
|
|
102
|
+
function renderSummary(models: ModelDescriptor[]): string {
|
|
103
|
+
const bySchema = new Map<string, ModelDescriptor[]>();
|
|
104
|
+
for (const m of models) {
|
|
105
|
+
const schema = m.schema || 'public';
|
|
106
|
+
if (!bySchema.has(schema)) bySchema.set(schema, []);
|
|
107
|
+
bySchema.get(schema)!.push(m);
|
|
108
|
+
}
|
|
109
|
+
const lines: string[] = [
|
|
110
|
+
'## Your data model',
|
|
111
|
+
'',
|
|
112
|
+
`${models.length} models across ${bySchema.size} schema(s) — summarized; the full reference is`,
|
|
113
|
+
'the `models/` directory and `everystack db:authz:report`. Compiled from the Models — edit',
|
|
114
|
+
'them and regenerate; never edit this section.',
|
|
115
|
+
];
|
|
116
|
+
for (const schema of [...bySchema.keys()].sort()) {
|
|
117
|
+
const group = bySchema.get(schema)!;
|
|
118
|
+
lines.push('', `### ${schema} — ${group.length} table(s)`, '');
|
|
119
|
+
const ownerScoped = group.filter((m) => m.abilities.some((a) => a.condition?.owner || a.condition?.via));
|
|
120
|
+
const anonReadable = group.filter((m) =>
|
|
121
|
+
m.abilities.some(
|
|
122
|
+
(a) =>
|
|
123
|
+
(a.action === 'read' || a.action === 'manage') &&
|
|
124
|
+
!a.condition?.role && !a.condition?.owner && !a.condition?.via &&
|
|
125
|
+
!a.condition?.where && !a.condition?.sql,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
const roleGated = group.filter((m) => m.abilities.some((a) => a.condition?.role));
|
|
129
|
+
const noAbilities = group.filter((m) => m.abilities.length === 0);
|
|
130
|
+
const operational = group.filter((m) => m.private || m.writtenBy !== 'app');
|
|
131
|
+
lines.push(
|
|
132
|
+
`- owner-scoped: ${ownerScoped.length} · anon-readable: ${anonReadable.length} · role-gated: ${roleGated.length}`,
|
|
133
|
+
);
|
|
134
|
+
if (noAbilities.length) {
|
|
135
|
+
lines.push(
|
|
136
|
+
`- no abilities declared (unreachable through the API): ${nameList(noAbilities.map((m) => m.table))}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (operational.length) {
|
|
140
|
+
lines.push(
|
|
141
|
+
`- operational (private / non-app writers): ${nameList(operational.map((m) => m.table))}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderDataModelSection(models: ModelDescriptor[], opts: DataModelOptions = {}): string {
|
|
149
|
+
if (models.length > (opts.threshold ?? DEFAULT_THRESHOLD)) return renderSummary(models);
|
|
84
150
|
const lines: string[] = [
|
|
85
151
|
'## Your data model',
|
|
86
152
|
'',
|
|
@@ -71,8 +71,24 @@ function projectMap(r: ProjectReality): Block {
|
|
|
71
71
|
`- \`${r.models.dir}/\` — \`defineModel\` tables (${r.models.files.length} model(s); the source of truth for schema + authz)`,
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
|
-
if (r.hasDatabase)
|
|
75
|
-
|
|
74
|
+
if (r.hasDatabase) {
|
|
75
|
+
lines.push(
|
|
76
|
+
r.migrationMode === 'everystack'
|
|
77
|
+
? '- `db/` — generated Drizzle schema (never edit by hand)'
|
|
78
|
+
: r.migrationMode === 'transitional'
|
|
79
|
+
? '- `db/` — Drizzle schema (migrating to Models; parts are still hand-maintained)'
|
|
80
|
+
: '- `db/` — Drizzle schema (hand-maintained)',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (r.migrationsDir) {
|
|
84
|
+
lines.push(
|
|
85
|
+
r.migrationMode === 'everystack'
|
|
86
|
+
? `- \`${r.migrationsDir}/\` — generated migrations`
|
|
87
|
+
: r.migrationMode === 'transitional'
|
|
88
|
+
? `- \`${r.migrationsDir}/\` — migrations (hand-authored journal, moving to \`db:generate\`)`
|
|
89
|
+
: `- \`${r.migrationsDir}/\` — migrations (hand-authored via drizzle-kit)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
76
92
|
if (r.handlers.api || r.handlers.worker || r.handlers.image || r.crons.length) {
|
|
77
93
|
const parts = [
|
|
78
94
|
r.handlers.api ? 'api.ts' : null,
|
|
@@ -87,14 +103,24 @@ function projectMap(r: ProjectReality): Block {
|
|
|
87
103
|
return gen('project-map', lines);
|
|
88
104
|
}
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
const SCRIPTS_MAX = 12;
|
|
107
|
+
const SCRIPT_CMD_MAX = 64;
|
|
108
|
+
|
|
109
|
+
function dailyWorkflow(r: ProjectReality): Block {
|
|
110
|
+
const lines = ['## Daily workflow', ''];
|
|
111
|
+
const entries = Object.entries(r.scripts);
|
|
112
|
+
for (const [name, cmd] of entries.slice(0, SCRIPTS_MAX)) {
|
|
113
|
+
const shown = cmd.length > SCRIPT_CMD_MAX ? `${cmd.slice(0, SCRIPT_CMD_MAX)}…` : cmd;
|
|
114
|
+
lines.push(`- \`pnpm ${name}\` — \`${shown}\``);
|
|
115
|
+
}
|
|
116
|
+
if (entries.length > SCRIPTS_MAX) {
|
|
117
|
+
lines.push(`- …and ${entries.length - SCRIPTS_MAX} more in \`package.json\``);
|
|
118
|
+
}
|
|
119
|
+
if (entries.length === 0) {
|
|
120
|
+
lines.push('- No scripts declared in `package.json` yet.');
|
|
121
|
+
}
|
|
122
|
+
lines.push('- Every feature starts with a failing test; conventional commits; tests pass before commit.');
|
|
123
|
+
return gen('daily-workflow', lines);
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
function deploy(r: ProjectReality): Block {
|
|
@@ -127,6 +153,13 @@ function extend(r: ProjectReality): Block {
|
|
|
127
153
|
'- **Add a table or field:** edit the Model in `models/`, run `everystack db:generate`,',
|
|
128
154
|
' review the emitted migration, then `everystack db:migrate`. Never hand-write a SQL',
|
|
129
155
|
' migration, never edit `db/schema.ts` — after any change `db:generate` must be a clean no-op.',
|
|
156
|
+
);
|
|
157
|
+
if (r.migrationMode === 'transitional') {
|
|
158
|
+
lines.push(
|
|
159
|
+
' _(Target flow — this app is migrating to Models and the hand-authored journal is being retired.)_',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
lines.push(
|
|
130
163
|
'- **Change who can read/write:** declare `can()` abilities on the Model — they compile',
|
|
131
164
|
' to RLS + grants. Never hand-write `CREATE POLICY` or `GRANT`.',
|
|
132
165
|
);
|
|
@@ -158,10 +191,24 @@ function extend(r: ProjectReality): Block {
|
|
|
158
191
|
}
|
|
159
192
|
|
|
160
193
|
function database(r: ProjectReality): Block {
|
|
194
|
+
const flow =
|
|
195
|
+
r.migrationMode === 'everystack'
|
|
196
|
+
? ['- `everystack db:generate` — compile Models → next migration (schema + RLS + grants)']
|
|
197
|
+
: r.migrationMode === 'transitional'
|
|
198
|
+
? [
|
|
199
|
+
'- **Mid-migration to Models:** the journal is still hand-authored. Target flow is',
|
|
200
|
+
' `everystack db:generate`; it must produce a clean, empty data diff before the',
|
|
201
|
+
' generated flow takes over. Until then, review hand-written SQL like the liability it is.',
|
|
202
|
+
]
|
|
203
|
+
: [
|
|
204
|
+
'- Migrations are hand-authored (drizzle-kit). The Models on-ramp: `everystack db:pull`',
|
|
205
|
+
' writes `models/` from the live database; from there `everystack db:generate` compiles',
|
|
206
|
+
' migrations (schema + RLS + grants) and hand-written SQL retires.',
|
|
207
|
+
];
|
|
161
208
|
return gen('database', [
|
|
162
209
|
'## Database operations',
|
|
163
210
|
'',
|
|
164
|
-
|
|
211
|
+
...flow,
|
|
165
212
|
'- `everystack db:migrate --stage <name>` — apply migrations on the deployed Lambda',
|
|
166
213
|
'- `everystack db:seed --stage dev` — seed the database (dev only)',
|
|
167
214
|
'- `everystack db:psql --stage dev` — psql into the stage database (credentials never exposed)',
|
|
@@ -273,7 +320,7 @@ export function buildStructure(reality: ProjectReality, ctx: RunbookContext): Bl
|
|
|
273
320
|
),
|
|
274
321
|
prerequisites(reality),
|
|
275
322
|
projectMap(reality),
|
|
276
|
-
dailyWorkflow(),
|
|
323
|
+
dailyWorkflow(reality),
|
|
277
324
|
deploy(reality),
|
|
278
325
|
slot(
|
|
279
326
|
'stage-notes',
|