@ran-sh/dsh-crew 2.2.1 → 2.2.3

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.
@@ -0,0 +1,168 @@
1
+ // Desktop-client integration for the official DSH desktop application.
2
+ //
3
+ // The desktop app (an Electron client) boots its own Harness against ~/.dsh with
4
+ // the app-exclusive `desktop` profile — the CLI refuses to manage that profile —
5
+ // and applies that profile's user patch layer, which the Harness hot-watches. So
6
+ // the only way the desktop app shows the Crew panel is an insert in
7
+ // <official home>/profiles/desktop/cordis.patch.yml pointing at the Crew web
8
+ // bridge snapshot. That file lives in the official home, which Crew otherwise
9
+ // treats as read-only: this module is reachable ONLY from the explicit
10
+ // `dsh-crew desktop attach|detach|status` commands and never runs during install,
11
+ // update or any supervision path. attach is idempotent and re-points a stale
12
+ // revision; detach removes exactly the block this module wrote, leaving the rest
13
+ // of the file byte for byte.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const BRIDGE_ID = 'dsh-crew-official-web-bridge';
21
+ const BRIDGE_PACKAGE = '@ran-sh/dsh-crew-web-bridge';
22
+ const MARKER = '# dsh-crew desktop bridge (managed by dsh-crew; remove with: dsh-crew desktop detach)';
23
+ const REVISION_RE = /^[a-f0-9]{64}$/;
24
+
25
+ export function desktopPatchFile({ home = homedir() } = {}) {
26
+ return join(home, '.dsh', 'profiles', 'desktop', 'cordis.patch.yml');
27
+ }
28
+
29
+ /** The bridge entry the Crew launcher itself would load, straight from the overlay. */
30
+ export function crewBridgeEntry({ home = homedir() } = {}) {
31
+ const overlayFile = join(home, '.config', 'dsh-crew', 'frontend', 'official-web.patch.json');
32
+ let parsed;
33
+ try { parsed = JSON.parse(readFileSync(overlayFile, 'utf8')); } catch { return { ok: false, code: 'CREW_BRIDGE_OVERLAY_UNAVAILABLE', overlayFile }; }
34
+ const root = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : null;
35
+ const insert = root && Array.isArray(root.insert) && root.insert.length === 1 ? root.insert[0] : null;
36
+ const entryUri = insert && typeof insert.name === 'string' ? insert.name : null;
37
+ if (!entryUri || insert.id !== BRIDGE_ID || !entryUri.startsWith('file://')) {
38
+ return { ok: false, code: 'CREW_BRIDGE_OVERLAY_INVALID', overlayFile };
39
+ }
40
+ let entryPath;
41
+ try { entryPath = fileURLToPath(entryUri); } catch { return { ok: false, code: 'CREW_BRIDGE_OVERLAY_INVALID', overlayFile }; }
42
+ if (!existsSync(entryPath)) return { ok: false, code: 'CREW_BRIDGE_SNAPSHOT_MISSING', overlayFile, entry: entryUri };
43
+ // The launcher checks the same pair before it starts a frontend: the snapshot's
44
+ // own manifest must claim to be Crew-managed and must agree with the revision
45
+ // its directory is named after.
46
+ const revision = basename(dirname(dirname(entryPath)));
47
+ let manifest;
48
+ try { manifest = JSON.parse(readFileSync(join(dirname(entryPath), 'package.json'), 'utf8')); } catch { manifest = null; }
49
+ if (!manifest || manifest.name !== BRIDGE_PACKAGE || manifest.dshCrewManagedFrontend !== true
50
+ || manifest.dshCrewFrontendRevision !== revision || !REVISION_RE.test(revision)) {
51
+ return { ok: false, code: 'CREW_BRIDGE_SNAPSHOT_INVALID', overlayFile, entry: entryUri, revision };
52
+ }
53
+ return { ok: true, overlayFile, entry: entryUri, entryPath, revision };
54
+ }
55
+
56
+ function blockFor(entryUri, eol) {
57
+ return [
58
+ MARKER,
59
+ '- insert:',
60
+ ` - id: ${BRIDGE_ID}`,
61
+ ` name: '${entryUri}'`,
62
+ '',
63
+ ].join(eol);
64
+ }
65
+
66
+ function blockPattern(eol) {
67
+ const e = eol === '\r\n' ? '\\r\\n' : '\\n';
68
+ return new RegExp(
69
+ `^${MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${e}- insert:${e} {4}- id: ${BRIDGE_ID}${e} {6}name: '[^'${e}]*'${e}?`,
70
+ 'm',
71
+ );
72
+ }
73
+
74
+ function detectsEol(text) {
75
+ return text.includes('\r\n') ? '\r\n' : '\n';
76
+ }
77
+
78
+ // Fail closed on a patch layer whose shape this module does not understand: a
79
+ // top-level YAML array of patch entries. Anything else is somebody's file.
80
+ function looksLikePatchArray(text) {
81
+ const firstMeaningful = text.split(/\r?\n/).find((line) => line.trim() !== '' && !line.trimStart().startsWith('#'));
82
+ if (firstMeaningful === undefined) return true;
83
+ const trimmed = firstMeaningful.trimStart();
84
+ return trimmed.startsWith('-') || trimmed === '[]' || trimmed === '---';
85
+ }
86
+
87
+ export function desktopStatus({ home = homedir() } = {}) {
88
+ const patchFile = desktopPatchFile({ home });
89
+ const bridge = crewBridgeEntry({ home });
90
+ let text = null;
91
+ try { text = readFileSync(patchFile, 'utf8'); } catch { text = null; }
92
+ const eol = text === null ? '\n' : detectsEol(text);
93
+ const block = text === null ? null : blockPattern(eol).exec(text);
94
+ return {
95
+ ok: true,
96
+ path: patchFile,
97
+ file_present: text !== null,
98
+ bridge: bridge.ok ? { entry: bridge.entry, revision: bridge.revision } : null,
99
+ bridge_error: bridge.ok ? null : bridge.code,
100
+ attached: block !== null,
101
+ current: block !== null && bridge.ok ? block[0].includes(bridge.entry) : false,
102
+ };
103
+ }
104
+
105
+ function writeAtomic(file, text) {
106
+ const temp = `${file}.dsh-crew.${process.pid}.tmp`;
107
+ writeFileSync(temp, text);
108
+ renameSync(temp, file);
109
+ }
110
+
111
+ export function desktopAttach({ home = homedir(), dryRun = false, log = () => {} } = {}) {
112
+ const bridge = crewBridgeEntry({ home });
113
+ if (!bridge.ok) return { ok: false, code: bridge.code, error: `Crew bridge snapshot is unavailable (${bridge.code})` };
114
+ const patchFile = desktopPatchFile({ home });
115
+ let text = null;
116
+ try { text = readFileSync(patchFile, 'utf8'); } catch { text = null; }
117
+ const eol = text === null ? '\n' : detectsEol(text);
118
+ if (text !== null && !looksLikePatchArray(text)) {
119
+ return { ok: false, code: 'DESKTOP_PATCH_SHAPE_UNSUPPORTED', error: `${patchFile} is not a top-level patch array; refusing to edit it`, path: patchFile };
120
+ }
121
+ const block = blockFor(bridge.entry, eol);
122
+ const withoutBlock = text === null ? null : text.replace(blockPattern(eol), '');
123
+ const current = withoutBlock !== null && text.includes(bridge.entry) && withoutBlock !== text;
124
+ const base = withoutBlock ?? '';
125
+ const separator = base === '' || base.endsWith('\n') ? '' : eol;
126
+ const next = `${base}${separator}${block}`;
127
+ if (text !== null && next === text) {
128
+ log(`- desktop app already carries the Crew bridge for revision ${bridge.revision}`);
129
+ return { ok: true, changed: false, path: patchFile, entry: bridge.entry, revision: bridge.revision };
130
+ }
131
+ if (dryRun) {
132
+ log(`- would ${current ? 're-point' : 'attach'} the Crew bridge in ${patchFile} (revision ${bridge.revision})`);
133
+ return { ok: true, changed: true, dryRun: true, path: patchFile, entry: bridge.entry, revision: bridge.revision };
134
+ }
135
+ mkdirSync(dirname(patchFile), { recursive: true });
136
+ writeAtomic(patchFile, next);
137
+ log(`✓ desktop app ${current ? 're-pointed to' : 'attached to'} the Crew bridge (revision ${bridge.revision})`);
138
+ log(' the Harness hot-watches this patch layer; if the panel does not appear, restart the desktop app');
139
+ return { ok: true, changed: true, path: patchFile, entry: bridge.entry, revision: bridge.revision };
140
+ }
141
+
142
+ export function desktopDetach({ home = homedir(), dryRun = false, log = () => {} } = {}) {
143
+ const patchFile = desktopPatchFile({ home });
144
+ let text;
145
+ try { text = readFileSync(patchFile, 'utf8'); } catch {
146
+ log('- desktop patch layer is absent; nothing to remove');
147
+ return { ok: true, changed: false, path: patchFile };
148
+ }
149
+ const eol = detectsEol(text);
150
+ const next = text.replace(blockPattern(eol), '');
151
+ if (next === text) {
152
+ log('- the Crew bridge is not present in the desktop patch layer');
153
+ return { ok: true, changed: false, path: patchFile };
154
+ }
155
+ if (dryRun) return { ok: true, changed: true, dryRun: true, path: patchFile };
156
+ // A file this module created holds only its own block: remove the file rather
157
+ // than leave an empty one behind.
158
+ if (next.trim() === '') {
159
+ rmSync(patchFile, { force: true });
160
+ log('✓ removed the Crew bridge from the desktop patch layer (the file held nothing else)');
161
+ return { ok: true, changed: true, path: patchFile, removed_file: true };
162
+ }
163
+ writeAtomic(patchFile, next);
164
+ log('✓ removed the Crew bridge from the desktop patch layer');
165
+ return { ok: true, changed: true, path: patchFile };
166
+ }
167
+
168
+ export { MARKER as DESKTOP_BRIDGE_MARKER, BRIDGE_ID as DESKTOP_BRIDGE_ID };
@@ -0,0 +1,204 @@
1
+ // Mirror the operator's user-level DSH configuration from the OFFICIAL home into
2
+ // Crew's own home, so the Crew hub works with the same models, providers, policy
3
+ // and MCP servers as the desktop app the operator actually uses.
4
+ //
5
+ // The official home is read-only here — nothing under ~/.dsh is ever written.
6
+ // Secrets are never copied either: provider entries reference their keys through
7
+ // `apiKeyEnv`, and those variables live in the operator's user environment, which
8
+ // every harness process inherits.
9
+ //
10
+ // Two hazards shape the rules:
11
+ // 1. The desktop app runs a DIFFERENT cohort than Crew pins (0.1.7-rc.2 vs
12
+ // 0.1.6-alpha.1), so an entry may name a package Crew's runtime does not
13
+ // have. The Harness installs patches fail-loud, so such an entry would break
14
+ // the hub: it is skipped and reported instead.
15
+ // 2. Crew's own managed blocks (the desktop bridge insert) are not user
16
+ // configuration and are never mirrored.
17
+
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { crewDshHome } from './install.mjs';
22
+ import { crewDshRuntimeRoot } from '../dsh-cli-runtime.mjs';
23
+ import { DESKTOP_BRIDGE_ID, DESKTOP_BRIDGE_MARKER } from './desktop-profile.mjs';
24
+
25
+ const CREW_PROFILE_NAME = 'dsh-crew';
26
+
27
+ export function officialHomeDir({ home = homedir() } = {}) {
28
+ return join(home, '.dsh');
29
+ }
30
+
31
+ export function configMirrorTargets({ home = homedir() } = {}) {
32
+ const crewHome = crewDshHome({ home });
33
+ const official = officialHomeDir({ home });
34
+ return {
35
+ crewHome,
36
+ homeLayer: {
37
+ label: 'home layer (MCP servers)',
38
+ source: join(official, 'cordis.patch.yml'),
39
+ target: join(crewHome, 'cordis.patch.yml'),
40
+ },
41
+ profileLayer: {
42
+ label: 'profile layer (models/providers/policy)',
43
+ source: join(official, 'profiles', 'desktop', 'cordis.patch.yml'),
44
+ target: join(crewHome, 'profiles', CREW_PROFILE_NAME, 'cordis.patch.yml'),
45
+ },
46
+ };
47
+ }
48
+
49
+ // Does the Harness's resolution of this specifier succeed inside the Crew runtime
50
+ // tree? npm nests whole subtrees under family packages, so the nested scopes are
51
+ // checked too — that is where a hoisted sibling actually resolves from.
52
+ function resolvesInRuntime(runtimeRoot, specifier) {
53
+ if (typeof specifier !== 'string' || specifier === '') return true;
54
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return true;
55
+ const moduleRoot = join(runtimeRoot, 'node_modules');
56
+ const parts = specifier.split('/');
57
+ if (existsSync(join(moduleRoot, ...parts))) return true;
58
+ const scopeDir = join(moduleRoot, '@deepseek-ai');
59
+ let packages;
60
+ try { packages = readdirSync(scopeDir); } catch { return false; }
61
+ for (const name of packages) {
62
+ const nested = join(scopeDir, name, 'node_modules');
63
+ if (existsSync(join(nested, ...parts))) return true;
64
+ let inner;
65
+ try { inner = readdirSync(join(nested, '@deepseek-ai')); } catch { continue; }
66
+ for (const deeper of inner) {
67
+ if (existsSync(join(nested, '@deepseek-ai', deeper, 'node_modules', ...parts))) return true;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ // Split a patch layer into its top-level entries, verbatim. A comment at column 0
74
+ // is NOT part of the preceding entry — it separates entries (Crew's own managed
75
+ // block marker sits between entries, and attaching it to the entry above would
76
+ // drop that entry from the mirror).
77
+ function splitEntries(text) {
78
+ const entries = [];
79
+ let current = null;
80
+ const flush = () => { if (current) entries.push(current); current = null; };
81
+ for (const line of text.split(/\r?\n/)) {
82
+ if (line.startsWith('- ') || line === '-') {
83
+ flush();
84
+ current = [line];
85
+ continue;
86
+ }
87
+ if (line.startsWith('#')) {
88
+ flush();
89
+ entries.push([line]);
90
+ continue;
91
+ }
92
+ if (current) current.push(line);
93
+ }
94
+ flush();
95
+ return entries.map((lines) => lines.join('\n').replace(/\s+$/, ''));
96
+ }
97
+
98
+ function entryMeta(entry) {
99
+ return {
100
+ id: entry.match(/^- id:[ \t]*(\S+)/m)?.[1] ?? null,
101
+ insertId: entry.match(/^[ \t]+- id:[ \t]*(\S+)/m)?.[1] ?? null,
102
+ name: entry.match(/^[ \t]+name:[ \t]*["']?([^"'\s]+)/m)?.[1] ?? null,
103
+ };
104
+ }
105
+
106
+ function isOwnManagedBlock(entry, meta) {
107
+ return entry.includes(DESKTOP_BRIDGE_MARKER) || meta.id === DESKTOP_BRIDGE_ID || meta.insertId === DESKTOP_BRIDGE_ID;
108
+ }
109
+
110
+ function mirrorLayer({ layer, crewHome, runtimeRoot, dryRun, log }) {
111
+ const { source, target, label } = layer;
112
+ if (!existsSync(source)) {
113
+ log(`- ${label}: no official source at ${source}; nothing to mirror`);
114
+ return { ok: true, changed: false, entries: 0, skipped: [], source, target, label };
115
+ }
116
+ const eol = readFileSync(source, 'utf8').includes('\r\n') ? '\r\n' : '\n';
117
+ const kept = [];
118
+ const skipped = [];
119
+ const isCommentOnly = (entry) => entry.split(/\r?\n/).every((line) => line.trim() === '' || line.startsWith('#'));
120
+ for (const entry of splitEntries(readFileSync(source, 'utf8'))) {
121
+ if (entry.trim() === '') continue;
122
+ if (isCommentOnly(entry) && !entry.includes(DESKTOP_BRIDGE_MARKER)) continue; // file prose, not configuration
123
+ const meta = entryMeta(entry);
124
+ if (isOwnManagedBlock(entry, meta)) {
125
+ skipped.push({ id: meta.insertId ?? meta.id, reason: 'crew-managed' });
126
+ continue;
127
+ }
128
+ if (!meta.id && !meta.insertId) {
129
+ skipped.push({ id: null, reason: 'unrecognized-entry' });
130
+ continue;
131
+ }
132
+ if (!resolvesInRuntime(runtimeRoot, meta.name)) {
133
+ skipped.push({ id: meta.id ?? meta.insertId, name: meta.name, reason: 'not-in-crew-cohort' });
134
+ continue;
135
+ }
136
+ kept.push(entry);
137
+ }
138
+ const header = [
139
+ `# Mirrored from ${source} by dsh-crew config import.`,
140
+ '# Crew keeps its own DSH home; this layer carries the operator\'s user-level',
141
+ '# configuration so the Crew hub matches the desktop app. Edit the source and',
142
+ '# re-run the import — changes here are overwritten.',
143
+ '',
144
+ ].join(eol);
145
+ const body = kept.length ? `${header}${kept.join(`${eol}${eol}`)}${eol}` : '';
146
+ const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
147
+ for (const item of skipped) {
148
+ log(` ! skipped ${item.id ?? '(unknown entry)'}${item.name ? ` [${item.name}]` : ''}: ${
149
+ item.reason === 'not-in-crew-cohort'
150
+ ? 'package is not in the Crew runtime cohort'
151
+ : item.reason === 'crew-managed' ? 'Crew-managed block' : 'unrecognized entry shape'
152
+ }`);
153
+ }
154
+ if (existing === body) {
155
+ log(`- ${label}: already mirrored (${kept.length} entries)`);
156
+ return { ok: true, changed: false, entries: kept.length, skipped, source, target, label };
157
+ }
158
+ if (dryRun) {
159
+ log(`- ${label}: would mirror ${kept.length} entries -> ${target}`);
160
+ return { ok: true, changed: true, dryRun: true, entries: kept.length, skipped, source, target, label };
161
+ }
162
+ if (!existsSync(dirname(target))) {
163
+ return { ok: false, code: 'CREW_HOME_LAYER_MISSING', error: `Crew directory is absent: ${dirname(target)}`, label };
164
+ }
165
+ const temp = `${target}.dsh-crew.${process.pid}.tmp`;
166
+ writeFileSync(temp, body);
167
+ renameSync(temp, target);
168
+ log(`✓ ${label}: mirrored ${kept.length} entries -> ${target}`);
169
+ return { ok: true, changed: true, entries: kept.length, skipped, source, target, label };
170
+ }
171
+
172
+ export function mirrorOfficialHarnessConfig({ home = homedir(), dryRun = false, log = () => {} } = {}) {
173
+ const targets = configMirrorTargets({ home });
174
+ const runtimeRoot = crewDshRuntimeRoot({ home });
175
+ const layers = [];
176
+ for (const layer of [targets.homeLayer, targets.profileLayer]) {
177
+ const result = mirrorLayer({ layer, crewHome: targets.crewHome, runtimeRoot, dryRun, log });
178
+ layers.push(result);
179
+ if (!result.ok) return { ok: false, code: result.code, error: result.error, layers };
180
+ }
181
+ return {
182
+ ok: true,
183
+ changed: layers.some((layer) => layer.changed),
184
+ dryRun,
185
+ layers: layers.map(({ label, source, target, changed, entries, skipped }) => ({ label, source, target, changed, entries, skipped })),
186
+ };
187
+ }
188
+
189
+ export function officialConfigMirrorStatus({ home = homedir() } = {}) {
190
+ const targets = configMirrorTargets({ home });
191
+ const inspect = (layer) => {
192
+ let targetText = null;
193
+ try { targetText = readFileSync(layer.target, 'utf8'); } catch { targetText = null; }
194
+ return {
195
+ label: layer.label,
196
+ source: layer.source,
197
+ source_present: existsSync(layer.source),
198
+ target: layer.target,
199
+ target_present: targetText !== null,
200
+ mirrored: targetText !== null && targetText.includes('by dsh-crew config import'),
201
+ };
202
+ };
203
+ return { ok: true, home_layer: inspect(targets.homeLayer), profile_layer: inspect(targets.profileLayer) };
204
+ }
@@ -497,7 +497,7 @@ export function writeGlobalConfig(patch) {
497
497
  }
498
498
 
499
499
  /** What is currently installed where — drives the settings-page buttons. */
500
- export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
500
+ export function installStatus({ home = homedir(), root = ROOT, env = process.env, claudeDetected = undefined } = {}) {
501
501
  // Host integrations are installed from the profile's loader link, not from the
502
502
  // release directory, so an upgrade re-points one link instead of rewriting four
503
503
  // host configurations. Readiness has to judge them against the path they were
@@ -549,9 +549,16 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
549
549
  || existsSync(join(codexRoot, 'agents', 'ds-flash.toml'))
550
550
  || existsSync(join(codexRoot, 'agents', 'ds-pro.toml'));
551
551
  const missing = Object.entries(components).filter(([, present]) => !present).map(([key]) => key);
552
+ // `installed` stays the footprint: the settings name our plugin, which is a
553
+ // deliberate distinction from `ready` (an enabled setting is not a callable
554
+ // plugin). `host_detected` records the other half a renderer needs — a machine
555
+ // with no Claude Code has nothing installed and nothing to repair, even though
556
+ // the best-effort settings are there.
557
+ const claudeHostDetected = claudeDetected === undefined ? resolveClaudeCommand() !== null : claudeDetected === true;
552
558
  return {
553
559
  claude: {
554
560
  installed: claudeFootprint,
561
+ host_detected: claudeHostDetected,
555
562
  ready: claudeMissing.length === 0,
556
563
  hud: hudWired,
557
564
  components: claudeComponents,