@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 +45 -1
- package/package.json +1 -1
- package/scripts/verify-studio.sh +16 -0
- package/src/agency.mjs +45 -0
- package/src/brand-pack-cmd.mjs +76 -0
- package/src/brand-packs.mjs +101 -0
- package/src/mvp-flow.mjs +53 -0
- package/src/private.mjs +39 -1
- package/src/template-resolver.mjs +38 -0
- package/src/white-label.mjs +17 -2
- package/src/workspaces-cmd.mjs +50 -0
- package/src/workspaces.mjs +74 -0
- package/templates/.claude/commands/handoff-package.md +37 -0
- package/templates/private/package.json.additions +5 -0
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
|
@@ -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
|
|
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 };
|
package/src/white-label.mjs
CHANGED
|
@@ -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).
|