gitdog 0.1.0 → 0.1.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/README.md +2 -2
- package/dist/api-keys.d.ts +2 -0
- package/dist/api-keys.js +47 -3
- package/dist/commands.js +3 -1
- package/dist/projects.js +7 -1
- package/dist/storage.js +50 -3
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +35 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://www.npmjs.com/package/gitdog"><img src="https://img.shields.io/npm/v/gitdog?logo=npm&color=CB3837" alt="npm version"></a>
|
|
13
|
-
<a href="https://www.npmjs.com/package/gitdog"><img src="https://img.shields.io/npm/dm/gitdog?color=555" alt="npm downloads"></a>
|
|
14
13
|
<a href="https://github.com/hyodotdev/gitdog-cli/actions/workflows/ci.yml"><img src="https://github.com/hyodotdev/gitdog-cli/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
14
|
+
<a href="https://github.com/hyodotdev/gitdog-cli/actions/workflows/release.yml"><img src="https://github.com/hyodotdev/gitdog-cli/actions/workflows/release.yml/badge.svg" alt="npm release"></a>
|
|
15
15
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D22.14.0-3c873a?logo=nodedotjs&logoColor=white" alt="Node.js 22.14 or later"></a>
|
|
16
16
|
<a href="https://gitdog.dev"><img src="https://img.shields.io/badge/Gitdog-gitdog.dev-111" alt="Gitdog website"></a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
|
-
<img src="https://raw.githubusercontent.com/hyodotdev/gitdog-cli/main/.github/assets/terminal.svg" alt="Gitdog terminal: connect a project, configure automation and review pending actions" width="800">
|
|
20
|
+
<img src="https://raw.githubusercontent.com/hyodotdev/gitdog-cli/main/.github/assets/terminal.svg?v=0.1.1" alt="Gitdog terminal: connect a project, configure automation and review pending actions" width="800">
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
Connect repositories, configure Gitdog's issue and discussion assistants, review
|
package/dist/api-keys.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ export type ProjectKey = {
|
|
|
11
11
|
};
|
|
12
12
|
export declare function projectHttpUrl(apiUrl: string): string;
|
|
13
13
|
export declare function readProjectKey(path: string): Promise<ProjectKey>;
|
|
14
|
+
/** Refuse key paths that Git would track, so secrets are not committed. */
|
|
15
|
+
export declare function assertUntrackedKeyPath(path: string): Promise<void>;
|
|
14
16
|
export declare function saveProjectKey(path: string, key: ProjectKey): Promise<void>;
|
|
15
17
|
export declare function callWithProjectKey<T>(apiUrl: string, keyFile: string | undefined, rawKey: string | undefined, kind: string, path: string, args: unknown, timeoutMs: number): Promise<T>;
|
package/dist/api-keys.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { mkdir, open, readFile, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
5
|
import { asRecord } from "./ui.js";
|
|
4
|
-
import { validateUrl } from "./storage.js";
|
|
6
|
+
import { exists, validateUrl } from "./storage.js";
|
|
5
7
|
export function projectHttpUrl(apiUrl) {
|
|
6
8
|
return validateUrl(process.env.GITDOG_HTTP_URL ??
|
|
7
9
|
apiUrl.replace(/\.convex\.cloud$/, ".convex.site"), "Project HTTP URL");
|
|
@@ -39,6 +41,48 @@ export async function readProjectKey(path) {
|
|
|
39
41
|
expiresAt: key.expiresAt,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
44
|
+
/** Refuse key paths that Git would track, so secrets are not committed. */
|
|
45
|
+
export async function assertUntrackedKeyPath(path) {
|
|
46
|
+
let directory = dirname(path);
|
|
47
|
+
while (!(await exists(directory))) {
|
|
48
|
+
const parent = dirname(directory);
|
|
49
|
+
if (parent === directory)
|
|
50
|
+
return;
|
|
51
|
+
directory = parent;
|
|
52
|
+
}
|
|
53
|
+
const canonicalDirectory = await realpath(directory);
|
|
54
|
+
const canonicalPath = join(canonicalDirectory, relative(directory, path));
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
57
|
+
cwd: canonicalDirectory,
|
|
58
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
});
|
|
61
|
+
if (stdout.trim() === "false")
|
|
62
|
+
return;
|
|
63
|
+
if (stdout.trim() !== "true")
|
|
64
|
+
throw new Error("Unexpected Git work tree result");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const gitError = error;
|
|
68
|
+
if (gitError.code === 128 &&
|
|
69
|
+
/fatal: not a git repository/i.test(gitError.stderr ?? ""))
|
|
70
|
+
return;
|
|
71
|
+
throw new Error("Could not verify the key path with Git. Fix Git configuration or choose the default private key location.");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await promisify(execFile)("git", ["check-ignore", "-q", "--", canonicalPath], {
|
|
75
|
+
cwd: canonicalDirectory,
|
|
76
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code === 1)
|
|
82
|
+
throw new Error("The key file would be inside a Git repository and not ignored. Choose a path outside the repository or add it to .gitignore.");
|
|
83
|
+
throw new Error("Could not verify Git ignore rules for the key path. No key was issued.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
42
86
|
export async function saveProjectKey(path, key) {
|
|
43
87
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
44
88
|
const handle = await open(path, "wx", 0o600);
|
package/dist/commands.js
CHANGED
|
@@ -7,7 +7,7 @@ import { login, logout, openBrowser } from "./auth.js";
|
|
|
7
7
|
import { API_CATALOG, publicOperation } from "./catalog.js";
|
|
8
8
|
import { BOOLEAN_SETTINGS, STYLE_SETTINGS, ProjectContext, limitParser, } from "./projects.js";
|
|
9
9
|
import { DEFAULT_API_URL, DEFAULT_SITE_URL, SessionStore, validateUrl, configDirectory, exists, } from "./storage.js";
|
|
10
|
-
import { readProjectKey, saveProjectKey, projectHttpUrl } from "./api-keys.js";
|
|
10
|
+
import { assertUntrackedKeyPath, readProjectKey, saveProjectKey, projectHttpUrl, } from "./api-keys.js";
|
|
11
11
|
import { Ui, VERSION, asRecord, integer } from "./ui.js";
|
|
12
12
|
const text = (value) => typeof value === "string" ? value : undefined;
|
|
13
13
|
const number = (value) => typeof value === "number" ? value : undefined;
|
|
@@ -164,6 +164,8 @@ export function createProgram(dependencies = {}) {
|
|
|
164
164
|
const out = text(opts.out);
|
|
165
165
|
if (out && (await exists(resolve(out))))
|
|
166
166
|
throw new Error("The output file already exists. Choose a new private path.");
|
|
167
|
+
if (out)
|
|
168
|
+
await assertUntrackedKeyPath(resolve(out));
|
|
167
169
|
const name = text(opts.name) ?? "CLI key";
|
|
168
170
|
const scopes = flag(opts.write) ? ["read", "write"] : ["read"];
|
|
169
171
|
const issued = await api.call("action", "apiKeys/action:create", {
|
package/dist/projects.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { exists, readJson, writeJson } from "./storage.js";
|
|
5
6
|
import { asRecord, integer } from "./ui.js";
|
|
@@ -83,13 +84,18 @@ export function validateProjectConfig(value) {
|
|
|
83
84
|
export async function findConfig(explicit, cwd = process.cwd()) {
|
|
84
85
|
if (explicit)
|
|
85
86
|
return resolve(cwd, explicit);
|
|
87
|
+
// Stop at the repository root or home so a file planted in a shared parent
|
|
88
|
+
// directory cannot choose the project.
|
|
86
89
|
let directory = resolve(cwd);
|
|
90
|
+
const home = resolve(homedir());
|
|
87
91
|
while (true) {
|
|
88
92
|
const path = join(directory, "gitdog.json");
|
|
89
93
|
if (await exists(path))
|
|
90
94
|
return path;
|
|
91
95
|
const parent = dirname(directory);
|
|
92
|
-
if (parent === directory
|
|
96
|
+
if (parent === directory ||
|
|
97
|
+
directory === home ||
|
|
98
|
+
(await exists(join(directory, ".git"))))
|
|
93
99
|
return undefined;
|
|
94
100
|
directory = parent;
|
|
95
101
|
}
|
package/dist/storage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, mkdir, open, readFile, rename, rm, stat, } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, open, readFile, rename, rmdir, rm, stat, } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -102,13 +102,24 @@ export class SessionStore {
|
|
|
102
102
|
while (!handle) {
|
|
103
103
|
signal?.throwIfAborted();
|
|
104
104
|
if (Date.now() >= deadline)
|
|
105
|
-
throw new Error(`Authentication lock timed out. Check that another Gitdog process is not running; stale lock: ${path}.`);
|
|
105
|
+
throw new Error(`Authentication lock timed out. Check that another Gitdog process is not running; stale lock: ${path} (recovery gate: ${path}.recovery).`);
|
|
106
106
|
try {
|
|
107
|
-
|
|
107
|
+
const created = await open(path, "wx", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await created.writeFile(String(process.pid));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
await created.close();
|
|
113
|
+
await rm(path, { force: true });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
handle = created;
|
|
108
117
|
}
|
|
109
118
|
catch (error) {
|
|
110
119
|
if (error.code !== "EEXIST")
|
|
111
120
|
throw error;
|
|
121
|
+
if (await removeAbandonedLock(path))
|
|
122
|
+
continue;
|
|
112
123
|
if (Date.now() >= deadline)
|
|
113
124
|
throw new Error(`Another Gitdog process is updating authentication. Retry later; if it crashed, remove ${path}.`);
|
|
114
125
|
await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, deadline - Date.now()))));
|
|
@@ -124,6 +135,42 @@ export class SessionStore {
|
|
|
124
135
|
}
|
|
125
136
|
}
|
|
126
137
|
}
|
|
138
|
+
/** Remove a lock whose recorded owner process no longer exists. */
|
|
139
|
+
async function removeAbandonedLock(path) {
|
|
140
|
+
const recovery = `${path}.recovery`;
|
|
141
|
+
try {
|
|
142
|
+
await mkdir(recovery, { mode: 0o700 });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// Re-read under the recovery mutex: another waiter may have replaced the
|
|
149
|
+
// dead owner's lock with a live lock before this cleaner acquired the gate.
|
|
150
|
+
const content = (await readFile(path, "utf8")).trim();
|
|
151
|
+
if (!/^[1-9]\d*$/.test(content))
|
|
152
|
+
return false;
|
|
153
|
+
const owner = Number(content);
|
|
154
|
+
if (!Number.isSafeInteger(owner) || owner === process.pid)
|
|
155
|
+
return false;
|
|
156
|
+
try {
|
|
157
|
+
process.kill(owner, 0);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "ESRCH")
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
await rm(path);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await rmdir(recovery);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
127
174
|
export function tokenExpiresSoon(token) {
|
|
128
175
|
try {
|
|
129
176
|
const payload = asRecord(JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")));
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.1";
|
|
2
2
|
export declare const DOG: string;
|
|
3
3
|
export declare function redactSecrets(value: string): string;
|
|
4
4
|
export declare function clean(value: string): string;
|
|
5
|
+
/** Escape characters JSON.stringify leaves raw; the JSON value is unchanged. */
|
|
6
|
+
export declare function safeJson(value: unknown): string;
|
|
5
7
|
export declare class Ui {
|
|
6
8
|
readonly json: boolean;
|
|
7
9
|
constructor(json?: boolean);
|
package/dist/ui.js
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
|
-
export const VERSION = "0.1.
|
|
3
|
+
export const VERSION = "0.1.1";
|
|
4
4
|
export const DOG = String.raw `
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
▗██▄▄ ▄▄██▖
|
|
6
|
+
██▀▜██▙▄ ▙▄ ▄▟██▛▀██
|
|
7
|
+
▐█▌ ▀███▄▐██▄ ▄███▀ ▜█▌
|
|
8
|
+
▐█▌ ███████▙ ▝▀▀▀ ▐█▌
|
|
9
|
+
██▌ ▄█████████▌ ▐█▌
|
|
10
|
+
▐█▌ ▄████████████ ▐█▌
|
|
11
|
+
▐█▙▟████████████▌ ▜█▘
|
|
12
|
+
▀▝▀▘ ▀▀███▛
|
|
13
|
+
|
|
14
|
+
▄█▙▖ ▄█▙▖
|
|
15
|
+
▜██▌ ▄▄ ▜██▘
|
|
16
|
+
▄███▛▜███▄
|
|
17
|
+
▗███▌ ▐███▖
|
|
18
|
+
█████▄ ▄█████
|
|
19
|
+
▗▄ ▝▜██▛▀ ▀▜██▛▘ ▄▖
|
|
20
|
+
▐██▄▖ ▗▄██▌
|
|
21
|
+
▟█████▙▄▖ ▗▄▄█████▙
|
|
22
|
+
▐█▛███████████▄▄▄▄▄▄███████████▜█▌
|
|
23
|
+
▀ ▝██████████████████████████▘ ▀
|
|
24
|
+
▝▜██████████████████████▛▘
|
|
25
|
+
▝▜██████████████████▀▘
|
|
26
|
+
▀▀▜█▜██████▛█▛▀▘
|
|
27
|
+
▀▀▀▀
|
|
10
28
|
`;
|
|
11
29
|
export function redactSecrets(value) {
|
|
12
30
|
return value
|
|
13
31
|
.replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[redacted token]")
|
|
14
32
|
.replace(/\bgdk_[a-f0-9]{64}\b/g, "[redacted API key]");
|
|
15
33
|
}
|
|
34
|
+
// C1 controls and bidirectional overrides can drive or spoof terminals.
|
|
35
|
+
const UNSAFE_UNICODE = /[\u0080-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
|
16
36
|
export function clean(value) {
|
|
17
|
-
return redactSecrets(stripVTControlCharacters(value)
|
|
37
|
+
return redactSecrets(stripVTControlCharacters(value)
|
|
38
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "")
|
|
39
|
+
.replace(UNSAFE_UNICODE, ""));
|
|
40
|
+
}
|
|
41
|
+
/** Escape characters JSON.stringify leaves raw; the JSON value is unchanged. */
|
|
42
|
+
export function safeJson(value) {
|
|
43
|
+
return redactSecrets(JSON.stringify(value ?? null, null, 2)).replace(UNSAFE_UNICODE, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
18
44
|
}
|
|
19
45
|
export class Ui {
|
|
20
46
|
json;
|
|
@@ -31,7 +57,7 @@ export class Ui {
|
|
|
31
57
|
banner() {
|
|
32
58
|
if (this.json || process.env.GITDOG_NO_BANNER === "1")
|
|
33
59
|
return;
|
|
34
|
-
process.stdout.write(this.color(DOG) + this.color(` G I T D O G / ${VERSION}\n`, 1));
|
|
60
|
+
process.stdout.write(this.color(DOG, 1) + this.color(` G I T D O G / ${VERSION}\n`, 1));
|
|
35
61
|
process.stdout.write(" Your repositories. A very good assistant.\n\n");
|
|
36
62
|
}
|
|
37
63
|
info(message) {
|
|
@@ -40,7 +66,7 @@ export class Ui {
|
|
|
40
66
|
}
|
|
41
67
|
output(value) {
|
|
42
68
|
if (this.json) {
|
|
43
|
-
process.stdout.write(`${
|
|
69
|
+
process.stdout.write(`${safeJson(value)}\n`);
|
|
44
70
|
return;
|
|
45
71
|
}
|
|
46
72
|
if (typeof value === "string") {
|