@ran-sh/dsh-crew 0.5.6 → 0.5.7

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.
Files changed (39) hide show
  1. package/README.md +14 -7
  2. package/README.zh.md +12 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +345 -22
  6. package/official-web-bridge/lib/client.js +345 -22
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/index.tsx +227 -51
  10. package/src/config-readiness.mjs +56 -5
  11. package/src/extension-contract.mjs +9 -5
  12. package/src/hub/index.mjs +854 -445
  13. package/src/hub-client.mjs +24 -4
  14. package/src/hub-compatibility.mjs +23 -7
  15. package/src/install/install.mjs +4 -4
  16. package/src/install/npx-lifecycle.mjs +263 -118
  17. package/src/job-contracts.mjs +19 -3
  18. package/src/jobs.mjs +2 -4
  19. package/src/local-request-guard.mjs +60 -0
  20. package/src/mcp-runtime.mjs +7 -5
  21. package/src/model-routing.mjs +141 -49
  22. package/src/multimodal.mjs +0 -0
  23. package/src/official-web-bridge.mjs +242 -84
  24. package/src/policy.mjs +62 -31
  25. package/src/provider-config-scrub.mjs +71 -0
  26. package/src/provider-delete-adapters.mjs +424 -0
  27. package/src/provider-health.mjs +124 -0
  28. package/src/provider-inventory.mjs +132 -0
  29. package/src/provider-lifecycle-state.mjs +81 -0
  30. package/src/provider-lifecycle.mjs +214 -0
  31. package/src/provider-profile-store.mjs +171 -0
  32. package/src/readiness-matrix.mjs +11 -5
  33. package/src/removable-waiter.mjs +29 -0
  34. package/src/runtime-identity.mjs +43 -13
  35. package/src/server.mjs +34 -16
  36. package/src/status-shard.mjs +13 -2
  37. package/src/workflow-runtime.mjs +44 -10
  38. package/windows/start-dsh-crew.cmd +3 -3
  39. package/windows/start-dsh-crew.ps1 +49 -5
@@ -113,8 +113,26 @@ export function hubRequestError(body, status) {
113
113
  return err;
114
114
  }
115
115
 
116
- async function call(path, init) {
117
- const res = await fetch(`${API}${path}`, init);
116
+ // Transport deadlines: every Hub call is bounded so a wedged local HTTP
117
+ // exchange can never outlive the workflow timeout contract. Long polls get
118
+ // their requested wait plus grace; spawn gets a wider bound because worktree
119
+ // creation can clone sizeable repositories. The env override exists for
120
+ // deterministic deadline tests; production defaults stay at 10s.
121
+ const ENV_TIMEOUT_MS = Number(process.env.DSH_CREW_HUB_TIMEOUT_MS);
122
+ const DEFAULT_TIMEOUT_MS = Number.isInteger(ENV_TIMEOUT_MS) && ENV_TIMEOUT_MS > 0 ? ENV_TIMEOUT_MS : 10_000;
123
+ const SPAWN_TIMEOUT_MS = Math.max(DEFAULT_TIMEOUT_MS, 60_000);
124
+ const LONG_POLL_GRACE_MS = 5_000;
125
+
126
+ async function call(path, init, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
127
+ let res;
128
+ try {
129
+ res = await fetch(`${API}${path}`, { ...init, signal: AbortSignal.timeout(timeoutMs) });
130
+ } catch (err) {
131
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
132
+ throw hubRequestError({ error: `hub request timed out after ${timeoutMs}ms` }, 0);
133
+ }
134
+ throw err;
135
+ }
118
136
  const body = await res.json();
119
137
  if (!res.ok || body.ok === false) throw hubRequestError(body, res.status);
120
138
  return body;
@@ -125,8 +143,10 @@ export const hub = {
125
143
  method: 'POST',
126
144
  headers: { 'content-type': 'application/json' },
127
145
  body: JSON.stringify(spec),
128
- }).then((b) => b.job),
146
+ }, { timeoutMs: SPAWN_TIMEOUT_MS }).then((b) => b.job),
129
147
  list: () => call('/jobs').then((b) => b.jobs),
130
- get: (id, waitSeconds = 0) => call(`/jobs/${id}?wait=${waitSeconds}`).then((b) => b.job),
148
+ get: (id, waitSeconds = 0) => call(`/jobs/${id}?wait=${waitSeconds}`, undefined, {
149
+ timeoutMs: waitSeconds > 0 ? (waitSeconds * 1000) + LONG_POLL_GRACE_MS : DEFAULT_TIMEOUT_MS,
150
+ }).then((b) => b.job),
131
151
  cancel: (id) => call(`/jobs/${id}/cancel`, { method: 'POST' }).then((b) => b.job),
132
152
  };
@@ -13,9 +13,16 @@ export function hubCompatibilityMessage(status = {}) {
13
13
  return `DSH workers hub is reachable but incompatible (${code}${detail}). Update/restart the Hub plugin before using Hub execution.`;
14
14
  }
15
15
 
16
- export function resolveHubExecutionMode(requestedMode = 'auto', status = {}) {
17
- if (requestedMode === 'standalone') {
18
- return { ok: true, mode: 'standalone', reason: 'explicit-standalone' };
16
+ export function resolveHubExecutionMode(requestedMode = 'auto', status = {}, { productionOnly = false } = {}) {
17
+ if (requestedMode === 'standalone') {
18
+ if (productionOnly) {
19
+ return {
20
+ ok: false,
21
+ code: 'STANDALONE_EXECUTION_DISABLED',
22
+ error: 'Standalone execution is disabled for the production 3210 Crew transport.',
23
+ };
24
+ }
25
+ return { ok: true, mode: 'standalone', reason: 'explicit-standalone' };
19
26
  }
20
27
 
21
28
  if (status.compatible === true) {
@@ -31,16 +38,25 @@ export function resolveHubExecutionMode(requestedMode = 'auto', status = {}) {
31
38
  };
32
39
  }
33
40
 
34
- if (requestedMode === 'hub') {
41
+ if (requestedMode === 'hub') {
35
42
  return {
36
43
  ok: false,
37
44
  code: status.code ?? HUB_COMPATIBILITY_CODES.UNREACHABLE,
38
45
  error: 'Session mode is "hub" but the DSH workers hub is not reachable.',
39
46
  hub: status,
40
47
  };
41
- }
42
-
43
- return {
48
+ }
49
+
50
+ if (productionOnly) {
51
+ return {
52
+ ok: false,
53
+ code: status.code ?? HUB_COMPATIBILITY_CODES.UNREACHABLE,
54
+ error: `Production 3210 Crew transport is unavailable (${status.code ?? HUB_COMPATIBILITY_CODES.UNREACHABLE}).`,
55
+ hub: status,
56
+ };
57
+ }
58
+
59
+ return {
44
60
  ok: true,
45
61
  mode: 'standalone',
46
62
  reason: 'hub-unreachable-fallback',
@@ -93,12 +93,12 @@ function legacySnapshotFrom(config = {}) {
93
93
  };
94
94
  }
95
95
 
96
- function attachReadMetadata(config, { version, canonical }) {
97
- return {
96
+ function attachReadMetadata(config, { version, canonical }) {
97
+ return {
98
98
  ...config,
99
99
  config_schema_version: version,
100
- config_authority: canonical ? 'canonical' : 'legacy-import',
101
- config_migration_required: !canonical,
100
+ config_authority: canonical ? 'canonical' : 'legacy-import',
101
+ config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
102
102
  config_activation: runtimeActivationMetadata(),
103
103
  };
104
104
  }
@@ -31,6 +31,7 @@ import {
31
31
  readFileSync,
32
32
  realpathSync,
33
33
  rmSync,
34
+ copyFileSync,
34
35
  writeFileSync,
35
36
  } from 'node:fs';
36
37
  import { createRequire } from 'node:module';
@@ -49,23 +50,23 @@ import {
49
50
  export const CREW_APP_DIRNAME = 'app';
50
51
  export const RELEASES_DIRNAME = 'releases';
51
52
  export const CURRENT_POINTER_FILENAME = 'current.json';
52
- export const KEEP_RELEASES = 2;
53
- export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
54
-
55
- export function npmCliInvocation(args, {
56
- platform = process.platform,
57
- environment = process.env,
58
- } = {}) {
59
- if (platform !== 'win32') return { command: 'npm', args: [...args] };
60
- const unsafe = args.find((arg) => /[\0\r\n"%!^&|<>]/.test(String(arg)));
61
- if (unsafe !== undefined) throw new Error(`unsafe npm argument: ${unsafe}`);
62
- const commandLine = ['npm.cmd', ...args.map((arg) => `"${String(arg)}"`)].join(' ');
63
- return {
64
- command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
65
- args: ['/d', '/s', '/c', commandLine],
66
- windowsVerbatimArguments: true,
67
- };
68
- }
53
+ export const KEEP_RELEASES = 2;
54
+ export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
55
+
56
+ export function npmCliInvocation(args, {
57
+ platform = process.platform,
58
+ environment = process.env,
59
+ } = {}) {
60
+ if (platform !== 'win32') return { command: 'npm', args: [...args] };
61
+ const unsafe = args.find((arg) => /[\0\r\n"%!^&|<>]/.test(String(arg)));
62
+ if (unsafe !== undefined) throw new Error(`unsafe npm argument: ${unsafe}`);
63
+ const commandLine = ['npm.cmd', ...args.map((arg) => `"${String(arg)}"`)].join(' ');
64
+ return {
65
+ command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
66
+ args: ['/d', '/s', '/c', commandLine],
67
+ windowsVerbatimArguments: true,
68
+ };
69
+ }
69
70
 
70
71
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
71
72
 
@@ -232,18 +233,18 @@ export function copyProductionDependencyTree({
232
233
  return { copied, missing, missingOptional };
233
234
  }
234
235
 
235
- function defaultNpmInstaller(stageRoot, log) {
236
- const npmArgs = [
237
- 'install', '--prefix', stageRoot,
238
- '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
239
- '--no-package-lock', '--loglevel=error',
240
- ];
241
- const invocation = npmCliInvocation(npmArgs);
242
- const result = spawnSync(invocation.command, invocation.args, {
243
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 600_000,
244
- windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments,
245
- env: sanitizedPackageManagerEnv(),
246
- });
236
+ function defaultNpmInstaller(stageRoot, log) {
237
+ const npmArgs = [
238
+ 'install', '--prefix', stageRoot,
239
+ '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
240
+ '--no-package-lock', '--loglevel=error',
241
+ ];
242
+ const invocation = npmCliInvocation(npmArgs);
243
+ const result = spawnSync(invocation.command, invocation.args, {
244
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 600_000,
245
+ windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments,
246
+ env: sanitizedPackageManagerEnv(),
247
+ });
247
248
  if (result.status !== 0) {
248
249
  log(`- npm fallback install failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-300)}`);
249
250
  return false;
@@ -569,30 +570,30 @@ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
569
570
  }
570
571
  log(`✓ Harness plugin registered (dedicated dsh-crew profile → ${releaseDir})`);
571
572
 
572
- const codex = installer.installCodex({ home, root: releaseDir });
573
+ const codex = installer.installCodex({ home, root: releaseDir });
573
574
  if (codex.ok === false) {
574
575
  log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
575
576
  return false;
576
577
  }
577
- log('✓ Codex Desktop integration');
578
-
579
- if (installer.installZCode) {
580
- const zcode = installer.installZCode({ home, root: releaseDir });
581
- if (zcode.ok === false) {
582
- log(`✗ ZCode integration failed (${zcode.code ?? 'unknown'})`);
583
- return false;
584
- }
585
- log('✓ ZCode integration');
586
- }
587
-
588
- const startup = installer.installWindowsStartup?.({ home, root: releaseDir });
589
- if (startup?.ok === false) {
590
- log(`✗ Windows login startup failed (${startup.code ?? 'unknown'})`);
591
- return false;
592
- }
593
- if (startup?.supported) log('✓ Windows login startup');
594
-
595
- const claude = await installer.installClaudeCode({ home, root: releaseDir });
578
+ log('✓ Codex Desktop integration');
579
+
580
+ if (installer.installZCode) {
581
+ const zcode = installer.installZCode({ home, root: releaseDir });
582
+ if (zcode.ok === false) {
583
+ log(`✗ ZCode integration failed (${zcode.code ?? 'unknown'})`);
584
+ return false;
585
+ }
586
+ log('✓ ZCode integration');
587
+ }
588
+
589
+ const startup = installer.installWindowsStartup?.({ home, root: releaseDir });
590
+ if (startup?.ok === false) {
591
+ log(`✗ Windows login startup failed (${startup.code ?? 'unknown'})`);
592
+ return false;
593
+ }
594
+ if (startup?.supported) log('✓ Windows login startup');
595
+
596
+ const claude = await installer.installClaudeCode({ home, root: releaseDir });
596
597
  if (claude.ok === false) {
597
598
  log(`✗ Claude Code integration failed`);
598
599
  return false;
@@ -700,8 +701,14 @@ export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
700
701
  */
701
702
  export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
702
703
  mkdirSync(destDir, { recursive: true });
703
- const result = runner('tar', ['-xzf', String(tgzPath), '-C', destDir], {
704
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
704
+ // GNU tar interprets a `C:\...` argument as a remote rsh host ("Cannot
705
+ // connect to C:"), so anchor the invocation inside destDir and pass the
706
+ // archive by basename: every argument stays colon-free for both bsdtar
707
+ // (Windows 10+) and GNU tar, whatever PATH resolves first.
708
+ const localArchive = join(destDir, 'candidate.tgz');
709
+ copyFileSync(tgzPath, localArchive);
710
+ const result = runner('tar', ['-xzf', 'candidate.tgz'], {
711
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, cwd: destDir,
705
712
  });
706
713
  const root = join(destDir, 'package');
707
714
  if (result.status !== 0 || !existsSync(join(root, 'package.json'))) {
@@ -779,15 +786,15 @@ export function resolveUpdateCandidate({
779
786
  mkdirSync(tmpDir, { recursive: true });
780
787
  try {
781
788
  const effectiveSpec = process.env.DSH_CREW_UPDATE_SPEC ?? spec;
782
- const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
783
- log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
784
- const invocation = npmCliInvocation(packArgs);
785
- const packed = runner(invocation.command, invocation.args, {
786
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
787
- timeout: 600_000, windowsHide: true,
788
- windowsVerbatimArguments: invocation.windowsVerbatimArguments,
789
- env: sanitizedPackageManagerEnv(),
790
- });
789
+ const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
790
+ log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
791
+ const invocation = npmCliInvocation(packArgs);
792
+ const packed = runner(invocation.command, invocation.args, {
793
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
794
+ timeout: 600_000, windowsHide: true,
795
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
796
+ env: sanitizedPackageManagerEnv(),
797
+ });
791
798
  if (packed.status !== 0) {
792
799
  return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: `${effectiveSpec}: ${(packed.stderr || packed.stdout || '').trim().slice(-300)}` };
793
800
  }
@@ -982,17 +989,17 @@ export function npxStatus({
982
989
  } catch { dshPlugin = 'unknown'; }
983
990
  }
984
991
 
985
- const st = installer.installStatus
986
- ? installer.installStatus({ home, root: pointer?.path ?? runningPackageRoot() })
987
- : realInstaller.installStatus({ home, root: pointer?.path ?? runningPackageRoot() });
988
- const codex = st?.codex?.installed ? 'installed' : 'not installed';
989
- const zcode = st?.zcode?.installed ? 'installed' : 'not installed';
990
- const claude = st?.claude?.installed ? 'installed' : 'not installed';
992
+ const st = installer.installStatus
993
+ ? installer.installStatus({ home, root: pointer?.path ?? runningPackageRoot() })
994
+ : realInstaller.installStatus({ home, root: pointer?.path ?? runningPackageRoot() });
995
+ const codex = st?.codex?.installed ? 'installed' : 'not installed';
996
+ const zcode = st?.zcode?.installed ? 'installed' : 'not installed';
997
+ const claude = st?.claude?.installed ? 'installed' : 'not installed';
991
998
  const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
992
- const officialWeb = !official.enabled ? 'disabled' : official.healthy ? 'installed' : 'needs repair';
993
- const startupState = installer.windowsStartupStatus?.({ home });
994
- const windowsStartup = !startupState?.supported ? 'not supported'
995
- : startupState.ready ? 'installed' : startupState.installed ? 'needs repair' : 'not installed';
999
+ const officialWeb = !official.enabled ? 'disabled' : official.healthy ? 'installed' : 'needs repair';
1000
+ const startupState = installer.windowsStartupStatus?.({ home });
1001
+ const windowsStartup = !startupState?.supported ? 'not supported'
1002
+ : startupState.ready ? 'installed' : startupState.installed ? 'needs repair' : 'not installed';
996
1003
 
997
1004
  log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
998
1005
  log(`Installed DSH Crew payload: ${installedLine}`);
@@ -1008,10 +1015,10 @@ export function npxStatus({
1008
1015
  }
1009
1016
  log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile on 3210)`);
1010
1017
  log(`Official 3080 UI bridge: ${officialWeb}`);
1011
- log(`Codex Desktop integration: ${codex}`);
1012
- log(`ZCode integration: ${zcode}`);
1013
- log(`Claude Code integration: ${claude}`);
1014
- log(`Windows login startup: ${windowsStartup}`);
1018
+ log(`Codex Desktop integration: ${codex}`);
1019
+ log(`ZCode integration: ${zcode}`);
1020
+ log(`Claude Code integration: ${claude}`);
1021
+ log(`Windows login startup: ${windowsStartup}`);
1015
1022
 
1016
1023
  return {
1017
1024
  ok: true,
@@ -1020,11 +1027,11 @@ export function npxStatus({
1020
1027
  installedPath: pointer?.path ?? null,
1021
1028
  dshPlugin,
1022
1029
  officialWeb,
1023
- codex,
1024
- zcode,
1025
- claude,
1026
- windowsStartup,
1027
- };
1030
+ codex,
1031
+ zcode,
1032
+ claude,
1033
+ windowsStartup,
1034
+ };
1028
1035
  }
1029
1036
 
1030
1037
  export async function npxUninstall({
@@ -1040,23 +1047,23 @@ export async function npxUninstall({
1040
1047
  const pointer = readCurrentPointer({ home });
1041
1048
  const name = pointer?.name ?? readManifest(runningPackageRoot())?.name;
1042
1049
 
1043
- const cx = installer.uninstallCodex({ home });
1050
+ const cx = installer.uninstallCodex({ home });
1044
1051
  if (cx.ok !== false) log('✓ Codex Desktop integration removed');
1045
- else fail('Codex Desktop integration removal failed');
1046
-
1047
- if (installer.uninstallZCode) {
1048
- const zc = installer.uninstallZCode({ home });
1049
- if (zc.ok !== false) log('✓ ZCode integration removed');
1050
- else fail('ZCode integration removal failed');
1051
- }
1052
+ else fail('Codex Desktop integration removal failed');
1052
1053
 
1053
- const cl = installer.uninstallClaudeCode ? await installer.uninstallClaudeCode({ home }) : realInstaller.uninstallClaudeCode({ home });
1054
- if (cl.ok !== false) log('✓ Claude Code integration removed');
1055
- else fail('Claude Code integration removal failed');
1056
-
1057
- const startup = installer.uninstallWindowsStartup?.({ home });
1058
- if (startup?.ok === false) fail('Windows login startup removal failed');
1059
- else if (startup?.supported) log('✓ Windows login startup removed');
1054
+ if (installer.uninstallZCode) {
1055
+ const zc = installer.uninstallZCode({ home });
1056
+ if (zc.ok !== false) log('✓ ZCode integration removed');
1057
+ else fail('ZCode integration removal failed');
1058
+ }
1059
+
1060
+ const cl = installer.uninstallClaudeCode ? await installer.uninstallClaudeCode({ home }) : realInstaller.uninstallClaudeCode({ home });
1061
+ if (cl.ok !== false) log('✓ Claude Code integration removed');
1062
+ else fail('Claude Code integration removal failed');
1063
+
1064
+ const startup = installer.uninstallWindowsStartup?.({ home });
1065
+ if (startup?.ok === false) fail('Windows login startup removal failed');
1066
+ else if (startup?.supported) log('✓ Windows login startup removed');
1060
1067
 
1061
1068
  const official = removeOfficialWebIntegration({ home, preserveIntent: !purge, remember: !purge });
1062
1069
  if (!official.ok) fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
@@ -1103,8 +1110,9 @@ Commands:
1103
1110
  integrate show Crew inside the official 3080 UI; backend stays isolated on 3210
1104
1111
  detach remove only the official 3080 bridge; isolated 3210 mode remains available
1105
1112
  status read-only report of launcher/installed versions and integrations
1106
- inspect print the machine-readable extension capability/readiness contract
1107
- jobs machine-first job API: list|get|watch|cancel|submit
1113
+ inspect print the machine-readable extension capability/readiness contract
1114
+ jobs machine-first job API: list|get|watch|cancel|submit
1115
+ providers 3210 provider lifecycle API: list|delete-plan|delete|rollback|probe
1108
1116
  update resolve the newest permitted package from the configured npm registry (or
1109
1117
  --candidate), stage and validate it, then activate; idempotent when current
1110
1118
  uninstall remove the Crew-managed payload, registration, and integrations (config kept)
@@ -1113,7 +1121,11 @@ Options:
1113
1121
  --candidate <path> update from a local payload directory or packed .tgz instead of the registry
1114
1122
  --after <sequence> with jobs watch/get: return canonical events after this cursor
1115
1123
  --detail <mode> with jobs get/watch: compact (default) or full
1116
- --request <path> with jobs submit: JSON Job Request document
1124
+ --request <path> with jobs submit: JSON Job Request document
1125
+ --plan <id> with providers delete: approved plan id
1126
+ --expected-revision <sha256> with providers delete: profile revision from delete-plan
1127
+ --replacement-default <id> with providers delete-plan: replacement Harness Default provider
1128
+ --confirm with providers delete: confirm the destructive mutation
1117
1129
  --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
1118
1130
  --help show this help
1119
1131
 
@@ -1136,7 +1148,7 @@ export async function npxInspect({
1136
1148
  return { ok: true, extension: body.extension };
1137
1149
  }
1138
1150
 
1139
- export async function npxJobs({
1151
+ export async function npxJobs({
1140
1152
  args = [],
1141
1153
  after = 0,
1142
1154
  detail = 'compact',
@@ -1169,15 +1181,129 @@ export async function npxJobs({
1169
1181
  const body = await response.json();
1170
1182
  if (!response.ok || body?.ok === false) throw new Error(body?.error ?? 'Crew jobs API unavailable');
1171
1183
  log(JSON.stringify(body, null, 2));
1172
- return { ok: true, body };
1173
- }
1174
-
1175
- function normalizeCommand(argv) {
1176
- const flags = argv.slice(1);
1177
- let candidate;
1178
- let after = 0;
1179
- let detail = 'compact';
1180
- let request;
1184
+ return { ok: true, body };
1185
+ }
1186
+
1187
+ /**
1188
+ * Call the isolated 3210 provider lifecycle API. The CLI never parses or
1189
+ * edits Harness YAML itself; the Hub remains the sole mutation authority.
1190
+ */
1191
+ export async function npxProviders({
1192
+ args = [],
1193
+ planId,
1194
+ expectedRevision,
1195
+ replacementDefault,
1196
+ confirm = false,
1197
+ purgeOrphanCredentials = false,
1198
+ log = console.log,
1199
+ fetchImpl = globalThis.fetch,
1200
+ readConfig = realInstaller.readGlobalConfig,
1201
+ } = {}) {
1202
+ const configuredHubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
1203
+ if (configuredHubUrl !== 'http://127.0.0.1:3210') throw new Error('providers CLI requires the isolated 3210 Crew Hub');
1204
+ const hubUrl = configuredHubUrl;
1205
+ const base = `${hubUrl}/_dsh/dsh-crew/providers`;
1206
+ const action = args[0] ?? 'list';
1207
+ const id = args[1];
1208
+ const options = new Map();
1209
+ for (let index = 2; index < args.length; index += 1) {
1210
+ const value = args[index];
1211
+ if (!value?.startsWith('--')) continue;
1212
+ const [name, inline] = value.split('=', 2);
1213
+ if (name === '--confirm' || name === '--purge-orphan-credentials') options.set(name === '--confirm' ? 'confirm' : name, true);
1214
+ else if (inline !== undefined) options.set(name, inline);
1215
+ else if (args[index + 1] !== undefined) options.set(name, args[++index]);
1216
+ }
1217
+ const resolvedPlan = planId ?? options.get('--plan');
1218
+ const resolvedRevision = expectedRevision ?? options.get('--expected-revision');
1219
+ const resolvedReplacement = replacementDefault ?? options.get('--replacement-default');
1220
+ const resolvedConfirm = confirm === true || options.get('confirm') === true;
1221
+ if (purgeOrphanCredentials === true || options.get('--purge-orphan-credentials') === true) {
1222
+ throw new Error('credential purge requires a separate explicit confirmation flow');
1223
+ }
1224
+ let url = base;
1225
+ let init = { headers: { accept: 'application/json' } };
1226
+ if (action === 'list') {
1227
+ // keep defaults
1228
+ } else if (action === 'delete-plan') {
1229
+ if (!id) throw new Error('providers delete-plan requires a provider id');
1230
+ url = `${base}/${encodeURIComponent(id)}/delete-plan`;
1231
+ init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(resolvedReplacement ? { replacement_default: resolvedReplacement } : {}) }) };
1232
+ } else if (action === 'delete') {
1233
+ if (!id) throw new Error('providers delete requires a provider id');
1234
+ if (!resolvedPlan || !resolvedRevision || !resolvedConfirm) throw new Error('providers delete requires --plan, --expected-revision and --confirm');
1235
+ url = `${base}/${encodeURIComponent(id)}`;
1236
+ init = {
1237
+ method: 'DELETE',
1238
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1239
+ body: JSON.stringify({ plan_id: resolvedPlan, expected_revision: resolvedRevision, confirm: true }),
1240
+ };
1241
+ } else if (action === 'probe') {
1242
+ if (!id) throw new Error('providers probe requires a provider id');
1243
+ url = `${base}/${encodeURIComponent(id)}/probe`;
1244
+ init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' };
1245
+ } else if (action === 'rollback') {
1246
+ if (!id) throw new Error('providers rollback requires a provider id');
1247
+ if (!resolvedPlan || !resolvedConfirm) throw new Error('providers rollback requires --plan and --confirm');
1248
+ url = `${base}/${encodeURIComponent(id)}/rollback`;
1249
+ init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
1250
+ } else {
1251
+ throw new Error(`unknown providers action: ${action}`);
1252
+ }
1253
+ const response = await fetchImpl(url, init);
1254
+ let body = await response.json();
1255
+ if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew provider API unavailable');
1256
+ if (action === 'delete' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
1257
+ const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1258
+ method: 'POST',
1259
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1260
+ body: JSON.stringify({ confirm: true }),
1261
+ });
1262
+ const restartBody = await restartResponse.json();
1263
+ if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
1264
+ const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-delete`;
1265
+ const verifyResponse = await fetchImpl(verifyUrl, {
1266
+ method: 'POST',
1267
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1268
+ body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1269
+ });
1270
+ const verifyBody = await verifyResponse.json();
1271
+ if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider deletion verification failed');
1272
+ body = { ...body, restart: restartBody, verification: verifyBody };
1273
+ }
1274
+ if (action === 'rollback' && body?.restart_required === true && body?.state === 'ROLLBACK_PENDING') {
1275
+ const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1276
+ method: 'POST',
1277
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1278
+ body: JSON.stringify({ confirm: true }),
1279
+ });
1280
+ const restartBody = await restartResponse.json();
1281
+ if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
1282
+ const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-rollback`;
1283
+ const verifyResponse = await fetchImpl(verifyUrl, {
1284
+ method: 'POST',
1285
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1286
+ body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1287
+ });
1288
+ const verifyBody = await verifyResponse.json();
1289
+ if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider rollback verification failed');
1290
+ body = { ...body, restart: restartBody, verification: verifyBody };
1291
+ }
1292
+ log(JSON.stringify(body, null, 2));
1293
+ return { ok: true, body };
1294
+ }
1295
+
1296
+ function normalizeCommand(argv) {
1297
+ const flags = argv.slice(1);
1298
+ let candidate;
1299
+ let after = 0;
1300
+ let detail = 'compact';
1301
+ let request;
1302
+ let planId;
1303
+ let expectedRevision;
1304
+ let replacementDefault;
1305
+ let confirm = false;
1306
+ let purgeOrphanCredentials = false;
1181
1307
  for (let index = 0; index < flags.length; index += 1) {
1182
1308
  if (flags[index] === '--candidate') {
1183
1309
  candidate = flags[index + 1];
@@ -1199,17 +1325,34 @@ function normalizeCommand(argv) {
1199
1325
  after = Number(flags[index].slice('--after='.length)); flags.splice(index, 1); index -= 1;
1200
1326
  } else if (flags[index]?.startsWith('--detail=')) {
1201
1327
  detail = flags[index].slice('--detail='.length); flags.splice(index, 1); index -= 1;
1202
- } else if (flags[index]?.startsWith('--request=')) {
1203
- request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
1204
- }
1328
+ } else if (flags[index]?.startsWith('--request=')) {
1329
+ request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
1330
+ } else if (flags[index] === '--plan' || flags[index] === '--expected-revision' || flags[index] === '--replacement-default') {
1331
+ const name = flags[index];
1332
+ const value = flags[index + 1];
1333
+ if (name === '--plan') planId = value;
1334
+ if (name === '--expected-revision') expectedRevision = value;
1335
+ if (name === '--replacement-default') replacementDefault = value;
1336
+ flags.splice(index, 2); index -= 1;
1337
+ } else if (flags[index]?.startsWith('--plan=')) {
1338
+ planId = flags[index].slice('--plan='.length); flags.splice(index, 1); index -= 1;
1339
+ } else if (flags[index]?.startsWith('--expected-revision=')) {
1340
+ expectedRevision = flags[index].slice('--expected-revision='.length); flags.splice(index, 1); index -= 1;
1341
+ } else if (flags[index]?.startsWith('--replacement-default=')) {
1342
+ replacementDefault = flags[index].slice('--replacement-default='.length); flags.splice(index, 1); index -= 1;
1343
+ } else if (flags[index] === '--confirm') {
1344
+ confirm = true; flags.splice(index, 1); index -= 1;
1345
+ } else if (flags[index] === '--purge-orphan-credentials') {
1346
+ purgeOrphanCredentials = true; flags.splice(index, 1); index -= 1;
1347
+ }
1205
1348
  }
1206
1349
  const knownFlags = new Set(['--purge']);
1207
1350
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
1208
1351
  const args = flags.filter((f) => !f.startsWith('--'));
1209
1352
  if (!Number.isInteger(after) || after < 0) unknown.push('--after');
1210
1353
  if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
1211
- return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, args, unknown };
1212
- }
1354
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown };
1355
+ }
1213
1356
 
1214
1357
  /**
1215
1358
  * CLI dispatcher used by bin/dsh-crew.mjs. Returns a process exit code.
@@ -1220,7 +1363,7 @@ export async function runNpxCli({
1220
1363
  error = console.error,
1221
1364
  commands = {},
1222
1365
  } = {}) {
1223
- const { command, purge, candidate, after, detail, request, args, unknown } = normalizeCommand(argv);
1366
+ const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
1224
1367
  if (command === '--help' || command === '-h' || command === 'help') {
1225
1368
  log(USAGE);
1226
1369
  return 0;
@@ -1229,7 +1372,7 @@ export async function runNpxCli({
1229
1372
  error(USAGE);
1230
1373
  return 1;
1231
1374
  }
1232
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'update', 'uninstall'].includes(command)) {
1375
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'update', 'uninstall'].includes(command)) {
1233
1376
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
1234
1377
  return 1;
1235
1378
  }
@@ -1239,15 +1382,17 @@ export async function runNpxCli({
1239
1382
  integrate: commands.integrate ?? npxIntegrate,
1240
1383
  detach: commands.detach ?? npxDetach,
1241
1384
  status: commands.status ?? npxStatus,
1242
- inspect: commands.inspect ?? npxInspect,
1243
- jobs: commands.jobs ?? npxJobs,
1385
+ inspect: commands.inspect ?? npxInspect,
1386
+ jobs: commands.jobs ?? npxJobs,
1387
+ providers: commands.providers ?? npxProviders,
1244
1388
  update: commands.update ?? npxUpdate,
1245
1389
  uninstall: commands.uninstall ?? npxUninstall,
1246
1390
  };
1247
1391
  let result;
1248
1392
  if (command === 'uninstall') result = await actions.uninstall({ purge, log });
1249
- else if (command === 'update') result = await actions.update({ candidate, log });
1250
- else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
1393
+ else if (command === 'update') result = await actions.update({ candidate, log });
1394
+ else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
1395
+ else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
1251
1396
  else result = await actions[command]({ log });
1252
1397
  return result?.ok === false ? 1 : 0;
1253
1398
  } catch (err) {