@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// steps/ingest.mjs: create the inputs/ folder skeleton and optionally copy operator-supplied materials.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, cp, writeFile, readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { isDryRun } from '../exec.mjs';
|
|
6
|
+
|
|
7
|
+
export const INGEST_SUBFOLDERS = ['research', 'references', 'images', 'notes'];
|
|
8
|
+
|
|
9
|
+
export async function ingestMaterials({ targetDir, ingestPath, isDryRun: dryRunOverride } = {}) {
|
|
10
|
+
if (!targetDir) {
|
|
11
|
+
const err = new Error('ingestMaterials requires targetDir');
|
|
12
|
+
err.code = 'INGEST_BAD_ARGS';
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const inputsDir = join(targetDir, 'inputs');
|
|
17
|
+
const dryRun = dryRunOverride !== undefined ? dryRunOverride : isDryRun();
|
|
18
|
+
|
|
19
|
+
let srcResolved = null;
|
|
20
|
+
if (ingestPath) {
|
|
21
|
+
srcResolved = resolve(ingestPath);
|
|
22
|
+
if (!existsSync(srcResolved)) {
|
|
23
|
+
const err = new Error(`ingest source path does not exist: ${srcResolved}`);
|
|
24
|
+
err.code = 'INGEST_PATH_NOT_FOUND';
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (dryRun) {
|
|
30
|
+
process.stdout.write(`[ingest] would create: ${inputsDir} + ${INGEST_SUBFOLDERS.map((s) => `${s}/`).join(', ')}\n`);
|
|
31
|
+
if (srcResolved) {
|
|
32
|
+
process.stdout.write(`[ingest] would copy: ${srcResolved} -> ${inputsDir}\n`);
|
|
33
|
+
process.stdout.write(`[ingest] would write: ${join(inputsDir, 'INGEST_MANIFEST.md')}\n`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
inputsDir,
|
|
37
|
+
created: ['inputs/', ...INGEST_SUBFOLDERS.map((s) => `inputs/${s}/`)],
|
|
38
|
+
copied: 0,
|
|
39
|
+
manifest: null,
|
|
40
|
+
dryRun: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const created = ['inputs/'];
|
|
45
|
+
await mkdir(inputsDir, { recursive: true });
|
|
46
|
+
for (const sub of INGEST_SUBFOLDERS) {
|
|
47
|
+
await mkdir(join(inputsDir, sub), { recursive: true });
|
|
48
|
+
created.push(`inputs/${sub}/`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!srcResolved) {
|
|
52
|
+
return { inputsDir, created, copied: 0, manifest: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await cp(srcResolved, inputsDir, { recursive: true, errorOnExist: false, force: true });
|
|
56
|
+
|
|
57
|
+
const summary = await summarize(srcResolved);
|
|
58
|
+
const manifestPath = join(inputsDir, 'INGEST_MANIFEST.md');
|
|
59
|
+
await writeFile(manifestPath, renderManifest({ src: srcResolved, ...summary }), 'utf8');
|
|
60
|
+
|
|
61
|
+
return { inputsDir, created, copied: summary.fileCount, manifest: manifestPath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function summarize(dir) {
|
|
65
|
+
let fileCount = 0;
|
|
66
|
+
let totalBytes = 0;
|
|
67
|
+
const topLevel = [];
|
|
68
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
topLevel.push(entry.isDirectory() ? `${entry.name}/` : entry.name);
|
|
71
|
+
const full = join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const sub = await walk(full);
|
|
74
|
+
fileCount += sub.fileCount;
|
|
75
|
+
totalBytes += sub.totalBytes;
|
|
76
|
+
} else if (entry.isFile()) {
|
|
77
|
+
fileCount += 1;
|
|
78
|
+
const s = await stat(full);
|
|
79
|
+
totalBytes += s.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { fileCount, totalBytes, topLevel };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function walk(dir) {
|
|
86
|
+
let fileCount = 0;
|
|
87
|
+
let totalBytes = 0;
|
|
88
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const full = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
const sub = await walk(full);
|
|
93
|
+
fileCount += sub.fileCount;
|
|
94
|
+
totalBytes += sub.totalBytes;
|
|
95
|
+
} else if (entry.isFile()) {
|
|
96
|
+
fileCount += 1;
|
|
97
|
+
const s = await stat(full);
|
|
98
|
+
totalBytes += s.size;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { fileCount, totalBytes };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderManifest({ src, fileCount, totalBytes, topLevel }) {
|
|
105
|
+
const sizeKb = (totalBytes / 1024).toFixed(1);
|
|
106
|
+
const lines = [
|
|
107
|
+
'# Ingest manifest',
|
|
108
|
+
'',
|
|
109
|
+
`Source path: ${src}`,
|
|
110
|
+
`Files copied: ${fileCount}`,
|
|
111
|
+
`Total size: ${sizeKb} KB`,
|
|
112
|
+
'',
|
|
113
|
+
'## Top-level entries',
|
|
114
|
+
'',
|
|
115
|
+
...topLevel.sort().map((name) => `- ${name}`),
|
|
116
|
+
'',
|
|
117
|
+
'> Generated by ostup at scaffold time. Safe to delete or regenerate.',
|
|
118
|
+
'',
|
|
119
|
+
];
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// steps/inject.mjs: re-substitute templates after deploy to inject REPO_URL and DEPLOY_URL into CLAUDE.md, AGENTS.md, README.md.
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { substitute } from '../substitute.mjs';
|
|
6
|
+
|
|
7
|
+
const TARGETS = ['CLAUDE.md', 'AGENTS.md', 'README.md'];
|
|
8
|
+
|
|
9
|
+
export async function injectUrls({ targetDir, tokens }) {
|
|
10
|
+
const touched = [];
|
|
11
|
+
for (const rel of TARGETS) {
|
|
12
|
+
const path = join(targetDir, rel);
|
|
13
|
+
if (!existsSync(path)) continue;
|
|
14
|
+
const raw = await readFile(path, 'utf8');
|
|
15
|
+
const out = substitute(raw, tokens);
|
|
16
|
+
if (out !== raw) {
|
|
17
|
+
await writeFile(path, out, 'utf8');
|
|
18
|
+
touched.push(rel);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return touched;
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// steps/next-app.mjs: run create-next-app or write a minimal static index.html into the target.
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { run } from '../exec.mjs';
|
|
4
|
+
|
|
5
|
+
export async function maybeScaffoldStack({ stack, projectName, targetDir }) {
|
|
6
|
+
if (stack === 'next') {
|
|
7
|
+
await run('next-app', 'npx', [
|
|
8
|
+
'create-next-app@latest', '.',
|
|
9
|
+
'--typescript',
|
|
10
|
+
'--tailwind',
|
|
11
|
+
'--app',
|
|
12
|
+
'--src-dir',
|
|
13
|
+
'--import-alias', '@/*',
|
|
14
|
+
'--use-npm',
|
|
15
|
+
'--eslint',
|
|
16
|
+
'--yes',
|
|
17
|
+
], { cwd: targetDir });
|
|
18
|
+
return { stack };
|
|
19
|
+
}
|
|
20
|
+
if (stack === 'static') {
|
|
21
|
+
await mkdir(targetDir, { recursive: true });
|
|
22
|
+
const html = [
|
|
23
|
+
'<!doctype html>',
|
|
24
|
+
'<html lang="en">',
|
|
25
|
+
'<head>',
|
|
26
|
+
' <meta charset="utf-8" />',
|
|
27
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
28
|
+
` <title>${projectName}</title>`,
|
|
29
|
+
'</head>',
|
|
30
|
+
'<body>',
|
|
31
|
+
` <h1>${projectName}</h1>`,
|
|
32
|
+
' <p>Scaffolded by ostup.</p>',
|
|
33
|
+
'</body>',
|
|
34
|
+
'</html>',
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
await writeFile(`${targetDir}/index.html`, html, 'utf8');
|
|
38
|
+
return { stack };
|
|
39
|
+
}
|
|
40
|
+
return { stack };
|
|
41
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// steps/protection.mjs: after vercel deploy, auto-disable ssoProtection + passwordProtection
|
|
2
|
+
// on the linked project via the Vercel REST API so the deploy URL returns 200 publicly.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
export const PROTECTION_BODY = Object.freeze({
|
|
9
|
+
ssoProtection: null,
|
|
10
|
+
passwordProtection: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const AUTH_JSON_PATHS = {
|
|
14
|
+
darwin: () => join(homedir(), 'Library', 'Application Support', 'com.vercel.cli', 'auth.json'),
|
|
15
|
+
linux: () => join(homedir(), '.local', 'share', 'com.vercel.cli', 'auth.json'),
|
|
16
|
+
win32: () => process.env.APPDATA ? join(process.env.APPDATA, 'com.vercel.cli', 'auth.json') : '',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function resolveVercelToken({ env = process.env, platform = process.platform } = {}) {
|
|
20
|
+
if (env.VERCEL_TOKEN && env.VERCEL_TOKEN.length > 0) {
|
|
21
|
+
return { token: env.VERCEL_TOKEN, source: 'env' };
|
|
22
|
+
}
|
|
23
|
+
const fn = AUTH_JSON_PATHS[platform];
|
|
24
|
+
if (!fn) return { token: null, source: null };
|
|
25
|
+
const path = fn();
|
|
26
|
+
if (!path || !existsSync(path)) return { token: null, source: null };
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(path, 'utf8');
|
|
29
|
+
const json = JSON.parse(raw);
|
|
30
|
+
if (json && typeof json.token === 'string' && json.token.length > 0) {
|
|
31
|
+
return { token: json.token, source: 'auth.json', path };
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// fall through to null
|
|
35
|
+
}
|
|
36
|
+
return { token: null, source: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readLinkedProject(targetDir) {
|
|
40
|
+
const path = join(targetDir, '.vercel', 'project.json');
|
|
41
|
+
if (!existsSync(path)) return null;
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(path, 'utf8');
|
|
44
|
+
const json = JSON.parse(raw);
|
|
45
|
+
return {
|
|
46
|
+
projectId: typeof json.projectId === 'string' ? json.projectId : null,
|
|
47
|
+
orgId: typeof json.orgId === 'string' ? json.orgId : null,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isTeamOrgId(orgId) {
|
|
55
|
+
return typeof orgId === 'string' && orgId.startsWith('team_');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildPatchUrl(projectId, orgId) {
|
|
59
|
+
const url = new URL(`https://api.vercel.com/v9/projects/${projectId}`);
|
|
60
|
+
if (isTeamOrgId(orgId)) url.searchParams.set('teamId', orgId);
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function patchProtection({ token, projectId, orgId, fetchImpl = fetch }) {
|
|
65
|
+
const url = buildPatchUrl(projectId, orgId);
|
|
66
|
+
const res = await fetchImpl(url, {
|
|
67
|
+
method: 'PATCH',
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${token}`,
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify(PROTECTION_BODY),
|
|
73
|
+
});
|
|
74
|
+
return res;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function verifyDeployStatus(deployUrl, {
|
|
78
|
+
delayMs = 5000,
|
|
79
|
+
retryDelayMs = 10000,
|
|
80
|
+
fetchImpl = fetch,
|
|
81
|
+
sleep,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
84
|
+
await wait(delayMs);
|
|
85
|
+
let res = await fetchImpl(deployUrl, { method: 'HEAD', redirect: 'manual' });
|
|
86
|
+
if (res.status === 200) return { status: 200, attempts: 1 };
|
|
87
|
+
if (res.status === 401) {
|
|
88
|
+
await wait(retryDelayMs);
|
|
89
|
+
res = await fetchImpl(deployUrl, { method: 'HEAD', redirect: 'manual' });
|
|
90
|
+
return { status: res.status, attempts: 2 };
|
|
91
|
+
}
|
|
92
|
+
return { status: res.status, attempts: 1 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printManualWarn(projectName, stream = process.stdout) {
|
|
96
|
+
stream.write(
|
|
97
|
+
[
|
|
98
|
+
'[protection] WARN: could not auto-disable deployment protection.',
|
|
99
|
+
'Your deploy URL will return 401 until you disable it manually:',
|
|
100
|
+
' 1. Open https://vercel.com/dashboard',
|
|
101
|
+
` 2. Click the project: ${projectName}`,
|
|
102
|
+
' 3. Settings, Deployment Protection, Disable',
|
|
103
|
+
'Continuing without disabling.',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n')
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printVerifyWarn(projectName, status, stream = process.stdout) {
|
|
110
|
+
stream.write(
|
|
111
|
+
[
|
|
112
|
+
`[protection] WARN: deploy URL still returns ${status} after disable.`,
|
|
113
|
+
'Protection may not have cleared. Manual fix:',
|
|
114
|
+
' 1. Open https://vercel.com/dashboard',
|
|
115
|
+
` 2. Click the project: ${projectName}`,
|
|
116
|
+
' 3. Settings, Deployment Protection, Disable',
|
|
117
|
+
'',
|
|
118
|
+
].join('\n')
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function disableProtection({
|
|
123
|
+
projectName,
|
|
124
|
+
deployUrl,
|
|
125
|
+
targetDir,
|
|
126
|
+
env = process.env,
|
|
127
|
+
platform = process.platform,
|
|
128
|
+
fetchImpl = fetch,
|
|
129
|
+
sleep,
|
|
130
|
+
skipVerify = false,
|
|
131
|
+
stream = process.stdout,
|
|
132
|
+
} = {}) {
|
|
133
|
+
const tokenInfo = await resolveVercelToken({ env, platform });
|
|
134
|
+
if (!tokenInfo.token) {
|
|
135
|
+
printManualWarn(projectName, stream);
|
|
136
|
+
return { disabled: false, reason: 'no-token' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const linked = await readLinkedProject(targetDir);
|
|
140
|
+
if (!linked || !linked.projectId) {
|
|
141
|
+
printManualWarn(projectName, stream);
|
|
142
|
+
return { disabled: false, reason: 'no-project-id' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { projectId, orgId } = linked;
|
|
146
|
+
|
|
147
|
+
let patchRes;
|
|
148
|
+
try {
|
|
149
|
+
patchRes = await patchProtection({ token: tokenInfo.token, projectId, orgId, fetchImpl });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
printManualWarn(projectName, stream);
|
|
152
|
+
return { disabled: false, reason: 'patch-network-error', error: err && err.message };
|
|
153
|
+
}
|
|
154
|
+
if (!patchRes || !patchRes.ok) {
|
|
155
|
+
printManualWarn(projectName, stream);
|
|
156
|
+
return { disabled: false, reason: 'patch-failed', status: patchRes && patchRes.status };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stream.write('[protection] PATCH ok\n');
|
|
160
|
+
|
|
161
|
+
if (skipVerify || !deployUrl) {
|
|
162
|
+
stream.write('[protection] disabled successfully\n');
|
|
163
|
+
return { disabled: true, verified: false, status: null, skippedVerify: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let verify;
|
|
167
|
+
try {
|
|
168
|
+
verify = await verifyDeployStatus(deployUrl, { fetchImpl, sleep });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
printVerifyWarn(projectName, 'unreachable', stream);
|
|
171
|
+
return { disabled: true, verified: false, status: null, error: err && err.message };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (verify.status === 200) {
|
|
175
|
+
stream.write('[protection] disabled successfully\n');
|
|
176
|
+
return { disabled: true, verified: true, status: 200 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
printVerifyWarn(projectName, verify.status, stream);
|
|
180
|
+
return { disabled: true, verified: false, status: verify.status };
|
|
181
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// steps/vercel.mjs: link the project to a Vercel scope and trigger a deploy; capture the URL.
|
|
2
|
+
import { run } from '../exec.mjs';
|
|
3
|
+
|
|
4
|
+
export function parseDeployUrl(stdout) {
|
|
5
|
+
const text = String(stdout || '');
|
|
6
|
+
const urls = [];
|
|
7
|
+
const re = /https?:\/\/[^\s"'`<>)\]]+/g;
|
|
8
|
+
let m;
|
|
9
|
+
while ((m = re.exec(text)) !== null) {
|
|
10
|
+
urls.push(m[0].replace(/[.,;:!?'"`)\]]+$/, ''));
|
|
11
|
+
}
|
|
12
|
+
if (urls.length === 0) return null;
|
|
13
|
+
const isProdLike = (u) => /\.vercel\.app(\/|$)/.test(u) && !u.includes('api.vercel.com') && !u.includes('/inspect');
|
|
14
|
+
const prod = urls.filter(isProdLike);
|
|
15
|
+
if (prod.length > 0) return prod[prod.length - 1];
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getPersonalScope() {
|
|
20
|
+
try {
|
|
21
|
+
const r = await run('vercel-whoami', 'vercel', ['whoami'], {});
|
|
22
|
+
return (r.stdout || '').toString().trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function linkAndDeploy({ projectName, vercelScope, cwd }) {
|
|
29
|
+
const personal = await getPersonalScope();
|
|
30
|
+
const linkArgs = ['link', '--yes', '--project', projectName];
|
|
31
|
+
if (vercelScope && vercelScope !== personal) {
|
|
32
|
+
linkArgs.push('--scope', vercelScope);
|
|
33
|
+
}
|
|
34
|
+
await run('vercel-link', 'vercel', linkArgs, { cwd });
|
|
35
|
+
const deploy = await run('vercel-deploy', 'vercel', ['--yes'], { cwd });
|
|
36
|
+
let url = parseDeployUrl(deploy.stdout);
|
|
37
|
+
if (!url) {
|
|
38
|
+
try {
|
|
39
|
+
const ls = await run('vercel-ls', 'vercel', ['ls', '--prod'], { cwd });
|
|
40
|
+
url = parseDeployUrl(ls.stdout);
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore, fallback to no URL
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { url, raw: deploy.stdout };
|
|
46
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// substitute.mjs: replace {{UPPERCASE_TOKENS}} in content with values; unknown keys fall back to TBD or stay deferred.
|
|
2
|
+
const TOKEN_RE = /\{\{([A-Z_][A-Z_0-9]*)\}\}/g;
|
|
3
|
+
|
|
4
|
+
export function substitute(content, values = {}, opts = {}) {
|
|
5
|
+
const defer = new Set(opts.defer || []);
|
|
6
|
+
return content.replace(TOKEN_RE, (match, key) => {
|
|
7
|
+
const v = values[key];
|
|
8
|
+
if (v == null || v === '') {
|
|
9
|
+
if (defer.has(key)) return match;
|
|
10
|
+
return 'TBD';
|
|
11
|
+
}
|
|
12
|
+
return String(v);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildTokenMap({
|
|
17
|
+
projectName,
|
|
18
|
+
purpose,
|
|
19
|
+
owner,
|
|
20
|
+
stack,
|
|
21
|
+
deploy,
|
|
22
|
+
displayName,
|
|
23
|
+
description,
|
|
24
|
+
repoUrl,
|
|
25
|
+
deployUrl,
|
|
26
|
+
} = {}) {
|
|
27
|
+
return {
|
|
28
|
+
PROJECT_NAME: projectName,
|
|
29
|
+
REPO_NAME: projectName,
|
|
30
|
+
DISPLAY_NAME: displayName || projectName,
|
|
31
|
+
PROJECT_PURPOSE_ONE_SENTENCE: description || purpose,
|
|
32
|
+
PURPOSE: description || purpose,
|
|
33
|
+
WHAT_WE_ARE_BUILDING_OR_FIXING: description || purpose,
|
|
34
|
+
WHAT_LIVES_HERE: description || purpose,
|
|
35
|
+
OWNER_OR_CLIENT: owner,
|
|
36
|
+
FRAMEWORK: stack,
|
|
37
|
+
LANGUAGE: stack,
|
|
38
|
+
LIVE_URL_OR_TBD: deployUrl || deploy,
|
|
39
|
+
DEPLOY_TARGET: deployUrl || deploy,
|
|
40
|
+
REPO_URL: repoUrl,
|
|
41
|
+
DEPLOY_URL: deployUrl,
|
|
42
|
+
INPUTS_PATH: 'inputs/',
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/summary.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// summary.mjs: render the final "Project ready" block exactly per spec.
|
|
2
|
+
export function renderProtectionLine(protection, deployUrl) {
|
|
3
|
+
if (!protection) return null;
|
|
4
|
+
if (protection.dryRun) return 'skipped (dry-run)';
|
|
5
|
+
if (protection.disabled && protection.verified) return 'disabled';
|
|
6
|
+
if (protection.disabled && protection.skippedVerify) return 'disabled (not verified)';
|
|
7
|
+
return `MANUAL FIX NEEDED: ${deployUrl || 'see Vercel dashboard'}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderSummary({ targetDir, repoUrl, deployUrl, profile, projectName, stack, protection }) {
|
|
11
|
+
const deploy = deployUrl || (stack === 'none' ? 'skipped (stack=none)' : 'TBD');
|
|
12
|
+
const protectionLine = renderProtectionLine(protection, deployUrl);
|
|
13
|
+
const lines = [
|
|
14
|
+
'================================================',
|
|
15
|
+
' Project ready',
|
|
16
|
+
'================================================',
|
|
17
|
+
` Local path: ${targetDir}`,
|
|
18
|
+
` Repo: ${repoUrl || 'TBD'}`,
|
|
19
|
+
` Deploy: ${deploy}`,
|
|
20
|
+
];
|
|
21
|
+
if (protectionLine) lines.push(` Protection: ${protectionLine}`);
|
|
22
|
+
lines.push(
|
|
23
|
+
` Profile: ${profile || 'goodshin'}`,
|
|
24
|
+
'================================================',
|
|
25
|
+
` Next: cd ${projectName} && claude`,
|
|
26
|
+
'================================================',
|
|
27
|
+
''
|
|
28
|
+
);
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function printSummary(opts) {
|
|
33
|
+
process.stdout.write(renderSummary(opts));
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template registry: every file copied into a scaffolded repo.
|
|
3
|
+
*
|
|
4
|
+
* Source paths are relative to the package root (`templates/...`).
|
|
5
|
+
* Destination paths are relative to the user's target directory.
|
|
6
|
+
*
|
|
7
|
+
* Placeholder vocabulary (any `{{TOKEN}}` not in buildTokenMap() becomes `TBD`):
|
|
8
|
+
* PROJECT_NAME, REPO_NAME, PROJECT_PURPOSE_ONE_SENTENCE, PURPOSE,
|
|
9
|
+
* WHAT_WE_ARE_BUILDING_OR_FIXING, WHAT_LIVES_HERE, OWNER_OR_CLIENT,
|
|
10
|
+
* FRAMEWORK, LANGUAGE, LIVE_URL_OR_TBD, DEPLOY_TARGET
|
|
11
|
+
*
|
|
12
|
+
* The rest of the kit's tokens (BUILD_CMD, ENV_VAR_*, ACTIVE_TASK_*, etc.)
|
|
13
|
+
* are intentionally left as TBD here; /bootstrap inside Claude Code fills
|
|
14
|
+
* them by scanning the target repo.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const REGISTRY = [
|
|
18
|
+
{ src: '.claude/commands/bootstrap.md', dest: '.claude/commands/bootstrap.md' },
|
|
19
|
+
{ src: '.claude/commands/prompt-start.md', dest: '.claude/commands/prompt-start.md' },
|
|
20
|
+
{ src: '.claude/commands/prompt-mid.md', dest: '.claude/commands/prompt-mid.md' },
|
|
21
|
+
{ src: '.claude/commands/prompt-end.md', dest: '.claude/commands/prompt-end.md' },
|
|
22
|
+
{ src: '.claude/commands/create-prd.md', dest: '.claude/commands/create-prd.md' },
|
|
23
|
+
{ src: '.claude/commands/generate-tasks.md', dest: '.claude/commands/generate-tasks.md' },
|
|
24
|
+
{ src: 'CLAUDE.md', dest: 'CLAUDE.md' },
|
|
25
|
+
{ src: 'AGENTS.md', dest: 'AGENTS.md' },
|
|
26
|
+
{ src: 'HANDOFF.md', dest: 'HANDOFF.md' },
|
|
27
|
+
{ src: 'docs/PROJECT_STATE.md', dest: 'docs/PROJECT_STATE.md' },
|
|
28
|
+
{ src: 'docs/MANUAL_TASKS.md', dest: 'docs/MANUAL_TASKS.md' },
|
|
29
|
+
{ src: 'docs/SESSION_NOTES.md', dest: 'docs/SESSION_NOTES.md' },
|
|
30
|
+
{ src: 'docs/ARCHITECTURE.md', dest: 'docs/ARCHITECTURE.md' },
|
|
31
|
+
{ src: 'tasks/.gitkeep', dest: 'tasks/.gitkeep' },
|
|
32
|
+
{ src: 'inputs/README.md', dest: 'inputs/README.md' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const OPTIONAL_REGISTRY = [
|
|
36
|
+
{ src: 'README.md', dest: 'README.md', onlyIfMissing: true },
|
|
37
|
+
// Stored as `_gitignore` in the package because npm strips top-level
|
|
38
|
+
// `.gitignore` from published tarballs. Renamed back on copy.
|
|
39
|
+
{ src: '_gitignore', dest: '.gitignore', onlyIfMissing: true },
|
|
40
|
+
{ src: '.ostup-config.yml.example', dest: '.ostup-config.yml.example', onlyIfMissing: true },
|
|
41
|
+
];
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ostup update`: refresh bundled templates from a pinned public GitHub release.
|
|
3
|
+
*
|
|
4
|
+
* The templates mirror repo is not wired up yet. Until TEMPLATES_SOURCE_URL
|
|
5
|
+
* below is set to a real release URL, `ostup update` fails fast with a clear
|
|
6
|
+
* message instead of making a network call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TEMPLATES_SOURCE_URL = 'TODO_TEMPLATES_REPO_NOT_YET_CREATED';
|
|
10
|
+
|
|
11
|
+
export async function update() {
|
|
12
|
+
if (TEMPLATES_SOURCE_URL.startsWith('TODO_')) {
|
|
13
|
+
const err = new Error(
|
|
14
|
+
'`ostup update` is not available yet in this release. To upgrade, reinstall the tool: npm install -g ostup@latest'
|
|
15
|
+
);
|
|
16
|
+
err.code = 'UPDATE_NOT_CONFIGURED';
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Implementation deferred to v0.2:
|
|
21
|
+
// 1. fetch ${TEMPLATES_SOURCE_URL}/releases/latest tarball
|
|
22
|
+
// 2. extract into a sibling `templates.new/` dir
|
|
23
|
+
// 3. atomic swap: templates -> templates.bak, templates.new -> templates
|
|
24
|
+
// 4. clean up templates.bak on success
|
|
25
|
+
throw new Error('update() implementation pending');
|
|
26
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: First-run on a new repo. Scan, ask up to 5 clarifiers if needed, fill all templates including HANDOFF.md.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Bootstrap this project
|
|
6
|
+
|
|
7
|
+
You are bootstrapping a new project. Job: populate the doc templates with real, project-specific content based on **evidence in this repo**. Do NOT invent facts. If you cannot determine something, ask a clarifier or write `TBD`.
|
|
8
|
+
|
|
9
|
+
## Step 1: Scan the repo (no questions yet)
|
|
10
|
+
|
|
11
|
+
Run these and absorb output:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
ls -la
|
|
15
|
+
find . -maxdepth 3 -type f \( -name "package.json" -o -name "pyproject.toml" -o -name "Cargo.toml" -o -name "go.mod" -o -name "Gemfile" -o -name "composer.json" -o -name "requirements.txt" -o -name "next.config.*" -o -name "supabase" -o -name "vercel.json" \) 2>/dev/null
|
|
16
|
+
[ -f README.md ] && cat README.md | head -100
|
|
17
|
+
[ -f package.json ] && cat package.json
|
|
18
|
+
[ -f .env.example ] && cat .env.example
|
|
19
|
+
[ -d .git ] && git remote -v && git log --oneline -5 2>/dev/null
|
|
20
|
+
find . -maxdepth 2 -type d ! -path '*/node_modules*' ! -path '*/.git*' ! -path '*/.next*' ! -path '*/dist*' ! -path '*/build*'
|
|
21
|
+
# Operator materials (if any) live in inputs/. Read the manifest and README first.
|
|
22
|
+
[ -f inputs/INGEST_MANIFEST.md ] && cat inputs/INGEST_MANIFEST.md
|
|
23
|
+
[ -f inputs/README.md ] && cat inputs/README.md
|
|
24
|
+
[ -d inputs ] && ls -la inputs/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Step 2: Determine what you know
|
|
28
|
+
|
|
29
|
+
Classify each as KNOWN (from scan) or UNKNOWN:
|
|
30
|
+
|
|
31
|
+
1. **Project name**: from package.json, README, or git remote
|
|
32
|
+
2. **Project purpose**: what this codebase does (one sentence)
|
|
33
|
+
3. **Tech stack**: language, framework, database, hosting, package manager
|
|
34
|
+
4. **Live URL / deploy target**: production URL if any
|
|
35
|
+
5. **Owner / client name**: who this is for
|
|
36
|
+
|
|
37
|
+
## Step 3: Ask clarifiers (only if needed)
|
|
38
|
+
|
|
39
|
+
If 1+ items are UNKNOWN, ask the operator up to **5 clarifiers** in a single numbered list with example answers:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Need a few facts before I fill the docs:
|
|
43
|
+
|
|
44
|
+
1. Project name and one-sentence purpose? (e.g. "OwlNest: boutique ski lodge booking site")
|
|
45
|
+
2. Owner or client? (e.g. "Sea Star SF" or "personal use")
|
|
46
|
+
3. Stack confirmed? (e.g. "Next.js 14 + Supabase + Vercel" or "different: please specify")
|
|
47
|
+
4. Live URL or deploy target? (e.g. "owlnest.jp" or "TBD, deploying to Vercel")
|
|
48
|
+
5. Any project-specific conventions to bake in? (e.g. "uses pnpm not npm", "deploy via `npm run ship`", "API keys in 1Password")
|
|
49
|
+
|
|
50
|
+
Reply with answers and I'll fill all docs.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Skip questions you already answered from scan. Never ask more than 5. If everything was KNOWN, skip Step 3.
|
|
54
|
+
|
|
55
|
+
## Step 4: Fill the templates
|
|
56
|
+
|
|
57
|
+
Edit each file. Replace every `{{PLACEHOLDER}}` with real content.
|
|
58
|
+
|
|
59
|
+
Files to update:
|
|
60
|
+
|
|
61
|
+
- **`CLAUDE.md`**: fill Parts 13, 14, 15, 16, 17 only. Do NOT modify Parts 1 through 12 (universal rules).
|
|
62
|
+
- **`HANDOFF.md`**: first entry: status = "Bootstrapped. No work started yet." Next actions = "the operator to brief on first feature/fix to tackle."
|
|
63
|
+
- **`docs/PROJECT_STATE.md`**: fill phase, priorities, key paths.
|
|
64
|
+
- **`docs/MANUAL_TASKS.md`**: leave Active section empty for now.
|
|
65
|
+
- **`docs/SESSION_NOTES.md`**: first entry: "Bootstrap session: repo scaffolded with Ostup Agent Kit."
|
|
66
|
+
- **`docs/ARCHITECTURE.md`**: fill stack table, file map, env vars (names only), deploy details.
|
|
67
|
+
|
|
68
|
+
**Rules for filling:**
|
|
69
|
+
|
|
70
|
+
1. Pull facts from repo scan FIRST.
|
|
71
|
+
2. Pull facts from the operator's clarifier answers SECOND.
|
|
72
|
+
3. For anything still unknown, write `TBD` not a guess.
|
|
73
|
+
4. Keep `CLAUDE.md` Parts 13 through 17 tight. Move detail to `docs/ARCHITECTURE.md`.
|
|
74
|
+
|
|
75
|
+
## Step 5: Verify each file
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
for f in CLAUDE.md HANDOFF.md docs/PROJECT_STATE.md docs/MANUAL_TASKS.md docs/SESSION_NOTES.md docs/ARCHITECTURE.md; do
|
|
79
|
+
echo "=== $f ==="
|
|
80
|
+
wc -l "$f"
|
|
81
|
+
grep -c "{{" "$f" 2>/dev/null || echo "0 placeholders left"
|
|
82
|
+
done
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Any file with `{{` remaining means you missed a placeholder. Fix it. The only exception is the COMMENT block in `docs/SESSION_NOTES.md` template formatting: those are HTML comments, not placeholders.
|
|
86
|
+
|
|
87
|
+
## Step 6: Report back
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
BOOTSTRAP COMPLETE
|
|
91
|
+
|
|
92
|
+
Files filled:
|
|
93
|
+
- CLAUDE.md (X lines, 0 placeholders left)
|
|
94
|
+
- HANDOFF.md (X lines, 0 placeholders left)
|
|
95
|
+
- docs/PROJECT_STATE.md (X lines, 0 placeholders left)
|
|
96
|
+
- docs/MANUAL_TASKS.md (X lines, 0 placeholders left)
|
|
97
|
+
- docs/SESSION_NOTES.md (X lines, first entry written)
|
|
98
|
+
- docs/ARCHITECTURE.md (X lines, 0 placeholders left)
|
|
99
|
+
|
|
100
|
+
Project: <name>
|
|
101
|
+
Stack: <stack>
|
|
102
|
+
Owner: <owner>
|
|
103
|
+
Deploy: <url or target>
|
|
104
|
+
|
|
105
|
+
TBDs that need operator attention:
|
|
106
|
+
- <list any TBD fields, or "none">
|
|
107
|
+
|
|
108
|
+
Next: run /prompt-start to begin a working session.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Stop. Do not start working on features yet.
|