@oaklandzoo/ostup 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/bin/cli.mjs +150 -0
- package/package.json +58 -0
- package/src/config.mjs +41 -0
- package/src/credential-prompts.mjs +117 -0
- package/src/env-loader.mjs +27 -0
- package/src/exec.mjs +78 -0
- package/src/mvp-flow.mjs +219 -0
- package/src/preflight.mjs +55 -0
- package/src/project-prompts.mjs +220 -0
- package/src/prompts.mjs +60 -0
- package/src/scaffold.mjs +112 -0
- package/src/steps/github.mjs +21 -0
- package/src/steps/ingest.mjs +121 -0
- package/src/steps/inject.mjs +22 -0
- package/src/steps/next-app.mjs +41 -0
- package/src/steps/protection.mjs +181 -0
- package/src/steps/vercel.mjs +46 -0
- package/src/substitute.mjs +44 -0
- package/src/summary.mjs +34 -0
- package/src/templates.mjs +41 -0
- package/src/update.mjs +26 -0
- package/templates/.claude/commands/bootstrap.md +111 -0
- package/templates/.claude/commands/create-prd.md +85 -0
- package/templates/.claude/commands/generate-tasks.md +74 -0
- package/templates/.claude/commands/prompt-end.md +129 -0
- package/templates/.claude/commands/prompt-mid.md +74 -0
- package/templates/.claude/commands/prompt-start.md +85 -0
- package/templates/.ostup-config.yml.example +10 -0
- package/templates/AGENTS.md +33 -0
- package/templates/CLAUDE.md +256 -0
- package/templates/HANDOFF.md +49 -0
- package/templates/README.md +15 -0
- package/templates/_gitignore +9 -0
- package/templates/docs/ARCHITECTURE.md +59 -0
- package/templates/docs/MANUAL_TASKS.md +20 -0
- package/templates/docs/PROJECT_STATE.md +41 -0
- package/templates/docs/SESSION_NOTES.md +29 -0
- package/templates/inputs/README.md +25 -0
- package/templates/inputs/images/.gitkeep +0 -0
- package/templates/inputs/notes/.gitkeep +0 -0
- package/templates/inputs/references/.gitkeep +0 -0
- package/templates/inputs/research/.gitkeep +0 -0
- package/templates/tasks/.gitkeep +0 -0
package/src/mvp-flow.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// mvp-flow.mjs: orchestrate the full MVP scaffold: preflight, prompts, creds, scaffold, GitHub, Vercel, inject, summary.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { resolve, join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { preflightOrExit } from './preflight.mjs';
|
|
7
|
+
import { runProjectPrompts, printSummary as printAnswersSummary, confirmProceed } from './project-prompts.mjs';
|
|
8
|
+
import { ensureCredentials } from './credential-prompts.mjs';
|
|
9
|
+
import { maybeScaffoldStack } from './steps/next-app.mjs';
|
|
10
|
+
import { ingestMaterials } from './steps/ingest.mjs';
|
|
11
|
+
import { createGithubRepo } from './steps/github.mjs';
|
|
12
|
+
import { linkAndDeploy } from './steps/vercel.mjs';
|
|
13
|
+
import { disableProtection } from './steps/protection.mjs';
|
|
14
|
+
import { injectUrls } from './steps/inject.mjs';
|
|
15
|
+
import { renderSummary } from './summary.mjs';
|
|
16
|
+
import { substitute, buildTokenMap } from './substitute.mjs';
|
|
17
|
+
import { REGISTRY, OPTIONAL_REGISTRY } from './templates.mjs';
|
|
18
|
+
import { run as exec, isDryRun } from './exec.mjs';
|
|
19
|
+
|
|
20
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
|
|
22
|
+
const KIT_OVERWRITE = new Set(['README.md']);
|
|
23
|
+
|
|
24
|
+
export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
|
|
25
|
+
if (!flags.yes && !process.stdin.isTTY) {
|
|
26
|
+
const err = new Error(
|
|
27
|
+
'no TTY detected. Interactive prompts cannot run. Pass --yes plus required flags, or run from a terminal.'
|
|
28
|
+
);
|
|
29
|
+
err.code = 'NO_TTY';
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
preflightOrExit();
|
|
34
|
+
|
|
35
|
+
const answers = await runProjectPrompts({ flags });
|
|
36
|
+
if (!answers || answers.cancelled) {
|
|
37
|
+
process.stdout.write('Cancelled.\n');
|
|
38
|
+
const err = new Error('user cancelled prompts');
|
|
39
|
+
err.code = 'USER_ABORT';
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
printAnswersSummary(answers);
|
|
44
|
+
if (!flags.yes) {
|
|
45
|
+
const proceed = await confirmProceed();
|
|
46
|
+
if (!proceed) {
|
|
47
|
+
process.stdout.write('Cancelled.\n');
|
|
48
|
+
const err = new Error('user declined to proceed');
|
|
49
|
+
err.code = 'USER_ABORT';
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const creds = await ensureCredentials({ stack: answers.stack });
|
|
55
|
+
|
|
56
|
+
const targetDir = resolve(cwd, answers.projectName);
|
|
57
|
+
await ensureFreshTarget(targetDir, flags.force);
|
|
58
|
+
await mkdir(targetDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
await ingestMaterials({
|
|
61
|
+
targetDir,
|
|
62
|
+
ingestPath: answers.ingestPath,
|
|
63
|
+
isDryRun: isDryRun(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await maybeScaffoldStack({ stack: answers.stack, projectName: answers.projectName, targetDir });
|
|
67
|
+
|
|
68
|
+
const tokens = buildTokenMap({
|
|
69
|
+
projectName: answers.projectName,
|
|
70
|
+
displayName: answers.displayName,
|
|
71
|
+
description: answers.description,
|
|
72
|
+
owner: answers.githubOwner,
|
|
73
|
+
stack: answers.stack === 'next' ? 'Next.js + TypeScript + Tailwind' : answers.stack,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await overlayKit({ targetDir, tokens });
|
|
77
|
+
await writeProjectEnvFiles({ targetDir, collected: creds.collected });
|
|
78
|
+
await ensureGitignoreEnv({ targetDir });
|
|
79
|
+
|
|
80
|
+
await exec('git-init', 'git', ['init'], { cwd: targetDir });
|
|
81
|
+
await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
|
|
82
|
+
await exec('git-add', 'git', ['add', '.'], { cwd: targetDir });
|
|
83
|
+
await exec('git-commit','git', ['commit', '-m', 'chore: initial scaffold'], { cwd: targetDir });
|
|
84
|
+
|
|
85
|
+
let repoUrl = '';
|
|
86
|
+
let deployUrl = '';
|
|
87
|
+
let protection = null;
|
|
88
|
+
|
|
89
|
+
const repo = await createGithubRepo({
|
|
90
|
+
owner: answers.githubOwner,
|
|
91
|
+
projectName: answers.projectName,
|
|
92
|
+
visibility: answers.visibility,
|
|
93
|
+
description: answers.description,
|
|
94
|
+
cwd: targetDir,
|
|
95
|
+
});
|
|
96
|
+
repoUrl = repo.url || (isDryRun() ? `https://github.com/${answers.githubOwner}/${answers.projectName}` : '');
|
|
97
|
+
|
|
98
|
+
if (answers.stack !== 'none') {
|
|
99
|
+
const dep = await linkAndDeploy({
|
|
100
|
+
projectName: answers.projectName,
|
|
101
|
+
vercelScope: answers.vercelScope,
|
|
102
|
+
cwd: targetDir,
|
|
103
|
+
});
|
|
104
|
+
deployUrl = dep.url || (isDryRun() ? `https://${answers.projectName}.vercel.app` : '');
|
|
105
|
+
|
|
106
|
+
if (isDryRun()) {
|
|
107
|
+
process.stdout.write('[protection] would auto-disable deployment protection\n');
|
|
108
|
+
protection = { dryRun: true };
|
|
109
|
+
} else {
|
|
110
|
+
protection = await disableProtection({
|
|
111
|
+
projectName: answers.projectName,
|
|
112
|
+
deployUrl,
|
|
113
|
+
targetDir,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalTokens = buildTokenMap({
|
|
119
|
+
projectName: answers.projectName,
|
|
120
|
+
displayName: answers.displayName,
|
|
121
|
+
description: answers.description,
|
|
122
|
+
owner: answers.githubOwner,
|
|
123
|
+
stack: answers.stack === 'next' ? 'Next.js + TypeScript + Tailwind' : answers.stack,
|
|
124
|
+
repoUrl,
|
|
125
|
+
deployUrl,
|
|
126
|
+
});
|
|
127
|
+
const touched = await injectUrls({ targetDir, tokens: finalTokens });
|
|
128
|
+
if (touched.length > 0 && !isDryRun()) {
|
|
129
|
+
await exec('git-add-docs', 'git', ['add', ...touched], { cwd: targetDir });
|
|
130
|
+
await exec('git-commit-docs', 'git', ['commit', '-m', 'docs: inject URLs'], { cwd: targetDir });
|
|
131
|
+
await exec('git-push', 'git', ['push'], { cwd: targetDir });
|
|
132
|
+
} else if (isDryRun()) {
|
|
133
|
+
await exec('git-add-docs', 'git', ['add', 'CLAUDE.md', 'AGENTS.md', 'README.md'], { cwd: targetDir });
|
|
134
|
+
await exec('git-commit-docs', 'git', ['commit', '-m', 'docs: inject URLs'], { cwd: targetDir });
|
|
135
|
+
await exec('git-push', 'git', ['push'], { cwd: targetDir });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
process.stdout.write(renderSummary({
|
|
139
|
+
targetDir,
|
|
140
|
+
repoUrl,
|
|
141
|
+
deployUrl,
|
|
142
|
+
profile: answers.profile,
|
|
143
|
+
projectName: answers.projectName,
|
|
144
|
+
stack: answers.stack,
|
|
145
|
+
protection,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return { targetDir, repoUrl, deployUrl, protection, answers };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function ensureFreshTarget(absTarget, force) {
|
|
152
|
+
if (!existsSync(absTarget)) return;
|
|
153
|
+
const entries = await readdir(absTarget);
|
|
154
|
+
const meaningful = entries.filter((n) => !['.DS_Store', '.git'].includes(n));
|
|
155
|
+
if (meaningful.length === 0) return;
|
|
156
|
+
if (force) return;
|
|
157
|
+
const err = new Error(
|
|
158
|
+
`target directory is not empty: ${absTarget}. Pass --force to scaffold into it anyway.`
|
|
159
|
+
);
|
|
160
|
+
err.code = 'TARGET_NOT_EMPTY';
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function overlayKit({ targetDir, tokens }) {
|
|
165
|
+
for (const entry of REGISTRY) {
|
|
166
|
+
await writeOne({ entry, targetDir, tokens, defer: ['REPO_URL', 'DEPLOY_URL', 'LIVE_URL_OR_TBD', 'DEPLOY_TARGET'] });
|
|
167
|
+
}
|
|
168
|
+
for (const entry of OPTIONAL_REGISTRY) {
|
|
169
|
+
const dest = join(targetDir, entry.dest);
|
|
170
|
+
if (entry.onlyIfMissing && existsSync(dest) && !KIT_OVERWRITE.has(entry.dest)) continue;
|
|
171
|
+
await writeOne({ entry, targetDir, tokens, defer: ['REPO_URL', 'DEPLOY_URL', 'LIVE_URL_OR_TBD', 'DEPLOY_TARGET'] });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeOne({ entry, targetDir, tokens, defer }) {
|
|
176
|
+
const src = resolve(TEMPLATES_ROOT, entry.src);
|
|
177
|
+
const dest = resolve(targetDir, entry.dest);
|
|
178
|
+
const raw = await readFile(src, 'utf8');
|
|
179
|
+
const out = substitute(raw, tokens, { defer });
|
|
180
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
181
|
+
await writeFile(dest, out, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function writeProjectEnvFiles({ targetDir, collected }) {
|
|
185
|
+
const exampleSrc = resolve(PKG_ROOT, '.env.example');
|
|
186
|
+
if (existsSync(exampleSrc)) {
|
|
187
|
+
const raw = await readFile(exampleSrc, 'utf8');
|
|
188
|
+
await writeFile(join(targetDir, '.env.example'), raw, 'utf8');
|
|
189
|
+
}
|
|
190
|
+
if (collected && Object.keys(collected).length > 0) {
|
|
191
|
+
const lines = ['# Generated by ostup. Never commit.', ''];
|
|
192
|
+
for (const [k, v] of Object.entries(collected)) {
|
|
193
|
+
lines.push(`${k}=${v}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push('');
|
|
196
|
+
await writeFile(join(targetDir, '.env'), lines.join('\n'), 'utf8');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function ensureGitignoreEnv({ targetDir }) {
|
|
201
|
+
const path = join(targetDir, '.gitignore');
|
|
202
|
+
let body = '';
|
|
203
|
+
if (existsSync(path)) {
|
|
204
|
+
body = await readFile(path, 'utf8');
|
|
205
|
+
}
|
|
206
|
+
const required = ['.env', '.env.local', '.vercel', '!.env.example', 'inputs/', '!inputs/README.md'];
|
|
207
|
+
const lines = new Set(body.split(/\r?\n/));
|
|
208
|
+
let changed = false;
|
|
209
|
+
for (const line of required) {
|
|
210
|
+
if (!lines.has(line)) {
|
|
211
|
+
lines.add(line);
|
|
212
|
+
changed = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (changed || !body) {
|
|
216
|
+
const final = Array.from(lines).filter(Boolean).join('\n') + '\n';
|
|
217
|
+
await writeFile(path, final, 'utf8');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// preflight.mjs: verify required binaries (node, git, gh, vercel) are installed.
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export const CHECKS = [
|
|
5
|
+
{ name: 'Node', cmd: ['node', '--version'], fix: 'Install Node 20+ from https://nodejs.org', minNodeMajor: 20 },
|
|
6
|
+
{ name: 'Git', cmd: ['git', '--version'], fix: 'Install git from https://git-scm.com' },
|
|
7
|
+
{ name: 'GitHub CLI', cmd: ['gh', '--version'], fix: 'Install: brew install gh' },
|
|
8
|
+
{ name: 'Vercel CLI', cmd: ['vercel', '--version'], fix: 'Install: npm i -g vercel' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function defaultRunner(cmd) {
|
|
12
|
+
return execSync(cmd.join(' '), { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function preflight({ runner = defaultRunner, checks = CHECKS } = {}) {
|
|
16
|
+
for (const check of checks) {
|
|
17
|
+
let out;
|
|
18
|
+
try {
|
|
19
|
+
out = runner(check.cmd);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return { ok: false, failed: check, reason: err.message || String(err) };
|
|
22
|
+
}
|
|
23
|
+
if (check.minNodeMajor) {
|
|
24
|
+
const major = parseInt(String(out).replace(/^v/, '').split('.')[0], 10);
|
|
25
|
+
if (!Number.isFinite(major) || major < check.minNodeMajor) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
failed: check,
|
|
29
|
+
reason: `Node ${String(out).trim()} is too old, need ${check.minNodeMajor}+`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function failMessage(failed, reason) {
|
|
38
|
+
return [
|
|
39
|
+
`[preflight] FAIL: ${failed.name}`,
|
|
40
|
+
`Fix: ${failed.fix}`,
|
|
41
|
+
`Re-run ostup after fixing.`,
|
|
42
|
+
reason ? `Detail: ${reason.split(/\r?\n/)[0]}` : null,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join('\n') + '\n';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function preflightOrExit(opts) {
|
|
49
|
+
const result = preflight(opts);
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
process.stderr.write(failMessage(result.failed, result.reason));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// project-prompts.mjs: collect project-level answers via @clack/prompts with validation helpers.
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const KEBAB_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
7
|
+
|
|
8
|
+
export function validateProjectName(name) {
|
|
9
|
+
if (typeof name !== 'string') return 'must be a string';
|
|
10
|
+
if (name.length < 2) return 'must be at least 2 chars';
|
|
11
|
+
if (name.length > 40) return 'must be 40 chars or fewer';
|
|
12
|
+
if (!KEBAB_RE.test(name)) return 'must be kebab-case: lowercase letters, digits, and hyphens. No leading or trailing hyphen.';
|
|
13
|
+
if (name.includes('--')) return 'must not contain consecutive hyphens';
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateDescription(desc) {
|
|
18
|
+
if (typeof desc !== 'string' || desc.trim() === '') return 'required';
|
|
19
|
+
if (desc.length < 10) return 'must be at least 10 chars';
|
|
20
|
+
if (desc.length > 200) return 'must be 200 chars or fewer';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateNonEmpty(label) {
|
|
25
|
+
return (value) => {
|
|
26
|
+
if (typeof value !== 'string' || value.trim() === '') return `${label} is required`;
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateIngestPath(value) {
|
|
32
|
+
const ne = validateNonEmpty('ingest path')(value);
|
|
33
|
+
if (ne) return ne;
|
|
34
|
+
if (!existsSync(value)) return `path does not exist: ${value}`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toTitleCase(kebab) {
|
|
39
|
+
return String(kebab || '')
|
|
40
|
+
.split(/[-_\s]+/)
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
43
|
+
.join(' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tryCmd(cmd) {
|
|
47
|
+
try {
|
|
48
|
+
return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getGithubOwnerDefault() {
|
|
55
|
+
return process.env.GITHUB_OWNER || tryCmd('gh api user --jq .login');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getVercelScopeDefault() {
|
|
59
|
+
return process.env.VERCEL_SCOPE || tryCmd('vercel whoami');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runProjectPrompts({ flags = {} } = {}) {
|
|
63
|
+
const presetName = flags.name;
|
|
64
|
+
const presetProfile = flags.profile;
|
|
65
|
+
const acceptDefaults = Boolean(flags.yes);
|
|
66
|
+
|
|
67
|
+
if (presetName) {
|
|
68
|
+
const err = validateProjectName(presetName);
|
|
69
|
+
if (err) throw new Error(`--name invalid: ${err}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const projectName = presetName ?? await p.text({
|
|
73
|
+
message: 'Project name',
|
|
74
|
+
placeholder: 'my-app',
|
|
75
|
+
validate: (v) => validateProjectName(v) || undefined,
|
|
76
|
+
});
|
|
77
|
+
if (p.isCancel(projectName)) return { cancelled: true };
|
|
78
|
+
|
|
79
|
+
const displayName = acceptDefaults
|
|
80
|
+
? toTitleCase(projectName)
|
|
81
|
+
: await p.text({
|
|
82
|
+
message: 'Display name',
|
|
83
|
+
initialValue: toTitleCase(projectName),
|
|
84
|
+
validate: (v) => validateNonEmpty('display name')(v) || undefined,
|
|
85
|
+
});
|
|
86
|
+
if (p.isCancel(displayName)) return { cancelled: true };
|
|
87
|
+
|
|
88
|
+
const autoDescription = `${displayName}: scaffolded with ostup on ${new Date().toISOString().slice(0, 10)}.`;
|
|
89
|
+
const description = acceptDefaults
|
|
90
|
+
? autoDescription
|
|
91
|
+
: await p.text({
|
|
92
|
+
message: 'One-sentence description (10-200 chars)',
|
|
93
|
+
validate: (v) => validateDescription(v) || undefined,
|
|
94
|
+
});
|
|
95
|
+
if (p.isCancel(description)) return { cancelled: true };
|
|
96
|
+
|
|
97
|
+
const profile = presetProfile ?? (acceptDefaults
|
|
98
|
+
? 'goodshin'
|
|
99
|
+
: await p.select({
|
|
100
|
+
message: 'Profile',
|
|
101
|
+
options: [
|
|
102
|
+
{ value: 'goodshin', label: 'goodshin' },
|
|
103
|
+
{ value: 'default', label: 'default' },
|
|
104
|
+
],
|
|
105
|
+
initialValue: 'goodshin',
|
|
106
|
+
}));
|
|
107
|
+
if (p.isCancel(profile)) return { cancelled: true };
|
|
108
|
+
|
|
109
|
+
const stack = acceptDefaults
|
|
110
|
+
? 'next'
|
|
111
|
+
: await p.select({
|
|
112
|
+
message: 'Stack',
|
|
113
|
+
options: [
|
|
114
|
+
{ value: 'next', label: 'next (Next.js + Tailwind, deploys to Vercel)' },
|
|
115
|
+
{ value: 'static', label: 'static (single index.html, deploys to Vercel)' },
|
|
116
|
+
{ value: 'none', label: 'none (no app code, no Vercel deploy)' },
|
|
117
|
+
],
|
|
118
|
+
initialValue: 'next',
|
|
119
|
+
});
|
|
120
|
+
if (p.isCancel(stack)) return { cancelled: true };
|
|
121
|
+
|
|
122
|
+
let ingestPath = null;
|
|
123
|
+
if (flags.ingest) {
|
|
124
|
+
const err = validateIngestPath(flags.ingest);
|
|
125
|
+
if (err) throw new Error(`--ingest invalid: ${err}`);
|
|
126
|
+
ingestPath = flags.ingest;
|
|
127
|
+
} else if (!acceptDefaults) {
|
|
128
|
+
const bring = await p.confirm({
|
|
129
|
+
message: 'Do you have prior materials to bring in? Research docs, reference repos, websites, images, notes.',
|
|
130
|
+
initialValue: false,
|
|
131
|
+
});
|
|
132
|
+
if (p.isCancel(bring)) return { cancelled: true };
|
|
133
|
+
if (bring) {
|
|
134
|
+
const path = await p.text({
|
|
135
|
+
message: 'Source path to copy from',
|
|
136
|
+
placeholder: '/path/to/materials',
|
|
137
|
+
validate: (v) => validateIngestPath(v) || undefined,
|
|
138
|
+
});
|
|
139
|
+
if (p.isCancel(path)) return { cancelled: true };
|
|
140
|
+
ingestPath = path;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const visibility = acceptDefaults
|
|
145
|
+
? 'private'
|
|
146
|
+
: await p.select({
|
|
147
|
+
message: 'GitHub visibility',
|
|
148
|
+
options: [
|
|
149
|
+
{ value: 'private', label: 'private' },
|
|
150
|
+
{ value: 'public', label: 'public' },
|
|
151
|
+
],
|
|
152
|
+
initialValue: 'private',
|
|
153
|
+
});
|
|
154
|
+
if (p.isCancel(visibility)) return { cancelled: true };
|
|
155
|
+
|
|
156
|
+
const ownerDefault = getGithubOwnerDefault();
|
|
157
|
+
let githubOwner;
|
|
158
|
+
if (acceptDefaults && ownerDefault) {
|
|
159
|
+
githubOwner = ownerDefault;
|
|
160
|
+
} else {
|
|
161
|
+
githubOwner = await p.text({
|
|
162
|
+
message: 'GitHub owner (username or org)',
|
|
163
|
+
initialValue: ownerDefault,
|
|
164
|
+
validate: (v) => validateNonEmpty('GitHub owner')(v) || undefined,
|
|
165
|
+
});
|
|
166
|
+
if (p.isCancel(githubOwner)) return { cancelled: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let vercelScope = '';
|
|
170
|
+
if (stack !== 'none') {
|
|
171
|
+
const scopeDefault = getVercelScopeDefault();
|
|
172
|
+
if (acceptDefaults && scopeDefault) {
|
|
173
|
+
vercelScope = scopeDefault;
|
|
174
|
+
} else {
|
|
175
|
+
vercelScope = await p.text({
|
|
176
|
+
message: 'Vercel scope (account or team slug)',
|
|
177
|
+
initialValue: scopeDefault,
|
|
178
|
+
validate: (v) => validateNonEmpty('Vercel scope')(v) || undefined,
|
|
179
|
+
});
|
|
180
|
+
if (p.isCancel(vercelScope)) return { cancelled: true };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
projectName,
|
|
186
|
+
displayName,
|
|
187
|
+
description,
|
|
188
|
+
profile,
|
|
189
|
+
stack,
|
|
190
|
+
ingestPath,
|
|
191
|
+
visibility,
|
|
192
|
+
githubOwner,
|
|
193
|
+
vercelScope,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function printSummary(answers) {
|
|
198
|
+
const lines = [
|
|
199
|
+
'',
|
|
200
|
+
'Summary',
|
|
201
|
+
'-------',
|
|
202
|
+
` Project name: ${answers.projectName}`,
|
|
203
|
+
` Display name: ${answers.displayName}`,
|
|
204
|
+
` Description: ${answers.description}`,
|
|
205
|
+
` Profile: ${answers.profile}`,
|
|
206
|
+
` Stack: ${answers.stack}`,
|
|
207
|
+
` Ingest path: ${answers.ingestPath || '(none)'}`,
|
|
208
|
+
` Visibility: ${answers.visibility}`,
|
|
209
|
+
` GitHub owner: ${answers.githubOwner}`,
|
|
210
|
+
` Vercel scope: ${answers.vercelScope || '(none, stack=none)'}`,
|
|
211
|
+
'',
|
|
212
|
+
];
|
|
213
|
+
process.stdout.write(lines.join('\n'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function confirmProceed() {
|
|
217
|
+
const answer = await p.confirm({ message: 'Proceed with these settings?', initialValue: true });
|
|
218
|
+
if (p.isCancel(answer)) return false;
|
|
219
|
+
return Boolean(answer);
|
|
220
|
+
}
|
package/src/prompts.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import promptsLib from 'prompts';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function gitUserName() {
|
|
6
|
+
try {
|
|
7
|
+
return execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
8
|
+
.toString()
|
|
9
|
+
.trim();
|
|
10
|
+
} catch {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runPrompts({ targetDir, config = {}, skipIfPresent = true }) {
|
|
16
|
+
const defaultName = basename(targetDir);
|
|
17
|
+
const defaultOwner = gitUserName();
|
|
18
|
+
|
|
19
|
+
const questions = [
|
|
20
|
+
{
|
|
21
|
+
type: skipIfPresent && config.projectName ? null : 'text',
|
|
22
|
+
name: 'projectName',
|
|
23
|
+
message: 'Project name',
|
|
24
|
+
initial: config.projectName || defaultName,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: skipIfPresent && config.purpose ? null : 'text',
|
|
28
|
+
name: 'purpose',
|
|
29
|
+
message: 'One-sentence purpose',
|
|
30
|
+
initial: config.purpose || '',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: skipIfPresent && config.owner ? null : 'text',
|
|
34
|
+
name: 'owner',
|
|
35
|
+
message: 'Owner / client',
|
|
36
|
+
initial: config.owner || defaultOwner,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: skipIfPresent && config.stack ? null : 'text',
|
|
40
|
+
name: 'stack',
|
|
41
|
+
message: 'Primary stack (e.g. "Next.js + Supabase")',
|
|
42
|
+
initial: config.stack || '',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: skipIfPresent && config.deploy ? null : 'text',
|
|
46
|
+
name: 'deploy',
|
|
47
|
+
message: 'Deploy target / live URL (optional)',
|
|
48
|
+
initial: config.deploy || '',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const answers = await promptsLib(questions, {
|
|
53
|
+
onCancel: () => {
|
|
54
|
+
process.stderr.write('cancelled\n');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return answers;
|
|
60
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
import kleur from 'kleur';
|
|
8
|
+
import { substitute, buildTokenMap } from './substitute.mjs';
|
|
9
|
+
import { REGISTRY, OPTIONAL_REGISTRY } from './templates.mjs';
|
|
10
|
+
import { loadConfig, mergeValues } from './config.mjs';
|
|
11
|
+
import { runPrompts } from './prompts.mjs';
|
|
12
|
+
|
|
13
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
|
|
15
|
+
|
|
16
|
+
export async function scaffold({ targetDir, flags, stdinIsTTY = process.stdin.isTTY }) {
|
|
17
|
+
const absTarget = resolve(targetDir);
|
|
18
|
+
|
|
19
|
+
await ensureTargetReady(absTarget, flags.force);
|
|
20
|
+
|
|
21
|
+
const config = await loadConfig({ targetDir: absTarget, configPath: flags.config });
|
|
22
|
+
const defaults = { projectName: basename(absTarget) };
|
|
23
|
+
|
|
24
|
+
let promptAnswers = {};
|
|
25
|
+
if (!flags.yes) {
|
|
26
|
+
if (!stdinIsTTY) {
|
|
27
|
+
const err = new Error(
|
|
28
|
+
'no TTY detected. Interactive prompts cannot run.\n' +
|
|
29
|
+
'Pass --yes (with an optional --config <path> or .ostup-config.yml in the target) to scaffold non-interactively.'
|
|
30
|
+
);
|
|
31
|
+
err.code = 'NO_TTY';
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
promptAnswers = await runPrompts({ targetDir: absTarget, config });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const values = mergeValues({ config, prompts: promptAnswers, defaults });
|
|
38
|
+
const tokens = buildTokenMap(values);
|
|
39
|
+
|
|
40
|
+
await mkdir(absTarget, { recursive: true });
|
|
41
|
+
|
|
42
|
+
for (const entry of REGISTRY) {
|
|
43
|
+
await writeOne({ entry, absTarget, tokens });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const entry of OPTIONAL_REGISTRY) {
|
|
47
|
+
const destPath = join(absTarget, entry.dest);
|
|
48
|
+
if (entry.onlyIfMissing && existsSync(destPath)) continue;
|
|
49
|
+
await writeOne({ entry, absTarget, tokens });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
initGitIfNeeded(absTarget);
|
|
53
|
+
printNextSteps(absTarget);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writeOne({ entry, absTarget, tokens }) {
|
|
57
|
+
const src = resolve(TEMPLATES_ROOT, entry.src);
|
|
58
|
+
const dest = resolve(absTarget, entry.dest);
|
|
59
|
+
const raw = await readFile(src, 'utf8');
|
|
60
|
+
const out = substitute(raw, tokens);
|
|
61
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
62
|
+
await writeFile(dest, out, 'utf8');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function ensureTargetReady(absTarget, force) {
|
|
66
|
+
if (!existsSync(absTarget)) return;
|
|
67
|
+
const entries = await readdir(absTarget);
|
|
68
|
+
const meaningful = entries.filter((n) => !['.DS_Store', '.git'].includes(n));
|
|
69
|
+
if (meaningful.length === 0) return;
|
|
70
|
+
if (!force) {
|
|
71
|
+
const err = new Error(
|
|
72
|
+
`target directory is not empty: ${absTarget}\n(pass --force to scaffold into it anyway)`
|
|
73
|
+
);
|
|
74
|
+
err.code = 'TARGET_NOT_EMPTY';
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function initGitIfNeeded(absTarget) {
|
|
80
|
+
try {
|
|
81
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
82
|
+
cwd: absTarget,
|
|
83
|
+
stdio: 'ignore',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
} catch {
|
|
87
|
+
// not a git repo yet: init it
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
execSync('git init', { cwd: absTarget, stdio: 'ignore' });
|
|
91
|
+
} catch {
|
|
92
|
+
process.stderr.write(kleur.yellow('warn: git init failed; you can run it manually\n'));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printNextSteps(absTarget) {
|
|
97
|
+
const colorize = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
98
|
+
const c = colorize ? kleur : { bold: (s) => s, cyan: (s) => s, dim: (s) => s };
|
|
99
|
+
const out = [
|
|
100
|
+
'',
|
|
101
|
+
c.bold('Scaffold complete.') + ` ${c.dim(absTarget)}`,
|
|
102
|
+
'',
|
|
103
|
+
'Next steps:',
|
|
104
|
+
` 1. ${c.cyan('cd')} ${absTarget}`,
|
|
105
|
+
` 2. ${c.cyan('claude')}`,
|
|
106
|
+
` 3. ${c.cyan('/bootstrap')} ${c.dim('(fills in the deeper placeholders)')}`,
|
|
107
|
+
` 4. ${c.cyan('/prompt-start')} ${c.dim('(begin a working session)')}`,
|
|
108
|
+
` 5. Edit ${c.cyan('CLAUDE.md')} Parts 13-17 if you need project-specific rules.`,
|
|
109
|
+
'',
|
|
110
|
+
].join('\n');
|
|
111
|
+
process.stdout.write(out + '\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// steps/github.mjs: create GitHub repo via gh CLI and capture the repo URL.
|
|
2
|
+
import { run } from '../exec.mjs';
|
|
3
|
+
|
|
4
|
+
export async function createGithubRepo({ owner, projectName, visibility, description, cwd }) {
|
|
5
|
+
const slug = `${owner}/${projectName}`;
|
|
6
|
+
const visFlag = visibility === 'public' ? '--public' : '--private';
|
|
7
|
+
await run('github', 'gh', [
|
|
8
|
+
'repo', 'create', slug,
|
|
9
|
+
visFlag,
|
|
10
|
+
'--source=.',
|
|
11
|
+
'--push',
|
|
12
|
+
'--description', description,
|
|
13
|
+
], { cwd });
|
|
14
|
+
const view = await run('github', 'gh', [
|
|
15
|
+
'repo', 'view', slug,
|
|
16
|
+
'--json', 'url',
|
|
17
|
+
'--jq', '.url',
|
|
18
|
+
], { cwd });
|
|
19
|
+
const url = (view.stdout || '').toString().trim();
|
|
20
|
+
return { slug, url };
|
|
21
|
+
}
|