@cofoundr/init 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -0
- package/bin/cofoundr.mjs +10 -0
- package/content/.claude-plugin/plugin.json +18 -0
- package/content/README.md +227 -0
- package/content/agents/brand-intake.md +129 -0
- package/content/agents/consolidate.md +154 -0
- package/content/agents/launch-kit-detect.md +109 -0
- package/content/agents/spec-phase.md +131 -0
- package/content/commands/audit.md +137 -0
- package/content/commands/constitution.md +107 -0
- package/content/commands/document.md +155 -0
- package/content/commands/implement.md +108 -0
- package/content/commands/new-feature.md +188 -0
- package/content/commands/new-project.md +243 -0
- package/content/commands/next.md +129 -0
- package/content/commands/onboard.md +176 -0
- package/content/commands/plan.md +138 -0
- package/content/commands/quick-brief.md +95 -0
- package/content/commands/resume.md +99 -0
- package/content/commands/review.md +76 -0
- package/content/commands/rules-check.md +54 -0
- package/content/commands/scope-guard.md +33 -0
- package/content/commands/setup-skills.md +109 -0
- package/content/commands/specify.md +53 -0
- package/content/commands/tasks.md +91 -0
- package/content/commands/translate.md +197 -0
- package/content/manifest.json +59 -0
- package/content/scaffold/.cofoundr/README.md +67 -0
- package/content/scaffold/.cofoundr/constitution.md.tmpl +54 -0
- package/content/scaffold/.cofoundr/manifest.json.tmpl +15 -0
- package/content/scaffold/.cofoundr/memory/decisions.md.tmpl +19 -0
- package/content/scaffold/.cofoundr/memory/knowledge.md.tmpl +23 -0
- package/content/scaffold/.cofoundr/memory/project.md.tmpl +27 -0
- package/content/scaffold/.cofoundr/specs/README.md +38 -0
- package/content/scaffold/AGENTS.md.tmpl +74 -0
- package/content/templates/agents.md +144 -0
- package/content/templates/brand.md +180 -0
- package/content/templates/feature.md +70 -0
- package/content/templates/phases/phase-template/README.md +65 -0
- package/content/templates/phases/phase-template/decisions.md +52 -0
- package/content/templates/phases/phase-template/research.md +59 -0
- package/content/templates/phases/phase-template/spec.md +90 -0
- package/content/templates/phases/phase-template/tests.md +65 -0
- package/content/templates/phases/phase-template/ui-spec.md +75 -0
- package/content/templates/phases.md +234 -0
- package/content/templates/prd.md +89 -0
- package/content/templates/product.md +73 -0
- package/content/templates/rules.md +99 -0
- package/content/templates/tech.md +129 -0
- package/package.json +39 -0
- package/src/adapters/aider.mjs +35 -0
- package/src/adapters/claude-code.mjs +114 -0
- package/src/adapters/cline.mjs +46 -0
- package/src/adapters/codex.mjs +29 -0
- package/src/adapters/copilot.mjs +54 -0
- package/src/adapters/cursor.mjs +69 -0
- package/src/adapters/gemini.mjs +41 -0
- package/src/adapters/index.mjs +14 -0
- package/src/adapters/windsurf.mjs +69 -0
- package/src/cli.mjs +124 -0
- package/src/commands/doctor.mjs +90 -0
- package/src/commands/init.mjs +190 -0
- package/src/commands/list.mjs +28 -0
- package/src/commands/onboard.mjs +130 -0
- package/src/commands/remove.mjs +89 -0
- package/src/commands/sync.mjs +81 -0
- package/src/core/detect.mjs +121 -0
- package/src/core/fs.mjs +42 -0
- package/src/core/license.mjs +170 -0
- package/src/core/log.mjs +32 -0
- package/src/core/prompts.mjs +62 -0
- package/src/core/scaffold.mjs +179 -0
- package/src/core/source.mjs +54 -0
- package/src/core/version.mjs +10 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { c, log } from '../core/log.mjs';
|
|
4
|
+
import { confirm } from '../core/prompts.mjs';
|
|
5
|
+
import { locateSource, loadManifest } from '../core/source.mjs';
|
|
6
|
+
|
|
7
|
+
const TOOL_PATHS = {
|
|
8
|
+
'claude-code': ['.claude/commands', '.claude/agents'], // CLAUDE.md is hand-edited; we only strip the cofoundr block via sync. Doctor flags it.
|
|
9
|
+
cursor: ['.cursor/rules/cofoundr.mdc'],
|
|
10
|
+
windsurf: ['.windsurf/rules/cofoundr.md', '.windsurfrules'],
|
|
11
|
+
cline: ['.clinerules/cofoundr.md'],
|
|
12
|
+
codex: ['.codex/instructions.md'],
|
|
13
|
+
aider: ['.aider.conf.yml'],
|
|
14
|
+
copilot: ['.github/copilot-instructions.md'],
|
|
15
|
+
gemini: ['GEMINI.md'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function remove(opts) {
|
|
19
|
+
const repoPath = resolve(opts.cwd);
|
|
20
|
+
const projManPath = join(repoPath, '.cofoundr', 'manifest.json');
|
|
21
|
+
if (!existsSync(projManPath)) {
|
|
22
|
+
log.err('No CoFoundr install detected here.');
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const projMan = JSON.parse(readFileSync(projManPath, 'utf8'));
|
|
28
|
+
const source = await locateSource();
|
|
29
|
+
const manifest = await loadManifest(source);
|
|
30
|
+
|
|
31
|
+
log.warn(`This will remove tool shims for: ${projMan.tools.join(', ')}`);
|
|
32
|
+
if (opts.hard) {
|
|
33
|
+
log.warn(`${c.bold('--hard')} given: also deleting .cofoundr/ entirely (specs/, memory/, reports/ will be lost).`);
|
|
34
|
+
} else {
|
|
35
|
+
log.info('Specs, memory, decisions, and reports will be preserved. Pass --hard to wipe them too.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!opts.yes) {
|
|
39
|
+
const ok = await confirm('Continue?', { defaultYes: false });
|
|
40
|
+
if (!ok) {
|
|
41
|
+
log.info('Cancelled.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Per-tool shims.
|
|
47
|
+
const removed = [];
|
|
48
|
+
for (const id of projMan.tools) {
|
|
49
|
+
const paths = TOOL_PATHS[id] || [];
|
|
50
|
+
for (const rel of paths) {
|
|
51
|
+
const abs = join(repoPath, rel);
|
|
52
|
+
if (!existsSync(abs)) continue;
|
|
53
|
+
if (!opts.dryRun) rmSync(abs, { recursive: true, force: true });
|
|
54
|
+
removed.push(rel);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Always-written shim files.
|
|
59
|
+
for (const rel of ['AGENTS.md']) {
|
|
60
|
+
// Don't remove AGENTS.md — it may have been hand-edited and is widely useful even without CoFoundr.
|
|
61
|
+
// Doctor will note its presence; user can delete manually if they really want to.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (opts.hard) {
|
|
65
|
+
const dir = join(repoPath, '.cofoundr');
|
|
66
|
+
if (existsSync(dir) && !opts.dryRun) rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
removed.push('.cofoundr/');
|
|
68
|
+
} else {
|
|
69
|
+
// Soft remove: keep memory/, specs/, reports/. Wipe commands/, agents/, templates/, README.md, manifest.json, constitution.md? Keep constitution — it's user content.
|
|
70
|
+
for (const rel of [
|
|
71
|
+
'.cofoundr/commands',
|
|
72
|
+
'.cofoundr/agents',
|
|
73
|
+
'.cofoundr/templates',
|
|
74
|
+
'.cofoundr/manifest.json',
|
|
75
|
+
'.cofoundr/README.md',
|
|
76
|
+
]) {
|
|
77
|
+
const abs = join(repoPath, rel);
|
|
78
|
+
if (!existsSync(abs)) continue;
|
|
79
|
+
if (!opts.dryRun) rmSync(abs, { recursive: true, force: true });
|
|
80
|
+
removed.push(rel);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log.blank();
|
|
85
|
+
log.ok(`Removed ${removed.length} path(s):`);
|
|
86
|
+
for (const r of removed) log.list(r);
|
|
87
|
+
log.blank();
|
|
88
|
+
log.info(c.dim('AGENTS.md was preserved (it is useful even without CoFoundr). Delete it manually if you want to.'));
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve, join, basename } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { c, log } from '../core/log.mjs';
|
|
5
|
+
import { locateSource, loadManifest } from '../core/source.mjs';
|
|
6
|
+
import { adapters, getAdapter } from '../adapters/index.mjs';
|
|
7
|
+
import { writeScaffold, writeProjectManifest } from '../core/scaffold.mjs';
|
|
8
|
+
|
|
9
|
+
export async function sync(opts) {
|
|
10
|
+
const repoPath = resolve(opts.cwd);
|
|
11
|
+
const projManPath = join(repoPath, '.cofoundr', 'manifest.json');
|
|
12
|
+
|
|
13
|
+
if (!existsSync(projManPath)) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`No CoFoundr install at ${repoPath}. Run \`npx @cofoundr/init init\` first.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const projMan = JSON.parse(await readFile(projManPath, 'utf8'));
|
|
20
|
+
const source = await locateSource();
|
|
21
|
+
const manifest = await loadManifest(source);
|
|
22
|
+
|
|
23
|
+
log.info(
|
|
24
|
+
`${c.bold('CoFoundr sync')} ${c.dim(`→ ${repoPath}`)}\n ${c.dim(
|
|
25
|
+
`current: v${projMan.cofoundrVersion} → latest: v${manifest.version}`
|
|
26
|
+
)}`
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
let selected;
|
|
30
|
+
if (opts.allTools) {
|
|
31
|
+
selected = adapters.map((a) => a.id);
|
|
32
|
+
} else if (opts.tools) {
|
|
33
|
+
selected = opts.tools;
|
|
34
|
+
} else {
|
|
35
|
+
selected = projMan.tools && projMan.tools.length ? projMan.tools : adapters.map((a) => a.id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Sync rewrites everything by design — opt out of skip-on-exists.
|
|
39
|
+
const writeOpts = { dryRun: opts.dryRun, force: true };
|
|
40
|
+
|
|
41
|
+
log.step('Refreshing scaffold (.cofoundr/, AGENTS.md)');
|
|
42
|
+
const scaffold = await writeScaffold({
|
|
43
|
+
repoPath,
|
|
44
|
+
source,
|
|
45
|
+
manifest,
|
|
46
|
+
projectName: deriveProjectName(repoPath),
|
|
47
|
+
selectedTools: selected,
|
|
48
|
+
dryRun: writeOpts.dryRun,
|
|
49
|
+
force: true,
|
|
50
|
+
});
|
|
51
|
+
reportFileResult(scaffold);
|
|
52
|
+
|
|
53
|
+
for (const id of selected) {
|
|
54
|
+
const adapter = getAdapter(id);
|
|
55
|
+
if (!adapter) {
|
|
56
|
+
log.warn(`No adapter for "${id}" — skipping.`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
log.step(`Refreshing ${c.bold(adapter.name)} shim`);
|
|
60
|
+
const result = await adapter.apply({ repoPath, source, manifest, opts: writeOpts });
|
|
61
|
+
reportFileResult(result);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await writeProjectManifest({ repoPath, manifest, selectedTools: selected, dryRun: opts.dryRun });
|
|
65
|
+
|
|
66
|
+
log.blank();
|
|
67
|
+
log.ok(`Sync complete. CoFoundr v${manifest.version}.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deriveProjectName(repoPath) {
|
|
71
|
+
try {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
|
|
73
|
+
if (pkg.name) return pkg.name;
|
|
74
|
+
} catch {}
|
|
75
|
+
return basename(repoPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportFileResult({ written, skipped }) {
|
|
79
|
+
for (const p of written) log.ok(c.dim(p));
|
|
80
|
+
for (const s of skipped) log.list(c.dim(`${s.path} (${s.reason})`));
|
|
81
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Each adapter contributes its own detect signals; this module runs them in parallel
|
|
5
|
+
// against the target repo and returns a stable list of detection results.
|
|
6
|
+
|
|
7
|
+
export const DETECT_SIGNALS = {
|
|
8
|
+
'claude-code': [
|
|
9
|
+
'.claude',
|
|
10
|
+
'.claude/commands',
|
|
11
|
+
'.claude/agents',
|
|
12
|
+
'.claude/skills',
|
|
13
|
+
'CLAUDE.md',
|
|
14
|
+
'.claude-plugin',
|
|
15
|
+
],
|
|
16
|
+
cursor: ['.cursor', '.cursor/rules', '.cursorrules'],
|
|
17
|
+
windsurf: ['.windsurf', '.windsurf/rules', '.windsurfrules'],
|
|
18
|
+
cline: ['.clinerules', '.roo'],
|
|
19
|
+
codex: ['AGENTS.md', '.codex'],
|
|
20
|
+
aider: ['.aider.conf.yml', '.aider.conf.yaml', '.aider'],
|
|
21
|
+
copilot: ['.github/copilot-instructions.md'],
|
|
22
|
+
gemini: ['GEMINI.md', '.gemini'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function detectTools(repoPath) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [tool, signals] of Object.entries(DETECT_SIGNALS)) {
|
|
28
|
+
const evidence = signals.filter((s) => existsSync(join(repoPath, s)));
|
|
29
|
+
out[tool] = { found: evidence.length > 0, evidence };
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detectRuntime(repoPath) {
|
|
35
|
+
// Best-effort: language and framework hints used by `init` and `onboard`.
|
|
36
|
+
const findings = {
|
|
37
|
+
languages: [],
|
|
38
|
+
frameworks: [],
|
|
39
|
+
packageManagers: [],
|
|
40
|
+
isMonorepo: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (existsSync(join(repoPath, 'package.json'))) findings.languages.push('javascript');
|
|
44
|
+
if (existsSync(join(repoPath, 'tsconfig.json'))) findings.languages.push('typescript');
|
|
45
|
+
if (existsSync(join(repoPath, 'pyproject.toml')) || existsSync(join(repoPath, 'requirements.txt')))
|
|
46
|
+
findings.languages.push('python');
|
|
47
|
+
if (existsSync(join(repoPath, 'go.mod'))) findings.languages.push('go');
|
|
48
|
+
if (existsSync(join(repoPath, 'Cargo.toml'))) findings.languages.push('rust');
|
|
49
|
+
if (existsSync(join(repoPath, 'Gemfile'))) findings.languages.push('ruby');
|
|
50
|
+
if (existsSync(join(repoPath, 'composer.json'))) findings.languages.push('php');
|
|
51
|
+
|
|
52
|
+
if (existsSync(join(repoPath, 'pnpm-workspace.yaml'))) findings.packageManagers.push('pnpm');
|
|
53
|
+
if (existsSync(join(repoPath, 'pnpm-lock.yaml'))) findings.packageManagers.push('pnpm');
|
|
54
|
+
if (existsSync(join(repoPath, 'yarn.lock'))) findings.packageManagers.push('yarn');
|
|
55
|
+
if (existsSync(join(repoPath, 'package-lock.json'))) findings.packageManagers.push('npm');
|
|
56
|
+
if (existsSync(join(repoPath, 'bun.lockb')) || existsSync(join(repoPath, 'bun.lock')))
|
|
57
|
+
findings.packageManagers.push('bun');
|
|
58
|
+
if (existsSync(join(repoPath, 'uv.lock'))) findings.packageManagers.push('uv');
|
|
59
|
+
if (existsSync(join(repoPath, 'poetry.lock'))) findings.packageManagers.push('poetry');
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
existsSync(join(repoPath, 'pnpm-workspace.yaml')) ||
|
|
63
|
+
existsSync(join(repoPath, 'turbo.json')) ||
|
|
64
|
+
existsSync(join(repoPath, 'lerna.json')) ||
|
|
65
|
+
(existsSync(join(repoPath, 'packages')) && safeIsDir(join(repoPath, 'packages'))) ||
|
|
66
|
+
(existsSync(join(repoPath, 'apps')) && safeIsDir(join(repoPath, 'apps')))
|
|
67
|
+
) {
|
|
68
|
+
findings.isMonorepo = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const candidate of [
|
|
72
|
+
'next.config.ts',
|
|
73
|
+
'next.config.js',
|
|
74
|
+
'next.config.mjs',
|
|
75
|
+
'nuxt.config.ts',
|
|
76
|
+
'nuxt.config.js',
|
|
77
|
+
'vite.config.ts',
|
|
78
|
+
'vite.config.js',
|
|
79
|
+
'astro.config.mjs',
|
|
80
|
+
'remix.config.js',
|
|
81
|
+
'svelte.config.js',
|
|
82
|
+
'angular.json',
|
|
83
|
+
'manage.py',
|
|
84
|
+
'mix.exs',
|
|
85
|
+
]) {
|
|
86
|
+
if (existsSync(join(repoPath, candidate))) findings.frameworks.push(candidate);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeIsDir(p) {
|
|
93
|
+
try {
|
|
94
|
+
return statSync(p).isDirectory();
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listTopLevel(repoPath, depth = 2) {
|
|
101
|
+
const entries = [];
|
|
102
|
+
walk(repoPath, '', depth, entries);
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function walk(root, rel, depth, acc) {
|
|
107
|
+
if (depth < 0) return;
|
|
108
|
+
let items;
|
|
109
|
+
try {
|
|
110
|
+
items = readdirSync(join(root, rel), { withFileTypes: true });
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const it of items) {
|
|
115
|
+
if (it.name.startsWith('.git')) continue;
|
|
116
|
+
if (it.name === 'node_modules' || it.name === '.next' || it.name === 'dist' || it.name === 'build') continue;
|
|
117
|
+
const sub = rel ? `${rel}/${it.name}` : it.name;
|
|
118
|
+
acc.push({ path: sub, dir: it.isDirectory() });
|
|
119
|
+
if (it.isDirectory()) walk(root, sub, depth - 1, acc);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/core/fs.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function ensureDir(path) {
|
|
6
|
+
await mkdir(path, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeIfChanged(path, contents, { dryRun = false, force = false } = {}) {
|
|
10
|
+
if (existsSync(path)) {
|
|
11
|
+
if (!force) {
|
|
12
|
+
const existing = await readFile(path, 'utf8');
|
|
13
|
+
if (existing === contents) return { wrote: false, reason: 'unchanged', path };
|
|
14
|
+
return { wrote: false, reason: 'exists', path };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (dryRun) return { wrote: true, dryRun: true, path };
|
|
18
|
+
await ensureDir(dirname(path));
|
|
19
|
+
await writeFile(path, contents, 'utf8');
|
|
20
|
+
return { wrote: true, path };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeAlways(path, contents, { dryRun = false } = {}) {
|
|
24
|
+
if (dryRun) return { wrote: true, dryRun: true, path };
|
|
25
|
+
await ensureDir(dirname(path));
|
|
26
|
+
await writeFile(path, contents, 'utf8');
|
|
27
|
+
return { wrote: true, path };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readIfExists(path) {
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
return readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function pathExists(path) {
|
|
36
|
+
try {
|
|
37
|
+
await stat(path);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// License verification against https://cofoundr.ai/api/licenses/verify.
|
|
2
|
+
//
|
|
3
|
+
// Resolution order (caller picks):
|
|
4
|
+
// 1. --license <key> flag
|
|
5
|
+
// 2. COFOUNDR_LICENSE_KEY env var
|
|
6
|
+
// 3. cached key from ~/.cofoundr/license-cache.json
|
|
7
|
+
// 4. interactive prompt
|
|
8
|
+
//
|
|
9
|
+
// Cache: stores { key, plan, email, verifiedAt }. Within CACHE_TTL_MS of
|
|
10
|
+
// verifiedAt we short-circuit and trust the cached "valid" verdict. Past
|
|
11
|
+
// that, we re-hit the server. The key itself is never re-prompted as long
|
|
12
|
+
// as the cache file exists — even if the server is unreachable.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
const KEY_PATTERN = /^csk_live_[A-Za-z0-9_-]{32}$/;
|
|
19
|
+
const DEFAULT_ENDPOINT = 'https://cofoundr.ai';
|
|
20
|
+
const CACHE_DIR = join(homedir(), '.cofoundr');
|
|
21
|
+
const CACHE_PATH = join(CACHE_DIR, 'license-cache.json');
|
|
22
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
23
|
+
|
|
24
|
+
export const PURCHASE_URL = 'https://cofoundr.ai/starter';
|
|
25
|
+
export const RECOVERY_URL = 'https://cofoundr.ai/account/licenses';
|
|
26
|
+
|
|
27
|
+
export function isValidLicenseFormat(key) {
|
|
28
|
+
return typeof key === 'string' && KEY_PATTERN.test(key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function endpoint() {
|
|
32
|
+
return process.env.COFOUNDR_LICENSE_ENDPOINT || DEFAULT_ENDPOINT;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readCache() {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
38
|
+
const raw = readFileSync(CACHE_PATH, 'utf8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (typeof parsed?.key !== 'string') return null;
|
|
41
|
+
return parsed;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeCache(payload) {
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
|
|
50
|
+
writeFileSync(CACHE_PATH, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
51
|
+
chmodSync(CACHE_PATH, 0o600);
|
|
52
|
+
} catch {
|
|
53
|
+
// Cache write failure is non-fatal — verification still succeeded for this run.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getCachedLicense() {
|
|
58
|
+
return readCache();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearCache() {
|
|
62
|
+
try {
|
|
63
|
+
if (existsSync(CACHE_PATH)) writeFileSync(CACHE_PATH, JSON.stringify({}), { mode: 0o600 });
|
|
64
|
+
} catch {
|
|
65
|
+
// No-op.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @param {{ fetchImpl?: typeof fetch, skipCache?: boolean }} [opts]
|
|
72
|
+
* @returns {Promise<{ valid: true, plan: string, email: string, source: 'cache'|'server' }
|
|
73
|
+
* | { valid: false, reason: 'malformed'|'not_found'|'not_authorized'|'refunded'|'revoked'|'network' }>}
|
|
74
|
+
*/
|
|
75
|
+
export async function verifyLicense(key, opts = {}) {
|
|
76
|
+
if (!isValidLicenseFormat(key)) {
|
|
77
|
+
return { valid: false, reason: 'malformed' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!opts.skipCache) {
|
|
81
|
+
const cached = readCache();
|
|
82
|
+
if (
|
|
83
|
+
cached &&
|
|
84
|
+
cached.key === key &&
|
|
85
|
+
typeof cached.verifiedAt === 'number' &&
|
|
86
|
+
Date.now() - cached.verifiedAt < CACHE_TTL_MS
|
|
87
|
+
) {
|
|
88
|
+
return {
|
|
89
|
+
valid: true,
|
|
90
|
+
plan: cached.plan || 'starter_kit',
|
|
91
|
+
email: cached.email || '',
|
|
92
|
+
source: 'cache',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
98
|
+
if (typeof fetchImpl !== 'function') {
|
|
99
|
+
// No fetch available (very old Node) — fall through to cache-or-fail.
|
|
100
|
+
const cached = readCache();
|
|
101
|
+
if (cached && cached.key === key) {
|
|
102
|
+
return { valid: true, plan: cached.plan || 'starter_kit', email: cached.email || '', source: 'cache' };
|
|
103
|
+
}
|
|
104
|
+
return { valid: false, reason: 'network' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let res;
|
|
108
|
+
try {
|
|
109
|
+
res = await fetchImpl(`${endpoint()}/api/licenses/verify`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ license: key }),
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// Network failure — fall back to cache if we have any record of this key.
|
|
116
|
+
const cached = readCache();
|
|
117
|
+
if (cached && cached.key === key) {
|
|
118
|
+
return { valid: true, plan: cached.plan || 'starter_kit', email: cached.email || '', source: 'cache' };
|
|
119
|
+
}
|
|
120
|
+
return { valid: false, reason: 'network' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
if (res.status === 429) return { valid: false, reason: 'network' };
|
|
125
|
+
return { valid: false, reason: 'network' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let body;
|
|
129
|
+
try {
|
|
130
|
+
body = await res.json();
|
|
131
|
+
} catch {
|
|
132
|
+
return { valid: false, reason: 'network' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (body?.valid === true) {
|
|
136
|
+
writeCache({
|
|
137
|
+
key,
|
|
138
|
+
plan: body.plan,
|
|
139
|
+
email: body.email,
|
|
140
|
+
verifiedAt: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
return { valid: true, plan: body.plan, email: body.email, source: 'server' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Server-flagged invalid: clear cache for this key so subsequent runs don't
|
|
146
|
+
// resurrect a revoked key from an old cache entry.
|
|
147
|
+
const cached = readCache();
|
|
148
|
+
if (cached?.key === key) clearCache();
|
|
149
|
+
|
|
150
|
+
return { valid: false, reason: body?.reason || 'not_found' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function reasonMessage(reason) {
|
|
154
|
+
switch (reason) {
|
|
155
|
+
case 'malformed':
|
|
156
|
+
return 'The license key format is invalid. Expected: csk_live_<32 chars>.';
|
|
157
|
+
case 'not_found':
|
|
158
|
+
return 'This license key is not recognised. Double-check the key, or recover it at ' + RECOVERY_URL + '.';
|
|
159
|
+
case 'not_authorized':
|
|
160
|
+
return 'This key is not authorized for the Starter Kit. Buy at ' + PURCHASE_URL + '.';
|
|
161
|
+
case 'refunded':
|
|
162
|
+
return 'This license was refunded and is no longer active.';
|
|
163
|
+
case 'revoked':
|
|
164
|
+
return 'This license has been revoked. Contact contact@cofoundr.ai if you think this is a mistake.';
|
|
165
|
+
case 'network':
|
|
166
|
+
return 'Could not reach the license server. Check your connection and try again.';
|
|
167
|
+
default:
|
|
168
|
+
return 'License verification failed.';
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/core/log.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Zero-dep ANSI helpers. Honors NO_COLOR.
|
|
2
|
+
|
|
3
|
+
const enabled = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
4
|
+
|
|
5
|
+
function wrap(open, close) {
|
|
6
|
+
return (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const c = {
|
|
10
|
+
bold: wrap(1, 22),
|
|
11
|
+
dim: wrap(2, 22),
|
|
12
|
+
red: wrap(31, 39),
|
|
13
|
+
green: wrap(32, 39),
|
|
14
|
+
yellow: wrap(33, 39),
|
|
15
|
+
blue: wrap(34, 39),
|
|
16
|
+
magenta: wrap(35, 39),
|
|
17
|
+
cyan: wrap(36, 39),
|
|
18
|
+
gray: wrap(90, 39),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const log = {
|
|
22
|
+
info: (msg) => process.stdout.write(`${msg}\n`),
|
|
23
|
+
ok: (msg) => process.stdout.write(`${c.green('✓')} ${msg}\n`),
|
|
24
|
+
warn: (msg) => process.stdout.write(`${c.yellow('!')} ${msg}\n`),
|
|
25
|
+
err: (msg) => process.stderr.write(`${c.red('✗')} ${msg}\n`),
|
|
26
|
+
step: (msg) => process.stdout.write(`${c.cyan('›')} ${msg}\n`),
|
|
27
|
+
list: (msg) => process.stdout.write(` ${c.dim('•')} ${msg}\n`),
|
|
28
|
+
blank: () => process.stdout.write('\n'),
|
|
29
|
+
debug: (msg) => {
|
|
30
|
+
if (process.env.COFOUNDR_DEBUG) process.stderr.write(`${c.gray('[debug]')} ${msg}\n`);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Tiny zero-dep prompt helpers built on readline/promises.
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
5
|
+
import { c } from './log.mjs';
|
|
6
|
+
|
|
7
|
+
export async function confirm(question, { defaultYes = true } = {}) {
|
|
8
|
+
if (!input.isTTY) return defaultYes;
|
|
9
|
+
const rl = createInterface({ input, output });
|
|
10
|
+
try {
|
|
11
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
12
|
+
const ans = (await rl.question(`${c.cyan('?')} ${question} ${c.dim(`(${hint}) `)}`)).trim().toLowerCase();
|
|
13
|
+
if (!ans) return defaultYes;
|
|
14
|
+
return ans === 'y' || ans === 'yes';
|
|
15
|
+
} finally {
|
|
16
|
+
rl.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function text(question, { defaultValue = '' } = {}) {
|
|
21
|
+
if (!input.isTTY) return defaultValue;
|
|
22
|
+
const rl = createInterface({ input, output });
|
|
23
|
+
try {
|
|
24
|
+
const hint = defaultValue ? c.dim(` (${defaultValue})`) : '';
|
|
25
|
+
const ans = (await rl.question(`${c.cyan('?')} ${question}${hint} `)).trim();
|
|
26
|
+
return ans || defaultValue;
|
|
27
|
+
} finally {
|
|
28
|
+
rl.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Multi-select via space-separated indices or "all" / "none".
|
|
34
|
+
* Returns an array of ids in the original order.
|
|
35
|
+
*/
|
|
36
|
+
export async function multiSelect(question, items, { defaults = [] } = {}) {
|
|
37
|
+
if (!input.isTTY) return defaults;
|
|
38
|
+
process.stdout.write(`${c.cyan('?')} ${question}\n`);
|
|
39
|
+
items.forEach((it, idx) => {
|
|
40
|
+
const marker = defaults.includes(it.id) ? c.green('✓') : ' ';
|
|
41
|
+
const sub = it.note ? c.dim(` — ${it.note}`) : '';
|
|
42
|
+
process.stdout.write(` ${marker} ${c.bold(String(idx + 1).padStart(2, ' '))}. ${it.label}${sub}\n`);
|
|
43
|
+
});
|
|
44
|
+
process.stdout.write(
|
|
45
|
+
`${c.dim(' Enter numbers separated by space (e.g. "1 3 5"), "all", "none", or just press Enter for the defaults.')}\n`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const rl = createInterface({ input, output });
|
|
49
|
+
try {
|
|
50
|
+
const raw = (await rl.question(' > ')).trim().toLowerCase();
|
|
51
|
+
if (!raw) return defaults;
|
|
52
|
+
if (raw === 'all') return items.map((it) => it.id);
|
|
53
|
+
if (raw === 'none') return [];
|
|
54
|
+
const idxs = raw
|
|
55
|
+
.split(/[,\s]+/)
|
|
56
|
+
.map((s) => parseInt(s, 10) - 1)
|
|
57
|
+
.filter((n) => !Number.isNaN(n) && n >= 0 && n < items.length);
|
|
58
|
+
return idxs.map((i) => items[i].id);
|
|
59
|
+
} finally {
|
|
60
|
+
rl.close();
|
|
61
|
+
}
|
|
62
|
+
}
|