@vibecompass/vibecompass 0.1.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/LICENSE +21 -0
- package/README.md +329 -0
- package/package.json +42 -0
- package/src/cli.js +572 -0
- package/src/frontmatter.js +28 -0
- package/src/generators/agent-files/agents-md.js +13 -0
- package/src/generators/agent-files/claude-md.js +13 -0
- package/src/generators/agent-files/copilot-instructions.js +13 -0
- package/src/generators/agent-files/cursor-rules.js +13 -0
- package/src/generators/agent-files/index.js +147 -0
- package/src/generators/agent-files/markers.js +49 -0
- package/src/generators/agent-files/template.js +75 -0
- package/src/hash.js +29 -0
- package/src/index.js +15 -0
- package/src/init.js +227 -0
- package/src/manifest.js +75 -0
- package/src/project-memory.js +678 -0
- package/src/project-yaml.js +107 -0
- package/src/read-model.js +557 -0
- package/src/scaffold.js +404 -0
- package/src/session.js +553 -0
- package/src/setup.js +719 -0
- package/src/simple-yaml.js +227 -0
- package/src/workflow.js +120 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
|
|
5
|
+
export const PLACEMENT_PATTERNS = {
|
|
6
|
+
WORKSPACE_ROOT: 'workspace-root',
|
|
7
|
+
DEDICATED_MEMORY_REPO: 'dedicated-memory-repo',
|
|
8
|
+
PRIMARY_REPO: 'primary-repo',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const VALID_PLACEMENT_PATTERNS = new Set(Object.values(PLACEMENT_PATTERNS));
|
|
12
|
+
|
|
13
|
+
export async function resolveInitCliOptions(options, environment = {}) {
|
|
14
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
15
|
+
let resolved = cloneInitOptions(options);
|
|
16
|
+
let guidedSummary = null;
|
|
17
|
+
|
|
18
|
+
if (resolved.guided) {
|
|
19
|
+
const prompter = createPrompter(environment.io, environment.runtime);
|
|
20
|
+
try {
|
|
21
|
+
const guided = await completeGuidedInitOptions(resolved, {
|
|
22
|
+
cwd,
|
|
23
|
+
prompter,
|
|
24
|
+
});
|
|
25
|
+
resolved = guided.options;
|
|
26
|
+
guidedSummary = guided.summary;
|
|
27
|
+
} finally {
|
|
28
|
+
await prompter.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
resolved = applyPlacementDefaults(resolved, { cwd });
|
|
33
|
+
ensureSessionBootstrap(resolved);
|
|
34
|
+
|
|
35
|
+
if (resolved.closeSessionGitRemote && resolved.closeSessionGitPublish === undefined) {
|
|
36
|
+
resolved.closeSessionGitPublish = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (resolved.startSession && !resolved.sessionWorkingOn) {
|
|
40
|
+
throw new Error('init requires --session-working-on when --start-session is used without guided setup.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
initOptions: {
|
|
45
|
+
rootDir: resolved.rootDir,
|
|
46
|
+
toolingRootDir: resolved.toolingRootDir,
|
|
47
|
+
name: resolved.name,
|
|
48
|
+
slug: resolved.slug,
|
|
49
|
+
description: resolved.description,
|
|
50
|
+
mode: resolved.mode,
|
|
51
|
+
repos: resolved.repos,
|
|
52
|
+
force: resolved.force,
|
|
53
|
+
sync: resolved.sync,
|
|
54
|
+
bootstrap: resolved.bootstrap,
|
|
55
|
+
generatedAt: resolved.generatedAt,
|
|
56
|
+
metadata: resolved.metadata,
|
|
57
|
+
closeSessionGitPublish: resolved.closeSessionGitPublish,
|
|
58
|
+
closeSessionGitRemote: resolved.closeSessionGitRemote,
|
|
59
|
+
placementPattern: resolved.placementPattern,
|
|
60
|
+
},
|
|
61
|
+
placementPattern: resolved.placementPattern ?? null,
|
|
62
|
+
guidedSummary,
|
|
63
|
+
sessionPlan: resolved.startSession
|
|
64
|
+
? {
|
|
65
|
+
workingOn: resolved.sessionWorkingOn,
|
|
66
|
+
}
|
|
67
|
+
: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyPlacementDefaults(options, environment = {}) {
|
|
72
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
73
|
+
const resolved = cloneInitOptions(options);
|
|
74
|
+
|
|
75
|
+
if (resolved.placementPattern && !VALID_PLACEMENT_PATTERNS.has(resolved.placementPattern)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`init placement must be one of ${[...VALID_PLACEMENT_PATTERNS].join(', ')}.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!resolved.placementPattern) {
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ownerDir = resolved.toolingRootDir ?? '.';
|
|
86
|
+
if (!resolved.rootDir) {
|
|
87
|
+
resolved.rootDir =
|
|
88
|
+
resolved.placementPattern === PLACEMENT_PATTERNS.DEDICATED_MEMORY_REPO
|
|
89
|
+
? ownerDir
|
|
90
|
+
: path.join(ownerDir, '.compass');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!resolved.toolingRootDir) {
|
|
94
|
+
resolved.toolingRootDir = ownerDir;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const existingMetadata = isPlainObject(resolved.metadata) ? resolved.metadata : {};
|
|
98
|
+
const existingSetup = isPlainObject(existingMetadata.setup) ? existingMetadata.setup : {};
|
|
99
|
+
resolved.metadata = {
|
|
100
|
+
...existingMetadata,
|
|
101
|
+
setup: {
|
|
102
|
+
...existingSetup,
|
|
103
|
+
placement_pattern: resolved.placementPattern,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Keep relative paths relative to the current CLI cwd; initializeProjectMemory resolves them later.
|
|
108
|
+
if (resolved.rootDir) {
|
|
109
|
+
resolved.rootDir = toRelativeIfInsideCwd(resolved.rootDir, cwd);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (resolved.toolingRootDir) {
|
|
113
|
+
resolved.toolingRootDir = toRelativeIfInsideCwd(resolved.toolingRootDir, cwd);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function recommendPlacementPattern(context) {
|
|
120
|
+
if (context.repoCount <= 1) {
|
|
121
|
+
return {
|
|
122
|
+
pattern: PLACEMENT_PATTERNS.PRIMARY_REPO,
|
|
123
|
+
reason: 'Single-repo projects usually fit best when project memory lives alongside the primary codebase.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (context.hasPrimaryRepo) {
|
|
128
|
+
return {
|
|
129
|
+
pattern: PLACEMENT_PATTERNS.PRIMARY_REPO,
|
|
130
|
+
reason: 'One repo clearly owns the product center of gravity, so colocating project memory there is the best default.',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (context.usesSharedWorkspace) {
|
|
135
|
+
return {
|
|
136
|
+
pattern: PLACEMENT_PATTERNS.WORKSPACE_ROOT,
|
|
137
|
+
reason: 'You already work from a shared multi-repo checkout, so a workspace-root `.compass/` keeps the shared memory close to that workflow.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
pattern: PLACEMENT_PATTERNS.DEDICATED_MEMORY_REPO,
|
|
143
|
+
reason: 'No single repo owns the product and there is no shared checkout to anchor on, so a dedicated memory repo is the safest default.',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function completeGuidedInitOptions(options, environment) {
|
|
148
|
+
let resolved = cloneInitOptions(options);
|
|
149
|
+
const cwd = environment.cwd;
|
|
150
|
+
const prompter = environment.prompter;
|
|
151
|
+
const projectNameDefault = path.basename(cwd);
|
|
152
|
+
|
|
153
|
+
if (!resolved.name) {
|
|
154
|
+
resolved.name = await askInput(prompter, 'Project name', {
|
|
155
|
+
defaultValue: projectNameDefault,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!resolved.mode) {
|
|
160
|
+
resolved.mode = await askChoice(prompter, 'Project mode', [
|
|
161
|
+
{
|
|
162
|
+
value: 'local-only',
|
|
163
|
+
description: 'Local files only. No hosted dependency.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
value: 'local-primary',
|
|
167
|
+
description: 'Local files stay authoritative, with optional hosted sync/projection.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
value: 'hosted-only',
|
|
171
|
+
description: 'Compatibility mode when hosted state remains authoritative.',
|
|
172
|
+
},
|
|
173
|
+
], {
|
|
174
|
+
defaultValue: 'local-only',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!Array.isArray(resolved.repos) || resolved.repos.length === 0) {
|
|
179
|
+
resolved.repos = await promptForRepos(prompter, cwd);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let recommendation = null;
|
|
183
|
+
if (!resolved.placementPattern) {
|
|
184
|
+
recommendation = await promptForPlacement(prompter, {
|
|
185
|
+
cwd,
|
|
186
|
+
repoCount: resolved.repos.length,
|
|
187
|
+
});
|
|
188
|
+
resolved.placementPattern = recommendation.pattern;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!resolved.toolingRootDir) {
|
|
192
|
+
const ownerLabel = describePlacementOwner(resolved.placementPattern);
|
|
193
|
+
const defaultOwnerDir = await suggestOwnerDirectory({
|
|
194
|
+
cwd,
|
|
195
|
+
placementPattern: resolved.placementPattern,
|
|
196
|
+
repos: resolved.repos,
|
|
197
|
+
});
|
|
198
|
+
resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
|
|
199
|
+
defaultValue: defaultOwnerDir,
|
|
200
|
+
validate: async (value) => {
|
|
201
|
+
if (!(await directoryExists(path.resolve(cwd, value)))) {
|
|
202
|
+
return `${ownerLabel} must already exist relative to the current working directory.`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return null;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resolved = applyPlacementDefaults(resolved, { cwd });
|
|
211
|
+
|
|
212
|
+
if (!resolved.sync && resolved.mode === 'local-primary') {
|
|
213
|
+
const configureSync = await askConfirm(
|
|
214
|
+
prompter,
|
|
215
|
+
'Configure hosted sync binding now?',
|
|
216
|
+
false,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (configureSync) {
|
|
220
|
+
resolved.sync = {
|
|
221
|
+
apiUrl: await askInput(prompter, 'Hosted sync API URL', {
|
|
222
|
+
defaultValue: 'https://vibecompass.dev',
|
|
223
|
+
}),
|
|
224
|
+
projectId: await askInput(prompter, 'Hosted project id'),
|
|
225
|
+
credentialEnvVar: await askInput(prompter, 'Credential env var', {
|
|
226
|
+
defaultValue: 'VIBECOMPASS_SYNC_TOKEN',
|
|
227
|
+
}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!resolved.bootstrap) {
|
|
233
|
+
resolved.bootstrap = {};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
resolved.bootstrap.workflow === undefined &&
|
|
238
|
+
resolved.bootstrap.claude === undefined &&
|
|
239
|
+
resolved.bootstrap.agents === undefined
|
|
240
|
+
) {
|
|
241
|
+
const wantsWorkflow = await askConfirm(
|
|
242
|
+
prompter,
|
|
243
|
+
'Scaffold workflow files (context.md plus guide READMEs)?',
|
|
244
|
+
true,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (wantsWorkflow) {
|
|
248
|
+
resolved.bootstrap.workflow = true;
|
|
249
|
+
resolved.bootstrap.claude = await askConfirm(
|
|
250
|
+
prompter,
|
|
251
|
+
'Create a starter CLAUDE.md if missing?',
|
|
252
|
+
true,
|
|
253
|
+
);
|
|
254
|
+
resolved.bootstrap.agents = await askConfirm(
|
|
255
|
+
prompter,
|
|
256
|
+
'Create a starter AGENTS.md if missing?',
|
|
257
|
+
true,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!resolved.startSession) {
|
|
263
|
+
resolved.startSession = await askConfirm(
|
|
264
|
+
prompter,
|
|
265
|
+
'Open the first builder session immediately after init?',
|
|
266
|
+
false,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let autoEnabledSessionBootstrap = false;
|
|
271
|
+
if (resolved.startSession) {
|
|
272
|
+
if (!resolved.bootstrap.workflow || !resolved.bootstrap.claude) {
|
|
273
|
+
resolved.bootstrap.workflow = true;
|
|
274
|
+
resolved.bootstrap.claude = true;
|
|
275
|
+
autoEnabledSessionBootstrap = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!resolved.sessionWorkingOn) {
|
|
279
|
+
resolved.sessionWorkingOn = await askInput(prompter, 'What are you working on?');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (resolved.closeSessionGitPublish === undefined && (resolved.bootstrap.workflow || resolved.startSession)) {
|
|
284
|
+
resolved.closeSessionGitPublish = await askConfirm(
|
|
285
|
+
prompter,
|
|
286
|
+
'Should close-session include a Git publish step?',
|
|
287
|
+
false,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (resolved.closeSessionGitPublish && !resolved.closeSessionGitRemote) {
|
|
292
|
+
resolved.closeSessionGitRemote = await askInput(
|
|
293
|
+
prompter,
|
|
294
|
+
'Git remote for the close-session publish step',
|
|
295
|
+
{
|
|
296
|
+
defaultValue: 'origin',
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
options: resolved,
|
|
303
|
+
summary: {
|
|
304
|
+
recommendedPlacementPattern: recommendation?.pattern ?? null,
|
|
305
|
+
recommendationReason: recommendation?.reason ?? null,
|
|
306
|
+
autoEnabledSessionBootstrap,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function promptForRepos(prompter, cwd) {
|
|
312
|
+
const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
|
|
313
|
+
defaultValue: 1,
|
|
314
|
+
min: 1,
|
|
315
|
+
});
|
|
316
|
+
const repoIdDefault = suggestRepoId(path.basename(cwd));
|
|
317
|
+
const repos = [];
|
|
318
|
+
|
|
319
|
+
for (let index = 0; index < repoCount; index += 1) {
|
|
320
|
+
const ordinal = index + 1;
|
|
321
|
+
const repo = {
|
|
322
|
+
id: await askInput(prompter, `Repo ${ordinal} id`, {
|
|
323
|
+
defaultValue: repoCount === 1 ? repoIdDefault : `repo-${ordinal}`,
|
|
324
|
+
}),
|
|
325
|
+
remote: await askInput(prompter, `Repo ${ordinal} remote`),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const defaultBranch = await askOptionalInput(prompter, `Repo ${ordinal} default branch`);
|
|
329
|
+
if (defaultBranch) {
|
|
330
|
+
repo.defaultBranch = defaultBranch;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
repos.push(repo);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return repos;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function promptForPlacement(prompter, context) {
|
|
340
|
+
let topology = {
|
|
341
|
+
repoCount: context.repoCount,
|
|
342
|
+
usesSharedWorkspace: false,
|
|
343
|
+
hasPrimaryRepo: false,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
if (context.repoCount > 1) {
|
|
347
|
+
topology.usesSharedWorkspace = await askConfirm(
|
|
348
|
+
prompter,
|
|
349
|
+
'Do you already work from a shared multi-repo workspace checkout?',
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
topology.hasPrimaryRepo = await askConfirm(
|
|
353
|
+
prompter,
|
|
354
|
+
'Does one repo clearly act as the product center of gravity?',
|
|
355
|
+
false,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const recommendation = recommendPlacementPattern(topology);
|
|
360
|
+
prompter.write(`Recommended placement: ${recommendation.pattern}\n`);
|
|
361
|
+
prompter.write(`${recommendation.reason}\n`);
|
|
362
|
+
|
|
363
|
+
const useRecommendation = await askConfirm(
|
|
364
|
+
prompter,
|
|
365
|
+
`Use ${recommendation.pattern}?`,
|
|
366
|
+
true,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (useRecommendation) {
|
|
370
|
+
return recommendation;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const overriddenPattern = await askChoice(prompter, 'Choose a placement pattern', [
|
|
374
|
+
{
|
|
375
|
+
value: PLACEMENT_PATTERNS.WORKSPACE_ROOT,
|
|
376
|
+
description: 'Shared multi-repo workspace root with a nested .compass/ directory.',
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
value: PLACEMENT_PATTERNS.DEDICATED_MEMORY_REPO,
|
|
380
|
+
description: 'A dedicated memory repo where canonical project memory lives at repo root.',
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
value: PLACEMENT_PATTERNS.PRIMARY_REPO,
|
|
384
|
+
description: 'One designated primary repo with a nested .compass/ directory.',
|
|
385
|
+
},
|
|
386
|
+
], {
|
|
387
|
+
defaultValue: recommendation.pattern,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
pattern: overriddenPattern,
|
|
392
|
+
reason: recommendation.reason,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function describePlacementOwner(placementPattern) {
|
|
397
|
+
switch (placementPattern) {
|
|
398
|
+
case PLACEMENT_PATTERNS.WORKSPACE_ROOT:
|
|
399
|
+
return 'Directory of the shared workspace root';
|
|
400
|
+
case PLACEMENT_PATTERNS.DEDICATED_MEMORY_REPO:
|
|
401
|
+
return 'Directory of the dedicated memory repo';
|
|
402
|
+
case PLACEMENT_PATTERNS.PRIMARY_REPO:
|
|
403
|
+
return 'Directory of the designated primary repo';
|
|
404
|
+
default:
|
|
405
|
+
return 'Directory that should own the project memory';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function suggestOwnerDirectory(context) {
|
|
410
|
+
if (context.placementPattern === PLACEMENT_PATTERNS.WORKSPACE_ROOT) {
|
|
411
|
+
return '.';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!Array.isArray(context.repos) || context.repos.length !== 1) {
|
|
415
|
+
return '.';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const hints = getRepoDirectoryHints(context.repos[0]);
|
|
419
|
+
if (hints.has(path.basename(context.cwd).toLowerCase())) {
|
|
420
|
+
return '.';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (const hint of hints) {
|
|
424
|
+
if (await directoryExists(path.resolve(context.cwd, hint))) {
|
|
425
|
+
return hint;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return '.';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function ensureSessionBootstrap(options) {
|
|
433
|
+
if (!options.startSession) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!options.bootstrap || typeof options.bootstrap !== 'object') {
|
|
438
|
+
options.bootstrap = {};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!options.bootstrap.workflow || !options.bootstrap.claude) {
|
|
442
|
+
options.bootstrap.workflow = true;
|
|
443
|
+
options.bootstrap.claude = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function createPrompter(io = {}, runtime = {}) {
|
|
448
|
+
if (typeof runtime.prompt === 'function') {
|
|
449
|
+
return {
|
|
450
|
+
async ask(spec) {
|
|
451
|
+
return runtime.prompt(spec);
|
|
452
|
+
},
|
|
453
|
+
write(text) {
|
|
454
|
+
(io.stdout ?? process.stdout).write(text);
|
|
455
|
+
},
|
|
456
|
+
async close() {},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const input = runtime.stdin ?? io.stdin ?? process.stdin;
|
|
461
|
+
const output = io.stdout ?? process.stdout;
|
|
462
|
+
|
|
463
|
+
if (!input?.isTTY) {
|
|
464
|
+
throw new Error('Guided init requires an interactive TTY or a custom prompt adapter.');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const rl = createInterface({ input, output });
|
|
468
|
+
return {
|
|
469
|
+
async ask(spec) {
|
|
470
|
+
const suffix = renderPromptSuffix(spec);
|
|
471
|
+
return rl.question(`${spec.message}${suffix}: `);
|
|
472
|
+
},
|
|
473
|
+
write(text) {
|
|
474
|
+
output.write(text);
|
|
475
|
+
},
|
|
476
|
+
async close() {
|
|
477
|
+
rl.close();
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function askInput(prompter, message, options = {}) {
|
|
483
|
+
while (true) {
|
|
484
|
+
const raw = await prompter.ask({
|
|
485
|
+
type: 'input',
|
|
486
|
+
message,
|
|
487
|
+
defaultValue: options.defaultValue ?? null,
|
|
488
|
+
});
|
|
489
|
+
const value = normalizePromptAnswer(raw, options.defaultValue ?? null);
|
|
490
|
+
|
|
491
|
+
if (!value) {
|
|
492
|
+
prompter.write('A value is required.\n');
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (typeof options.validate === 'function') {
|
|
497
|
+
const validationError = await options.validate(value);
|
|
498
|
+
if (validationError) {
|
|
499
|
+
prompter.write(`${validationError}\n`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function askOptionalInput(prompter, message, options = {}) {
|
|
509
|
+
const raw = await prompter.ask({
|
|
510
|
+
type: 'input',
|
|
511
|
+
message,
|
|
512
|
+
defaultValue: options.defaultValue ?? null,
|
|
513
|
+
});
|
|
514
|
+
return normalizePromptAnswer(raw, options.defaultValue ?? null);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function askConfirm(prompter, message, defaultValue) {
|
|
518
|
+
while (true) {
|
|
519
|
+
const raw = await prompter.ask({
|
|
520
|
+
type: 'confirm',
|
|
521
|
+
message,
|
|
522
|
+
defaultValue,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const normalized = String(raw ?? '').trim().toLowerCase();
|
|
526
|
+
if (!normalized) {
|
|
527
|
+
return defaultValue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (['y', 'yes', 'true'].includes(normalized)) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (['n', 'no', 'false'].includes(normalized)) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
prompter.write('Please answer yes or no.\n');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function askChoice(prompter, message, choices, options = {}) {
|
|
543
|
+
const defaultValue = options.defaultValue ?? choices[0]?.value ?? null;
|
|
544
|
+
prompter.write(
|
|
545
|
+
`${choices
|
|
546
|
+
.map((choice, index) => `${index + 1}. ${choice.value} — ${choice.description}`)
|
|
547
|
+
.join('\n')}\n`,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
while (true) {
|
|
551
|
+
const raw = await prompter.ask({
|
|
552
|
+
type: 'choice',
|
|
553
|
+
message,
|
|
554
|
+
defaultValue,
|
|
555
|
+
choices,
|
|
556
|
+
});
|
|
557
|
+
const normalized = normalizePromptAnswer(raw, defaultValue)?.toLowerCase();
|
|
558
|
+
|
|
559
|
+
if (!normalized) {
|
|
560
|
+
prompter.write('A choice is required.\n');
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const byIndex = Number.parseInt(normalized, 10);
|
|
565
|
+
if (!Number.isNaN(byIndex) && choices[byIndex - 1]) {
|
|
566
|
+
return choices[byIndex - 1].value;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const byValue = choices.find((choice) => choice.value === normalized);
|
|
570
|
+
if (byValue) {
|
|
571
|
+
return byValue.value;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
prompter.write('Please choose one of the listed options.\n');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function askInteger(prompter, message, options = {}) {
|
|
579
|
+
while (true) {
|
|
580
|
+
const raw = await prompter.ask({
|
|
581
|
+
type: 'input',
|
|
582
|
+
message,
|
|
583
|
+
defaultValue: options.defaultValue ?? null,
|
|
584
|
+
});
|
|
585
|
+
const normalized = normalizePromptAnswer(raw, options.defaultValue ?? null);
|
|
586
|
+
const value = Number.parseInt(String(normalized), 10);
|
|
587
|
+
|
|
588
|
+
if (Number.isNaN(value)) {
|
|
589
|
+
prompter.write('Please enter a whole number.\n');
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (options.min !== undefined && value < options.min) {
|
|
594
|
+
prompter.write(`Value must be at least ${options.min}.\n`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderPromptSuffix(spec) {
|
|
603
|
+
if (spec.type === 'confirm') {
|
|
604
|
+
return spec.defaultValue ? ' [Y/n]' : ' [y/N]';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (spec.defaultValue) {
|
|
608
|
+
return ` [${spec.defaultValue}]`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return '';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function normalizePromptAnswer(raw, defaultValue) {
|
|
615
|
+
const trimmed = String(raw ?? '').trim();
|
|
616
|
+
if (!trimmed) {
|
|
617
|
+
if (defaultValue === null || defaultValue === undefined) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return String(defaultValue);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return trimmed;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function cloneInitOptions(options = {}) {
|
|
628
|
+
return {
|
|
629
|
+
...options,
|
|
630
|
+
repos: Array.isArray(options.repos)
|
|
631
|
+
? options.repos.map((repo) => ({ ...repo }))
|
|
632
|
+
: [],
|
|
633
|
+
bootstrap: isPlainObject(options.bootstrap) ? { ...options.bootstrap } : undefined,
|
|
634
|
+
sync: isPlainObject(options.sync) ? { ...options.sync } : undefined,
|
|
635
|
+
metadata: isPlainObject(options.metadata) ? cloneMetadata(options.metadata) : undefined,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function cloneMetadata(metadata) {
|
|
640
|
+
const clone = {};
|
|
641
|
+
|
|
642
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
643
|
+
if (Array.isArray(value)) {
|
|
644
|
+
clone[key] = [...value];
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (isPlainObject(value)) {
|
|
649
|
+
clone[key] = cloneMetadata(value);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
clone[key] = value;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return clone;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function isPlainObject(value) {
|
|
660
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function suggestRepoId(value) {
|
|
664
|
+
const slug = value
|
|
665
|
+
.toLowerCase()
|
|
666
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
667
|
+
.replace(/^-+|-+$/g, '');
|
|
668
|
+
|
|
669
|
+
return slug || 'repo';
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function getRepoDirectoryHints(repo) {
|
|
673
|
+
const hints = new Set();
|
|
674
|
+
|
|
675
|
+
if (typeof repo?.id === 'string' && repo.id.trim()) {
|
|
676
|
+
hints.add(repo.id.trim().toLowerCase());
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (typeof repo?.remote === 'string' && repo.remote.trim()) {
|
|
680
|
+
const remoteHint = extractRemoteSlug(repo.remote.trim());
|
|
681
|
+
if (remoteHint) {
|
|
682
|
+
hints.add(remoteHint);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return hints;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function extractRemoteSlug(remote) {
|
|
690
|
+
const normalized = remote.replace(/\/+$/, '');
|
|
691
|
+
const slashIndex = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf(':'));
|
|
692
|
+
const tail = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
|
|
693
|
+
const slug = tail.replace(/\.git$/i, '').trim().toLowerCase();
|
|
694
|
+
return slug || null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function directoryExists(directoryPath) {
|
|
698
|
+
try {
|
|
699
|
+
await access(directoryPath);
|
|
700
|
+
return true;
|
|
701
|
+
} catch {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function toRelativeIfInsideCwd(targetPath, cwd) {
|
|
707
|
+
const absolute = path.resolve(cwd, targetPath);
|
|
708
|
+
const relative = path.relative(cwd, absolute);
|
|
709
|
+
|
|
710
|
+
if (!relative || relative === '') {
|
|
711
|
+
return '.';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (relative.startsWith('..')) {
|
|
715
|
+
return absolute;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return relative;
|
|
719
|
+
}
|