@oaklandzoo/ostup 0.9.0 → 0.10.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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/scripts/verify-profile.sh +46 -0
- package/src/bootstrap.mjs +146 -0
- package/src/brief/profile-router.mjs +63 -28
- package/templates/START_HERE.md +1 -1
- package/templates/profiles/blog/README.md +45 -40
- package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
- package/templates/profiles/blog/app/page.tsx +30 -0
- package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
- package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
- package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
- package/templates/profiles/blog/content/posts/intro.mdx +14 -0
- package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
- package/templates/profiles/blog/lib/posts.ts +43 -0
- package/templates/profiles/blog/next.config.mjs.additions +9 -0
- package/templates/profiles/blog/package.json.additions +6 -0
- package/templates/profiles/booking/.env.example.additions +3 -11
- package/templates/profiles/booking/README.md +26 -21
- package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
- package/templates/profiles/booking/app/page.tsx +38 -0
- package/templates/profiles/booking/components/BookingForm.tsx +130 -0
- package/templates/profiles/booking/components/Hero.tsx +20 -0
- package/templates/profiles/booking/components/ServiceList.tsx +19 -0
- package/templates/profiles/booking/lib/resend.ts +33 -0
- package/templates/profiles/booking/lib/storage.ts +44 -0
- package/templates/profiles/booking/package.json.additions +5 -0
- package/templates/profiles/booking/section-prompts.md +10 -8
- package/templates/profiles/lead-gen/.env.example.additions +2 -2
- package/templates/profiles/lead-gen/README.md +35 -27
- package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
- package/templates/profiles/lead-gen/app/layout.tsx +29 -0
- package/templates/profiles/lead-gen/app/page.tsx +79 -0
- package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
- package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
- package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
- package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
- package/templates/profiles/lead-gen/lib/resend.ts +33 -0
- package/templates/profiles/lead-gen/package.json.additions +5 -0
- package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
- package/templates/profiles/saas-dashboard/README.md +43 -36
- package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
- package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
- package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
- package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
- package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
- package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
- package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
- package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
- package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
- package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
- package/templates/profiles/saas-dashboard/middleware.ts +19 -0
- package/templates/profiles/saas-dashboard/package.json.additions +9 -0
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@ 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.
|
|
20
27
|
|
|
21
28
|
## Quick Start
|
|
22
29
|
|
|
@@ -199,6 +206,7 @@ you do not have to repeat them every session.
|
|
|
199
206
|
| Beginner installer failed | The bash and PowerShell installers log to `~/.ostup/logs/install-*.log`. The ostup CLI logs to `~/.ostup/logs/init-*.log`. Open the most recent log for the failing command's stderr. |
|
|
200
207
|
| "BOOTSTRAP_FAILED: Homebrew missing" | You ran `npx ostup init` on a Mac without Homebrew. Run the Mac beginner installer at the top of this README instead. |
|
|
201
208
|
| "BOOTSTRAP_FAILED: WinGet missing" | Update App Installer from the Microsoft Store: `ms-windows-store://pdp/?productid=9NBLGGH4NNS1`. |
|
|
209
|
+
| "/opt/homebrew/Cellar is not writable" | You are on a shared Mac where Homebrew was installed by another user. 0.9.1+ handles this automatically by installing `gh` to `~/.local/bin/` and routing `vercel` through `~/.npm-global/`. If you saw this error, you are on 0.9.0; upgrade with `npx @oaklandzoo/ostup@latest`. |
|
|
202
210
|
|
|
203
211
|
## Advanced: API tokens instead of interactive login
|
|
204
212
|
|
package/package.json
CHANGED
|
@@ -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"
|
package/src/bootstrap.mjs
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
|
+
import { existsSync, constants as fsConstants } from 'node:fs';
|
|
10
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
9
12
|
import { detectAll, installCommandFor } from './tool-registry.mjs';
|
|
10
13
|
import { run as exec } from './exec.mjs';
|
|
11
14
|
|
|
@@ -55,6 +58,114 @@ function patchPathFor(plan) {
|
|
|
55
58
|
process.env.PATH = toAdd.join(sep) + sep + (process.env.PATH || '');
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// --- darwin multi-user helpers ----------------------------------------------
|
|
62
|
+
// On a Mac where Homebrew was installed by a different user (shared / refurbished /
|
|
63
|
+
// family Mac), the current user can READ /opt/homebrew/ (so detection passes) but
|
|
64
|
+
// cannot WRITE to it (so `brew install gh` fails). Same applies to npm's global
|
|
65
|
+
// prefix which lives under brew. These helpers detect that state and switch to
|
|
66
|
+
// user-local install strategies that don't need sudo or admin handoff.
|
|
67
|
+
|
|
68
|
+
async function isPathWritable(path) {
|
|
69
|
+
try {
|
|
70
|
+
await access(path, fsConstants.W_OK);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function checkBrewWritable() {
|
|
78
|
+
for (const cellar of ['/opt/homebrew/Cellar', '/usr/local/Cellar']) {
|
|
79
|
+
if (existsSync(cellar)) {
|
|
80
|
+
return { exists: true, writable: await isPathWritable(cellar), cellar };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { exists: false, writable: false, cellar: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function checkNpmGlobalWritable() {
|
|
87
|
+
let prefix = null;
|
|
88
|
+
try {
|
|
89
|
+
prefix = execSync('npm prefix -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return { prefix: null, writable: false };
|
|
92
|
+
}
|
|
93
|
+
if (!prefix) return { prefix: null, writable: false };
|
|
94
|
+
return { prefix, writable: await isPathWritable(prefix) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function appendToZshrcIfMissing(line) {
|
|
98
|
+
const zshrc = join(homedir(), '.zshrc');
|
|
99
|
+
try {
|
|
100
|
+
const body = existsSync(zshrc) ? await readFile(zshrc, 'utf8') : '';
|
|
101
|
+
if (body.includes(line)) return false;
|
|
102
|
+
const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
103
|
+
await writeFile(zshrc, body + sep + line + '\n');
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function prependToProcessPath(dir) {
|
|
111
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
112
|
+
const parts = (process.env.PATH || '').split(sep);
|
|
113
|
+
if (parts.includes(dir)) return;
|
|
114
|
+
process.env.PATH = dir + sep + (process.env.PATH || '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchLatestGhVersion() {
|
|
118
|
+
try {
|
|
119
|
+
const out = execSync(
|
|
120
|
+
'curl -fsSL https://api.github.com/repos/cli/cli/releases/latest',
|
|
121
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
122
|
+
).toString();
|
|
123
|
+
const m = out.match(/"tag_name"\s*:\s*"v([0-9]+\.[0-9]+\.[0-9]+)"/);
|
|
124
|
+
return m ? m[1] : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function installGhBinaryDirect() {
|
|
131
|
+
// Multi-user-Mac fallback: download gh from its GitHub release tarball into
|
|
132
|
+
// ~/.local/bin/gh. No brew, no sudo, no admin handoff. Patches PATH for the
|
|
133
|
+
// current process and appends an export line to ~/.zshrc for future shells.
|
|
134
|
+
const home = homedir();
|
|
135
|
+
const binDir = join(home, '.local', 'bin');
|
|
136
|
+
const arch = process.arch === 'arm64' ? 'macOS_arm64' : 'macOS_amd64';
|
|
137
|
+
const version = (await fetchLatestGhVersion()) || '2.82.1';
|
|
138
|
+
const url = `https://github.com/cli/cli/releases/download/v${version}/gh_${version}_${arch}.tar.gz`;
|
|
139
|
+
|
|
140
|
+
const installScript = [
|
|
141
|
+
'set -euo pipefail',
|
|
142
|
+
`mkdir -p "${binDir}"`,
|
|
143
|
+
'tmp=$(mktemp -d)',
|
|
144
|
+
'cd "$tmp"',
|
|
145
|
+
`curl -fL -o gh.tar.gz "${url}"`,
|
|
146
|
+
'tar -xzf gh.tar.gz',
|
|
147
|
+
`cp gh_${version}_${arch}/bin/gh "${binDir}/gh"`,
|
|
148
|
+
`chmod +x "${binDir}/gh"`,
|
|
149
|
+
'rm -rf "$tmp"',
|
|
150
|
+
].join('\n');
|
|
151
|
+
|
|
152
|
+
await exec('bootstrap-gh-direct', 'bash', ['-c', installScript], { stdio: 'inherit' });
|
|
153
|
+
prependToProcessPath(binDir);
|
|
154
|
+
await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function setupUserNpmPrefix() {
|
|
158
|
+
// Multi-user-Mac fallback: route `npm install -g` into ~/.npm-global so the
|
|
159
|
+
// current user owns the directory and doesn't need write access to brew's
|
|
160
|
+
// npm prefix.
|
|
161
|
+
const home = homedir();
|
|
162
|
+
const prefix = join(home, '.npm-global');
|
|
163
|
+
const binDir = join(prefix, 'bin');
|
|
164
|
+
await exec('bootstrap-npm-prefix', 'npm', ['config', 'set', 'prefix', prefix], { stdio: 'inherit' });
|
|
165
|
+
prependToProcessPath(binDir);
|
|
166
|
+
await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
58
169
|
function checkInstallPrerequisites(missing, hasOnPath, platform) {
|
|
59
170
|
const reasons = [];
|
|
60
171
|
for (const m of missing) {
|
|
@@ -167,10 +278,45 @@ export async function runBootstrap({
|
|
|
167
278
|
}
|
|
168
279
|
}
|
|
169
280
|
|
|
281
|
+
// On darwin: detect multi-user-Mac scenario (brew owned by another user) and
|
|
282
|
+
// switch to user-local install strategies before running the loop. Skip when
|
|
283
|
+
// running under an injected hasOnPath (tests use platform-agnostic flows).
|
|
284
|
+
const useUserGhFallback = new Set();
|
|
285
|
+
let needsUserNpmPrefix = false;
|
|
286
|
+
if (platform === 'darwin' && hasOnPath === defaultHasOnPath) {
|
|
287
|
+
const brewMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'brew');
|
|
288
|
+
const npmgMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'npm-g');
|
|
289
|
+
if (brewMissing.length > 0) {
|
|
290
|
+
const brewState = await checkBrewWritable();
|
|
291
|
+
if (brewState.exists && !brewState.writable) {
|
|
292
|
+
process.stdout.write('\n[bootstrap] Homebrew is installed but owned by another user.\n');
|
|
293
|
+
process.stdout.write('[bootstrap] Switching to user-local install for brew-managed tools (no sudo, no admin handoff).\n\n');
|
|
294
|
+
for (const m of brewMissing) {
|
|
295
|
+
if (m.tool.id === 'gh') useUserGhFallback.add(m.tool.id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (npmgMissing.length > 0) {
|
|
300
|
+
const npmState = await checkNpmGlobalWritable();
|
|
301
|
+
if (!npmState.writable) {
|
|
302
|
+
process.stdout.write('[bootstrap] npm global prefix is not writable by this user. Setting up ~/.npm-global as a per-user prefix.\n\n');
|
|
303
|
+
needsUserNpmPrefix = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (needsUserNpmPrefix) {
|
|
308
|
+
await setupUserNpmPrefix();
|
|
309
|
+
}
|
|
310
|
+
|
|
170
311
|
// Run installs sequentially. stdio: 'inherit' so sudo/UAC prompts, progress bars, and
|
|
171
312
|
// any interactive winget/brew confirmations reach the user's terminal.
|
|
172
313
|
const installed = [];
|
|
173
314
|
for (const m of missing) {
|
|
315
|
+
if (useUserGhFallback.has(m.tool.id)) {
|
|
316
|
+
await installGhBinaryDirect();
|
|
317
|
+
installed.push(m.tool.id);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
174
320
|
const plan = installCommandFor(m.tool, platform);
|
|
175
321
|
await exec(`bootstrap-${m.tool.id}`, plan.cmd, plan.args || [], {
|
|
176
322
|
stdio: 'inherit',
|
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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 {
|
package/templates/START_HERE.md
CHANGED
|
@@ -41,7 +41,7 @@ That is the whole jump-in. The agent reads, asks, proposes, you approve, it buil
|
|
|
41
41
|
| `docs/brief.md` | Operator brief (only if you scaffolded with `ostup brief`). Source of truth for scope, brand, constraints. | Both |
|
|
42
42
|
| `docs/brief.json` | Machine-readable brief (same trigger). Used by downstream commands. | Agent |
|
|
43
43
|
| `tasks/prd-initial-build.md` | Auto-seeded first PRD from the brief (same trigger). | Both |
|
|
44
|
-
| `
|
|
44
|
+
| Profile overlay files at the project root (`app/page.tsx`, `app/api/*`, `components/*`, `lib/*`, plus `README.md` + `section-prompts.md`) | (if you ran `ostup brief`) Working code for your profile: hero, day-one flow, API routes. Agent extends from here instead of building from scratch. | Both |
|
|
45
45
|
| `.claude/commands/` | Slash command definitions. | Agent |
|
|
46
46
|
|
|
47
47
|
## Slash commands
|
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# Profile: blog
|
|
2
2
|
|
|
3
|
-
> Content engine. MDX posts, RSS, sitemap
|
|
3
|
+
> Content engine. MDX posts, RSS, sitemap. SEO-first. No CMS v1.
|
|
4
|
+
|
|
5
|
+
## What this profile ships (v1)
|
|
6
|
+
|
|
7
|
+
The overlay drops a working blog into your new project. After `vercel --prod` you have:
|
|
8
|
+
|
|
9
|
+
- A homepage at `/` listing all posts in `content/posts/`.
|
|
10
|
+
- Individual post pages at `/posts/[slug]` rendering MDX via `next-mdx-remote`.
|
|
11
|
+
- `/feed.xml` (RSS 2.0) and `/sitemap.xml`.
|
|
12
|
+
- Three starter posts in `content/posts/*.mdx` with frontmatter (title, date, summary, author).
|
|
13
|
+
- `Article` + `Organization` JSON-LD schema injected per post page.
|
|
14
|
+
|
|
15
|
+
## Pro upgrades (not shipped in v1)
|
|
16
|
+
|
|
17
|
+
- Tag index / tag pages.
|
|
18
|
+
- Author pages (multi-author).
|
|
19
|
+
- Newsletter integration.
|
|
20
|
+
- Editorial dashboard.
|
|
21
|
+
- Image optimization for inline post images.
|
|
4
22
|
|
|
5
23
|
## Day-one scope
|
|
6
24
|
|
|
7
25
|
| Section | Purpose | Required |
|
|
8
26
|
|---|---|---|
|
|
9
|
-
| Homepage | Latest
|
|
27
|
+
| Homepage | Latest posts sorted desc by date | yes |
|
|
10
28
|
| Post page | Single post by slug, MDX-rendered | yes |
|
|
11
|
-
|
|
|
12
|
-
| Tag page | All posts under a tag | yes |
|
|
13
|
-
| Author page (if multi-author) | Author bio + their posts | conditional |
|
|
14
|
-
| RSS feed | `/rss.xml` valid RSS 2.0 | yes |
|
|
29
|
+
| RSS feed | `/feed.xml` valid RSS 2.0 | yes |
|
|
15
30
|
| Sitemap | `/sitemap.xml` valid sitemap | yes |
|
|
16
|
-
|
|
|
17
|
-
| Footer | Links, RSS, year | yes |
|
|
18
|
-
|
|
19
|
-
## Wired infrastructure
|
|
20
|
-
|
|
21
|
-
- **MDX** for post bodies. Posts live as `.mdx` files in `content/posts/`.
|
|
22
|
-
- Frontmatter schema: `title`, `slug`, `date`, `tags[]`, `author?`, `description?`, `draft?`.
|
|
23
|
-
- `next-mdx-remote` or `@next/mdx` (agent picks; both work).
|
|
24
|
-
- Open Graph + Twitter card metadata per post.
|
|
25
|
-
- Article + Organization JSON-LD schema.
|
|
31
|
+
| Starter content | 3 MDX posts in `content/posts/` | yes |
|
|
26
32
|
|
|
27
33
|
## Env additions
|
|
28
34
|
|
|
29
|
-
See `.env.example.additions
|
|
35
|
+
See `.env.example.additions`. The blog needs `NEXT_PUBLIC_APP_URL` for absolute URLs in RSS + sitemap.
|
|
30
36
|
|
|
31
37
|
## File contracts
|
|
32
38
|
|
|
@@ -34,37 +40,36 @@ See `.env.example.additions` (mostly empty for blog v1).
|
|
|
34
40
|
content/posts/<slug>.mdx
|
|
35
41
|
---
|
|
36
42
|
title: "Post title"
|
|
37
|
-
slug: "post-slug"
|
|
38
43
|
date: "2026-05-21"
|
|
39
|
-
|
|
40
|
-
author: "
|
|
41
|
-
description: "One-sentence summary for SEO."
|
|
44
|
+
summary: "One-sentence summary for SEO."
|
|
45
|
+
author: "Author name"
|
|
42
46
|
---
|
|
43
|
-
|
|
44
|
-
Body in MDX.
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
name: "GG"
|
|
49
|
-
bio: "One-paragraph bio."
|
|
50
|
-
social: { x: "@goodshin", github: "DubsFan" }
|
|
51
|
-
---
|
|
48
|
+
Body in MDX.
|
|
52
49
|
```
|
|
53
50
|
|
|
54
51
|
## Hard rules
|
|
55
52
|
|
|
56
53
|
- Posts ship as committed MDX files. No DB v1.
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
- Reading width capped at 720px or so for body comfort.
|
|
61
|
-
- Light + dark mode via `prefers-color-scheme`.
|
|
54
|
+
- RSS + sitemap regenerate at build time (statically rendered).
|
|
55
|
+
- Reading width capped around 720px for body comfort.
|
|
56
|
+
- Light theme by default. Dark mode is operator-add.
|
|
62
57
|
|
|
63
58
|
## Acceptance
|
|
64
59
|
|
|
65
|
-
- Homepage shows
|
|
66
|
-
- Individual post page renders MDX
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
|
|
60
|
+
- Homepage shows all posts sorted by date desc.
|
|
61
|
+
- Individual post page renders MDX.
|
|
62
|
+
- `/feed.xml` validates at https://validator.w3.org/feed/.
|
|
63
|
+
- `/sitemap.xml` includes every post.
|
|
64
|
+
- Lighthouse Performance >= 95 (static content).
|
|
65
|
+
|
|
66
|
+
## Visual verification
|
|
67
|
+
|
|
68
|
+
After deploy, run:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bash scripts/screenshot.sh <your-vercel-url>
|
|
72
|
+
bash scripts/screenshot.sh <your-vercel-url>/posts/welcome
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Read the resulting PNGs to confirm the post list and an individual post render as expected.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getAllPosts } from '@/lib/posts';
|
|
2
|
+
|
|
3
|
+
function escapeXml(s: string): string {
|
|
4
|
+
return s
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function GET() {
|
|
13
|
+
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
14
|
+
const posts = await getAllPosts();
|
|
15
|
+
const items = posts
|
|
16
|
+
.map(
|
|
17
|
+
(p) => `
|
|
18
|
+
<item>
|
|
19
|
+
<title>${escapeXml(p.title)}</title>
|
|
20
|
+
<link>${siteUrl}/posts/${p.slug}</link>
|
|
21
|
+
<guid>${siteUrl}/posts/${p.slug}</guid>
|
|
22
|
+
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
|
|
23
|
+
<description>${escapeXml(p.summary)}</description>
|
|
24
|
+
</item>`,
|
|
25
|
+
)
|
|
26
|
+
.join('');
|
|
27
|
+
|
|
28
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
29
|
+
<rss version="2.0">
|
|
30
|
+
<channel>
|
|
31
|
+
<title>{{DISPLAY_NAME}}</title>
|
|
32
|
+
<link>${siteUrl}</link>
|
|
33
|
+
<description>{{PROJECT_PURPOSE_ONE_SENTENCE}}</description>
|
|
34
|
+
<language>en</language>${items}
|
|
35
|
+
</channel>
|
|
36
|
+
</rss>`;
|
|
37
|
+
|
|
38
|
+
return new Response(xml, {
|
|
39
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { getAllPosts } from '@/lib/posts';
|
|
3
|
+
|
|
4
|
+
export default async function HomePage() {
|
|
5
|
+
const posts = await getAllPosts();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
9
|
+
<header className="border-b border-slate-200 pb-8">
|
|
10
|
+
<h1 className="text-4xl font-bold tracking-tight">{`{{DISPLAY_NAME}}`}</h1>
|
|
11
|
+
<p className="mt-2 text-lg text-slate-600">{`{{PROJECT_PURPOSE_ONE_SENTENCE}}`}</p>
|
|
12
|
+
</header>
|
|
13
|
+
<ul className="mt-10 space-y-10">
|
|
14
|
+
{posts.map((post) => (
|
|
15
|
+
<li key={post.slug}>
|
|
16
|
+
<article>
|
|
17
|
+
<Link href={`/posts/${post.slug}`}>
|
|
18
|
+
<h2 className="text-2xl font-semibold tracking-tight hover:underline">{post.title}</h2>
|
|
19
|
+
</Link>
|
|
20
|
+
<p className="mt-1 text-sm text-slate-500">
|
|
21
|
+
{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
|
22
|
+
</p>
|
|
23
|
+
<p className="mt-3 text-slate-700">{post.summary}</p>
|
|
24
|
+
</article>
|
|
25
|
+
</li>
|
|
26
|
+
))}
|
|
27
|
+
</ul>
|
|
28
|
+
</main>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation';
|
|
2
|
+
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
3
|
+
import { getAllPosts, getPostBySlug } from '@/lib/posts';
|
|
4
|
+
|
|
5
|
+
export async function generateStaticParams() {
|
|
6
|
+
const posts = await getAllPosts();
|
|
7
|
+
return posts.map((p) => ({ slug: p.slug }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
11
|
+
const { slug } = await params;
|
|
12
|
+
const post = await getPostBySlug(slug);
|
|
13
|
+
if (!post) notFound();
|
|
14
|
+
|
|
15
|
+
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
16
|
+
|
|
17
|
+
const articleSchema = {
|
|
18
|
+
'@context': 'https://schema.org',
|
|
19
|
+
'@type': 'Article',
|
|
20
|
+
headline: post.title,
|
|
21
|
+
description: post.summary,
|
|
22
|
+
datePublished: post.date,
|
|
23
|
+
author: { '@type': 'Person', name: post.author },
|
|
24
|
+
publisher: {
|
|
25
|
+
'@type': 'Organization',
|
|
26
|
+
name: '{{DISPLAY_NAME}}',
|
|
27
|
+
url: siteUrl,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
33
|
+
<script
|
|
34
|
+
type="application/ld+json"
|
|
35
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
|
36
|
+
/>
|
|
37
|
+
<article>
|
|
38
|
+
<header className="border-b border-slate-200 pb-6">
|
|
39
|
+
<h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
|
|
40
|
+
<p className="mt-2 text-sm text-slate-500">
|
|
41
|
+
{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
|
42
|
+
{post.author ? ` - ${post.author}` : ''}
|
|
43
|
+
</p>
|
|
44
|
+
</header>
|
|
45
|
+
<div className="prose prose-slate mt-8 max-w-none">
|
|
46
|
+
<MDXRemote source={post.body} />
|
|
47
|
+
</div>
|
|
48
|
+
</article>
|
|
49
|
+
</main>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAllPosts } from '@/lib/posts';
|
|
2
|
+
|
|
3
|
+
export async function GET() {
|
|
4
|
+
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
5
|
+
const posts = await getAllPosts();
|
|
6
|
+
|
|
7
|
+
const urls = [
|
|
8
|
+
`<url><loc>${siteUrl}</loc><changefreq>weekly</changefreq></url>`,
|
|
9
|
+
...posts.map(
|
|
10
|
+
(p) =>
|
|
11
|
+
`<url><loc>${siteUrl}/posts/${p.slug}</loc><lastmod>${new Date(p.date).toISOString()}</lastmod></url>`,
|
|
12
|
+
),
|
|
13
|
+
].join('');
|
|
14
|
+
|
|
15
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
16
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
|
|
17
|
+
|
|
18
|
+
return new Response(xml, {
|
|
19
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Getting started, the boring version
|
|
3
|
+
date: 2026-05-21
|
|
4
|
+
summary: A simple checklist anyone can use to start something new without overthinking it.
|
|
5
|
+
author: {{OWNER_OR_CLIENT}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Most "getting started" guides are aspirational. This one is boring on purpose.
|
|
9
|
+
|
|
10
|
+
1. Decide one outcome that would count as done.
|
|
11
|
+
2. Block thirty minutes on the calendar this week.
|
|
12
|
+
3. Write the smallest thing that moves you toward the outcome.
|
|
13
|
+
4. Tell one person what you did.
|
|
14
|
+
5. Repeat next week.
|
|
15
|
+
|
|
16
|
+
That is the whole list. The trick is not finding a better list. The trick is doing the list when you do not feel like it.
|