@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 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, --restart to apply a changed profile
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.23.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",
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.2.0"
14
+ version: "1.3.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
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
- if (plan.focused && !runningFocused) {
1520
- log(`✗ ${current.pane.session}: ${plan.command} is running, not focused`);
1521
- log(
1522
- ` to focus it: vdx ai --restart --focused ${shellQuote(projectRoot)}` +
1523
- (plan.resumeArgs.length ? ' (stops the running agent, then resumes its conversation)' : ' (stops the running agent)'),
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
- const missing = missingArgs(current.proc.args, plan.args);
1528
- if (missing.length > 0) {
1529
- log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
1530
- log(
1531
- ` to apply the profile: vdx ai --restart${plan.focused ? ' --focused' : ''}${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
1532
- (plan.resumeArgs.length
1533
- ? ` (stops the running agent, then resumes its conversation)`
1534
- : opts.fresh
1535
- ? ' (stops the running agent and starts a new conversation)'
1536
- : ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
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.2.0';
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 prompt may appear)
515
- deps.log('→ running `npm publish` (OTP prompt may appear)...');
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
- deps.log(`✗ npm publish failed (exit ${e?.status ?? '?'}); reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
521
- throw new Error('npm publish failed');
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