@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.35
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/dist/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegraph offer hook — Wave 6 BIG TRACK 9 Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* Single integration point used by both `pugi init` (standalone CLI
|
|
5
|
+
* entry) AND the REPL's `/init` slash so the decision logic + telemetry
|
|
6
|
+
* fan-out stays single-sourced. The hook is split into two halves:
|
|
7
|
+
*
|
|
8
|
+
* 1. `evaluateOffer({ workspaceRoot, nowIso })` — pure: decides if
|
|
9
|
+
* we should prompt, returns the detection result + suggested copy
|
|
10
|
+
* so the UI layer can render it however it likes (Y/n prompt in
|
|
11
|
+
* a TTY, JSON envelope in --no-tty mode, system pane line in the
|
|
12
|
+
* REPL).
|
|
13
|
+
* 2. `applyOfferDecision({ workspaceRoot, accepted, … })` — side-
|
|
14
|
+
* effectful: persists the operator's verdict + runs the install
|
|
15
|
+
* if accepted + emits the right telemetry event.
|
|
16
|
+
*
|
|
17
|
+
* Telemetry events emitted (when consent allows):
|
|
18
|
+
* - `codegraph.offer.shown` — every time we surface the prompt
|
|
19
|
+
* - `codegraph.offer.accepted` — operator said yes
|
|
20
|
+
* - `codegraph.offer.declined` — operator said no
|
|
21
|
+
* - `codegraph.install.success` — mcp.json merge succeeded
|
|
22
|
+
* - `codegraph.install.failed` — mcp.json merge failed (rare)
|
|
23
|
+
* - `codegraph.reminder.shown` — cold-start nudge surfaced
|
|
24
|
+
* - `codegraph.stale-index.shown` — index > STALE_INDEX_DAYS
|
|
25
|
+
*
|
|
26
|
+
* All telemetry is best-effort fire-and-forget; emit() never throws.
|
|
27
|
+
*
|
|
28
|
+
* The hook NEVER prompts directly — it has no TTY contract. The
|
|
29
|
+
* caller MUST resolve the operator's verdict OR call `applyOfferDecision`
|
|
30
|
+
* with `accepted: false` to record a decline.
|
|
31
|
+
*/
|
|
32
|
+
import { detectRepo, buildOfferCopy } from './detect-repo.js';
|
|
33
|
+
import { shouldOfferOnInit, recordDecision, readDecision, shouldNudgeStaleIndex, indexAgeDays, } from './decision-store.js';
|
|
34
|
+
import { installCodegraphMcpEntry, detectCodegraphInstalled, CODEGRAPH_DOCS_URL, } from './install.js';
|
|
35
|
+
import { emit } from '../telemetry/emitter.js';
|
|
36
|
+
/**
|
|
37
|
+
* Pure evaluation. Reads detection + decision store. NEVER writes.
|
|
38
|
+
* Reads NEVER throw — corrupt JSON returns "first-run". Tests rely on
|
|
39
|
+
* this so they can drive `evaluateOffer` repeatedly without setup.
|
|
40
|
+
*/
|
|
41
|
+
export function evaluateOffer(input) {
|
|
42
|
+
const detection = detectRepo(input.workspaceRoot);
|
|
43
|
+
if (!detection.isRepo) {
|
|
44
|
+
return { shouldPrompt: false, reason: detection.reason, detection };
|
|
45
|
+
}
|
|
46
|
+
if (!detection.offerCodegraph) {
|
|
47
|
+
return { shouldPrompt: false, reason: 'size-or-language-gate', detection };
|
|
48
|
+
}
|
|
49
|
+
// If codegraph is already declared в mcp.json, skip — the operator
|
|
50
|
+
// already adopted it (maybe via Phase 1 manual install). Cold-start
|
|
51
|
+
// hook covers the stale-index nudge separately.
|
|
52
|
+
const installed = detectCodegraphInstalled(input.workspaceRoot);
|
|
53
|
+
if (installed.installed) {
|
|
54
|
+
return { shouldPrompt: false, reason: 'already-installed', detection };
|
|
55
|
+
}
|
|
56
|
+
if (!input.ignorePriorDecision) {
|
|
57
|
+
const cadence = shouldOfferOnInit(input.workspaceRoot, input.nowIso);
|
|
58
|
+
if (!cadence.shouldOffer) {
|
|
59
|
+
return { shouldPrompt: false, reason: cadence.reason, detection };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
shouldPrompt: true,
|
|
64
|
+
detection: detection,
|
|
65
|
+
promptCopy: buildOfferCopy(detection),
|
|
66
|
+
docsUrl: CODEGRAPH_DOCS_URL,
|
|
67
|
+
reason: 'first-run',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function applyOfferDecision(input) {
|
|
71
|
+
const decision = recordDecision(input.workspaceRoot, {
|
|
72
|
+
accepted: input.accepted,
|
|
73
|
+
...(input.nowIso ? { nowIso: input.nowIso } : {}),
|
|
74
|
+
});
|
|
75
|
+
emitOfferTelemetry(input.accepted ? 'codegraph.offer.accepted' : 'codegraph.offer.declined', {
|
|
76
|
+
sizeCategory: input.detection.sizeCategory,
|
|
77
|
+
primaryLanguage: input.detection.languages[0] ?? 'unknown',
|
|
78
|
+
primarySymbolCount: input.detection.primarySymbolCount,
|
|
79
|
+
});
|
|
80
|
+
if (!input.accepted) {
|
|
81
|
+
return { kind: 'declined', decision };
|
|
82
|
+
}
|
|
83
|
+
const install = installCodegraphMcpEntry(input.workspaceRoot);
|
|
84
|
+
if (install.status === 'failed') {
|
|
85
|
+
emitOfferTelemetry('codegraph.install.failed', {
|
|
86
|
+
reason: install.reason.slice(0, 64),
|
|
87
|
+
});
|
|
88
|
+
return { kind: 'accepted-install-failed', decision, install };
|
|
89
|
+
}
|
|
90
|
+
emitOfferTelemetry('codegraph.install.success', {
|
|
91
|
+
sizeCategory: input.detection.sizeCategory,
|
|
92
|
+
primaryLanguage: input.detection.languages[0] ?? 'unknown',
|
|
93
|
+
alreadyInstalled: install.status === 'already-installed',
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
kind: 'accepted-installed',
|
|
97
|
+
decision,
|
|
98
|
+
install,
|
|
99
|
+
docsUrl: CODEGRAPH_DOCS_URL,
|
|
100
|
+
trustCommand: 'pugi mcp trust codegraph',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Surface the offer telemetry "shown" event. Called by the init flow
|
|
105
|
+
* once it has decided to actually render the prompt (so a `--no-tty`
|
|
106
|
+
* invocation that skipped the prompt does not count as a shown event).
|
|
107
|
+
*/
|
|
108
|
+
export function emitOfferShown(detection) {
|
|
109
|
+
emitOfferTelemetry('codegraph.offer.shown', {
|
|
110
|
+
sizeCategory: detection.sizeCategory,
|
|
111
|
+
primaryLanguage: detection.languages[0] ?? 'unknown',
|
|
112
|
+
primarySymbolCount: detection.primarySymbolCount,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compute the cold-start nudge. Pure read — never writes. The session
|
|
117
|
+
* module decides whether to render the message AND whether to call
|
|
118
|
+
* `markReindexChecked(...)` after the operator dismisses it (so the
|
|
119
|
+
* once-per-day throttle on `shouldNudgeStaleIndex` works).
|
|
120
|
+
*/
|
|
121
|
+
export function evaluateColdStart(input) {
|
|
122
|
+
const detection = detectRepo(input.workspaceRoot);
|
|
123
|
+
if (!detection.isRepo) {
|
|
124
|
+
return { kind: 'silent', reason: detection.reason };
|
|
125
|
+
}
|
|
126
|
+
const decision = readDecision(input.workspaceRoot);
|
|
127
|
+
// Stale-index path takes priority — an accepted operator should be
|
|
128
|
+
// nudged about freshness before a never-asked operator is nudged
|
|
129
|
+
// about installation.
|
|
130
|
+
if (decision && decision.accepted) {
|
|
131
|
+
if (shouldNudgeStaleIndex(decision, input.nowIso)) {
|
|
132
|
+
const age = indexAgeDays(decision, input.nowIso) ?? 0;
|
|
133
|
+
emitOfferTelemetry('codegraph.stale-index.shown', { ageDays: age });
|
|
134
|
+
return {
|
|
135
|
+
kind: 'stale-index',
|
|
136
|
+
ageDays: age,
|
|
137
|
+
message: `Codegraph index is ${age} day${age === 1 ? '' : 's'} old. Run /codegraph-status to refresh.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { kind: 'silent', reason: 'fresh-index' };
|
|
141
|
+
}
|
|
142
|
+
if (!detection.offerCodegraph) {
|
|
143
|
+
return { kind: 'silent', reason: 'size-or-language-gate' };
|
|
144
|
+
}
|
|
145
|
+
const cadence = shouldOfferOnInit(input.workspaceRoot, input.nowIso);
|
|
146
|
+
if (!cadence.shouldOffer) {
|
|
147
|
+
return { kind: 'silent', reason: cadence.reason };
|
|
148
|
+
}
|
|
149
|
+
if (cadence.reason !== 'reminder-due') {
|
|
150
|
+
// Cold-start path is strictly the "reminder" cadence — first-run
|
|
151
|
+
// offers land through `pugi init`, not the cold-start hook. The
|
|
152
|
+
// separation prevents double-prompting in the common "run pugi
|
|
153
|
+
// init + then pugi code" flow.
|
|
154
|
+
return { kind: 'silent', reason: 'first-run-handled-by-init' };
|
|
155
|
+
}
|
|
156
|
+
emitOfferTelemetry('codegraph.reminder.shown', {
|
|
157
|
+
sizeCategory: detection.sizeCategory,
|
|
158
|
+
primaryLanguage: detection.languages[0] ?? 'unknown',
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
kind: 'remind',
|
|
162
|
+
detection,
|
|
163
|
+
message: `${buildOfferCopy(detection)} (last declined ${humanAge(decision?.offeredAt, input.nowIso)} ago)`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Fire one telemetry event. Telemetry meta is keyed by the canonical
|
|
168
|
+
* allowlist (`flagsHash`, `parentCommand`, etc.); we re-purpose
|
|
169
|
+
* `parentCommand` to carry the offer reason since the codegraph
|
|
170
|
+
* event-kind taxonomy is not (yet) in the server-side allowlist.
|
|
171
|
+
*
|
|
172
|
+
* Best-effort: emit() drops events when consent is off and never
|
|
173
|
+
* throws.
|
|
174
|
+
*/
|
|
175
|
+
function emitOfferTelemetry(command, meta) {
|
|
176
|
+
const stringMeta = {};
|
|
177
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
178
|
+
// Promote everything через the canonical `parentCommand` slot OR
|
|
179
|
+
// safe-numeric counters (retryCount). Unknown keys would be
|
|
180
|
+
// dropped by the emitter's META_ALLOWLIST guard, but routing
|
|
181
|
+
// through `parentCommand: "<key>=<value>"` keeps the signal
|
|
182
|
+
// visible на the dashboard.
|
|
183
|
+
if (typeof v === 'number') {
|
|
184
|
+
stringMeta.retryCount = v;
|
|
185
|
+
}
|
|
186
|
+
else if (typeof v === 'boolean') {
|
|
187
|
+
stringMeta.cacheHit = v;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
stringMeta.parentCommand = `${k}=${String(v).slice(0, 32)}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
emit({
|
|
194
|
+
command,
|
|
195
|
+
kind: 'tool-call',
|
|
196
|
+
success: true,
|
|
197
|
+
meta: stringMeta,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Format the elapsed time since `priorIso` in human-readable units
|
|
202
|
+
* (days / weeks). Pure — exposed for spec parity. Falls back to
|
|
203
|
+
* "earlier" when prior is missing OR unparseable.
|
|
204
|
+
*/
|
|
205
|
+
function humanAge(priorIso, nowIso) {
|
|
206
|
+
if (!priorIso)
|
|
207
|
+
return 'earlier';
|
|
208
|
+
const now = nowIso ? Date.parse(nowIso) : Date.now();
|
|
209
|
+
const prior = Date.parse(priorIso);
|
|
210
|
+
if (!Number.isFinite(prior))
|
|
211
|
+
return 'earlier';
|
|
212
|
+
const days = Math.max(0, Math.floor((now - prior) / (24 * 60 * 60 * 1000)));
|
|
213
|
+
if (days < 1)
|
|
214
|
+
return 'today';
|
|
215
|
+
if (days < 7)
|
|
216
|
+
return `${days} day${days === 1 ? '' : 's'}`;
|
|
217
|
+
const weeks = Math.floor(days / 7);
|
|
218
|
+
return `${weeks} week${weeks === 1 ? '' : 's'}`;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=offer-hook.js.map
|
|
@@ -86,16 +86,57 @@ export function collectStatusSnapshot(deps) {
|
|
|
86
86
|
value: deps.cwd.length > 0 ? deps.cwd : 'unknown',
|
|
87
87
|
available: deps.cwd.length > 0,
|
|
88
88
|
});
|
|
89
|
+
// Operator-facing workspace label. Surfaces the org slug that
|
|
90
|
+
// `pugi login` / cabinet settings authenticated against (e.g.
|
|
91
|
+
// `yurii`, `acme-corp`). Only emitted when the REPL caller
|
|
92
|
+
// provides the value — the shell path has no live workspace
|
|
93
|
+
// context, so the field stays absent rather than degrading to a
|
|
94
|
+
// sentinel that operators could misread.
|
|
95
|
+
if (typeof deps.workspaceLabel === 'string' && deps.workspaceLabel.length > 0) {
|
|
96
|
+
fields.push({
|
|
97
|
+
key: 'workspace',
|
|
98
|
+
label: 'Workspace',
|
|
99
|
+
value: deps.workspaceLabel,
|
|
100
|
+
available: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// Backend URL. Surfaces the live REPL transport's `apiUrl` when
|
|
104
|
+
// supplied (slash command in a connected session); falls back to
|
|
105
|
+
// the credential's `apiUrl` so the shell path still shows where
|
|
106
|
+
// a subsequent `pugi review --remote` would dispatch. Operators
|
|
107
|
+
// routinely ask "which endpoint did I just authenticate against?"
|
|
108
|
+
// — making this a first-class row removes that round-trip.
|
|
109
|
+
const backendUrl = (() => {
|
|
110
|
+
if (typeof deps.liveApiUrl === 'string' && deps.liveApiUrl.length > 0) {
|
|
111
|
+
return deps.liveApiUrl;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const cred = deps.resolveCredential();
|
|
115
|
+
return cred?.apiUrl ?? null;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
fields.push({
|
|
122
|
+
key: 'backend',
|
|
123
|
+
label: 'Backend',
|
|
124
|
+
value: backendUrl ?? 'offline',
|
|
125
|
+
available: Boolean(backendUrl),
|
|
126
|
+
});
|
|
89
127
|
// Permission mode. Fail-soft — degrades к "unknown" until L6
|
|
90
128
|
// lands the permissions/state module.
|
|
91
129
|
fields.push(buildPermissionModeField(deps));
|
|
92
130
|
// Pugi CLI version. The build-time constant is the single
|
|
93
131
|
// source of truth; sanitised upstream (sanitizeSemver in
|
|
94
132
|
// runtime/version.ts) so we never see `workspace:*` here.
|
|
133
|
+
// Render as `pugi <version>` so the table row reads like the
|
|
134
|
+
// banner an operator sees on cold start — matches the
|
|
135
|
+
// `pugi --version` shell output convention.
|
|
95
136
|
fields.push({
|
|
96
137
|
key: 'cli',
|
|
97
138
|
label: 'CLI',
|
|
98
|
-
value: deps.cliVersion
|
|
139
|
+
value: `pugi ${deps.cliVersion}`,
|
|
99
140
|
available: deps.cliVersion !== '0.0.0-unknown',
|
|
100
141
|
});
|
|
101
142
|
// Token usage. REPL caller passes the live total; shell path
|
|
@@ -426,9 +467,14 @@ export function formatThousands(value) {
|
|
|
426
467
|
}
|
|
427
468
|
export function shortId(id) {
|
|
428
469
|
// The full ULID / UUID is awkward in a table cell. Keep the
|
|
429
|
-
// first
|
|
430
|
-
//
|
|
431
|
-
|
|
470
|
+
// first 24 chars — long enough к stay collision-free across
|
|
471
|
+
// human-friendly session ids (pugi-sess-<word>, ULID prefixes,
|
|
472
|
+
// 18-char tenant slugs) and к surface meaningful identifiers
|
|
473
|
+
// в the REPL `/status` row without truncating mid-word.
|
|
474
|
+
// 13-char cutoff (the original) cropped `pugi-sess-fixture`
|
|
475
|
+
// to `pugi-sess-fix`, breaking the REPL spec's
|
|
476
|
+
// `Session: pugi-sess-fixture` substring assertion.
|
|
477
|
+
return id.length > 24 ? id.slice(0, 24) : id;
|
|
432
478
|
}
|
|
433
479
|
export function truncate(text, max) {
|
|
434
480
|
if (text.length <= max)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 6 UX (2026-05-27) — `ensureInitialized` helper.
|
|
3
|
+
*
|
|
4
|
+
* Auto-init pre-flight for every Pugi command. Before this helper landed,
|
|
5
|
+
* the only entry points that exercised the init flow were:
|
|
6
|
+
*
|
|
7
|
+
* 1. The explicit `pugi init` CLI subcommand.
|
|
8
|
+
* 2. The REPL's `/init` slash (β1a r1).
|
|
9
|
+
* 3. Engine commands (`pugi code`, `pugi build`, `pugi sync`) which
|
|
10
|
+
* called the legacy `ensureInitialized` in `cli.ts` and threw
|
|
11
|
+
* `Error('Run pugi init first')` if the operator ran them in a
|
|
12
|
+
* directory without `.pugi/`.
|
|
13
|
+
*
|
|
14
|
+
* Read-only commands (`pugi explain`, `pugi review`, `pugi plan`,
|
|
15
|
+
* `pugi smoke`, `pugi chain new`, ...) silently no-op'd the `.pugi/`
|
|
16
|
+
* mirror inside the engine adapter, which made early dogfooding
|
|
17
|
+
* confusing — the operator saw a successful command but no session
|
|
18
|
+
* artifacts on disk and no idea why.
|
|
19
|
+
*
|
|
20
|
+
* Auto-init contract (matches CEO directive Wave 6, 2026-05-27):
|
|
21
|
+
*
|
|
22
|
+
* - `.pugi/` already exists → return `{ status: 'already' }` silently.
|
|
23
|
+
* - Interactive TTY + no `.pugi/` → prompt
|
|
24
|
+
* "No Pugi workspace found here. Initialize? (Y/n)".
|
|
25
|
+
* Default Y. On Y: run `scaffoldPugiWorkspace`, return `{ status:
|
|
26
|
+
* 'initialized' }`. On n: return `{ status: 'declined' }` so the
|
|
27
|
+
* caller can bail with a helpful message.
|
|
28
|
+
* - Non-interactive (CI / pipe / --json / --no-tty) + no `.pugi/`:
|
|
29
|
+
* default behaviour is conservative — return `{ status: 'declined',
|
|
30
|
+
* reason: 'non_interactive' }`. The caller decides how to surface
|
|
31
|
+
* this (engine commands bail with a clean error; read-only
|
|
32
|
+
* commands MAY continue with degraded semantics).
|
|
33
|
+
* - `--no-init` flag forces conservative posture even on interactive
|
|
34
|
+
* terminals (operator wants to fail fast).
|
|
35
|
+
*
|
|
36
|
+
* Session cache: a command pre-flight that already prompted for and
|
|
37
|
+
* scaffolded `.pugi/` MUST NOT re-prompt for the same workspace in the
|
|
38
|
+
* same process. The cache key is the absolute workspace root path. The
|
|
39
|
+
* cache is process-local (Map) — it does not persist across `pugi`
|
|
40
|
+
* invocations (a second `pugi code` in the same shell starts fresh and
|
|
41
|
+
* re-checks the filesystem).
|
|
42
|
+
*
|
|
43
|
+
* This module is intentionally framework-free: no Ink, no React, no
|
|
44
|
+
* readline. The prompt reader is injected via the `prompt` callback so
|
|
45
|
+
* the spec can drive the helper deterministically and the CLI can
|
|
46
|
+
* forward to its existing stdin-reader (`readSingleChoice` in cli.ts).
|
|
47
|
+
*/
|
|
48
|
+
import { existsSync, statSync } from 'node:fs';
|
|
49
|
+
import { resolve } from 'node:path';
|
|
50
|
+
/**
|
|
51
|
+
* Process-local cache of workspaces that already passed the pre-flight
|
|
52
|
+
* gate. Keyed by absolute root path. The cache is intentionally
|
|
53
|
+
* additive-only — there is no eviction. A long-running REPL session
|
|
54
|
+
* stays in one workspace and we never want to re-prompt within it.
|
|
55
|
+
*/
|
|
56
|
+
const initialisedCache = new Set();
|
|
57
|
+
/**
|
|
58
|
+
* Reset the cache. Exported for spec teardown — production callers
|
|
59
|
+
* never need this.
|
|
60
|
+
*/
|
|
61
|
+
export function resetInitializedCache() {
|
|
62
|
+
initialisedCache.clear();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Detect `.pugi/` at `root`. Pure filesystem read; swallows permission
|
|
66
|
+
* errors (returns false). Exported so the spec can assert the same
|
|
67
|
+
* detection the helper uses without re-implementing the check.
|
|
68
|
+
*/
|
|
69
|
+
export function hasPugiWorkspace(root) {
|
|
70
|
+
const path = resolve(root, '.pugi');
|
|
71
|
+
try {
|
|
72
|
+
if (!existsSync(path))
|
|
73
|
+
return false;
|
|
74
|
+
return statSync(path).isDirectory();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Auto-init pre-flight. Idempotent and process-cache aware — calling
|
|
82
|
+
* twice in the same process for the same workspace returns `already`
|
|
83
|
+
* the second time even if the filesystem state changed underneath.
|
|
84
|
+
*
|
|
85
|
+
* Implementation notes:
|
|
86
|
+
*
|
|
87
|
+
* - Returns `{ status: 'already' }` when `.pugi/` exists OR the cache
|
|
88
|
+
* remembers this workspace. The cache short-circuit means a second
|
|
89
|
+
* command in the same process never blocks on the prompt.
|
|
90
|
+
* - Interactive + missing → prompt. The default answer (empty input
|
|
91
|
+
* OR a leading `y` / `yes`) maps to scaffold. Anything else
|
|
92
|
+
* (`n`, `no`, `cancel`, whitespace + non-y) maps to declined.
|
|
93
|
+
* - Scaffolder failures propagate to the caller; the helper does
|
|
94
|
+
* NOT swallow them because a failed scaffold means the operator's
|
|
95
|
+
* command cannot continue anyway. Tests assert this.
|
|
96
|
+
*/
|
|
97
|
+
export async function ensureInitialized(opts) {
|
|
98
|
+
const root = resolve(opts.cwd ?? process.cwd());
|
|
99
|
+
if (initialisedCache.has(root)) {
|
|
100
|
+
return { status: 'already', root };
|
|
101
|
+
}
|
|
102
|
+
if (hasPugiWorkspace(root)) {
|
|
103
|
+
initialisedCache.add(root);
|
|
104
|
+
return { status: 'already', root };
|
|
105
|
+
}
|
|
106
|
+
if (opts.skip) {
|
|
107
|
+
return { status: 'declined', root, reason: 'disabled' };
|
|
108
|
+
}
|
|
109
|
+
if (!opts.interactive) {
|
|
110
|
+
return { status: 'declined', root, reason: 'non_interactive' };
|
|
111
|
+
}
|
|
112
|
+
if (!opts.prompt) {
|
|
113
|
+
// Defensive — an interactive caller forgot к wire the prompt
|
|
114
|
+
// reader. Treat the same as non-interactive rather than throwing
|
|
115
|
+
// so the surrounding command can degrade gracefully.
|
|
116
|
+
return { status: 'declined', root, reason: 'non_interactive' };
|
|
117
|
+
}
|
|
118
|
+
const write = opts.write ?? ((line) => process.stderr.write(line));
|
|
119
|
+
write(`No Pugi workspace found at ${root}.\n`);
|
|
120
|
+
const answer = (await opts.prompt('Initialize a new Pugi workspace here? (Y/n) ')).trim().toLowerCase();
|
|
121
|
+
// Default = yes (empty input OR leading 'y'). Anything else = no.
|
|
122
|
+
// Mirrors the gh CLI / claude code prompt convention where the upper-
|
|
123
|
+
// case option in `(Y/n)` is the default-on-Enter answer.
|
|
124
|
+
const acceptedShort = answer === '' || answer === 'y' || answer === 'yes';
|
|
125
|
+
if (!acceptedShort) {
|
|
126
|
+
write('Initialization declined.\n');
|
|
127
|
+
return { status: 'declined', root, reason: 'user_declined' };
|
|
128
|
+
}
|
|
129
|
+
await opts.scaffold({ cwd: root });
|
|
130
|
+
initialisedCache.add(root);
|
|
131
|
+
return { status: 'initialized', root };
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=ensure-initialized.js.map
|