@ran-sh/dsh-crew 0.3.3 → 0.3.5

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
@@ -40,21 +40,33 @@ Codex Desktop / Claude Code
40
40
 
41
41
  Prerequisites: **Node.js** (Git is needed only for coding-worker isolation).
42
42
 
43
- Recommended: manage DSH Crew directly through npm/npx no clone required:
43
+ Recommended: install a stable, package-manager-installed launcher and manage DSH Crew with it:
44
44
 
45
45
  ```bash
46
- npx @ran-sh/dsh-crew@latest install
46
+ npm install -g @ran-sh/dsh-crew@latest
47
+ dsh-crew install
47
48
  ```
48
49
 
49
50
  Manage the installation later with the same CLI:
50
51
 
51
52
  ```bash
52
- npx @ran-sh/dsh-crew@latest status
53
- npx @ran-sh/dsh-crew@latest update
54
- npx @ran-sh/dsh-crew@latest uninstall # add --purge to also remove config/backups
53
+ dsh-crew status
54
+ dsh-crew update
55
+ dsh-crew uninstall # add --purge to also remove config/backups
55
56
  ```
56
57
 
57
- An npx install persists the already-built package under Crew-owned state (`~/.config/dsh-crew/app`) before registering it, so the installation never depends on a transient npx cache and does not require pnpm, a lockfile, or rebuilding anything.
58
+ The globally installed package is only the launcher/manager. The actual Crew runtime/plugin payload is persisted under Crew-owned state (`~/.config/dsh-crew/app`) before Harness registration, so runtime behavior never depends on npm cache paths.
59
+
60
+ > Known compatibility issue: transient `npx @ran-sh/dsh-crew …` execution is currently unreliable on some npm versions (npm/cli#9870: the npx cache bin is not put on the spawned command PATH). Until that upstream fix reaches your npm, use the global-launcher flow above instead.
61
+
62
+ An update of the managed payload does not require a clone or rebuild. When the launcher and payload versions match, `dsh-crew update` resolves the newest permitted package from your configured npm registry (or an explicit `--candidate <path>` override). When the running launcher is newer, its validated package payload is used first to converge the older managed payload. Every path stages and validates before switching. If the managed payload is newer than the launcher, it is never downgraded and the CLI prints the exact launcher-refresh command.
63
+
64
+ > Migration boundary for legacy `<= 0.3.3`: those immutable launchers cannot discover newer registry versions, so their old update behavior cannot be retroactively fixed. Refresh the launcher first, then converge the managed payload—no source checkout is required:
65
+ >
66
+ > ```bash
67
+ > npm install -g @ran-sh/dsh-crew@latest
68
+ > dsh-crew update
69
+ > ```
58
70
 
59
71
  Developer / source setup (alternative path):
60
72
 
@@ -113,16 +125,16 @@ A coding worker receives a temporary git worktree at the requested revision, so
113
125
 
114
126
  ## Update / uninstall
115
127
 
116
- npx-managed installs update in place (config, credentials, and backups are preserved; the candidate is staged and validated before switching):
128
+ The managed Crew payload updates in place (config, credentials, and backups are preserved; the candidate is staged and validated before switching):
117
129
 
118
130
  ```bash
119
- npx @ran-sh/dsh-crew@latest update
131
+ dsh-crew update
120
132
  ```
121
133
 
122
- Uninstall an npx-managed install:
134
+ Uninstall the managed payload and integrations:
123
135
 
124
136
  ```bash
125
- npx @ran-sh/dsh-crew@latest uninstall
137
+ dsh-crew uninstall
126
138
  ```
127
139
 
128
140
  Update a source install instead with:
package/README.zh.md CHANGED
@@ -40,21 +40,33 @@ Codex Desktop / Claude Code
40
40
 
41
41
  需要:**Node.js**(编码 worker 隔离需要 **Git**)。
42
42
 
43
- 推荐直接通过 npm/npx 管理,无需克隆仓库:
43
+ 推荐安装稳定的包管理器全局启动器,并用它管理 DSH Crew:
44
44
 
45
45
  ```bash
46
- npx @ran-sh/dsh-crew@latest install
46
+ npm install -g @ran-sh/dsh-crew@latest
47
+ dsh-crew install
47
48
  ```
48
49
 
49
50
  后续管理使用同一 CLI:
50
51
 
51
52
  ```bash
52
- npx @ran-sh/dsh-crew@latest status
53
- npx @ran-sh/dsh-crew@latest update
54
- npx @ran-sh/dsh-crew@latest uninstall # 加 --purge 才会同时删除配置/备份
53
+ dsh-crew status
54
+ dsh-crew update
55
+ dsh-crew uninstall # 加 --purge 才会同时删除配置/备份
55
56
  ```
56
57
 
57
- npx 安装会把已构建好的包持久化到 Crew 自有状态(`~/.config/dsh-crew/app`)后再注册,安装不依赖临时的 npx 缓存,也不需要 pnpm、lockfile 或任何构建步骤。
58
+ 全局安装的包只是启动器/管理器;真正的 Crew 运行时/插件载荷会先持久化到 Crew 自有状态(`~/.config/dsh-crew/app`)再注册,运行时行为不依赖 npm 缓存路径。
59
+
60
+ > 已知兼容性问题:部分 npm 版本下,临时 `npx @ran-sh/dsh-crew …` 执行不可靠(npm/cli#9870:npx 缓存中的 bin 未加入子进程 PATH)。在该上游修复可用之前,请使用上面的全局启动器方式。
61
+
62
+ 托管载荷的更新无需克隆或构建。启动器与载荷版本相同时,`dsh-crew update` 会从你配置的 npm registry 解析最新允许版本(或用 `--candidate <path>` 显式指定);当前启动器较新时,会优先用这个已校验的启动器包收敛旧载荷。所有路径都会先暂存、校验,再切换。托管载荷较新时绝不降级,CLI 会打印刷新启动器的准确命令。
63
+
64
+ > 旧版 `<= 0.3.3` 的迁移边界:这些不可变的旧启动器无法发现更新的 registry 版本,其旧 update 行为无法被追溯修复。受支持的迁移方式是先刷新启动器,再收敛托管载荷;无需源码检出:
65
+ >
66
+ > ```bash
67
+ > npm install -g @ran-sh/dsh-crew@latest
68
+ > dsh-crew update
69
+ > ```
58
70
 
59
71
  开发者 / 源码安装(备选路径):
60
72
 
@@ -113,16 +125,16 @@ Use ds-reviewer to review the implementation.
113
125
 
114
126
  ## 更新 / 卸载
115
127
 
116
- npx 安装使用 update 原地升级(配置、凭据和备份保留;候选包先暂存校验再切换):
128
+ 托管 Crew 载荷使用 update 原地升级(配置、凭据和备份保留;候选包先暂存校验再切换):
117
129
 
118
130
  ```bash
119
- npx @ran-sh/dsh-crew@latest update
131
+ dsh-crew update
120
132
  ```
121
133
 
122
- 卸载 npx 安装:
134
+ 卸载托管载荷与集成:
123
135
 
124
136
  ```bash
125
- npx @ran-sh/dsh-crew@latest uninstall
137
+ dsh-crew uninstall
126
138
  ```
127
139
 
128
140
  源码安装的更新方式:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -1,10 +1,11 @@
1
- // v0.3.3 npx-managed lifecycle: install / status / update / uninstall.
1
+ // Packaged global-launcher lifecycle: install / status / update / uninstall.
2
2
  //
3
3
  // Public UX:
4
- // npx @ran-sh/dsh-crew@latest install|status|update|uninstall
4
+ // npm install -g @ran-sh/dsh-crew@latest
5
+ // dsh-crew install|status|update|uninstall
5
6
  //
6
- // An npx invocation runs from a transient package-manager extraction/cache
7
- // path. This module therefore persists the already-built package payload into
7
+ // A package-manager launcher may run from a replaceable global/cache path.
8
+ // This module therefore persists the already-built package payload into
8
9
  // Crew-owned state BEFORE registering it with the Harness profile, so the
9
10
  // registration never depends on the cache, tarball, or temp extraction dir:
10
11
  //
@@ -565,57 +566,252 @@ export async function npxInstall({
565
566
  return { ok: true, version: manifest.version, path: releaseDir };
566
567
  }
567
568
 
569
+ export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
570
+ export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
571
+
572
+ /**
573
+ * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
574
+ * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
575
+ * at `package/`.
576
+ */
577
+ export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
578
+ mkdirSync(destDir, { recursive: true });
579
+ const result = runner('tar', ['-xzf', String(tgzPath), '-C', destDir], {
580
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
581
+ });
582
+ const root = join(destDir, 'package');
583
+ if (result.status !== 0 || !existsSync(join(root, 'package.json'))) {
584
+ return { ok: false, detail: `tar extract failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-200)}` };
585
+ }
586
+ return { ok: true, sourceRoot: root };
587
+ }
588
+
589
+ /**
590
+ * The globally installed launcher intentionally does not self-replace. When
591
+ * the payload is newer, keep it authoritative and give the exact launcher
592
+ * refresh command. A newer launcher is handled by npxUpdate before this point.
593
+ */
594
+ function noteLauncherDivergence({ log, home = homedir() }) {
595
+ const launcherVersion = readManifest(runningPackageRoot())?.version ?? null;
596
+ let installedVersion = null;
597
+ try { installedVersion = readCurrentPointer({ home }).version ?? null; } catch { installedVersion = null; }
598
+ if (launcherVersion && installedVersion && compareVersions(installedVersion, launcherVersion) > 0) {
599
+ log('');
600
+ log(`- note: managed payload ${installedVersion} is newer than the global launcher ${launcherVersion}; the payload remains authoritative.`);
601
+ log(` Refresh the launcher when convenient: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Resolve an update candidate WITHOUT a source checkout:
607
+ * - explicit candidate path: a payload directory or a packed .tgz file;
608
+ * - otherwise npm registry mode: `npm pack <spec>` using the user's own
609
+ * configured registry/auth (no policy or config mutation), extracted to a
610
+ * disposable directory the caller cleans up via returned cleanup().
611
+ */
612
+ export function resolveUpdateCandidate({
613
+ candidate,
614
+ spec = UPDATE_DEFAULT_SPEC,
615
+ home = homedir(),
616
+ log = () => {},
617
+ runner = spawnSync,
618
+ } = {}) {
619
+ const candidateValue = candidate ?? process.env.DSH_CREW_CANDIDATE ?? undefined;
620
+ if (candidateValue !== undefined && typeof candidateValue !== 'string') {
621
+ return { ok: false, code: 'INVALID_CANDIDATE', detail: 'candidate must be a directory or .tgz path' };
622
+ }
623
+ if (typeof candidateValue === 'string' && candidateValue.trim()) {
624
+ const value = resolve(candidateValue.trim());
625
+ if (!existsSync(value)) return { ok: false, code: 'CANDIDATE_NOT_FOUND', detail: value };
626
+ if (existsSync(join(value, 'package.json'))) {
627
+ const manifest = readManifest(value);
628
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
629
+ return { ok: true, sourceRoot: value, version: manifest.version, cleanup: null };
630
+ }
631
+ if (/\.tgz$/i.test(value)) {
632
+ const tmpRoot = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
633
+ const extracted = extractPackageTarball(value, tmpRoot, { runner });
634
+ if (!extracted.ok) {
635
+ rmSync(tmpRoot, { recursive: true, force: true });
636
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
637
+ }
638
+ const manifest = readManifest(extracted.sourceRoot);
639
+ if (!manifest?.name || !manifest?.version) {
640
+ rmSync(tmpRoot, { recursive: true, force: true });
641
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
642
+ }
643
+ return {
644
+ ok: true,
645
+ sourceRoot: extracted.sourceRoot,
646
+ version: manifest.version,
647
+ cleanup: () => rmSync(tmpRoot, { recursive: true, force: true }),
648
+ };
649
+ }
650
+ return { ok: false, code: 'UNSUPPORTED_CANDIDATE', detail: value };
651
+ }
652
+
653
+ // Registry mode.
654
+ const tmpDir = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
655
+ mkdirSync(tmpDir, { recursive: true });
656
+ try {
657
+ const effectiveSpec = process.env.DSH_CREW_UPDATE_SPEC ?? spec;
658
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
659
+ const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
660
+ log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
661
+ const packed = runner(npm, packArgs, {
662
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32',
663
+ timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv(),
664
+ });
665
+ if (packed.status !== 0) {
666
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: `${effectiveSpec}: ${(packed.stderr || packed.stdout || '').trim().slice(-300)}` };
667
+ }
668
+ let info;
669
+ try { info = JSON.parse(packed.stdout)[0]; } catch {
670
+ return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: String(packed.stdout).slice(-200) };
671
+ }
672
+ if (!info?.filename) return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: 'pack output missing filename' };
673
+ const tgzPath = join(tmpDir, info.filename);
674
+ const extracted = extractPackageTarball(tgzPath, join(tmpDir, 'x'), { runner });
675
+ if (!extracted.ok) {
676
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
677
+ }
678
+ const manifest = readManifest(extracted.sourceRoot);
679
+ if (!manifest?.name || !manifest?.version) {
680
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: info.filename };
681
+ }
682
+ if (info.name !== manifest.name || info.version !== manifest.version) {
683
+ return { ok: false, code: 'CANDIDATE_IDENTITY_MISMATCH', detail: `pack ${info.name}@${info.version} vs manifest ${manifest.name}@${manifest.version}` };
684
+ }
685
+ return {
686
+ ok: true,
687
+ sourceRoot: extracted.sourceRoot,
688
+ version: manifest.version,
689
+ cleanup: () => rmSync(tmpDir, { recursive: true, force: true }),
690
+ };
691
+ } catch (error) {
692
+ rmSync(tmpDir, { recursive: true, force: true });
693
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: String(error?.message ?? error).slice(-300) };
694
+ }
695
+ }
696
+
568
697
  export async function npxUpdate({
569
698
  home = homedir(),
570
699
  log = console.log,
571
700
  sourceRoot,
701
+ candidate,
702
+ spec,
572
703
  installer = realInstaller,
573
704
  ensureRuntime,
574
705
  npmInstaller,
706
+ runner = spawnSync,
575
707
  } = {}) {
576
- log('DSH Crew updater (npx-managed)');
577
- const candidateRoot = sourceRoot ?? runningPackageRoot();
578
- const manifest = readManifest(candidateRoot);
579
- if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
708
+ log('DSH Crew updater');
709
+ // Candidate resolution: explicit path/dir override > a newer validated
710
+ // running launcher > configured npm registry (@latest). This makes the
711
+ // supported legacy bootstrap (`npm install -g ...@latest`, then `update`)
712
+ // independent of registry propagation after the launcher refresh.
713
+ const explicitCandidate = candidate ?? sourceRoot;
714
+ const initialHealth = currentInstallationHealth({ home });
715
+ const launcherRoot = runningPackageRoot();
716
+ const launcherManifest = readManifest(launcherRoot);
717
+ const launcherCanConverge = explicitCandidate === undefined
718
+ && initialHealth.pointer?.version
719
+ && launcherManifest?.name === UPDATE_PACKAGE_NAME
720
+ && launcherManifest?.version
721
+ && compareVersions(launcherManifest.version, initialHealth.pointer.version) > 0;
722
+ let resolved;
723
+ if (launcherCanConverge) {
724
+ log(`- newer launcher ${launcherManifest.version}; converging managed payload ${initialHealth.pointer.version} before registry resolution`);
725
+ resolved = { ok: true, sourceRoot: launcherRoot, version: launcherManifest.version, cleanup: null };
726
+ } else {
727
+ resolved = resolveUpdateCandidate({ candidate: explicitCandidate, spec, home, log, runner });
728
+ }
729
+ if (!resolved.ok) {
730
+ log(`✗ candidate resolution failed (${resolved.code})${resolved.detail ? `: ${resolved.detail}` : ''}`);
731
+ return { ok: false, error: `candidate resolution failed (${resolved.code})` };
732
+ }
733
+ try {
734
+ const manifest = readManifest(resolved.sourceRoot);
735
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
736
+
737
+ const health = currentInstallationHealth({ home });
738
+
739
+ if (health.installed && health.healthy && health.pointer.version === manifest.version) {
740
+ log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
741
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
742
+ if (!activated) return { ok: false, error: 'activation failed' };
743
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
744
+ log('');
745
+ log('Done.');
746
+ return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
747
+ }
580
748
 
581
- const health = currentInstallationHealth({ home });
749
+ if (health.installed && health.healthy && compareVersions(manifest.version, health.pointer.version) < 0) {
750
+ const source = explicitCandidate === undefined && !launcherCanConverge ? 'registry latest' : 'candidate';
751
+ log(`- ${source} (${manifest.version}) is not newer than the installed payload (${health.pointer.version}); nothing to update`);
752
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest: readManifest(health.pointer.path), log, installer });
753
+ if (!activated) return { ok: false, error: 'activation failed' };
754
+ return { ok: true, idempotent: true, version: health.pointer.version, path: health.pointer.path };
755
+ }
582
756
 
583
- if (health.installed && health.healthy && health.pointer.version === manifest.version) {
584
- log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
585
- const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
757
+ // Stale, unhealthy, older, or missing installation: stage the candidate
758
+ // fully and validate it before switching. The previous usable release is
759
+ // left in place until the replacement has been committed and activated.
760
+ if (health.installed && !health.healthy) {
761
+ log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
762
+ } else if (health.installed) {
763
+ log(`- updating managed payload ${health.pointer.version} -> ${manifest.version}`);
764
+ }
765
+
766
+ const staged = stageCandidatePayload({ sourceRoot: resolved.sourceRoot, home, log, npmInstaller });
767
+ if (!staged.ok) {
768
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
769
+ return { ok: false, error: `staging failed (${staged.code})` };
770
+ }
771
+ log(`✓ candidate payload staged and validated (${manifest.version})`);
772
+
773
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
774
+ log('✓ durable release committed under Crew-owned state');
775
+
776
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
586
777
  if (!activated) return { ok: false, error: 'activation failed' };
778
+
587
779
  if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
780
+
781
+ noteLauncherDivergence({ log, home });
588
782
  log('');
589
783
  log('Done.');
590
- return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
591
- }
592
-
593
- // Stale, unhealthy, or missing installation: stage the candidate fully and
594
- // validate it before switching. The previous usable release is left in place
595
- // until the replacement has been committed and activated successfully.
596
- if (health.installed && !health.healthy) {
597
- log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
784
+ log('Restart DeepSeek Harness and Codex Desktop.');
785
+ return { ok: true, updated: true, version: manifest.version, path: releaseDir };
786
+ } finally {
787
+ resolved.cleanup?.();
598
788
  }
789
+ }
599
790
 
600
- const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
601
- if (!staged.ok) {
602
- log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
603
- return { ok: false, error: `staging failed (${staged.code})` };
791
+ /**
792
+ * Compare dotted numeric versions; returns -1/0/1. Non-numeric segments fall
793
+ * back to string comparison so prerelease identifiers stay deterministic.
794
+ */
795
+ export function compareVersions(left, right) {
796
+ const asParts = (value) => String(value ?? '').split('.');
797
+ const a = asParts(left);
798
+ const b = asParts(right);
799
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
800
+ const x = a[index] ?? '0';
801
+ const y = b[index] ?? '0';
802
+ const xn = /^\d+$/.test(x);
803
+ const yn = /^\d+$/.test(y);
804
+ if (xn && yn) {
805
+ const diff = Number(x) - Number(y);
806
+ if (diff !== 0) return diff < 0 ? -1 : 1;
807
+ } else if (xn !== yn) {
808
+ // npm semantics: a numeric release segment outranks a prerelease segment.
809
+ return xn ? 1 : -1;
810
+ } else if (x !== y) {
811
+ return x < y ? -1 : 1;
812
+ }
604
813
  }
605
- log(`✓ candidate payload staged and validated (${manifest.version})`);
606
-
607
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
608
- log('✓ durable release committed under Crew-owned state');
609
-
610
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
611
- if (!activated) return { ok: false, error: 'activation failed' };
612
-
613
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
614
-
615
- log('');
616
- log('Done.');
617
- log('Restart DeepSeek Harness and Codex Desktop.');
618
- return { ok: true, updated: true, version: manifest.version, path: releaseDir };
814
+ return 0;
619
815
  }
620
816
 
621
817
  export function npxStatus({
@@ -664,8 +860,18 @@ export function npxStatus({
664
860
  const codex = st?.codex?.installed ? 'installed' : 'not installed';
665
861
  const claude = st?.claude?.installed ? 'installed' : 'not installed';
666
862
 
667
- log(`DSH Crew CLI (npx candidate): ${candidateVersion ?? 'unknown'}`);
668
- log(`Installed DSH Crew: ${installedLine}`);
863
+ log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
864
+ log(`Installed DSH Crew payload: ${installedLine}`);
865
+ if (candidateVersion && installedVersion && candidateVersion !== installedVersion) {
866
+ const direction = compareVersions(candidateVersion, installedVersion);
867
+ if (direction > 0) {
868
+ log(`- launcher ${candidateVersion} is newer than the managed payload ${installedVersion}.`);
869
+ log(' Run: dsh-crew update');
870
+ } else {
871
+ log(`- managed payload ${installedVersion} is newer than the launcher ${candidateVersion}; the payload remains authoritative.`);
872
+ log(` Refresh the launcher with: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
873
+ }
874
+ }
669
875
  log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile; official web profile ignored)`);
670
876
  log(`Codex Desktop integration: ${codex}`);
671
877
  log(`Claude Code integration: ${claude}`);
@@ -736,25 +942,40 @@ export async function npxUninstall({
736
942
 
737
943
  // ---- CLI dispatch --------------------------------------------------------------
738
944
 
739
- export const USAGE = `usage: dsh-crew <command> [--purge]
945
+ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
740
946
 
741
947
  Commands:
742
948
  install persist the candidate package into Crew-owned state and register it
743
- status read-only report of candidate/installed versions and integrations
744
- update upgrade-aware update: stages/validates first, repairs, idempotent when current
949
+ status read-only report of launcher/installed versions and integrations
950
+ update resolve the newest permitted package from the configured npm registry (or
951
+ --candidate), stage and validate it, then activate; idempotent when current
745
952
  uninstall remove the Crew-managed payload, registration, and integrations (config kept)
746
953
 
747
954
  Options:
748
- --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
749
- --help show this help
955
+ --candidate <path> update from a local payload directory or packed .tgz instead of the registry
956
+ --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
957
+ --help show this help
750
958
 
959
+ Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
751
960
  Source checkouts use scripts/setup.mjs instead.`;
752
961
 
753
962
  function normalizeCommand(argv) {
754
963
  const flags = argv.slice(1);
964
+ let candidate;
965
+ for (let index = 0; index < flags.length; index += 1) {
966
+ if (flags[index] === '--candidate') {
967
+ candidate = flags[index + 1];
968
+ flags.splice(index, 2);
969
+ index -= 1;
970
+ } else if (flags[index]?.startsWith('--candidate=')) {
971
+ candidate = flags[index].slice('--candidate='.length);
972
+ flags.splice(index, 1);
973
+ index -= 1;
974
+ }
975
+ }
755
976
  const knownFlags = new Set(['--purge']);
756
977
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
757
- return { command: argv[0], purge: flags.includes('--purge'), unknown };
978
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, unknown };
758
979
  }
759
980
 
760
981
  /**
@@ -766,7 +987,7 @@ export async function runNpxCli({
766
987
  error = console.error,
767
988
  commands = {},
768
989
  } = {}) {
769
- const { command, purge, unknown } = normalizeCommand(argv);
990
+ const { command, purge, candidate, unknown } = normalizeCommand(argv);
770
991
  if (command === '--help' || command === '-h' || command === 'help') {
771
992
  log(USAGE);
772
993
  return 0;
@@ -786,9 +1007,10 @@ export async function runNpxCli({
786
1007
  update: commands.update ?? npxUpdate,
787
1008
  uninstall: commands.uninstall ?? npxUninstall,
788
1009
  };
789
- const result = command === 'uninstall'
790
- ? await actions.uninstall({ purge, log })
791
- : await actions[command]({ log });
1010
+ let result;
1011
+ if (command === 'uninstall') result = await actions.uninstall({ purge, log });
1012
+ else if (command === 'update') result = await actions.update({ candidate, log });
1013
+ else result = await actions[command]({ log });
792
1014
  return result?.ok === false ? 1 : 0;
793
1015
  } catch (err) {
794
1016
  error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.3';
11
+ export const RUNTIME_VERSION = '0.3.5';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([