dsh-github-app 0.0.0-stage → 0.0.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/index.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import "./_virtual/_rollup-plugin-process-env.mjs";
2
+ import { API_PREFIX } from "./constants.mjs";
3
+ import { buildTrustedHosts } from "./fence.mjs";
4
+ import { createApiHandler } from "./api.mjs";
5
+ import { cancelUninstallCleanup, profileDeclaresPackage, watchForUninstallCleanup } from "./uninstall.mjs";
6
+ import { resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ // src/index.ts
9
+ /**
10
+ * Resolve the profile directory from the loader context. The loader sets
11
+ * `baseUrl` to the directory of the profile configuration, which is also the
12
+ * directory holding `package.json`.
13
+ *
14
+ * @param baseUrl context base URL, usually a `file:` URL.
15
+ * @returns an absolute directory path, or null when it cannot be resolved.
16
+ */
17
+ function resolveProfileDir(baseUrl) {
18
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") return null;
19
+ try {
20
+ const value = baseUrl.startsWith("file:") ? fileURLToPath(new URL(".", baseUrl)) : baseUrl;
21
+ return resolve(value);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ const name = "github";
27
+ const inject = ["webServer", "webStartup"];
28
+ function apply(ctx) {
29
+ cancelUninstallCleanup();
30
+ const trustedHosts = buildTrustedHosts(ctx.webStartup?.trustedHosts, (message) => {
31
+ console.warn("[dsh-github-app] ignoring invalid trusted host:", message);
32
+ });
33
+ const handler = createApiHandler(ctx, { trustedHosts });
34
+ ctx.effect(() => ctx.webServer.register({
35
+ kind: "prefix",
36
+ path: API_PREFIX,
37
+ handler
38
+ }), "dsh-github-app: api");
39
+ const profileDir = resolveProfileDir(ctx.baseUrl);
40
+ if (profileDir !== null && profileDeclaresPackage(profileDir) === true) {
41
+ const watchedProfileDir = profileDir;
42
+ ctx.effect(() => () => {
43
+ watchForUninstallCleanup({ profileDir: watchedProfileDir });
44
+ }, "dsh-github-app: uninstall cleanup");
45
+ }
46
+ }
47
+ export { apply, inject, name };
package/keyCrypto.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Passphrase encryption for the GitHub App private key.
3
+ *
4
+ * The key is stored as a self-describing JSON envelope: scrypt turns the
5
+ * passphrase into an AES-256-GCM key, and the envelope carries only the KDF
6
+ * parameters, salt, IV and authentication tag — never the passphrase. The
7
+ * passphrase is not persisted anywhere and is required again after every
8
+ * server restart.
9
+ */
10
+ /**
11
+ * Encrypt a PEM private key under a passphrase.
12
+ *
13
+ * @param pem PEM text.
14
+ * @param passphrase user passphrase; never stored.
15
+ * @returns the JSON envelope to write to disk.
16
+ */
17
+ export declare function encryptPrivateKey(pem: string, passphrase: string): Promise<string>;
18
+ /**
19
+ * Decrypt a key envelope produced by {@link encryptPrivateKey}.
20
+ *
21
+ * @param text JSON envelope read from disk.
22
+ * @param passphrase user passphrase.
23
+ * @returns the PEM text.
24
+ */
25
+ export declare function decryptPrivateKey(text: string, passphrase: string): Promise<string>;
package/keyCrypto.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { passphraseError } from "./passphrase.mjs";
2
+ import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "node:crypto";
3
+ // src/keyCrypto.ts
4
+ /**
5
+ * Passphrase encryption for the GitHub App private key.
6
+ *
7
+ * The key is stored as a self-describing JSON envelope: scrypt turns the
8
+ * passphrase into an AES-256-GCM key, and the envelope carries only the KDF
9
+ * parameters, salt, IV and authentication tag — never the passphrase. The
10
+ * passphrase is not persisted anywhere and is required again after every
11
+ * server restart.
12
+ */
13
+ const ENVELOPE_VERSION = 1;
14
+ const KDF = "scrypt";
15
+ const CIPHER = "aes-256-gcm";
16
+ const KEY_BYTES = 32;
17
+ const SALT_BYTES = 16;
18
+ const IV_BYTES = 12;
19
+ const TAG_BYTES = 16;
20
+ const MIN_N = 4096;
21
+ const MAX_N = 1 << 18;
22
+ const MAX_R = 16;
23
+ const MAX_P = 4;
24
+ const AAD = Buffer.from("dsh-github-app:private-key:v1", "utf8");
25
+ function decodeField(value, bytes) {
26
+ if (typeof value !== "string" || value === "") throw new Error("Malformed key envelope");
27
+ const buffer = Buffer.from(value, "base64");
28
+ if (buffer.length !== bytes) throw new Error("Malformed key envelope");
29
+ return buffer;
30
+ }
31
+ function readEnvelope(text) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(text);
35
+ } catch {
36
+ throw new Error("Malformed key envelope");
37
+ }
38
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Malformed key envelope");
39
+ const value = parsed;
40
+ const N = value.N;
41
+ const r = value.r;
42
+ const p = value.p;
43
+ if (value.version !== ENVELOPE_VERSION || value.kdf !== KDF || value.cipher !== CIPHER) throw new Error("Unsupported key envelope");
44
+ if (!Number.isInteger(N) || N < MIN_N || N > MAX_N || (N & N - 1) !== 0) throw new Error("Malformed key envelope");
45
+ if (!Number.isInteger(r) || r < 1 || r > MAX_R) throw new Error("Malformed key envelope");
46
+ if (!Number.isInteger(p) || p < 1 || p > MAX_P) throw new Error("Malformed key envelope");
47
+ decodeField(value.salt, SALT_BYTES);
48
+ decodeField(value.iv, IV_BYTES);
49
+ decodeField(value.tag, TAG_BYTES);
50
+ if (typeof value.data !== "string" || value.data === "") throw new Error("Malformed key envelope");
51
+ return value;
52
+ }
53
+ function deriveKey(passphrase, salt, N, r, p) {
54
+ return new Promise((resolve, reject) => {
55
+ scrypt(passphrase, salt, KEY_BYTES, {
56
+ N,
57
+ r,
58
+ p,
59
+ maxmem: 128 * N * r + 67108864
60
+ }, (error, key) => {
61
+ if (error) reject(error);
62
+ else resolve(key);
63
+ });
64
+ });
65
+ }
66
+ /**
67
+ * Encrypt a PEM private key under a passphrase.
68
+ *
69
+ * @param pem PEM text.
70
+ * @param passphrase user passphrase; never stored.
71
+ * @returns the JSON envelope to write to disk.
72
+ */
73
+ async function encryptPrivateKey(pem, passphrase) {
74
+ const invalid = passphraseError(passphrase);
75
+ if (invalid !== null) throw new Error(invalid);
76
+ if (pem.trim() === "") throw new Error("Private key is empty");
77
+ const N = 32768;
78
+ const r = 8;
79
+ const p = 1;
80
+ const salt = randomBytes(SALT_BYTES);
81
+ const iv = randomBytes(IV_BYTES);
82
+ const key = await deriveKey(passphrase, salt, N, r, p);
83
+ try {
84
+ const cipher = createCipheriv(CIPHER, key, iv);
85
+ cipher.setAAD(AAD);
86
+ const data = Buffer.concat([cipher.update(pem, "utf8"), cipher.final()]);
87
+ const tag = cipher.getAuthTag();
88
+ const envelope = {
89
+ version: ENVELOPE_VERSION,
90
+ kdf: KDF,
91
+ cipher: CIPHER,
92
+ N,
93
+ r,
94
+ p,
95
+ salt: salt.toString("base64"),
96
+ iv: iv.toString("base64"),
97
+ tag: tag.toString("base64"),
98
+ data: data.toString("base64")
99
+ };
100
+ return `${JSON.stringify(envelope, null, 2)}\n`;
101
+ } finally {
102
+ key.fill(0);
103
+ }
104
+ }
105
+ /**
106
+ * Decrypt a key envelope produced by {@link encryptPrivateKey}.
107
+ *
108
+ * @param text JSON envelope read from disk.
109
+ * @param passphrase user passphrase.
110
+ * @returns the PEM text.
111
+ */
112
+ async function decryptPrivateKey(text, passphrase) {
113
+ const invalid = passphraseError(passphrase);
114
+ if (invalid !== null) throw new Error(invalid);
115
+ const envelope = readEnvelope(text);
116
+ const salt = decodeField(envelope.salt, SALT_BYTES);
117
+ const iv = decodeField(envelope.iv, IV_BYTES);
118
+ const tag = decodeField(envelope.tag, TAG_BYTES);
119
+ const data = Buffer.from(envelope.data, "base64");
120
+ const key = await deriveKey(passphrase, salt, envelope.N, envelope.r, envelope.p);
121
+ try {
122
+ const decipher = createDecipheriv(CIPHER, key, iv);
123
+ decipher.setAAD(AAD);
124
+ decipher.setAuthTag(tag);
125
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
126
+ } catch {
127
+ throw new Error("Cannot decrypt private key");
128
+ } finally {
129
+ key.fill(0);
130
+ }
131
+ }
132
+ export { decryptPrivateKey, encryptPrivateKey };
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "dsh-github-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "GitHub CLI (gh) integration for DSH: configure a GitHub App and authenticate gh for git operations.",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": "./index.mjs",
10
+ "./client": "./client/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "author": "Mikhail Lysikov <d8corp@mail.ru>",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/d8corp/dsh-github-app.git"
18
+ },
19
+ "keywords": [
20
+ "DeepSeek",
21
+ "Harness",
22
+ "GitHub",
23
+ "plugin"
24
+ ],
25
+ "bugs": {
26
+ "url": "https://github.com/d8corp/dsh-github-app/issues"
27
+ },
28
+ "homepage": "https://github.com/d8corp/dsh-github-app",
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "scripts": {
33
+ "build": "npm run build:theme && npm run build:client && innetjs release -tc -lc -e",
34
+ "build:client": "innetjs build -i client -tc -lc -e",
35
+ "build:theme": "innetjs run buildEnv.ts -e",
36
+ "release": "npm run build:theme && npm run build:client && innetjs release -p -tc -lc -e",
37
+ "test": "jest",
38
+ "lint": "eslint src --ext .ts,.tsx",
39
+ "lint:fix": "eslint src --ext .ts,.tsx --fix"
40
+ },
41
+ "dsh": {
42
+ "manifestVersion": "1",
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "platform": "web",
48
+ "inject": [
49
+ "@deepseek-ai/dsh-client-ui-settings"
50
+ ]
51
+ }
52
+ }
6
53
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Passphrase rules shared by the browser settings UI and the host.
3
+ */
4
+ /**
5
+ * Validate a passphrase supplied by the browser.
6
+ *
7
+ * @param value raw value from the request.
8
+ * @returns an error message, or null when the passphrase is usable.
9
+ */
10
+ export declare function passphraseError(value: unknown): string | null;
package/passphrase.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { MAX_PASSPHRASE_CHARS } from "./constants.mjs";
2
+ // src/passphrase.ts
3
+ /**
4
+ * Passphrase rules shared by the browser settings UI and the host.
5
+ */
6
+ /**
7
+ * Validate a passphrase supplied by the browser.
8
+ *
9
+ * @param value raw value from the request.
10
+ * @returns an error message, or null when the passphrase is usable.
11
+ */
12
+ function passphraseError(value) {
13
+ if (typeof value !== "string" || value.trim() === "") return "Passphrase is required.";
14
+ if (value.length < 8) return `Passphrase must be at least 8 characters.`;
15
+ if (value.length > 1024) return `Passphrase must be at most ${MAX_PASSPHRASE_CHARS} characters.`;
16
+ return null;
17
+ }
18
+ export { passphraseError };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Private-file helpers for the GitHub App key.
3
+ *
4
+ * Deletions are restricted to the harness home so a tampered settings file
5
+ * cannot turn `clearConfig` into arbitrary file removal.
6
+ */
7
+ /**
8
+ * Whether a path lives inside the harness home, judged by its parent's real
9
+ * location so a symlinked directory cannot escape the check.
10
+ *
11
+ * @param file candidate path.
12
+ * @returns true when removing the path cannot reach outside `$DSH_HOME`.
13
+ */
14
+ export declare function isInsideDshHome(file: string | null | undefined): boolean;
15
+ /**
16
+ * Remove a file only when it is inside the harness home.
17
+ *
18
+ * @param file candidate path.
19
+ * @returns true when the file is gone (or was absent), false otherwise.
20
+ */
21
+ export declare function removeSecretFile(file: string | null | undefined): boolean;
package/secureFile.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { dshHomeDir } from "./settings.mjs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { existsSync, realpathSync, unlinkSync } from "node:fs";
4
+ // src/secureFile.ts
5
+ /**
6
+ * Private-file helpers for the GitHub App key.
7
+ *
8
+ * Deletions are restricted to the harness home so a tampered settings file
9
+ * cannot turn `clearConfig` into arbitrary file removal.
10
+ */
11
+ /**
12
+ * Real path when it exists. Missing paths are resolved through their nearest
13
+ * existing ancestor, so symlinked parents (`/var` → `/private/var` on macOS)
14
+ * still compare correctly against a real home directory.
15
+ */
16
+ function realOrResolved(path) {
17
+ const resolved = resolve(path);
18
+ try {
19
+ return realpathSync.native(resolved);
20
+ } catch {
21
+ const parent = dirname(resolved);
22
+ if (parent === resolved) return resolved;
23
+ return join(realOrResolved(parent), basename(resolved));
24
+ }
25
+ }
26
+ /**
27
+ * Whether a path lives inside the harness home, judged by its parent's real
28
+ * location so a symlinked directory cannot escape the check.
29
+ *
30
+ * @param file candidate path.
31
+ * @returns true when removing the path cannot reach outside `$DSH_HOME`.
32
+ */
33
+ function isInsideDshHome(file) {
34
+ if (typeof file !== "string" || file.trim() === "") return false;
35
+ const home = realOrResolved(dshHomeDir());
36
+ const parent = realOrResolved(dirname(resolve(file)));
37
+ const relation = relative(home, parent);
38
+ return !relation.startsWith("..") && !isAbsolute(relation);
39
+ }
40
+ /**
41
+ * Remove a file only when it is inside the harness home.
42
+ *
43
+ * @param file candidate path.
44
+ * @returns true when the file is gone (or was absent), false otherwise.
45
+ */
46
+ function removeSecretFile(file) {
47
+ if (!isInsideDshHome(file)) return false;
48
+ const target = file;
49
+ try {
50
+ if (existsSync(target)) unlinkSync(target);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+ export { isInsideDshHome, removeSecretFile };
package/settings.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Host-side settings for dsh-github-app.
3
+ *
4
+ * Stored at `$DSH_HOME/dsh-github-app/github.json` (or `~/.dsh/dsh-github-app/github.json`).
5
+ * The private key is never stored here — only its path on the host. The key
6
+ * itself lives in a separate 0600 file encrypted with a passphrase that is
7
+ * never persisted.
8
+ */
9
+ export interface GithubSettings {
10
+ appId: string;
11
+ installationId: string;
12
+ gitUserName: string;
13
+ gitUserEmail: string;
14
+ keyPath: string | null;
15
+ keyFileName: string | null;
16
+ tokenIssuedAt: string | null;
17
+ tokenExpiresAt: string | null;
18
+ }
19
+ /**
20
+ * Resolve the DeepSeek Harness home: `$DSH_HOME` when set, otherwise `~/.dsh`.
21
+ *
22
+ * The configured value is `~`-expanded and resolved to an absolute path, so
23
+ * `DSH_HOME=~/harness` means `$HOME/harness` — never a literal `./~` directory
24
+ * relative to the server's working directory.
25
+ *
26
+ * Kept local on purpose — the host half must load with no runtime dependency
27
+ * beyond Node built-ins, so no `@deepseek-ai/*` helper package is imported here.
28
+ *
29
+ * @returns the absolute harness home directory.
30
+ */
31
+ export declare function dshHomeDir(env?: NodeJS.ProcessEnv): string;
32
+ /**
33
+ * Plugin-owned data directory under the DSH home.
34
+ *
35
+ * @param env environment to read `DSH_HOME` from.
36
+ * @returns `$DSH_HOME/dsh-github-app`.
37
+ */
38
+ export declare function pluginDir(env?: NodeJS.ProcessEnv): string;
39
+ export declare function settingsPath(env?: NodeJS.ProcessEnv): string;
40
+ /** Fixed location of the encrypted private key envelope. */
41
+ export declare function encryptedKeyPath(env?: NodeJS.ProcessEnv): string;
42
+ export declare function normalizeSettings(value: unknown): GithubSettings;
43
+ export declare function readSettings(file?: string): GithubSettings;
44
+ export declare function writeSettings(settings: unknown, file?: string): GithubSettings;
45
+ export declare function keyFileExists(settings: unknown): boolean;
package/settings.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { writeSecretFile } from "./atomicFile.mjs";
2
+ import { KEY_ENCRYPTED_FILENAME, PLUGIN_DIR_NAME, SETTINGS_FILENAME } from "./constants.mjs";
3
+ import { parseTimestamp } from "./time.mjs";
4
+ import { join, resolve } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ // src/settings.ts
8
+ /**
9
+ * Host-side settings for dsh-github-app.
10
+ *
11
+ * Stored at `$DSH_HOME/dsh-github-app/github.json` (or `~/.dsh/dsh-github-app/github.json`).
12
+ * The private key is never stored here — only its path on the host. The key
13
+ * itself lives in a separate 0600 file encrypted with a passphrase that is
14
+ * never persisted.
15
+ */
16
+ const MAX_ID_CHARS = 64;
17
+ const MAX_GIT_NAME_CHARS = 128;
18
+ const MAX_GIT_EMAIL_CHARS = 255;
19
+ /**
20
+ * Expand a leading `~` to the current user's home directory. `~user/...` is
21
+ * deliberately left alone, matching the harness home-path helper.
22
+ *
23
+ * @param value configured path.
24
+ * @returns the path with `~` (or `~/`) expanded.
25
+ */
26
+ function expandHome(value) {
27
+ if (value === "~") return homedir();
28
+ if (value.startsWith("~/") || value.startsWith("~\\")) return join(homedir(), value.slice(2));
29
+ return value;
30
+ }
31
+ /**
32
+ * Resolve the DeepSeek Harness home: `$DSH_HOME` when set, otherwise `~/.dsh`.
33
+ *
34
+ * The configured value is `~`-expanded and resolved to an absolute path, so
35
+ * `DSH_HOME=~/harness` means `$HOME/harness` — never a literal `./~` directory
36
+ * relative to the server's working directory.
37
+ *
38
+ * Kept local on purpose — the host half must load with no runtime dependency
39
+ * beyond Node built-ins, so no `@deepseek-ai/*` helper package is imported here.
40
+ *
41
+ * @returns the absolute harness home directory.
42
+ */
43
+ function dshHomeDir(env = process.env) {
44
+ const configured = env && typeof env.DSH_HOME === "string" ? env.DSH_HOME.trim() : "";
45
+ return resolve(configured === "" ? join(homedir(), ".dsh") : expandHome(configured));
46
+ }
47
+ /**
48
+ * Plugin-owned data directory under the DSH home.
49
+ *
50
+ * @param env environment to read `DSH_HOME` from.
51
+ * @returns `$DSH_HOME/dsh-github-app`.
52
+ */
53
+ function pluginDir(env = process.env) {
54
+ return join(dshHomeDir(env), PLUGIN_DIR_NAME);
55
+ }
56
+ function settingsPath(env = process.env) {
57
+ return join(pluginDir(env), SETTINGS_FILENAME);
58
+ }
59
+ /** Fixed location of the encrypted private key envelope. */
60
+ function encryptedKeyPath(env = process.env) {
61
+ return join(pluginDir(env), KEY_ENCRYPTED_FILENAME);
62
+ }
63
+ function isPlainObject(value) {
64
+ return value !== null && typeof value === "object" && !Array.isArray(value);
65
+ }
66
+ function normalizeId(value) {
67
+ if (typeof value === "number" && Number.isFinite(value)) value = String(Math.trunc(value));
68
+ if (typeof value !== "string") return "";
69
+ const trimmed = value.trim();
70
+ if (trimmed === "") return "";
71
+ return trimmed.slice(0, MAX_ID_CHARS);
72
+ }
73
+ function normalizePath(value) {
74
+ if (typeof value !== "string") return null;
75
+ const trimmed = value.trim();
76
+ if (trimmed === "") return null;
77
+ return trimmed;
78
+ }
79
+ /**
80
+ * Normalize a persisted token timestamp to canonical ISO-8601 UTC.
81
+ *
82
+ * Accepts a parseable date string or a finite, non-negative epoch-millisecond
83
+ * number; anything else — objects, arrays, `"nonsense"`, `NaN`, negative or
84
+ * out-of-range numbers — normalizes to `null` instead of throwing.
85
+ *
86
+ * @param value candidate timestamp read from settings JSON.
87
+ * @returns canonical ISO string, or null when the value is unusable.
88
+ */
89
+ function normalizeTimestamp(value) {
90
+ const ms = parseTimestamp(value);
91
+ if (ms === null) return null;
92
+ try {
93
+ return new Date(ms).toISOString();
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function normalizeSettings(value) {
99
+ const source = isPlainObject(value) ? value : {};
100
+ return {
101
+ appId: normalizeId(source.appId),
102
+ installationId: normalizeId(source.installationId),
103
+ gitUserName: typeof source.gitUserName === "string" ? source.gitUserName.trim().slice(0, MAX_GIT_NAME_CHARS) : "",
104
+ gitUserEmail: typeof source.gitUserEmail === "string" ? source.gitUserEmail.trim().slice(0, MAX_GIT_EMAIL_CHARS) : "",
105
+ keyPath: normalizePath(source.keyPath),
106
+ keyFileName: typeof source.keyFileName === "string" && source.keyFileName.trim() !== "" ? source.keyFileName.trim().slice(0, 255) : null,
107
+ tokenIssuedAt: normalizeTimestamp(source.tokenIssuedAt),
108
+ tokenExpiresAt: normalizeTimestamp(source.tokenExpiresAt)
109
+ };
110
+ }
111
+ function readSettings(file = settingsPath()) {
112
+ try {
113
+ return normalizeSettings(JSON.parse(readFileSync(file, "utf8")));
114
+ } catch {
115
+ return normalizeSettings({});
116
+ }
117
+ }
118
+ function writeSettings(settings, file = settingsPath()) {
119
+ const normalized = normalizeSettings(settings);
120
+ writeSecretFile(file, `${JSON.stringify(normalized, null, 2)}\n`);
121
+ return normalized;
122
+ }
123
+ function keyFileExists(settings) {
124
+ const s = normalizeSettings(settings);
125
+ return !!s.keyPath && existsSync(s.keyPath);
126
+ }
127
+ export { dshHomeDir, encryptedKeyPath, keyFileExists, normalizeSettings, pluginDir, readSettings, settingsPath, writeSettings };
package/time.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Parse an ISO-8601 timestamp (or epoch milliseconds) into epoch milliseconds.
3
+ *
4
+ * @param value candidate timestamp.
5
+ * @returns epoch milliseconds, or null when missing, unparsable or negative.
6
+ */
7
+ export declare function parseTimestamp(value: unknown): number | null;
package/time.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // src/time.ts
2
+ /**
3
+ * Parse an ISO-8601 timestamp (or epoch milliseconds) into epoch milliseconds.
4
+ *
5
+ * @param value candidate timestamp.
6
+ * @returns epoch milliseconds, or null when missing, unparsable or negative.
7
+ */
8
+ function parseTimestamp(value) {
9
+ let ms;
10
+ if (typeof value === "number") ms = value;
11
+ else if (typeof value === "string" && value.trim() !== "") ms = Date.parse(value);
12
+ else return null;
13
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
14
+ }
15
+ export { parseTimestamp };
package/uninstall.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Removes plugin-owned artifacts after the bundle is uninstalled.
3
+ *
4
+ * DSH has no uninstall lifecycle: removing a bundle disposes the plugin fiber
5
+ * exactly like disabling it, an HMR reload, or a host restart. Disposal alone
6
+ * therefore proves nothing. The watcher keeps reading the profile manifest
7
+ * after disposal and deletes plugin data only after the package has stayed
8
+ * absent from both `dependencies` and `dsh.profile.bundles` for a quiet
9
+ * period. Until the package is gone, the manifest still declares it, so a
10
+ * disable, reload, or restart leaves the data alone.
11
+ *
12
+ * The watcher lives in the host process, so this covers removals performed
13
+ * while a DSH host is running. Removing the package with no host running
14
+ * (`dsh plugin remove`) leaves the data directory for manual cleanup.
15
+ */
16
+ /**
17
+ * Whether a profile manifest still claims this package.
18
+ *
19
+ * @param profileDir profile root holding `package.json`.
20
+ * @param packageName bundle package name.
21
+ * @returns true when declared, false when absent, null when the manifest
22
+ * cannot be read — callers must treat null as "still installed".
23
+ */
24
+ export declare function profileDeclaresPackage(profileDir: string, packageName?: string): boolean | null;
25
+ /**
26
+ * Delete the plugin data directory. Paths are derived from `DSH_HOME`, never
27
+ * from user input.
28
+ *
29
+ * @param env environment to read `DSH_HOME` from.
30
+ * @returns true when the directory was removed.
31
+ */
32
+ export declare function removePluginArtifacts(env?: NodeJS.ProcessEnv): boolean;
33
+ export interface UninstallCleanupOptions {
34
+ /** Profile root captured while the plugin was mounted. */
35
+ profileDir: string;
36
+ packageName?: string;
37
+ env?: NodeJS.ProcessEnv;
38
+ /** Manifest re-read interval; tests shorten this. */
39
+ intervalMs?: number;
40
+ /** Consecutive absent readings required before cleanup. */
41
+ confirmations?: number;
42
+ /** Upper bound on the watcher's lifetime. */
43
+ timeoutMs?: number;
44
+ }
45
+ /**
46
+ * Watch the profile for this package's removal and delete plugin artifacts.
47
+ *
48
+ * @param options profile location and timing knobs.
49
+ * @returns a function that stops the watcher before it acts.
50
+ */
51
+ export declare function watchForUninstallCleanup(options: UninstallCleanupOptions): () => void;
52
+ /**
53
+ * Cancel watchers started by this module instance, for example when the
54
+ * plugin mounts again after an HMR reload or a reinstall.
55
+ */
56
+ export declare function cancelUninstallCleanup(): void;