soja-cli 1.4.0 → 1.5.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 CHANGED
@@ -6,7 +6,7 @@
6
6
  ▄▄▄▄▄▄█▀ ▀█▄▄▄▄█▀ ▀█▄▄▄▄█▀ ██ ██ ▄▄▄
7
7
  ```
8
8
 
9
- **Software Operations & Job Assistant** · v1.4.0 · by Enmauel.biz
9
+ **Software Operations & Job Assistant** · v1.5.1 · by Enmauel.biz
10
10
 
11
11
  > No dashboards. No browser. No bullshit. Just work.
12
12
 
@@ -1,5 +1,6 @@
1
1
  import { GitConsole } from '../../git/console.js';
2
2
  import { FolderService } from './folder-service.js';
3
+ import { PreferenceService } from './preference-service.js';
3
4
  import { GitWorkflowService } from './git-workflow-service.js';
4
5
  import { ProjectService } from './project-service.js';
5
6
  import { SessionService } from './session-service.js';
@@ -16,6 +17,7 @@ export function createServices(repos, config, options) {
16
17
  tasks,
17
18
  git: new GitWorkflowService(tasks, projects, options.git),
18
19
  folders: new FolderService(config),
20
+ preferences: new PreferenceService(config),
19
21
  gitConsole: options.gitConsole ?? new GitConsole(),
20
22
  environment: { mode: 'local' },
21
23
  backups: options.backups,
@@ -0,0 +1,17 @@
1
+ /** Per-machine interface preferences, kept in the config file. */
2
+ export class PreferenceService {
3
+ config;
4
+ constructor(config) {
5
+ this.config = config;
6
+ }
7
+ /** Whether the interface reports the mouse. On unless turned off. */
8
+ mouse() {
9
+ return this.config.load()?.mouse ?? true;
10
+ }
11
+ /** Before setup there is no config file yet: the choice then lasts for this session only. */
12
+ setMouse(on) {
13
+ const current = this.config.load();
14
+ if (current)
15
+ this.config.save({ ...current, mouse: on });
16
+ }
17
+ }
@@ -40,6 +40,7 @@ const COMMANDS = [
40
40
  ['logout', 'Sign out and go back to local mode'],
41
41
  ['account delete [--yes]', 'Delete the Online account and anonymize shared authorship'],
42
42
  ['mode [local|remote]', 'Show or switch where SOJA keeps data'],
43
+ ['mouse [on|off]', 'Show or switch whether the interface uses the mouse (M in the interface)'],
43
44
  ['whoami', 'Current user, workspace and mode'],
44
45
  ['import-local [--from <slug>]', 'Bring local-mode projects, tasks and history to the team server (owners)'],
45
46
  ['sync [--dismiss]', 'Sync now (remote mode); shows conflicts and rejected changes'],
@@ -0,0 +1,25 @@
1
+ import { PreferenceService } from '../../application/services/preference-service.js';
2
+ import { FileConfigStore } from '../../config/config.js';
3
+ import { resolvePaths } from '../../config/paths.js';
4
+ import { SojaError, ValidationError } from '../../domain/errors.js';
5
+ import { bold, dim, print, success } from '../output.js';
6
+ import { parseCommand } from './args.js';
7
+ /** `soja mouse [on|off]`: whether the interface uses the mouse on this machine. */
8
+ export async function mouseCommand(args) {
9
+ const { positionals } = parseCommand(args, {});
10
+ const store = new FileConfigStore(resolvePaths().configFile);
11
+ const preferences = new PreferenceService(store);
12
+ const target = positionals[0];
13
+ if (!target) {
14
+ print(`mouse ${bold(preferences.mouse() ? 'on' : 'off')}`);
15
+ if (process.env.SOJA_MOUSE === '0')
16
+ print(dim('SOJA_MOUSE=0 turns it off for this shell anyway.'));
17
+ return;
18
+ }
19
+ if (target !== 'on' && target !== 'off')
20
+ throw new ValidationError('Use `soja mouse on` or `soja mouse off`.');
21
+ if (!store.load())
22
+ throw new SojaError('SOJA is not set up yet.', { hint: 'Run `soja` once; `M` in the interface also switches the mouse.' });
23
+ preferences.setMouse(target === 'on');
24
+ success(`Mouse ${bold(target)}${target === 'off' ? dim(' · the terminal selects and copies text as usual; M in the interface turns it back on') : ''}.`);
25
+ }
@@ -31,7 +31,7 @@ export async function runInterface(options = {}) {
31
31
  const updates = createUpdater();
32
32
  // Light or dark palette, from the terminal's own background (SOJA_THEME overrides).
33
33
  setThemeMode(await detectThemeMode());
34
- const instance = render(_jsx(App, { services: runtime.services, welcomeOnLocal: savedConfig?.mode === 'remote' && !hasRemoteToken, updates: () => updates.cachedCheck(), mouse: process.env.SOJA_MOUSE !== '0', ...(options.route ? { initialRoute: options.route } : {}) }), { alternateScreen: true, exitOnCtrlC: true });
34
+ const instance = render(_jsx(App, { services: runtime.services, welcomeOnLocal: savedConfig?.mode === 'remote' && !hasRemoteToken, updates: () => updates.cachedCheck(), mouse: process.env.SOJA_MOUSE !== '0' && runtime.services.preferences.mouse(), ...(options.route ? { initialRoute: options.route } : {}) }), { alternateScreen: true, exitOnCtrlC: true });
35
35
  await instance.waitUntilExit();
36
36
  reopenInCurrentMode = new FileConfigStore(runtime.paths.configFile).load()?.mode !== runtime.services.environment.mode;
37
37
  }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { chatCommand } from './commands/chat.js';
11
11
  import { importLocalCommand } from './commands/import.js';
12
12
  import { branchCommand, commitCommand, mergeCommand, pullRequestCommand, pushCommand } from './commands/git-ops.js';
13
13
  import { printHelp, printVersion } from './commands/info.js';
14
+ import { mouseCommand } from './commands/preferences.js';
14
15
  import { projectCommand } from './commands/project.js';
15
16
  import { taskCommand } from './commands/task.js';
16
17
  import { startCommand } from './commands/start.js';
@@ -74,6 +75,8 @@ async function main(argv) {
74
75
  return whoamiCommand();
75
76
  case 'mode':
76
77
  return modeCommand(rest);
78
+ case 'mouse':
79
+ return mouseCommand(rest);
77
80
  case 'sync':
78
81
  return syncCommand(rest);
79
82
  case 'chat':
@@ -28,6 +28,8 @@ export const configSchema = z
28
28
  * repository picker.
29
29
  */
30
30
  parentFolders: z.array(z.string().min(1)).default([]),
31
+ /** Whether the interface reports the mouse (`soja mouse on|off`, `M` in the interface). On when unset. */
32
+ mouse: z.boolean().optional(),
31
33
  remote: remoteSchema.optional(),
32
34
  })
33
35
  .refine((config) => config.mode !== 'remote' || config.remote !== undefined, {
@@ -1,6 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import { GitWorkflowService } from '../../application/services/git-workflow-service.js';
3
3
  import { FolderService } from '../../application/services/folder-service.js';
4
+ import { PreferenceService } from '../../application/services/preference-service.js';
4
5
  import { ProjectService } from '../../application/services/project-service.js';
5
6
  import { TaskService } from '../../application/services/task-service.js';
6
7
  import { createLocalRepositories } from '../local/index.js';
@@ -85,6 +86,7 @@ export function createReplicaServices(api, handle, config, git, gitConsole, WebS
85
86
  ...(localDatabaseFile ? { importer: new LocalImporter(localDatabaseFile, config, api, store, engine) } : {}),
86
87
  git: new GitWorkflowService(tasks, projects, git),
87
88
  folders: new FolderService(config),
89
+ preferences: new PreferenceService(config),
88
90
  gitConsole,
89
91
  environment: { mode: 'remote', server: api.baseUrl },
90
92
  backups,
@@ -33,12 +33,15 @@ export class GhReleases {
33
33
  /** Public release metadata and tarballs from npm; no GitHub account is needed to update. */
34
34
  export class NpmReleases {
35
35
  packageName;
36
- constructor(packageName = 'soja-cli') {
36
+ fetchImpl;
37
+ constructor(packageName = 'soja-cli', fetchImpl = fetch) {
37
38
  this.packageName = packageName;
39
+ this.fetchImpl = fetchImpl;
38
40
  }
39
41
  async latest() {
40
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(this.packageName)}/latest`, {
41
- headers: { accept: 'application/vnd.npm.install-v1+json' },
42
+ // The `/latest` document only speaks plain JSON: the abbreviated install format gets a 406.
43
+ const response = await this.fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(this.packageName)}/latest`, {
44
+ headers: { accept: 'application/json' },
42
45
  signal: AbortSignal.timeout(10_000),
43
46
  });
44
47
  if (!response.ok)
package/dist/ui/Shell.js CHANGED
@@ -30,6 +30,7 @@ import { AccessScreen } from './screens/AccessScreen.js';
30
30
  import { ApiClient } from '../data/remote/api-client.js';
31
31
  import { CredentialStore } from '../config/credentials.js';
32
32
  import { resolvePaths } from '../config/paths.js';
33
+ import { useMouseToggle } from './hooks/use-mouse-toggle.js';
33
34
  /**
34
35
  * The running app, laid out like a desktop application: title bar, sidebar,
35
36
  * the screen stack in the main area, status bar, and floating windows and
@@ -73,6 +74,7 @@ function Frame() {
73
74
  const timer = setInterval(() => void followPullRequests(), 180_000);
74
75
  return () => clearInterval(timer);
75
76
  }, [followPullRequests]);
77
+ const mouseToggle = useMouseToggle();
76
78
  useKeys(Layer.global, (input, key) => {
77
79
  if (input === ':' || (key.ctrl && input === 'k'))
78
80
  openPalette();
@@ -90,6 +92,8 @@ function Frame() {
90
92
  go({ type: 'push', route: { name: 'workspaces' } });
91
93
  else if (input === 'A')
92
94
  go({ type: 'push', route: { name: 'access' } });
95
+ else if (input === 'M')
96
+ mouseToggle.toggle();
93
97
  else if (input === '#' && chat)
94
98
  go({ type: 'push', route: { name: 'chat' } });
95
99
  else if (key.escape && stack.length > 1)
@@ -113,7 +117,7 @@ function Frame() {
113
117
  // The screen under a window stays visible, but only the window takes keys and clicks.
114
118
  const active = top && !overlay;
115
119
  return (_jsx(Box, { display: top ? 'flex' : 'none', flexDirection: "column", flexGrow: 1, children: _jsx(ActiveScope, { active: active, children: _jsx(ScreenFor, { route: entry, active: active }) }) }, `${index}:${JSON.stringify(entry)}`));
116
- }) })] }), _jsx(Box, { height: FOOTER_GAP_ROWS, flexShrink: 0 }), _jsx(StatusBar, { columns: columns, mode: overlay ? modeForOverlay(overlay) : modeFor(route), hints: hints, right: server ? undefined : 'local' }), overlay ? (_jsx(ActiveScope, { active: true, children: _jsx(OverlayFor, { overlay: overlay }) })) : null, flash ? _jsx(Toast, { flash: flash, columns: columns, rows: rows }) : null] }));
120
+ }) })] }), _jsx(Box, { height: FOOTER_GAP_ROWS, flexShrink: 0 }), _jsx(StatusBar, { columns: columns, mode: overlay ? modeForOverlay(overlay) : modeFor(route), hints: hints, right: server ? undefined : 'local', mouse: { on: mouseToggle.enabled, onToggle: mouseToggle.toggle, active: !overlay } }), overlay ? (_jsx(ActiveScope, { active: true, children: _jsx(OverlayFor, { overlay: overlay }) })) : null, flash ? _jsx(Toast, { flash: flash, columns: columns, rows: rows }) : null] }));
117
121
  }
118
122
  function ScreenFor({ route, active }) {
119
123
  switch (route.name) {
@@ -1,18 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { palette } from '../theme/theme.js';
3
+ import { Clickable } from '../kit/Clickable.js';
4
+ import { palette, symbols } from '../theme/theme.js';
4
5
  import { APP_SLOGAN, WORDMARK_WIDTH } from '../branding/brand.js';
5
6
  import { Logo } from '../branding/Logo.js';
6
7
  import { FOOTER_BRAND_GAP_ROWS, footerRowsFor } from '../hooks/use-layout.js';
8
+ const mouseLabel = (on) => ` ${on ? symbols.active : symbols.inactive} mouse ${on ? 'on' : 'off'} `;
7
9
  /**
8
10
  * The bottom bar: a pill with where you are, then the shortcuts that work
9
11
  * right now as keys. Shortcuts that do not fit are dropped from the end.
10
12
  */
11
- export function StatusBar({ columns, mode, hints, right }) {
13
+ export function StatusBar({ columns, mode, hints, right, mouse }) {
12
14
  const footerRows = footerRowsFor(columns);
13
15
  const inlineBrand = columns >= WORDMARK_WIDTH + APP_SLOGAN.length + 4;
14
16
  const sloganWidth = inlineBrand ? columns - WORDMARK_WIDTH - 4 : columns - 2;
15
- const available = columns - mode.length - 4 - (right ? right.length + 3 : 0);
17
+ const mouseWidth = mouse ? mouseLabel(mouse.on).length + 4 : 0;
18
+ const available = columns - mode.length - 4 - (right ? right.length + 3 : 0) - mouseWidth;
16
19
  const shown = [];
17
20
  let used = 0;
18
21
  for (const hint of hints) {
@@ -22,5 +25,5 @@ export function StatusBar({ columns, mode, hints, right }) {
22
25
  shown.push(hint);
23
26
  used += size;
24
27
  }
25
- return (_jsxs(Box, { width: columns, height: footerRows, flexDirection: "column", backgroundColor: palette.bar, flexShrink: 0, children: [_jsxs(Box, { width: columns, height: 1, backgroundColor: palette.bar, justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { backgroundColor: palette.bar, wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: palette.accent, color: palette.onAccent, bold: true, children: ` ${mode.toUpperCase()} ` }), shown.map(([keys, action]) => (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: palette.bar, children: " " }), _jsx(Text, { backgroundColor: palette.neutralSoft, color: palette.text, bold: true, children: ` ${keys} ` }), _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, children: ` ${action}` })] }, keys + action)))] }), right ? (_jsx(Text, { backgroundColor: palette.bar, color: palette.faint, children: `${right} ` })) : null] }), _jsx(Box, { height: FOOTER_BRAND_GAP_ROWS, flexShrink: 0 }), _jsxs(Box, { width: columns, height: footerRows - 1 - FOOTER_BRAND_GAP_ROWS, paddingX: 1, backgroundColor: palette.bar, flexShrink: 0, flexDirection: inlineBrand ? 'row' : 'column', alignItems: inlineBrand ? 'center' : 'flex-start', justifyContent: "center", children: [_jsx(Logo, { cursorVisible: true }), _jsx(Box, { width: sloganWidth, marginLeft: inlineBrand ? 2 : 0, children: _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, wrap: "wrap", children: APP_SLOGAN }) })] })] }));
28
+ return (_jsxs(Box, { width: columns, height: footerRows, flexDirection: "column", backgroundColor: palette.bar, flexShrink: 0, children: [_jsxs(Box, { width: columns, height: 1, backgroundColor: palette.bar, justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { backgroundColor: palette.bar, wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: palette.accent, color: palette.onAccent, bold: true, children: ` ${mode.toUpperCase()} ` }), shown.map(([keys, action]) => (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: palette.bar, children: " " }), _jsx(Text, { backgroundColor: palette.neutralSoft, color: palette.text, bold: true, children: ` ${keys} ` }), _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, children: ` ${action}` })] }, keys + action)))] }), _jsxs(Box, { flexShrink: 0, children: [right ? (_jsx(Text, { backgroundColor: palette.bar, color: palette.faint, children: `${right} ` })) : null, mouse ? (_jsx(Clickable, { onClick: mouse.onToggle, active: mouse.active, children: _jsxs(Text, { backgroundColor: palette.bar, children: [_jsx(Text, { backgroundColor: palette.neutralSoft, color: palette.text, bold: true, children: ' M ' }), _jsx(Text, { backgroundColor: palette.bar, color: mouse.on ? palette.muted : palette.warning, children: mouseLabel(mouse.on) })] }) })) : null] })] }), _jsx(Box, { height: FOOTER_BRAND_GAP_ROWS, flexShrink: 0 }), _jsxs(Box, { width: columns, height: footerRows - 1 - FOOTER_BRAND_GAP_ROWS, paddingX: 1, backgroundColor: palette.bar, flexShrink: 0, flexDirection: inlineBrand ? 'row' : 'column', alignItems: inlineBrand ? 'center' : 'flex-start', justifyContent: "center", children: [_jsx(Logo, { cursorVisible: true }), _jsx(Box, { width: sloganWidth, marginLeft: inlineBrand ? 2 : 0, children: _jsx(Text, { backgroundColor: palette.bar, color: palette.muted, wrap: "wrap", children: APP_SLOGAN }) })] })] }));
26
29
  }
@@ -1,6 +1,7 @@
1
1
  import { FILTER_LABELS, TASK_FILTERS } from '../../application/filters.js';
2
2
  import { useAppState } from '../app-state.js';
3
3
  import { useFlows } from './use-flows.js';
4
+ import { useMouseToggle } from './use-mouse-toggle.js';
4
5
  /** The command palette: every major action, filterable by typing. */
5
6
  export function useCommandPalette(newTaskProjectId) {
6
7
  const { openOverlay, go, quit, services, session, notify, setHomeFilter } = useAppState();
@@ -10,6 +11,7 @@ export function useCommandPalette(newTaskProjectId) {
10
11
  go({ type: 'reset' });
11
12
  };
12
13
  const flows = useFlows();
14
+ const mouse = useMouseToggle();
13
15
  return () => {
14
16
  const commands = [
15
17
  { id: 'new-task', label: 'New task', key: 'n', run: () => openOverlay({ kind: 'new-task', projectId: newTaskProjectId }) },
@@ -40,6 +42,7 @@ export function useCommandPalette(newTaskProjectId) {
40
42
  },
41
43
  }]
42
44
  : []),
45
+ { id: 'mouse', label: mouse.enabled ? 'Mouse off' : 'Mouse on', key: 'M', run: mouse.toggle },
43
46
  { id: 'help', label: 'Help', key: '?', run: () => go({ type: 'push', route: { name: 'help' } }) },
44
47
  { id: 'quit', label: 'Quit', key: 'q', run: quit },
45
48
  ];
@@ -0,0 +1,20 @@
1
+ import { useAppState } from '../app-state.js';
2
+ import { useMouseSwitch } from '../input/KeyProvider.js';
3
+ /**
4
+ * Turns the mouse on or off now and remembers it on this machine. With the
5
+ * mouse off the terminal selects and copies text as usual; `M` brings it back.
6
+ */
7
+ export function useMouseToggle() {
8
+ const { services, notify } = useAppState();
9
+ const mouse = useMouseSwitch();
10
+ const toggle = () => {
11
+ const next = !mouse.enabled;
12
+ mouse.set(next);
13
+ services.preferences.setMouse(next);
14
+ if (next)
15
+ notify('Mouse on', 'success');
16
+ else
17
+ notify('Mouse off', 'success', 'Press M to turn it back on.');
18
+ };
19
+ return { enabled: mouse.enabled, toggle };
20
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useApp, useInput, useStdout } from 'ink';
3
- import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
4
  import { KeyDispatcher, Layer } from './dispatcher.js';
5
5
  import { MOUSE_OFF, MOUSE_ON, MouseDispatcher, parseMouse, rectOf } from './mouse.js';
6
6
  const InputContext = createContext(null);
@@ -9,7 +9,9 @@ const InputContext = createContext(null);
9
9
  * reports to the clickable regions. A mouse report never reaches a key handler.
10
10
  */
11
11
  export function KeyProvider({ children, mouse = false }) {
12
- const [context] = useState(() => ({ keys: new KeyDispatcher(), mouse: new MouseDispatcher(), mouseEnabled: mouse }));
12
+ const [dispatchers] = useState(() => ({ keys: new KeyDispatcher(), mouse: new MouseDispatcher() }));
13
+ const [mouseEnabled, setMouseEnabled] = useState(mouse);
14
+ const context = useMemo(() => ({ ...dispatchers, mouseEnabled, setMouseEnabled }), [dispatchers, mouseEnabled]);
13
15
  const { stdout } = useStdout();
14
16
  useInput((input, key) => {
15
17
  const event = parseMouse(input);
@@ -18,8 +20,9 @@ export function KeyProvider({ children, mouse = false }) {
18
20
  else
19
21
  context.keys.dispatch(input, key);
20
22
  });
23
+ // Turning the mouse off hands selection and copy back to the terminal.
21
24
  useEffect(() => {
22
- if (!mouse)
25
+ if (!mouseEnabled)
23
26
  return;
24
27
  stdout.write(MOUSE_ON);
25
28
  // Whatever way SOJA ends, the terminal must stop sending mouse reports.
@@ -29,9 +32,14 @@ export function KeyProvider({ children, mouse = false }) {
29
32
  off();
30
33
  process.off('exit', off);
31
34
  };
32
- }, [mouse, stdout]);
35
+ }, [mouseEnabled, stdout]);
33
36
  return _jsx(InputContext.Provider, { value: context, children: children });
34
37
  }
38
+ /** The mouse switch: whether it is on, and turning it on or off for this session. */
39
+ export function useMouseSwitch() {
40
+ const { mouseEnabled, setMouseEnabled } = useInputContext();
41
+ return { enabled: mouseEnabled, set: setMouseEnabled };
42
+ }
35
43
  function useInputContext() {
36
44
  const context = useContext(InputContext);
37
45
  if (!context)
@@ -72,6 +72,7 @@ const SECTIONS = [
72
72
  ['wheel', 'Scroll lists and chat'],
73
73
  ['click outside', 'Close a window'],
74
74
  ['shift+drag', 'Select text to copy'],
75
+ ['M', 'Mouse on / off (remembered)'],
75
76
  ],
76
77
  ],
77
78
  [
@@ -100,6 +100,7 @@ export const symbols = {
100
100
  check: '✓',
101
101
  cross: '✕',
102
102
  active: '●',
103
+ inactive: '○',
103
104
  rule: '─',
104
105
  comment: '›',
105
106
  ellipsis: '…',
@@ -20,15 +20,27 @@ Luego, en npmjs.com → `soja-cli` → **Settings → Trusted Publisher → GitH
20
20
  - Repository: `soja-cli`
21
21
  - Workflow filename: `publish.yml`
22
22
  - Environment: vacío
23
+ - Permisos: **npm stage publish** (solo dejar versiones en espera, no publicarlas)
23
24
 
24
- Y en **Settings → Publishing access**, elige *Require two-factor authentication and disallow tokens*: a partir de ahí solo el workflow puede publicar.
25
+ Y en **Settings → Publishing access**, elige *Require two-factor authentication and disallow bypass 2fa tokens*.
26
+
27
+ Con eso, ni el workflow ni un token robado pueden publicar solos: el workflow deja la versión **en espera** (*staged publishing*) y solo sale a npm cuando la cuenta propietaria la aprueba con 2FA.
25
28
 
26
29
  ## Versiones siguientes (automático)
27
30
 
28
31
  Sube la versión en `package.json` y `package-lock.json`, mueve el changelog y haz commit `release: vX.Y.Z` en `main` (por PR). Al subir el tag `vX.Y.Z`:
29
32
 
30
33
  - `release.yml` crea el release de GitHub con el paquete adjunto;
31
- - `publish.yml` valida (lint, tests, build, que el tag coincida con la versión), empaqueta sin *source maps* en un job sin permisos de publicación y publica ese mismo archivo en otro job con OIDC, sin tokens de larga duración y con *provenance* (npm registra desde qué commit y workflow se construyó). Si la versión ya está en npm, no hace nada.
34
+ - `publish.yml` valida (lint, tests, build, que el tag coincida con la versión), empaqueta sin *source maps* en un job sin permisos de publicación y deja ese mismo archivo en espera en npm desde otro job, con OIDC (sin tokens de larga duración) y *provenance* (npm registra desde qué commit y workflow se construyó). Si la versión ya está en npm, no hace nada.
35
+
36
+ Después apruébala tú (necesita npm ≥ 11.17 y tu 2FA):
37
+
38
+ ```bash
39
+ npm stage list soja-cli # muestra la versión en espera y su id
40
+ npm stage approve <stage-id> # la publica
41
+ ```
42
+
43
+ El resumen del run de `publish.yml` en GitHub recuerda estos comandos. Si algo no cuadra, `npm stage reject <stage-id>` la descarta.
32
44
 
33
45
  ## Qué implica que sea público
34
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soja-cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Software Operations & Job Assistant",
5
5
  "author": "Enmauel.biz",
6
6
  "license": "SEE LICENSE IN LICENSE",