@gatebolt/cli 0.5.0 → 0.7.0
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 +5 -4
- package/dist/agents/claude-code.js +1 -30
- package/dist/commands/init.js +27 -13
- package/dist/commands/link.js +3 -5
- package/dist/git.js +15 -0
- package/dist/profiles.js +30 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,10 +56,11 @@ gatebolt init
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
It **links** the repository (writing a committable `.gatebolt`, exactly like `gatebolt link`),
|
|
59
|
-
**wires** your agent's declare → observe hooks — for Claude Code,
|
|
60
|
-
`.claude/settings.
|
|
61
|
-
that reconciles an open declaration before a push.
|
|
62
|
-
in), and is safe to re-run — it never duplicates
|
|
59
|
+
**wires** your agent's declare → observe hooks — for Claude Code, merged into the committable
|
|
60
|
+
`.claude/settings.json` so enforcement travels to every clone and worktree — and installs a
|
|
61
|
+
**pre-push backstop** at `.git/hooks/pre-push` that reconciles an open declaration before a push.
|
|
62
|
+
It reuses a saved profile (it never signs you in), and is safe to re-run — it never duplicates
|
|
63
|
+
hooks.
|
|
63
64
|
|
|
64
65
|
| Flag | Description |
|
|
65
66
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -2,9 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { isRecord } from "../json.js";
|
|
4
4
|
const CLAUDE_DIR = ".claude";
|
|
5
|
-
const SETTINGS_FILE = "settings.
|
|
6
|
-
const GITIGNORE_FILE = ".gitignore";
|
|
7
|
-
const GITIGNORE_ENTRY = `${CLAUDE_DIR}/${SETTINGS_FILE}`;
|
|
5
|
+
const SETTINGS_FILE = "settings.json";
|
|
8
6
|
const PRE_TOOL_USE = "PreToolUse";
|
|
9
7
|
const STOP = "Stop";
|
|
10
8
|
const EDIT_TOOLS_MATCHER = "Edit|Write|NotebookEdit";
|
|
@@ -59,13 +57,6 @@ export function mergeSettings(existing, opts) {
|
|
|
59
57
|
}
|
|
60
58
|
return { settings, changed };
|
|
61
59
|
}
|
|
62
|
-
export function withGitignoreEntry(existing, entry) {
|
|
63
|
-
const present = existing.split("\n").some((line) => line.trim() === entry);
|
|
64
|
-
if (present)
|
|
65
|
-
return null;
|
|
66
|
-
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
67
|
-
return `${existing}${prefix}${entry}\n`;
|
|
68
|
-
}
|
|
69
60
|
function plainObject(value, label) {
|
|
70
61
|
if (value === undefined)
|
|
71
62
|
return {};
|
|
@@ -116,11 +107,9 @@ async function wireClaudeCode(root, opts) {
|
|
|
116
107
|
await mkdir(join(root, CLAUDE_DIR), { recursive: true });
|
|
117
108
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
118
109
|
}
|
|
119
|
-
const gitignoreChanged = await ensureGitignore(root);
|
|
120
110
|
return {
|
|
121
111
|
settingsPath,
|
|
122
112
|
settingsChanged: changed,
|
|
123
|
-
gitignoreChanged,
|
|
124
113
|
enforce: effectiveEnforce(settings),
|
|
125
114
|
};
|
|
126
115
|
}
|
|
@@ -147,24 +136,6 @@ async function readSettings(path) {
|
|
|
147
136
|
}
|
|
148
137
|
return data;
|
|
149
138
|
}
|
|
150
|
-
async function ensureGitignore(root) {
|
|
151
|
-
const path = join(root, GITIGNORE_FILE);
|
|
152
|
-
const updated = withGitignoreEntry(await readTextOrEmpty(path), GITIGNORE_ENTRY);
|
|
153
|
-
if (updated === null)
|
|
154
|
-
return false;
|
|
155
|
-
await writeFile(path, updated, "utf8");
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
async function readTextOrEmpty(path) {
|
|
159
|
-
try {
|
|
160
|
-
return await readFile(path, "utf8");
|
|
161
|
-
}
|
|
162
|
-
catch (error) {
|
|
163
|
-
if (isNotFound(error))
|
|
164
|
-
return "";
|
|
165
|
-
throw error;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
139
|
function safeParse(raw) {
|
|
169
140
|
try {
|
|
170
141
|
return JSON.parse(raw);
|
package/dist/commands/init.js
CHANGED
|
@@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { claudeCodeAdapter } from "../agents/claude-code.js";
|
|
7
7
|
import { repoIdentityFromName, resolveConnection } from "../config.js";
|
|
8
|
-
import { hooksPath, repoContext } from "../git.js";
|
|
8
|
+
import { hooksPath, isPathIgnored, isUncommitted, repoContext, } from "../git.js";
|
|
9
9
|
import { isRecord } from "../json.js";
|
|
10
|
-
import { profileLoader, readRepoLink } from "../profiles.js";
|
|
10
|
+
import { pinRepoProfile, profileLoader, readRepoLink } from "../profiles.js";
|
|
11
11
|
import { deriveIdentity, linkRepository } from "./link.js";
|
|
12
12
|
const ADAPTERS = [claudeCodeAdapter];
|
|
13
13
|
const ENFORCE_VALUES = ["lenient", "strict"];
|
|
@@ -15,10 +15,10 @@ const PRE_PUSH_FILE = "pre-push";
|
|
|
15
15
|
const PRE_PUSH_MARKER = "GateBolt pre-push backstop";
|
|
16
16
|
const HOOK_MODE = 0o755;
|
|
17
17
|
const ID_PREFIX_LEN = 8;
|
|
18
|
-
const GITIGNORE_ENTRY = ".claude/settings.local.json";
|
|
19
18
|
const NO_REMOTE = "GateBolt cannot derive this repository's identity from its `origin` remote. Re-run with --name <a-name-for-this-repo>.";
|
|
20
19
|
const NO_KEY = "No GateBolt key found. Run `gatebolt login` (or `gatebolt configure`) first, then re-run `gatebolt init`.";
|
|
21
|
-
const EPHEMERAL_CLI = "GateBolt is running from a temporary npx/dlx cache, so the hooks would break when the package manager prunes it. Install it durably
|
|
20
|
+
const EPHEMERAL_CLI = "GateBolt is running from a temporary npx/dlx cache, so the hooks would break when the package manager prunes it. Install it durably so `gatebolt` is on PATH on every machine — `npm i -g @gatebolt/cli` — then re-run `gatebolt init`. To link only, pass --no-hooks --no-pre-push.";
|
|
21
|
+
const NOT_ON_PATH = "GateBolt isn't on your PATH, so a committed hook command would only name a path that exists on this machine. Install it durably so `gatebolt` is on PATH on every machine — `npm i -g @gatebolt/cli` — then re-run `gatebolt init`. To link only, pass --no-hooks --no-pre-push.";
|
|
22
22
|
export async function runInit(argv) {
|
|
23
23
|
const { values } = parseArgs({
|
|
24
24
|
args: argv,
|
|
@@ -47,6 +47,7 @@ export async function runInit(argv) {
|
|
|
47
47
|
const prePush = values["no-pre-push"]
|
|
48
48
|
? null
|
|
49
49
|
: await installPrePush(command, Boolean(values.force));
|
|
50
|
+
const settingsState = await settingsGitState(wired[0]?.result.settingsPath);
|
|
50
51
|
process.stdout.write(summary({
|
|
51
52
|
root: repo.root,
|
|
52
53
|
link,
|
|
@@ -54,6 +55,7 @@ export async function runInit(argv) {
|
|
|
54
55
|
prePush,
|
|
55
56
|
enforce: wired[0]?.result.enforce ?? enforce ?? "lenient",
|
|
56
57
|
noHooks: Boolean(values["no-hooks"]),
|
|
58
|
+
...settingsState,
|
|
57
59
|
}));
|
|
58
60
|
}
|
|
59
61
|
export function parseEnforce(value) {
|
|
@@ -78,6 +80,7 @@ async function connect(loadProfile) {
|
|
|
78
80
|
async function ensureLink(connection, root, name) {
|
|
79
81
|
const existing = await readRepoLink(root);
|
|
80
82
|
if (existing) {
|
|
83
|
+
await pinRepoProfile(existing.repositoryId, connection.profileName);
|
|
81
84
|
return { repositoryId: existing.repositoryId, existed: true };
|
|
82
85
|
}
|
|
83
86
|
const identity = name ? repoIdentityFromName(name) : await deriveIdentity();
|
|
@@ -93,14 +96,25 @@ async function ensureLink(connection, root, name) {
|
|
|
93
96
|
created: result.created,
|
|
94
97
|
};
|
|
95
98
|
}
|
|
99
|
+
async function settingsGitState(settingsPath) {
|
|
100
|
+
if (!settingsPath) {
|
|
101
|
+
return { settingsIgnored: false, settingsUncommitted: false };
|
|
102
|
+
}
|
|
103
|
+
const settingsIgnored = await isPathIgnored(settingsPath);
|
|
104
|
+
const settingsUncommitted = settingsIgnored
|
|
105
|
+
? false
|
|
106
|
+
: await isUncommitted(settingsPath);
|
|
107
|
+
return { settingsIgnored, settingsUncommitted };
|
|
108
|
+
}
|
|
96
109
|
function resolveCommand(root) {
|
|
97
110
|
const cli = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
98
111
|
if (isEphemeralPath(cli)) {
|
|
99
112
|
throw new Error(EPHEMERAL_CLI);
|
|
100
113
|
}
|
|
101
|
-
if (onDurablePath("gatebolt", root))
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
if (!onDurablePath("gatebolt", root)) {
|
|
115
|
+
throw new Error(NOT_ON_PATH);
|
|
116
|
+
}
|
|
117
|
+
return "gatebolt";
|
|
104
118
|
}
|
|
105
119
|
// npx/dlx run from throwaway caches that get pruned, breaking any hook wired
|
|
106
120
|
// here: npm marks them with an `_npx` segment, pnpm with `dlx`, yarn with `xfs-…`.
|
|
@@ -121,9 +135,6 @@ function isInsideRoot(dir, root) {
|
|
|
121
135
|
const rel = relative(root, dir);
|
|
122
136
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
123
137
|
}
|
|
124
|
-
function quote(value) {
|
|
125
|
-
return /\s/.test(value) ? `"${value}"` : value;
|
|
126
|
-
}
|
|
127
138
|
async function wireAgents(root, command, enforce) {
|
|
128
139
|
const wired = [];
|
|
129
140
|
for (const adapter of ADAPTERS) {
|
|
@@ -174,6 +185,12 @@ function summary(parts) {
|
|
|
174
185
|
"",
|
|
175
186
|
enforceLine(parts.enforce),
|
|
176
187
|
];
|
|
188
|
+
if (!parts.noHooks && parts.settingsIgnored) {
|
|
189
|
+
lines.push("Warning: .claude/settings.json is gitignored, so committing it won't work — enforcement won't travel to other clones or worktrees until you remove the ignore rule and commit it.");
|
|
190
|
+
}
|
|
191
|
+
else if (!parts.noHooks && parts.settingsUncommitted) {
|
|
192
|
+
lines.push("Commit .claude/settings.json so every worktree, clone, and machine enforces this policy.");
|
|
193
|
+
}
|
|
177
194
|
if (!parts.link.existed) {
|
|
178
195
|
lines.push("Commit .gatebolt so every clone and CI job resolves the same repository.");
|
|
179
196
|
}
|
|
@@ -193,9 +210,6 @@ function hookLines(root, wired, noHooks) {
|
|
|
193
210
|
for (const { displayName, result } of wired) {
|
|
194
211
|
const verb = result.settingsChanged ? "wired" : "already wired";
|
|
195
212
|
lines.push(`${displayName}: ${verb} → ${relative(root, result.settingsPath)}`);
|
|
196
|
-
if (result.gitignoreChanged) {
|
|
197
|
-
lines.push(` ignored ${GITIGNORE_ENTRY} in .gitignore`);
|
|
198
|
-
}
|
|
199
213
|
}
|
|
200
214
|
return lines;
|
|
201
215
|
}
|
package/dist/commands/link.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parseArgs } from "node:util";
|
|
|
2
2
|
import { parseRemoteUrl, repoIdentityFromName, resolveConnection, } from "../config.js";
|
|
3
3
|
import { remoteOriginUrl, repoContext } from "../git.js";
|
|
4
4
|
import { isRecord } from "../json.js";
|
|
5
|
-
import { profileLoader, writeRepoLink } from "../profiles.js";
|
|
5
|
+
import { pinRepoProfile, profileLoader, writeRepoLink } from "../profiles.js";
|
|
6
6
|
import { postChange } from "../transport.js";
|
|
7
7
|
export async function runLink(argv) {
|
|
8
8
|
const { values } = parseArgs({
|
|
@@ -42,10 +42,8 @@ export async function linkRepository(connection, root, identity) {
|
|
|
42
42
|
if (!isLinkResult(result)) {
|
|
43
43
|
throw new Error("Unexpected response from server.");
|
|
44
44
|
}
|
|
45
|
-
await writeRepoLink(root, {
|
|
46
|
-
|
|
47
|
-
...(connection.profileName ? { profile: connection.profileName } : {}),
|
|
48
|
-
});
|
|
45
|
+
await writeRepoLink(root, { repositoryId: result.repositoryId });
|
|
46
|
+
await pinRepoProfile(result.repositoryId, connection.profileName);
|
|
49
47
|
return result;
|
|
50
48
|
}
|
|
51
49
|
function raiseNoRemote() {
|
package/dist/git.js
CHANGED
|
@@ -34,6 +34,21 @@ export async function hooksPath() {
|
|
|
34
34
|
const output = await git(["rev-parse", "--git-path", "hooks"]);
|
|
35
35
|
return resolve(output.trim());
|
|
36
36
|
}
|
|
37
|
+
export async function isPathIgnored(path) {
|
|
38
|
+
try {
|
|
39
|
+
await git(["check-ignore", "--quiet", path]);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (gitExitedNonZero(error))
|
|
44
|
+
return false;
|
|
45
|
+
throw gitFailure(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function isUncommitted(path) {
|
|
49
|
+
const output = await git(["status", "--porcelain", "--", path]);
|
|
50
|
+
return output.trim() !== "";
|
|
51
|
+
}
|
|
37
52
|
export async function remoteOriginUrl() {
|
|
38
53
|
try {
|
|
39
54
|
const output = await git(["remote", "get-url", "origin"]);
|
package/dist/profiles.js
CHANGED
|
@@ -24,8 +24,23 @@ export function parseConfig(raw) {
|
|
|
24
24
|
if (typeof raw.default === "string") {
|
|
25
25
|
config.default = raw.default;
|
|
26
26
|
}
|
|
27
|
+
if (raw.repos !== undefined) {
|
|
28
|
+
const repos = parseRepos(raw.repos);
|
|
29
|
+
if (Object.keys(repos).length > 0)
|
|
30
|
+
config.repos = repos;
|
|
31
|
+
}
|
|
27
32
|
return config;
|
|
28
33
|
}
|
|
34
|
+
function parseRepos(value) {
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
return {};
|
|
37
|
+
const repos = {};
|
|
38
|
+
for (const [id, name] of Object.entries(value)) {
|
|
39
|
+
if (typeof name === "string")
|
|
40
|
+
repos[id] = name;
|
|
41
|
+
}
|
|
42
|
+
return repos;
|
|
43
|
+
}
|
|
29
44
|
function parseProfile(value) {
|
|
30
45
|
if (!isRecord(value))
|
|
31
46
|
return null;
|
|
@@ -52,9 +67,8 @@ export async function resolveProfile(opts) {
|
|
|
52
67
|
const config = await loadConfig();
|
|
53
68
|
if (!config)
|
|
54
69
|
return null;
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const preference = configured(config, nonEmpty(opts.preferred)) ?? pointer;
|
|
70
|
+
const pin = await repoPin(config, opts.root);
|
|
71
|
+
const preference = configured(config, nonEmpty(opts.preferred)) ?? configured(config, pin);
|
|
58
72
|
const name = selectProfileName(config, {
|
|
59
73
|
name: opts.name,
|
|
60
74
|
env: process.env.GATEBOLT_PROFILE,
|
|
@@ -65,8 +79,6 @@ export async function resolveProfile(opts) {
|
|
|
65
79
|
const profile = config.profiles[name];
|
|
66
80
|
if (profile)
|
|
67
81
|
return { name, ...profile };
|
|
68
|
-
if (!explicit && name === preference)
|
|
69
|
-
return null;
|
|
70
82
|
throw new Error(`Profile "${name}" is not in ${configPath()}. Run \`gatebolt configure --profile ${name}\`.`);
|
|
71
83
|
}
|
|
72
84
|
function configured(config, name) {
|
|
@@ -108,15 +120,25 @@ async function loadConfig() {
|
|
|
108
120
|
}
|
|
109
121
|
return config;
|
|
110
122
|
}
|
|
111
|
-
async function
|
|
123
|
+
async function repoPin(config, root) {
|
|
112
124
|
try {
|
|
113
125
|
const link = await readRepoLink(root);
|
|
114
|
-
return link?.
|
|
126
|
+
return link ? config.repos?.[link.repositoryId] : undefined;
|
|
115
127
|
}
|
|
116
128
|
catch {
|
|
129
|
+
// A malformed .gatebolt still resolves a key so `link` can repair it.
|
|
117
130
|
return undefined;
|
|
118
131
|
}
|
|
119
132
|
}
|
|
133
|
+
export async function pinRepoProfile(repositoryId, profileName) {
|
|
134
|
+
if (!profileName)
|
|
135
|
+
return;
|
|
136
|
+
const config = await readConfigForWrite();
|
|
137
|
+
if (config.repos?.[repositoryId] === profileName)
|
|
138
|
+
return;
|
|
139
|
+
config.repos = { ...config.repos, [repositoryId]: profileName };
|
|
140
|
+
await writeConfig(config);
|
|
141
|
+
}
|
|
120
142
|
export async function readRepoLink(root) {
|
|
121
143
|
let raw;
|
|
122
144
|
try {
|
|
@@ -134,11 +156,7 @@ export async function readRepoLink(root) {
|
|
|
134
156
|
throw new Error(`${join(root, REPO_LINK_FILE)} is malformed — it should hold {"repositoryId": "<uuid>"}. ` +
|
|
135
157
|
"Run `gatebolt link` from the repo root to rewrite it, then commit it.");
|
|
136
158
|
}
|
|
137
|
-
|
|
138
|
-
if (typeof data.profile === "string") {
|
|
139
|
-
link.profile = data.profile;
|
|
140
|
-
}
|
|
141
|
-
return link;
|
|
159
|
+
return { repositoryId: data.repositoryId };
|
|
142
160
|
}
|
|
143
161
|
export async function writeRepoLink(root, link) {
|
|
144
162
|
const path = join(root, REPO_LINK_FILE);
|
package/package.json
CHANGED