@chatcode/cco-market 1.45.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/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Locate the DSH host package in CLI and packaged Desktop runtimes. */
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
/** The entry with symlinks resolved, or unchanged when it cannot be read. */
|
|
5
|
+
function realpathOf(entry) {
|
|
6
|
+
try {
|
|
7
|
+
return realpathSync(entry);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// A path that does not exist, or one inside an ASAR archive, where
|
|
11
|
+
// realpath fails: the raw entry is still the best guess and the walk
|
|
12
|
+
// below simply answers null if it leads nowhere.
|
|
13
|
+
return entry;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const DSH_PACKAGE = '@deepseek-ai/dsh';
|
|
17
|
+
/**
|
|
18
|
+
* The host package's own manifest, or null when `directory` is not it.
|
|
19
|
+
* @returns the parsed manifest of `@deepseek-ai/dsh`, or null.
|
|
20
|
+
*/
|
|
21
|
+
function readDshManifest(directory) {
|
|
22
|
+
try {
|
|
23
|
+
const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
24
|
+
return manifest.name === DSH_PACKAGE ? { name: DSH_PACKAGE, version: manifest.version } : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isDshPackage(directory) {
|
|
31
|
+
return readDshManifest(directory) !== null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The version of the DSH host this market is running inside.
|
|
35
|
+
*
|
|
36
|
+
* Read from the same manifest `findDshInstallDir` already parses to identify
|
|
37
|
+
* the package — the version was sitting in that object and being discarded.
|
|
38
|
+
*
|
|
39
|
+
* Worth reporting because the host version has repeatedly been the thing
|
|
40
|
+
* neither side could see. #293 turned on it (the reporter was on
|
|
41
|
+
* 0.1.1-rc.2 while every attempt to reproduce had been on 0.1.0-rc.8, which
|
|
42
|
+
* nobody knew until three rounds in), and #404 is entirely about a plugin
|
|
43
|
+
* that requires a host newer than the Desktop build it was installed on.
|
|
44
|
+
*
|
|
45
|
+
* The directory comes back too, because WHERE it was found is the other half
|
|
46
|
+
* of the answer: a path under Electron's resources is a Desktop-bundled host,
|
|
47
|
+
* which #139 established can be older than whatever `npm ls` would report.
|
|
48
|
+
* Asking the user is not a substitute — that is the number they do not have.
|
|
49
|
+
* @returns the host version and the directory it was read from, or null when
|
|
50
|
+
* no host package is locatable (a plain `dsh web` from a global install can
|
|
51
|
+
* legitimately land here).
|
|
52
|
+
*/
|
|
53
|
+
export function dshHostInfo(entry = process.argv[1]) {
|
|
54
|
+
const directory = findDshInstallDir(entry);
|
|
55
|
+
if (directory === null)
|
|
56
|
+
return null;
|
|
57
|
+
const manifest = readDshManifest(directory);
|
|
58
|
+
// Located but unversioned: report the directory anyway. "The host is here
|
|
59
|
+
// and declares no version" is a fact worth carrying, and it is not the
|
|
60
|
+
// same fact as "no host found".
|
|
61
|
+
const version = typeof manifest?.version === 'string' && manifest.version !== ''
|
|
62
|
+
? manifest.version
|
|
63
|
+
: 'unknown';
|
|
64
|
+
return { version, directory };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Walk up from the CLI entry first, then inspect Electron's authoritative
|
|
68
|
+
* resources directory. Desktop distributions may keep node_modules outside
|
|
69
|
+
* the ASAR, expose them through ASAR's virtual filesystem, or disable ASAR.
|
|
70
|
+
*
|
|
71
|
+
* The entry is resolved through symlinks before the walk, because for a
|
|
72
|
+
* globally installed dsh it IS one. `npm i -g` and Homebrew both put a link
|
|
73
|
+
* in a `bin/` directory pointing at the real package, so `process.argv[1]`
|
|
74
|
+
* is `/opt/homebrew/bin/dsh` and walking up from there reaches `/` without
|
|
75
|
+
* ever passing the package. Measured: the same call answers `null` for the
|
|
76
|
+
* link and the correct directory for its target.
|
|
77
|
+
*
|
|
78
|
+
* That was not a cosmetic gap. Everything downstream reads the host version
|
|
79
|
+
* from here — the exported log's `dsh host:` line (#426), Discover's
|
|
80
|
+
* host-requirement column and filter (#473), and the pre-update check
|
|
81
|
+
* (#404) — and a null version makes every one of them answer "unknown",
|
|
82
|
+
* silently, on exactly the ordinary global install. A bundled Desktop host
|
|
83
|
+
* is reached by the resources branch below and was never affected, which is
|
|
84
|
+
* why this survived: the case that worked is the one that gets tested.
|
|
85
|
+
*/
|
|
86
|
+
export function findDshInstallDir(entry = process.argv[1]) {
|
|
87
|
+
if (entry !== undefined) {
|
|
88
|
+
let directory = resolve(dirname(realpathOf(entry)));
|
|
89
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
90
|
+
if (isDshPackage(directory))
|
|
91
|
+
return directory;
|
|
92
|
+
const parent = dirname(directory);
|
|
93
|
+
if (parent === directory)
|
|
94
|
+
break;
|
|
95
|
+
directory = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const electronProcess = process;
|
|
99
|
+
if (typeof electronProcess.resourcesPath !== 'string'
|
|
100
|
+
|| electronProcess.resourcesPath.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
for (const applicationRoot of ['app.asar.unpacked', 'app.asar', 'app']) {
|
|
103
|
+
const candidate = join(electronProcess.resourcesPath, applicationRoot, 'node_modules', '@deepseek-ai', 'dsh');
|
|
104
|
+
if (isDshPackage(candidate))
|
|
105
|
+
return candidate;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
package/lib/gist.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Gist transport for profile backups — the third backup channel next
|
|
3
|
+
* to the local download and WebDAV (issue #89).
|
|
4
|
+
*
|
|
5
|
+
* Security posture:
|
|
6
|
+
* - The API host is hard-coded to api.github.com, so there is no SSRF surface
|
|
7
|
+
* (unlike WebDAV, which accepts arbitrary user URLs).
|
|
8
|
+
* - The Gist id is validated against a strict character allowlist before it
|
|
9
|
+
* is interpolated into the request path.
|
|
10
|
+
* - Tokens are never persisted: the client sends one per request (session
|
|
11
|
+
* memory only) or the operator sets CHATCODE_CLI_MARKET_GITHUB_TOKEN on the
|
|
12
|
+
* host; nothing
|
|
13
|
+
* is written to disk by this module.
|
|
14
|
+
* - Downloaded content goes through `validatedBackup` before it is returned,
|
|
15
|
+
* mirroring downloadWebdav's strict restore-side validation.
|
|
16
|
+
*
|
|
17
|
+
* Timeouts and errors:
|
|
18
|
+
* - Every request carries an AbortSignal: the caller's (route-level timeout,
|
|
19
|
+
* so a wedged gh CLI or slow network yields a definite answer) merged with
|
|
20
|
+
* a 30 s hard ceiling. Errors are classified into machine-readable codes
|
|
21
|
+
* (`GistError`) so the UI can show friendly localized messages instead of
|
|
22
|
+
* raw DOMException/network noise like "TimeoutError: signal timed out".
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { request as httpsRequest } from 'node:https';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { MAX_BACKUP_BYTES, validatedBackup } from './backup.js';
|
|
29
|
+
import { compatibilityEnv } from './home-paths.js';
|
|
30
|
+
/** The single file every dshmarket backup Gist carries. */
|
|
31
|
+
export const GIST_FILENAME = 'dsh-profile-backup.json';
|
|
32
|
+
/** GitHub hard limit for one Gist file (1 MB); enforced before upload. */
|
|
33
|
+
export const GIST_MAX_BYTES = 1024 * 1024;
|
|
34
|
+
/** Environment variable for a host-configured token (never read from disk). */
|
|
35
|
+
export const GIST_TOKEN_ENV = 'CHATCODE_CLI_MARKET_GITHUB_TOKEN';
|
|
36
|
+
export const LEGACY_GIST_TOKEN_ENV = 'DSH_GITHUB_TOKEN';
|
|
37
|
+
const GIST_API_HOST = 'api.github.com';
|
|
38
|
+
const GIST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
39
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
40
|
+
/** Node network error codes that mean "GitHub is unreachable". */
|
|
41
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
42
|
+
'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT',
|
|
43
|
+
'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'ECONNABORTED',
|
|
44
|
+
]);
|
|
45
|
+
/** Error with a code for the UI; the message stays human-readable. */
|
|
46
|
+
export class GistError extends Error {
|
|
47
|
+
code;
|
|
48
|
+
constructor(message, code = 'other') {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'GistError';
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Classify any thrown value into a stable GistErrorCode. */
|
|
55
|
+
export function gistErrorCode(error) {
|
|
56
|
+
if (error instanceof GistError)
|
|
57
|
+
return error.code;
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
60
|
+
return 'timeout';
|
|
61
|
+
const raw = error.code ?? error.cause?.code;
|
|
62
|
+
if (typeof raw === 'string' && NETWORK_ERROR_CODES.has(raw))
|
|
63
|
+
return 'network';
|
|
64
|
+
}
|
|
65
|
+
return 'other';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Normalize a Gist id or a gist.github.com URL to a bare id.
|
|
69
|
+
* Anything else (paths, embedded slashes, oversize input) is rejected.
|
|
70
|
+
*/
|
|
71
|
+
export function parseGistId(input) {
|
|
72
|
+
const trimmed = input.trim();
|
|
73
|
+
if (trimmed === '')
|
|
74
|
+
throw new Error('gist id is required');
|
|
75
|
+
let candidate = trimmed;
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(trimmed);
|
|
78
|
+
if (url.protocol === 'https:' && (url.hostname === 'gist.github.com' || url.hostname.endsWith('.gist.github.com'))) {
|
|
79
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
80
|
+
candidate = parts[parts.length - 1] ?? '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Not a URL — treat the raw input as the candidate; the allowlist decides.
|
|
85
|
+
}
|
|
86
|
+
if (!GIST_ID_RE.test(candidate))
|
|
87
|
+
throw new Error('invalid gist id');
|
|
88
|
+
return candidate;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the token for one request, in order of preference:
|
|
92
|
+
* 1. an explicitly supplied token (session memory only);
|
|
93
|
+
* 2. the host-configured CHATCODE_CLI_MARKET_GITHUB_TOKEN environment variable;
|
|
94
|
+
* 3. an already-logged-in GitHub CLI (`gh auth token`) — the token is used
|
|
95
|
+
* for this request only and never written to disk.
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveGistTokenSource(bodyToken) {
|
|
98
|
+
if (typeof bodyToken === 'string' && bodyToken.trim() !== '')
|
|
99
|
+
return { token: bodyToken.trim(), source: 'token' };
|
|
100
|
+
const configured = compatibilityEnv(process.env, GIST_TOKEN_ENV, LEGACY_GIST_TOKEN_ENV);
|
|
101
|
+
if (configured !== undefined)
|
|
102
|
+
return { token: configured, source: 'env' };
|
|
103
|
+
const ghToken = await ghAuthToken();
|
|
104
|
+
if (ghToken !== null)
|
|
105
|
+
return { token: ghToken, source: 'gh' };
|
|
106
|
+
throw new GistError('GitHub token is required (enter it in the Backup tab, set CHATCODE_CLI_MARKET_GITHUB_TOKEN, or log in with the gh CLI)', 'auth');
|
|
107
|
+
}
|
|
108
|
+
/** Resolve just the token (kept for callers that do not need the source). */
|
|
109
|
+
export async function resolveGistToken(bodyToken) {
|
|
110
|
+
return (await resolveGistTokenSource(bodyToken)).token;
|
|
111
|
+
}
|
|
112
|
+
/** Short-lived in-memory cache for the gh-derived token (no disk, no browser). */
|
|
113
|
+
let ghTokenCache = null;
|
|
114
|
+
/** Test hook: drop the gh token cache between tests. */
|
|
115
|
+
export function resetGhTokenCache() {
|
|
116
|
+
ghTokenCache = null;
|
|
117
|
+
}
|
|
118
|
+
/** Ask an already-authenticated GitHub CLI for its token, if available. */
|
|
119
|
+
async function ghAuthToken() {
|
|
120
|
+
if (ghTokenCache !== null && Date.now() < ghTokenCache.expires)
|
|
121
|
+
return ghTokenCache.token;
|
|
122
|
+
const token = await fetchGhToken();
|
|
123
|
+
// Positive cache 10 min; negative cache 30 s so a wedged gh cannot make
|
|
124
|
+
// every click wait.
|
|
125
|
+
ghTokenCache = { token, expires: Date.now() + (token !== null ? 10 * 60_000 : 30_000) };
|
|
126
|
+
return token;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run `gh auth token` in a DETACHED child and give up after 8 s no matter
|
|
130
|
+
* what. The parent never waits on the child (unref), so even if gh.exe
|
|
131
|
+
* wedges under WSL interop the host event loop stays free and the request
|
|
132
|
+
* returns a definite answer. 8 s because a Windows gh.exe cold start through
|
|
133
|
+
* WSL interop is routinely slower than a native binary.
|
|
134
|
+
*/
|
|
135
|
+
function fetchGhToken() {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const candidates = ['gh', join(homedir(), '.local', 'bin', 'gh')];
|
|
138
|
+
let settled = false;
|
|
139
|
+
const finish = (value) => {
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
settled = true;
|
|
143
|
+
resolve(value);
|
|
144
|
+
};
|
|
145
|
+
const tryNext = (index) => {
|
|
146
|
+
if (index >= candidates.length) {
|
|
147
|
+
finish(null);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const command = candidates[index];
|
|
151
|
+
let child = null;
|
|
152
|
+
try {
|
|
153
|
+
child = spawn(command, ['auth', 'token'], {
|
|
154
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
155
|
+
detached: true,
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
tryNext(index + 1);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Defensive: a mocked/odd spawn can return nullish — never crash on it.
|
|
164
|
+
if (child == null) {
|
|
165
|
+
tryNext(index + 1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
let out = '';
|
|
169
|
+
child.stdout?.on('data', (chunk) => { out += chunk.toString(); });
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
// Parent gives up; the detached child may keep running as an orphan
|
|
172
|
+
// but cannot block this process or its event loop.
|
|
173
|
+
try {
|
|
174
|
+
child?.kill('SIGKILL');
|
|
175
|
+
}
|
|
176
|
+
catch { /* already gone */ }
|
|
177
|
+
finish(out.trim() !== '' ? out.trim() : null);
|
|
178
|
+
}, 8_000);
|
|
179
|
+
// On spawn failure (e.g. ENOENT) Node emits 'error' AND THEN 'close'
|
|
180
|
+
// (code -2). The 'close' handler must not resolve from an errored
|
|
181
|
+
// child: when 'gh' is missing from PATH (typical for systemd services)
|
|
182
|
+
// that premature finish(null) would race ahead of the next candidate's
|
|
183
|
+
// successful token and lock the result to null.
|
|
184
|
+
let errored = false;
|
|
185
|
+
child.on('error', () => {
|
|
186
|
+
if (errored)
|
|
187
|
+
return;
|
|
188
|
+
errored = true;
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
if (!settled)
|
|
191
|
+
tryNext(index + 1);
|
|
192
|
+
});
|
|
193
|
+
child.on('close', () => {
|
|
194
|
+
if (errored)
|
|
195
|
+
return; // companion of 'error' — the next candidate owns the result
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
finish(out.trim() !== '' ? out.trim() : null);
|
|
198
|
+
});
|
|
199
|
+
child.unref();
|
|
200
|
+
};
|
|
201
|
+
tryNext(0);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Map a request-level failure (abort or network error) to a GistError. */
|
|
205
|
+
function classifyRequestError(error) {
|
|
206
|
+
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
|
207
|
+
return new GistError('GitHub request timed out', 'timeout');
|
|
208
|
+
}
|
|
209
|
+
const raw = error?.code ?? error?.cause?.code;
|
|
210
|
+
if (typeof raw === 'string' && NETWORK_ERROR_CODES.has(raw)) {
|
|
211
|
+
return new GistError(`GitHub is unreachable (${raw})`, 'network');
|
|
212
|
+
}
|
|
213
|
+
return error instanceof Error ? error : new GistError(String(error), 'other');
|
|
214
|
+
}
|
|
215
|
+
function gistRequest(token, method, path, body, signal) {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const headers = {
|
|
218
|
+
authorization: `Bearer ${token}`,
|
|
219
|
+
'user-agent': 'ChatCode-CLI-Market',
|
|
220
|
+
accept: 'application/vnd.github+json',
|
|
221
|
+
};
|
|
222
|
+
if (body !== undefined) {
|
|
223
|
+
headers['content-type'] = 'application/json';
|
|
224
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
225
|
+
}
|
|
226
|
+
// Route-level signal (when given) wins; the 30 s ceiling still applies
|
|
227
|
+
// as a hard fallback so a forgotten signal can never hang the server.
|
|
228
|
+
const hardCeiling = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
229
|
+
const effectiveSignal = signal !== undefined ? AbortSignal.any([signal, hardCeiling]) : hardCeiling;
|
|
230
|
+
const request = httpsRequest({
|
|
231
|
+
protocol: 'https:',
|
|
232
|
+
hostname: GIST_API_HOST,
|
|
233
|
+
path,
|
|
234
|
+
method,
|
|
235
|
+
headers,
|
|
236
|
+
signal: effectiveSignal,
|
|
237
|
+
}, (response) => {
|
|
238
|
+
const chunks = [];
|
|
239
|
+
let size = 0;
|
|
240
|
+
// GET returns the whole Gist including metadata; POST/PATCH return the
|
|
241
|
+
// created/updated Gist object, which ECHOES the file content back — so
|
|
242
|
+
// a large backup makes the response as large as the upload. Cap all
|
|
243
|
+
// methods alike so a hostile Gist cannot OOM us.
|
|
244
|
+
const maxBytes = MAX_BACKUP_BYTES + 16 * 1024;
|
|
245
|
+
response.once('error', reject);
|
|
246
|
+
const declared = Number(response.headers['content-length']);
|
|
247
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
248
|
+
response.destroy(new Error('GitHub response is too large'));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
response.on('data', (chunk) => {
|
|
252
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
253
|
+
size += value.byteLength;
|
|
254
|
+
if (size > maxBytes) {
|
|
255
|
+
response.destroy(new Error('GitHub response is too large'));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
chunks.push(value);
|
|
259
|
+
});
|
|
260
|
+
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
|
261
|
+
});
|
|
262
|
+
request.once('error', (error) => reject(classifyRequestError(error)));
|
|
263
|
+
if (body !== undefined)
|
|
264
|
+
request.end(body);
|
|
265
|
+
else
|
|
266
|
+
request.end();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function parseGistError(status, body, action) {
|
|
270
|
+
let message = body;
|
|
271
|
+
try {
|
|
272
|
+
const parsed = JSON.parse(body);
|
|
273
|
+
if (typeof parsed.message === 'string' && parsed.message !== '')
|
|
274
|
+
message = parsed.message;
|
|
275
|
+
}
|
|
276
|
+
catch { /* keep the raw body */ }
|
|
277
|
+
if (status === 401)
|
|
278
|
+
return new GistError('GitHub token is invalid or revoked', 'auth');
|
|
279
|
+
if (status === 403)
|
|
280
|
+
return new GistError(`GitHub rejected the ${action} (rate limit or insufficient scope): ${message}`, 'rate-limit');
|
|
281
|
+
if (status === 404)
|
|
282
|
+
return new GistError('Gist not found (check the id/URL)', 'notfound');
|
|
283
|
+
if (status === 422)
|
|
284
|
+
return new GistError(`GitHub rejected the ${action}: ${message}`, 'invalid');
|
|
285
|
+
return new GistError(`GitHub ${action} failed: HTTP ${status} ${message}`, 'other');
|
|
286
|
+
}
|
|
287
|
+
async function sendGistRequest(token, method, path, body, action, signal) {
|
|
288
|
+
const response = await gistRequest(token, method, path, body, signal);
|
|
289
|
+
const ok = method === 'POST' ? response.status === 201 : response.status === 200;
|
|
290
|
+
if (!ok)
|
|
291
|
+
throw parseGistError(response.status, response.body, action);
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
/** Create a new private Gist carrying one backup file. */
|
|
295
|
+
export async function createGist(token, content, signal) {
|
|
296
|
+
const body = JSON.stringify({
|
|
297
|
+
description: 'ChatCode CLI Market profile backup',
|
|
298
|
+
public: false,
|
|
299
|
+
files: { [GIST_FILENAME]: { content } },
|
|
300
|
+
});
|
|
301
|
+
const response = await sendGistRequest(token, 'POST', '/gists', body, 'Gist creation', signal);
|
|
302
|
+
const data = JSON.parse(response.body);
|
|
303
|
+
if (typeof data.id !== 'string' || data.id === '')
|
|
304
|
+
throw new Error('GitHub returned an invalid Gist');
|
|
305
|
+
return { id: data.id, htmlUrl: typeof data.html_url === 'string' ? data.html_url : `https://gist.github.com/${data.id}` };
|
|
306
|
+
}
|
|
307
|
+
/** Overwrite the backup file inside an existing Gist (other files kept). */
|
|
308
|
+
export async function updateGist(token, gistId, content, signal) {
|
|
309
|
+
const body = JSON.stringify({ files: { [GIST_FILENAME]: { content } } });
|
|
310
|
+
const response = await sendGistRequest(token, 'PATCH', `/gists/${gistId}`, body, 'Gist update', signal);
|
|
311
|
+
const data = JSON.parse(response.body);
|
|
312
|
+
return { id: typeof data.id === 'string' ? data.id : gistId, htmlUrl: typeof data.html_url === 'string' ? data.html_url : `https://gist.github.com/${gistId}` };
|
|
313
|
+
}
|
|
314
|
+
/** Download and strictly validate the backup file inside a Gist. */
|
|
315
|
+
export async function readGist(token, gistId, signal) {
|
|
316
|
+
const response = await sendGistRequest(token, 'GET', `/gists/${gistId}`, undefined, 'Gist read', signal);
|
|
317
|
+
let data;
|
|
318
|
+
try {
|
|
319
|
+
data = JSON.parse(response.body);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
throw new GistError('GitHub returned an unreadable Gist payload', 'invalid');
|
|
323
|
+
}
|
|
324
|
+
const file = data.files?.[GIST_FILENAME];
|
|
325
|
+
const content = file !== null && typeof file === 'object' && !Array.isArray(file)
|
|
326
|
+
? file.content
|
|
327
|
+
: undefined;
|
|
328
|
+
if (typeof content !== 'string')
|
|
329
|
+
throw new GistError(`Gist has no ${GIST_FILENAME} file`, 'invalid');
|
|
330
|
+
let parsed;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(content);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
throw new GistError('Gist backup is not valid JSON', 'invalid');
|
|
336
|
+
}
|
|
337
|
+
// Strict validation server-side: restore only accepts real backups.
|
|
338
|
+
try {
|
|
339
|
+
return validatedBackup(parsed);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
throw new GistError(error instanceof Error ? error.message : String(error), 'invalid');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** Confirm the token is usable (GET /user). */
|
|
346
|
+
export async function verifyGistToken(token, signal) {
|
|
347
|
+
await sendGistRequest(token, 'GET', '/user', undefined, 'token verification', signal);
|
|
348
|
+
}
|
|
349
|
+
/** True when the serialized backup fits inside a Gist file. */
|
|
350
|
+
export function fitsGistLimit(content) {
|
|
351
|
+
return Buffer.byteLength(content) <= GIST_MAX_BYTES;
|
|
352
|
+
}
|
package/lib/groups.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom plugin groups (Roadmap #60): user-defined named collections of
|
|
3
|
+
* installed plugins whose enable/disable state can be switched as a unit —
|
|
4
|
+
* borrowing the "group by capability, toggle as one" idea from Claude
|
|
5
|
+
* Desktop's skill management. Membership lives in state.json and is the
|
|
6
|
+
* only durable truth: a group's switch state is always derived from its
|
|
7
|
+
* members and never persisted itself.
|
|
8
|
+
*
|
|
9
|
+
* Pure CRUD over the caller-owned state objects; routes.ts persists after
|
|
10
|
+
* each mutation and applies the live toggles for the batch action.
|
|
11
|
+
*/
|
|
12
|
+
/** Group names: letters/digits (incl. CJK), spaces, underscores, hyphens. */
|
|
13
|
+
const GROUP_NAME_RE = /^[\p{L}\p{N}_ -]{1,40}$/u;
|
|
14
|
+
function isGroupName(value) {
|
|
15
|
+
return typeof value === 'string' && GROUP_NAME_RE.test(value);
|
|
16
|
+
}
|
|
17
|
+
export function createGroup(state, name) {
|
|
18
|
+
if (!isGroupName(name))
|
|
19
|
+
return { ok: false, error: 'invalid group name / 分组名称无效' };
|
|
20
|
+
if (state.groups[name] !== undefined)
|
|
21
|
+
return { ok: false, error: 'group already exists / 分组已存在' };
|
|
22
|
+
state.groups[name] = [];
|
|
23
|
+
state.groupOrder.push(name);
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
export function renameGroup(state, name, newName) {
|
|
27
|
+
if (typeof name !== 'string' || state.groups[name] === undefined) {
|
|
28
|
+
return { ok: false, error: 'group not found / 分组不存在' };
|
|
29
|
+
}
|
|
30
|
+
if (!isGroupName(newName))
|
|
31
|
+
return { ok: false, error: 'invalid group name / 分组名称无效' };
|
|
32
|
+
if (newName !== name && state.groups[newName] !== undefined) {
|
|
33
|
+
return { ok: false, error: 'group already exists / 分组已存在' };
|
|
34
|
+
}
|
|
35
|
+
const members = state.groups[name];
|
|
36
|
+
delete state.groups[name];
|
|
37
|
+
state.groups[newName] = members;
|
|
38
|
+
const index = state.groupOrder.indexOf(name);
|
|
39
|
+
if (index !== -1)
|
|
40
|
+
state.groupOrder[index] = newName;
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
export function deleteGroup(state, name) {
|
|
44
|
+
if (typeof name !== 'string' || state.groups[name] === undefined) {
|
|
45
|
+
return { ok: false, error: 'group not found / 分组不存在' };
|
|
46
|
+
}
|
|
47
|
+
delete state.groups[name];
|
|
48
|
+
// Mutate in place: routes.ts hands over the LIVE groupOrder array, and the
|
|
49
|
+
// response serializes that same array — replacing it here would orphan it.
|
|
50
|
+
const index = state.groupOrder.indexOf(name);
|
|
51
|
+
if (index !== -1)
|
|
52
|
+
state.groupOrder.splice(index, 1);
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Replace a group's membership. Only currently installed plugins can be
|
|
57
|
+
* members — ghost names (uninstalled meanwhile) are dropped and duplicates
|
|
58
|
+
* collapse, so the persisted list stays clean. Themes are exclusive: a group
|
|
59
|
+
* may hold at most one theme plugin, mirroring the global one-active-theme
|
|
60
|
+
* rule (only one theme can be enabled at a time).
|
|
61
|
+
*/
|
|
62
|
+
export function setGroupMembers(state, name, members, installed, themes) {
|
|
63
|
+
if (typeof name !== 'string' || state.groups[name] === undefined) {
|
|
64
|
+
return { ok: false, error: 'group not found / 分组不存在' };
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(members))
|
|
67
|
+
return { ok: false, error: 'members must be an array / 成员必须是数组' };
|
|
68
|
+
const kept = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const member of members) {
|
|
71
|
+
if (typeof member !== 'string' || member === '' || seen.has(member))
|
|
72
|
+
continue;
|
|
73
|
+
// The market itself never participates in groups (#60 assumptions).
|
|
74
|
+
if (member === '@chatcode/cco-market' || member === 'dsh-market' || member === 'dshmarket')
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(member);
|
|
77
|
+
if (installed.has(member))
|
|
78
|
+
kept.push(member);
|
|
79
|
+
}
|
|
80
|
+
let themeCount = 0;
|
|
81
|
+
for (const member of kept)
|
|
82
|
+
if (themes.has(member))
|
|
83
|
+
themeCount += 1;
|
|
84
|
+
if (themeCount > 1) {
|
|
85
|
+
return { ok: false, error: 'a group can contain at most one theme / 每组最多一个主题' };
|
|
86
|
+
}
|
|
87
|
+
state.groups[name] = kept;
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
90
|
+
/** Drop `name` from every group (called after a successful uninstall). */
|
|
91
|
+
export function removeFromGroups(state, name) {
|
|
92
|
+
for (const group of Object.keys(state.groups)) {
|
|
93
|
+
const members = state.groups[group];
|
|
94
|
+
if (members.includes(name))
|
|
95
|
+
state.groups[group] = members.filter(member => member !== name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** ChatCode CLI Home resolution with legacy Harness input compatibility. */
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
/** Default single-root ChatCode CLI home. */
|
|
5
|
+
export function defaultChatCodeHome() {
|
|
6
|
+
return join(homedir(), '.chatcode-cli');
|
|
7
|
+
}
|
|
8
|
+
/** @deprecated Compatibility alias for downstream code. */
|
|
9
|
+
export const defaultDshHome = defaultChatCodeHome;
|
|
10
|
+
/** Expand the tilde forms supported by ChatCode CLI configuration. */
|
|
11
|
+
export function expandHomePath(path) {
|
|
12
|
+
if (path === '~')
|
|
13
|
+
return homedir();
|
|
14
|
+
if (path.startsWith('~/') || path.startsWith('~\\'))
|
|
15
|
+
return join(homedir(), path.slice(2));
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an explicit home, `CHATCODE_CLI_HOME`, legacy `DSH_HOME`, or the
|
|
20
|
+
* default to one normalized absolute path. Blank environment values are unset.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveChatCodeHome(configured, env = process.env) {
|
|
23
|
+
const chatCodeHome = env.CHATCODE_CLI_HOME?.trim();
|
|
24
|
+
const legacyHome = env.DSH_HOME?.trim();
|
|
25
|
+
const selected = configured
|
|
26
|
+
?? (chatCodeHome !== undefined && chatCodeHome.length > 0
|
|
27
|
+
? chatCodeHome
|
|
28
|
+
: legacyHome !== undefined && legacyHome.length > 0
|
|
29
|
+
? legacyHome
|
|
30
|
+
: defaultChatCodeHome());
|
|
31
|
+
return resolve(expandHomePath(selected));
|
|
32
|
+
}
|
|
33
|
+
/** @deprecated Compatibility alias for callers that still import the old API. */
|
|
34
|
+
export const resolveDshHome = resolveChatCodeHome;
|
|
35
|
+
/** Read a non-empty ChatCode variable before its legacy compatibility input. */
|
|
36
|
+
export function compatibilityEnv(env, currentName, legacyName) {
|
|
37
|
+
const current = env[currentName]?.trim();
|
|
38
|
+
if (current !== undefined && current.length > 0)
|
|
39
|
+
return current;
|
|
40
|
+
const legacy = env[legacyName]?.trim();
|
|
41
|
+
return legacy !== undefined && legacy.length > 0 ? legacy : undefined;
|
|
42
|
+
}
|