@vodmal/vdx-cli 0.12.1 → 0.13.1
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 +6 -2
- package/package.json +1 -1
- package/src/ai.ts +33 -12
- package/src/doctor.ts +1 -1
- package/src/index.ts +10 -5
- package/src/publish.ts +66 -6
package/README.md
CHANGED
|
@@ -48,7 +48,10 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
|
48
48
|
vdx doctor [--format=ansi|markdown|json]
|
|
49
49
|
|
|
50
50
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
51
|
-
vdx ai [project-path] [--
|
|
51
|
+
vdx ai [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd
|
|
52
|
+
|
|
53
|
+
# Print the installed version
|
|
54
|
+
vdx --version
|
|
52
55
|
|
|
53
56
|
# MCP stdio server consumed by the Claude Code plugin
|
|
54
57
|
vdx-mcp --project <path>
|
|
@@ -67,7 +70,8 @@ vdx test # → mise run test (which calls `vitest run` or whatever wa
|
|
|
67
70
|
# 3. Ship a new minor release of a Node lib
|
|
68
71
|
vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
|
|
69
72
|
|
|
70
|
-
# 4. Start the agent here
|
|
73
|
+
# 4. Start the agent here, continuing its last conversation (--new: a new one);
|
|
74
|
+
# re-run to attach, --restart to apply a changed profile
|
|
71
75
|
vdx ai
|
|
72
76
|
```
|
|
73
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vdx",
|
package/src/ai.ts
CHANGED
|
@@ -13,6 +13,10 @@ import type { Predicate } from './rubric.ts';
|
|
|
13
13
|
* and hands it to tmux, or runs it in this terminal when tmux is not wanted or
|
|
14
14
|
* not installed.
|
|
15
15
|
*
|
|
16
|
+
* A start continues the project's last conversation (the profile's
|
|
17
|
+
* `resume_args`) — after a reboot `vdx ai` brings the agent back where it was;
|
|
18
|
+
* `--new` starts a new conversation.
|
|
19
|
+
*
|
|
16
20
|
* The profile is personal (which agent, which permissions), so it is never
|
|
17
21
|
* bundled into the CLI: it is read from `$VDX_ENVIRONMENT` or
|
|
18
22
|
* `~/.vdx-environment.yaml`. Without one, vdx starts a plain `claude` or
|
|
@@ -499,7 +503,8 @@ export class Tmux {
|
|
|
499
503
|
export interface AiOptions {
|
|
500
504
|
path: string;
|
|
501
505
|
restart: boolean;
|
|
502
|
-
resume
|
|
506
|
+
/** `--new`: start a new conversation — leave out the profile's resume args. */
|
|
507
|
+
fresh: boolean;
|
|
503
508
|
detach: boolean;
|
|
504
509
|
dryRun: boolean;
|
|
505
510
|
}
|
|
@@ -544,6 +549,13 @@ export const EXIT_USAGE = 2;
|
|
|
544
549
|
export const EXIT_DRIFT = 3;
|
|
545
550
|
export const EXIT_LAUNCH_FAILED = 4;
|
|
546
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Without tmux the agent runs in the foreground. One that fails this soon after
|
|
554
|
+
* a start with the resume args had nothing to resume (`claude --continue` exits
|
|
555
|
+
* 1 at once); the window covers answering the channel prompt that comes first.
|
|
556
|
+
*/
|
|
557
|
+
export const NOTHING_TO_RESUME_MS = 30_000;
|
|
558
|
+
|
|
547
559
|
export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
|
|
548
560
|
const lines = [
|
|
549
561
|
`vdx ai — ${plan.projectRoot}`,
|
|
@@ -658,9 +670,9 @@ function settle(plan: LaunchPlan, paneId: string, deps: AiDeps): SettleResult {
|
|
|
658
670
|
}
|
|
659
671
|
|
|
660
672
|
/**
|
|
661
|
-
* Settle a pane
|
|
662
|
-
* resume exits at once; start it once more without them rather
|
|
663
|
-
* project without an agent.
|
|
673
|
+
* Settle a pane started with the plan's resume args, if it has any. An agent
|
|
674
|
+
* with nothing to resume exits at once; start it once more without them rather
|
|
675
|
+
* than leave the project without an agent.
|
|
664
676
|
*/
|
|
665
677
|
function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean {
|
|
666
678
|
const result = settle(plan, paneId, deps);
|
|
@@ -724,6 +736,8 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
724
736
|
log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
|
|
725
737
|
return EXIT_USAGE;
|
|
726
738
|
}
|
|
739
|
+
// A start continues the last conversation unless asked for a new one.
|
|
740
|
+
if (opts.fresh) plan = { ...plan, resumeArgs: [] };
|
|
727
741
|
|
|
728
742
|
let multiplexer = plan.multiplexer;
|
|
729
743
|
if (multiplexer === 'tmux' && !tmux.available()) {
|
|
@@ -751,12 +765,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
751
765
|
return EXIT_USAGE;
|
|
752
766
|
}
|
|
753
767
|
for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
|
|
754
|
-
const
|
|
755
|
-
|
|
768
|
+
const startedAt = Date.now();
|
|
769
|
+
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit' });
|
|
756
770
|
if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
|
|
757
771
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
758
772
|
return 127;
|
|
759
773
|
}
|
|
774
|
+
const failedAtOnce = res.status !== null && res.status !== 0 && Date.now() - startedAt < NOTHING_TO_RESUME_MS;
|
|
775
|
+
if (plan.resumeArgs.length > 0 && failedAtOnce) {
|
|
776
|
+
log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${commandLine(plan.command, plan.args)}`);
|
|
777
|
+
res = spawnSync(plan.command, plan.args, { cwd: projectRoot, stdio: 'inherit' });
|
|
778
|
+
}
|
|
760
779
|
return res.status ?? 1;
|
|
761
780
|
}
|
|
762
781
|
|
|
@@ -769,14 +788,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
769
788
|
}
|
|
770
789
|
|
|
771
790
|
if (current && !opts.restart) {
|
|
791
|
+
if (opts.fresh) log(`note: ${current.pane.session} already runs ${plan.command} — --new replaces it only with --restart`);
|
|
772
792
|
const missing = missingArgs(current.proc.args, plan.args);
|
|
773
793
|
if (missing.length > 0) {
|
|
774
794
|
log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
|
|
775
795
|
log(
|
|
776
|
-
` to apply the profile: vdx ai --restart ${shellQuote(projectRoot)}` +
|
|
796
|
+
` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
777
797
|
(plan.resumeArgs.length
|
|
778
798
|
? ` (stops the running agent, then resumes the conversation with ${plan.resumeArgs.join(' ')})`
|
|
779
|
-
:
|
|
799
|
+
: opts.fresh
|
|
800
|
+
? ' (stops the running agent and starts a new conversation)'
|
|
801
|
+
: ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
|
|
780
802
|
);
|
|
781
803
|
return EXIT_DRIFT;
|
|
782
804
|
}
|
|
@@ -791,14 +813,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
791
813
|
return finish(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
|
|
792
814
|
}
|
|
793
815
|
|
|
794
|
-
if (opts.restart) log(`note: no running ${plan.command} in this project — starting
|
|
795
|
-
const line = commandLine(plan.command, [...plan.args, ...
|
|
816
|
+
if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
|
|
817
|
+
const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
|
|
796
818
|
const cmd = tmuxShellCommand(line, deps.shell);
|
|
797
819
|
const reuse = tmux.hasSession(plan.sessionName);
|
|
798
820
|
const paneId = reuse
|
|
799
821
|
? tmux.newWindow(plan.sessionName, projectRoot, cmd)
|
|
800
822
|
: tmux.newSession(plan.sessionName, projectRoot, cmd);
|
|
801
823
|
log(`▶ ${plan.sessionName}${reuse ? ' (new window)' : ''}: ${line}`);
|
|
802
|
-
|
|
803
|
-
return finish(ok, plan.sessionName, paneId, opts, deps);
|
|
824
|
+
return finish(settleResumed(plan, paneId, deps), plan.sessionName, paneId, opts, deps);
|
|
804
825
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -162,7 +162,7 @@ function checkNpmAuth(): CheckResult {
|
|
|
162
162
|
id: 'npm-auth',
|
|
163
163
|
label: 'npm auth',
|
|
164
164
|
status: 'warning',
|
|
165
|
-
message: 'not logged in — `vdx publish`
|
|
165
|
+
message: 'not logged in — `vdx publish` refuses at pre-flight (npm-auth)',
|
|
166
166
|
remedy: 'npm login',
|
|
167
167
|
};
|
|
168
168
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
reportDoctorJson,
|
|
20
20
|
reportDoctorAnsi,
|
|
21
21
|
} from './report.ts';
|
|
22
|
-
import { runDoctor, looksLikeProject } from './doctor.ts';
|
|
22
|
+
import { runDoctor, looksLikeProject, readCliVersion } from './doctor.ts';
|
|
23
23
|
import { planInit, writeInit, renderPlanSummary } from './init.ts';
|
|
24
24
|
import {
|
|
25
25
|
planPublish,
|
|
@@ -41,7 +41,8 @@ function usage(): never {
|
|
|
41
41
|
vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
|
|
42
42
|
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
43
43
|
vdx doctor [--format=ansi|markdown|json] [--json]
|
|
44
|
-
vdx ai [project_path] [--
|
|
44
|
+
vdx ai [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd)
|
|
45
|
+
vdx --version
|
|
45
46
|
`,
|
|
46
47
|
);
|
|
47
48
|
process.exit(1);
|
|
@@ -271,16 +272,20 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
271
272
|
return v !== undefined;
|
|
272
273
|
};
|
|
273
274
|
const restart = bool('restart');
|
|
274
|
-
const
|
|
275
|
+
const fresh = bool('new');
|
|
276
|
+
// `--resume` asked to continue in 0.12; continuing is the default now.
|
|
277
|
+
// Still read, so the path after it is not taken for its value.
|
|
278
|
+
bool('resume');
|
|
275
279
|
const detach = bool('detach');
|
|
276
280
|
const dryRun = bool('dry-run');
|
|
277
281
|
process.exit(
|
|
278
|
-
runAi({ path: positionals[0] ?? '.', restart,
|
|
282
|
+
runAi({ path: positionals[0] ?? '.', restart, fresh, detach, dryRun }, defaultDeps()),
|
|
279
283
|
);
|
|
280
284
|
}
|
|
281
285
|
|
|
282
286
|
const parsed = parseArgs(process.argv);
|
|
283
|
-
if (parsed.cmd === '
|
|
287
|
+
if (parsed.cmd === '--version') process.stdout.write(readCliVersion() + '\n');
|
|
288
|
+
else if (parsed.cmd === 'audit') cmdAudit(parsed);
|
|
284
289
|
else if (parsed.cmd === 'init') cmdInit(parsed);
|
|
285
290
|
else if (parsed.cmd === 'publish') cmdPublish(parsed);
|
|
286
291
|
else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
|
package/src/publish.ts
CHANGED
|
@@ -79,12 +79,21 @@ function isGitWorkingTreeClean(projectRoot: string): { ok: boolean; detail: stri
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/** Runs npm with these args and returns stdout; throws with `stderr` on a non-zero exit. */
|
|
83
|
+
export type NpmRunner = (args: string[]) => string;
|
|
84
|
+
|
|
85
|
+
// A caller's `npm run -s` passes `npm_config_loglevel=silent` down, and npm then
|
|
86
|
+
// fails with an empty stderr — no E401 or E404 left to tell the cases apart.
|
|
87
|
+
const runNpm: NpmRunner = (args) =>
|
|
88
|
+
execFileSync('npm', args, {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
env: { ...process.env, npm_config_loglevel: 'error' },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | null {
|
|
83
95
|
try {
|
|
84
|
-
const out =
|
|
85
|
-
encoding: 'utf8',
|
|
86
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
-
});
|
|
96
|
+
const out = npm(['view', packageName, 'version']);
|
|
88
97
|
return out.trim() || null;
|
|
89
98
|
} catch (e: any) {
|
|
90
99
|
const stderr = String(e?.stderr ?? e?.message ?? '');
|
|
@@ -93,6 +102,54 @@ function fetchPublishedVersion(packageName: string): string | null {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The registry `npm publish` sends this package to, when it is not npm's
|
|
107
|
+
* default: `publishConfig.registry`, else the scope's registry from npm config.
|
|
108
|
+
*/
|
|
109
|
+
export function publishRegistry(
|
|
110
|
+
pkg: { name?: string; publishConfig?: { registry?: string } },
|
|
111
|
+
npm: NpmRunner,
|
|
112
|
+
): string | null {
|
|
113
|
+
if (pkg.publishConfig?.registry) return pkg.publishConfig.registry;
|
|
114
|
+
const scope = pkg.name?.match(/^(@[^/]+)\//)?.[1];
|
|
115
|
+
if (!scope) return null;
|
|
116
|
+
try {
|
|
117
|
+
const value = npm(['config', 'get', `${scope}:registry`]).trim();
|
|
118
|
+
return value && value !== 'undefined' ? value : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Logged in where the package goes? Without it `npm publish` fails only after
|
|
126
|
+
* the bump and the pack, and with E404 on a scoped package, not E401 — an
|
|
127
|
+
* expired npm session reads as a missing package.
|
|
128
|
+
*/
|
|
129
|
+
export function checkNpmAuth(registry: string | null, npm: NpmRunner): PreflightCheck {
|
|
130
|
+
const where = registry ?? 'npm';
|
|
131
|
+
const login = registry ? `npm login --registry ${registry}` : 'npm login';
|
|
132
|
+
try {
|
|
133
|
+
const who = npm(registry ? ['whoami', '--registry', registry] : ['whoami']).trim();
|
|
134
|
+
if (who) return { name: 'npm-auth', ok: true, message: `OK (logged in to ${where} as ${who})` };
|
|
135
|
+
return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}` };
|
|
136
|
+
} catch (e: any) {
|
|
137
|
+
const stderr = String(e?.stderr ?? e?.message ?? '');
|
|
138
|
+
if (/E401|ENEEDAUTH/.test(stderr)) {
|
|
139
|
+
return {
|
|
140
|
+
name: 'npm-auth',
|
|
141
|
+
ok: false,
|
|
142
|
+
message: `not logged in to ${where} (the npm session may have expired). Run: ${login}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: 'npm-auth',
|
|
147
|
+
ok: false,
|
|
148
|
+
message: `npm whoami failed for ${where}: ${stderr.split('\n').find((l) => l.trim()) ?? stderr}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
96
153
|
function checkMiseOverride(projectRoot: string): boolean {
|
|
97
154
|
const miseToml = path.join(projectRoot, 'mise.toml');
|
|
98
155
|
if (!fs.existsSync(miseToml)) return false;
|
|
@@ -116,6 +173,7 @@ export function planPublish(
|
|
|
116
173
|
opts: PublishOptions,
|
|
117
174
|
audit: AuditResult,
|
|
118
175
|
ctx: Ctx,
|
|
176
|
+
npm: NpmRunner = runNpm,
|
|
119
177
|
): PublishPlan {
|
|
120
178
|
if (checkMiseOverride(opts.projectRoot)) {
|
|
121
179
|
return {
|
|
@@ -197,10 +255,12 @@ export function planPublish(
|
|
|
197
255
|
: `release-artifact at ${raLevel}, need >= L3. Add files[], main/exports, license in package.json.`,
|
|
198
256
|
});
|
|
199
257
|
|
|
258
|
+
preflight.push(checkNpmAuth(publishRegistry(pkg, npm), npm));
|
|
259
|
+
|
|
200
260
|
let published: string | null = null;
|
|
201
261
|
let registryError: string | null = null;
|
|
202
262
|
try {
|
|
203
|
-
published = fetchPublishedVersion(packageName);
|
|
263
|
+
published = fetchPublishedVersion(packageName, npm);
|
|
204
264
|
} catch (e: any) {
|
|
205
265
|
registryError = e?.message ?? String(e);
|
|
206
266
|
}
|