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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Lysikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # dsh-github-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `dsh-github-app` connects the DSH agent to GitHub through a **GitHub App** instead of a personal access token or an interactive `gh auth login`.
4
+
5
+ You create a GitHub App once, install it on your account or organization, and give the plugin three things: the App ID, the installation ID, and the App's private key.
6
+ The plugin exchanges that key for a short-lived **installation access token**, signs `gh` in with it, configures git's credential helper, and exports the token to the environment of the agent's child processes.
7
+ The private key is encrypted on disk with a passphrase that is never stored anywhere.
8
+
9
+ Why a GitHub App:
10
+
11
+ - The token lives for about one hour and is limited to the repositories and permissions you granted to the installation.
12
+ - Access is revoked by uninstalling or suspending the App, or by deleting the private key in GitHub — no password or PAT to hunt down.
13
+ - No browser OAuth flow is needed inside the harness: the agent gets a token by itself.
14
+
15
+ ## How it works
16
+
17
+ ```
18
+ DSH Settings → GitHub Plugin host GitHub
19
+ ───────────────────── ─────────── ──────
20
+ App ID + Installation ID ─────────▶ sign a JWT with the App key ───▶ POST /app/installations/{id}/access_tokens
21
+ Passphrase + private key ─────────▶ encrypt key at rest │
22
+ ◀──────┘ installation token (~1 h)
23
+ gh auth login --with-token
24
+ gh auth setup-git
25
+ GH_TOKEN / GITHUB_TOKEN → agent env
26
+ ```
27
+
28
+ The decrypted key exists only for the duration of a Connect call. The installation token is stored by `gh` in its own credential store and exported to the agent's environment; it is never sent back to the browser.
29
+
30
+ ## What the settings section looks like
31
+
32
+ ```
33
+ Settings → GitHub
34
+
35
+ CLI status [ Ready ] GitHub 2.62.0
36
+ Git 2.43.0
37
+ App ID [ 123456 ]
38
+ Installation ID [ 12345678 ]
39
+ Git user name [ Your Name ]
40
+ Git user email [ you@example.com ]
41
+ Key passphrase [ •••••••• ]
42
+ Private key (.pem)[ Choose file ] Key uploaded and encrypted (app.pem)
43
+ Connection [ Connected ] 59:42 [ Disconnect ]
44
+ ```
45
+
46
+ Badges:
47
+
48
+ - **CLI status**: `Install required` when `gh` or `git` is missing, `Ready` otherwise. The button installs both when needed.
49
+ - **Connection**: `Pending setup` before a key is uploaded, `Disconnected` when a key exists but the login is not active, `Connected` when `gh` has a working account.
50
+ - The `59:42` next to the connection is the remaining lifetime of the installation token. It turns yellow under five minutes and red at zero.
51
+
52
+ ## Setup
53
+
54
+ ### 1. Create a GitHub App
55
+
56
+ Open **GitHub → Settings → Developer settings → GitHub Apps → New GitHub App** (for an organization: **Organization settings → Developer settings → GitHub Apps**). GitHub's direct link is <https://github.com/settings/apps/new>.
57
+
58
+ - **Webhook**: uncheck *Active* — the plugin does not receive webhooks.
59
+ - **Repository permissions**: set **Contents: Read and write** if the agent should clone and push. **Metadata: Read-only** is granted automatically. Add *Issues*, *Pull requests*, *Actions* and other permissions only if the agent should use the matching `gh` commands; the App cannot do more than you grant here.
60
+ - **Where can this GitHub App be installed**: choose the narrowest option that fits your case.
61
+ - Click **Create GitHub App**.
62
+
63
+ ### 2. Find the App ID
64
+
65
+ The App's settings page shows **App ID** near the top. It is a number such as `123456`. Do not use the *Client ID* — it is a different value.
66
+
67
+ ### 3. Install the App and get the Installation ID
68
+
69
+ - On the App page click **Install App** and choose the account or organization, then the repositories it may access.
70
+ - After installing, the browser lands on `https://github.com/settings/installations/<installation-id>` (for an organization: `https://github.com/organizations/<org>/settings/installations/<installation-id>`). The number at the end is the **Installation ID**, for example `12345678`.
71
+ - You can return to this page later via **Settings → Applications → Configure**.
72
+
73
+ ### 4. Generate the private key
74
+
75
+ On the App settings page open **Private keys** and click **Generate a private key**. A file named like `your-app.2026-01-02.private-key.pem` is downloaded. GitHub does not let you download that key again, so keep it somewhere safe; if it is lost, generate a new one. Deleting the key in GitHub immediately stops tokens issued with it.
76
+
77
+ ### 5. Fill in the plugin
78
+
79
+ Open **Settings → GitHub** in DSH and fill in the fields:
80
+
81
+ | Field | What to enter | Where it comes from |
82
+ | --- | --- | --- |
83
+ | **App ID** | The numeric App ID | App settings page, *App ID* |
84
+ | **Installation ID** | The numeric installation ID | URL of the installation page, `/installations/<id>` |
85
+ | **Git user name** | Author name for commits | Your choice; written to `git config --global user.name` |
86
+ | **Git user email** | Author email for commits | Your choice; written to `git config --global user.email` |
87
+ | **Key passphrase** | Any passphrase of 8+ characters | Invented by you. It encrypts the key at rest and is **not stored** |
88
+ | **Private key (.pem)** | The file from step 4 | GitHub App → *Private keys* |
89
+
90
+ Text fields are saved automatically a moment after you stop typing, and only the field you changed is written. Git identity is applied to the global git config only when it actually changes; clearing a field removes that global setting.
91
+
92
+ Then:
93
+
94
+ 1. If **CLI status** says `Install required`, click **Install missing components**. On Linux the package manager usually needs root; if the host lacks those privileges the manager's error is shown and you can install `gh`/`git` manually. On Debian/Ubuntu `gh` is not in the default repositories and the plugin shows a link to GitHub's installation instructions.
95
+ 2. Enter the passphrase and upload the `.pem` file. The key is encrypted and written to disk; the original file is not needed afterwards.
96
+ 3. Click **Connect**.
97
+
98
+ ### What happens on Connect
99
+
100
+ 1. The plugin decrypts the key with your passphrase and signs a short-lived JWT as the GitHub App.
101
+ 2. It asks GitHub for an installation access token.
102
+ 3. It runs `gh auth login --with-token` and `gh auth setup-git`, so both `gh` and `git` can use the token.
103
+ 4. It exports `GH_TOKEN` and `GITHUB_TOKEN` into the plugin host's environment, which the agent's child processes inherit.
104
+ 5. It reads the token expiry from GitHub and starts the countdown.
105
+
106
+ If any step after the login fails, the plugin logs `gh` out again instead of leaving a half-connected state. A wrong passphrase or a corrupted key file is reported without touching the stored key.
107
+
108
+ ## Everyday use
109
+
110
+ - **The token expires after about one hour.** GitHub App installation tokens are not refreshable, so when the countdown reaches zero the connection becomes unavailable and you click **Connect** again with the passphrase.
111
+ - **Disconnect** runs `gh auth logout --hostname github.com`, removes `GH_TOKEN`/`GITHUB_TOKEN` from the host environment, and clears the recorded lifetime. Note that this logs out the active `github.com` account in gh's shared credential store, which may be a personal account if you changed accounts outside DSH.
112
+ - **The passphrase is required for upload and for every Connect.** It is not stored, not sent anywhere except the plugin's own host, and cannot be recovered. If it is lost, upload the key again with a new passphrase.
113
+ - **`gh` and `git` are shared tools.** The plugin drives their global settings (`gh` credential store, `git config --global`), so changes are visible outside DSH. Disconnecting or uninstalling does not remove the git identity.
114
+
115
+ ## Security
116
+
117
+ - The private key is encrypted at rest with scrypt + AES-256-GCM (unique salt and IV, authenticated payload) in a `0600` file under `$DSH_HOME/dsh-github-app/`.
118
+ - The passphrase is never persisted and never returned to the browser. The decrypted key exists in memory only during a Connect call.
119
+ - Every request to the plugin API is checked by the DSH connection service: a live browser session or API credential is required even for loopback requests, so a spoofed `Host` header cannot bypass it. Only a profile without the connection service (headless) falls back to accepting loopback requests directly.
120
+ - The installation token is never exposed to the browser. It is written to `gh`'s credential store and exported to the host environment for the agent; any process started by the agent can read it for as long as it is valid.
121
+ - Revoke access at any time by uninstalling the App, deleting its private key in GitHub, or clicking **Disconnect** in DSH.
122
+
123
+ ## Files
124
+
125
+ The plugin keeps its data in `$DSH_HOME/dsh-github-app/` (`$DSH_HOME` defaults to `~/.dsh`):
126
+
127
+ | File | Contents |
128
+ | --- | --- |
129
+ | `github.json` | App ID, installation ID, git identity, key file name, token timestamps (mode `0600`) |
130
+ | `github-app.pem.enc` | The encrypted GitHub App private key (mode `0600`) |
131
+
132
+ ## Uninstall
133
+
134
+ Removing the bundle deletes the plugin-owned `$DSH_HOME/dsh-github-app/` directory — settings and the encrypted key — about 15 seconds after the profile stops listing the package in `dependencies` and `dsh.profile.bundles`. DSH has no uninstall hook, so the running host watches its own profile and performs the cleanup; disabling the plugin, an HMR reload, or a host restart keeps the data because the profile still declares the package.
135
+
136
+ - Removing the package while no DSH host is running (`dsh plugin remove` from a shell) leaves the watcher without a process to run in; delete `$DSH_HOME/dsh-github-app/` manually.
137
+ - A DSH update is a removal followed by an installation, so the cleanup also runs on update. Reinstall within the 15-second window, or back up the directory first, to keep the current settings.
138
+ - Uninstalling does **not** log `gh` out. Click **Disconnect** first if you want to drop the token immediately.
139
+ - The `gh` token store and the git identity are shared user settings and are left untouched.
140
+
141
+ ## Troubleshooting
142
+
143
+ | Symptom | Likely cause |
144
+ | --- | --- |
145
+ | `Install required` does not go away | `gh`/`git` are not on `PATH` for the DSH host; install them manually and restart the host. |
146
+ | `Cannot decrypt private key` | Wrong passphrase, or the key file was replaced/corrupted. Upload the `.pem` again. |
147
+ | `GitHub API 401` | The App ID and the private key belong to different Apps, or the key was deleted in GitHub. |
148
+ | `GitHub API 404` | Wrong installation ID, or the App is not installed on that account/repository. |
149
+ | `GitHub API 403` / `gh` commands fail with 403 | The App lacks the permission for that operation (for example *Contents: Read and write* for pushing). |
150
+ | Connected, but pushes fail | `gh auth setup-git` did not configure the credential helper; run Connect again and check `git config --global --get credential.https://github.com.helper`. |
@@ -0,0 +1,6 @@
1
+ (function() {
2
+ const env = { "INNETJS_CSS": ".gh-section{color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;font-size:14px;line-height:22px;width:100%}.gh-row{align-items:center;border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;gap:8px;padding:16px 0}.gh-row:last-of-type{border-bottom:none}.gh-text{flex:1;flex-direction:column;gap:4px;padding-right:48px}.gh-text,.gh-titleRow{display:flex;min-width:0}.gh-titleRow{align-items:center;flex-wrap:wrap;gap:8px}.gh-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}.gh-badge{align-items:center;border:.5px solid transparent;border-radius:999px;display:inline-flex;flex:none;font-size:11px;font-weight:500;gap:5px;line-height:17px;padding:1px 8px;white-space:nowrap}.gh-badge-missing{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.gh-badge-missing,.gh-badge-pending{background:var(--dsw-alias-bg-module-platform)}.gh-badge-pending{border-color:var(--dsw-alias-label-caption);color:var(--dsw-alias-label-secondary)}.gh-badge-connected{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}.gh-dot{background:currentColor;border-radius:50%;flex:none;height:6px;width:6px}.gh-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;white-space:pre-line}.gh-desc code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.gh-control{align-items:center;display:flex;flex:none;gap:8px;min-width:0}.gh-timer{box-sizing:border-box;color:var(--dsw-alias-label-primary);display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-variant-numeric:tabular-nums;line-height:22px;min-width:72px;text-align:right}.gh-timer-soon{color:var(--dsw-alias-state-warn-primary)}.gh-timer-expired{color:var(--dsw-alias-state-error-primary)}.gh-input{background:var(--dsw-alias-bg-module-platform);border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;height:36px;line-height:22px;max-width:320px;padding:0 14px}.gh-input:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-input:disabled{cursor:default;opacity:.5}.gh-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-button{align-items:center;border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;cursor:pointer;display:inline-flex;font:inherit;font-size:14px;gap:6px;height:36px;justify-content:center;line-height:22px;padding:0 14px;white-space:nowrap}.gh-button:disabled{cursor:default;opacity:.4}.gh-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:500}.gh-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.gh-secondary{background:transparent;border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}.gh-secondary:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput{color:var(--dsw-alias-label-secondary);font-size:12px}.gh-fileInput::file-selector-button{align-items:center;background:transparent;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);cursor:pointer;display:inline-flex;font:inherit;font-size:12px;height:28px;justify-content:center;line-height:18px;margin-right:8px;padding:0 10px;white-space:nowrap}.gh-fileInput::file-selector-button:hover{background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput:disabled::file-selector-button{cursor:default;opacity:.4}.gh-notice{border-radius:var(--dsw-radius-md);font-size:12px;line-height:18px;margin:0;padding:8px 12px}.gh-notice-success{color:var(--dsw-alias-state-success-primary)}.gh-notice-error,.gh-notice-success{background:var(--dsw-alias-bg-module-platform)}.gh-notice-error{color:var(--dsw-alias-state-error-primary)}.gh-loading{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:16px 0}@media (max-width:640px){.gh-row{flex-wrap:wrap}.gh-text{flex-basis:100%;padding-right:0}.gh-control{align-items:stretch;width:100%}.gh-input{max-width:none;width:100%}}" };
3
+ if (typeof process === "undefined") globalThis.process = { env };
4
+ else if (process.env) Object.assign(process.env, env);
5
+ else process.env = env;
6
+ })();
package/api.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export type ApiHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
3
+ export declare function createApiHandler(ctx: unknown, { trustedHosts }: {
4
+ trustedHosts: string[];
5
+ }): ApiHandler;
package/api.mjs ADDED
@@ -0,0 +1,258 @@
1
+ import { writeSecretFile } from "./atomicFile.mjs";
2
+ import { MAX_BODY_BYTES, TOKEN_TTL_SECONDS } from "./constants.mjs";
3
+ import { PAYLOAD_TOO_LARGE, isLoopbackRequest, isTrustedApiRequest, readJsonBody, writeJson } from "./fence.mjs";
4
+ import { passphraseError } from "./passphrase.mjs";
5
+ import { encryptPrivateKey } from "./keyCrypto.mjs";
6
+ import { parseTimestamp } from "./time.mjs";
7
+ import { encryptedKeyPath, keyFileExists, readSettings, writeSettings } from "./settings.mjs";
8
+ import { connect, disconnect, getAuthStatus, getGhInfo, getGitIdentity, getGitInfo, installGh, installGit, setGitIdentity } from "./github.mjs";
9
+ import { isInsideDshHome, removeSecretFile } from "./secureFile.mjs";
10
+ import { errorMessage } from "./utils/errorMessage.mjs";
11
+ // src/api.ts
12
+ /**
13
+ * Resolve the connection service for browser-session verification.
14
+ *
15
+ * @param ctx plugin context.
16
+ * @returns the service, or undefined when this profile has none.
17
+ */
18
+ function resolveConnection(ctx) {
19
+ if (ctx === null || typeof ctx !== "object") return void 0;
20
+ const candidate = ctx;
21
+ let direct;
22
+ try {
23
+ direct = candidate.connection;
24
+ } catch {}
25
+ if (direct && typeof direct.requestRejection === "function") return direct;
26
+ if (typeof candidate.get === "function") try {
27
+ const service = candidate.get("connection");
28
+ if (service && typeof service.requestRejection === "function") return service;
29
+ } catch {}
30
+ }
31
+ /**
32
+ * Require a live DSH browser session (or API credential) for a request.
33
+ *
34
+ * @param ctx plugin context.
35
+ * @param req incoming request.
36
+ * @returns undefined when admitted, otherwise the HTTP status to return.
37
+ */
38
+ function sessionRejection(ctx, req) {
39
+ const connection = resolveConnection(ctx);
40
+ if (!connection || typeof connection.requestRejection !== "function") return 403;
41
+ let rejection;
42
+ try {
43
+ rejection = connection.requestRejection(req);
44
+ } catch {
45
+ return 403;
46
+ }
47
+ if (rejection === void 0) return void 0;
48
+ return rejection === 401 ? 401 : 403;
49
+ }
50
+ function ok(res, value) {
51
+ writeJson(res, 200, {
52
+ ok: true,
53
+ value
54
+ });
55
+ }
56
+ function fail(res, status, message) {
57
+ writeJson(res, status, {
58
+ ok: false,
59
+ error: { message }
60
+ });
61
+ }
62
+ const PEM_MARKER = /PRIVATE KEY-----/;
63
+ /**
64
+ * Validate browser-supplied key material, encrypt it and store it at the
65
+ * fixed encrypted-key path.
66
+ *
67
+ * @param pem PEM text.
68
+ * @param passphrase user passphrase; never persisted.
69
+ * @returns the stored path, or an HTTP status and message for the failure.
70
+ */
71
+ async function storeEncryptedKey(pem, passphrase) {
72
+ const invalid = passphraseError(passphrase);
73
+ if (invalid !== null) return {
74
+ ok: false,
75
+ status: 400,
76
+ message: invalid
77
+ };
78
+ if (!PEM_MARKER.test(pem)) return {
79
+ ok: false,
80
+ status: 400,
81
+ message: "Invalid private key format"
82
+ };
83
+ try {
84
+ const envelope = await encryptPrivateKey(pem, passphrase);
85
+ const keyPath = encryptedKeyPath();
86
+ writeSecretFile(keyPath, envelope);
87
+ return {
88
+ ok: true,
89
+ keyPath
90
+ };
91
+ } catch (error) {
92
+ return {
93
+ ok: false,
94
+ status: 500,
95
+ message: errorMessage(error, "Cannot store encrypted private key")
96
+ };
97
+ }
98
+ }
99
+ async function statusPayload() {
100
+ const settings = readSettings();
101
+ const gh = await getGhInfo();
102
+ const git = await getGitInfo();
103
+ let auth = {
104
+ loggedIn: false,
105
+ gitConfigured: false
106
+ };
107
+ if (gh.installed) auth = await getAuthStatus();
108
+ const gitIdentity = git.installed ? await getGitIdentity() : {
109
+ name: "",
110
+ email: ""
111
+ };
112
+ return {
113
+ gh,
114
+ git,
115
+ config: {
116
+ appId: settings.appId,
117
+ installationId: settings.installationId,
118
+ gitUserName: settings.gitUserName,
119
+ gitUserEmail: settings.gitUserEmail,
120
+ keyFileName: settings.keyFileName,
121
+ hasKey: keyFileExists(settings)
122
+ },
123
+ auth,
124
+ token: {
125
+ issuedAt: parseTimestamp(settings.tokenIssuedAt),
126
+ expiresAt: parseTimestamp(settings.tokenExpiresAt),
127
+ ttlSeconds: TOKEN_TTL_SECONDS,
128
+ active: auth.loggedIn
129
+ },
130
+ gitIdentity
131
+ };
132
+ }
133
+ function createApiHandler(ctx, { trustedHosts }) {
134
+ return async (req, res) => {
135
+ if (!isTrustedApiRequest(req, trustedHosts)) {
136
+ fail(res, 403, "Untrusted request");
137
+ return;
138
+ }
139
+ if (resolveConnection(ctx) !== void 0 || !isLoopbackRequest(req)) {
140
+ const rejection = sessionRejection(ctx, req);
141
+ if (rejection !== void 0) {
142
+ fail(res, rejection, rejection === 401 ? "Authentication required: open the DSH Web UI from its printed URL." : "Untrusted request");
143
+ return;
144
+ }
145
+ }
146
+ if ((req.method || "GET").toUpperCase() !== "POST") {
147
+ fail(res, 405, "Method not allowed");
148
+ return;
149
+ }
150
+ const body = await readJsonBody(req, MAX_BODY_BYTES);
151
+ if (body === PAYLOAD_TOO_LARGE) {
152
+ fail(res, 413, "Payload too large");
153
+ return;
154
+ }
155
+ const payload = body !== null && typeof body === "object" ? body : {};
156
+ const action = payload.action;
157
+ if (typeof action !== "string" || action.trim() === "") {
158
+ fail(res, 400, "Missing action");
159
+ return;
160
+ }
161
+ try {
162
+ switch (action) {
163
+ case "status":
164
+ ok(res, await statusPayload());
165
+ break;
166
+ case "installGh":
167
+ ok(res, {
168
+ install: await installGh(),
169
+ status: await statusPayload()
170
+ });
171
+ break;
172
+ case "installGit":
173
+ ok(res, {
174
+ install: await installGit(),
175
+ status: await statusPayload()
176
+ });
177
+ break;
178
+ case "saveConfig": {
179
+ const patch = {};
180
+ if (typeof payload.appId === "string") patch.appId = payload.appId;
181
+ if (typeof payload.installationId === "string") patch.installationId = payload.installationId;
182
+ if (typeof payload.gitUserName === "string") patch.gitUserName = payload.gitUserName;
183
+ if (typeof payload.gitUserEmail === "string") patch.gitUserEmail = payload.gitUserEmail;
184
+ let uploadedName = null;
185
+ if (typeof payload.pemBase64 === "string" && payload.pemBase64.trim() !== "") {
186
+ const pemBuffer = Buffer.from(payload.pemBase64, "base64");
187
+ let pemText = pemBuffer.toString("utf8");
188
+ const stored = await storeEncryptedKey(pemText, payload.passphrase).finally(() => {
189
+ pemText = "";
190
+ pemBuffer.fill(0);
191
+ });
192
+ if (!stored.ok) {
193
+ fail(res, stored.status, stored.message);
194
+ return;
195
+ }
196
+ patch.keyPath = stored.keyPath;
197
+ uploadedName = typeof payload.pemFileName === "string" ? payload.pemFileName.trim() : "";
198
+ }
199
+ const latest = readSettings();
200
+ if (uploadedName !== null) patch.keyFileName = uploadedName || latest.keyFileName || "github-app.pem.enc";
201
+ const replacedKeyPath = patch.keyPath && latest.keyPath && latest.keyPath !== patch.keyPath && isInsideDshHome(latest.keyPath) ? latest.keyPath : null;
202
+ const next = writeSettings({
203
+ ...latest,
204
+ ...patch
205
+ });
206
+ if (replacedKeyPath !== null) removeSecretFile(replacedKeyPath);
207
+ if (next.gitUserName !== latest.gitUserName || next.gitUserEmail !== latest.gitUserEmail) await setGitIdentity(next.gitUserName, next.gitUserEmail);
208
+ ok(res, {
209
+ settings: next,
210
+ status: await statusPayload()
211
+ });
212
+ break;
213
+ }
214
+ case "clearConfig": {
215
+ await disconnect();
216
+ const current = readSettings();
217
+ removeSecretFile(current.keyPath);
218
+ removeSecretFile(encryptedKeyPath());
219
+ writeSettings({
220
+ appId: "",
221
+ installationId: "",
222
+ keyPath: null,
223
+ keyFileName: null
224
+ });
225
+ ok(res, await statusPayload());
226
+ break;
227
+ }
228
+ case "connect": {
229
+ const settings = readSettings();
230
+ ok(res, {
231
+ connect: await connect(settings, payload.passphrase),
232
+ status: await statusPayload()
233
+ });
234
+ break;
235
+ }
236
+ case "disconnect": {
237
+ const result = await disconnect();
238
+ if (!result.ok) {
239
+ fail(res, 500, result.error || "Disconnect failed");
240
+ return;
241
+ }
242
+ ok(res, await statusPayload());
243
+ break;
244
+ }
245
+ case "test":
246
+ ok(res, {
247
+ auth: await getAuthStatus(),
248
+ status: await statusPayload()
249
+ });
250
+ break;
251
+ default: fail(res, 400, `Unknown action: ${action}`);
252
+ }
253
+ } catch (error) {
254
+ fail(res, 500, errorMessage(error, "Internal error"));
255
+ }
256
+ };
257
+ }
258
+ export { createApiHandler };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Atomic private-file writes shared by settings and key storage.
3
+ *
4
+ * Files are written to a sibling temp file, fsynced, renamed into place and
5
+ * tightened to 0600, so a crash never leaves a half-written secret and an
6
+ * existing destination cannot keep looser permissions.
7
+ */
8
+ /**
9
+ * Write a secret file atomically with mode 0600.
10
+ *
11
+ * @param file destination path.
12
+ * @param content file contents.
13
+ */
14
+ export declare function writeSecretFile(file: string, content: string): void;
package/atomicFile.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { dirname } from "node:path";
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ // src/atomicFile.ts
5
+ /**
6
+ * Atomic private-file writes shared by settings and key storage.
7
+ *
8
+ * Files are written to a sibling temp file, fsynced, renamed into place and
9
+ * tightened to 0600, so a crash never leaves a half-written secret and an
10
+ * existing destination cannot keep looser permissions.
11
+ */
12
+ /**
13
+ * Write a secret file atomically with mode 0600.
14
+ *
15
+ * @param file destination path.
16
+ * @param content file contents.
17
+ */
18
+ function writeSecretFile(file, content) {
19
+ const directory = dirname(file);
20
+ mkdirSync(directory, {
21
+ recursive: true,
22
+ mode: 448
23
+ });
24
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
25
+ let handle;
26
+ try {
27
+ handle = openSync(temp, "w", 384);
28
+ writeFileSync(handle, content, "utf8");
29
+ fsyncSync(handle);
30
+ closeSync(handle);
31
+ handle = void 0;
32
+ } catch (error) {
33
+ if (handle !== void 0) try {
34
+ closeSync(handle);
35
+ } catch {}
36
+ try {
37
+ unlinkSync(temp);
38
+ } catch {}
39
+ throw error;
40
+ }
41
+ try {
42
+ renameSync(temp, file);
43
+ } catch (error) {
44
+ try {
45
+ unlinkSync(temp);
46
+ } catch {}
47
+ throw error;
48
+ }
49
+ try {
50
+ chmodSync(file, 384);
51
+ } catch {}
52
+ }
53
+ export { writeSecretFile };
@@ -0,0 +1,3 @@
1
+ (function(){let e={INNETJS_CSS:`.gh-section{color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;font-size:14px;line-height:22px;width:100%}.gh-row{align-items:center;border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;gap:8px;padding:16px 0}.gh-row:last-of-type{border-bottom:none}.gh-text{flex:1;flex-direction:column;gap:4px;padding-right:48px}.gh-text,.gh-titleRow{display:flex;min-width:0}.gh-titleRow{align-items:center;flex-wrap:wrap;gap:8px}.gh-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}.gh-badge{align-items:center;border:.5px solid transparent;border-radius:999px;display:inline-flex;flex:none;font-size:11px;font-weight:500;gap:5px;line-height:17px;padding:1px 8px;white-space:nowrap}.gh-badge-missing{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.gh-badge-missing,.gh-badge-pending{background:var(--dsw-alias-bg-module-platform)}.gh-badge-pending{border-color:var(--dsw-alias-label-caption);color:var(--dsw-alias-label-secondary)}.gh-badge-connected{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}.gh-dot{background:currentColor;border-radius:50%;flex:none;height:6px;width:6px}.gh-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;white-space:pre-line}.gh-desc code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.gh-control{align-items:center;display:flex;flex:none;gap:8px;min-width:0}.gh-timer{box-sizing:border-box;color:var(--dsw-alias-label-primary);display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-variant-numeric:tabular-nums;line-height:22px;min-width:72px;text-align:right}.gh-timer-soon{color:var(--dsw-alias-state-warn-primary)}.gh-timer-expired{color:var(--dsw-alias-state-error-primary)}.gh-input{background:var(--dsw-alias-bg-module-platform);border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;height:36px;line-height:22px;max-width:320px;padding:0 14px}.gh-input:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-input:disabled{cursor:default;opacity:.5}.gh-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-button{align-items:center;border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;cursor:pointer;display:inline-flex;font:inherit;font-size:14px;gap:6px;height:36px;justify-content:center;line-height:22px;padding:0 14px;white-space:nowrap}.gh-button:disabled{cursor:default;opacity:.4}.gh-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:500}.gh-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.gh-secondary{background:transparent;border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}.gh-secondary:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput{color:var(--dsw-alias-label-secondary);font-size:12px}.gh-fileInput::file-selector-button{align-items:center;background:transparent;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);cursor:pointer;display:inline-flex;font:inherit;font-size:12px;height:28px;justify-content:center;line-height:18px;margin-right:8px;padding:0 10px;white-space:nowrap}.gh-fileInput::file-selector-button:hover{background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput:disabled::file-selector-button{cursor:default;opacity:.4}.gh-notice{border-radius:var(--dsw-radius-md);font-size:12px;line-height:18px;margin:0;padding:8px 12px}.gh-notice-success{color:var(--dsw-alias-state-success-primary)}.gh-notice-error,.gh-notice-success{background:var(--dsw-alias-bg-module-platform)}.gh-notice-error{color:var(--dsw-alias-state-error-primary)}.gh-loading{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:16px 0}@media (max-width:640px){.gh-row{flex-wrap:wrap}.gh-text{flex-basis:100%;padding-right:0}.gh-control{align-items:stretch;width:100%}.gh-input{max-width:none;width:100%}}`};typeof process>`u`?globalThis.process={env:e}:process.env?Object.assign(process.env,e):process.env=e})();function e(e){return typeof e!=`string`||e.trim()===``?`Passphrase is required.`:e.length<8?`Passphrase must be at least 8 characters.`:e.length>1024?`Passphrase must be at most 1024 characters.`:null}async function t(e,t){let n=Object.assign({action:e},t||{}),r=await fetch(`/github/api`,{method:`POST`,headers:{"content-type":`application/json`},credentials:`same-origin`,body:JSON.stringify(n)}),i=null;try{i=await r.json()}catch{i=null}let a=typeof i==`object`&&i?i:null;if(a&&a.ok===!1){let e=a.error,t=e&&typeof e.message==`string`?e.message:`Request failed`;throw Error(t)}if(!r.ok)throw Error(`GitHub request failed (HTTP `+r.status+`).`);if(!a)throw Error(`Unreadable response.`);return Object.prototype.hasOwnProperty.call(a,`value`)?a.value:a}function n(e){return e<10?`0`+e:String(e)}function r(e){let t=Math.max(0,Math.ceil(e/1e3)),r=t%60,i=Math.floor(t/60)%60;return t>=3600?Math.floor(t/3600)+`:`+n(i)+`:`+n(r):n(i)+`:`+n(r)}function i(e){return e==null||!Number.isFinite(e)?`unknown`:e<=0?`expired`:e<3e5?`soon`:`valid`}function a(e,t,n){return typeof e==`number`&&Number.isFinite(e)&&e>0?e:typeof t==`number`&&Number.isFinite(t)&&t>0?t+n:null}function o(e){if(typeof document>`u`||!document.head||document.querySelector(`style[data-plugin="${e}"]`)!==null)return;let t=document.createElement(`style`);t.dataset.plugin=e,t.textContent=`.gh-section{color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;font-size:14px;line-height:22px;width:100%}.gh-row{align-items:center;border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;gap:8px;padding:16px 0}.gh-row:last-of-type{border-bottom:none}.gh-text{flex:1;flex-direction:column;gap:4px;padding-right:48px}.gh-text,.gh-titleRow{display:flex;min-width:0}.gh-titleRow{align-items:center;flex-wrap:wrap;gap:8px}.gh-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}.gh-badge{align-items:center;border:.5px solid transparent;border-radius:999px;display:inline-flex;flex:none;font-size:11px;font-weight:500;gap:5px;line-height:17px;padding:1px 8px;white-space:nowrap}.gh-badge-missing{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.gh-badge-missing,.gh-badge-pending{background:var(--dsw-alias-bg-module-platform)}.gh-badge-pending{border-color:var(--dsw-alias-label-caption);color:var(--dsw-alias-label-secondary)}.gh-badge-connected{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}.gh-dot{background:currentColor;border-radius:50%;flex:none;height:6px;width:6px}.gh-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;white-space:pre-line}.gh-desc code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.gh-control{align-items:center;display:flex;flex:none;gap:8px;min-width:0}.gh-timer{box-sizing:border-box;color:var(--dsw-alias-label-primary);display:inline-block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-variant-numeric:tabular-nums;line-height:22px;min-width:72px;text-align:right}.gh-timer-soon{color:var(--dsw-alias-state-warn-primary)}.gh-timer-expired{color:var(--dsw-alias-state-error-primary)}.gh-input{background:var(--dsw-alias-bg-module-platform);border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;height:36px;line-height:22px;max-width:320px;padding:0 14px}.gh-input:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-input:disabled{cursor:default;opacity:.5}.gh-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-button{align-items:center;border:none;border-radius:var(--dsw-radius-md);box-sizing:border-box;cursor:pointer;display:inline-flex;font:inherit;font-size:14px;gap:6px;height:36px;justify-content:center;line-height:22px;padding:0 14px;white-space:nowrap}.gh-button:disabled{cursor:default;opacity:.4}.gh-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}.gh-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:500}.gh-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.gh-secondary{background:transparent;border:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}.gh-secondary:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput{color:var(--dsw-alias-label-secondary);font-size:12px}.gh-fileInput::file-selector-button{align-items:center;background:transparent;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);box-sizing:border-box;color:var(--dsw-alias-label-primary);cursor:pointer;display:inline-flex;font:inherit;font-size:12px;height:28px;justify-content:center;line-height:18px;margin-right:8px;padding:0 10px;white-space:nowrap}.gh-fileInput::file-selector-button:hover{background:var(--dsw-alias-interactive-bg-hover)}.gh-fileInput:disabled::file-selector-button{cursor:default;opacity:.4}.gh-notice{border-radius:var(--dsw-radius-md);font-size:12px;line-height:18px;margin:0;padding:8px 12px}.gh-notice-success{color:var(--dsw-alias-state-success-primary)}.gh-notice-error,.gh-notice-success{background:var(--dsw-alias-bg-module-platform)}.gh-notice-error{color:var(--dsw-alias-state-error-primary)}.gh-loading{border-bottom:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:16px 0}@media (max-width:640px){.gh-row{flex-wrap:wrap}.gh-text{flex-basis:100%;padding-right:0}.gh-control{align-items:stretch;width:100%}.gh-input{max-width:none;width:100%}}`,document.head.appendChild(t)}function s(e,t){return e instanceof Error&&e.message?e.message:t}let c;function l(){return c}function u(e){c=e}function d({children:e,variant:t=`primary`,disabled:n,onClick:r}){return/* @__PURE__ */ l().createElement(`button`,{className:`gh-button gh-`+t,disabled:n,onClick:r},e)}function f({expiresAt:e,onExpired:t}){let n=l(),{useEffect:a,useRef:o,useState:s}=n,[c,u]=s(()=>Date.now()),d=o(null);a(()=>{if(u(Date.now()),e===null)return;let t=setInterval(()=>u(Date.now()),1e3);return()=>clearInterval(t)},[e]);let f=e===null?null:e-c;if(a(()=>{f===null||f>0||d.current!==e&&(d.current=e,t&&t())},[e,f]),f===null)return null;let p=i(f),m=p===`soon`?`gh-timer-soon`:p===`expired`?`gh-timer-expired`:`gh-timer-valid`;return/* @__PURE__ */ n.createElement(`span`,{className:`gh-timer `+m},r(f))}function p({onChange:e,accept:t,disabled:n}){return/* @__PURE__ */ l().createElement(`input`,{className:`gh-fileInput`,type:`file`,accept:t,disabled:n,onChange:e})}function m({value:e,onChange:t,type:n=`text`,placeholder:r,disabled:i}){return/* @__PURE__ */ l().createElement(`input`,{className:`gh-input`,type:n,value:e,disabled:i,onChange:e=>t(e.target.value),placeholder:r})}function ee({kind:e,text:t}){return/* @__PURE__ */ l().createElement(`div`,{className:`gh-notice gh-notice-`+e,style:{marginTop:`16px`}},t)}function h({title:e,description:t,control:n,badge:r}){let i=l();return/* @__PURE__ */ i.createElement(`div`,{className:`gh-row`},/* @__PURE__ */ i.createElement(`div`,{className:`gh-text`},/* @__PURE__ */ i.createElement(`div`,{className:`gh-titleRow`},/* @__PURE__ */ i.createElement(`span`,{className:`gh-title`},e),r||null),t?/* @__PURE__ */ i.createElement(`div`,{className:`gh-desc`},t):null),/* @__PURE__ */ i.createElement(`div`,{className:`gh-control`},n))}function g({state:e,label:t}){let n=l();return/* @__PURE__ */ n.createElement(`span`,{className:`gh-badge gh-badge-`+e},/* @__PURE__ */ n.createElement(`span`,{className:`gh-dot`}),t)}function _(e,t){let n={};return t===null||(e.appId!==t.appId&&(n.appId=e.appId),e.installationId!==t.installationId&&(n.installationId=e.installationId),e.gitUserName!==t.gitUserName&&(n.gitUserName=e.gitUserName),e.gitUserEmail!==t.gitUserEmail&&(n.gitUserEmail=e.gitUserEmail)),n}function te(e){return new Promise((t,n)=>{let r=new FileReader;r.onload=()=>{let e=String(r.result||``);t(e.includes(`,`)?e.split(`,`)[1]:e)},r.onerror=()=>n(r.error||/* @__PURE__ */ Error(`File read failed`)),r.readAsDataURL(e)})}function v(){let n=l(),{useState:r,useEffect:i,useRef:o}=n,[c,u]=r(!0),[v,y]=r(!1),[b,x]=r(null),[S,ne]=r(null),[C,w]=r(``),[T,E]=r(``),[D,O]=r(``),[k,A]=r(``),[j,M]=r(``),N=o(!1),P=o(null);function F(){return{appId:C,installationId:T,gitUserName:D,gitUserEmail:k}}async function I(){try{let e=await t(`status`),n=e&&e.config,r=e&&e.gitIdentity,i={appId:n&&n.appId||``,installationId:n&&n.installationId||``,gitUserName:n&&n.gitUserName||r&&r.name||``,gitUserEmail:n&&n.gitUserEmail||r&&r.email||``};ne(e),w(i.appId),E(i.installationId),O(i.gitUserName),A(i.gitUserEmail),P.current=i,N.current=!0}catch(e){x({kind:`error`,text:s(e,`Failed to load status.`)})}finally{u(!1)}}i(()=>{I()},[]),i(()=>{let e=P.current;if(!N.current||e===null)return;let n=_(F(),e);if(Object.keys(n).length===0)return;let r=setTimeout(async()=>{try{await t(`saveConfig`,n),P.current={...e,...n}}catch(e){x({kind:`error`,text:s(e,`Save failed.`)})}},600);return()=>clearTimeout(r)},[C,T,D,k]);async function re(){let e=P.current;if(e===null)return;let n=_(F(),e);Object.keys(n).length!==0&&(await t(`saveConfig`,n),P.current={...e,...n})}async function L(e){y(!0),x(null);try{await e(),await I()}catch(e){x({kind:`error`,text:s(e,`Action failed.`)})}finally{y(!1)}}async function ie(){await L(async()=>{let e=await t(`installGh`);if(e&&e.install&&e.install.ok===!1)throw Error(e.install.error||`Install failed.`);x({kind:`success`,text:`Missing components installed.`})})}function R(){let t=e(j);return t===null||(x({kind:`error`,text:t}),!1)}async function ae(e){let n=e.target.files&&e.target.files[0];n&&(R()&&await L(async()=>{let e=await te(n);await t(`saveConfig`,{..._(F(),P.current),pemBase64:e,pemFileName:n.name,passphrase:j}),x({kind:`success`,text:`Private key uploaded and encrypted.`})}),e.target.value=``)}async function oe(){R()&&await L(async()=>{await re();let e=await t(`connect`,{passphrase:j});if(e&&e.connect&&e.connect.ok===!1)throw Error(e.connect.error||`Connect failed.`);M(``),x({kind:`success`,text:`Connected.`})})}async function se(){await L(async()=>{await t(`disconnect`),M(``),x({kind:`success`,text:`Disconnected.`})})}if(c)return/* @__PURE__ */ n.createElement(`div`,{className:`gh-section`},/* @__PURE__ */ n.createElement(`div`,{className:`gh-loading`},`Loading…`));let z=S?.gh??{installed:!1,version:null},B=S?.git??{installed:!1,version:null},V=S?.config??{hasKey:!1,keyFileName:null,appId:``,installationId:``,gitUserName:``,gitUserEmail:``},H=S?.auth??{loggedIn:!1,gitConfigured:!1},U=S?.token??null,W=z.installed&&B.installed,G=v||!W,K=v||!B.installed,ce=U!==null&&Number.isFinite(U.ttlSeconds)?U.ttlSeconds:3600,le=U!==null&&Number.isFinite(U.issuedAt)?U.issuedAt:null,ue=U!==null&&Number.isFinite(U.expiresAt)?U.expiresAt:null,de=H.loggedIn?a(ue,le,ce*1e3):null,q=`Ready`,J=`connected`;W||(q=`Install required`,J=`missing`);let Y=`Pending setup`,X=`pending`;H.loggedIn?(Y=`Connected`,X=`connected`):(V.hasKey||H.error)&&(Y=`Disconnected`,X=`missing`);let Z=`Authenticate GitHub CLI with a GitHub App`;!H.loggedIn&&H.error&&(Z+=`
2
+ `+H.error);let Q=null;W||(Q=/* @__PURE__ */ n.createElement(d,{disabled:v,onClick:ie},v?`Installing…`:`Install missing components`));let $;return $=H.loggedIn?/* @__PURE__ */ n.createElement(d,{variant:`secondary`,disabled:v||!z.installed,onClick:se},v?`Disconnecting…`:`Disconnect`):/* @__PURE__ */ n.createElement(d,{disabled:G||!V.hasKey||!C||!T,onClick:oe},v?`Connecting…`:`Connect`),/* @__PURE__ */ n.createElement(`div`,{className:`gh-section`},/* @__PURE__ */ n.createElement(h,{key:`status`,title:`CLI status`,description:(z.installed?`GitHub version `+(z.version||`unknown`):`GitHub CLI is not installed`)+`
3
+ `+(B.installed?`Git version `+(B.version||`unknown`):`Git is not installed`),badge:/* @__PURE__ */ n.createElement(g,{state:J,label:q}),control:Q}),/* @__PURE__ */ n.createElement(h,{key:`appId`,title:`App ID`,description:`Your GitHub App ID`,control:/* @__PURE__ */ n.createElement(m,{value:C,disabled:G,onChange:w,placeholder:`123456`})}),/* @__PURE__ */ n.createElement(h,{key:`installationId`,title:`Installation ID`,description:`Your GitHub App installation ID`,control:/* @__PURE__ */ n.createElement(m,{value:T,disabled:G,onChange:E,placeholder:`12345678`})}),/* @__PURE__ */ n.createElement(h,{key:`gitUserName`,title:`Git user name`,description:`Commit author name (git config --global user.name)`,control:/* @__PURE__ */ n.createElement(m,{value:D,disabled:K,onChange:O,placeholder:`Your Name`})}),/* @__PURE__ */ n.createElement(h,{key:`gitUserEmail`,title:`Git user email`,description:`Commit author email (git config --global user.email)`,control:/* @__PURE__ */ n.createElement(m,{type:`email`,value:k,disabled:K,onChange:A,placeholder:`you@example.com`})}),/* @__PURE__ */ n.createElement(h,{key:`passphrase`,title:`Key passphrase`,description:`Encrypts the private key at rest. It is never stored and is required for upload and every connect.`,control:/* @__PURE__ */ n.createElement(m,{type:`password`,value:j,disabled:v,onChange:M,placeholder:`At least 8 characters`})}),/* @__PURE__ */ n.createElement(h,{key:`key`,title:`Private key (.pem)`,description:V.hasKey?`Key uploaded and encrypted`+(V.keyFileName?` (`+V.keyFileName+`)`:``):`Upload the GitHub App private key`,control:/* @__PURE__ */ n.createElement(p,{accept:`.pem`,disabled:G,onChange:ae})}),/* @__PURE__ */ n.createElement(h,{key:`actions`,title:`Connection`,description:Z,badge:/* @__PURE__ */ n.createElement(g,{state:X,label:Y}),control:/* @__PURE__ */ n.createElement(n.Fragment,null,/* @__PURE__ */ n.createElement(f,{expiresAt:de,onExpired:I}),$)}),b?/* @__PURE__ */ n.createElement(ee,{kind:b.kind,text:b.text}):null)}const y=`dsh-github-app`;window.__ModuleLoader__.load({id:y,factory:e=>(u(e("react")),o(y),{apply(e){e.slots.inject(`settings.section`,()=>e.slots.register({name:`settings.section`,id:`github`,order:40,label:`GitHub`},v))},inject:[`slots`]})});
package/constants.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Same-origin browser API prefix (host route). */
2
+ export declare const API_PREFIX = "/github/api";
3
+ /** Maximum request body size for the browser API (bytes). */
4
+ export declare const MAX_BODY_BYTES: number;
5
+ /** This package's name as the profile manifest declares it. */
6
+ export declare const PACKAGE_NAME = "dsh-github-app";
7
+ /** Plugin data directory name under the DSH home. */
8
+ export declare const PLUGIN_DIR_NAME = "dsh-github-app";
9
+ /** Settings file name inside the plugin directory. */
10
+ export declare const SETTINGS_FILENAME = "github.json";
11
+ /** Encrypted private key file name inside the plugin directory. */
12
+ export declare const KEY_ENCRYPTED_FILENAME = "github-app.pem.enc";
13
+ /** Default hostname for GitHub. */
14
+ export declare const DEFAULT_HOSTNAME = "github.com";
15
+ /** GitHub App installation access tokens are issued for one hour. */
16
+ export declare const TOKEN_TTL_SECONDS = 3600;
17
+ /** Minimum length of the passphrase that encrypts the private key. */
18
+ export declare const MIN_PASSPHRASE_CHARS = 8;
19
+ /** Maximum accepted passphrase length (guards scrypt input size). */
20
+ export declare const MAX_PASSPHRASE_CHARS = 1024;
package/constants.mjs ADDED
@@ -0,0 +1,20 @@
1
+ // src/constants.ts
2
+ /** Same-origin browser API prefix (host route). */
3
+ const API_PREFIX = "/github/api";
4
+ /** Maximum request body size for the browser API (bytes). */
5
+ const MAX_BODY_BYTES = 1048576;
6
+ /** This package's name as the profile manifest declares it. */
7
+ const PACKAGE_NAME = "dsh-github-app";
8
+ /** Plugin data directory name under the DSH home. */
9
+ const PLUGIN_DIR_NAME = "dsh-github-app";
10
+ /** Settings file name inside the plugin directory. */
11
+ const SETTINGS_FILENAME = "github.json";
12
+ /** Encrypted private key file name inside the plugin directory. */
13
+ const KEY_ENCRYPTED_FILENAME = "github-app.pem.enc";
14
+ /** Default hostname for GitHub. */
15
+ const DEFAULT_HOSTNAME = "github.com";
16
+ /** GitHub App installation access tokens are issued for one hour. */
17
+ const TOKEN_TTL_SECONDS = 3600;
18
+ /** Maximum accepted passphrase length (guards scrypt input size). */
19
+ const MAX_PASSPHRASE_CHARS = 1024;
20
+ export { API_PREFIX, DEFAULT_HOSTNAME, KEY_ENCRYPTED_FILENAME, MAX_BODY_BYTES, MAX_PASSPHRASE_CHARS, PACKAGE_NAME, PLUGIN_DIR_NAME, SETTINGS_FILENAME, TOKEN_TTL_SECONDS };
package/contracts.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shapes shared by the host API and the browser settings UI.
3
+ *
4
+ * Type-only: importing this module never adds runtime code to either bundle.
5
+ */
6
+ export interface ToolStatus {
7
+ installed: boolean;
8
+ version: string | null;
9
+ }
10
+ export interface GitIdentity {
11
+ name: string;
12
+ email: string;
13
+ }
14
+ export interface StatusAuth {
15
+ loggedIn: boolean;
16
+ gitConfigured: boolean;
17
+ error?: string;
18
+ }
19
+ export interface StatusConfig {
20
+ appId: string;
21
+ installationId: string;
22
+ gitUserName: string;
23
+ gitUserEmail: string;
24
+ keyFileName: string | null;
25
+ hasKey: boolean;
26
+ }
27
+ export interface StatusToken {
28
+ issuedAt: number | null;
29
+ expiresAt: number | null;
30
+ ttlSeconds: number;
31
+ active: boolean;
32
+ }
33
+ export interface StatusPayload {
34
+ gh: ToolStatus;
35
+ git: ToolStatus;
36
+ config: StatusConfig;
37
+ auth: StatusAuth;
38
+ token: StatusToken;
39
+ gitIdentity: GitIdentity;
40
+ }
@@ -0,0 +1,5 @@
1
+ # dsh-github-app profile patch layer.
2
+ # Registers the GitHub CLI integration (settings + host API).
3
+ - insert:
4
+ - id: github
5
+ name: 'dsh-github-app'