@oaklandzoo/ostup 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +12 -0
  2. package/bin/cli.mjs +57 -1
  3. package/package.json +1 -1
  4. package/scripts/verify-auth.sh +30 -0
  5. package/scripts/verify-studio.sh +16 -0
  6. package/src/agency.mjs +45 -0
  7. package/src/auth-clerk.mjs +146 -0
  8. package/src/auth-google.mjs +137 -0
  9. package/src/brand-pack-cmd.mjs +76 -0
  10. package/src/brand-packs.mjs +101 -0
  11. package/src/credential-prompts-npm.mjs +154 -0
  12. package/src/credential-prompts.mjs +5 -0
  13. package/src/mvp-flow.mjs +70 -0
  14. package/src/private.mjs +39 -1
  15. package/src/template-resolver.mjs +38 -0
  16. package/src/white-label.mjs +17 -2
  17. package/src/workspaces-cmd.mjs +50 -0
  18. package/src/workspaces.mjs +74 -0
  19. package/templates/.claude/commands/add-auth.md +87 -0
  20. package/templates/.claude/commands/handoff-package.md +37 -0
  21. package/templates/.claude/commands/publish.md +77 -0
  22. package/templates/auth-clerk/env.example.additions +10 -0
  23. package/templates/auth-clerk/layout.tsx +25 -0
  24. package/templates/auth-clerk/middleware.ts +20 -0
  25. package/templates/auth-clerk/package.json.additions +5 -0
  26. package/templates/auth-clerk/sign-in-page.tsx +9 -0
  27. package/templates/auth-clerk/sign-up-page.tsx +9 -0
  28. package/templates/auth-google/auth.ts +12 -0
  29. package/templates/auth-google/env.example.additions +9 -0
  30. package/templates/auth-google/package.json.additions +5 -0
  31. package/templates/auth-google/route.ts +2 -0
  32. package/templates/auth-google/sign-in-page.tsx +32 -0
  33. package/templates/auth-google/sign-up-page.tsx +32 -0
  34. package/templates/private/middleware.ts +8 -1
  35. package/templates/private/package.json.additions +5 -0
@@ -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
@@ -107,6 +107,36 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
107
107
  stack: answers.stack === 'next' ? 'Next.js + TypeScript + Tailwind' : answers.stack,
108
108
  });
109
109
 
110
+ let brandPackUsed = null;
111
+ if (flags.brandPack) {
112
+ const { findBrandPack } = await import('./brand-packs.mjs');
113
+ const pack = await findBrandPack(flags.brandPack);
114
+ if (!pack) {
115
+ const err = new Error(`No brand pack named '${flags.brandPack}'. Run 'ostup brand-pack list' to see available packs.`);
116
+ err.code = 'BRAND_PACK_NOT_FOUND';
117
+ throw err;
118
+ }
119
+ brandPackUsed = pack.name;
120
+ tokens.BRAND_PRIMARY = pack.colors.primary;
121
+ tokens.BRAND_ACCENT = pack.colors.accent;
122
+ tokens.BRAND_BACKGROUND = pack.colors.background;
123
+ tokens.BRAND_FONT_HEADING = pack.fonts.heading;
124
+ tokens.BRAND_FONT_BODY = pack.fonts.body;
125
+ if (pack.tagline) tokens.BRAND_TAGLINE = pack.tagline;
126
+ process.stdout.write(`[brand-pack] using '${pack.name}' (primary=${pack.colors.primary})\n`);
127
+ }
128
+
129
+ if (flags.templates) {
130
+ const { resolveTemplatesRoot } = await import('./template-resolver.mjs');
131
+ try {
132
+ resolveTemplatesRoot(flags.templates);
133
+ process.stdout.write(`[templates] override path noted: ${flags.templates}\n`);
134
+ process.stdout.write('[templates] NOTE: v1 ships the resolver + flag; overlay-applier integration lands in v2.1.\n');
135
+ } catch (err) {
136
+ throw err;
137
+ }
138
+ }
139
+
110
140
  await overlayKit({ targetDir, tokens });
111
141
  if (brief) {
112
142
  await writeBriefFiles({ targetDir, brief, force: true });
@@ -134,6 +164,23 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
134
164
  }
135
165
  }
136
166
 
167
+ if (flags.auth && flags.auth !== 'none') {
168
+ const profile = brief?.scaffold?.profile || null;
169
+ if (flags.auth === 'clerk') {
170
+ const { applyAuthClerk } = await import('./auth-clerk.mjs');
171
+ const r = await applyAuthClerk({ targetDir, profile, tokens });
172
+ process.stdout.write(`[auth] applied clerk overlay. ${r.touched.length} file(s) touched\n`);
173
+ } else if (flags.auth === 'google') {
174
+ const { applyAuthGoogle } = await import('./auth-google.mjs');
175
+ const r = await applyAuthGoogle({ targetDir, profile, tokens });
176
+ process.stdout.write(`[auth] applied google overlay. ${r.touched.length} file(s) touched\n`);
177
+ } else {
178
+ const err = new Error(`Unknown --auth value '${flags.auth}'. Use clerk, google, or none.`);
179
+ err.code = 'AUTH_UNKNOWN_PROVIDER';
180
+ throw err;
181
+ }
182
+ }
183
+
137
184
  if (flags.private) {
138
185
  const { applyPrivate } = await import('./private.mjs');
139
186
  const profile = brief?.scaffold?.profile || null;
@@ -144,6 +191,29 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
144
191
  );
145
192
  }
146
193
 
194
+ // Record this scaffold in the operator's workspace registry. Best-effort: don't fail the scaffold if write fails.
195
+ try {
196
+ const { recordWorkspace } = await import('./workspaces.mjs');
197
+ const { createHash } = await import('node:crypto');
198
+ const briefHash = brief ? createHash('sha256').update(JSON.stringify(brief)).digest('hex').slice(0, 16) : null;
199
+ const pkgVersion = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8').then((r) => JSON.parse(r).version).catch(() => null);
200
+ await recordWorkspace({
201
+ name: answers.projectName,
202
+ path: targetDir,
203
+ profile: brief?.scaffold?.profile || null,
204
+ brief_hash: briefHash,
205
+ templates_path: flags.templates || null,
206
+ ostup_version: pkgVersion,
207
+ white_label: !!flags.whiteLabel,
208
+ private: !!flags.private,
209
+ auth: flags.auth && flags.auth !== 'none' ? flags.auth : null,
210
+ brand_pack: brandPackUsed,
211
+ });
212
+ process.stdout.write(`[workspaces] recorded '${answers.projectName}' in ~/.ostup/workspaces.json\n`);
213
+ } catch (err) {
214
+ process.stdout.write(`[workspaces] WARN: ${err.message} (scaffold succeeded; registry write failed)\n`);
215
+ }
216
+
147
217
  await exec('git-init', 'git', ['init'], { cwd: targetDir });
148
218
  await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
149
219
  await exec('git-add', 'git', ['add', '.'], { cwd: targetDir });
package/src/private.mjs CHANGED
@@ -41,7 +41,7 @@ function detectSaas(targetDir, profile) {
41
41
  }
42
42
 
43
43
  function plannedOperations(targetDir, useKvRateLimit) {
44
- // Each op: { kind: 'create'|'patch', dest: <relative path>, src: <template relpath>|null, patcher?: fn }
44
+ // Each op: { kind: 'create'|'patch'|'package-merge', dest, src, mode?, patcher? }
45
45
  const rateLimitSrc = useKvRateLimit ? 'lib-rate-limit-kv.ts' : 'lib-rate-limit-memory.ts';
46
46
  return [
47
47
  { kind: 'create', dest: 'middleware.ts', src: 'middleware.ts' },
@@ -50,11 +50,32 @@ function plannedOperations(targetDir, useKvRateLimit) {
50
50
  { kind: 'create', dest: 'app/api/audit/health/route.ts', src: 'api-audit-health.ts' },
51
51
  { kind: 'create', dest: 'lib/audit.ts', src: 'lib-audit.ts' },
52
52
  { kind: 'create', dest: 'lib/rate-limit.ts', src: rateLimitSrc },
53
+ { kind: 'package-merge', dest: 'package.json', src: 'package.json.additions' },
54
+ ...(useKvRateLimit
55
+ ? [{ kind: 'package-merge-extra', dest: 'package.json', deps: { '@vercel/kv': '^3.0.0' } }]
56
+ : []),
53
57
  { kind: 'patch', dest: 'CLAUDE.md', src: 'CLAUDE_PART_20.md', mode: 'append' },
54
58
  { kind: 'patch', dest: 'app/layout.tsx', src: null, mode: 'layout-robots' },
55
59
  ];
56
60
  }
57
61
 
62
+ function mergePackageJson(existing, additionsText) {
63
+ const additions = JSON.parse(additionsText);
64
+ const target = existing ? JSON.parse(existing) : {};
65
+ for (const section of ['dependencies', 'devDependencies', 'scripts']) {
66
+ if (additions[section]) {
67
+ target[section] = { ...(target[section] || {}), ...additions[section] };
68
+ }
69
+ }
70
+ return JSON.stringify(target, null, 2) + '\n';
71
+ }
72
+
73
+ function mergePackageJsonExtra(existing, extraDeps) {
74
+ const target = existing ? JSON.parse(existing) : {};
75
+ target.dependencies = { ...(target.dependencies || {}), ...extraDeps };
76
+ return JSON.stringify(target, null, 2) + '\n';
77
+ }
78
+
58
79
  async function loadTemplate(rel) {
59
80
  return readFile(join(PRIVATE_TEMPLATES, rel), 'utf8');
60
81
  }
@@ -120,6 +141,23 @@ export async function applyPrivate({ targetDir, profile = null, force = false }
120
141
  await mkdir(dirname(destPath), { recursive: true });
121
142
  await writeFile(destPath, content, 'utf8');
122
143
  performed.push({ kind: 'created', path: op.dest });
144
+ } else if (op.kind === 'package-merge') {
145
+ const additionsText = await loadTemplate(op.src);
146
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
147
+ const merged = mergePackageJson(prior, additionsText);
148
+ await mkdir(dirname(destPath), { recursive: true });
149
+ await writeFile(destPath, merged, 'utf8');
150
+ if (prior !== null) {
151
+ performed.push({ kind: 'patched', path: op.dest, prior });
152
+ } else {
153
+ performed.push({ kind: 'created', path: op.dest });
154
+ }
155
+ } else if (op.kind === 'package-merge-extra') {
156
+ const prior = existsSync(destPath) ? await readFile(destPath, 'utf8') : null;
157
+ const merged = mergePackageJsonExtra(prior, op.deps);
158
+ await mkdir(dirname(destPath), { recursive: true });
159
+ await writeFile(destPath, merged, 'utf8');
160
+ // No new manifest entry — the prior package-merge op already tracks this file.
123
161
  } else if (op.kind === 'patch') {
124
162
  if (!existsSync(destPath)) {
125
163
  // Patching a missing file is a soft-skip (e.g. no app/layout.tsx exists).
@@ -0,0 +1,38 @@
1
+ // template-resolver.mjs: resolve overlay paths, honoring --templates <path> override before falling back to built-in.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve, dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8
+ const BUILT_IN_TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
9
+
10
+ class TemplateResolverError extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export function resolveTemplatesRoot(override) {
18
+ if (!override) return BUILT_IN_TEMPLATES_ROOT;
19
+ const abs = resolve(override);
20
+ if (!existsSync(abs)) {
21
+ throw new TemplateResolverError(
22
+ 'TEMPLATES_PATH_NOT_FOUND',
23
+ `--templates path does not exist: ${abs}`,
24
+ );
25
+ }
26
+ return abs;
27
+ }
28
+
29
+ export function resolveOverlayPath(overlayName, override) {
30
+ if (override) {
31
+ const candidate = resolve(override, overlayName);
32
+ if (existsSync(candidate)) return candidate;
33
+ }
34
+ const builtIn = resolve(BUILT_IN_TEMPLATES_ROOT, overlayName);
35
+ return existsSync(builtIn) ? builtIn : null;
36
+ }
37
+
38
+ export { BUILT_IN_TEMPLATES_ROOT, TemplateResolverError };
@@ -1,9 +1,12 @@
1
1
  // white-label.mjs: post-process specific generated files to strip OSTUP / Goodshin attribution.
2
2
  // Used when `ostup init --white-label` is passed (Studio tier).
3
3
 
4
- import { readFile, writeFile } from 'node:fs/promises';
4
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ const WHITE_LABEL_MANIFEST_PATH = '.ostup/white-label.json';
9
+ const SCHEMA_VERSION = '1.0.0';
7
10
 
8
11
  const TARGETS = [
9
12
  'AGENTS.md',
@@ -50,5 +53,17 @@ export async function applyWhiteLabel({ targetDir } = {}) {
50
53
  touched.push(rel);
51
54
  }
52
55
  }
56
+
57
+ // Write manifest so downstream commands (e.g. /handoff-package) can detect white-label mode.
58
+ const manifestPath = join(targetDir, WHITE_LABEL_MANIFEST_PATH);
59
+ await mkdir(dirname(manifestPath), { recursive: true });
60
+ await writeFile(
61
+ manifestPath,
62
+ JSON.stringify({ schema_version: SCHEMA_VERSION, appliedAt: new Date().toISOString(), touched }, null, 2) + '\n',
63
+ 'utf8',
64
+ );
65
+
53
66
  return { touched };
54
67
  }
68
+
69
+ export { WHITE_LABEL_MANIFEST_PATH };
@@ -0,0 +1,50 @@
1
+ // workspaces-cmd.mjs: ostup workspaces ls|show subcommand handler.
2
+
3
+ import { listWorkspaces, findWorkspace, WorkspacesError } from './workspaces.mjs';
4
+ import { homedir } from 'node:os';
5
+
6
+ function homeRelative(p) {
7
+ if (!p) return '';
8
+ const home = homedir();
9
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
10
+ }
11
+
12
+ export async function runWorkspaces({ action = 'ls', positional = [] } = {}) {
13
+ if (action === 'ls') {
14
+ const items = await listWorkspaces();
15
+ if (items.length === 0) {
16
+ process.stdout.write('No workspaces recorded yet. They appear here after `ostup init` succeeds.\n');
17
+ return;
18
+ }
19
+ const header = `${pad('name', 24)} ${pad('profile', 16)} ${pad('version', 8)} ${pad('date', 10)} path`;
20
+ process.stdout.write(header + '\n');
21
+ process.stdout.write('-'.repeat(header.length) + '\n');
22
+ for (const w of items) {
23
+ const name = pad(w.name, 24);
24
+ const profile = pad(w.profile || '-', 16);
25
+ const version = pad(w.ostup_version || '-', 8);
26
+ const date = pad((w.scaffolded_at || '').slice(0, 10), 10);
27
+ process.stdout.write(`${name} ${profile} ${version} ${date} ${homeRelative(w.path)}\n`);
28
+ }
29
+ return;
30
+ }
31
+ if (action === 'show') {
32
+ const name = positional[0];
33
+ if (!name) {
34
+ throw new WorkspacesError('WORKSPACES_INVALID', 'Usage: ostup workspaces show <name>');
35
+ }
36
+ const entry = await findWorkspace(name);
37
+ if (!entry) {
38
+ throw new WorkspacesError('WORKSPACES_NOT_FOUND', `No workspace named '${name}'.`);
39
+ }
40
+ process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
41
+ return;
42
+ }
43
+ throw new WorkspacesError('WORKSPACES_UNKNOWN_ACTION', `Unknown action '${action}'. Use: ls | show <name>`);
44
+ }
45
+
46
+ function pad(s, n) {
47
+ s = String(s);
48
+ if (s.length >= n) return s.slice(0, n - 1) + '_';
49
+ return s + ' '.repeat(n - s.length);
50
+ }
@@ -0,0 +1,74 @@
1
+ // workspaces.mjs: ~/.ostup/workspaces.json local registry. Atomic writes. Never phones home.
2
+
3
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ const WORKSPACES_PATH = join(homedir(), '.ostup', 'workspaces.json');
9
+ const SCHEMA_VERSION = '1.0.0';
10
+
11
+ class WorkspacesError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ function emptyRegistry() {
19
+ return { schema_version: SCHEMA_VERSION, workspaces: [] };
20
+ }
21
+
22
+ export async function readWorkspaces(path = WORKSPACES_PATH) {
23
+ if (!existsSync(path)) return emptyRegistry();
24
+ try {
25
+ const raw = await readFile(path, 'utf8');
26
+ const parsed = JSON.parse(raw);
27
+ if (!parsed || !Array.isArray(parsed.workspaces)) return emptyRegistry();
28
+ return parsed;
29
+ } catch (err) {
30
+ throw new WorkspacesError('WORKSPACES_INVALID', `Cannot parse ${path}: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ async function atomicWrite(path, content) {
35
+ await mkdir(dirname(path), { recursive: true });
36
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
37
+ await writeFile(temp, content, 'utf8');
38
+ await rename(temp, path);
39
+ }
40
+
41
+ export async function recordWorkspace(entry, path = WORKSPACES_PATH) {
42
+ if (!entry || typeof entry.name !== 'string') {
43
+ throw new WorkspacesError('WORKSPACES_INVALID_ENTRY', 'entry.name is required');
44
+ }
45
+ const registry = await readWorkspaces(path);
46
+ const normalized = {
47
+ name: entry.name,
48
+ path: entry.path || '',
49
+ scaffolded_at: entry.scaffolded_at || new Date().toISOString(),
50
+ profile: entry.profile || null,
51
+ brief_hash: entry.brief_hash || null,
52
+ templates_path: entry.templates_path || null,
53
+ ostup_version: entry.ostup_version || null,
54
+ white_label: !!entry.white_label,
55
+ private: !!entry.private,
56
+ auth: entry.auth || null,
57
+ brand_pack: entry.brand_pack || null,
58
+ };
59
+ registry.workspaces.push(normalized);
60
+ await atomicWrite(path, JSON.stringify(registry, null, 2) + '\n');
61
+ return normalized;
62
+ }
63
+
64
+ export async function listWorkspaces(path = WORKSPACES_PATH) {
65
+ const registry = await readWorkspaces(path);
66
+ return registry.workspaces;
67
+ }
68
+
69
+ export async function findWorkspace(name, path = WORKSPACES_PATH) {
70
+ const items = await listWorkspaces(path);
71
+ return items.find((w) => w.name === name) || null;
72
+ }
73
+
74
+ export { WORKSPACES_PATH, SCHEMA_VERSION, WorkspacesError };
@@ -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
+ ```
@@ -115,9 +115,46 @@ Optional bundle: handoff-<project>-<YYYY-MM-DD>.zip (run ostup export-pro to cre
115
115
  The recipient can read the package in ~10 minutes and pick up the work.
116
116
  ```
117
117
 
118
+ ## Step 0: Agency-aware mode (Studio v2+)
119
+
120
+ Before reading project context, check two operator-level signals:
121
+
122
+ ### Detect white-label mode
123
+
124
+ If `.ostup/white-label.json` exists at the project root, this project was scaffolded with `--white-label`. The generated handoff package MUST scrub OSTUP / Goodshin attribution from its text (Same scrub rules as `ostup init --white-label`).
125
+
126
+ ```bash
127
+ [ -f .ostup/white-label.json ] && echo "white-label mode active"
128
+ ```
129
+
130
+ ### Detect agency cover sheet
131
+
132
+ If `~/.ostup/agency.json` exists, prepend a "Client-ready" cover sheet at the top of `docs/HANDOFF_PACKAGE.md` with the agency's branding. Read it via:
133
+
134
+ ```bash
135
+ [ -f ~/.ostup/agency.json ] && cat ~/.ostup/agency.json
136
+ ```
137
+
138
+ Then render a cover sheet block at the very top of the package:
139
+
140
+ ```markdown
141
+ > # Prepared by <agency.name>
142
+ >
143
+ > <agency.tagline (if set)>
144
+ >
145
+ > Contact: <agency.contact_email (if set)>
146
+ > Web: <agency.website (if set)>
147
+ >
148
+ > ---
149
+ ```
150
+
151
+ If `agency.name` is missing or `agency.json` doesn't exist, skip the cover sheet entirely. The package still works without it.
152
+
118
153
  ## Hard rules
119
154
 
120
155
  - Pull every fact from existing files (brief, HANDOFF, PROJECT_STATE, ARCHITECTURE, MANUAL_TASKS, git log). Do not invent.
121
156
  - If a section has no source, write `_TBD — operator to fill_` rather than guess.
122
157
  - This is a SNAPSHOT. Regenerate when you re-hand-off.
123
158
  - Operator can edit the file directly to refine; do not auto-regenerate on every session.
159
+ - When `.ostup/white-label.json` is present, NEVER emit "OSTUP" / "Goodshin" / "scaffolded with ostup" in the generated package.
160
+ - Agency cover sheet renders gracefully with missing optional fields (only `name` required).