@pugi/cli 0.1.0-beta.23 → 0.1.0-beta.25
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/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/native-pugi.js +67 -3
- package/dist/core/engine/tool-bridge.js +123 -3
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +84 -0
- package/dist/core/repl/slash-commands.js +25 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +9 -0
- package/dist/runtime/cli.js +170 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/repl-splash-mascot.js +19 -7
- package/package.json +3 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi hooks` — operator surface for user-config hooks (Leak L12 MVP).
|
|
3
|
+
*
|
|
4
|
+
* Two subcommands ship in the MVP:
|
|
5
|
+
*
|
|
6
|
+
* pugi hooks list List configured hooks per event.
|
|
7
|
+
* pugi hooks doctor Validate the config and surface any
|
|
8
|
+
* parse / schema errors.
|
|
9
|
+
*
|
|
10
|
+
* Both accept `--json` for scripted callers. Argument grammar is
|
|
11
|
+
* intentionally narrow — no `add` / `remove` / `test` subcommands in
|
|
12
|
+
* the MVP. Operators hand-edit `~/.pugi/hooks-mvp.json` for now.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes:
|
|
15
|
+
* 0 -> happy path (no hooks OR config valid).
|
|
16
|
+
* 1 -> config present but invalid (only `doctor` returns this).
|
|
17
|
+
* 2 -> unknown subcommand / argument error.
|
|
18
|
+
*
|
|
19
|
+
* Brand voice: ASCII only.
|
|
20
|
+
*/
|
|
21
|
+
import { ALL_HOOK_EVENTS_V2, defaultHooksMvpPath, loadHooksConfig, } from '../../core/hooks/index.js';
|
|
22
|
+
function parseFlags(args) {
|
|
23
|
+
const rest = [];
|
|
24
|
+
const flags = { json: false };
|
|
25
|
+
for (const arg of args) {
|
|
26
|
+
if (arg === '--json') {
|
|
27
|
+
flags.json = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
rest.push(arg);
|
|
31
|
+
}
|
|
32
|
+
return { rest, flags };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Top-level dispatcher for `pugi hooks <subcommand>`. Returns the
|
|
36
|
+
* intended process exit code. `cli.ts` is expected to set
|
|
37
|
+
* `process.exitCode = <return value>` so error states propagate to
|
|
38
|
+
* scripted callers without throwing.
|
|
39
|
+
*/
|
|
40
|
+
export async function runHooksCommand(args, ctx) {
|
|
41
|
+
const { rest, flags } = parseFlags(args);
|
|
42
|
+
const sub = rest[0];
|
|
43
|
+
if (!sub || sub === 'help' || sub === '--help') {
|
|
44
|
+
emitUsage(ctx, flags);
|
|
45
|
+
return sub ? 0 : 2;
|
|
46
|
+
}
|
|
47
|
+
if (sub === 'list') {
|
|
48
|
+
return runList(ctx, flags);
|
|
49
|
+
}
|
|
50
|
+
if (sub === 'doctor') {
|
|
51
|
+
return runDoctor(ctx, flags);
|
|
52
|
+
}
|
|
53
|
+
ctx.writeOutput({ ok: false, error: `unknown subcommand: ${sub}` }, `pugi hooks: unknown subcommand '${sub}'. Try 'pugi hooks --help'.`);
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
function emitUsage(ctx, flags) {
|
|
57
|
+
const text = [
|
|
58
|
+
'pugi hooks — user-config lifecycle hooks (MVP).',
|
|
59
|
+
'',
|
|
60
|
+
'Subcommands:',
|
|
61
|
+
' pugi hooks list Show hooks configured per event.',
|
|
62
|
+
' pugi hooks doctor Validate ~/.pugi/hooks-mvp.json.',
|
|
63
|
+
'',
|
|
64
|
+
'Flags:',
|
|
65
|
+
' --json Emit a JSON envelope instead of human text.',
|
|
66
|
+
'',
|
|
67
|
+
'Config file:',
|
|
68
|
+
' ~/.pugi/hooks-mvp.json',
|
|
69
|
+
'',
|
|
70
|
+
'Status:',
|
|
71
|
+
' MVP — 2 events out of 8. Remaining events (PostToolUse,',
|
|
72
|
+
" UserPromptSubmit, Stop, SubagentStop, PreCompact, Notification)",
|
|
73
|
+
' deferred to fast-follow PR.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
ctx.writeOutput({
|
|
76
|
+
ok: true,
|
|
77
|
+
command: 'hooks',
|
|
78
|
+
usage: text,
|
|
79
|
+
}, text);
|
|
80
|
+
if (flags.json) {
|
|
81
|
+
// The structured payload is already emitted by writeOutput when
|
|
82
|
+
// --json is on; nothing extra to do.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function runList(ctx, flags) {
|
|
86
|
+
let config;
|
|
87
|
+
try {
|
|
88
|
+
config = loadHooksConfig(ctx.configPath);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
const msg = error.message;
|
|
92
|
+
ctx.writeOutput({ ok: false, error: msg }, `pugi hooks list: ${msg}\nFix the config or remove the file. Run 'pugi hooks doctor' for details.`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const perEvent = {
|
|
96
|
+
SessionStart: [],
|
|
97
|
+
PreToolUse: [],
|
|
98
|
+
PostToolUse: [],
|
|
99
|
+
UserPromptSubmit: [],
|
|
100
|
+
Stop: [],
|
|
101
|
+
SubagentStop: [],
|
|
102
|
+
PreCompact: [],
|
|
103
|
+
Notification: [],
|
|
104
|
+
};
|
|
105
|
+
for (const event of ALL_HOOK_EVENTS_V2) {
|
|
106
|
+
perEvent[event] = config.list(event).map((entry) => ({
|
|
107
|
+
matcher: entry.matcher,
|
|
108
|
+
command: entry.command,
|
|
109
|
+
timeoutMs: entry.timeoutMs,
|
|
110
|
+
blocking: entry.blocking,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const total = Object.values(perEvent).reduce((acc, list) => acc + list.length, 0);
|
|
114
|
+
const payload = {
|
|
115
|
+
ok: true,
|
|
116
|
+
configPath: config.configPath(),
|
|
117
|
+
total,
|
|
118
|
+
perEvent,
|
|
119
|
+
};
|
|
120
|
+
if (flags.json) {
|
|
121
|
+
ctx.writeOutput(payload, JSON.stringify(payload, null, 2));
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push(`pugi hooks (${total} configured)`);
|
|
126
|
+
lines.push(` config: ${config.configPath()}`);
|
|
127
|
+
if (total === 0) {
|
|
128
|
+
lines.push(' no hooks configured — create the file above to add one.');
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
for (const event of ALL_HOOK_EVENTS_V2) {
|
|
132
|
+
const list = perEvent[event];
|
|
133
|
+
if (list.length === 0)
|
|
134
|
+
continue;
|
|
135
|
+
lines.push(` ${event}:`);
|
|
136
|
+
for (const entry of list) {
|
|
137
|
+
const tags = [];
|
|
138
|
+
if (entry.matcher)
|
|
139
|
+
tags.push(`matcher=${entry.matcher}`);
|
|
140
|
+
if (entry.timeoutMs)
|
|
141
|
+
tags.push(`timeoutMs=${entry.timeoutMs}`);
|
|
142
|
+
if (entry.blocking)
|
|
143
|
+
tags.push('blocking');
|
|
144
|
+
const suffix = tags.length ? ` [${tags.join(', ')}]` : '';
|
|
145
|
+
lines.push(` - ${entry.command}${suffix}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const text = lines.join('\n');
|
|
150
|
+
ctx.writeOutput(payload, text);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
function runDoctor(ctx, flags) {
|
|
154
|
+
const path = ctx.configPath ?? defaultHooksMvpPath();
|
|
155
|
+
try {
|
|
156
|
+
const config = loadHooksConfig(ctx.configPath);
|
|
157
|
+
const total = config.flatten().length;
|
|
158
|
+
const payload = {
|
|
159
|
+
ok: true,
|
|
160
|
+
configPath: config.configPath(),
|
|
161
|
+
total,
|
|
162
|
+
issues: [],
|
|
163
|
+
};
|
|
164
|
+
const text = total
|
|
165
|
+
? `pugi hooks doctor: ${path} OK (${total} hooks).`
|
|
166
|
+
: `pugi hooks doctor: ${path} not present (no hooks configured).`;
|
|
167
|
+
ctx.writeOutput(payload, text);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const msg = error.message;
|
|
172
|
+
const payload = {
|
|
173
|
+
ok: false,
|
|
174
|
+
configPath: path,
|
|
175
|
+
error: msg,
|
|
176
|
+
};
|
|
177
|
+
const text = `pugi hooks doctor: ${msg}`;
|
|
178
|
+
ctx.writeOutput(payload, text);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
// flags.json is consumed by writeOutput in the host shell.
|
|
182
|
+
void flags;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Brand voice: ASCII only, no emoji, no banned words.
|
|
26
26
|
*/
|
|
27
|
-
import { extname } from 'node:path';
|
|
28
27
|
import { inspectLspServers, startLspClient } from '../../core/lsp/client.js';
|
|
28
|
+
import { languageForFile as inferLanguage } from '../../core/lsp/language-detect.js';
|
|
29
29
|
import { loadSettings } from '../../core/settings.js';
|
|
30
30
|
export async function runLspCommand(args, opts) {
|
|
31
31
|
const [op, file, ...rest] = args;
|
|
@@ -96,6 +96,25 @@ export async function runLspCommand(args, opts) {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
const settings = loadSettings(opts.cwd);
|
|
99
|
+
// Leak L15 (2026-05-27): `pugi lsp check <file>` — manual probe of
|
|
100
|
+
// the post-edit diagnostics pipeline. Identical output to what the
|
|
101
|
+
// model sees appended to its tool envelope after a successful
|
|
102
|
+
// edit/write. Lets operators dry-run the auto-diagnostic surface
|
|
103
|
+
// without dispatching an actual edit.
|
|
104
|
+
if (op === 'check') {
|
|
105
|
+
const { runPostEditDiagnostics } = await import('../../core/lsp/post-edit-diagnostics.js');
|
|
106
|
+
const result = await runPostEditDiagnostics(file, {
|
|
107
|
+
cwd: opts.cwd,
|
|
108
|
+
...(settings.lsp ? { lspSettings: settings.lsp } : {}),
|
|
109
|
+
});
|
|
110
|
+
if (opts.json) {
|
|
111
|
+
return { ok: true, text: JSON.stringify(result, null, 2), exitCode: 0 };
|
|
112
|
+
}
|
|
113
|
+
if (result.skip) {
|
|
114
|
+
return { ok: true, text: `${file}: skipped (${result.reason})`, exitCode: 0 };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, text: result.tail, exitCode: 0 };
|
|
117
|
+
}
|
|
99
118
|
const clientResult = await startLspClient(lang, {
|
|
100
119
|
cwd: opts.cwd,
|
|
101
120
|
...(settings.lsp ? { lspSettings: settings.lsp } : {}),
|
|
@@ -204,6 +223,7 @@ function usage() {
|
|
|
204
223
|
' pugi lsp definition <file> <line> <col>\n' +
|
|
205
224
|
' pugi lsp references <file> <line> <col>\n' +
|
|
206
225
|
' pugi lsp diagnostics <file>\n' +
|
|
226
|
+
' pugi lsp check <file> (Leak L15: probe post-edit tail)\n' +
|
|
207
227
|
' pugi lsp find_definition <file> <symbol>\n' +
|
|
208
228
|
' pugi lsp servers',
|
|
209
229
|
exitCode: 2,
|
|
@@ -341,26 +361,8 @@ function pullLangFlag(args) {
|
|
|
341
361
|
function isLspLanguage(value) {
|
|
342
362
|
return value === 'ts' || value === 'js' || value === 'py' || value === 'go' || value === 'rust';
|
|
343
363
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
case '.tsx':
|
|
349
|
-
return 'ts';
|
|
350
|
-
case '.js':
|
|
351
|
-
case '.jsx':
|
|
352
|
-
case '.mjs':
|
|
353
|
-
case '.cjs':
|
|
354
|
-
return 'js';
|
|
355
|
-
case '.py':
|
|
356
|
-
case '.pyi':
|
|
357
|
-
return 'py';
|
|
358
|
-
case '.go':
|
|
359
|
-
return 'go';
|
|
360
|
-
case '.rs':
|
|
361
|
-
return 'rust';
|
|
362
|
-
default:
|
|
363
|
-
return undefined;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
364
|
+
// Leak L15 (2026-05-27): single source of truth for ext → language
|
|
365
|
+
// lives in `core/lsp/language-detect.ts`. The CLI surface re-exports
|
|
366
|
+
// the lookup so existing call sites keep their import path.
|
|
367
|
+
export { inferLanguage };
|
|
366
368
|
//# sourceMappingURL=lsp.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi repo-map` — Leak L28 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Builds a compact AST-light summary of the workspace's source surface
|
|
5
|
+
* (top-level function / class / interface / type / enum declarations
|
|
6
|
+
* plus the leading JSDoc / markdown summary line) and renders it as
|
|
7
|
+
* a markdown listing. The same builder backs the engine boot-time
|
|
8
|
+
* system-prompt injection — running `pugi repo-map` shows the operator
|
|
9
|
+
* EXACTLY what the engine would inject.
|
|
10
|
+
*
|
|
11
|
+
* The command surface mirrors the L28 spec:
|
|
12
|
+
*
|
|
13
|
+
* `pugi repo-map` build + show (cache hit when fresh)
|
|
14
|
+
* `pugi repo-map --refresh` bust cache + rebuild from scratch
|
|
15
|
+
* `pugi repo-map --format=json` machine-readable envelope
|
|
16
|
+
*
|
|
17
|
+
* Exit code is always 0 — the command is informational, not a gate.
|
|
18
|
+
* The `--json` envelope carries an `ok: false` branch when the repo is
|
|
19
|
+
* too large to map (the L28 5000-file ceiling), so scripted callers
|
|
20
|
+
* can detect the skip without parsing prose.
|
|
21
|
+
*
|
|
22
|
+
* The handler is awaited even though the underlying pipeline is sync,
|
|
23
|
+
* matching the pattern of every other `run*Command` (stickers, status,
|
|
24
|
+
* doctor) so future async paths (remote cache pull) drop в without
|
|
25
|
+
* changing the call sites.
|
|
26
|
+
*/
|
|
27
|
+
import { buildAndFormatRepoMap } from '../../core/repo-map/build.js';
|
|
28
|
+
/**
|
|
29
|
+
* Run the command. Always resolves к the structured envelope so the
|
|
30
|
+
* caller can chain into a status table. Exit code is set к 0 in every
|
|
31
|
+
* branch — the command is informational.
|
|
32
|
+
*/
|
|
33
|
+
export async function runRepoMapCommand(ctx) {
|
|
34
|
+
const result = buildAndFormatRepoMap({
|
|
35
|
+
root: ctx.cwd,
|
|
36
|
+
refresh: ctx.refresh === true,
|
|
37
|
+
writeCache: ctx.writeCache !== false,
|
|
38
|
+
formatBytesCap: ctx.formatBytesCap,
|
|
39
|
+
omitHeader: false,
|
|
40
|
+
});
|
|
41
|
+
if (!result.build.ok) {
|
|
42
|
+
const payload = {
|
|
43
|
+
ok: false,
|
|
44
|
+
command: 'repo-map',
|
|
45
|
+
root: result.build.root,
|
|
46
|
+
reason: result.build.reason,
|
|
47
|
+
walked: result.build.walked,
|
|
48
|
+
};
|
|
49
|
+
const text = result.build.reason === 'too-large'
|
|
50
|
+
? `repo-map skipped: workspace exceeds the ${result.build.walked}-file ceiling. Tighten .pugiignore and re-run.`
|
|
51
|
+
: `repo-map skipped: no workspace at ${result.build.root}.`;
|
|
52
|
+
ctx.writeOutput(payload, text);
|
|
53
|
+
// Informational — never a gate.
|
|
54
|
+
process.exitCode = 0;
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
const build = result.build;
|
|
58
|
+
if (!build.ok) {
|
|
59
|
+
// TS narrowing — handled above.
|
|
60
|
+
throw new Error('unreachable');
|
|
61
|
+
}
|
|
62
|
+
const format = result.format;
|
|
63
|
+
const payload = {
|
|
64
|
+
ok: true,
|
|
65
|
+
command: 'repo-map',
|
|
66
|
+
root: build.root,
|
|
67
|
+
stats: {
|
|
68
|
+
filesScanned: build.scanStats.walked,
|
|
69
|
+
filesIncluded: format.filesIncluded,
|
|
70
|
+
filesRebuilt: build.diffStats.rebuilt,
|
|
71
|
+
filesReused: build.diffStats.reused,
|
|
72
|
+
filesDropped: build.diffStats.dropped,
|
|
73
|
+
skippedLarge: build.scanStats.skippedLarge,
|
|
74
|
+
skippedIgnored: build.scanStats.skippedIgnored,
|
|
75
|
+
bytes: format.bytes,
|
|
76
|
+
truncated: format.truncated,
|
|
77
|
+
},
|
|
78
|
+
cachePath: build.cachePath,
|
|
79
|
+
cacheWritten: build.cacheWritten,
|
|
80
|
+
text: format.text,
|
|
81
|
+
};
|
|
82
|
+
// The text surface IS the formatted markdown. We append a one-line
|
|
83
|
+
// stats footer so the operator sees the cache-hit ratio at a glance
|
|
84
|
+
// (engine context cache is opaque otherwise) — kept off the JSON
|
|
85
|
+
// payload so machine consumers do not have to skip a trailer.
|
|
86
|
+
const footer = formatFooter(payload.stats);
|
|
87
|
+
ctx.writeOutput(payload, `${format.text}\n${footer}`);
|
|
88
|
+
process.exitCode = 0;
|
|
89
|
+
return payload;
|
|
90
|
+
}
|
|
91
|
+
function formatFooter(stats) {
|
|
92
|
+
const truncatedHint = stats.truncated ? ' (truncated к budget)' : '';
|
|
93
|
+
return `${stats.filesIncluded} files · ${stats.filesRebuilt} rebuilt · ${stats.filesReused} cache hit · ${stats.bytes} bytes${truncatedHint}`;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=repo-map.js.map
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L27 (2026-05-27) — `pugi update` dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Top-level command + in-REPL `/update` slash share this handler.
|
|
5
|
+
* Both surfaces delegate IO + persistence here so the channel
|
|
6
|
+
* resolution, the registry probe, and the install shell-out stay
|
|
7
|
+
* single-sourced.
|
|
8
|
+
*
|
|
9
|
+
* Command grammar:
|
|
10
|
+
*
|
|
11
|
+
* pugi update -> probe + interactive prompt
|
|
12
|
+
* pugi update --check -> probe + JSON envelope (scripted)
|
|
13
|
+
* pugi update --channel <name> -> switch channel + probe
|
|
14
|
+
* pugi update --apply -> probe + shell `npm i -g …`
|
|
15
|
+
* (confirms unless --yes)
|
|
16
|
+
* pugi update --apply --yes -> probe + shell, no confirmation
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
*
|
|
20
|
+
* 0 — happy path (no update OR update completed) OR `--check` JSON
|
|
21
|
+
* 1 — install / probe failure with structured error envelope
|
|
22
|
+
* 2 — argument error (bad channel, conflicting flags)
|
|
23
|
+
*
|
|
24
|
+
* R2 atomic swap deferred (sprint plan L27 mention): the leak research
|
|
25
|
+
* also describes Claude Code's R2-backed binary swap. Pugi ships
|
|
26
|
+
* exclusively via npm today; building a parallel R2 distribution
|
|
27
|
+
* channel + checksum verification + rollback is materially more work
|
|
28
|
+
* than the L27 acceptance criteria allow. Document the deferral in
|
|
29
|
+
* the PR body and revisit when npm's once-per-day rate-limit or
|
|
30
|
+
* outage cadence justifies the parallel pipeline.
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { DEFAULT_UPDATE_CHANNEL, UPDATE_CHANNELS, describeChannel, npmTagForChannel, parseUpdateChannel, } from '../../core/auto-update/channels.js';
|
|
35
|
+
import { checkForChannelUpdate, } from '../../core/auto-update/checker.js';
|
|
36
|
+
import { resolveEffectiveChannel, setUpdateChannel, writeLastCheckedAt, } from '../../core/auto-update/state.js';
|
|
37
|
+
import { PUGI_CLI_VERSION } from '../version.js';
|
|
38
|
+
/**
|
|
39
|
+
* Default subprocess runner. Spawns `npm install -g @pugi/cli@<tag>`
|
|
40
|
+
* inheriting stdio so the operator sees the live npm output.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultSpawnInstaller(channel) {
|
|
43
|
+
const tag = npmTagForChannel(channel);
|
|
44
|
+
const args = ['install', '-g', `@pugi/cli@${tag}`];
|
|
45
|
+
return new Promise((resolvePromise) => {
|
|
46
|
+
const child = spawn('npm', args, { stdio: 'inherit' });
|
|
47
|
+
child.on('exit', (code) => {
|
|
48
|
+
resolvePromise(typeof code === 'number' ? code : 1);
|
|
49
|
+
});
|
|
50
|
+
child.on('error', () => {
|
|
51
|
+
// ENOENT / EACCES — npm not on PATH or permission denied. We
|
|
52
|
+
// surface a non-zero code so the dispatcher's JSON envelope
|
|
53
|
+
// tells the operator the apply failed; the inherited stdio
|
|
54
|
+
// already printed the underlying error to the terminal.
|
|
55
|
+
resolvePromise(127);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse a CLI / slash argv into our `UpdateCommandFlags`. Returns
|
|
61
|
+
* `null` AND writes the usage error via `writeError` for conflicting
|
|
62
|
+
* combinations. Both `pugi update` and the in-REPL `/update <args>`
|
|
63
|
+
* surface call through this so the validation lives in one place.
|
|
64
|
+
*/
|
|
65
|
+
export function parseUpdateArgs(argv, options = {}) {
|
|
66
|
+
let check = false;
|
|
67
|
+
let apply = false;
|
|
68
|
+
let yes = false;
|
|
69
|
+
let json = options.jsonDefault ?? false;
|
|
70
|
+
let channel = null;
|
|
71
|
+
let channelInvalid;
|
|
72
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
73
|
+
const token = argv[i] ?? '';
|
|
74
|
+
if (token === '--check') {
|
|
75
|
+
check = true;
|
|
76
|
+
}
|
|
77
|
+
else if (token === '--apply') {
|
|
78
|
+
apply = true;
|
|
79
|
+
}
|
|
80
|
+
else if (token === '--yes' || token === '-y') {
|
|
81
|
+
yes = true;
|
|
82
|
+
}
|
|
83
|
+
else if (token === '--json') {
|
|
84
|
+
json = true;
|
|
85
|
+
}
|
|
86
|
+
else if (token === '--channel') {
|
|
87
|
+
const value = argv[i + 1];
|
|
88
|
+
i += 1;
|
|
89
|
+
const parsed = parseUpdateChannel(value);
|
|
90
|
+
if (!parsed) {
|
|
91
|
+
channelInvalid = value ?? '';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
channel = parsed;
|
|
95
|
+
}
|
|
96
|
+
else if (token.startsWith('--channel=')) {
|
|
97
|
+
const value = token.slice('--channel='.length);
|
|
98
|
+
const parsed = parseUpdateChannel(value);
|
|
99
|
+
if (!parsed) {
|
|
100
|
+
channelInvalid = value;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
channel = parsed;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
return {
|
|
107
|
+
error: `pugi update: unknown argument '${token}'. See \`pugi update --help\`.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (channelInvalid !== undefined) {
|
|
112
|
+
return {
|
|
113
|
+
error: `pugi update: unknown channel '${channelInvalid}'. Allowed: ${UPDATE_CHANNELS.join(' / ')}.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
check,
|
|
118
|
+
apply,
|
|
119
|
+
yes,
|
|
120
|
+
json,
|
|
121
|
+
channel,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Run the full command. Returns the structured envelope so the in-
|
|
126
|
+
* REPL slash can decide whether to render the human-readable text OR
|
|
127
|
+
* pretty-print the JSON.
|
|
128
|
+
*/
|
|
129
|
+
export async function runUpdateCommand(ctx) {
|
|
130
|
+
const flags = ctx.flags;
|
|
131
|
+
const home = ctx.home;
|
|
132
|
+
// 1. Resolve channel. `--channel <name>` wins; otherwise read the
|
|
133
|
+
// persisted preference; otherwise hard default `beta`.
|
|
134
|
+
const cliFlagChannel = flags.channel;
|
|
135
|
+
const effectiveChannel = resolveEffectiveChannel({
|
|
136
|
+
cliFlag: cliFlagChannel,
|
|
137
|
+
homeDir: home,
|
|
138
|
+
});
|
|
139
|
+
// 2. Persist the channel switch BEFORE the probe so a probe failure
|
|
140
|
+
// still leaves the operator on the channel they asked for.
|
|
141
|
+
let switched = false;
|
|
142
|
+
if (cliFlagChannel) {
|
|
143
|
+
setUpdateChannel(cliFlagChannel, home);
|
|
144
|
+
switched = true;
|
|
145
|
+
}
|
|
146
|
+
// 3. Probe the registry.
|
|
147
|
+
const outcome = await checkForChannelUpdate({
|
|
148
|
+
channel: effectiveChannel,
|
|
149
|
+
currentVersion: PUGI_CLI_VERSION,
|
|
150
|
+
...(ctx.fetchImpl ? { fetchImpl: ctx.fetchImpl } : {}),
|
|
151
|
+
...(ctx.registryUrl ? { registryUrl: ctx.registryUrl } : {}),
|
|
152
|
+
});
|
|
153
|
+
// 4. Record the timestamp on a successful probe (regardless of
|
|
154
|
+
// whether an update is available). Failed probes do NOT update
|
|
155
|
+
// the timestamp so the cold-start banner retries on the next
|
|
156
|
+
// invocation.
|
|
157
|
+
if (!outcome.error) {
|
|
158
|
+
const now = ctx.now ? new Date(ctx.now()) : new Date();
|
|
159
|
+
try {
|
|
160
|
+
writeLastCheckedAt(now, home);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Best-effort — a read-only home should not crash the dispatcher.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 5. Build the envelope shape ALL action paths share.
|
|
167
|
+
const baseEnvelope = {
|
|
168
|
+
command: 'update',
|
|
169
|
+
ok: outcome.error === null,
|
|
170
|
+
available: outcome.available,
|
|
171
|
+
channel: outcome.channel,
|
|
172
|
+
npmTag: outcome.npmTag,
|
|
173
|
+
current: outcome.current,
|
|
174
|
+
latest: outcome.latest,
|
|
175
|
+
gap: outcome.gap,
|
|
176
|
+
installCommand: outcome.installCommand,
|
|
177
|
+
action: outcome.error ? 'error' : 'probe',
|
|
178
|
+
error: outcome.error,
|
|
179
|
+
meta: { cliVersion: PUGI_CLI_VERSION },
|
|
180
|
+
};
|
|
181
|
+
// 6. --check: emit the envelope, never prompt, never apply.
|
|
182
|
+
if (flags.check) {
|
|
183
|
+
const text = renderHumanText(baseEnvelope, { switched });
|
|
184
|
+
ctx.writeOutput(baseEnvelope, text);
|
|
185
|
+
return baseEnvelope;
|
|
186
|
+
}
|
|
187
|
+
// 7. No update available — bail with a friendly message.
|
|
188
|
+
if (!outcome.available || !outcome.latest) {
|
|
189
|
+
const envelope = {
|
|
190
|
+
...baseEnvelope,
|
|
191
|
+
action: outcome.error ? 'error' : (switched ? 'switch' : 'no_update'),
|
|
192
|
+
};
|
|
193
|
+
const text = renderHumanText(envelope, { switched });
|
|
194
|
+
ctx.writeOutput(envelope, text);
|
|
195
|
+
return envelope;
|
|
196
|
+
}
|
|
197
|
+
// 8. Update IS available. Branch on --apply.
|
|
198
|
+
if (!flags.apply) {
|
|
199
|
+
// Default: print the offer, suggest the install command, leave
|
|
200
|
+
// the install to the operator. Mirrors the leak research note:
|
|
201
|
+
// operators install side-effects must remain explicit on the CLI
|
|
202
|
+
// happy path — `pugi update` showing the gap is informational,
|
|
203
|
+
// `pugi update --apply` is the destructive verb.
|
|
204
|
+
const envelope = {
|
|
205
|
+
...baseEnvelope,
|
|
206
|
+
action: switched ? 'switch' : 'probe',
|
|
207
|
+
};
|
|
208
|
+
const text = renderHumanText(envelope, { switched });
|
|
209
|
+
ctx.writeOutput(envelope, text);
|
|
210
|
+
return envelope;
|
|
211
|
+
}
|
|
212
|
+
// 9. --apply path. Confirm unless --yes, then spawn npm.
|
|
213
|
+
const installer = ctx.spawnInstaller ?? defaultSpawnInstaller;
|
|
214
|
+
let confirmed = flags.yes;
|
|
215
|
+
if (!confirmed) {
|
|
216
|
+
confirmed = await ctx.promptConfirm(`Run \`${outcome.installCommand}\` to update ${outcome.current} -> ${outcome.latest}? [y/N]`);
|
|
217
|
+
}
|
|
218
|
+
if (!confirmed) {
|
|
219
|
+
const envelope = {
|
|
220
|
+
...baseEnvelope,
|
|
221
|
+
action: 'probe',
|
|
222
|
+
ok: false,
|
|
223
|
+
error: 'apply_cancelled_by_operator',
|
|
224
|
+
};
|
|
225
|
+
const text = `Cancelled. Run \`${outcome.installCommand}\` manually when you are ready.`;
|
|
226
|
+
ctx.writeOutput(envelope, text);
|
|
227
|
+
return envelope;
|
|
228
|
+
}
|
|
229
|
+
const exitCode = await installer(outcome.channel);
|
|
230
|
+
const applyEnvelope = {
|
|
231
|
+
...baseEnvelope,
|
|
232
|
+
action: 'apply',
|
|
233
|
+
installExitCode: exitCode,
|
|
234
|
+
ok: exitCode === 0,
|
|
235
|
+
error: exitCode === 0 ? null : `npm_install_exit_${exitCode}`,
|
|
236
|
+
};
|
|
237
|
+
const applyText = exitCode === 0
|
|
238
|
+
? `Updated to @pugi/cli@${outcome.latest}. Restart your shell so the new binary takes effect.`
|
|
239
|
+
: `Update failed (npm exit ${exitCode}). Try \`${outcome.installCommand}\` manually.`;
|
|
240
|
+
ctx.writeOutput(applyEnvelope, applyText);
|
|
241
|
+
return applyEnvelope;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Build the operator-readable hint that lives next to the JSON
|
|
245
|
+
* envelope. The text + the JSON are both passed to `writeOutput`; the
|
|
246
|
+
* caller (`writeOutput` in cli.ts) picks based on `--json`. Exported
|
|
247
|
+
* so the spec can assert the literal strings the operator sees.
|
|
248
|
+
*/
|
|
249
|
+
export function renderHumanText(envelope, options = {}) {
|
|
250
|
+
const { current, latest, channel, installCommand, available, error } = envelope;
|
|
251
|
+
const lines = [];
|
|
252
|
+
lines.push(`Channel: ${channel} — ${describeChannel(channel)}`);
|
|
253
|
+
if (options.switched) {
|
|
254
|
+
lines.push(`Persisted channel selection -> ${channel}.`);
|
|
255
|
+
}
|
|
256
|
+
if (error) {
|
|
257
|
+
lines.push(`Update check failed: ${error}`);
|
|
258
|
+
lines.push(`Manual: \`${installCommand}\` (no probe necessary).`);
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
}
|
|
261
|
+
if (available && latest) {
|
|
262
|
+
lines.push(`Update available: ${current} -> ${latest}.`);
|
|
263
|
+
lines.push(`Run \`${installCommand}\` to upgrade.`);
|
|
264
|
+
lines.push(`Or \`pugi update --apply\` to upgrade with confirmation.`);
|
|
265
|
+
return lines.join('\n');
|
|
266
|
+
}
|
|
267
|
+
lines.push(`Up to date (${current} is the latest on ${channel}).`);
|
|
268
|
+
return lines.join('\n');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Render a one-line cold-start hint that callers (REPL boot, doctor
|
|
272
|
+
* banner) splice above their own UI. Returns `null` when there is
|
|
273
|
+
* nothing to nudge the operator about. Pure — no IO.
|
|
274
|
+
*/
|
|
275
|
+
export function renderUpdateHint(outcome) {
|
|
276
|
+
if (!outcome.available || !outcome.latest)
|
|
277
|
+
return null;
|
|
278
|
+
return `Update available: ${outcome.current} -> ${outcome.latest}. Run \`pugi update\`.`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Convenience entry point — resolve the effective channel from
|
|
282
|
+
* `~/.pugi/config.json` + DEFAULT_UPDATE_CHANNEL without forcing the
|
|
283
|
+
* caller to import multiple modules. Used by the cold-start banner +
|
|
284
|
+
* the doctor probe.
|
|
285
|
+
*/
|
|
286
|
+
export function effectiveChannel(home = homedir()) {
|
|
287
|
+
return resolveEffectiveChannel({ homeDir: home }) ?? DEFAULT_UPDATE_CHANNEL;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=update.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.25');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|