@everystack/cli 0.3.11 → 0.3.12
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/audit.ts +20 -0
- package/src/cli/commands/runbook.ts +175 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/runbook/detect.ts +127 -0
- package/src/cli/runbook/generate.ts +81 -0
- package/src/cli/runbook/index.ts +20 -0
- package/src/cli/runbook/markers.ts +96 -0
- package/src/cli/runbook/sections.ts +303 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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>",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/cli/audit-source-api.ts",
|
|
38
38
|
"default": "./src/cli/audit-source-api.ts"
|
|
39
39
|
},
|
|
40
|
+
"./runbook": {
|
|
41
|
+
"types": "./src/cli/runbook/index.ts",
|
|
42
|
+
"default": "./src/cli/runbook/index.ts"
|
|
43
|
+
},
|
|
40
44
|
"./client": {
|
|
41
45
|
"types": "./src/client/index.ts",
|
|
42
46
|
"default": "./src/client/index.ts"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
12
12
|
import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle.js';
|
|
13
13
|
import { runUiAudit, reportUiAudit } from './ui-audit.js';
|
|
14
|
+
import { checkRunbook } from './runbook.js';
|
|
14
15
|
import { resolveWeightBudget } from '../bundle-weight.js';
|
|
15
16
|
import { runSecurityProbe, reportProbeResults } from './security-probe.js';
|
|
16
17
|
import { auditDeployedSql, printReport, loadWaivers } from './security.js';
|
|
@@ -105,6 +106,25 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
|
|
|
105
106
|
warn(`ui:audit skipped: ${err.message}`);
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// --- runbook --check (docs currency) — advisory, never gates -------------
|
|
110
|
+
// A stale operations manual must not block a deploy; it just gets named.
|
|
111
|
+
// Skipped silently when the cwd isn't an everystack app (remote-only audits).
|
|
112
|
+
try {
|
|
113
|
+
const check = await checkRunbook(flags['ui-dir'] || '.');
|
|
114
|
+
console.log('');
|
|
115
|
+
info('── runbook ──────────────────────────────────────────────────');
|
|
116
|
+
if (check.status === 'current') {
|
|
117
|
+
success('docs/RUNBOOK.md is current.');
|
|
118
|
+
} else if (check.status === 'missing') {
|
|
119
|
+
warn('docs/RUNBOOK.md is missing — generate it with `everystack runbook` (advisory; does not gate).');
|
|
120
|
+
} else {
|
|
121
|
+
warn('docs/RUNBOOK.md is stale — regenerate with `everystack runbook` (advisory; does not gate).');
|
|
122
|
+
}
|
|
123
|
+
ran.push('runbook (advisory)');
|
|
124
|
+
} catch {
|
|
125
|
+
// not an everystack project dir — nothing to check
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
// --- audit:lighthouse (perf/SEO) — opt-in; heavy and informational -------
|
|
109
129
|
if (flags.lighthouse) {
|
|
110
130
|
console.log('');
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack runbook` — compile docs/RUNBOOK.md, the per-app operations manual.
|
|
3
|
+
*
|
|
4
|
+
* db:generate for documentation (docs/plans/everystack-runbook.md): pure
|
|
5
|
+
* detection → templates keyed by the app's reality → es:gen/es:slot merge that
|
|
6
|
+
* preserves human words. The pure core lives in ../runbook/; this shell is the
|
|
7
|
+
* IO: read the old file, load the app's Models for the data-model chapter,
|
|
8
|
+
* write/check/diff, map to exit codes.
|
|
9
|
+
*
|
|
10
|
+
* everystack runbook generate or update (slot content preserved)
|
|
11
|
+
* everystack runbook --check exit 1 if regeneration would change the file
|
|
12
|
+
* everystack runbook --diff name the sections that would change; write nothing
|
|
13
|
+
* everystack runbook --force overwrite hand-edits inside generated blocks
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import { detectProjectReality } from '../runbook/detect.js';
|
|
20
|
+
import { generateRunbook, type RunbookResult } from '../runbook/generate.js';
|
|
21
|
+
import { renderDataModelSection } from '../runbook/model-section.js';
|
|
22
|
+
import { parseRunbook, sha8 } from '../runbook/markers.js';
|
|
23
|
+
import { fail, info, step, success, warn } from '../output.js';
|
|
24
|
+
|
|
25
|
+
const OUT_REL = path.join('docs', 'RUNBOOK.md');
|
|
26
|
+
|
|
27
|
+
/** This package's version, for the provenance stamp. tsx polyfills __dirname in ESM; ts-jest is CJS. */
|
|
28
|
+
function cliVersion(): string {
|
|
29
|
+
try {
|
|
30
|
+
const dir = eval('__dirname') as string;
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, '..', '..', '..', 'package.json'), 'utf8'));
|
|
32
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.0.0';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Import the app's Model barrel for the data-model chapter. Absent or unloadable → honest warning, no chapter. */
|
|
39
|
+
async function loadDataModel(root: string, hasIndex: boolean): Promise<{ dataModel: string | null; warning: string | null }> {
|
|
40
|
+
if (!hasIndex) return { dataModel: null, warning: null };
|
|
41
|
+
const barrel = path.join(root, 'models', 'index.ts');
|
|
42
|
+
try {
|
|
43
|
+
const mod = await import(pathToFileURL(barrel).href);
|
|
44
|
+
const models = mod.models ?? mod.default;
|
|
45
|
+
if (!Array.isArray(models)) {
|
|
46
|
+
return { dataModel: null, warning: `models/index.ts exports no \`models\` array — data-model chapter skipped.` };
|
|
47
|
+
}
|
|
48
|
+
return { dataModel: renderDataModelSection(models), warning: null };
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
return {
|
|
51
|
+
dataModel: null,
|
|
52
|
+
warning: `Could not load models from ${barrel} (${err.message}) — data-model chapter skipped.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RunbookRun {
|
|
58
|
+
outPath: string;
|
|
59
|
+
oldText: string | null;
|
|
60
|
+
result: RunbookResult;
|
|
61
|
+
modelWarning: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Result-producing core, shared by the command, `--check`, and the `audit` capstone. */
|
|
65
|
+
export async function runRunbook(root: string): Promise<RunbookRun> {
|
|
66
|
+
const reality = detectProjectReality(root);
|
|
67
|
+
const { dataModel, warning } = await loadDataModel(reality.root, reality.models.hasIndex);
|
|
68
|
+
const outPath = path.join(reality.root, OUT_REL);
|
|
69
|
+
const oldText = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null;
|
|
70
|
+
const result = generateRunbook(reality, { cliVersion: cliVersion(), dataModel }, oldText ?? undefined);
|
|
71
|
+
return { outPath, oldText, result, modelWarning: warning };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RunbookCheck {
|
|
75
|
+
status: 'missing' | 'stale' | 'current';
|
|
76
|
+
conflicts: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Is docs/RUNBOOK.md current? Pure read — writes nothing. */
|
|
80
|
+
export async function checkRunbook(root: string): Promise<RunbookCheck> {
|
|
81
|
+
const { oldText, result } = await runRunbook(root);
|
|
82
|
+
if (oldText === null) return { status: 'missing', conflicts: [] };
|
|
83
|
+
return { status: result.changed ? 'stale' : 'current', conflicts: result.conflicts };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RunbookDiffSummary {
|
|
87
|
+
added: string[];
|
|
88
|
+
removed: string[];
|
|
89
|
+
changed: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Section-level diff: which gen blocks a regeneration would add, remove, or rewrite. */
|
|
93
|
+
export function diffSummary(oldText: string, newText: string): RunbookDiffSummary {
|
|
94
|
+
const genShas = (text: string) =>
|
|
95
|
+
new Map(
|
|
96
|
+
parseRunbook(text).blocks.flatMap((b) => (b.kind === 'gen' ? [[b.id, sha8(b.content)] as const] : [])),
|
|
97
|
+
);
|
|
98
|
+
const before = genShas(oldText);
|
|
99
|
+
const after = genShas(newText);
|
|
100
|
+
return {
|
|
101
|
+
added: [...after.keys()].filter((id) => !before.has(id)),
|
|
102
|
+
removed: [...before.keys()].filter((id) => !after.has(id)),
|
|
103
|
+
changed: [...after.keys()].filter((id) => before.has(id) && before.get(id) !== after.get(id)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runbookCommand(flags: Record<string, string>): Promise<void> {
|
|
108
|
+
const root = flags.dir || '.';
|
|
109
|
+
let run: RunbookRun;
|
|
110
|
+
try {
|
|
111
|
+
run = await runRunbook(root);
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
fail(err.message);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
if (run.modelWarning) warn(run.modelWarning);
|
|
117
|
+
const rel = path.relative(process.cwd(), run.outPath) || OUT_REL;
|
|
118
|
+
|
|
119
|
+
if ('check' in flags) {
|
|
120
|
+
if (run.oldText === null) {
|
|
121
|
+
fail(`${rel} is missing — run \`everystack runbook\` to generate it.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
if (run.result.conflicts.length) {
|
|
125
|
+
warn(`Hand-edited generated section(s): ${run.result.conflicts.join(', ')} — move those words to a slot or Notes.`);
|
|
126
|
+
}
|
|
127
|
+
if (run.result.changed) {
|
|
128
|
+
fail(`${rel} is stale — run \`everystack runbook\` to regenerate.`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
success(`${rel} is current.`);
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if ('diff' in flags) {
|
|
136
|
+
if (run.oldText === null) {
|
|
137
|
+
info(`${rel} does not exist yet — \`everystack runbook\` would create it.`);
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
if (!run.result.changed) {
|
|
141
|
+
success(`${rel} is current — nothing would change.`);
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
const summary = diffSummary(run.oldText, run.result.text);
|
|
145
|
+
if (summary.added.length) info(`sections added: ${summary.added.join(', ')}`);
|
|
146
|
+
if (summary.removed.length) info(`sections removed: ${summary.removed.join(', ')}`);
|
|
147
|
+
if (summary.changed.length) info(`sections updated: ${summary.changed.join(', ')}`);
|
|
148
|
+
if (run.result.conflicts.length) {
|
|
149
|
+
warn(`hand-edits that would be overwritten: ${run.result.conflicts.join(', ')} (regenerate with --force)`);
|
|
150
|
+
}
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// default: write
|
|
155
|
+
if (run.result.conflicts.length && !('force' in flags)) {
|
|
156
|
+
fail(
|
|
157
|
+
`Generated section(s) were hand-edited: ${run.result.conflicts.join(', ')}. ` +
|
|
158
|
+
'Move those words into a slot or the Notes section, or rerun with --force to overwrite them.',
|
|
159
|
+
);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
if (run.oldText !== null && !run.result.changed) {
|
|
163
|
+
success(`${rel} is already current.`);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
step(`Writing ${rel} ...`);
|
|
167
|
+
fs.mkdirSync(path.dirname(run.outPath), { recursive: true });
|
|
168
|
+
fs.writeFileSync(run.outPath, run.result.text);
|
|
169
|
+
success(
|
|
170
|
+
run.oldText === null
|
|
171
|
+
? `${rel} generated. Fill the marked slots (overview, stage-notes, notes) — they survive every regeneration.`
|
|
172
|
+
: `${rel} regenerated. Slot content preserved.`,
|
|
173
|
+
);
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { bundleAnalyzeCommand, bundleAuditCommand } from './commands/bundle.js';
|
|
|
26
26
|
import { auditLighthouseCommand } from './commands/lighthouse.js';
|
|
27
27
|
import { auditCommand } from './commands/audit.js';
|
|
28
28
|
import { uiAuditCommand } from './commands/ui-audit.js';
|
|
29
|
+
import { runbookCommand } from './commands/runbook.js';
|
|
29
30
|
import { fail } from './output.js';
|
|
30
31
|
|
|
31
32
|
const args = process.argv.slice(2);
|
|
@@ -242,6 +243,9 @@ async function main() {
|
|
|
242
243
|
await uiAuditCommand(positional, flags);
|
|
243
244
|
break;
|
|
244
245
|
}
|
|
246
|
+
case 'runbook':
|
|
247
|
+
await runbookCommand(flags);
|
|
248
|
+
break;
|
|
245
249
|
case 'secrets': {
|
|
246
250
|
// secrets <subcommand> [positional...] [--flags]
|
|
247
251
|
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
@@ -305,6 +309,7 @@ Usage:
|
|
|
305
309
|
everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs 3] [--path /] [--json out.json] Mobile Lighthouse (perf/a11y/bp/seo), median over N runs
|
|
306
310
|
everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat) AND UI design-bloat (style repetition + hardcoded colors). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
|
|
307
311
|
everystack ui:audit [dir] [--ui-repeat N] [--ui-color-repeat N] Source-side UI design-bloat: styles copy-pasted across call sites, hardcoded colors that should be tokens, raw primitives reimplementing a ui component, oversized components, duplicated JSX. Located + fixable; advisory, never gates
|
|
312
|
+
everystack runbook [--dir <root>] [--check] [--diff] [--force] Compile docs/RUNBOOK.md, the per-app operations manual, from the app's detected reality (tier, Models, handlers). Regenerate after the app grows or an everystack upgrade; slot content survives. --check exits 1 if stale (CI); --diff names the sections that would change
|
|
308
313
|
everystack certs:generate [--output ./certs]
|
|
309
314
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
310
315
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detectProjectReality — the runbook compiler's input, R0 of the runbook plan
|
|
3
|
+
* (docs/plans/everystack-runbook.md).
|
|
4
|
+
*
|
|
5
|
+
* Pure, local filesystem reads over a consumer app's root: which @everystack/*
|
|
6
|
+
* packages are installed (→ tier), whether Models/handlers/crons/migrations exist.
|
|
7
|
+
* No network, no AWS — purity is what makes `runbook --check` trustworthy in CI.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export type Tier = 'V1' | 'V2' | 'V3';
|
|
14
|
+
|
|
15
|
+
export interface ProjectReality {
|
|
16
|
+
root: string;
|
|
17
|
+
/** package.json name. */
|
|
18
|
+
name: string;
|
|
19
|
+
tier: Tier;
|
|
20
|
+
/** Installed @everystack/* short names, sorted (e.g. ['api', 'cli', 'server']). */
|
|
21
|
+
packages: string[];
|
|
22
|
+
/** True when the app has a database story: models declared, a db/ dir, or the api package. */
|
|
23
|
+
hasDatabase: boolean;
|
|
24
|
+
models: {
|
|
25
|
+
/** Relative dir ('models') or null when absent. */
|
|
26
|
+
dir: string | null;
|
|
27
|
+
/** Model files (\.ts, excluding index.ts), sorted. */
|
|
28
|
+
files: string[];
|
|
29
|
+
hasIndex: boolean;
|
|
30
|
+
};
|
|
31
|
+
handlers: { api: boolean; worker: boolean; image: boolean };
|
|
32
|
+
/** Cron handler filenames under server/cron/, sorted. */
|
|
33
|
+
crons: string[];
|
|
34
|
+
hasSstConfig: boolean;
|
|
35
|
+
/** Expo Router app/ directory present. */
|
|
36
|
+
hasExpoApp: boolean;
|
|
37
|
+
/** Relative migrations dir ('drizzle' or 'db/migrations') or null. */
|
|
38
|
+
migrationsDir: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const V2_PACKAGES = new Set(['api', 'auth', 'admin', 'logging', 'security', 'query']);
|
|
42
|
+
const V3_PACKAGES = new Set(['jobs', 'storage', 'images']);
|
|
43
|
+
|
|
44
|
+
function isDir(p: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return fs.statSync(p).isDirectory();
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isFile(p: string): boolean {
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(p).isFile();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function listTs(dir: string): string[] {
|
|
61
|
+
if (!isDir(dir)) return [];
|
|
62
|
+
return fs
|
|
63
|
+
.readdirSync(dir)
|
|
64
|
+
.filter((f) => f.endsWith('.ts') || f.endsWith('.tsx'))
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function detectProjectReality(root: string): ProjectReality {
|
|
69
|
+
const abs = path.resolve(root);
|
|
70
|
+
const pkgPath = path.join(abs, 'package.json');
|
|
71
|
+
if (!isFile(pkgPath)) {
|
|
72
|
+
throw new Error(`No package.json at ${abs} — run from the app root (or pass it).`);
|
|
73
|
+
}
|
|
74
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
75
|
+
const deps: Record<string, string> = {
|
|
76
|
+
...(pkg.dependencies ?? {}),
|
|
77
|
+
...(pkg.devDependencies ?? {}),
|
|
78
|
+
};
|
|
79
|
+
const packages = Object.keys(deps)
|
|
80
|
+
.filter((d) => d.startsWith('@everystack/'))
|
|
81
|
+
.map((d) => d.slice('@everystack/'.length))
|
|
82
|
+
.sort();
|
|
83
|
+
if (packages.length === 0) {
|
|
84
|
+
throw new Error(`${pkgPath} has no @everystack/* dependencies — not an everystack app.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tier: Tier = packages.some((p) => V3_PACKAGES.has(p))
|
|
88
|
+
? 'V3'
|
|
89
|
+
: packages.some((p) => V2_PACKAGES.has(p))
|
|
90
|
+
? 'V2'
|
|
91
|
+
: 'V1';
|
|
92
|
+
|
|
93
|
+
const modelsDir = path.join(abs, 'models');
|
|
94
|
+
const modelFiles = listTs(modelsDir).filter((f) => f !== 'index.ts');
|
|
95
|
+
const models = {
|
|
96
|
+
dir: isDir(modelsDir) ? 'models' : null,
|
|
97
|
+
files: modelFiles,
|
|
98
|
+
hasIndex: isFile(path.join(modelsDir, 'index.ts')),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handlers = {
|
|
102
|
+
api: isFile(path.join(abs, 'server', 'api.ts')),
|
|
103
|
+
worker: isFile(path.join(abs, 'server', 'worker.ts')),
|
|
104
|
+
image: isFile(path.join(abs, 'server', 'image.ts')),
|
|
105
|
+
};
|
|
106
|
+
const crons = listTs(path.join(abs, 'server', 'cron'));
|
|
107
|
+
|
|
108
|
+
const migrationsDir = isDir(path.join(abs, 'drizzle'))
|
|
109
|
+
? 'drizzle'
|
|
110
|
+
: isDir(path.join(abs, 'db', 'migrations'))
|
|
111
|
+
? 'db/migrations'
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
root: abs,
|
|
116
|
+
name: typeof pkg.name === 'string' && pkg.name ? pkg.name : path.basename(abs),
|
|
117
|
+
tier,
|
|
118
|
+
packages,
|
|
119
|
+
hasDatabase: models.files.length > 0 || isDir(path.join(abs, 'db')) || packages.includes('api'),
|
|
120
|
+
models,
|
|
121
|
+
handlers,
|
|
122
|
+
crons,
|
|
123
|
+
hasSstConfig: isFile(path.join(abs, 'sst.config.ts')),
|
|
124
|
+
hasExpoApp: isDir(path.join(abs, 'app')),
|
|
125
|
+
migrationsDir,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateRunbook — compile + merge, the runbook's db:generate.
|
|
3
|
+
*
|
|
4
|
+
* Fresh: reality → structure → text. Regenerate: the new structure defines the
|
|
5
|
+
* document; filled slot content carries over by name; content whose home
|
|
6
|
+
* disappeared (removed slots, text pasted outside any block) moves into Notes
|
|
7
|
+
* rather than being dropped; hand-edits inside generated blocks surface as
|
|
8
|
+
* conflicts (the caller decides whether to overwrite). Deterministic and
|
|
9
|
+
* idempotent: unchanged project → byte-identical output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ProjectReality } from './detect.js';
|
|
13
|
+
import { parseRunbook, composeRunbook, sha8, type Block } from './markers.js';
|
|
14
|
+
import { buildStructure, headerStamp, PLACEHOLDER_PREFIX, type RunbookContext } from './sections.js';
|
|
15
|
+
|
|
16
|
+
export interface RunbookResult {
|
|
17
|
+
text: string;
|
|
18
|
+
/** Section ids whose generated content was hand-edited in the old file. */
|
|
19
|
+
conflicts: string[];
|
|
20
|
+
/** False when the output is byte-identical to the provided old text. */
|
|
21
|
+
changed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isFilled(content: string): boolean {
|
|
25
|
+
const trimmed = content.trim();
|
|
26
|
+
return trimmed !== '' && !trimmed.startsWith(PLACEHOLDER_PREFIX);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function generateRunbook(
|
|
30
|
+
reality: ProjectReality,
|
|
31
|
+
ctx: RunbookContext,
|
|
32
|
+
oldText?: string,
|
|
33
|
+
): RunbookResult {
|
|
34
|
+
const blocks = buildStructure(reality, ctx);
|
|
35
|
+
const conflicts: string[] = [];
|
|
36
|
+
|
|
37
|
+
if (oldText !== undefined) {
|
|
38
|
+
const old = parseRunbook(oldText);
|
|
39
|
+
|
|
40
|
+
for (const b of old.blocks) {
|
|
41
|
+
if (b.kind === 'gen' && b.recordedSha && sha8(b.content) !== b.recordedSha) {
|
|
42
|
+
conflicts.push(b.id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const oldSlots = new Map(
|
|
47
|
+
old.blocks.flatMap((b) => (b.kind === 'slot' ? [[b.name, b.content] as const] : [])),
|
|
48
|
+
);
|
|
49
|
+
const newSlotNames = new Set(blocks.flatMap((b) => (b.kind === 'slot' ? [b.name] : [])));
|
|
50
|
+
|
|
51
|
+
// Content with no home in the new structure is carried into Notes, never dropped.
|
|
52
|
+
const carries: string[] = [];
|
|
53
|
+
for (const [name, content] of oldSlots) {
|
|
54
|
+
if (!newSlotNames.has(name) && isFilled(content)) {
|
|
55
|
+
carries.push(`_Carried from removed slot \`${name}\`:_\n\n${content.trim()}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const orphan of old.orphans) {
|
|
59
|
+
carries.push(orphan);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const b of blocks) {
|
|
63
|
+
if (b.kind !== 'slot') continue;
|
|
64
|
+
const oldContent = oldSlots.get(b.name);
|
|
65
|
+
if (oldContent !== undefined && isFilled(oldContent)) b.content = oldContent;
|
|
66
|
+
if (b.name === 'notes' && carries.length > 0) {
|
|
67
|
+
const base = isFilled(b.content) ? [b.content] : [];
|
|
68
|
+
b.content = [...base, ...carries].join('\n\n');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const text = composeRunbook(headerStamp(reality, ctx), blocks);
|
|
74
|
+
return {
|
|
75
|
+
text,
|
|
76
|
+
conflicts,
|
|
77
|
+
changed: oldText === undefined ? true : text !== oldText,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type { Block };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@everystack/cli/runbook` — the programmatic surface of the runbook compiler.
|
|
3
|
+
*
|
|
4
|
+
* db:generate for documentation (docs/plans/everystack-runbook.md): detect the
|
|
5
|
+
* app's reality, compile section templates keyed by it, merge over the existing
|
|
6
|
+
* docs/RUNBOOK.md preserving human slot content. Everything here is pure and
|
|
7
|
+
* local (no network, no AWS) except the thin IO in runRunbook/checkRunbook, so
|
|
8
|
+
* a consumer can assert runbook currency in their own tests/CI:
|
|
9
|
+
*
|
|
10
|
+
* const { status } = await checkRunbook('.');
|
|
11
|
+
* expect(status).toBe('current');
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from './detect.js';
|
|
15
|
+
export * from './markers.js';
|
|
16
|
+
export * from './sections.js';
|
|
17
|
+
export * from './generate.js';
|
|
18
|
+
export * from './model-section.js';
|
|
19
|
+
export { runRunbook, checkRunbook, diffSummary } from '../commands/runbook.js';
|
|
20
|
+
export type { RunbookRun, RunbookCheck, RunbookDiffSummary } from '../commands/runbook.js';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runbook marker format — the mechanical seam between compiler-owned and
|
|
3
|
+
* human-owned content (docs/plans/everystack-runbook.md).
|
|
4
|
+
*
|
|
5
|
+
* <!-- es:gen section=<id> sha=<sha8> --> compiler-owned, replaced wholesale
|
|
6
|
+
* <!-- es:slot name=<name> --> human/agent-owned, preserved verbatim
|
|
7
|
+
*
|
|
8
|
+
* Markers are HTML comments, invisible in rendered markdown. Each gen block
|
|
9
|
+
* records the sha8 of its own content; a mismatch on re-parse means someone
|
|
10
|
+
* hand-edited a generated section — surfaced as a conflict, never silently lost.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export type Block =
|
|
16
|
+
| { kind: 'gen'; id: string; content: string; recordedSha?: string }
|
|
17
|
+
| { kind: 'slot'; name: string; content: string };
|
|
18
|
+
|
|
19
|
+
export interface ParsedRunbook {
|
|
20
|
+
/** The provenance stamp line, or null if the file has none. */
|
|
21
|
+
header: string | null;
|
|
22
|
+
blocks: Block[];
|
|
23
|
+
/** Non-empty text found outside any block — carried into Notes on merge. */
|
|
24
|
+
orphans: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sha8(content: string): string {
|
|
28
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HEADER_RE = /^<!-- everystack-runbook .* -->$/;
|
|
32
|
+
const GEN_OPEN_RE = /^<!-- es:gen section=(\S+) sha=([0-9a-f]+) -->$/;
|
|
33
|
+
const GEN_CLOSE = '<!-- /es:gen -->';
|
|
34
|
+
const SLOT_OPEN_RE = /^<!-- es:slot name=(\S+) -->$/;
|
|
35
|
+
const SLOT_CLOSE = '<!-- /es:slot -->';
|
|
36
|
+
|
|
37
|
+
export function parseRunbook(text: string): ParsedRunbook {
|
|
38
|
+
const lines = text.split('\n');
|
|
39
|
+
const blocks: Block[] = [];
|
|
40
|
+
const orphans: string[] = [];
|
|
41
|
+
let header: string | null = null;
|
|
42
|
+
let orphanRun: string[] = [];
|
|
43
|
+
|
|
44
|
+
const flushOrphans = () => {
|
|
45
|
+
const chunk = orphanRun.join('\n').trim();
|
|
46
|
+
if (chunk) orphans.push(chunk);
|
|
47
|
+
orphanRun = [];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (header === null && blocks.length === 0 && HEADER_RE.test(line)) {
|
|
54
|
+
header = line;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const gen = line.match(GEN_OPEN_RE);
|
|
59
|
+
const slot = gen ? null : line.match(SLOT_OPEN_RE);
|
|
60
|
+
if (gen || slot) {
|
|
61
|
+
flushOrphans();
|
|
62
|
+
const close = gen ? GEN_CLOSE : SLOT_CLOSE;
|
|
63
|
+
const content: string[] = [];
|
|
64
|
+
i++;
|
|
65
|
+
while (i < lines.length && lines[i] !== close) {
|
|
66
|
+
content.push(lines[i]);
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
// A missing close marker means a mangled file; treat the run as content anyway.
|
|
70
|
+
const body = content.join('\n');
|
|
71
|
+
if (gen) {
|
|
72
|
+
blocks.push({ kind: 'gen', id: gen[1], content: body, recordedSha: gen[2] });
|
|
73
|
+
} else {
|
|
74
|
+
blocks.push({ kind: 'slot', name: slot![1], content: body });
|
|
75
|
+
}
|
|
76
|
+
i++; // past the close marker
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
orphanRun.push(line);
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
flushOrphans();
|
|
83
|
+
return { header, blocks, orphans };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function composeRunbook(header: string, blocks: Block[]): string {
|
|
87
|
+
const parts = [header];
|
|
88
|
+
for (const b of blocks) {
|
|
89
|
+
if (b.kind === 'gen') {
|
|
90
|
+
parts.push(`<!-- es:gen section=${b.id} sha=${sha8(b.content)} -->\n${b.content}\n${GEN_CLOSE}`);
|
|
91
|
+
} else {
|
|
92
|
+
parts.push(`<!-- es:slot name=${b.name} -->\n${b.content}\n${SLOT_CLOSE}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return parts.join('\n\n') + '\n';
|
|
96
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runbook sections — templates keyed by the app's detected reality.
|
|
3
|
+
*
|
|
4
|
+
* Each section renders procedure-sized markdown (short prose + exact commands),
|
|
5
|
+
* conditioned on what the app actually has: a V1 static app gets no database
|
|
6
|
+
* chapter; crons render only the cron files that exist. Templates version with
|
|
7
|
+
* the cli package — upgrading everystack and regenerating diffs in what's new.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately deterministic: same ProjectReality (+ optional data-model
|
|
10
|
+
* markdown) → same blocks, so `runbook --check` is trustworthy in CI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ProjectReality } from './detect.js';
|
|
14
|
+
import type { Block } from './markers.js';
|
|
15
|
+
|
|
16
|
+
export interface RunbookContext {
|
|
17
|
+
cliVersion: string;
|
|
18
|
+
/** Pre-rendered "Your data model" markdown (from the app's Models), or null/absent. */
|
|
19
|
+
dataModel?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** All slot placeholders share this prefix — a slot still starting with it counts as unfilled. */
|
|
23
|
+
export const PLACEHOLDER_PREFIX = '_(Fill in:';
|
|
24
|
+
|
|
25
|
+
export function headerStamp(reality: ProjectReality, ctx: RunbookContext): string {
|
|
26
|
+
return `<!-- everystack-runbook cli:${ctx.cliVersion} tier:${reality.tier} packages:${reality.packages.join(',')} models:${reality.models.files.length} -->`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gen(id: string, lines: string[]): Block {
|
|
30
|
+
return { kind: 'gen', id, content: lines.join('\n') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slot(name: string, placeholder: string): Block {
|
|
34
|
+
return { kind: 'slot', name, content: placeholder };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const has = (r: ProjectReality, pkg: string) => r.packages.includes(pkg);
|
|
38
|
+
|
|
39
|
+
function title(r: ProjectReality): Block {
|
|
40
|
+
return gen('title', [
|
|
41
|
+
`# Runbook — ${r.name}`,
|
|
42
|
+
'',
|
|
43
|
+
'> How to operate this app: configure, extend, deploy, monitor, maintain.',
|
|
44
|
+
`> Compiled by \`everystack runbook\` from the app's detected reality (tier ${r.tier}).`,
|
|
45
|
+
'> Generated sections belong to the compiler — change the app and regenerate;',
|
|
46
|
+
'> your words live in the marked slots and survive every regeneration.',
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function prerequisites(r: ProjectReality): Block {
|
|
51
|
+
const lines = [
|
|
52
|
+
'## Prerequisites & configuration',
|
|
53
|
+
'',
|
|
54
|
+
'- Node 22+ and pnpm. Install dependencies: `pnpm install`.',
|
|
55
|
+
'- AWS credentials for the deploy profile (SST resolves them at deploy time).',
|
|
56
|
+
'- Secrets are per stage, stored in SSM — never in a committed `.env`:',
|
|
57
|
+
' - `everystack secrets list --stage dev`',
|
|
58
|
+
' - `everystack secrets set KEY "value" --stage dev`',
|
|
59
|
+
' - `everystack secrets import .env.production --stage production`',
|
|
60
|
+
'- Never put a secret behind an `EXPO_PUBLIC_*` name — those compile into the',
|
|
61
|
+
' client bundle forever. `everystack bundle:audit` will catch it; don\'t make it.',
|
|
62
|
+
];
|
|
63
|
+
return gen('prerequisites', lines);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function projectMap(r: ProjectReality): Block {
|
|
67
|
+
const lines = ['## Project map', ''];
|
|
68
|
+
if (r.hasExpoApp) lines.push('- `app/` — Expo Router screens, navigation, API routes');
|
|
69
|
+
if (r.models.dir) {
|
|
70
|
+
lines.push(
|
|
71
|
+
`- \`${r.models.dir}/\` — \`defineModel\` tables (${r.models.files.length} model(s); the source of truth for schema + authz)`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (r.hasDatabase) lines.push('- `db/` — generated Drizzle schema (never edit by hand)');
|
|
75
|
+
if (r.migrationsDir) lines.push(`- \`${r.migrationsDir}/\` — generated migrations`);
|
|
76
|
+
if (r.handlers.api || r.handlers.worker || r.handlers.image || r.crons.length) {
|
|
77
|
+
const parts = [
|
|
78
|
+
r.handlers.api ? 'api.ts' : null,
|
|
79
|
+
r.handlers.worker ? 'worker.ts' : null,
|
|
80
|
+
r.handlers.image ? 'image.ts' : null,
|
|
81
|
+
r.crons.length ? 'cron/' : null,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
lines.push(`- \`server/\` — Lambda handlers (${parts.join(', ')})`);
|
|
84
|
+
}
|
|
85
|
+
if (r.hasSstConfig) lines.push('- `sst.config.ts` — AWS infrastructure definition');
|
|
86
|
+
lines.push('- `docs/RUNBOOK.md` — this file; regenerate with `everystack runbook`');
|
|
87
|
+
return gen('project-map', lines);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dailyWorkflow(): Block {
|
|
91
|
+
return gen('daily-workflow', [
|
|
92
|
+
'## Daily workflow',
|
|
93
|
+
'',
|
|
94
|
+
'- `pnpm dev` — start the Expo dev server',
|
|
95
|
+
'- `pnpm test` — run the tests; every feature starts with a failing test',
|
|
96
|
+
'- Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`); tests pass before commit',
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function deploy(r: ProjectReality): Block {
|
|
101
|
+
const lines = [
|
|
102
|
+
'## Deploy & release',
|
|
103
|
+
'',
|
|
104
|
+
'Two release paths — know which one a change needs:',
|
|
105
|
+
'',
|
|
106
|
+
'- `everystack deploy --stage dev` — deploy infrastructure + server code (SST).',
|
|
107
|
+
' Needed when server code, `sst.config.ts`, or native configuration changes.',
|
|
108
|
+
'- `everystack update --channel production --message "..."` — publish an OTA update',
|
|
109
|
+
' (JS and assets only, no redeploy). The path for app-code changes.',
|
|
110
|
+
'- `everystack channels list` — see the channels that exist.',
|
|
111
|
+
];
|
|
112
|
+
if (r.hasDatabase) {
|
|
113
|
+
lines.push('', 'After a deploy that includes new migrations: `everystack db:migrate --stage <name>`.');
|
|
114
|
+
}
|
|
115
|
+
return gen('deploy', lines);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extend(r: ProjectReality): Block {
|
|
119
|
+
const lines = [
|
|
120
|
+
'## Extend the app the right way',
|
|
121
|
+
'',
|
|
122
|
+
'These paths match the enforced gates — the routes that are hard to do wrong:',
|
|
123
|
+
'',
|
|
124
|
+
];
|
|
125
|
+
if (r.models.dir) {
|
|
126
|
+
lines.push(
|
|
127
|
+
'- **Add a table or field:** edit the Model in `models/`, run `everystack db:generate`,',
|
|
128
|
+
' review the emitted migration, then `everystack db:migrate`. Never hand-write a SQL',
|
|
129
|
+
' migration, never edit `db/schema.ts` — after any change `db:generate` must be a clean no-op.',
|
|
130
|
+
'- **Change who can read/write:** declare `can()` abilities on the Model — they compile',
|
|
131
|
+
' to RLS + grants. Never hand-write `CREATE POLICY` or `GRANT`.',
|
|
132
|
+
);
|
|
133
|
+
} else if (r.hasDatabase) {
|
|
134
|
+
lines.push(
|
|
135
|
+
'- **Schema changes:** migrate to Models (`everystack db:pull` writes `models/` from the',
|
|
136
|
+
' live database), then the flow is: edit Model → `everystack db:generate` → `db:migrate`.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (r.handlers.api) {
|
|
140
|
+
lines.push('- **Add an endpoint:** an `rpc` on the handler in `server/api.ts`.');
|
|
141
|
+
}
|
|
142
|
+
lines.push(
|
|
143
|
+
'- **Add a screen:** keep it thin — routing and composition only; reusable UI in shared',
|
|
144
|
+
' components, logic in `lib/`. `everystack ui:audit` flags drift.',
|
|
145
|
+
);
|
|
146
|
+
if (r.hasDatabase) {
|
|
147
|
+
lines.push(
|
|
148
|
+
'- **Add data:** it lives in PostgreSQL, served by the API. Never bundle a data file',
|
|
149
|
+
' into the app — `bundle:audit` gates it.',
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
lines.push(
|
|
153
|
+
'- **Need persistence or user accounts?** That is the V2 tier: add `@everystack/api` and',
|
|
154
|
+
' `@everystack/auth`, declare Models, and regenerate this runbook for the database chapters.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return gen('extend', lines);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function database(r: ProjectReality): Block {
|
|
161
|
+
return gen('database', [
|
|
162
|
+
'## Database operations',
|
|
163
|
+
'',
|
|
164
|
+
'- `everystack db:generate` — compile Models → next migration (schema + RLS + grants)',
|
|
165
|
+
'- `everystack db:migrate --stage <name>` — apply migrations on the deployed Lambda',
|
|
166
|
+
'- `everystack db:seed --stage dev` — seed the database (dev only)',
|
|
167
|
+
'- `everystack db:psql --stage dev` — psql into the stage database (credentials never exposed)',
|
|
168
|
+
'- `everystack db:doctor` — verify the security posture (RLS, roles, search_path)',
|
|
169
|
+
'',
|
|
170
|
+
'Backups & recovery:',
|
|
171
|
+
'',
|
|
172
|
+
'- `everystack db:backup [--stage <name>]` / `db:backups` — logical pg_dump to the private backups bucket',
|
|
173
|
+
'- `everystack db:restore --from <id> --confirm` — **destructive**: overwrites the stage database',
|
|
174
|
+
'- `everystack db:snapshot` / `db:snapshots` — physical RDS snapshot (instant DR)',
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function auth(): Block {
|
|
179
|
+
return gen('auth', [
|
|
180
|
+
'## Auth operations',
|
|
181
|
+
'',
|
|
182
|
+
'- JWT auth with refresh tokens; tokens are minted server-side, never in the client.',
|
|
183
|
+
'- Rotating the JWT secret: `everystack secrets set JwtSecret <new> --stage <name>` then',
|
|
184
|
+
' redeploy — existing sessions invalidate and users sign in again. Plan the window.',
|
|
185
|
+
'- OAuth provider credentials are per-stage secrets (`everystack secrets list` to see names).',
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function monitoring(r: ProjectReality): Block {
|
|
190
|
+
const lines = [
|
|
191
|
+
'## Monitoring',
|
|
192
|
+
'',
|
|
193
|
+
'- `everystack logs:errors --stage <name>` — recent server errors with cause + stack (start here for any 5xx)',
|
|
194
|
+
'- `everystack logs:tail --stage <name>` — follow live logs',
|
|
195
|
+
'- `everystack logs:query --stage <name>` — search structured logs',
|
|
196
|
+
];
|
|
197
|
+
if (has(r, 'admin')) {
|
|
198
|
+
lines.push('- Admin dashboard — Logs, Crashes, Issues, and Health screens over the same data');
|
|
199
|
+
}
|
|
200
|
+
return gen('monitoring', lines);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function security(): Block {
|
|
204
|
+
return gen('security', [
|
|
205
|
+
'## Security maintenance',
|
|
206
|
+
'',
|
|
207
|
+
'Run before a production deploy and after any migration:',
|
|
208
|
+
'',
|
|
209
|
+
'- `everystack security:audit` — white-box audit of the SQL surface',
|
|
210
|
+
'- `everystack db:doctor` — RLS, roles, and posture check',
|
|
211
|
+
'- `everystack db:authz:diff` — declared abilities vs live grants/policies',
|
|
212
|
+
'- `everystack security:probe --stage <name>` — live probing of the deployed surface',
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function workers(r: ProjectReality): Block {
|
|
217
|
+
const lines = [
|
|
218
|
+
'## Workers & queues',
|
|
219
|
+
'',
|
|
220
|
+
'- Background jobs run on SQS workers (`server/worker.ts`); failed jobs land in the',
|
|
221
|
+
' dead-letter queue — check it when work goes missing.',
|
|
222
|
+
];
|
|
223
|
+
if (r.crons.length) {
|
|
224
|
+
lines.push(`- Scheduled work in \`server/cron/\`: ${r.crons.map((c) => `\`${c}\``).join(', ')}`);
|
|
225
|
+
}
|
|
226
|
+
return gen('workers', lines);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function storageImages(r: ProjectReality): Block {
|
|
230
|
+
const lines = ['## Storage & images', ''];
|
|
231
|
+
if (has(r, 'storage')) {
|
|
232
|
+
lines.push('- File uploads use presigned S3 URLs (`@everystack/storage`); ownership is enforced server-side.');
|
|
233
|
+
}
|
|
234
|
+
if (has(r, 'images') || r.handlers.image) {
|
|
235
|
+
lines.push('- Images are processed on demand (`server/image.ts`) and delivered via the CDN.');
|
|
236
|
+
}
|
|
237
|
+
lines.push('- `everystack cache:purge` after replacing public assets in place.');
|
|
238
|
+
return gen('storage-images', lines);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function audits(): Block {
|
|
242
|
+
return gen('audits', [
|
|
243
|
+
'## Keep it healthy',
|
|
244
|
+
'',
|
|
245
|
+
'- `everystack audit --stage <name>` — the capstone: security + bundle + ui checks, one exit code',
|
|
246
|
+
'- `everystack bundle:audit <url>` — secret/data leaks + bundle weight on what actually ships',
|
|
247
|
+
'- `everystack ui:audit` — source-side design bloat (advisory, `file:line` + fix)',
|
|
248
|
+
'- `everystack audit:lighthouse` — perf/SEO (informational)',
|
|
249
|
+
'- `everystack runbook --check` — is this document current?',
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function troubleshoot(r: ProjectReality): Block {
|
|
254
|
+
const lines = [
|
|
255
|
+
'## Troubleshoot',
|
|
256
|
+
'',
|
|
257
|
+
'- `everystack diag` — environment + deployed-stack diagnosis',
|
|
258
|
+
'- Dev server misbehaving: restart with a clear cache — `npx expo start -c`',
|
|
259
|
+
];
|
|
260
|
+
if (has(r, 'logging')) {
|
|
261
|
+
lines.push('- Deployed 5xx: `everystack logs:errors --stage <name>` first — it carries cause + stack.');
|
|
262
|
+
}
|
|
263
|
+
return gen('troubleshoot', lines);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The full ordered structure for a detected reality. Deterministic. */
|
|
267
|
+
export function buildStructure(reality: ProjectReality, ctx: RunbookContext): Block[] {
|
|
268
|
+
const blocks: Block[] = [
|
|
269
|
+
title(reality),
|
|
270
|
+
slot(
|
|
271
|
+
'overview',
|
|
272
|
+
'_(Fill in: what this app is, who it serves, anything an operator should know first. An agent connected to the everystack MCP can fill this via the `runbook` prompt.)_',
|
|
273
|
+
),
|
|
274
|
+
prerequisites(reality),
|
|
275
|
+
projectMap(reality),
|
|
276
|
+
dailyWorkflow(),
|
|
277
|
+
deploy(reality),
|
|
278
|
+
slot(
|
|
279
|
+
'stage-notes',
|
|
280
|
+
'_(Fill in: deployed URLs per stage, channel-to-stage mapping, release cadence.)_',
|
|
281
|
+
),
|
|
282
|
+
extend(reality),
|
|
283
|
+
];
|
|
284
|
+
if (reality.hasDatabase) {
|
|
285
|
+
blocks.push(database(reality));
|
|
286
|
+
if (ctx.dataModel) blocks.push({ kind: 'gen', id: 'data-model', content: ctx.dataModel });
|
|
287
|
+
}
|
|
288
|
+
if (has(reality, 'auth')) blocks.push(auth());
|
|
289
|
+
if (has(reality, 'logging')) blocks.push(monitoring(reality));
|
|
290
|
+
if (reality.hasDatabase) blocks.push(security());
|
|
291
|
+
if (has(reality, 'jobs') || reality.handlers.worker) blocks.push(workers(reality));
|
|
292
|
+
if (has(reality, 'storage') || has(reality, 'images') || reality.handlers.image) {
|
|
293
|
+
blocks.push(storageImages(reality));
|
|
294
|
+
}
|
|
295
|
+
blocks.push(audits(), troubleshoot(reality));
|
|
296
|
+
blocks.push(
|
|
297
|
+
slot(
|
|
298
|
+
'notes',
|
|
299
|
+
'_(Fill in: anything project-specific that does not fit a generated section — escalation contacts, oddities, tribal knowledge.)_',
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
return blocks;
|
|
303
|
+
}
|