@oaklandzoo/ostup 0.10.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 +20 -0
- package/bin/cli.mjs +40 -2
- package/package.json +1 -1
- package/scripts/verify-auth.sh +30 -0
- package/src/auth-clerk.mjs +146 -0
- package/src/auth-google.mjs +137 -0
- package/src/credential-prompts-npm.mjs +154 -0
- package/src/credential-prompts.mjs +5 -0
- package/src/doctor-privacy.mjs +103 -0
- package/src/doctor.mjs +7 -1
- package/src/mvp-flow.mjs +27 -0
- package/src/private-cmd.mjs +35 -0
- package/src/private.mjs +215 -0
- package/templates/.claude/commands/add-auth.md +87 -0
- package/templates/.claude/commands/add-storage.md +31 -1
- package/templates/.claude/commands/publish.md +77 -0
- package/templates/auth-clerk/env.example.additions +10 -0
- package/templates/auth-clerk/layout.tsx +25 -0
- package/templates/auth-clerk/middleware.ts +20 -0
- package/templates/auth-clerk/package.json.additions +5 -0
- package/templates/auth-clerk/sign-in-page.tsx +9 -0
- package/templates/auth-clerk/sign-up-page.tsx +9 -0
- package/templates/auth-google/auth.ts +12 -0
- package/templates/auth-google/env.example.additions +9 -0
- package/templates/auth-google/package.json.additions +5 -0
- package/templates/auth-google/route.ts +2 -0
- package/templates/auth-google/sign-in-page.tsx +32 -0
- package/templates/auth-google/sign-up-page.tsx +32 -0
- package/templates/private/CLAUDE_PART_20.md +30 -0
- package/templates/private/api-audit-health.ts +9 -0
- package/templates/private/api-blob-proxy.ts +40 -0
- package/templates/private/lib-audit.ts +18 -0
- package/templates/private/lib-rate-limit-kv.ts +21 -0
- package/templates/private/lib-rate-limit-memory.ts +25 -0
- package/templates/private/middleware.ts +67 -0
- package/templates/private/next.config.ts +14 -0
package/README.md
CHANGED
|
@@ -24,6 +24,26 @@ When you run this tool, it will:
|
|
|
24
24
|
(Better Auth + guarded dashboard + settings), blog (MDX posts + RSS
|
|
25
25
|
+ sitemap). Your first deploy shows a working homepage and day-one
|
|
26
26
|
flow, not a blank Next.js welcome.
|
|
27
|
+
8. Optionally pass `--private` to ship an **app-level default-deny**
|
|
28
|
+
project on Vercel Hobby: middleware blocks every request except
|
|
29
|
+
sign-in/up + audit-health, blob helpers default to `access: 'private'`,
|
|
30
|
+
image optimizer cannot proxy external URLs, rate limiting and audit
|
|
31
|
+
logging are wired. Run `ostup doctor --privacy <url>` after deploy to
|
|
32
|
+
verify. Toggle on/off an existing project with `ostup private add` /
|
|
33
|
+
`ostup private remove` (manifest at `.ostup/private.json` makes the
|
|
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.
|
|
27
47
|
|
|
28
48
|
## Quick Start
|
|
29
49
|
|
package/bin/cli.mjs
CHANGED
|
@@ -11,7 +11,7 @@ loadDotEnv();
|
|
|
11
11
|
|
|
12
12
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
13
|
|
|
14
|
-
const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap']);
|
|
14
|
+
const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap', 'private']);
|
|
15
15
|
|
|
16
16
|
async function readPkg() {
|
|
17
17
|
const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
|
|
@@ -45,6 +45,13 @@ 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;
|
|
51
|
+
else if (a === '--private') flags.private = true;
|
|
52
|
+
else if (a === '--privacy') flags.privacy = true;
|
|
53
|
+
else if (a === '--url') flags.url = argv[++i];
|
|
54
|
+
else if (a.startsWith('--url=')) flags.url = a.slice('--url='.length);
|
|
48
55
|
else if (a === '--no-log') flags.noLog = true;
|
|
49
56
|
else if (a === '--skip-bootstrap') flags.skipBootstrap = true;
|
|
50
57
|
else if (a === '--no-install') flags.noInstall = true;
|
|
@@ -78,6 +85,8 @@ function printHelp() {
|
|
|
78
85
|
' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
|
|
79
86
|
' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
|
|
80
87
|
' doctor Self-diagnosis: tool detection + auth + permissions + disk + Chrome. Read-only.',
|
|
88
|
+
' doctor --privacy Probe a deployed URL for default-deny enforcement (privacy mode).',
|
|
89
|
+
' private add|remove|status Apply, undo, or report on app-level privacy (default-deny middleware).',
|
|
81
90
|
' update Refresh bundled templates from the pinned source.',
|
|
82
91
|
'',
|
|
83
92
|
'Flags for `ostup init`:',
|
|
@@ -89,6 +98,9 @@ function printHelp() {
|
|
|
89
98
|
' --ingest <path> Copy operator materials from <path> into inputs/.',
|
|
90
99
|
' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
|
|
91
100
|
' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
|
|
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).',
|
|
92
104
|
' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
|
|
93
105
|
' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
|
|
94
106
|
' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
|
|
@@ -193,7 +205,7 @@ if (subcommand === 'export-pro') {
|
|
|
193
205
|
if (subcommand === 'doctor') {
|
|
194
206
|
const { runDoctor, printDoctorReport } = await import('../src/doctor.mjs');
|
|
195
207
|
try {
|
|
196
|
-
const result = await runDoctor();
|
|
208
|
+
const result = await runDoctor({ flags });
|
|
197
209
|
process.stdout.write(printDoctorReport(result));
|
|
198
210
|
process.exit(result.failCount > 0 ? 1 : 0);
|
|
199
211
|
} catch (err) {
|
|
@@ -202,6 +214,25 @@ if (subcommand === 'doctor') {
|
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
if (subcommand === 'private') {
|
|
218
|
+
const action = subPositional[0] || 'status';
|
|
219
|
+
const { runPrivate } = await import('../src/private-cmd.mjs');
|
|
220
|
+
try {
|
|
221
|
+
await runPrivate({ action, flags, cwd: process.cwd() });
|
|
222
|
+
process.exit(0);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
process.stderr.write(`${err.message}\n`);
|
|
225
|
+
const userErrors = new Set([
|
|
226
|
+
'PRIVATE_SAAS_CONFLICT',
|
|
227
|
+
'PRIVATE_NOT_APPLIED',
|
|
228
|
+
'PRIVATE_ALREADY_APPLIED',
|
|
229
|
+
'PRIVATE_DIRTY',
|
|
230
|
+
'PRIVATE_UNKNOWN_ACTION',
|
|
231
|
+
]);
|
|
232
|
+
process.exit(userErrors.has(err.code) ? 1 : 2);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
205
236
|
if (subcommand === 'bootstrap') {
|
|
206
237
|
const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
|
|
207
238
|
try {
|
|
@@ -257,6 +288,13 @@ try {
|
|
|
257
288
|
'INGEST_PATH_NOT_FOUND',
|
|
258
289
|
'BRIEF_NOT_FOUND',
|
|
259
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',
|
|
260
298
|
'NO_TTY_BOOTSTRAP',
|
|
261
299
|
'BOOTSTRAP_DECLINED',
|
|
262
300
|
'BOOTSTRAP_FAILED',
|
package/package.json
CHANGED
|
@@ -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
|
}
|