@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
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
@@ -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', 'private']);
14
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap', 'private', 'workspaces', 'brand-pack']);
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 === '--templates') flags.templates = argv[++i];
52
+ else if (a.startsWith('--templates=')) flags.templates = a.slice('--templates='.length);
53
+ else if (a === '--brand-pack') flags.brandPack = argv[++i];
54
+ else if (a.startsWith('--brand-pack=')) flags.brandPack = a.slice('--brand-pack='.length);
48
55
  else if (a === '--private') flags.private = true;
49
56
  else if (a === '--privacy') flags.privacy = true;
50
57
  else if (a === '--url') flags.url = argv[++i];
@@ -96,6 +103,8 @@ function printHelp() {
96
103
  ' --brief <path> Load a brief.json from <path> and write brief files + apply profile overlay.',
97
104
  ' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
98
105
  ' --private App-level default-deny: middleware, blob proxy, image-optimizer lock, audit + rate-limit, CLAUDE.md Part 20. Refused with --profile saas-dashboard.',
106
+ ' --auth <provider> Wire authentication. Values: clerk (Clerk + @clerk/nextjs), google (NextAuth v5 + Google), none. Refused with --profile saas-dashboard.',
107
+ ' --publish-ready Walk through npm token setup so this CLI can publish later (writes ~/.npmrc).',
99
108
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
100
109
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
101
110
  ' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
@@ -228,6 +237,46 @@ if (subcommand === 'private') {
228
237
  }
229
238
  }
230
239
 
240
+ if (subcommand === 'workspaces') {
241
+ const action = subPositional[0] || 'ls';
242
+ const positional = subPositional.slice(1);
243
+ const { runWorkspaces } = await import('../src/workspaces-cmd.mjs');
244
+ try {
245
+ await runWorkspaces({ action, positional });
246
+ process.exit(0);
247
+ } catch (err) {
248
+ process.stderr.write(`${err.message}\n`);
249
+ const userErrors = new Set([
250
+ 'WORKSPACES_INVALID',
251
+ 'WORKSPACES_NOT_FOUND',
252
+ 'WORKSPACES_UNKNOWN_ACTION',
253
+ 'WORKSPACES_INVALID_ENTRY',
254
+ ]);
255
+ process.exit(userErrors.has(err.code) ? 1 : 2);
256
+ }
257
+ }
258
+
259
+ if (subcommand === 'brand-pack') {
260
+ const action = subPositional[0] || 'list';
261
+ const positional = subPositional.slice(1);
262
+ const { runBrandPack } = await import('../src/brand-pack-cmd.mjs');
263
+ try {
264
+ await runBrandPack({ action, positional, flags });
265
+ process.exit(0);
266
+ } catch (err) {
267
+ process.stderr.write(`${err.message}\n`);
268
+ const userErrors = new Set([
269
+ 'BRAND_PACK_INVALID_ENTRY',
270
+ 'BRAND_PACK_NOT_FOUND',
271
+ 'BRAND_PACK_DUPLICATE_NAME',
272
+ 'BRAND_PACK_LIMIT_EXCEEDED',
273
+ 'BRAND_PACK_UNKNOWN_ACTION',
274
+ 'BRAND_PACK_INVALID_REGISTRY',
275
+ ]);
276
+ process.exit(userErrors.has(err.code) ? 1 : 2);
277
+ }
278
+ }
279
+
231
280
  if (subcommand === 'bootstrap') {
232
281
  const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
233
282
  try {
@@ -283,6 +332,13 @@ try {
283
332
  'INGEST_PATH_NOT_FOUND',
284
333
  'BRIEF_NOT_FOUND',
285
334
  'BRIEF_INVALID',
335
+ 'AUTH_ALREADY_APPLIED',
336
+ 'AUTH_PROFILE_CONFLICT',
337
+ 'AUTH_DIRTY',
338
+ 'AUTH_UNKNOWN_PROVIDER',
339
+ 'PRIVATE_SAAS_CONFLICT',
340
+ 'PRIVATE_ALREADY_APPLIED',
341
+ 'PRIVATE_DIRTY',
286
342
  'NO_TTY_BOOTSTRAP',
287
343
  'BOOTSTRAP_DECLINED',
288
344
  '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.13.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,16 @@
1
+ #!/usr/bin/env bash
2
+ # verify-studio.sh: run Studio v2 unit tests in isolation.
3
+
4
+ set -euo pipefail
5
+
6
+ OSTUP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ cd "$OSTUP_ROOT"
8
+
9
+ echo ">> verifying Studio v2 modules"
10
+ node --test \
11
+ test/template-resolver.test.mjs \
12
+ test/workspaces.test.mjs \
13
+ test/brand-packs.test.mjs \
14
+ test/agency.test.mjs
15
+
16
+ echo ">> ok: all Studio v2 module tests pass"
package/src/agency.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // agency.mjs: ~/.ostup/agency.json operator-level config for Studio handoff bundles.
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const AGENCY_CONFIG_PATH = join(homedir(), '.ostup', 'agency.json');
9
+
10
+ class AgencyError extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ export async function readAgencyConfig(path = AGENCY_CONFIG_PATH) {
18
+ if (!existsSync(path)) return null;
19
+ let raw;
20
+ try {
21
+ raw = await readFile(path, 'utf8');
22
+ } catch {
23
+ return null;
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ } catch (err) {
29
+ throw new AgencyError('AGENCY_CONFIG_INVALID', `Cannot parse ${path}: ${err.message}`);
30
+ }
31
+ if (!parsed || typeof parsed !== 'object') return null;
32
+ if (typeof parsed.name !== 'string' || !parsed.name.trim()) {
33
+ throw new AgencyError('AGENCY_CONFIG_INVALID', 'agency.name is required and must be a non-empty string');
34
+ }
35
+ return {
36
+ schema_version: parsed.schema_version || '1.0.0',
37
+ name: parsed.name.trim(),
38
+ logo_path: parsed.logo_path || null,
39
+ tagline: parsed.tagline || null,
40
+ contact_email: parsed.contact_email || null,
41
+ website: parsed.website || null,
42
+ };
43
+ }
44
+
45
+ export { AGENCY_CONFIG_PATH, AgencyError };
@@ -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,76 @@
1
+ // brand-pack-cmd.mjs: ostup brand-pack add|remove|list|show subcommand handler.
2
+
3
+ import * as p from '@clack/prompts';
4
+ import { addBrandPack, removeBrandPack, listBrandPacks, findBrandPack, BrandPackError, MAX_PACKS } from './brand-packs.mjs';
5
+
6
+ export async function runBrandPack({ action = 'list', positional = [], flags = {} } = {}) {
7
+ if (action === 'list' || action === 'ls') {
8
+ const packs = await listBrandPacks();
9
+ if (packs.length === 0) {
10
+ process.stdout.write(`No brand packs yet. Cap: ${MAX_PACKS} per user.\n`);
11
+ process.stdout.write(`Add one with: ostup brand-pack add\n`);
12
+ return;
13
+ }
14
+ process.stdout.write(`${packs.length} brand pack(s) (cap: ${MAX_PACKS}):\n`);
15
+ for (const pk of packs) {
16
+ process.stdout.write(` - ${pk.name} primary=${pk.colors.primary} tagline="${pk.tagline || ''}" created=${pk.created_at.slice(0, 10)}\n`);
17
+ }
18
+ return;
19
+ }
20
+ if (action === 'show') {
21
+ const name = positional[0];
22
+ if (!name) throw new BrandPackError('BRAND_PACK_INVALID_ENTRY', 'Usage: ostup brand-pack show <name>');
23
+ const pk = await findBrandPack(name);
24
+ if (!pk) throw new BrandPackError('BRAND_PACK_NOT_FOUND', `No brand pack named '${name}'.`);
25
+ process.stdout.write(JSON.stringify(pk, null, 2) + '\n');
26
+ return;
27
+ }
28
+ if (action === 'remove' || action === 'rm') {
29
+ const name = positional[0];
30
+ if (!name) throw new BrandPackError('BRAND_PACK_INVALID_ENTRY', 'Usage: ostup brand-pack remove <name>');
31
+ const removed = await removeBrandPack(name);
32
+ process.stdout.write(`Removed brand pack '${removed.name}'.\n`);
33
+ return;
34
+ }
35
+ if (action === 'add') {
36
+ if (flags.yes) {
37
+ // Non-interactive add with defaults: name from positional, everything else default.
38
+ const name = positional[0];
39
+ if (!name) throw new BrandPackError('BRAND_PACK_INVALID_ENTRY', 'Usage with --yes: ostup brand-pack add <name>');
40
+ const added = await addBrandPack({ name });
41
+ process.stdout.write(`Added '${added.name}' with default colors/fonts. Edit ~/.ostup/brand-packs.json to customize.\n`);
42
+ return;
43
+ }
44
+ const name = await p.text({ message: 'Brand pack name (kebab-case)', validate: (v) => (!v || !v.trim() ? 'Required.' : undefined) });
45
+ if (p.isCancel(name)) { process.stdout.write('Cancelled.\n'); return; }
46
+ const primary = await p.text({ message: 'Primary color (hex)', initialValue: '#0f172a', placeholder: '#0f172a' });
47
+ if (p.isCancel(primary)) { process.stdout.write('Cancelled.\n'); return; }
48
+ const accent = await p.text({ message: 'Accent color (hex)', initialValue: '#2563eb', placeholder: '#2563eb' });
49
+ if (p.isCancel(accent)) { process.stdout.write('Cancelled.\n'); return; }
50
+ const background = await p.text({ message: 'Background color (hex)', initialValue: '#ffffff', placeholder: '#ffffff' });
51
+ if (p.isCancel(background)) { process.stdout.write('Cancelled.\n'); return; }
52
+ const heading = await p.text({ message: 'Heading font family', initialValue: 'system-ui, sans-serif' });
53
+ if (p.isCancel(heading)) { process.stdout.write('Cancelled.\n'); return; }
54
+ const body = await p.text({ message: 'Body font family', initialValue: 'system-ui, sans-serif' });
55
+ if (p.isCancel(body)) { process.stdout.write('Cancelled.\n'); return; }
56
+ const voiceRaw = await p.text({ message: 'Voice (3-5 comma-separated words; e.g. credible, warm, premium)', initialValue: '' });
57
+ if (p.isCancel(voiceRaw)) { process.stdout.write('Cancelled.\n'); return; }
58
+ const tagline = await p.text({ message: 'Tagline (one line; optional)', initialValue: '' });
59
+ if (p.isCancel(tagline)) { process.stdout.write('Cancelled.\n'); return; }
60
+ const logoPath = await p.text({ message: 'Logo path (absolute; optional)', initialValue: '' });
61
+ if (p.isCancel(logoPath)) { process.stdout.write('Cancelled.\n'); return; }
62
+
63
+ const voice = String(voiceRaw).split(',').map((s) => s.trim()).filter(Boolean).slice(0, 5);
64
+ const added = await addBrandPack({
65
+ name: String(name).trim(),
66
+ colors: { primary: String(primary), accent: String(accent), background: String(background) },
67
+ fonts: { heading: String(heading), body: String(body) },
68
+ voice,
69
+ logo_path: String(logoPath).trim() || null,
70
+ tagline: String(tagline).trim() || null,
71
+ });
72
+ process.stdout.write(`Added brand pack '${added.name}'. Now ${(await listBrandPacks()).length}/${MAX_PACKS} used.\n`);
73
+ return;
74
+ }
75
+ throw new BrandPackError('BRAND_PACK_UNKNOWN_ACTION', `Unknown action '${action}'. Use: add | remove | list | show <name>`);
76
+ }
@@ -0,0 +1,101 @@
1
+ // brand-packs.mjs: ~/.ostup/brand-packs.json operator-level registry. Cap: 5 per user.
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 BRAND_PACKS_PATH = join(homedir(), '.ostup', 'brand-packs.json');
9
+ const SCHEMA_VERSION = '1.0.0';
10
+ export const MAX_PACKS = 5;
11
+
12
+ class BrandPackError extends Error {
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ function emptyRegistry() {
20
+ return { schema_version: SCHEMA_VERSION, packs: [] };
21
+ }
22
+
23
+ export async function readBrandPacks(path = BRAND_PACKS_PATH) {
24
+ if (!existsSync(path)) return emptyRegistry();
25
+ try {
26
+ const raw = await readFile(path, 'utf8');
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || !Array.isArray(parsed.packs)) return emptyRegistry();
29
+ return parsed;
30
+ } catch (err) {
31
+ throw new BrandPackError('BRAND_PACK_INVALID_REGISTRY', `Cannot parse ${path}: ${err.message}`);
32
+ }
33
+ }
34
+
35
+ async function atomicWrite(path, content) {
36
+ await mkdir(dirname(path), { recursive: true });
37
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
38
+ await writeFile(temp, content, 'utf8');
39
+ await rename(temp, path);
40
+ }
41
+
42
+ export async function addBrandPack(entry, path = BRAND_PACKS_PATH) {
43
+ if (!entry || typeof entry.name !== 'string' || !entry.name.trim()) {
44
+ throw new BrandPackError('BRAND_PACK_INVALID_ENTRY', 'entry.name is required and must be a non-empty string');
45
+ }
46
+ const registry = await readBrandPacks(path);
47
+ if (registry.packs.some((p) => p.name === entry.name)) {
48
+ throw new BrandPackError(
49
+ 'BRAND_PACK_DUPLICATE_NAME',
50
+ `A brand pack named '${entry.name}' already exists. Remove it first or pick a different name.`,
51
+ );
52
+ }
53
+ if (registry.packs.length >= MAX_PACKS) {
54
+ throw new BrandPackError(
55
+ 'BRAND_PACK_LIMIT_EXCEEDED',
56
+ `Maximum ${MAX_PACKS} brand packs per user. Remove one with 'ostup brand-pack remove <name>' before adding a new one.`,
57
+ );
58
+ }
59
+ const normalized = {
60
+ name: entry.name.trim(),
61
+ created_at: entry.created_at || new Date().toISOString(),
62
+ colors: {
63
+ primary: entry.colors?.primary || '#0f172a',
64
+ accent: entry.colors?.accent || '#2563eb',
65
+ background: entry.colors?.background || '#ffffff',
66
+ },
67
+ fonts: {
68
+ heading: entry.fonts?.heading || 'system-ui, sans-serif',
69
+ body: entry.fonts?.body || 'system-ui, sans-serif',
70
+ },
71
+ voice: Array.isArray(entry.voice) ? entry.voice.slice(0, 5) : [],
72
+ logo_path: entry.logo_path || null,
73
+ tagline: entry.tagline || null,
74
+ };
75
+ registry.packs.push(normalized);
76
+ await atomicWrite(path, JSON.stringify(registry, null, 2) + '\n');
77
+ return normalized;
78
+ }
79
+
80
+ export async function removeBrandPack(name, path = BRAND_PACKS_PATH) {
81
+ const registry = await readBrandPacks(path);
82
+ const idx = registry.packs.findIndex((p) => p.name === name);
83
+ if (idx === -1) {
84
+ throw new BrandPackError('BRAND_PACK_NOT_FOUND', `No brand pack named '${name}'.`);
85
+ }
86
+ const removed = registry.packs.splice(idx, 1)[0];
87
+ await atomicWrite(path, JSON.stringify(registry, null, 2) + '\n');
88
+ return removed;
89
+ }
90
+
91
+ export async function listBrandPacks(path = BRAND_PACKS_PATH) {
92
+ const registry = await readBrandPacks(path);
93
+ return registry.packs;
94
+ }
95
+
96
+ export async function findBrandPack(name, path = BRAND_PACKS_PATH) {
97
+ const items = await listBrandPacks(path);
98
+ return items.find((p) => p.name === name) || null;
99
+ }
100
+
101
+ export { BRAND_PACKS_PATH, SCHEMA_VERSION, BrandPackError };