@vodmal/vdx-cli 0.23.0 → 0.24.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/README.md +3 -2
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +81 -19
- package/src/index.ts +8 -4
- package/src/init.ts +1 -1
- package/src/publish.ts +13 -5
package/README.md
CHANGED
|
@@ -75,11 +75,12 @@ vdx init --stack node
|
|
|
75
75
|
vdx test # → mise run test (which calls `vitest run` or whatever was detected)
|
|
76
76
|
|
|
77
77
|
# 3. Ship a new minor release of a Node lib
|
|
78
|
-
vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP) → commit + tag → push → wait for the registry
|
|
78
|
+
vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP or browser) → commit + tag → push → wait for the registry
|
|
79
79
|
|
|
80
80
|
# 4. Start the agent here, continuing its last conversation (--new: a new one;
|
|
81
81
|
# another machine's conversation is continued on that machine);
|
|
82
|
-
# re-run to attach
|
|
82
|
+
# re-run to attach; an agent without the profile's flags is a question:
|
|
83
|
+
# Enter attaches as is, r restarts it; --restart applies a changed profile
|
|
83
84
|
vdx ai
|
|
84
85
|
|
|
85
86
|
# 5. The same on another machine (an ssh host with vdx), attaching from here
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
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/rubric/vdx-rubric.yaml
CHANGED
package/src/ai.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface SessionProfile {
|
|
|
84
84
|
project_names?: NameSource[];
|
|
85
85
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
86
86
|
machines?: string[];
|
|
87
|
+
/** What to do when a session cannot read ~/Downloads, ~/Desktop or ~/Documents (macOS): shown after the warning. */
|
|
88
|
+
folders_fix?: string;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/**
|
|
@@ -222,6 +224,9 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
222
224
|
if (!isMachineLabel(m)) fail(`session.machines[${i}]`, 'must be an ssh destination: letters, digits, . _ - and no leading -');
|
|
223
225
|
});
|
|
224
226
|
}
|
|
227
|
+
if (session.folders_fix !== undefined && (typeof session.folders_fix !== 'string' || session.folders_fix.trim() === '')) {
|
|
228
|
+
fail('session.folders_fix', 'must be a non-empty string');
|
|
229
|
+
}
|
|
225
230
|
}
|
|
226
231
|
|
|
227
232
|
const gitDoc = envDoc.git;
|
|
@@ -708,6 +713,8 @@ export interface AiDeps {
|
|
|
708
713
|
processes: () => ProcInfo[];
|
|
709
714
|
/** Was this agent process started focused; default: its environment (`processFocused`). */
|
|
710
715
|
agentFocused?: (pid: number) => boolean;
|
|
716
|
+
/** Default `process.platform`: the folder check is for macOS. */
|
|
717
|
+
platform?: NodeJS.Platform;
|
|
711
718
|
sleep: (ms: number) => void;
|
|
712
719
|
confirmTimeoutMs: number;
|
|
713
720
|
/** One line from the person at the terminal, or null when there is none. */
|
|
@@ -1308,6 +1315,46 @@ export function processFocused(pid: number): boolean {
|
|
|
1308
1315
|
}
|
|
1309
1316
|
}
|
|
1310
1317
|
|
|
1318
|
+
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
1319
|
+
export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The guarded folders an agent started here could not read. Through the tmux server
|
|
1323
|
+
* (`run-shell`): its sessions have the server's rights, not vdx's. Without a server, or
|
|
1324
|
+
* without tmux, this process's own — the agent gets the rights of this terminal. A folder
|
|
1325
|
+
* that does not exist is skipped.
|
|
1326
|
+
*/
|
|
1327
|
+
export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
1328
|
+
const dirs = GUARDED_FOLDERS.map((f) => path.join(home, f));
|
|
1329
|
+
// `ls`, not `[ -d ]`: a denied folder still passes -d.
|
|
1330
|
+
const script = dirs.map((d) => `{ [ ! -d ${shellQuote(d)} ] || ls ${shellQuote(d)} >/dev/null 2>&1 || echo ${shellQuote(d)}; }`).join('; ');
|
|
1331
|
+
const out = tmux?.tryRun(['run-shell', script]);
|
|
1332
|
+
if (out !== undefined && out !== null) return out.split('\n').filter(Boolean);
|
|
1333
|
+
return dirs.filter((d) => {
|
|
1334
|
+
try {
|
|
1335
|
+
fs.readdirSync(d);
|
|
1336
|
+
return false;
|
|
1337
|
+
} catch (e: any) {
|
|
1338
|
+
return e?.code === 'EPERM' || e?.code === 'EACCES';
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** One warning and its fix — the profile's `session.folders_fix`, else what works anywhere. */
|
|
1344
|
+
export function foldersWarning(denied: string[], viaTmux: boolean, fix: string | undefined, home: string): string[] {
|
|
1345
|
+
const names = denied.map((d) => (d.startsWith(home + path.sep) ? `~${d.slice(home.length)}` : d)).join(', ');
|
|
1346
|
+
return [
|
|
1347
|
+
`⚠ this session cannot read ${names} — macOS holds the denial ${viaTmux ? 'in the tmux server' : 'for this terminal'}; ` +
|
|
1348
|
+
`files there are out of the agent's reach`,
|
|
1349
|
+
` fix: ${
|
|
1350
|
+
fix ??
|
|
1351
|
+
(viaTmux
|
|
1352
|
+
? 'restart the tmux server (it closes every session)'
|
|
1353
|
+
: 'allow this terminal app in System Settings → Privacy & Security → Files and Folders')
|
|
1354
|
+
}`,
|
|
1355
|
+
];
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1311
1358
|
/** The agent's command line; a focused one carries VDX_FOCUSED=1 in front, for the shell in its tmux pane. */
|
|
1312
1359
|
function agentLine(plan: LaunchPlan, args: string[]): string {
|
|
1313
1360
|
return `${plan.focused ? `${FOCUSED_ENV_VAR}=1 ` : ''}${commandLine(plan.command, args)}`;
|
|
@@ -1436,13 +1483,18 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1436
1483
|
}
|
|
1437
1484
|
|
|
1438
1485
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1486
|
+
// Said before a start or an attach, never in the way: the agent runs, without those files.
|
|
1487
|
+
const denied = (deps.platform ?? process.platform) === 'darwin' ? unreadableFolders(multiplexer === 'tmux' ? tmux : null, deps.home) : [];
|
|
1488
|
+
const folders = denied.length ? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home) : [];
|
|
1439
1489
|
if (opts.dryRun) {
|
|
1440
1490
|
deps.out(renderPlan(plan, multiplexer, running, focusedOf));
|
|
1441
1491
|
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
1492
|
+
for (const l of folders) deps.out(` ${l}\n`);
|
|
1442
1493
|
ensureAuthor(projectRoot, poolDirs, deps, true);
|
|
1443
1494
|
return EXIT_OK;
|
|
1444
1495
|
}
|
|
1445
1496
|
for (const l of choice?.lines ?? []) log(l);
|
|
1497
|
+
for (const l of folders) log(l);
|
|
1446
1498
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1447
1499
|
if (choice?.elsewhere && !current) {
|
|
1448
1500
|
const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
|
|
@@ -1516,29 +1568,39 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1516
1568
|
);
|
|
1517
1569
|
}
|
|
1518
1570
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1571
|
+
const unfocused = plan.focused && !runningFocused;
|
|
1572
|
+
const missing = unfocused ? [] : missingArgs(current.proc.args, plan.args);
|
|
1573
|
+
if (!unfocused && missing.length === 0) {
|
|
1574
|
+
log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
|
|
1575
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1576
|
+
}
|
|
1577
|
+
// Drift. In a terminal the person picks: the agent as it runs, or a restart
|
|
1578
|
+
// with the profile; without one nothing is touched, and the fix is named.
|
|
1579
|
+
const drift = `${current.pane.session}: ${plan.command} is running${unfocused ? ', not focused' : ` without ${missing.join(' ')}`}`;
|
|
1580
|
+
const fix = `vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}`;
|
|
1581
|
+
const effect = plan.resumeArgs.length
|
|
1582
|
+
? 'stops the running agent, then resumes its conversation'
|
|
1583
|
+
: opts.fresh
|
|
1584
|
+
? 'stops the running agent and starts a new conversation'
|
|
1585
|
+
: 'stops the running agent; the conversation is not resumed — the profile has no resume_args';
|
|
1586
|
+
if (!deps.interactive) {
|
|
1587
|
+
log(`✗ ${drift}`);
|
|
1588
|
+
log(` ${unfocused ? 'to focus it' : 'to apply the profile'}: ${fix} (${effect})`);
|
|
1525
1589
|
return EXIT_DRIFT;
|
|
1526
1590
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
);
|
|
1591
|
+
log(`⚠ ${drift}`);
|
|
1592
|
+
log(` Enter ${opts.detach ? 'leave it running' : 'attach to it'} as is${unfocused ? '' : ' — the flags wait for a restart'}`);
|
|
1593
|
+
log(` r restart it ${unfocused ? 'focused' : 'with the profile'}: ${effect}`);
|
|
1594
|
+
log(` q leave it; later: ${fix}`);
|
|
1595
|
+
const answer = deps.ask(`${opts.detach ? 'Leave it as is' : 'Attach as is'}? [Enter/r/q]: `);
|
|
1596
|
+
if (answer === '') {
|
|
1597
|
+
log(`→ ${current.pane.session}: as is — ${current.proc.args}`);
|
|
1598
|
+
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1599
|
+
}
|
|
1600
|
+
if (answer?.toLowerCase() !== 'r') {
|
|
1601
|
+
log('nothing done');
|
|
1538
1602
|
return EXIT_DRIFT;
|
|
1539
1603
|
}
|
|
1540
|
-
log(`✓ ${current.pane.session}: already running with the profile${runningFocused ? ', focused' : ''} — ${current.proc.args}`);
|
|
1541
|
-
return finish(true, current.pane.session, current.pane.paneId, opts, deps);
|
|
1542
1604
|
}
|
|
1543
1605
|
|
|
1544
1606
|
if (current) {
|
package/src/index.ts
CHANGED
|
@@ -83,7 +83,10 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
83
83
|
applies this one
|
|
84
84
|
--conversation <id>
|
|
85
85
|
continue this Claude Code conversation
|
|
86
|
-
--restart restart the running agent in its pane — applies a changed profile
|
|
86
|
+
--restart restart the running agent in its pane — applies a changed profile.
|
|
87
|
+
Without it, an agent running without the profile's flags is
|
|
88
|
+
a question in a terminal: Enter attaches to it as is, r
|
|
89
|
+
restarts it, q leaves it
|
|
87
90
|
--detach start without attaching; print the command that attaches
|
|
88
91
|
--dry-run print the plan and the project's running agents; start nothing
|
|
89
92
|
--check for the agent's own session: how the agent here is launched, and
|
|
@@ -102,8 +105,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
|
|
|
102
105
|
the project runs on another machine starts only after a yes.
|
|
103
106
|
|
|
104
107
|
Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
|
|
105
|
-
3 the running agent lacks profile flags, or is not focused under --focused
|
|
106
|
-
(rerun with --restart); 4 the start
|
|
108
|
+
3 the running agent lacks profile flags, or is not focused under --focused,
|
|
109
|
+
and was left as is — no terminal, or q (rerun with --restart); 4 the start
|
|
107
110
|
was not confirmed.
|
|
108
111
|
Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
|
|
109
112
|
`;
|
|
@@ -134,7 +137,8 @@ mise.toml is overwritten only with --force.
|
|
|
134
137
|
pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
|
|
135
138
|
and the checks run again;
|
|
136
139
|
the version bump in package.json and package-lock.json;
|
|
137
|
-
npm publish (npm asks for an OTP
|
|
140
|
+
npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
|
|
141
|
+
there puts the version back);
|
|
138
142
|
git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
|
|
139
143
|
a wait until the registry shows the new version (up to 3 min).
|
|
140
144
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
package/src/init.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
13
13
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
14
14
|
|
|
15
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.
|
|
15
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.3.0';
|
|
16
16
|
|
|
17
17
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
18
18
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
package/src/publish.ts
CHANGED
|
@@ -481,7 +481,7 @@ export function waitForRegistry(name: string, version: string, deps: PublishDeps
|
|
|
481
481
|
* or there is none. The pre-flight refused a branch behind its upstream.
|
|
482
482
|
* 5. Wait until the registry shows the version.
|
|
483
483
|
*
|
|
484
|
-
* On npm publish failure → restore the original files.
|
|
484
|
+
* On npm publish failure or interruption (Ctrl-C at its prompt) → restore the original files.
|
|
485
485
|
* Git failures after a successful npm publish leave the user in a
|
|
486
486
|
* partially-released state; they fix it manually (commit/tag/push what's
|
|
487
487
|
* needed) — we don't try to unpublish.
|
|
@@ -511,14 +511,22 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
511
511
|
`✓ bumped ${bumped.map((f) => path.relative(plan.projectRoot, f)).join(' and ')}: ${plan.currentVersion} → ${plan.newVersion}`,
|
|
512
512
|
);
|
|
513
513
|
|
|
514
|
-
// 2. npm publish (interactive — OTP
|
|
515
|
-
|
|
514
|
+
// 2. npm publish (interactive — npm asks for an OTP or a confirmation in the browser).
|
|
515
|
+
// Ctrl-C at that prompt reaches npm and vdx alike: vdx holds out while npm runs, so the
|
|
516
|
+
// interrupted npm comes back as an error and the bump is put back — not left for the next run.
|
|
517
|
+
deps.log('→ running `npm publish` (npm may ask for an OTP or a confirmation in the browser)...');
|
|
518
|
+
const holdOut = () => {};
|
|
519
|
+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
520
|
+
for (const s of signals) process.on(s, holdOut);
|
|
516
521
|
try {
|
|
517
522
|
deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
|
|
518
523
|
} catch (e: any) {
|
|
519
524
|
for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
|
|
520
|
-
|
|
521
|
-
|
|
525
|
+
const what = e?.signal ? `interrupted (${e.signal})` : `failed (exit ${e?.status ?? '?'})`;
|
|
526
|
+
deps.log(`✗ npm publish ${what}; reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
|
|
527
|
+
throw new Error(`npm publish ${e?.signal ? 'interrupted' : 'failed'}`);
|
|
528
|
+
} finally {
|
|
529
|
+
for (const s of signals) process.off(s, holdOut);
|
|
522
530
|
}
|
|
523
531
|
deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
|
|
524
532
|
|