@oaklandzoo/ostup 0.11.0 → 0.12.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 CHANGED
@@ -32,6 +32,18 @@ When you run this tool, it will:
32
32
  verify. Toggle on/off an existing project with `ostup private add` /
33
33
  `ostup private remove` (manifest at `.ostup/private.json` makes the
34
34
  remove cleanly revertable).
35
+ 9. Optionally pass `--auth=clerk` or `--auth=google` to wire **a
36
+ working login flow** into the scaffold. Clerk uses `@clerk/nextjs`
37
+ (hosted UI, free up to 10k MAU); Google uses NextAuth v5 with the
38
+ Google provider (free; you create the OAuth app on Google Cloud).
39
+ Both ship sign-in / sign-up pages out of the box. `--auth` composes
40
+ cleanly with `--private` (the default-deny middleware recognizes
41
+ both providers' session cookies).
42
+ 10. Optionally pass `--publish-ready` if this CLI will be used to
43
+ publish an npm package later. ostup walks you through getting an
44
+ npm Bypass-2FA token (browser flow or guided token paste) and
45
+ saves it to `~/.npmrc`. After that, the `/publish` slash command
46
+ lets your agent ship a new version with one command.
35
47
 
36
48
  ## Quick Start
37
49
 
package/bin/cli.mjs CHANGED
@@ -45,6 +45,9 @@ function parseArgs(argv) {
45
45
  else if (a === '--output') flags.output = argv[++i];
46
46
  else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
47
47
  else if (a === '--white-label') flags.whiteLabel = true;
48
+ else if (a === '--auth') flags.auth = argv[++i];
49
+ else if (a.startsWith('--auth=')) flags.auth = a.slice('--auth='.length);
50
+ else if (a === '--publish-ready') flags.publishReady = true;
48
51
  else if (a === '--private') flags.private = true;
49
52
  else if (a === '--privacy') flags.privacy = true;
50
53
  else if (a === '--url') flags.url = argv[++i];
@@ -96,6 +99,8 @@ function printHelp() {
96
99
  ' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
97
100
  ' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
98
101
  ' --private App-level default-deny: middleware, blob proxy, image-optimizer lock, audit + rate-limit, CLAUDE.md Part 20. Refused with --profile saas-dashboard.',
102
+ ' --auth <provider> Wire authentication. Values: clerk (Clerk + @clerk/nextjs), google (NextAuth v5 + Google), none. Refused with --profile saas-dashboard.',
103
+ ' --publish-ready Walk through npm token setup so this CLI can publish later (writes ~/.npmrc).',
99
104
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
100
105
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
101
106
  ' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
@@ -283,6 +288,13 @@ try {
283
288
  'INGEST_PATH_NOT_FOUND',
284
289
  'BRIEF_NOT_FOUND',
285
290
  'BRIEF_INVALID',
291
+ 'AUTH_ALREADY_APPLIED',
292
+ 'AUTH_PROFILE_CONFLICT',
293
+ 'AUTH_DIRTY',
294
+ 'AUTH_UNKNOWN_PROVIDER',
295
+ 'PRIVATE_SAAS_CONFLICT',
296
+ 'PRIVATE_ALREADY_APPLIED',
297
+ 'PRIVATE_DIRTY',
286
298
  'NO_TTY_BOOTSTRAP',
287
299
  'BOOTSTRAP_DECLINED',
288
300
  'BOOTSTRAP_FAILED',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.11.0",
3
+ "version": "0.12.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,30 @@
1
+ #!/usr/bin/env bash
2
+ # verify-auth.sh: apply an auth overlay to a fresh temp dir and assert files + manifest land.
3
+ #
4
+ # Usage: bash scripts/verify-auth.sh <clerk|google>
5
+
6
+ set -euo pipefail
7
+
8
+ PROVIDER="${1:-}"
9
+ case "$PROVIDER" in
10
+ clerk|google) ;;
11
+ *)
12
+ echo "usage: $0 <clerk|google>" >&2
13
+ exit 2
14
+ ;;
15
+ esac
16
+
17
+ OSTUP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
18
+ TEST_FILE="$OSTUP_ROOT/test/auth-$PROVIDER.test.mjs"
19
+
20
+ echo ">> verifying auth provider: $PROVIDER"
21
+ echo ">> running: node --test $TEST_FILE"
22
+ cd "$OSTUP_ROOT"
23
+ node --test "$TEST_FILE"
24
+
25
+ echo ">> ok: $PROVIDER overlay applies and asserts pass"
26
+ echo ""
27
+ echo ">> for live verification (operator only):"
28
+ echo " 1. ostup init --yes --name verify-auth --brief test/fixtures/brief-leadgen.json --auth=$PROVIDER"
29
+ echo " 2. cd verify-auth && npm install && npx next build"
30
+ echo " 3. open the deployed URL, sign in, verify session lands on /dashboard"
@@ -0,0 +1,146 @@
1
+ // auth-clerk.mjs: applyAuthClerk post-processor. Drops Clerk integration into a scaffold.
2
+
3
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates', 'auth-clerk');
10
+
11
+ export const AUTH_MANIFEST_PATH = '.ostup/auth.json';
12
+
13
+ class AuthError extends Error {
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ function plannedOps(targetDir) {
21
+ return [
22
+ { kind: 'create-or-overwrite', dest: 'middleware.ts', src: 'middleware.ts' },
23
+ { kind: 'create-or-overwrite', dest: 'app/layout.tsx', src: 'layout.tsx' },
24
+ { kind: 'create', dest: 'app/sign-in/[[...sign-in]]/page.tsx', src: 'sign-in-page.tsx' },
25
+ { kind: 'create', dest: 'app/sign-up/[[...sign-up]]/page.tsx', src: 'sign-up-page.tsx' },
26
+ { kind: 'package-merge', dest: 'package.json', src: 'package.json.additions' },
27
+ { kind: 'env-append', dest: '.env.example', src: 'env.example.additions' },
28
+ ];
29
+ }
30
+
31
+ async function loadTemplate(rel) {
32
+ return readFile(join(TEMPLATES_ROOT, rel), 'utf8');
33
+ }
34
+
35
+ function tokenSubstitute(body, tokens) {
36
+ if (!tokens) return body;
37
+ return body.replace(/\{\{([A-Z_][A-Z_0-9]*)\}\}/g, (m, k) => (tokens[k] != null ? String(tokens[k]) : 'TBD'));
38
+ }
39
+
40
+ function mergePackageJson(existing, additionsText) {
41
+ const additions = JSON.parse(additionsText);
42
+ const target = existing ? JSON.parse(existing) : {};
43
+ for (const section of ['dependencies', 'devDependencies', 'scripts']) {
44
+ if (additions[section]) {
45
+ target[section] = { ...(target[section] || {}), ...additions[section] };
46
+ }
47
+ }
48
+ return JSON.stringify(target, null, 2) + '\n';
49
+ }
50
+
51
+ export async function applyAuthClerk({ targetDir, profile = null, tokens = {}, force = false } = {}) {
52
+ const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
53
+ if (existsSync(manifestPath) && !force) {
54
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
55
+ throw new AuthError(
56
+ 'AUTH_ALREADY_APPLIED',
57
+ `${AUTH_MANIFEST_PATH} already exists (provider: ${manifest.provider}). Run remove first, or pass --force.`,
58
+ );
59
+ }
60
+ if (profile === 'saas-dashboard') {
61
+ throw new AuthError(
62
+ 'AUTH_PROFILE_CONFLICT',
63
+ '--auth=clerk is not compatible with --profile saas-dashboard. saas-dashboard ships Better Auth. Pick one.',
64
+ );
65
+ }
66
+
67
+ const ops = plannedOps(targetDir);
68
+ const performed = [];
69
+
70
+ for (const op of ops) {
71
+ const destPath = join(targetDir, op.dest);
72
+ const tmpl = await loadTemplate(op.src);
73
+ const content = tokenSubstitute(tmpl, tokens);
74
+
75
+ if (op.kind === 'create') {
76
+ if (existsSync(destPath) && !force) {
77
+ throw new AuthError('AUTH_DIRTY', `${op.dest} already exists; refuse to overwrite without --force.`);
78
+ }
79
+ await mkdir(dirname(destPath), { recursive: true });
80
+ await writeFile(destPath, content, 'utf8');
81
+ performed.push({ kind: 'created', path: op.dest });
82
+ } else if (op.kind === 'create-or-overwrite') {
83
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
84
+ await mkdir(dirname(destPath), { recursive: true });
85
+ await writeFile(destPath, content, 'utf8');
86
+ if (prior !== null) {
87
+ performed.push({ kind: 'replaced', path: op.dest, prior });
88
+ } else {
89
+ performed.push({ kind: 'created', path: op.dest });
90
+ }
91
+ } else if (op.kind === 'package-merge') {
92
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
93
+ const merged = mergePackageJson(prior, content);
94
+ await mkdir(dirname(destPath), { recursive: true });
95
+ await writeFile(destPath, merged, 'utf8');
96
+ if (prior !== null) {
97
+ performed.push({ kind: 'patched', path: op.dest, prior });
98
+ } else {
99
+ performed.push({ kind: 'created', path: op.dest });
100
+ }
101
+ } else if (op.kind === 'env-append') {
102
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
103
+ const next = prior ? prior + (prior.endsWith('\n') ? '' : '\n') + '\n' + content : content;
104
+ await mkdir(dirname(destPath), { recursive: true });
105
+ await writeFile(destPath, next, 'utf8');
106
+ if (prior !== null) {
107
+ performed.push({ kind: 'patched', path: op.dest, prior });
108
+ } else {
109
+ performed.push({ kind: 'created', path: op.dest });
110
+ }
111
+ }
112
+ }
113
+
114
+ await mkdir(dirname(manifestPath), { recursive: true });
115
+ await writeFile(
116
+ manifestPath,
117
+ JSON.stringify({ appliedAt: new Date().toISOString(), provider: 'clerk', ops: performed }, null, 2) + '\n',
118
+ 'utf8',
119
+ );
120
+
121
+ return { applied: true, provider: 'clerk', touched: performed.map((p) => p.path) };
122
+ }
123
+
124
+ export async function removeAuthClerk({ targetDir } = {}) {
125
+ const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
126
+ if (!existsSync(manifestPath)) {
127
+ throw new AuthError('AUTH_NOT_APPLIED', `${AUTH_MANIFEST_PATH} not found.`);
128
+ }
129
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
130
+ const restored = [];
131
+ for (const op of [...manifest.ops].reverse()) {
132
+ const p = join(targetDir, op.path);
133
+ if (op.kind === 'created' && existsSync(p)) {
134
+ await rm(p, { force: true });
135
+ restored.push({ kind: 'deleted', path: op.path });
136
+ } else if ((op.kind === 'replaced' || op.kind === 'patched') && existsSync(p)) {
137
+ await writeFile(p, op.prior, 'utf8');
138
+ restored.push({ kind: 'restored', path: op.path });
139
+ }
140
+ }
141
+ await rm(manifestPath, { force: true });
142
+ try { await rm(join(targetDir, '.ostup'), { recursive: false }); } catch {}
143
+ return { restored: restored.map((r) => r.path), provider: manifest.provider };
144
+ }
145
+
146
+ export { AuthError };
@@ -0,0 +1,137 @@
1
+ // auth-google.mjs: applyAuthGoogle post-processor. Drops NextAuth v5 + Google provider into a scaffold.
2
+
3
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates', 'auth-google');
10
+
11
+ export const AUTH_MANIFEST_PATH = '.ostup/auth.json';
12
+
13
+ class AuthError extends Error {
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ function plannedOps() {
21
+ return [
22
+ { kind: 'create', dest: 'auth.ts', src: 'auth.ts' },
23
+ { kind: 'create', dest: 'app/api/auth/[...nextauth]/route.ts', src: 'route.ts' },
24
+ { kind: 'create', dest: 'app/sign-in/page.tsx', src: 'sign-in-page.tsx' },
25
+ { kind: 'create', dest: 'app/sign-up/page.tsx', src: 'sign-up-page.tsx' },
26
+ { kind: 'package-merge', dest: 'package.json', src: 'package.json.additions' },
27
+ { kind: 'env-append', dest: '.env.example', src: 'env.example.additions' },
28
+ ];
29
+ }
30
+
31
+ async function loadTemplate(rel) {
32
+ return readFile(join(TEMPLATES_ROOT, rel), 'utf8');
33
+ }
34
+
35
+ function tokenSubstitute(body, tokens) {
36
+ if (!tokens) return body;
37
+ return body.replace(/\{\{([A-Z_][A-Z_0-9]*)\}\}/g, (m, k) => (tokens[k] != null ? String(tokens[k]) : 'TBD'));
38
+ }
39
+
40
+ function mergePackageJson(existing, additionsText) {
41
+ const additions = JSON.parse(additionsText);
42
+ const target = existing ? JSON.parse(existing) : {};
43
+ for (const section of ['dependencies', 'devDependencies', 'scripts']) {
44
+ if (additions[section]) {
45
+ target[section] = { ...(target[section] || {}), ...additions[section] };
46
+ }
47
+ }
48
+ return JSON.stringify(target, null, 2) + '\n';
49
+ }
50
+
51
+ export async function applyAuthGoogle({ targetDir, profile = null, tokens = {}, force = false } = {}) {
52
+ const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
53
+ if (existsSync(manifestPath) && !force) {
54
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
55
+ throw new AuthError(
56
+ 'AUTH_ALREADY_APPLIED',
57
+ `${AUTH_MANIFEST_PATH} already exists (provider: ${manifest.provider}). Run remove first, or pass --force.`,
58
+ );
59
+ }
60
+ if (profile === 'saas-dashboard') {
61
+ throw new AuthError(
62
+ 'AUTH_PROFILE_CONFLICT',
63
+ '--auth=google is not compatible with --profile saas-dashboard. saas-dashboard ships Better Auth. Pick one.',
64
+ );
65
+ }
66
+
67
+ const ops = plannedOps();
68
+ const performed = [];
69
+
70
+ for (const op of ops) {
71
+ const destPath = join(targetDir, op.dest);
72
+ const tmpl = await loadTemplate(op.src);
73
+ const content = tokenSubstitute(tmpl, tokens);
74
+
75
+ if (op.kind === 'create') {
76
+ if (existsSync(destPath) && !force) {
77
+ throw new AuthError('AUTH_DIRTY', `${op.dest} already exists; refuse to overwrite without --force.`);
78
+ }
79
+ await mkdir(dirname(destPath), { recursive: true });
80
+ await writeFile(destPath, content, 'utf8');
81
+ performed.push({ kind: 'created', path: op.dest });
82
+ } else if (op.kind === 'package-merge') {
83
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
84
+ const merged = mergePackageJson(prior, content);
85
+ await mkdir(dirname(destPath), { recursive: true });
86
+ await writeFile(destPath, merged, 'utf8');
87
+ if (prior !== null) {
88
+ performed.push({ kind: 'patched', path: op.dest, prior });
89
+ } else {
90
+ performed.push({ kind: 'created', path: op.dest });
91
+ }
92
+ } else if (op.kind === 'env-append') {
93
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
94
+ const next = prior ? prior + (prior.endsWith('\n') ? '' : '\n') + '\n' + content : content;
95
+ await mkdir(dirname(destPath), { recursive: true });
96
+ await writeFile(destPath, next, 'utf8');
97
+ if (prior !== null) {
98
+ performed.push({ kind: 'patched', path: op.dest, prior });
99
+ } else {
100
+ performed.push({ kind: 'created', path: op.dest });
101
+ }
102
+ }
103
+ }
104
+
105
+ await mkdir(dirname(manifestPath), { recursive: true });
106
+ await writeFile(
107
+ manifestPath,
108
+ JSON.stringify({ appliedAt: new Date().toISOString(), provider: 'google', ops: performed }, null, 2) + '\n',
109
+ 'utf8',
110
+ );
111
+
112
+ return { applied: true, provider: 'google', touched: performed.map((p) => p.path) };
113
+ }
114
+
115
+ export async function removeAuthGoogle({ targetDir } = {}) {
116
+ const manifestPath = join(targetDir, AUTH_MANIFEST_PATH);
117
+ if (!existsSync(manifestPath)) {
118
+ throw new AuthError('AUTH_NOT_APPLIED', `${AUTH_MANIFEST_PATH} not found.`);
119
+ }
120
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
121
+ const restored = [];
122
+ for (const op of [...manifest.ops].reverse()) {
123
+ const p = join(targetDir, op.path);
124
+ if (op.kind === 'created' && existsSync(p)) {
125
+ await rm(p, { force: true });
126
+ restored.push({ kind: 'deleted', path: op.path });
127
+ } else if (op.kind === 'patched' && existsSync(p)) {
128
+ await writeFile(p, op.prior, 'utf8');
129
+ restored.push({ kind: 'restored', path: op.path });
130
+ }
131
+ }
132
+ await rm(manifestPath, { force: true });
133
+ try { await rm(join(targetDir, '.ostup'), { recursive: false }); } catch {}
134
+ return { restored: restored.map((r) => r.path), provider: manifest.provider };
135
+ }
136
+
137
+ export { AuthError };
@@ -0,0 +1,154 @@
1
+ // credential-prompts-npm.mjs: detect npm credentials, walk operator through token-paste or browser flow.
2
+
3
+ import * as p from '@clack/prompts';
4
+ import { execSync } from 'node:child_process';
5
+ import { readFileSync, existsSync, appendFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { run as exec } from './exec.mjs';
9
+
10
+ const NPMRC_PATH = join(homedir(), '.npmrc');
11
+
12
+ export function checkNpmAuth({ env = process.env, runner = defaultCmdOk, npmrcReader = defaultNpmrcReader } = {}) {
13
+ if (env.NPM_TOKEN) return { ok: true, source: 'env' };
14
+ const npmrc = npmrcReader();
15
+ if (npmrc && /_authToken=/.test(npmrc)) return { ok: true, source: 'npmrc' };
16
+ if (runner('npm whoami')) return { ok: true, source: 'npm-cli' };
17
+ return { ok: false };
18
+ }
19
+
20
+ function defaultCmdOk(cmd) {
21
+ try {
22
+ execSync(cmd, { stdio: ['ignore', 'ignore', 'ignore'] });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function defaultNpmrcReader() {
30
+ try {
31
+ return existsSync(NPMRC_PATH) ? readFileSync(NPMRC_PATH, 'utf8') : '';
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ const NPM_BLURB = [
38
+ '',
39
+ 'npm credentials not found.',
40
+ '',
41
+ 'Why this matters: if you ever want to publish a package from this machine,',
42
+ 'you need a token. Skip this step if you are only building apps (most users).',
43
+ '',
44
+ 'If you do not have an npm account yet:',
45
+ ' 1. Open https://www.npmjs.com/signup',
46
+ ' 2. Create an account, then return here.',
47
+ '',
48
+ 'To create a granular Bypass-2FA token (recommended; survives 2FA-required publishes):',
49
+ ' 1. Open https://www.npmjs.com/settings/<your-username>/tokens/new',
50
+ ' 2. Token type: Granular access token',
51
+ ' 3. Expiration: 90 days (or longer)',
52
+ ' 4. Packages and scopes: select what you plan to publish',
53
+ ' 5. Permissions: Read and Write',
54
+ ' 6. 2FA: select "Bypass 2FA"',
55
+ ' 7. Generate token, copy the value (starts with npm_)',
56
+ '',
57
+ ].join('\n');
58
+
59
+ export async function promptForNpmToken() {
60
+ process.stdout.write(NPM_BLURB);
61
+ const token = await p.password({
62
+ message: 'Paste your npm token (starts with npm_; leave blank to skip)',
63
+ });
64
+ if (p.isCancel(token)) return null;
65
+ const trimmed = typeof token === 'string' ? token.trim() : '';
66
+ if (!trimmed) return null;
67
+ return trimmed;
68
+ }
69
+
70
+ function binaryOnPath(bin) {
71
+ try {
72
+ execSync(`${bin} --version`, { stdio: ['ignore', 'ignore', 'ignore'] });
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ async function chooseNpmAuthMethod() {
80
+ const choice = await p.select({
81
+ message: 'npm: how would you like to set up publishing?',
82
+ options: [
83
+ { value: 'browser', label: 'Sign in via npm login --auth-type=web (recommended)' },
84
+ { value: 'token', label: 'Paste a Bypass-2FA token instead' },
85
+ { value: 'skip', label: 'Skip for now (no publishing from this machine)' },
86
+ ],
87
+ initialValue: 'browser',
88
+ });
89
+ if (p.isCancel(choice)) return 'skip';
90
+ return choice;
91
+ }
92
+
93
+ async function attemptBrowserAuth() {
94
+ try {
95
+ await exec('npm-login', 'npm', ['login', '--auth-type=web'], { stdio: 'inherit' });
96
+ return true;
97
+ } catch {
98
+ process.stdout.write('npm browser sign-in did not complete. Falling back to token paste.\n');
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function writeNpmrcToken(token, npmrcPath = NPMRC_PATH) {
104
+ const line = `//registry.npmjs.org/:_authToken=${token}\n`;
105
+ if (existsSync(npmrcPath)) {
106
+ const existing = readFileSync(npmrcPath, 'utf8');
107
+ if (existing.includes('//registry.npmjs.org/:_authToken=')) {
108
+ const replaced = existing.replace(/^\/\/registry\.npmjs\.org\/:_authToken=.*$/m, line.trim());
109
+ writeFileSync(npmrcPath, replaced, 'utf8');
110
+ } else {
111
+ appendFileSync(npmrcPath, (existing.endsWith('\n') ? '' : '\n') + line, 'utf8');
112
+ }
113
+ } else {
114
+ writeFileSync(npmrcPath, line, 'utf8');
115
+ }
116
+ }
117
+
118
+ export async function ensureNpmCredentials({ flags = {} } = {}) {
119
+ const result = checkNpmAuth();
120
+ if (result.ok) {
121
+ process.stdout.write(`npm: using existing auth (${result.source}).\n`);
122
+ return { ok: true, source: result.source };
123
+ }
124
+
125
+ if (!binaryOnPath('npm')) {
126
+ process.stdout.write('npm CLI not on PATH; skipping npm setup.\n');
127
+ return { ok: false, source: 'missing-npm' };
128
+ }
129
+
130
+ const method = flags.yes ? 'skip' : await chooseNpmAuthMethod();
131
+ if (method === 'skip') {
132
+ process.stdout.write('npm: setup skipped. Run `npm login` later if you need to publish.\n');
133
+ return { ok: false, source: 'skipped' };
134
+ }
135
+
136
+ if (method === 'browser') {
137
+ const ok = await attemptBrowserAuth();
138
+ if (ok && checkNpmAuth().ok) {
139
+ process.stdout.write('npm: signed in via browser.\n');
140
+ return { ok: true, source: 'browser' };
141
+ }
142
+ }
143
+
144
+ const token = await promptForNpmToken();
145
+ if (!token) {
146
+ process.stdout.write('npm: no token provided. Skipping.\n');
147
+ return { ok: false, source: 'skipped' };
148
+ }
149
+ writeNpmrcToken(token);
150
+ process.stdout.write(`npm: token saved to ${NPMRC_PATH}.\n`);
151
+ return { ok: true, source: 'npmrc-write' };
152
+ }
153
+
154
+ export { writeNpmrcToken, NPMRC_PATH };
@@ -2,6 +2,7 @@
2
2
  import * as p from '@clack/prompts';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { run as exec } from './exec.mjs';
5
+ import { ensureNpmCredentials } from './credential-prompts-npm.mjs';
5
6
 
6
7
  export function checkGithubAuth({ env = process.env, runner = defaultCmdOk } = {}) {
7
8
  if (env.GH_TOKEN) return { ok: true, source: 'env-or-dotenv' };
@@ -178,5 +179,9 @@ export async function ensureCredentials({ stack = 'next', flags = {} } = {}) {
178
179
  }
179
180
  }
180
181
 
182
+ if (flags.publishReady) {
183
+ await ensureNpmCredentials({ flags });
184
+ }
185
+
181
186
  return { collected };
182
187
  }
package/src/mvp-flow.mjs CHANGED
@@ -134,6 +134,23 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
134
134
  }
135
135
  }
136
136
 
137
+ if (flags.auth && flags.auth !== 'none') {
138
+ const profile = brief?.scaffold?.profile || null;
139
+ if (flags.auth === 'clerk') {
140
+ const { applyAuthClerk } = await import('./auth-clerk.mjs');
141
+ const r = await applyAuthClerk({ targetDir, profile, tokens });
142
+ process.stdout.write(`[auth] applied clerk overlay. ${r.touched.length} file(s) touched\n`);
143
+ } else if (flags.auth === 'google') {
144
+ const { applyAuthGoogle } = await import('./auth-google.mjs');
145
+ const r = await applyAuthGoogle({ targetDir, profile, tokens });
146
+ process.stdout.write(`[auth] applied google overlay. ${r.touched.length} file(s) touched\n`);
147
+ } else {
148
+ const err = new Error(`Unknown --auth value '${flags.auth}'. Use clerk, google, or none.`);
149
+ err.code = 'AUTH_UNKNOWN_PROVIDER';
150
+ throw err;
151
+ }
152
+ }
153
+
137
154
  if (flags.private) {
138
155
  const { applyPrivate } = await import('./private.mjs');
139
156
  const profile = brief?.scaffold?.profile || null;
@@ -0,0 +1,87 @@
1
+ ---
2
+ description: Add authentication to this project after the fact. Clerk or Google OAuth (NextAuth v5).
3
+ ---
4
+
5
+ # Add auth
6
+
7
+ Use this when the project was scaffolded without `--auth` and the operator now wants login.
8
+
9
+ ## Step 1: Which provider?
10
+
11
+ If the operator named one (e.g. `/add-auth clerk`), use it. Otherwise ask:
12
+
13
+ | Provider | Cost | Best for |
14
+ |---|---|---|
15
+ | `clerk` | Free tier up to 10k MAU | Production-grade hosted auth UI; faster setup |
16
+ | `google` | Free | Sign in with Google only (no custom auth); requires Google Cloud Console |
17
+
18
+ ## Step 2: Apply the overlay
19
+
20
+ Run from the project root:
21
+
22
+ ```bash
23
+ ostup private add # if you also want default-deny middleware (skip if already on)
24
+ ```
25
+
26
+ Then for Clerk:
27
+
28
+ ```bash
29
+ # Manually run the auth-clerk overlay path (or rerun ostup with --auth=clerk via update)
30
+ ```
31
+
32
+ Or scaffold the files by hand from the templates at `node_modules/@oaklandzoo/ostup/templates/auth-clerk/` (or `auth-google/`).
33
+
34
+ ## Step 3: Get the keys
35
+
36
+ ### Clerk
37
+
38
+ 1. Open https://dashboard.clerk.com/apps
39
+ 2. Click **Create application** if you do not have one. Name it the project name. Select Email + Google.
40
+ 3. Once created, go to **API Keys**. Copy:
41
+ - **Publishable key** → `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
42
+ - **Secret key** → `CLERK_SECRET_KEY`
43
+ 4. Paste both into `.env.local`.
44
+
45
+ ### Google OAuth
46
+
47
+ 1. Open https://console.cloud.google.com/apis/credentials/oauthclient
48
+ 2. **Application type**: Web application.
49
+ 3. **Authorized redirect URIs**: add `http://localhost:3000/api/auth/callback/google` AND your production URL with the same path.
50
+ 4. Click **Create**. Copy:
51
+ - **Client ID** → `GOOGLE_CLIENT_ID`
52
+ - **Client secret** → `GOOGLE_CLIENT_SECRET`
53
+ 5. Generate a NextAuth secret:
54
+ ```bash
55
+ openssl rand -base64 32
56
+ ```
57
+ Paste as `AUTH_SECRET`.
58
+ 6. Paste all three into `.env.local`.
59
+
60
+ ## Step 4: Verify
61
+
62
+ ```bash
63
+ npm run dev
64
+ ```
65
+
66
+ Open http://localhost:3000/sign-in. You should see the Clerk widget or the Google sign-in button. Sign in. You should land on `/dashboard` or `/`.
67
+
68
+ If the sign-in page renders but sign-in fails, double-check the env vars are loaded (`npm run dev` reads `.env.local` automatically).
69
+
70
+ ## Step 5: Deploy the env vars to Vercel
71
+
72
+ ```bash
73
+ vercel env add NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY production # repeat per env per key
74
+ ```
75
+
76
+ Or paste them into the Vercel dashboard under **Settings → Environment Variables**.
77
+
78
+ ## Report
79
+
80
+ ```
81
+ Added <provider> auth:
82
+ - Provider: <clerk|google>
83
+ - Sign-in: /sign-in
84
+ - Sign-up: /sign-up
85
+ - Protected: /dashboard
86
+ - Env vars: added to .env.local (also push to Vercel before deploying)
87
+ ```
@@ -0,0 +1,77 @@
1
+ ---
2
+ description: Publish a new version of this package to npm. Bumps version, runs tests, publishes, tags, pushes.
3
+ ---
4
+
5
+ # Publish to npm
6
+
7
+ Use this when the operator says "publish a patch / minor / major" or "ship a new version".
8
+
9
+ ## Preflight
10
+
11
+ - This command only makes sense if `package.json` has a `version` field and the project is an npm package (not a private app).
12
+ - Confirm `~/.npmrc` has an authToken or `npm whoami` succeeds. If neither, stop and tell the operator to run `npm login` or paste a Bypass-2FA token from `https://www.npmjs.com/settings/<username>/tokens/new`.
13
+ - Working tree must be clean (`git status --porcelain` returns empty). If dirty, refuse and ask the operator to commit or stash first.
14
+
15
+ ## Steps
16
+
17
+ ### 1. Pick the version bump
18
+
19
+ If the operator named a level (patch / minor / major), use it. Otherwise ask:
20
+
21
+ | Level | When |
22
+ |---|---|
23
+ | patch | Bug fixes, no API change |
24
+ | minor | New backwards-compatible features |
25
+ | major | Breaking changes |
26
+
27
+ ### 2. Bump the version
28
+
29
+ ```bash
30
+ npm version <patch|minor|major> --no-git-tag-version
31
+ ```
32
+
33
+ This updates `package.json` only. We tag manually at the end so the commit message + tag stay in sync.
34
+
35
+ ### 3. Run tests
36
+
37
+ ```bash
38
+ npm test
39
+ ```
40
+
41
+ If tests fail, stop. Revert the version bump (`git checkout package.json package-lock.json`) and report to the operator.
42
+
43
+ ### 4. Publish
44
+
45
+ ```bash
46
+ npm publish
47
+ ```
48
+
49
+ If npm asks for an OTP and the bypass token is missing, fail clearly and tell the operator to set up a token first.
50
+
51
+ ### 5. Commit + tag + push
52
+
53
+ ```bash
54
+ VERSION=$(node -p "require('./package.json').version")
55
+ git add package.json package-lock.json
56
+ git commit -m "chore: release v$VERSION"
57
+ git tag "v$VERSION"
58
+ git push
59
+ git push origin "v$VERSION"
60
+ ```
61
+
62
+ ### 6. Verify
63
+
64
+ ```bash
65
+ npm view <package-name> version
66
+ ```
67
+
68
+ Expect the new version. If npm's CDN hasn't propagated yet, retry after 30s.
69
+
70
+ ## Report
71
+
72
+ ```
73
+ Published <package-name>@<version>
74
+ - npm: https://www.npmjs.com/package/<package-name>
75
+ - Tag: v<version> pushed to origin
76
+ - Verify: npm view <package-name> version
77
+ ```
@@ -0,0 +1,10 @@
1
+ # --- Clerk auth additions ---
2
+ # Get keys at https://dashboard.clerk.com/apps (create an app, copy from API Keys)
3
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
4
+ CLERK_SECRET_KEY=
5
+
6
+ # Optional: where Clerk redirects after sign-in / sign-up
7
+ NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
8
+ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
9
+ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
10
+ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
@@ -0,0 +1,25 @@
1
+ import type { Metadata } from 'next';
2
+ import { ClerkProvider } from '@clerk/nextjs';
3
+ import './globals.css';
4
+
5
+ export const metadata: Metadata = {
6
+ title: '{{DISPLAY_NAME}}',
7
+ description: '{{PROJECT_PURPOSE_ONE_SENTENCE}}',
8
+ };
9
+
10
+ // Build-safe: wrap with ClerkProvider only when the publishable key is set.
11
+ // This lets `next build` succeed in CI / fresh scaffolds before the operator
12
+ // has configured the env. In production the key is present and Clerk handles auth.
13
+ const Wrapper = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
14
+ ? ClerkProvider
15
+ : ({ children }: { children: React.ReactNode }) => <>{children}</>;
16
+
17
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
18
+ return (
19
+ <Wrapper>
20
+ <html lang="en">
21
+ <body className="bg-white text-slate-900 antialiased">{children}</body>
22
+ </html>
23
+ </Wrapper>
24
+ );
25
+ }
@@ -0,0 +1,20 @@
1
+ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
2
+
3
+ const isProtectedRoute = createRouteMatcher([
4
+ '/dashboard(.*)',
5
+ '/api/account(.*)',
6
+ ]);
7
+
8
+ export default clerkMiddleware(async (auth, req) => {
9
+ if (isProtectedRoute(req)) {
10
+ await auth.protect();
11
+ }
12
+ });
13
+
14
+ export const config = {
15
+ matcher: [
16
+ // Run middleware on all non-static routes.
17
+ '/((?!_next/static|_next/image|favicon\\.ico).*)',
18
+ '/(api|trpc)(.*)',
19
+ ],
20
+ };
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "@clerk/nextjs": "^6.0.0"
4
+ }
5
+ }
@@ -0,0 +1,9 @@
1
+ import { SignIn } from '@clerk/nextjs';
2
+
3
+ export default function SignInPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <SignIn />
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,9 @@
1
+ import { SignUp } from '@clerk/nextjs';
2
+
3
+ export default function SignUpPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <SignUp />
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,12 @@
1
+ import NextAuth from 'next-auth';
2
+ import Google from 'next-auth/providers/google';
3
+
4
+ export const { handlers, signIn, signOut, auth } = NextAuth({
5
+ providers: [
6
+ Google({
7
+ clientId: process.env.GOOGLE_CLIENT_ID || '',
8
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
9
+ }),
10
+ ],
11
+ trustHost: true,
12
+ });
@@ -0,0 +1,9 @@
1
+ # --- Google OAuth (NextAuth v5) additions ---
2
+ # Create an OAuth 2.0 Client ID at:
3
+ # https://console.cloud.google.com/apis/credentials/oauthclient
4
+ # Authorized redirect URI: <your-prod-url>/api/auth/callback/google
5
+ GOOGLE_CLIENT_ID=
6
+ GOOGLE_CLIENT_SECRET=
7
+
8
+ # NextAuth secret. Generate one with: openssl rand -base64 32
9
+ AUTH_SECRET=
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "next-auth": "^5.0.0-beta.20"
4
+ }
5
+ }
@@ -0,0 +1,2 @@
1
+ import { handlers } from '@/auth';
2
+ export const { GET, POST } = handlers;
@@ -0,0 +1,32 @@
1
+ import { signIn } from '@/auth';
2
+
3
+ export default function SignInPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
7
+ <h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
8
+ <p className="mt-2 text-sm text-slate-600">Continue to {`{{DISPLAY_NAME}}`}.</p>
9
+ <form
10
+ action={async () => {
11
+ 'use server';
12
+ await signIn('google', { redirectTo: '/dashboard' });
13
+ }}
14
+ className="mt-8"
15
+ >
16
+ <button
17
+ type="submit"
18
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
19
+ >
20
+ <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
21
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
22
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
23
+ <path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
24
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
25
+ </svg>
26
+ Sign in with Google
27
+ </button>
28
+ </form>
29
+ </div>
30
+ </main>
31
+ );
32
+ }
@@ -0,0 +1,32 @@
1
+ import { signIn } from '@/auth';
2
+
3
+ export default function SignUpPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
7
+ <h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
8
+ <p className="mt-2 text-sm text-slate-600">Sign up with Google to get started.</p>
9
+ <form
10
+ action={async () => {
11
+ 'use server';
12
+ await signIn('google', { redirectTo: '/dashboard' });
13
+ }}
14
+ className="mt-8"
15
+ >
16
+ <button
17
+ type="submit"
18
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
19
+ >
20
+ <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
21
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
22
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
23
+ <path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
24
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
25
+ </svg>
26
+ Continue with Google
27
+ </button>
28
+ </form>
29
+ </div>
30
+ </main>
31
+ );
32
+ }
@@ -3,7 +3,14 @@ import type { NextRequest } from 'next/server';
3
3
  import { rateLimit } from '@/lib/rate-limit';
4
4
  import { audit } from '@/lib/audit';
5
5
 
6
- const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
6
+ const SESSION_COOKIE_NAMES = [
7
+ '__session', // Clerk
8
+ '__clerk_db_jwt', // Clerk db cookie
9
+ 'authjs.session-token', // NextAuth v5
10
+ '__Secure-authjs.session-token', // NextAuth v5 secure
11
+ 'better-auth.session_token', // Better Auth
12
+ 'session_token', // Generic fallback
13
+ ];
7
14
 
8
15
  function getIp(req: NextRequest): string {
9
16
  const xff = req.headers.get('x-forwarded-for');