@oaklandzoo/ostup 0.9.1 → 0.11.0

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.
Files changed (67) hide show
  1. package/README.md +15 -0
  2. package/bin/cli.mjs +28 -2
  3. package/package.json +1 -1
  4. package/scripts/verify-profile.sh +46 -0
  5. package/src/brief/profile-router.mjs +63 -28
  6. package/src/doctor-privacy.mjs +103 -0
  7. package/src/doctor.mjs +7 -1
  8. package/src/mvp-flow.mjs +10 -0
  9. package/src/private-cmd.mjs +35 -0
  10. package/src/private.mjs +215 -0
  11. package/templates/.claude/commands/add-storage.md +31 -1
  12. package/templates/START_HERE.md +1 -1
  13. package/templates/private/CLAUDE_PART_20.md +30 -0
  14. package/templates/private/api-audit-health.ts +9 -0
  15. package/templates/private/api-blob-proxy.ts +40 -0
  16. package/templates/private/lib-audit.ts +18 -0
  17. package/templates/private/lib-rate-limit-kv.ts +21 -0
  18. package/templates/private/lib-rate-limit-memory.ts +25 -0
  19. package/templates/private/middleware.ts +60 -0
  20. package/templates/private/next.config.ts +14 -0
  21. package/templates/profiles/blog/README.md +45 -40
  22. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  23. package/templates/profiles/blog/app/page.tsx +30 -0
  24. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  25. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  26. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  27. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  28. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  29. package/templates/profiles/blog/lib/posts.ts +43 -0
  30. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  31. package/templates/profiles/blog/package.json.additions +6 -0
  32. package/templates/profiles/booking/.env.example.additions +3 -11
  33. package/templates/profiles/booking/README.md +26 -21
  34. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  35. package/templates/profiles/booking/app/page.tsx +38 -0
  36. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  37. package/templates/profiles/booking/components/Hero.tsx +20 -0
  38. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  39. package/templates/profiles/booking/lib/resend.ts +33 -0
  40. package/templates/profiles/booking/lib/storage.ts +44 -0
  41. package/templates/profiles/booking/package.json.additions +5 -0
  42. package/templates/profiles/booking/section-prompts.md +10 -8
  43. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  44. package/templates/profiles/lead-gen/README.md +35 -27
  45. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  46. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  47. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  48. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  49. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  50. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  51. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  52. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  53. package/templates/profiles/lead-gen/package.json.additions +5 -0
  54. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  55. package/templates/profiles/saas-dashboard/README.md +43 -36
  56. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  57. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  58. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  59. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  60. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  61. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  62. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  63. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  64. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  65. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  66. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  67. package/templates/profiles/saas-dashboard/package.json.additions +9 -0
package/README.md CHANGED
@@ -17,6 +17,21 @@ When you run this tool, it will:
17
17
  6. Create an `inputs/` folder inside the project where you can drop any
18
18
  prior materials (research, reference repos, screenshots, brand
19
19
  assets) you want the agent to have on hand
20
+ 7. If you ran `ostup brief` first (recommended), drop in **working code
21
+ for your profile**. The four industry profiles ship real Next.js
22
+ overlays: lead-gen (hero + services + contact form + Resend wiring),
23
+ booking (booking form + dates + email confirmation), saas-dashboard
24
+ (Better Auth + guarded dashboard + settings), blog (MDX posts + RSS
25
+ + sitemap). Your first deploy shows a working homepage and day-one
26
+ flow, not a blank Next.js welcome.
27
+ 8. Optionally pass `--private` to ship an **app-level default-deny**
28
+ project on Vercel Hobby: middleware blocks every request except
29
+ sign-in/up + audit-health, blob helpers default to `access: 'private'`,
30
+ image optimizer cannot proxy external URLs, rate limiting and audit
31
+ logging are wired. Run `ostup doctor --privacy <url>` after deploy to
32
+ verify. Toggle on/off an existing project with `ostup private add` /
33
+ `ostup private remove` (manifest at `.ostup/private.json` makes the
34
+ remove cleanly revertable).
20
35
 
21
36
  ## Quick Start
22
37
 
package/bin/cli.mjs CHANGED
@@ -11,7 +11,7 @@ loadDotEnv();
11
11
 
12
12
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
13
 
14
- const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap']);
14
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap', 'private']);
15
15
 
16
16
  async function readPkg() {
17
17
  const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
@@ -45,6 +45,10 @@ function parseArgs(argv) {
45
45
  else if (a === '--output') flags.output = argv[++i];
46
46
  else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
47
47
  else if (a === '--white-label') flags.whiteLabel = true;
48
+ else if (a === '--private') flags.private = true;
49
+ else if (a === '--privacy') flags.privacy = true;
50
+ else if (a === '--url') flags.url = argv[++i];
51
+ else if (a.startsWith('--url=')) flags.url = a.slice('--url='.length);
48
52
  else if (a === '--no-log') flags.noLog = true;
49
53
  else if (a === '--skip-bootstrap') flags.skipBootstrap = true;
50
54
  else if (a === '--no-install') flags.noInstall = true;
@@ -78,6 +82,8 @@ function printHelp() {
78
82
  ' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
79
83
  ' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
80
84
  ' doctor Self-diagnosis: tool detection + auth + permissions + disk + Chrome. Read-only.',
85
+ ' doctor --privacy Probe a deployed URL for default-deny enforcement (privacy mode).',
86
+ ' private add|remove|status Apply, undo, or report on app-level privacy (default-deny middleware).',
81
87
  ' update Refresh bundled templates from the pinned source.',
82
88
  '',
83
89
  'Flags for `ostup init`:',
@@ -89,6 +95,7 @@ function printHelp() {
89
95
  ' --ingest <path> Copy operator materials from <path> into inputs/.',
90
96
  ' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
91
97
  ' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
98
+ ' --private App-level default-deny: middleware, blob proxy, image-optimizer lock, audit + rate-limit, CLAUDE.md Part 20. Refused with --profile saas-dashboard.',
92
99
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
93
100
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
94
101
  ' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
@@ -193,7 +200,7 @@ if (subcommand === 'export-pro') {
193
200
  if (subcommand === 'doctor') {
194
201
  const { runDoctor, printDoctorReport } = await import('../src/doctor.mjs');
195
202
  try {
196
- const result = await runDoctor();
203
+ const result = await runDoctor({ flags });
197
204
  process.stdout.write(printDoctorReport(result));
198
205
  process.exit(result.failCount > 0 ? 1 : 0);
199
206
  } catch (err) {
@@ -202,6 +209,25 @@ if (subcommand === 'doctor') {
202
209
  }
203
210
  }
204
211
 
212
+ if (subcommand === 'private') {
213
+ const action = subPositional[0] || 'status';
214
+ const { runPrivate } = await import('../src/private-cmd.mjs');
215
+ try {
216
+ await runPrivate({ action, flags, cwd: process.cwd() });
217
+ process.exit(0);
218
+ } catch (err) {
219
+ process.stderr.write(`${err.message}\n`);
220
+ const userErrors = new Set([
221
+ 'PRIVATE_SAAS_CONFLICT',
222
+ 'PRIVATE_NOT_APPLIED',
223
+ 'PRIVATE_ALREADY_APPLIED',
224
+ 'PRIVATE_DIRTY',
225
+ 'PRIVATE_UNKNOWN_ACTION',
226
+ ]);
227
+ process.exit(userErrors.has(err.code) ? 1 : 2);
228
+ }
229
+ }
230
+
205
231
  if (subcommand === 'bootstrap') {
206
232
  const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
207
233
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffolds a new repo with the Ostup Agent Kit pre-installed: slash commands, doc templates, and a clean working state.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # verify-profile.sh: apply a profile overlay to a fresh temp dir and assert key files exist.
3
+ #
4
+ # Usage: bash scripts/verify-profile.sh <profile-name>
5
+ # profile-name: one of lead-gen, booking, saas-dashboard, blog
6
+ #
7
+ # This script does NOT run `next build` (that requires installing many deps and a
8
+ # full Next.js scaffold). It verifies that the overlay APPLIES correctly and the
9
+ # expected files land in the target directory. Full next-build verification is
10
+ # the operator's job after `ostup init --brief`.
11
+
12
+ set -euo pipefail
13
+
14
+ PROFILE="${1:-}"
15
+ if [[ -z "$PROFILE" ]]; then
16
+ echo "usage: $0 <profile-name>" >&2
17
+ echo " profile-name: lead-gen | booking | saas-dashboard | blog" >&2
18
+ exit 2
19
+ fi
20
+
21
+ case "$PROFILE" in
22
+ lead-gen|booking|saas-dashboard|blog)
23
+ ;;
24
+ *)
25
+ echo "error: unknown profile '$PROFILE'. expected one of: lead-gen, booking, saas-dashboard, blog" >&2
26
+ exit 2
27
+ ;;
28
+ esac
29
+
30
+ OSTUP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
31
+ TEST_FILE="$OSTUP_ROOT/test/profile-$(echo "$PROFILE" | tr -d -)-overlay.test.mjs"
32
+
33
+ # Map profile names to test file names: lead-gen -> profile-leadgen-overlay.test.mjs
34
+ case "$PROFILE" in
35
+ lead-gen) TEST_FILE="$OSTUP_ROOT/test/profile-leadgen-overlay.test.mjs" ;;
36
+ booking) TEST_FILE="$OSTUP_ROOT/test/profile-booking-overlay.test.mjs" ;;
37
+ saas-dashboard) TEST_FILE="$OSTUP_ROOT/test/profile-saas-overlay.test.mjs" ;;
38
+ blog) TEST_FILE="$OSTUP_ROOT/test/profile-blog-overlay.test.mjs" ;;
39
+ esac
40
+
41
+ echo ">> verifying profile: $PROFILE"
42
+ echo ">> running: node --test $TEST_FILE"
43
+ cd "$OSTUP_ROOT"
44
+ node --test "$TEST_FILE"
45
+
46
+ echo ">> ok: $PROFILE overlay applies and asserts pass"
@@ -9,18 +9,11 @@ import { substitute } from '../substitute.mjs';
9
9
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
10
  const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
11
11
 
12
- /**
13
- * Returns the absolute path to the profile's template overlay folder.
14
- * Returns null if the profile has no overlay (yet).
15
- */
16
12
  export function profileTemplatePath(profile) {
17
13
  const p = resolve(TEMPLATES_ROOT, 'profiles', profile);
18
14
  return existsSync(p) ? p : null;
19
15
  }
20
16
 
21
- /**
22
- * Recursively list every file in a directory, returning relative paths.
23
- */
24
17
  async function listFiles(dir, base = dir, out = []) {
25
18
  const entries = await readdir(dir, { withFileTypes: true });
26
19
  for (const entry of entries) {
@@ -34,19 +27,51 @@ async function listFiles(dir, base = dir, out = []) {
34
27
  return out;
35
28
  }
36
29
 
37
- /**
38
- * Apply a profile overlay to the target directory.
39
- * - Reads every file in templates/profiles/<profile>/
40
- * - Substitutes tokens
41
- * - Writes to <targetDir>/<relative-path>
42
- *
43
- * Skip rules:
44
- * - .DS_Store
45
- * - any path containing /node_modules/
46
- *
47
- * Files with name suffix `.additions` are appended to the existing file at the
48
- * destination (used for .env.example.additions). All others are written.
49
- */
30
+ function mergeJsonObjects(target, source) {
31
+ const out = { ...target };
32
+ for (const key of Object.keys(source)) {
33
+ const sv = source[key];
34
+ const tv = out[key];
35
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
36
+ out[key] = { ...tv, ...sv };
37
+ } else {
38
+ out[key] = sv;
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export async function mergePackageJsonAdditions(destPath, additionsText) {
45
+ let additions;
46
+ try {
47
+ additions = JSON.parse(additionsText);
48
+ } catch (err) {
49
+ throw new Error(`Invalid JSON in package.json.additions: ${err.message}`);
50
+ }
51
+ let existing = {};
52
+ if (existsSync(destPath)) {
53
+ const raw = await readFile(destPath, 'utf8');
54
+ try {
55
+ existing = JSON.parse(raw);
56
+ } catch (err) {
57
+ throw new Error(`Invalid JSON in target package.json at ${destPath}: ${err.message}`);
58
+ }
59
+ }
60
+ const merged = mergeJsonObjects(existing, additions);
61
+ await writeFile(destPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
62
+ }
63
+
64
+ export async function mergeNextConfigAdditions(destPath, additionsText) {
65
+ const marker = '--- added by ostup profile overlay ---';
66
+ if (existsSync(destPath)) {
67
+ const existing = await readFile(destPath, 'utf8');
68
+ if (existing.includes(marker)) return;
69
+ await writeFile(destPath, existing + `\n// ${marker}\n` + additionsText, 'utf8');
70
+ } else {
71
+ await writeFile(destPath, `// next.config.mjs (created by ostup profile overlay)\n` + additionsText, 'utf8');
72
+ }
73
+ }
74
+
50
75
  export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun = false } = {}) {
51
76
  const overlayPath = profileTemplatePath(profile);
52
77
  if (!overlayPath) {
@@ -59,12 +84,19 @@ export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun =
59
84
  const actions = [];
60
85
  for (const rel of filtered) {
61
86
  const src = join(overlayPath, rel);
62
- const isAddition = rel.endsWith('.additions');
63
- const dest = isAddition
64
- ? join(targetDir, rel.replace(/\.additions$/, ''))
65
- : join(targetDir, rel);
66
-
67
- actions.push({ rel, src, dest, isAddition });
87
+ let kind = 'copy';
88
+ let dest = join(targetDir, rel);
89
+ if (rel.endsWith('package.json.additions')) {
90
+ kind = 'package-merge';
91
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
92
+ } else if (rel.endsWith('next.config.mjs.additions')) {
93
+ kind = 'next-config-merge';
94
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
95
+ } else if (rel.endsWith('.additions')) {
96
+ kind = 'append';
97
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
98
+ }
99
+ actions.push({ rel, src, dest, kind });
68
100
  }
69
101
 
70
102
  if (dryRun) {
@@ -75,9 +107,12 @@ export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun =
75
107
  const raw = await readFile(a.src, 'utf8');
76
108
  const out = substitute(raw, tokens);
77
109
  await mkdir(dirname(a.dest), { recursive: true });
78
- if (a.isAddition && existsSync(a.dest)) {
110
+ if (a.kind === 'package-merge') {
111
+ await mergePackageJsonAdditions(a.dest, out);
112
+ } else if (a.kind === 'next-config-merge') {
113
+ await mergeNextConfigAdditions(a.dest, out);
114
+ } else if (a.kind === 'append' && existsSync(a.dest)) {
79
115
  const existing = await readFile(a.dest, 'utf8');
80
- // Append with a separator if not already present
81
116
  const sep = existing.endsWith('\n') ? '' : '\n';
82
117
  await writeFile(a.dest, existing + sep + '\n# --- added by ostup profile overlay ---\n' + out, 'utf8');
83
118
  } else {
@@ -0,0 +1,103 @@
1
+ // doctor-privacy.mjs: HTTP probes for ostup doctor --privacy.
2
+ // Auto-detects deployed URL from .vercel/project.json; --url overrides.
3
+
4
+ import { readFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const PROBE_PATHS = [
9
+ '/api/health',
10
+ '/api/contact',
11
+ '/api/booking/request',
12
+ '/api/account/settings',
13
+ '/api/blob/test.jpg',
14
+ '/uploads/test.jpg',
15
+ '/_next/image?url=https%3A%2F%2Fexample.com%2Ftest.jpg&w=64&q=75',
16
+ '/dashboard',
17
+ '/dashboard/settings',
18
+ ];
19
+
20
+ const AUDIT_HEALTH_PATH = '/api/audit/health';
21
+
22
+ export async function detectDeployedUrl(cwd = process.cwd()) {
23
+ const projectJson = join(cwd, '.vercel', 'project.json');
24
+ if (!existsSync(projectJson)) return null;
25
+ try {
26
+ const raw = await readFile(projectJson, 'utf8');
27
+ const data = JSON.parse(raw);
28
+ if (data.productionDomain) {
29
+ const host = String(data.productionDomain).replace(/^https?:\/\//, '');
30
+ return `https://${host}`;
31
+ }
32
+ if (data.production_alias) {
33
+ const host = String(data.production_alias).replace(/^https?:\/\//, '');
34
+ return `https://${host}`;
35
+ }
36
+ if (data.projectId) {
37
+ return null; // We could shell out to `vercel inspect`, but skip in v1.
38
+ }
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ async function probeOne(baseUrl, path, fetchImpl = fetch) {
46
+ const url = `${baseUrl.replace(/\/+$/, '')}${path}`;
47
+ try {
48
+ const res = await fetchImpl(url, {
49
+ method: 'GET',
50
+ redirect: 'manual',
51
+ headers: { 'user-agent': 'ostup-doctor-privacy/1.0' },
52
+ });
53
+ return { url, status: res.status, ok: res.status === 401 || res.status === 404 || res.status === 403 || (res.status >= 300 && res.status < 400) };
54
+ } catch (err) {
55
+ return { url, status: 0, ok: false, error: err instanceof Error ? err.message : String(err) };
56
+ }
57
+ }
58
+
59
+ async function probeAuditHealth(baseUrl, fetchImpl = fetch) {
60
+ const url = `${baseUrl.replace(/\/+$/, '')}${AUDIT_HEALTH_PATH}`;
61
+ try {
62
+ const res = await fetchImpl(url, { method: 'GET', headers: { 'user-agent': 'ostup-doctor-privacy/1.0' } });
63
+ if (!res.ok) return { url, status: res.status, ok: false };
64
+ const body = await res.json().catch(() => null);
65
+ return { url, status: res.status, ok: body?.ok === true, body };
66
+ } catch (err) {
67
+ return { url, status: 0, ok: false, error: err instanceof Error ? err.message : String(err) };
68
+ }
69
+ }
70
+
71
+ export async function runPrivacyChecks({ url, cwd = process.cwd(), report, fetchImpl = fetch } = {}) {
72
+ let baseUrl = url;
73
+ if (!baseUrl) baseUrl = await detectDeployedUrl(cwd);
74
+ if (!baseUrl) {
75
+ report('fail', 'privacy url', 'could not resolve deployed URL. Pass --url <vercel-url> or run from a Vercel-linked project dir.');
76
+ return;
77
+ }
78
+ report('ok', 'privacy url', baseUrl);
79
+
80
+ for (const p of PROBE_PATHS) {
81
+ const result = await probeOne(baseUrl, p, fetchImpl);
82
+ if (result.error) {
83
+ report('warn', `probe ${p}`, `network error: ${result.error}`);
84
+ continue;
85
+ }
86
+ if (result.ok) {
87
+ report('ok', `probe ${p}`, `${result.status} (denied as expected)`);
88
+ } else {
89
+ report('fail', `probe ${p}`, `${result.status} (expected 401/403/404/redirect; got 2xx)`);
90
+ }
91
+ }
92
+
93
+ const audit = await probeAuditHealth(baseUrl, fetchImpl);
94
+ if (audit.error) {
95
+ report('warn', 'probe audit-health', `network error: ${audit.error}`);
96
+ } else if (audit.ok) {
97
+ report('ok', 'probe audit-health', `${audit.status} (audit pipeline wired)`);
98
+ } else {
99
+ report('fail', 'probe audit-health', `${audit.status} (audit pipeline NOT wired)`);
100
+ }
101
+ }
102
+
103
+ export { PROBE_PATHS, AUDIT_HEALTH_PATH };
package/src/doctor.mjs CHANGED
@@ -25,7 +25,7 @@ function runCapture(cmd) {
25
25
  }
26
26
  }
27
27
 
28
- export async function runDoctor() {
28
+ export async function runDoctor({ flags = {} } = {}) {
29
29
  const findings = [];
30
30
  let okCount = 0;
31
31
  let failCount = 0;
@@ -38,6 +38,12 @@ export async function runDoctor() {
38
38
  else warnCount++;
39
39
  }
40
40
 
41
+ if (flags.privacy) {
42
+ const { runPrivacyChecks } = await import('./doctor-privacy.mjs');
43
+ await runPrivacyChecks({ url: flags.url, report });
44
+ return { findings, okCount, warnCount, failCount };
45
+ }
46
+
41
47
  // === Tool detection (shared with bootstrap) ===
42
48
  const detection = detectAll();
43
49
  for (const f of detection.found) {
package/src/mvp-flow.mjs CHANGED
@@ -134,6 +134,16 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
134
134
  }
135
135
  }
136
136
 
137
+ if (flags.private) {
138
+ const { applyPrivate } = await import('./private.mjs');
139
+ const profile = brief?.scaffold?.profile || null;
140
+ const priv = await applyPrivate({ targetDir, profile });
141
+ process.stdout.write(
142
+ `[private] applied default-deny. ${priv.touched.length} file(s) touched. ` +
143
+ `rate-limit backend: ${priv.useKvRateLimit ? 'kv' : 'in-memory'}\n`,
144
+ );
145
+ }
146
+
137
147
  await exec('git-init', 'git', ['init'], { cwd: targetDir });
138
148
  await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
139
149
  await exec('git-add', 'git', ['add', '.'], { cwd: targetDir });
@@ -0,0 +1,35 @@
1
+ // private-cmd.mjs: ostup private add|remove|status subcommand.
2
+
3
+ import { applyPrivate, removePrivate, statusPrivate, PrivateError } from './private.mjs';
4
+
5
+ export async function runPrivate({ action = 'status', flags = {}, cwd = process.cwd() } = {}) {
6
+ if (action === 'add') {
7
+ const result = await applyPrivate({ targetDir: cwd, force: !!flags.force });
8
+ process.stdout.write(
9
+ `[private] applied. ${result.touched.length} file(s) touched. ` +
10
+ `rate-limit backend: ${result.useKvRateLimit ? 'kv' : 'in-memory'}\n`,
11
+ );
12
+ process.stdout.write(`[private] manifest: .ostup/private.json (run 'ostup private remove' to undo)\n`);
13
+ return;
14
+ }
15
+ if (action === 'remove') {
16
+ const result = await removePrivate({ targetDir: cwd, force: !!flags.force });
17
+ process.stdout.write(`[private] removed. ${result.restored.length} file(s) reverted.\n`);
18
+ return;
19
+ }
20
+ if (action === 'status') {
21
+ const result = await statusPrivate({ targetDir: cwd });
22
+ if (!result.applied) {
23
+ process.stdout.write(`[private] not applied. Run 'ostup private add' to enable.\n`);
24
+ return;
25
+ }
26
+ process.stdout.write(`[private] applied at ${result.manifest.appliedAt}\n`);
27
+ process.stdout.write(`[private] rate-limit backend: ${result.manifest.useKvRateLimit ? 'kv' : 'in-memory'}\n`);
28
+ process.stdout.write(`[private] files under management:\n`);
29
+ for (const op of result.manifest.ops) {
30
+ process.stdout.write(` ${op.kind === 'created' ? '+' : '~'} ${op.path}\n`);
31
+ }
32
+ return;
33
+ }
34
+ throw new PrivateError('PRIVATE_UNKNOWN_ACTION', `unknown action '${action}'. Use: add | remove | status`);
35
+ }
@@ -0,0 +1,215 @@
1
+ // private.mjs: applyPrivate post-processor. Lays down default-deny middleware,
2
+ // blob proxy, audit + rate-limit libs, locked next.config, CLAUDE.md Part 20.
3
+ // Captures original content of patched files for clean rollback by `ostup private remove`.
4
+
5
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const PRIVATE_TEMPLATES = resolve(PKG_ROOT, 'templates', 'private');
12
+
13
+ const SAAS_CONFLICT_MSG =
14
+ '--private is not compatible with the saas-dashboard profile. ' +
15
+ 'saas-dashboard already provides Better Auth + a session-checking middleware.ts. ' +
16
+ 'Layering default-deny on top creates two competing matchers.\n\n' +
17
+ 'Pick one:\n' +
18
+ ' - Use --private alone for a generic single-user-protected app\n' +
19
+ ' - Use --profile saas-dashboard alone for the SaaS shell';
20
+
21
+ export const PRIVATE_MANIFEST_PATH = '.ostup/private.json';
22
+
23
+ class PrivateError extends Error {
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ function detectSaas(targetDir, profile) {
31
+ if (profile === 'saas-dashboard') return true;
32
+ const pkgPath = join(targetDir, 'package.json');
33
+ if (!existsSync(pkgPath)) return false;
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
36
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
37
+ return Boolean(deps['better-auth']);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function plannedOperations(targetDir, useKvRateLimit) {
44
+ // Each op: { kind: 'create'|'patch', dest: <relative path>, src: <template relpath>|null, patcher?: fn }
45
+ const rateLimitSrc = useKvRateLimit ? 'lib-rate-limit-kv.ts' : 'lib-rate-limit-memory.ts';
46
+ return [
47
+ { kind: 'create', dest: 'middleware.ts', src: 'middleware.ts' },
48
+ { kind: 'create', dest: 'next.config.ts', src: 'next.config.ts' },
49
+ { kind: 'create', dest: 'app/api/blob/[...path]/route.ts', src: 'api-blob-proxy.ts' },
50
+ { kind: 'create', dest: 'app/api/audit/health/route.ts', src: 'api-audit-health.ts' },
51
+ { kind: 'create', dest: 'lib/audit.ts', src: 'lib-audit.ts' },
52
+ { kind: 'create', dest: 'lib/rate-limit.ts', src: rateLimitSrc },
53
+ { kind: 'patch', dest: 'CLAUDE.md', src: 'CLAUDE_PART_20.md', mode: 'append' },
54
+ { kind: 'patch', dest: 'app/layout.tsx', src: null, mode: 'layout-robots' },
55
+ ];
56
+ }
57
+
58
+ async function loadTemplate(rel) {
59
+ return readFile(join(PRIVATE_TEMPLATES, rel), 'utf8');
60
+ }
61
+
62
+ function patchLayoutRobots(body) {
63
+ // Inject `robots: { index: false, follow: false }` into the metadata export, if present.
64
+ // Idempotent: skip if already contains "robots:".
65
+ if (/robots\s*:/.test(body)) return body;
66
+ // Match: export const metadata: Metadata = { ... };
67
+ const re = /(export\s+const\s+metadata\s*:\s*Metadata\s*=\s*\{)([^}]*)(\})/;
68
+ if (!re.test(body)) return body;
69
+ return body.replace(re, (_full, open, inner, close) => {
70
+ const trimmed = inner.replace(/\s*$/, '');
71
+ const sep = trimmed.endsWith(',') ? '' : (trimmed.length > 0 ? ',' : '');
72
+ const insert = `${trimmed}${sep}\n robots: { index: false, follow: false },\n`;
73
+ return `${open}${insert}${close}`;
74
+ });
75
+ }
76
+
77
+ function envMentions(targetDir, names) {
78
+ for (const file of ['.env', '.env.local', '.env.production', '.env.example']) {
79
+ const p = join(targetDir, file);
80
+ if (!existsSync(p)) continue;
81
+ try {
82
+ const body = readFileSync(p, 'utf8');
83
+ for (const n of names) {
84
+ if (new RegExp(`^${n}=`, 'm').test(body)) return true;
85
+ }
86
+ } catch {}
87
+ }
88
+ return false;
89
+ }
90
+
91
+ export async function applyPrivate({ targetDir, profile = null, force = false } = {}) {
92
+ if (detectSaas(targetDir, profile)) {
93
+ throw new PrivateError('PRIVATE_SAAS_CONFLICT', SAAS_CONFLICT_MSG);
94
+ }
95
+
96
+ const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
97
+ if (existsSync(manifestPath) && !force) {
98
+ throw new PrivateError(
99
+ 'PRIVATE_ALREADY_APPLIED',
100
+ `${PRIVATE_MANIFEST_PATH} already exists. Run 'ostup private remove' first, or pass --force.`,
101
+ );
102
+ }
103
+
104
+ const useKv = envMentions(targetDir, ['KV_URL', 'KV_REST_API_URL']);
105
+ const ops = plannedOperations(targetDir, useKv);
106
+
107
+ const performed = [];
108
+
109
+ for (const op of ops) {
110
+ const destPath = join(targetDir, op.dest);
111
+
112
+ if (op.kind === 'create') {
113
+ if (existsSync(destPath) && !force) {
114
+ throw new PrivateError(
115
+ 'PRIVATE_DIRTY',
116
+ `${op.dest} already exists; refusing to overwrite without --force.`,
117
+ );
118
+ }
119
+ const content = await loadTemplate(op.src);
120
+ await mkdir(dirname(destPath), { recursive: true });
121
+ await writeFile(destPath, content, 'utf8');
122
+ performed.push({ kind: 'created', path: op.dest });
123
+ } else if (op.kind === 'patch') {
124
+ if (!existsSync(destPath)) {
125
+ // Patching a missing file is a soft-skip (e.g. no app/layout.tsx exists).
126
+ continue;
127
+ }
128
+ const prior = await readFile(destPath, 'utf8');
129
+ let next;
130
+ if (op.mode === 'append') {
131
+ const addition = await loadTemplate(op.src);
132
+ if (prior.includes('# PART 20: PRIVACY DEFAULT-DENY')) {
133
+ continue;
134
+ }
135
+ next = prior + addition;
136
+ } else if (op.mode === 'layout-robots') {
137
+ next = patchLayoutRobots(prior);
138
+ if (next === prior) continue;
139
+ } else {
140
+ continue;
141
+ }
142
+ await writeFile(destPath, next, 'utf8');
143
+ performed.push({ kind: 'patched', path: op.dest, prior });
144
+ }
145
+ }
146
+
147
+ await mkdir(dirname(manifestPath), { recursive: true });
148
+ await writeFile(
149
+ manifestPath,
150
+ JSON.stringify(
151
+ {
152
+ appliedAt: new Date().toISOString(),
153
+ useKvRateLimit: useKv,
154
+ ops: performed,
155
+ },
156
+ null,
157
+ 2,
158
+ ) + '\n',
159
+ 'utf8',
160
+ );
161
+
162
+ return { applied: true, touched: performed.map((p) => p.path), useKvRateLimit: useKv };
163
+ }
164
+
165
+ export async function removePrivate({ targetDir, force = false } = {}) {
166
+ const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
167
+ if (!existsSync(manifestPath)) {
168
+ throw new PrivateError('PRIVATE_NOT_APPLIED', `${PRIVATE_MANIFEST_PATH} not found; nothing to remove.`);
169
+ }
170
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
171
+ const restored = [];
172
+
173
+ // Walk in reverse so dependent files come back in the right order.
174
+ for (const op of [...manifest.ops].reverse()) {
175
+ const p = join(targetDir, op.path);
176
+ if (op.kind === 'created') {
177
+ if (existsSync(p)) {
178
+ await rm(p, { force: true });
179
+ restored.push({ kind: 'deleted', path: op.path });
180
+ }
181
+ } else if (op.kind === 'patched') {
182
+ if (!existsSync(p)) {
183
+ if (!force) {
184
+ throw new PrivateError(
185
+ 'PRIVATE_DIRTY',
186
+ `${op.path} is gone; cannot restore. Re-add the file or pass --force to skip.`,
187
+ );
188
+ }
189
+ continue;
190
+ }
191
+ // Optional: integrity check against prior. Skipped in v1.
192
+ await writeFile(p, op.prior, 'utf8');
193
+ restored.push({ kind: 'restored', path: op.path });
194
+ }
195
+ }
196
+
197
+ await rm(manifestPath, { force: true });
198
+ // Best-effort: remove empty .ostup/ dir.
199
+ try {
200
+ await rm(join(targetDir, '.ostup'), { recursive: false });
201
+ } catch {}
202
+
203
+ return { restored: restored.map((r) => r.path) };
204
+ }
205
+
206
+ export async function statusPrivate({ targetDir } = {}) {
207
+ const manifestPath = join(targetDir, PRIVATE_MANIFEST_PATH);
208
+ if (!existsSync(manifestPath)) {
209
+ return { applied: false, manifest: null };
210
+ }
211
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
212
+ return { applied: true, manifest };
213
+ }
214
+
215
+ export { PrivateError };