open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { existsSync, writeFileSync } from 'node:fs';
5import { execSync } from 'node:child_process';
6import { join } from 'node:path';
7import { toBeacon } from '../lib/beacon.js';
8import { vitDir, readProjectConfig, writeProjectConfig } from '../lib/vit-dir.js';
9import { requireAgent } from '../lib/agent.js';
10import { mark, name, DOT_VIT_README } from '../lib/brand.js';
11import { jsonOk, jsonError } from '../lib/json-output.js';
12import { errorMessage, formatError } from '../lib/error-format.js';
13
14export default function register(program) {
15 program
16 .command('init')
17 .description('Initialize .vit directory and set project beacon. Use the most official upstream or well-known git URL so all contributors converge on the same beacon.')
18 .option('--beacon <url>', 'Git URL (or "." to read from git remote upstream/origin) to derive the beacon URI')
19 .option('--secondary <url>', 'Secondary beacon URL for upstream cap discovery')
20 .option('--json', 'Output as JSON')
21 .option('-v, --verbose', 'Show step-by-step details')
22 .action(async (opts) => {
23 try {
24 const gate = requireAgent();
25 if (!gate.ok) {
26 if (opts.json) {
27 jsonError('agent required', 'run vit init from a coding agent');
28 return;
29 }
30 console.error(`${name} init should be run by a coding agent (e.g. claude code, gemini cli).`);
31 console.error(`open your agent and ask it to run '${name} init' for you.`);
32 process.exitCode = 1;
33 return;
34 }
35
36 const { verbose } = opts;
37 const vlog = opts.json ? (...a) => console.error(...a) : console.log;
38 const dir = vitDir();
39 if (verbose) vlog(`[verbose] .vit dir: ${dir}`);
40
41 if (!opts.beacon && !opts.secondary) {
42 const config = readProjectConfig();
43 if (config.beacon) {
44 if (opts.json) {
45 const out = { beacon: config.beacon };
46 if (config.secondaryBeacon) out.secondaryBeacon = config.secondaryBeacon;
47 jsonOk(out);
48 return;
49 }
50 console.log(`${mark} beacon: ${config.beacon}`);
51 if (config.secondaryBeacon) {
52 console.log(`${mark} secondary beacon: ${config.secondaryBeacon}`);
53 }
54 console.log(`hint: to change the beacon, run: ${name} init --beacon <git-url>`);
55 return;
56 }
57
58 let isGitRepo = false;
59 try {
60 execSync('git rev-parse --is-inside-work-tree', {
61 encoding: 'utf-8',
62 stdio: ['pipe', 'pipe', 'pipe'],
63 });
64 isGitRepo = true;
65 } catch {}
66 if (verbose) vlog(`[verbose] in git repo: ${isGitRepo ? 'yes' : 'no'}`);
67
68 const hasVitDir = existsSync(dir);
69 if (!isGitRepo) {
70 let remotes = [];
71 if (opts.json) {
72 jsonOk({
73 status: hasVitDir ? 'no beacon' : 'not initialized',
74 git: false,
75 remotes,
76 });
77 return;
78 }
79 console.log(hasVitDir ? 'status: no beacon' : 'status: not initialized');
80 console.log('git: false');
81 if (hasVitDir) {
82 console.log(`hint: run: ${name} init --beacon <canonical-git-url>`);
83 } else {
84 console.log(`hint: navigate to a git repository and run '${name} init' there.`);
85 console.log(` to start fresh: git init && git remote add origin <your-repo-url>`);
86 }
87 return;
88 }
89
90 console.log(hasVitDir ? 'status: no beacon' : 'status: not initialized');
91 console.log('git: true');
92
93 let remoteNames = [];
94 try {
95 remoteNames = execSync('git remote', {
96 encoding: 'utf-8',
97 stdio: ['pipe', 'pipe', 'pipe'],
98 })
99 .trim()
100 .split('\n')
101 .filter(Boolean);
102 } catch (err) {
103 console.warn(`warning: failed to list git remotes: ${errorMessage(err)}`);
104 remoteNames = [];
105 }
106 if (verbose) vlog(`[verbose] remotes detected: ${remoteNames.length > 0 ? remoteNames.join(', ') : 'none'}`);
107
108 const remotes = [];
109 for (const name of remoteNames) {
110 try {
111 const url = execSync(`git config --get remote.${name}.url`, {
112 encoding: 'utf-8',
113 stdio: ['pipe', 'pipe', 'pipe'],
114 }).trim();
115 if (url) remotes.push({ name, url });
116 } catch (err) {
117 console.warn(`warning: failed to read git remote ${name} url: ${errorMessage(err)}`);
118 }
119 }
120 if (verbose && remotes.length > 0) {
121 vlog(`[verbose] remote urls: ${remotes.map(r => `${r.name}=${r.url}`).join(' ')}`);
122 }
123
124 if (opts.json) {
125 jsonOk({
126 status: hasVitDir ? 'no beacon' : 'not initialized',
127 git: true,
128 remotes: remotes.map(r => ({ name: r.name, url: r.url })),
129 });
130 return;
131 }
132
133 const remotesDisplay = remotes.length > 0
134 ? remotes.map(remote => `${remote.name}=${remote.url}`).join(' ')
135 : 'none';
136 console.log(`remotes: ${remotesDisplay}`);
137
138 const upstream = remotes.find(remote => remote.name === 'upstream');
139 const origin = remotes.find(remote => remote.name === 'origin');
140 if (upstream) {
141 console.log('hint: detected upstream remote. upstream points to the canonical repo.');
142 console.log(`hint: run: ${name} init --beacon ${upstream.url}`);
143 } else if (origin) {
144 console.log(`hint: run: ${name} init --beacon ${origin.url}`);
145 } else {
146 console.log(`hint: no git remotes found. run: ${name} init --beacon <canonical-git-url>`);
147 }
148 return;
149 }
150
151 if (opts.secondary && !opts.beacon) {
152 const config = readProjectConfig();
153 if (!config.beacon) {
154 if (opts.json) {
155 jsonError("no primary beacon set — run 'vit init --beacon <url>' first");
156 return;
157 }
158 console.error("no primary beacon set — run 'vit init --beacon <url>' first");
159 process.exitCode = 1;
160 return;
161 }
162
163 const secondary = 'vit:' + toBeacon(opts.secondary);
164 const merged = { ...config, secondaryBeacon: secondary };
165 writeProjectConfig(merged);
166 if (opts.json) {
167 jsonOk({ beacon: merged.beacon, secondaryBeacon: merged.secondaryBeacon });
168 return;
169 }
170 console.log(`${mark} beacon: ${merged.beacon}`);
171 console.log(`${mark} secondary beacon: ${merged.secondaryBeacon}`);
172 return;
173 }
174
175 let gitUrl = opts.beacon;
176 if (gitUrl === '.') {
177 if (verbose) vlog('[verbose] resolving --beacon . via remote.upstream.url then remote.origin.url');
178 let usedRemote = '';
179 try {
180 gitUrl = execSync('git config --get remote.upstream.url', {
181 encoding: 'utf-8',
182 stdio: ['pipe', 'pipe', 'pipe'],
183 }).trim();
184 if (gitUrl) usedRemote = 'upstream';
185 } catch {
186 gitUrl = '';
187 }
188
189 if (!gitUrl) {
190 try {
191 gitUrl = execSync('git config --get remote.origin.url', {
192 encoding: 'utf-8',
193 stdio: ['pipe', 'pipe', 'pipe'],
194 }).trim();
195 if (gitUrl) usedRemote = 'origin';
196 } catch {
197 gitUrl = '';
198 }
199 }
200
201 if (!gitUrl) {
202 if (opts.json) {
203 jsonError('no git remote found', 'set a remote or provide a git URL directly');
204 return;
205 }
206 console.error('No git remote found. Set a remote or provide a git URL directly.');
207 process.exitCode = 1;
208 return;
209 }
210 if (verbose) vlog(`[verbose] Read git remote ${usedRemote}: ${gitUrl}`);
211 }
212
213 const beacon = 'vit:' + toBeacon(gitUrl);
214 if (verbose) vlog(`[verbose] Computed beacon: ${beacon}`);
215 const existing = readProjectConfig();
216 const merged = { ...existing, beacon };
217 if (opts.secondary) {
218 merged.secondaryBeacon = 'vit:' + toBeacon(opts.secondary);
219 }
220 writeProjectConfig(merged);
221 if (verbose) vlog(`[verbose] Wrote config.json`);
222 const readmePath = join(vitDir(), 'README.md');
223 if (!existsSync(readmePath)) {
224 writeFileSync(readmePath, DOT_VIT_README);
225 if (verbose) vlog(`[verbose] Wrote .vit/README.md`);
226 }
227 if (opts.json) {
228 const out = { beacon: merged.beacon };
229 if (merged.secondaryBeacon) out.secondaryBeacon = merged.secondaryBeacon;
230 jsonOk(out);
231 return;
232 }
233 console.log(`${mark} beacon: ${beacon}`);
234 if (merged.secondaryBeacon) {
235 console.log(`${mark} secondary beacon: ${merged.secondaryBeacon}`);
236 }
237 } catch (err) {
238 if (opts.json) {
239 jsonError(err);
240 return;
241 }
242 console.error(formatError(err, { verbose: opts.verbose }));
243 process.exitCode = 1;
244 }
245 });
246}