@oaklandzoo/ostup 0.2.0 → 0.4.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 (32) hide show
  1. package/README.md +23 -0
  2. package/bin/cli.mjs +27 -1
  3. package/package.json +1 -1
  4. package/src/brief/classify.mjs +218 -0
  5. package/src/brief/index.mjs +126 -0
  6. package/src/brief/profile-router.mjs +89 -0
  7. package/src/brief/questions.mjs +333 -0
  8. package/src/brief/render-brief.mjs +232 -0
  9. package/src/brief/sample-briefs.mjs +304 -0
  10. package/src/brief/schema.mjs +176 -0
  11. package/src/mvp-flow.mjs +43 -1
  12. package/src/templates.mjs +2 -0
  13. package/templates/.claude/commands/generate-image-prompt.md +118 -0
  14. package/templates/.claude/commands/generate-image.md +168 -0
  15. package/templates/.claude/commands/preflight.md +8 -0
  16. package/templates/AGENTS.md +11 -1
  17. package/templates/CLAUDE.md +3 -0
  18. package/templates/START_HERE.md +10 -4
  19. package/templates/profiles/blog/.env.example.additions +7 -0
  20. package/templates/profiles/blog/README.md +70 -0
  21. package/templates/profiles/blog/section-prompts.md +63 -0
  22. package/templates/profiles/booking/.env.example.additions +16 -0
  23. package/templates/profiles/booking/README.md +61 -0
  24. package/templates/profiles/booking/section-prompts.md +47 -0
  25. package/templates/profiles/lead-gen/.env.example.additions +8 -0
  26. package/templates/profiles/lead-gen/README.md +58 -0
  27. package/templates/profiles/lead-gen/section-prompts.md +47 -0
  28. package/templates/profiles/marketing/README.md +39 -0
  29. package/templates/profiles/marketing/section-prompts.md +36 -0
  30. package/templates/profiles/saas-dashboard/.env.example.additions +21 -0
  31. package/templates/profiles/saas-dashboard/README.md +60 -0
  32. package/templates/profiles/saas-dashboard/section-prompts.md +52 -0
package/README.md CHANGED
@@ -102,6 +102,29 @@ The tool will ask you a series of questions. Answer each one. When the
102
102
  tool finishes, you will see a live deploy URL. Open it in your browser
103
103
  to confirm the site is up.
104
104
 
105
+ ## Use (operator brief mode, recommended for real projects)
106
+
107
+ If you want the agent to start with a richer context (business goal,
108
+ audience, business model, day-one flows, brand, constraints), run the
109
+ 10-question intake first:
110
+
111
+ ```
112
+ ostup brief
113
+ ```
114
+
115
+ That writes `docs/brief.md`, `docs/brief.json`, and `tasks/prd-initial-build.md`
116
+ in the current folder. Then scaffold the project with the brief:
117
+
118
+ ```
119
+ ostup init --brief ./docs/brief.json
120
+ ```
121
+
122
+ The tool classifies one of 8 site profiles (marketing, lead-gen,
123
+ booking, blog, storefront, saas-dashboard, directory, portfolio),
124
+ picks the right add-ons (auth, payments, email, postgres, mdx, blob,
125
+ analytics), and applies a profile-specific overlay so the agent has a
126
+ clear day-one plan.
127
+
105
128
  ## Questions the tool will ask
106
129
 
107
130
  | Question | What to answer |
package/bin/cli.mjs CHANGED
@@ -10,7 +10,7 @@ loadDotEnv();
10
10
 
11
11
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
12
 
13
- const SUBCOMMANDS = new Set(['init', 'update']);
13
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief']);
14
14
 
15
15
  async function readPkg() {
16
16
  const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
@@ -37,6 +37,10 @@ function parseArgs(argv) {
37
37
  else if (a.startsWith('--config=')) flags.config = a.slice('--config='.length);
38
38
  else if (a === '--ingest') flags.ingest = argv[++i];
39
39
  else if (a.startsWith('--ingest=')) flags.ingest = a.slice('--ingest='.length);
40
+ else if (a === '--brief') flags.brief = argv[++i];
41
+ else if (a.startsWith('--brief=')) flags.brief = a.slice('--brief='.length);
42
+ else if (a === '--target') flags.target = argv[++i];
43
+ else if (a.startsWith('--target=')) flags.target = a.slice('--target='.length);
40
44
  else if (a.startsWith('-')) {
41
45
  process.stderr.write(`unknown flag: ${a}\n`);
42
46
  process.exit(1);
@@ -57,6 +61,7 @@ function printHelp() {
57
61
  '',
58
62
  'Commands:',
59
63
  ' init Scaffold a new project (interactive or with --yes).',
64
+ ' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
60
65
  ' update Refresh bundled templates from the pinned source.',
61
66
  '',
62
67
  'Flags for `ostup init`:',
@@ -66,9 +71,16 @@ function printHelp() {
66
71
  ' --profile <name> Skip the profile prompt (goodshin or default).',
67
72
  ' --name <kebab> Skip the projectName prompt.',
68
73
  ' --ingest <path> Copy operator materials from <path> into inputs/.',
74
+ ' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
69
75
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
70
76
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
71
77
  '',
78
+ 'Flags for `ostup brief`:',
79
+ ' --brief <path> Load brief.json from <path> instead of running the intake.',
80
+ ' --target <dir> Write brief files into <dir> (default: current working dir).',
81
+ ' --force Overwrite existing docs/brief.md, docs/brief.json, tasks/prd-initial-build.md.',
82
+ ' --dry-run Print what would be written without writing.',
83
+ '',
72
84
  'Global flags:',
73
85
  ' --version, -v Print version and exit.',
74
86
  ' --help, -h Print this help and exit.',
@@ -118,6 +130,18 @@ if (subcommand === 'update') {
118
130
  }
119
131
  }
120
132
 
133
+ if (subcommand === 'brief') {
134
+ const { runBrief } = await import('../src/brief/index.mjs');
135
+ try {
136
+ await runBrief({ flags });
137
+ process.exit(0);
138
+ } catch (err) {
139
+ process.stderr.write(`${err.message}\n`);
140
+ const userErrors = new Set(['NO_TTY', 'USER_ABORT', 'BRIEF_NOT_FOUND', 'BRIEF_INVALID', 'BRIEF_EXISTS']);
141
+ process.exit(userErrors.has(err.code) ? 1 : 2);
142
+ }
143
+ }
144
+
121
145
  // subcommand === 'init'
122
146
  if (flags.kitOnly) {
123
147
  const { scaffold } = await import('../src/scaffold.mjs');
@@ -145,6 +169,8 @@ try {
145
169
  'NO_GH_CREDS',
146
170
  'NO_VERCEL_CREDS',
147
171
  'INGEST_PATH_NOT_FOUND',
172
+ 'BRIEF_NOT_FOUND',
173
+ 'BRIEF_INVALID',
148
174
  ]);
149
175
  process.exit(userErrors.has(err.code) ? 1 : 2);
150
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.2.0",
3
+ "version": "0.4.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,218 @@
1
+ // brief/classify.mjs: deterministic profile classifier. AI call only when user explicitly asks.
2
+
3
+ import { PROFILES, ADDONS } from './schema.mjs';
4
+
5
+ const SIGNAL_PATTERNS = {
6
+ booking: [
7
+ /booking/i, /appointment/i, /calendar/i, /availability/i, /deposit/i,
8
+ /reservation/i, /lodge/i, /salon/i, /stylist/i, /barber/i, /clinic/i,
9
+ ],
10
+ 'saas-dashboard': [
11
+ /subscription/i, /dashboard/i, /\blogin\b/i, /\bteam\b/i, /workspace/i,
12
+ /billing/i, /multi[- ]?tenant/i, /admin shell/i, /metering/i,
13
+ ],
14
+ storefront: [
15
+ /\bproduct\b/i, /\bcart\b/i, /checkout/i, /\bstore\b/i, /\bsku\b/i,
16
+ /inventory/i, /ecommerce/i, /\bshop\b/i,
17
+ ],
18
+ blog: [
19
+ /\bblog\b/i, /\barticles?\b/i, /newsletter/i, /\bseo\b/i, /\bcontent engine\b/i,
20
+ /\brss\b/i, /\bmdx\b/i, /\bessays?\b/i, /\bposts?\b/i,
21
+ ],
22
+ directory: [
23
+ /directory/i, /listings?/i, /\bmap\b/i, /search.*filter/i, /\bprofiles?\b/i,
24
+ /\bcategories?\b/i,
25
+ ],
26
+ 'lead-gen': [
27
+ /\bservices?\b/i, /\bquote\b/i, /estimate/i, /service area/i, /\bcall\b/i,
28
+ /\bcontact\b/i, /lead capture/i, /\bplumber\b/i, /\broofer\b/i, /\belectrician\b/i,
29
+ /\blocksmith\b/i, /\blandscap/i, /\bcleaning\b/i,
30
+ ],
31
+ portfolio: [
32
+ /portfolio/i, /case studies/i, /\bwork\b/i, /\bclients\b/i, /\bgallery\b/i,
33
+ /freelance/i, /agency/i,
34
+ ],
35
+ };
36
+
37
+ const ADDON_RULES = {
38
+ payments: (brief) => {
39
+ const types = brief.business_model?.types || [];
40
+ return types.some((t) => ['booking_revenue', 'deposit', 'subscription', 'one_time_sale'].includes(t))
41
+ || (brief.scope?.core_flows || []).includes('payment');
42
+ },
43
+ auth: (brief) => {
44
+ const types = brief.business_model?.types || [];
45
+ if (types.includes('subscription') || types.includes('dashboard') || types.includes('operations')) return true;
46
+ const data = brief.data_model?.entities || [];
47
+ if (data.some((e) => e.name?.toLowerCase().includes('user'))) return true;
48
+ return (brief.scope?.core_flows || []).includes('auth') || (brief.scope?.core_flows || []).includes('dashboard');
49
+ },
50
+ email: (brief) => {
51
+ const types = brief.business_model?.types || [];
52
+ if (types.includes('lead_capture') || types.includes('booking_revenue')) return true;
53
+ const flows = brief.scope?.core_flows || [];
54
+ return flows.includes('contact_form') || flows.includes('booking') || flows.includes('newsletter');
55
+ },
56
+ postgres: (brief) => {
57
+ if (brief.data_model?.needs_storage) return true;
58
+ const recs = brief.data_model?.recommended_storage || [];
59
+ return recs.includes('postgres');
60
+ },
61
+ mdx: (brief) => {
62
+ const types = brief.business_model?.types || [];
63
+ if (types.includes('content_traffic')) return true;
64
+ return (brief.scope?.core_flows || []).includes('content_crud');
65
+ },
66
+ blob: (brief) => {
67
+ const recs = brief.data_model?.recommended_storage || [];
68
+ if (recs.includes('blob')) return true;
69
+ return (brief.scope?.core_flows || []).includes('file_upload_download');
70
+ },
71
+ analytics: () => false, // Pro-only; never auto-included
72
+ };
73
+
74
+ const PROFILE_SCORES_MIN_DIFF = 2; // strongest profile must beat runner-up by this much
75
+
76
+ function tokenize(brief) {
77
+ const text = [
78
+ brief.project?.summary || '',
79
+ brief.project?.display_name || '',
80
+ (brief.scope?.must_have_sections || []).join(' '),
81
+ (brief.scope?.core_flows || []).join(' '),
82
+ brief.business_model?.pricing_notes || '',
83
+ ].join(' ');
84
+ return text;
85
+ }
86
+
87
+ export function deterministicScores(brief) {
88
+ const text = tokenize(brief);
89
+ const scores = {};
90
+ for (const profile of PROFILES) {
91
+ const patterns = SIGNAL_PATTERNS[profile] || [];
92
+ scores[profile] = 0;
93
+ for (const re of patterns) {
94
+ if (re.test(text)) scores[profile] += 1;
95
+ }
96
+ }
97
+
98
+ // Boost by business-model signals.
99
+ const types = brief.business_model?.types || [];
100
+ if (types.includes('subscription')) {
101
+ scores['saas-dashboard'] += 3;
102
+ }
103
+ if (types.includes('dashboard')) {
104
+ scores['saas-dashboard'] += 2;
105
+ }
106
+ if (types.includes('booking_revenue') || types.includes('deposit')) {
107
+ scores['booking'] += 3;
108
+ }
109
+ if (types.includes('one_time_sale')) {
110
+ scores['storefront'] += 3;
111
+ }
112
+ if (types.includes('content_traffic')) {
113
+ scores['blog'] += 2;
114
+ }
115
+ if (types.includes('lead_capture') && !types.some((t) => ['subscription', 'booking_revenue', 'deposit', 'one_time_sale'].includes(t))) {
116
+ scores['lead-gen'] += 2;
117
+ }
118
+ if (types.length === 1 && types[0] === 'brand_awareness') {
119
+ scores['marketing'] += 2;
120
+ }
121
+
122
+ // Core-flow boosts.
123
+ const flows = brief.scope?.core_flows || [];
124
+ if (flows.includes('booking')) scores['booking'] += 2;
125
+ if (flows.includes('payment') && (flows.includes('auth') || flows.includes('dashboard'))) scores['saas-dashboard'] += 2;
126
+ if (flows.includes('search_filter')) scores['directory'] += 2;
127
+ if (flows.includes('content_crud')) scores['blog'] += 2;
128
+ if (flows.includes('contact_form') && !flows.includes('payment') && !flows.includes('booking')) scores['lead-gen'] += 1;
129
+
130
+ return scores;
131
+ }
132
+
133
+ export function classify(brief, { explicitProfile = null } = {}) {
134
+ // Operator picked explicitly. Honor unless empty.
135
+ if (explicitProfile && PROFILES.includes(explicitProfile)) {
136
+ const scores = deterministicScores(brief);
137
+ return {
138
+ site_profile: explicitProfile,
139
+ confidence: 1.0,
140
+ why: [`Operator explicitly selected '${explicitProfile}'`],
141
+ secondary_profiles: rankRest(scores, explicitProfile),
142
+ addons: resolveAddons(brief),
143
+ scores,
144
+ };
145
+ }
146
+
147
+ const scores = deterministicScores(brief);
148
+ const ranked = Object.entries(scores).sort(([, a], [, b]) => b - a);
149
+ const [topProfile, topScore] = ranked[0];
150
+ const [, secondScore] = ranked[1] || ['marketing', 0];
151
+
152
+ let chosen = topProfile;
153
+ let confidence;
154
+ if (topScore === 0) {
155
+ chosen = 'marketing';
156
+ confidence = 0.5; // fallback default
157
+ } else if (topScore - secondScore >= PROFILE_SCORES_MIN_DIFF) {
158
+ confidence = Math.min(0.95, 0.7 + (topScore - secondScore) * 0.05);
159
+ } else {
160
+ confidence = Math.min(0.7, 0.55 + topScore * 0.02);
161
+ }
162
+
163
+ return {
164
+ site_profile: chosen,
165
+ confidence: round2(confidence),
166
+ why: explainChoice(brief, scores, chosen),
167
+ secondary_profiles: rankRest(scores, chosen),
168
+ addons: resolveAddons(brief),
169
+ scores,
170
+ };
171
+ }
172
+
173
+ function explainChoice(brief, scores, chosen) {
174
+ const reasons = [];
175
+ const types = brief.business_model?.types || [];
176
+ if (chosen === 'booking' && (types.includes('booking_revenue') || types.includes('deposit'))) {
177
+ reasons.push('Booking or deposit is a primary revenue model');
178
+ }
179
+ if (chosen === 'saas-dashboard' && types.includes('subscription')) {
180
+ reasons.push('Subscription model implies SaaS dashboard scaffold');
181
+ }
182
+ if (chosen === 'storefront' && types.includes('one_time_sale')) {
183
+ reasons.push('Direct product sale requires storefront');
184
+ }
185
+ if (chosen === 'blog' && types.includes('content_traffic')) {
186
+ reasons.push('Content traffic is the primary value model');
187
+ }
188
+ if (chosen === 'lead-gen' && types.includes('lead_capture')) {
189
+ reasons.push('Lead capture is the primary value model');
190
+ }
191
+ if (scores[chosen] > 0 && reasons.length === 0) {
192
+ reasons.push(`Strongest signal match for ${chosen} (score ${scores[chosen]})`);
193
+ }
194
+ if (reasons.length === 0) {
195
+ reasons.push('No strong signals; defaulted to marketing');
196
+ }
197
+ return reasons;
198
+ }
199
+
200
+ function rankRest(scores, chosen) {
201
+ return Object.entries(scores)
202
+ .filter(([p, s]) => p !== chosen && s > 0)
203
+ .sort(([, a], [, b]) => b - a)
204
+ .slice(0, 2)
205
+ .map(([p]) => p);
206
+ }
207
+
208
+ export function resolveAddons(brief) {
209
+ const addons = [];
210
+ for (const addon of ADDONS) {
211
+ if (ADDON_RULES[addon] && ADDON_RULES[addon](brief)) addons.push(addon);
212
+ }
213
+ return addons;
214
+ }
215
+
216
+ function round2(n) {
217
+ return Math.round(n * 100) / 100;
218
+ }
@@ -0,0 +1,126 @@
1
+ // brief/index.mjs: entry point for ostup brief subcommand + runtime helpers.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { writeFile, readFile, mkdir } from 'node:fs/promises';
5
+ import { dirname, resolve, join } from 'node:path';
6
+ import { renderBrief, renderInitialPrdSeed } from './render-brief.mjs';
7
+ import { validateBrief, SCHEMA_VERSION } from './schema.mjs';
8
+ import { runIntake } from './questions.mjs';
9
+ import { classify, resolveAddons } from './classify.mjs';
10
+
11
+ /**
12
+ * Load a brief from a JSON path, validate it, return the parsed object.
13
+ * Throws BRIEF_INVALID if validation fails.
14
+ */
15
+ export async function loadBrief(path) {
16
+ const abs = resolve(path);
17
+ if (!existsSync(abs)) {
18
+ const err = new Error(`brief file not found: ${abs}`);
19
+ err.code = 'BRIEF_NOT_FOUND';
20
+ throw err;
21
+ }
22
+ const raw = await readFile(abs, 'utf8');
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ } catch (e) {
27
+ const err = new Error(`brief file is not valid JSON: ${abs}\n${e.message}`);
28
+ err.code = 'BRIEF_INVALID';
29
+ throw err;
30
+ }
31
+ const result = validateBrief(parsed);
32
+ if (!result.ok) {
33
+ const err = new Error(`brief at ${abs} failed validation:\n ${result.errors.join('\n ')}`);
34
+ err.code = 'BRIEF_INVALID';
35
+ throw err;
36
+ }
37
+ return parsed;
38
+ }
39
+
40
+ /**
41
+ * Write brief.md + brief.json to <targetDir>/docs/.
42
+ * Also writes tasks/prd-initial-build.md (PRD seed).
43
+ *
44
+ * If force is false and either file exists, throws BRIEF_EXISTS.
45
+ */
46
+ export async function writeBriefFiles({ targetDir, brief, force = false } = {}) {
47
+ const docsDir = join(targetDir, 'docs');
48
+ const tasksDir = join(targetDir, 'tasks');
49
+ const briefMdPath = join(docsDir, 'brief.md');
50
+ const briefJsonPath = join(docsDir, 'brief.json');
51
+ const prdSeedPath = join(tasksDir, 'prd-initial-build.md');
52
+
53
+ if (!force) {
54
+ for (const p of [briefMdPath, briefJsonPath, prdSeedPath]) {
55
+ if (existsSync(p)) {
56
+ const err = new Error(`refusing to overwrite ${p} (pass --force to overwrite)`);
57
+ err.code = 'BRIEF_EXISTS';
58
+ throw err;
59
+ }
60
+ }
61
+ }
62
+
63
+ await mkdir(docsDir, { recursive: true });
64
+ await mkdir(tasksDir, { recursive: true });
65
+ await writeFile(briefMdPath, renderBrief(brief), 'utf8');
66
+ await writeFile(briefJsonPath, JSON.stringify(brief, null, 2) + '\n', 'utf8');
67
+ await writeFile(prdSeedPath, renderInitialPrdSeed(brief), 'utf8');
68
+
69
+ return { briefMdPath, briefJsonPath, prdSeedPath };
70
+ }
71
+
72
+ /**
73
+ * `ostup brief` subcommand entry point.
74
+ *
75
+ * Modes:
76
+ * - interactive (default): runs the 10-question intake
77
+ * - with --brief <path>: loads from path, re-renders brief.md + PRD seed
78
+ *
79
+ * Always writes brief files into <cwd>/docs/ (or --target). Does NOT scaffold,
80
+ * does NOT create GitHub repos, does NOT deploy. Pure intake + render.
81
+ */
82
+ export async function runBrief({ flags = {}, cwd = process.cwd() } = {}) {
83
+ const targetDir = flags.target ? resolve(flags.target) : cwd;
84
+
85
+ let brief;
86
+ if (flags.brief) {
87
+ brief = await loadBrief(flags.brief);
88
+ process.stdout.write(`Loaded brief from ${resolve(flags.brief)}\n`);
89
+ } else {
90
+ if (!process.stdin.isTTY && !flags.yes) {
91
+ const err = new Error(
92
+ 'no TTY detected. Interactive intake cannot run.\nPass --brief <path> to load from a JSON file.'
93
+ );
94
+ err.code = 'NO_TTY';
95
+ throw err;
96
+ }
97
+ const result = await runIntake({ defaults: flags.defaults || {} });
98
+ if (result.cancelled) {
99
+ process.stdout.write('Cancelled.\n');
100
+ const err = new Error('user cancelled intake');
101
+ err.code = 'USER_ABORT';
102
+ throw err;
103
+ }
104
+ brief = result.brief;
105
+ }
106
+
107
+ if (flags.dryRun) {
108
+ process.stdout.write('\n[brief] would write:\n');
109
+ process.stdout.write(` ${join(targetDir, 'docs/brief.md')}\n`);
110
+ process.stdout.write(` ${join(targetDir, 'docs/brief.json')}\n`);
111
+ process.stdout.write(` ${join(targetDir, 'tasks/prd-initial-build.md')}\n`);
112
+ process.stdout.write(`\n[brief] classification: ${brief.classification.site_profile} (confidence ${brief.classification.confidence})\n`);
113
+ process.stdout.write(`[brief] add-ons: ${(brief.scaffold.addons || []).join(', ') || '(none)'}\n`);
114
+ return { brief, paths: null, dryRun: true };
115
+ }
116
+
117
+ const paths = await writeBriefFiles({ targetDir, brief, force: flags.force });
118
+ process.stdout.write(`\nBrief written:\n`);
119
+ process.stdout.write(` ${paths.briefMdPath}\n`);
120
+ process.stdout.write(` ${paths.briefJsonPath}\n`);
121
+ process.stdout.write(` ${paths.prdSeedPath}\n`);
122
+ process.stdout.write(`\nProfile: ${brief.classification.site_profile} (confidence ${brief.classification.confidence})\n`);
123
+ process.stdout.write(`Add-ons: ${(brief.scaffold.addons || []).join(', ') || '(none)'}\n`);
124
+
125
+ return { brief, paths };
126
+ }
@@ -0,0 +1,89 @@
1
+ // brief/profile-router.mjs: decide which templates to overlay based on the brief's profile + add-ons.
2
+
3
+ import { resolve, dirname, join } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { substitute } from '../substitute.mjs';
8
+
9
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
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
+ export function profileTemplatePath(profile) {
17
+ const p = resolve(TEMPLATES_ROOT, 'profiles', profile);
18
+ return existsSync(p) ? p : null;
19
+ }
20
+
21
+ /**
22
+ * Recursively list every file in a directory, returning relative paths.
23
+ */
24
+ async function listFiles(dir, base = dir, out = []) {
25
+ const entries = await readdir(dir, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const full = join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ await listFiles(full, base, out);
30
+ } else if (entry.isFile()) {
31
+ out.push(full.slice(base.length + 1));
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
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
+ */
50
+ export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun = false } = {}) {
51
+ const overlayPath = profileTemplatePath(profile);
52
+ if (!overlayPath) {
53
+ return { applied: false, reason: `no overlay folder for profile '${profile}'`, files: [] };
54
+ }
55
+
56
+ const files = await listFiles(overlayPath);
57
+ const filtered = files.filter((f) => !f.endsWith('.DS_Store') && !f.includes('/node_modules/'));
58
+
59
+ const actions = [];
60
+ for (const rel of filtered) {
61
+ 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 });
68
+ }
69
+
70
+ if (dryRun) {
71
+ return { applied: false, reason: 'dry-run', files: actions.map((a) => a.rel) };
72
+ }
73
+
74
+ for (const a of actions) {
75
+ const raw = await readFile(a.src, 'utf8');
76
+ const out = substitute(raw, tokens);
77
+ await mkdir(dirname(a.dest), { recursive: true });
78
+ if (a.isAddition && existsSync(a.dest)) {
79
+ const existing = await readFile(a.dest, 'utf8');
80
+ // Append with a separator if not already present
81
+ const sep = existing.endsWith('\n') ? '' : '\n';
82
+ await writeFile(a.dest, existing + sep + '\n# --- added by ostup profile overlay ---\n' + out, 'utf8');
83
+ } else {
84
+ await writeFile(a.dest, out, 'utf8');
85
+ }
86
+ }
87
+
88
+ return { applied: true, reason: 'overlay applied', files: actions.map((a) => a.rel) };
89
+ }