@oaklandzoo/ostup 0.12.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.
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');
@@ -48,6 +48,10 @@ function parseArgs(argv) {
48
48
  else if (a === '--auth') flags.auth = argv[++i];
49
49
  else if (a.startsWith('--auth=')) flags.auth = a.slice('--auth='.length);
50
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);
51
55
  else if (a === '--private') flags.private = true;
52
56
  else if (a === '--privacy') flags.privacy = true;
53
57
  else if (a === '--url') flags.url = argv[++i];
@@ -233,6 +237,46 @@ if (subcommand === 'private') {
233
237
  }
234
238
  }
235
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
+
236
280
  if (subcommand === 'bootstrap') {
237
281
  const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
238
282
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.12.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,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,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 };
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 });
@@ -161,6 +191,29 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
161
191
  );
162
192
  }
163
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
+
164
217
  await exec('git-init', 'git', ['init'], { cwd: targetDir });
165
218
  await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
166
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 };
@@ -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).
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "@vercel/blob": "^0.27.0"
4
+ }
5
+ }