@forceuser/git-profile-switcher 0.1.4
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 +96 -0
- package/bin/gip.mjs +25 -0
- package/dist/runtime/app/index.js +677 -0
- package/dist/runtime/cli/completion.js +142 -0
- package/dist/runtime/cli/help.js +288 -0
- package/dist/runtime/cli/parse.js +41 -0
- package/dist/runtime/cli/prompts.js +389 -0
- package/dist/runtime/config/paths.js +39 -0
- package/dist/runtime/git/config.js +188 -0
- package/dist/runtime/profiles/repository.js +183 -0
- package/dist/runtime/profiles/transfer.js +111 -0
- package/dist/runtime/profiles/types.js +23 -0
- package/dist/runtime/prompt/status.js +103 -0
- package/dist/runtime/shell/integration.js +254 -0
- package/dist/runtime/tui/index.js +604 -0
- package/package.json +105 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { normalizeDirectoryPath } from "#config/paths";
|
|
5
|
+
import { createEmptyProfileStore, PROFILE_PROMPT_COLORS, } from "./types.js";
|
|
6
|
+
export function createProfileRepository(path) {
|
|
7
|
+
async function read() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(path, "utf8");
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
return normalizeStore(parsed);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (isNotFoundError(error)) {
|
|
15
|
+
return createEmptyProfileStore();
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function save(data) {
|
|
21
|
+
await mkdir(dirname(path), { recursive: true });
|
|
22
|
+
await writeFile(path, `${JSON.stringify(normalizeStore(data), null, 2)}\n`, {
|
|
23
|
+
mode: 0o600,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
read,
|
|
28
|
+
save,
|
|
29
|
+
async upsertProfile(input) {
|
|
30
|
+
const data = await read();
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
const existing = data.profiles.find((profile) => profile.name === input.name);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.userName = input.userName;
|
|
35
|
+
existing.userEmail = input.userEmail;
|
|
36
|
+
if (input.promptColor !== undefined) {
|
|
37
|
+
existing.promptColor = input.promptColor;
|
|
38
|
+
}
|
|
39
|
+
existing.updatedAt = now;
|
|
40
|
+
await save(data);
|
|
41
|
+
return existing;
|
|
42
|
+
}
|
|
43
|
+
const profile = {
|
|
44
|
+
name: input.name,
|
|
45
|
+
userName: input.userName,
|
|
46
|
+
userEmail: input.userEmail,
|
|
47
|
+
...(input.promptColor ? { promptColor: input.promptColor } : {}),
|
|
48
|
+
createdAt: now,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
};
|
|
51
|
+
data.profiles.push(profile);
|
|
52
|
+
sortStore(data);
|
|
53
|
+
await save(data);
|
|
54
|
+
return profile;
|
|
55
|
+
},
|
|
56
|
+
async setProfilePromptColor(input) {
|
|
57
|
+
const data = await read();
|
|
58
|
+
const profile = data.profiles.find((candidate) => candidate.name === input.name);
|
|
59
|
+
if (!profile) {
|
|
60
|
+
throw new Error(`Unknown profile: ${input.name}`);
|
|
61
|
+
}
|
|
62
|
+
if (input.promptColor) {
|
|
63
|
+
profile.promptColor = input.promptColor;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
delete profile.promptColor;
|
|
67
|
+
}
|
|
68
|
+
profile.updatedAt = new Date().toISOString();
|
|
69
|
+
await save(data);
|
|
70
|
+
return profile;
|
|
71
|
+
},
|
|
72
|
+
async removeProfile(name) {
|
|
73
|
+
const data = await read();
|
|
74
|
+
const nextProfiles = data.profiles.filter((profile) => profile.name !== name);
|
|
75
|
+
if (nextProfiles.length === data.profiles.length) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
data.profiles = nextProfiles;
|
|
79
|
+
data.rules = data.rules.filter((rule) => rule.profileName !== name);
|
|
80
|
+
await save(data);
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
async addRule(input) {
|
|
84
|
+
const data = await read();
|
|
85
|
+
if (!data.profiles.some((profile) => profile.name === input.profileName)) {
|
|
86
|
+
throw new Error(`Unknown profile: ${input.profileName}`);
|
|
87
|
+
}
|
|
88
|
+
const directory = normalizeDirectoryPath(input.directory, input.homeDir);
|
|
89
|
+
const existing = data.rules.find((rule) => rule.profileName === input.profileName && rule.directory === directory);
|
|
90
|
+
if (existing) {
|
|
91
|
+
return existing;
|
|
92
|
+
}
|
|
93
|
+
const rule = {
|
|
94
|
+
id: `rule_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
|
95
|
+
profileName: input.profileName,
|
|
96
|
+
directory,
|
|
97
|
+
createdAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
data.rules.push(rule);
|
|
100
|
+
sortStore(data);
|
|
101
|
+
await save(data);
|
|
102
|
+
return rule;
|
|
103
|
+
},
|
|
104
|
+
async setDirectoryProfile(input) {
|
|
105
|
+
const data = await read();
|
|
106
|
+
if (!data.profiles.some((profile) => profile.name === input.profileName)) {
|
|
107
|
+
throw new Error(`Unknown profile: ${input.profileName}`);
|
|
108
|
+
}
|
|
109
|
+
const directory = normalizeDirectoryPath(input.directory, input.homeDir);
|
|
110
|
+
const existing = data.rules.find((rule) => rule.profileName === input.profileName && rule.directory === directory);
|
|
111
|
+
if (existing) {
|
|
112
|
+
data.rules = data.rules.filter((rule) => rule.directory !== directory || rule.id === existing.id);
|
|
113
|
+
sortStore(data);
|
|
114
|
+
await save(data);
|
|
115
|
+
return existing;
|
|
116
|
+
}
|
|
117
|
+
const rule = {
|
|
118
|
+
id: `rule_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
|
119
|
+
profileName: input.profileName,
|
|
120
|
+
directory,
|
|
121
|
+
createdAt: new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
data.rules = [...data.rules.filter((candidate) => candidate.directory !== directory), rule];
|
|
124
|
+
sortStore(data);
|
|
125
|
+
await save(data);
|
|
126
|
+
return rule;
|
|
127
|
+
},
|
|
128
|
+
async clearDirectoryProfile(input) {
|
|
129
|
+
const data = await read();
|
|
130
|
+
const directory = normalizeDirectoryPath(input.directory, input.homeDir);
|
|
131
|
+
const removed = data.rules.filter((rule) => rule.directory === directory);
|
|
132
|
+
if (removed.length === 0) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
data.rules = data.rules.filter((rule) => rule.directory !== directory);
|
|
136
|
+
await save(data);
|
|
137
|
+
return removed;
|
|
138
|
+
},
|
|
139
|
+
async removeRule(id) {
|
|
140
|
+
const data = await read();
|
|
141
|
+
const nextRules = data.rules.filter((rule) => rule.id !== id);
|
|
142
|
+
if (nextRules.length === data.rules.length) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
data.rules = nextRules;
|
|
146
|
+
await save(data);
|
|
147
|
+
return true;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function normalizeStore(input) {
|
|
152
|
+
const data = {
|
|
153
|
+
version: 1,
|
|
154
|
+
profiles: Array.isArray(input.profiles) ? input.profiles.map(normalizeProfile) : [],
|
|
155
|
+
rules: Array.isArray(input.rules) ? input.rules : [],
|
|
156
|
+
};
|
|
157
|
+
sortStore(data);
|
|
158
|
+
return data;
|
|
159
|
+
}
|
|
160
|
+
function normalizeProfile(profile) {
|
|
161
|
+
const { promptColor, ...profileWithoutPromptColor } = profile;
|
|
162
|
+
const normalizedPromptColor = normalizePromptColor(promptColor);
|
|
163
|
+
return {
|
|
164
|
+
...profileWithoutPromptColor,
|
|
165
|
+
...(normalizedPromptColor ? { promptColor: normalizedPromptColor } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function normalizePromptColor(value) {
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const normalized = value.trim().toLowerCase();
|
|
173
|
+
return PROFILE_PROMPT_COLORS.includes(normalized)
|
|
174
|
+
? normalized
|
|
175
|
+
: null;
|
|
176
|
+
}
|
|
177
|
+
function sortStore(data) {
|
|
178
|
+
data.profiles.sort((left, right) => left.name.localeCompare(right.name));
|
|
179
|
+
data.rules.sort((left, right) => left.directory.localeCompare(right.directory));
|
|
180
|
+
}
|
|
181
|
+
function isNotFoundError(error) {
|
|
182
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
183
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createEmptyProfileStore, PROFILE_PROMPT_COLORS, } from "#profiles/types";
|
|
2
|
+
export function createProfileExportBundle(data) {
|
|
3
|
+
return {
|
|
4
|
+
kind: "git-profile-switcher/profile-store",
|
|
5
|
+
version: 1,
|
|
6
|
+
exportedAt: new Date().toISOString(),
|
|
7
|
+
data: normalizeProfileStoreData(data),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function parseProfileImportBundle(input) {
|
|
11
|
+
if (isRecord(input) && input.kind === "git-profile-switcher/profile-store") {
|
|
12
|
+
if (input.version !== 1) {
|
|
13
|
+
throw new Error(`Unsupported export bundle version: ${String(input.version)}`);
|
|
14
|
+
}
|
|
15
|
+
return normalizeProfileStoreData(input.data);
|
|
16
|
+
}
|
|
17
|
+
return normalizeProfileStoreData(input);
|
|
18
|
+
}
|
|
19
|
+
export function mergeProfileStoreData(current, incoming) {
|
|
20
|
+
const next = createEmptyProfileStore();
|
|
21
|
+
const profiles = new Map();
|
|
22
|
+
for (const profile of current.profiles) {
|
|
23
|
+
profiles.set(profile.name, profile);
|
|
24
|
+
}
|
|
25
|
+
for (const profile of incoming.profiles) {
|
|
26
|
+
profiles.set(profile.name, profile);
|
|
27
|
+
}
|
|
28
|
+
next.profiles = [...profiles.values()];
|
|
29
|
+
const knownProfiles = new Set(next.profiles.map((profile) => profile.name));
|
|
30
|
+
const rules = new Map();
|
|
31
|
+
for (const rule of [...current.rules, ...incoming.rules]) {
|
|
32
|
+
if (!knownProfiles.has(rule.profileName)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
rules.set(rule.id, rule);
|
|
36
|
+
}
|
|
37
|
+
next.rules = [...rules.values()];
|
|
38
|
+
return sortProfileStoreData(next);
|
|
39
|
+
}
|
|
40
|
+
export function normalizeProfileStoreData(input) {
|
|
41
|
+
if (!isRecord(input)) {
|
|
42
|
+
throw new Error("Invalid profile store: expected an object.");
|
|
43
|
+
}
|
|
44
|
+
const version = input.version;
|
|
45
|
+
if (version !== 1) {
|
|
46
|
+
throw new Error(`Unsupported profile store version: ${String(version)}`);
|
|
47
|
+
}
|
|
48
|
+
const profiles = readArray(input.profiles, "profiles").map(parseProfile);
|
|
49
|
+
const profileNames = new Set(profiles.map((profile) => profile.name));
|
|
50
|
+
const rules = readArray(input.rules, "rules")
|
|
51
|
+
.map(parseRule)
|
|
52
|
+
.filter((rule) => profileNames.has(rule.profileName));
|
|
53
|
+
return sortProfileStoreData({ version: 1, profiles, rules });
|
|
54
|
+
}
|
|
55
|
+
function parseProfile(input) {
|
|
56
|
+
if (!isRecord(input)) {
|
|
57
|
+
throw new Error("Invalid profile: expected an object.");
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name: readString(input.name, "profile.name"),
|
|
61
|
+
userName: readString(input.userName, "profile.userName"),
|
|
62
|
+
userEmail: readString(input.userEmail, "profile.userEmail"),
|
|
63
|
+
...readOptionalPromptColor(input.promptColor),
|
|
64
|
+
createdAt: readString(input.createdAt, "profile.createdAt"),
|
|
65
|
+
updatedAt: readString(input.updatedAt, "profile.updatedAt"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function parseRule(input) {
|
|
69
|
+
if (!isRecord(input)) {
|
|
70
|
+
throw new Error("Invalid rule: expected an object.");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
id: readString(input.id, "rule.id"),
|
|
74
|
+
profileName: readString(input.profileName, "rule.profileName"),
|
|
75
|
+
directory: readString(input.directory, "rule.directory"),
|
|
76
|
+
createdAt: readString(input.createdAt, "rule.createdAt"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function sortProfileStoreData(data) {
|
|
80
|
+
data.profiles.sort((left, right) => left.name.localeCompare(right.name));
|
|
81
|
+
data.rules.sort((left, right) => left.directory.localeCompare(right.directory));
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
function readArray(value, field) {
|
|
85
|
+
if (!Array.isArray(value)) {
|
|
86
|
+
throw new Error(`Invalid profile store: ${field} must be an array.`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function readString(value, field) {
|
|
91
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
92
|
+
throw new Error(`Invalid profile store: ${field} must be a non-empty string.`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function readOptionalPromptColor(value) {
|
|
97
|
+
if (value === undefined || value === null || value === "") {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
if (typeof value !== "string") {
|
|
101
|
+
throw new Error("Invalid profile store: profile.promptColor must be a string.");
|
|
102
|
+
}
|
|
103
|
+
const normalized = value.trim().toLowerCase();
|
|
104
|
+
if (!PROFILE_PROMPT_COLORS.includes(normalized)) {
|
|
105
|
+
throw new Error(`Invalid profile store: unsupported profile.promptColor ${value}.`);
|
|
106
|
+
}
|
|
107
|
+
return { promptColor: normalized };
|
|
108
|
+
}
|
|
109
|
+
function isRecord(value) {
|
|
110
|
+
return typeof value === "object" && value !== null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const PROFILE_PROMPT_COLOR_CODES = {
|
|
2
|
+
red: "31",
|
|
3
|
+
green: "32",
|
|
4
|
+
yellow: "33",
|
|
5
|
+
blue: "34",
|
|
6
|
+
magenta: "35",
|
|
7
|
+
cyan: "36",
|
|
8
|
+
gray: "90",
|
|
9
|
+
"bright-red": "91",
|
|
10
|
+
"bright-green": "92",
|
|
11
|
+
"bright-yellow": "93",
|
|
12
|
+
"bright-blue": "94",
|
|
13
|
+
"bright-magenta": "95",
|
|
14
|
+
"bright-cyan": "96",
|
|
15
|
+
};
|
|
16
|
+
export const PROFILE_PROMPT_COLORS = Object.keys(PROFILE_PROMPT_COLOR_CODES);
|
|
17
|
+
export function createEmptyProfileStore() {
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
profiles: [],
|
|
21
|
+
rules: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { findMatchingRule, readActiveGitIdentity } from "#git/config";
|
|
3
|
+
import { PROFILE_PROMPT_COLOR_CODES, } from "#profiles/types";
|
|
4
|
+
export function getPromptStatus(input) {
|
|
5
|
+
const cwd = resolve(input.cwd ?? process.cwd());
|
|
6
|
+
const sessionIdentity = readSessionGitIdentity(input.env ?? process.env);
|
|
7
|
+
if (sessionIdentity.userName || sessionIdentity.userEmail || sessionIdentity.profileName) {
|
|
8
|
+
const sessionProfileByName = sessionIdentity.profileName
|
|
9
|
+
? findProfileByName(input.data.profiles, sessionIdentity.profileName)
|
|
10
|
+
: null;
|
|
11
|
+
const sessionProfileByIdentity = findProfileByIdentity(input.data.profiles, {
|
|
12
|
+
userName: sessionIdentity.userName,
|
|
13
|
+
userEmail: sessionIdentity.userEmail,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
cwd,
|
|
17
|
+
profileName: sessionProfileByName?.name ??
|
|
18
|
+
sessionProfileByIdentity?.name ??
|
|
19
|
+
sessionIdentity.profileName ??
|
|
20
|
+
null,
|
|
21
|
+
profilePromptColor: sessionProfileByName?.promptColor ?? sessionProfileByIdentity?.promptColor ?? null,
|
|
22
|
+
userName: sessionIdentity.userName,
|
|
23
|
+
userEmail: sessionIdentity.userEmail,
|
|
24
|
+
ruleId: null,
|
|
25
|
+
directory: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const rule = findMatchingRule(input.data, cwd);
|
|
29
|
+
const gitIdentity = readActiveGitIdentity(cwd);
|
|
30
|
+
const ruleProfile = rule ? findProfileByName(input.data.profiles, rule.profileName) : null;
|
|
31
|
+
const identityProfile = findProfileByIdentity(input.data.profiles, {
|
|
32
|
+
userName: gitIdentity.userName,
|
|
33
|
+
userEmail: gitIdentity.userEmail,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
cwd,
|
|
37
|
+
profileName: ruleProfile?.name ?? rule?.profileName ?? identityProfile?.name ?? null,
|
|
38
|
+
profilePromptColor: ruleProfile?.promptColor ?? identityProfile?.promptColor ?? null,
|
|
39
|
+
userName: ruleProfile?.userName ?? gitIdentity.userName ?? null,
|
|
40
|
+
userEmail: ruleProfile?.userEmail ?? gitIdentity.userEmail ?? null,
|
|
41
|
+
ruleId: rule?.id ?? null,
|
|
42
|
+
directory: rule?.directory ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function readSessionGitIdentity(env) {
|
|
46
|
+
return {
|
|
47
|
+
profileName: env.GIP_PROFILE_NAME || null,
|
|
48
|
+
userName: env.GIT_AUTHOR_NAME || env.GIT_COMMITTER_NAME || null,
|
|
49
|
+
userEmail: env.GIT_AUTHOR_EMAIL || env.GIT_COMMITTER_EMAIL || null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function renderPromptStatus(status, format = "auto", shell = null) {
|
|
53
|
+
if (format === "profile") {
|
|
54
|
+
return renderProfilePrompt(status, shell);
|
|
55
|
+
}
|
|
56
|
+
if (format === "auto" && status.profileName) {
|
|
57
|
+
return renderProfilePrompt(status, shell);
|
|
58
|
+
}
|
|
59
|
+
if (format === "auto") {
|
|
60
|
+
return renderIdentityPrompt(status);
|
|
61
|
+
}
|
|
62
|
+
return renderIdentityPrompt(status);
|
|
63
|
+
}
|
|
64
|
+
function renderIdentityPrompt(status) {
|
|
65
|
+
if (!status.userName && !status.userEmail) {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
if (status.userName && status.userEmail) {
|
|
69
|
+
return `${status.userName} <${status.userEmail}>`;
|
|
70
|
+
}
|
|
71
|
+
return status.userName ?? status.userEmail ?? "";
|
|
72
|
+
}
|
|
73
|
+
function renderProfilePrompt(status, shell) {
|
|
74
|
+
if (!status.profileName) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
const profileName = status.profilePromptColor
|
|
78
|
+
? applyPromptColor(status.profileName, status.profilePromptColor, shell)
|
|
79
|
+
: status.profileName;
|
|
80
|
+
return `[gip ${profileName}]`;
|
|
81
|
+
}
|
|
82
|
+
function applyPromptColor(value, promptColor, shell) {
|
|
83
|
+
if (!shell) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
const code = PROFILE_PROMPT_COLOR_CODES[promptColor];
|
|
87
|
+
if (shell === "bash") {
|
|
88
|
+
return `\u0001\x1b[${code}m\u0002${value}\u0001\x1b[0m\u0002`;
|
|
89
|
+
}
|
|
90
|
+
if (shell === "zsh") {
|
|
91
|
+
return `%{\x1b[${code}m%}${value}%{\x1b[0m%}`;
|
|
92
|
+
}
|
|
93
|
+
return `\x1b[${code}m${value}\x1b[0m`;
|
|
94
|
+
}
|
|
95
|
+
function findProfileByName(profiles, name) {
|
|
96
|
+
return profiles.find((profile) => profile.name === name) ?? null;
|
|
97
|
+
}
|
|
98
|
+
function findProfileByIdentity(profiles, identity) {
|
|
99
|
+
if (!identity.userName || !identity.userEmail) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return (profiles.find((profile) => profile.userName === identity.userName && profile.userEmail === identity.userEmail) ?? null);
|
|
103
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { generateCompletionScript } from "#cli/completion";
|
|
5
|
+
const PROMPT_BLOCK_START = "# >>> gip prompt >>>";
|
|
6
|
+
const PROMPT_BLOCK_END = "# <<< gip prompt <<<";
|
|
7
|
+
const COMPLETION_BLOCK_START = "# >>> gip completion >>>";
|
|
8
|
+
const COMPLETION_BLOCK_END = "# <<< gip completion <<<";
|
|
9
|
+
const SHELL_BLOCK_START = "# >>> gip shell >>>";
|
|
10
|
+
const SHELL_BLOCK_END = "# <<< gip shell <<<";
|
|
11
|
+
const PROMPT_BLOCK_PATTERN = new RegExp(`${escapeRegExp(PROMPT_BLOCK_START)}\\n[\\s\\S]*?\\n${escapeRegExp(PROMPT_BLOCK_END)}\\n?`, "g");
|
|
12
|
+
const COMPLETION_BLOCK_PATTERN = new RegExp(`${escapeRegExp(COMPLETION_BLOCK_START)}\\n[\\s\\S]*?\\n${escapeRegExp(COMPLETION_BLOCK_END)}\\n?`, "g");
|
|
13
|
+
const SHELL_BLOCK_PATTERN = new RegExp(`${escapeRegExp(SHELL_BLOCK_START)}\\n[\\s\\S]*?\\n${escapeRegExp(SHELL_BLOCK_END)}\\n?`, "g");
|
|
14
|
+
export async function installShellPrompt(input) {
|
|
15
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
16
|
+
const current = await readOptionalText(path);
|
|
17
|
+
const next = appendManagedBlock(removeShellPromptBlock(current), renderPromptBlock(input.shell, input.promptFormat));
|
|
18
|
+
return await writeIfChanged(path, current, next);
|
|
19
|
+
}
|
|
20
|
+
export async function uninstallShellPrompt(input) {
|
|
21
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
22
|
+
const current = await readOptionalText(path);
|
|
23
|
+
return await writeIfChanged(path, current, removeShellPromptBlock(current));
|
|
24
|
+
}
|
|
25
|
+
export async function installShellCompletion(input) {
|
|
26
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
27
|
+
const current = await readOptionalText(path);
|
|
28
|
+
const next = appendManagedBlock(removeShellCompletionBlock(current), renderCompletionBlock(input.shell));
|
|
29
|
+
return await writeIfChanged(path, current, next);
|
|
30
|
+
}
|
|
31
|
+
export async function uninstallShellCompletion(input) {
|
|
32
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
33
|
+
const current = await readOptionalText(path);
|
|
34
|
+
return await writeIfChanged(path, current, removeShellCompletionBlock(current));
|
|
35
|
+
}
|
|
36
|
+
export async function installShellSession(input) {
|
|
37
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
38
|
+
const current = await readOptionalText(path);
|
|
39
|
+
const next = appendManagedBlock(removeShellSessionBlock(current), renderShellSessionBlock(input.shell));
|
|
40
|
+
return await writeIfChanged(path, current, next);
|
|
41
|
+
}
|
|
42
|
+
export async function uninstallShellSession(input) {
|
|
43
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
44
|
+
const current = await readOptionalText(path);
|
|
45
|
+
return await writeIfChanged(path, current, removeShellSessionBlock(current));
|
|
46
|
+
}
|
|
47
|
+
export async function installShellAll(input) {
|
|
48
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
49
|
+
const current = await readOptionalText(path);
|
|
50
|
+
const cleaned = removeShellSessionBlock(removeShellPromptBlock(removeShellCompletionBlock(current)));
|
|
51
|
+
const next = appendManagedBlock(appendManagedBlock(appendManagedBlock(cleaned, renderCompletionBlock(input.shell)), renderShellSessionBlock(input.shell)), renderPromptBlock(input.shell, input.promptFormat));
|
|
52
|
+
return await writeIfChanged(path, current, next);
|
|
53
|
+
}
|
|
54
|
+
export async function uninstallShellAll(input) {
|
|
55
|
+
const path = resolve(input.configPath ?? getDefaultShellConfigPath(input.shell));
|
|
56
|
+
const current = await readOptionalText(path);
|
|
57
|
+
const next = removeShellSessionBlock(removeShellPromptBlock(removeShellCompletionBlock(current)));
|
|
58
|
+
return await writeIfChanged(path, current, next);
|
|
59
|
+
}
|
|
60
|
+
export function detectShell(env = process.env) {
|
|
61
|
+
const shell = env.SHELL?.toLowerCase() ?? "";
|
|
62
|
+
if (shell.endsWith("/zsh") || env.ZSH_VERSION) {
|
|
63
|
+
return "zsh";
|
|
64
|
+
}
|
|
65
|
+
if (shell.endsWith("/bash") || env.BASH_VERSION) {
|
|
66
|
+
return "bash";
|
|
67
|
+
}
|
|
68
|
+
if (shell.endsWith("/fish") || env.FISH_VERSION) {
|
|
69
|
+
return "fish";
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
export function renderPromptBlock(shell, promptFormat = "auto") {
|
|
74
|
+
const promptCommand = renderPromptCommand(promptFormat);
|
|
75
|
+
if (shell === "fish") {
|
|
76
|
+
return `${PROMPT_BLOCK_START}
|
|
77
|
+
if functions -q fish_prompt; and not functions -q __gip_original_fish_prompt
|
|
78
|
+
functions -c fish_prompt __gip_original_fish_prompt
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
function fish_prompt
|
|
82
|
+
set -lx GIP_PROMPT_SHELL fish
|
|
83
|
+
set -l gip_segment (${promptCommand} 2>/dev/null)
|
|
84
|
+
set -l gip_original_prompt
|
|
85
|
+
if functions -q __gip_original_fish_prompt
|
|
86
|
+
set gip_original_prompt (__gip_original_fish_prompt | string collect)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if test -n "$gip_segment"
|
|
90
|
+
if test -n "$gip_original_prompt"; and string match -q "*\\n*" "$gip_original_prompt"
|
|
91
|
+
printf "%s " "$gip_segment"
|
|
92
|
+
else
|
|
93
|
+
printf "%s\\n" "$gip_segment"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
printf "%s" "$gip_original_prompt"
|
|
98
|
+
end
|
|
99
|
+
${PROMPT_BLOCK_END}
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
if (shell === "zsh") {
|
|
103
|
+
return `${PROMPT_BLOCK_START}
|
|
104
|
+
setopt prompt_subst
|
|
105
|
+
|
|
106
|
+
_gip_prompt_segment() {
|
|
107
|
+
local gip_segment
|
|
108
|
+
gip_segment="$(GIP_PROMPT_SHELL=zsh ${promptCommand} 2>/dev/null)" || return
|
|
109
|
+
if [ -n "$gip_segment" ]; then
|
|
110
|
+
case "$GIP_ORIGINAL_PROMPT" in
|
|
111
|
+
*$'\\n'*|*'\\n'*)
|
|
112
|
+
printf '%s ' "$gip_segment"
|
|
113
|
+
;;
|
|
114
|
+
*)
|
|
115
|
+
printf '%s\\n%%{%%}' "$gip_segment"
|
|
116
|
+
;;
|
|
117
|
+
esac
|
|
118
|
+
fi
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if [ -z "\${GIP_ORIGINAL_PROMPT+x}" ]; then
|
|
122
|
+
GIP_ORIGINAL_PROMPT="$PROMPT"
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
PROMPT='$(_gip_prompt_segment)'"\${GIP_ORIGINAL_PROMPT}"
|
|
126
|
+
${PROMPT_BLOCK_END}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
return `${PROMPT_BLOCK_START}
|
|
130
|
+
_gip_prompt_segment() {
|
|
131
|
+
local gip_segment
|
|
132
|
+
if [ -n "\${GIP_PROMPT_DEBUG_LOG:-}" ]; then
|
|
133
|
+
gip_segment="$(GIP_PROMPT_SHELL=bash ${promptCommand} 2>>"\${GIP_PROMPT_DEBUG_LOG}")" || return
|
|
134
|
+
else
|
|
135
|
+
gip_segment="$(GIP_PROMPT_SHELL=bash ${promptCommand} 2>/dev/null)" || return
|
|
136
|
+
fi
|
|
137
|
+
if [ -n "$gip_segment" ]; then
|
|
138
|
+
case "$GIP_ORIGINAL_PS1" in
|
|
139
|
+
*$'\\n'*|*'\\n'*)
|
|
140
|
+
printf '%s ' "$gip_segment"
|
|
141
|
+
;;
|
|
142
|
+
*)
|
|
143
|
+
printf '%s\\n\\001\\002' "$gip_segment"
|
|
144
|
+
;;
|
|
145
|
+
esac
|
|
146
|
+
fi
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if [ -z "\${GIP_ORIGINAL_PS1+x}" ]; then
|
|
150
|
+
GIP_ORIGINAL_PS1="$PS1"
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
PS1='$(_gip_prompt_segment)'"\${GIP_ORIGINAL_PS1}"
|
|
154
|
+
${PROMPT_BLOCK_END}
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
export function renderCompletionBlock(shell) {
|
|
158
|
+
return `${COMPLETION_BLOCK_START}
|
|
159
|
+
${generateCompletionScript(shell).trimEnd()}
|
|
160
|
+
${COMPLETION_BLOCK_END}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
export function renderShellSessionBlock(shell) {
|
|
164
|
+
return `${SHELL_BLOCK_START}
|
|
165
|
+
${renderSessionCommandWrapper(shell).trimEnd()}
|
|
166
|
+
${SHELL_BLOCK_END}
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
function renderSessionCommandWrapper(shell) {
|
|
170
|
+
if (shell === "fish") {
|
|
171
|
+
return `function gip
|
|
172
|
+
if test (count $argv) -gt 0; and test "$argv[1]" = now; and not contains -- --help $argv; and not contains -- -h $argv
|
|
173
|
+
command gip $argv --exports --shell fish | source
|
|
174
|
+
else
|
|
175
|
+
command gip $argv
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
const shellName = shell === "zsh" ? "zsh" : "bash";
|
|
181
|
+
return `gip() {
|
|
182
|
+
if [ "\${1:-}" = "now" ]; then
|
|
183
|
+
local gip_arg
|
|
184
|
+
for gip_arg in "$@"; do
|
|
185
|
+
case "$gip_arg" in
|
|
186
|
+
--help|-h)
|
|
187
|
+
command gip "$@"
|
|
188
|
+
return
|
|
189
|
+
;;
|
|
190
|
+
esac
|
|
191
|
+
done
|
|
192
|
+
local gip_session
|
|
193
|
+
gip_session="$(command gip "$@" --exports --shell ${shellName})" || return
|
|
194
|
+
eval "$gip_session"
|
|
195
|
+
else
|
|
196
|
+
command gip "$@"
|
|
197
|
+
fi
|
|
198
|
+
}
|
|
199
|
+
`;
|
|
200
|
+
}
|
|
201
|
+
function renderPromptCommand(promptFormat) {
|
|
202
|
+
if (promptFormat === "auto") {
|
|
203
|
+
return "gip prompt";
|
|
204
|
+
}
|
|
205
|
+
return `gip prompt --format ${promptFormat}`;
|
|
206
|
+
}
|
|
207
|
+
export function removeShellPromptBlock(text) {
|
|
208
|
+
return text.replace(PROMPT_BLOCK_PATTERN, "").trimEnd();
|
|
209
|
+
}
|
|
210
|
+
export function removeShellCompletionBlock(text) {
|
|
211
|
+
return text.replace(COMPLETION_BLOCK_PATTERN, "").trimEnd();
|
|
212
|
+
}
|
|
213
|
+
export function removeShellSessionBlock(text) {
|
|
214
|
+
return text.replace(SHELL_BLOCK_PATTERN, "").trimEnd();
|
|
215
|
+
}
|
|
216
|
+
export function getDefaultShellConfigPath(shell) {
|
|
217
|
+
const home = homedir();
|
|
218
|
+
if (shell === "zsh") {
|
|
219
|
+
return join(home, ".zshrc");
|
|
220
|
+
}
|
|
221
|
+
if (shell === "bash") {
|
|
222
|
+
return join(home, ".bashrc");
|
|
223
|
+
}
|
|
224
|
+
return join(home, ".config", "fish", "config.fish");
|
|
225
|
+
}
|
|
226
|
+
function appendManagedBlock(text, block) {
|
|
227
|
+
const trimmed = text.trimEnd();
|
|
228
|
+
if (!trimmed) {
|
|
229
|
+
return block;
|
|
230
|
+
}
|
|
231
|
+
return `${trimmed}\n\n${block}`;
|
|
232
|
+
}
|
|
233
|
+
async function writeIfChanged(path, current, next) {
|
|
234
|
+
if (next === current) {
|
|
235
|
+
return { path, changed: false };
|
|
236
|
+
}
|
|
237
|
+
await mkdir(dirname(path), { recursive: true });
|
|
238
|
+
await writeFile(path, next);
|
|
239
|
+
return { path, changed: true };
|
|
240
|
+
}
|
|
241
|
+
async function readOptionalText(path) {
|
|
242
|
+
try {
|
|
243
|
+
return await readFile(path, "utf8");
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function escapeRegExp(value) {
|
|
253
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
254
|
+
}
|