bet-cli 0.2.0 → 0.3.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 +24 -2
- package/dist/commands/edit.js +48 -0
- package/dist/commands/info.js +6 -3
- package/dist/commands/update.js +12 -6
- package/dist/lib/config.js +9 -0
- package/dist/lib/editor.js +97 -0
- package/dist/lib/git.js +14 -8
- package/dist/lib/help.js +15 -3
- package/dist/main.js +17 -15
- package/landing-page/index.html +996 -0
- package/landing-page/vercel.json +25 -0
- package/package.json +1 -1
- package/skills/bet/SKILL.md +129 -0
- package/src/commands/edit.ts +56 -0
- package/src/commands/info.tsx +200 -175
- package/src/commands/update.ts +203 -149
- package/src/lib/config.ts +17 -1
- package/src/lib/editor.ts +131 -0
- package/src/lib/git.ts +20 -10
- package/src/lib/help.ts +28 -15
- package/src/lib/types.ts +2 -0
- package/src/main.ts +3 -1
- package/tests/config.test.ts +71 -0
- package/tests/editor.test.ts +167 -0
package/src/commands/update.ts
CHANGED
|
@@ -3,7 +3,12 @@ import readline from "node:readline";
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { readConfig, resolveRoots, writeConfig } from "../lib/config.js";
|
|
5
5
|
import { normalizeAbsolute } from "../utils/paths.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
installUpdateCron,
|
|
8
|
+
uninstallUpdateCron,
|
|
9
|
+
parseCronSchedule,
|
|
10
|
+
formatScheduleLabel,
|
|
11
|
+
} from "../lib/cron.js";
|
|
7
12
|
import { scanRoots } from "../lib/scan.js";
|
|
8
13
|
import { computeMetadata } from "../lib/metadata.js";
|
|
9
14
|
import { getEffectiveIgnores, isPathIgnored } from "../lib/ignore.js";
|
|
@@ -30,16 +35,16 @@ export function willOverrideRoots(
|
|
|
30
35
|
providedRootConfigs: RootConfig[] | undefined,
|
|
31
36
|
configRoots: RootConfig[],
|
|
32
37
|
): boolean {
|
|
33
|
-
return !!(
|
|
34
|
-
providedRootConfigs !== undefined &&
|
|
35
|
-
configRoots.length > 0
|
|
36
|
-
);
|
|
38
|
+
return !!(providedRootConfigs !== undefined && configRoots.length > 0);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const DEFAULT_SLUG_PARENT_FOLDERS = ["src", "app"];
|
|
40
42
|
|
|
41
43
|
export { DEFAULT_SLUG_PARENT_FOLDERS };
|
|
42
|
-
export function projectSlug(
|
|
44
|
+
export function projectSlug(
|
|
45
|
+
pathName: string,
|
|
46
|
+
slugParentFolders: string[],
|
|
47
|
+
): string {
|
|
43
48
|
const folderName = path.basename(pathName);
|
|
44
49
|
if (slugParentFolders.includes(folderName)) {
|
|
45
50
|
return path.basename(path.dirname(pathName));
|
|
@@ -47,8 +52,14 @@ export function projectSlug(pathName: string, slugParentFolders: string[]): stri
|
|
|
47
52
|
return folderName;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
async function promptYesNo(
|
|
51
|
-
|
|
55
|
+
async function promptYesNo(
|
|
56
|
+
question: string,
|
|
57
|
+
defaultNo = true,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
const rl = readline.createInterface({
|
|
60
|
+
input: process.stdin,
|
|
61
|
+
output: process.stderr,
|
|
62
|
+
});
|
|
52
63
|
return new Promise((resolve) => {
|
|
53
64
|
const defaultHint = defaultNo ? "y/N" : "Y/n";
|
|
54
65
|
rl.question(question + " [" + defaultHint + "] ", (answer) => {
|
|
@@ -69,166 +80,209 @@ export function registerUpdate(program: Command): void {
|
|
|
69
80
|
.description("Scan roots and update the project index")
|
|
70
81
|
.option("--roots <paths>", "Comma-separated list of roots to scan")
|
|
71
82
|
.option("--force", "Allow overriding configured roots when not in TTY")
|
|
72
|
-
.option(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
83
|
+
.option(
|
|
84
|
+
"--cron [frequency]",
|
|
85
|
+
"Run update on a schedule: Nm/Nh/Nd e.g. 5m, 1h, 2d (default 1h), or 0/false to disable",
|
|
86
|
+
)
|
|
87
|
+
.action(
|
|
88
|
+
async (options: {
|
|
89
|
+
roots?: string;
|
|
90
|
+
force?: boolean;
|
|
91
|
+
cron?: boolean | string;
|
|
92
|
+
}) => {
|
|
93
|
+
try {
|
|
94
|
+
const config = await readConfig();
|
|
95
|
+
const providedPaths = parseRoots(options.roots);
|
|
96
|
+
const providedRootConfigs = providedPaths
|
|
97
|
+
? pathsToRootConfigs(providedPaths)
|
|
98
|
+
: undefined;
|
|
99
|
+
const configRoots =
|
|
100
|
+
config.roots.length > 0 ? config.roots : undefined;
|
|
101
|
+
const rootsToUse = providedRootConfigs ?? configRoots;
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (willOverride) {
|
|
96
|
-
log.warn("--roots overrides configured roots", "configured:", configRoots!.map((r) => r.path).join(", "), "provided:", providedRootConfigs!.map((r) => r.path).join(", "));
|
|
97
|
-
process.stderr.write(
|
|
98
|
-
"Warning: --roots will override your configured roots.\n" +
|
|
99
|
-
" Configured: " +
|
|
100
|
-
configRoots!.map((r) => r.path).join(", ") +
|
|
101
|
-
"\n Provided: " +
|
|
102
|
-
providedRootConfigs!.map((r) => r.path).join(", ") +
|
|
103
|
-
"\n",
|
|
104
|
-
);
|
|
105
|
-
if (!process.stdin.isTTY) {
|
|
106
|
-
if (!options.force) {
|
|
107
|
-
log.error("update failed: refusing to override roots without confirmation (use --force when not in TTY)");
|
|
103
|
+
if (!rootsToUse || rootsToUse.length === 0) {
|
|
104
|
+
log.error("update failed: no roots specified");
|
|
108
105
|
process.stderr.write(
|
|
109
|
-
"Error:
|
|
106
|
+
"Error: No roots specified. Please provide roots using --roots option.\n" +
|
|
107
|
+
"Example: bet update --roots /path/to/your/code\n",
|
|
110
108
|
);
|
|
111
109
|
process.exitCode = 1;
|
|
112
110
|
return;
|
|
113
111
|
}
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
|
|
113
|
+
const willOverride = willOverrideRoots(
|
|
114
|
+
providedRootConfigs,
|
|
115
|
+
config.roots,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (willOverride) {
|
|
119
|
+
log.warn(
|
|
120
|
+
"--roots overrides configured roots",
|
|
121
|
+
"configured:",
|
|
122
|
+
configRoots!.map((r) => r.path).join(", "),
|
|
123
|
+
"provided:",
|
|
124
|
+
providedRootConfigs!.map((r) => r.path).join(", "),
|
|
125
|
+
);
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
"Warning: --roots will override your configured roots.\n" +
|
|
128
|
+
" Configured: " +
|
|
129
|
+
configRoots!.map((r) => r.path).join(", ") +
|
|
130
|
+
"\n Provided: " +
|
|
131
|
+
providedRootConfigs!.map((r) => r.path).join(", ") +
|
|
132
|
+
"\n",
|
|
133
|
+
);
|
|
134
|
+
if (!process.stdin.isTTY) {
|
|
135
|
+
if (!options.force) {
|
|
136
|
+
log.error(
|
|
137
|
+
"update failed: refusing to override roots without confirmation (use --force when not in TTY)",
|
|
138
|
+
);
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
"Error: Refusing to override without confirmation. Run interactively or use --force.\n",
|
|
141
|
+
);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
const confirmed = await promptYesNo("Continue?", true);
|
|
147
|
+
if (!confirmed) {
|
|
148
|
+
log.info("update aborted by user");
|
|
149
|
+
process.stderr.write("Aborted.\n");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
120
153
|
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
154
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
const rootsResolved = resolveRoots(rootsToUse);
|
|
156
|
+
const rootPaths = rootsResolved.map((r) => r.path);
|
|
157
|
+
log.info("update started", "roots=" + rootPaths.join(", "));
|
|
158
|
+
log.debug("scanning roots", rootPaths.length, "root(s)");
|
|
159
|
+
const ignores = getEffectiveIgnores(config);
|
|
160
|
+
const candidates = await scanRoots(rootPaths, ignores);
|
|
161
|
+
const ignoredPaths = config.ignoredPaths ?? [];
|
|
162
|
+
const filteredCandidates = candidates.filter(
|
|
163
|
+
(c) => !isPathIgnored(c.path, ignoredPaths),
|
|
164
|
+
);
|
|
165
|
+
log.debug(
|
|
166
|
+
"found",
|
|
167
|
+
filteredCandidates.length,
|
|
168
|
+
"candidate(s) after ignoring paths",
|
|
169
|
+
);
|
|
170
|
+
const projects: Record<string, Project> = {};
|
|
171
|
+
|
|
172
|
+
for (const candidate of filteredCandidates) {
|
|
173
|
+
const hasGit = await isInsideGitRepo(candidate.path);
|
|
174
|
+
const auto = await computeMetadata(candidate.path, hasGit, ignores);
|
|
175
|
+
const slug = projectSlug(
|
|
176
|
+
candidate.path,
|
|
177
|
+
config.slugParentFolders ?? DEFAULT_SLUG_PARENT_FOLDERS,
|
|
178
|
+
);
|
|
179
|
+
const existing = config.projects[candidate.path];
|
|
180
|
+
const rootConfig = rootsResolved.find(
|
|
181
|
+
(r) => r.path === candidate.root,
|
|
182
|
+
);
|
|
183
|
+
const rootName = rootConfig?.name ?? path.basename(candidate.root);
|
|
184
|
+
|
|
185
|
+
const project: Project = {
|
|
186
|
+
id: candidate.path,
|
|
187
|
+
slug,
|
|
188
|
+
name: slug,
|
|
189
|
+
path: candidate.path,
|
|
190
|
+
root: candidate.root,
|
|
191
|
+
rootName,
|
|
192
|
+
hasGit,
|
|
193
|
+
hasReadme: candidate.hasReadme,
|
|
194
|
+
auto,
|
|
195
|
+
user: existing?.user,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
projects[candidate.path] = project;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const nextConfig: Config = {
|
|
202
|
+
version: config.version ?? 1,
|
|
203
|
+
roots: rootsResolved,
|
|
204
|
+
projects,
|
|
205
|
+
updatedAt: new Date().toISOString(),
|
|
206
|
+
...(config.ignores !== undefined && { ignores: config.ignores }),
|
|
207
|
+
...(config.ignoredPaths !== undefined && {
|
|
208
|
+
ignoredPaths: config.ignoredPaths,
|
|
209
|
+
}),
|
|
210
|
+
...(config.slugParentFolders !== undefined && {
|
|
211
|
+
slugParentFolders: config.slugParentFolders,
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
await writeConfig(nextConfig);
|
|
216
|
+
|
|
217
|
+
const projectCount = Object.keys(projects).length;
|
|
218
|
+
const rootCount = rootsResolved.length;
|
|
219
|
+
log.info(
|
|
220
|
+
"update completed",
|
|
221
|
+
"projects=" + projectCount,
|
|
222
|
+
"roots=" + rootCount,
|
|
223
|
+
);
|
|
160
224
|
|
|
161
|
-
const nextConfig: Config = {
|
|
162
|
-
version: config.version ?? 1,
|
|
163
|
-
roots: rootsResolved,
|
|
164
|
-
projects,
|
|
165
|
-
updatedAt: new Date().toISOString(),
|
|
166
|
-
...(config.ignores !== undefined && { ignores: config.ignores }),
|
|
167
|
-
...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
|
|
168
|
-
...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
await writeConfig(nextConfig);
|
|
172
|
-
|
|
173
|
-
const projectCount = Object.keys(projects).length;
|
|
174
|
-
const rootCount = rootsResolved.length;
|
|
175
|
-
log.info("update completed", "projects=" + projectCount, "roots=" + rootCount);
|
|
176
|
-
|
|
177
|
-
process.stdout.write(
|
|
178
|
-
"Indexed " +
|
|
179
|
-
projectCount +
|
|
180
|
-
" projects from " +
|
|
181
|
-
rootCount +
|
|
182
|
-
" root(s).\n",
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
if (options.cron !== undefined && options.cron !== false) {
|
|
186
|
-
const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
|
|
187
|
-
? process.argv[1]
|
|
188
|
-
: path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
|
|
189
|
-
const cronOpt = options.cron;
|
|
190
|
-
if (cronOpt === true) {
|
|
191
|
-
const { wrapperPath, logPath } = await installUpdateCron({
|
|
192
|
-
nodePath: process.execPath,
|
|
193
|
-
entryScriptPath,
|
|
194
|
-
schedule: "1h",
|
|
195
|
-
});
|
|
196
|
-
process.stdout.write("Installed cron for bet update (every hour).\n");
|
|
197
225
|
process.stdout.write(
|
|
198
|
-
|
|
226
|
+
"Indexed " +
|
|
227
|
+
projectCount +
|
|
228
|
+
" projects from " +
|
|
229
|
+
rootCount +
|
|
230
|
+
" root(s).\n",
|
|
199
231
|
);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const parsed = parseCronSchedule(cronOpt);
|
|
232
|
+
|
|
233
|
+
if (options.cron !== undefined && options.cron !== false) {
|
|
234
|
+
const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
|
|
235
|
+
? process.argv[1]
|
|
236
|
+
: path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
|
|
237
|
+
const cronOpt = options.cron;
|
|
238
|
+
if (cronOpt === true) {
|
|
208
239
|
const { wrapperPath, logPath } = await installUpdateCron({
|
|
209
240
|
nodePath: process.execPath,
|
|
210
241
|
entryScriptPath,
|
|
211
|
-
schedule:
|
|
242
|
+
schedule: "1h",
|
|
212
243
|
});
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
process.stdout.write(
|
|
245
|
+
"Installed cron for bet update (every hour).\n",
|
|
246
|
+
);
|
|
215
247
|
process.stdout.write(
|
|
216
248
|
` Wrapper script: ${wrapperPath}\n Log file: ${logPath}\n To view or edit crontab: crontab -l / crontab -e\n`,
|
|
217
249
|
);
|
|
218
|
-
}
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
250
|
+
} else if (typeof cronOpt === "string") {
|
|
251
|
+
const normalized = cronOpt.trim().toLowerCase();
|
|
252
|
+
if (normalized === "0" || normalized === "false") {
|
|
253
|
+
await uninstallUpdateCron();
|
|
254
|
+
process.stdout.write("Removed cron for bet update.\n");
|
|
255
|
+
} else {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = parseCronSchedule(cronOpt);
|
|
258
|
+
const { wrapperPath, logPath } = await installUpdateCron({
|
|
259
|
+
nodePath: process.execPath,
|
|
260
|
+
entryScriptPath,
|
|
261
|
+
schedule: cronOpt,
|
|
262
|
+
});
|
|
263
|
+
const label = formatScheduleLabel(parsed);
|
|
264
|
+
process.stdout.write(
|
|
265
|
+
`Installed cron for bet update (${label}).\n`,
|
|
266
|
+
);
|
|
267
|
+
process.stdout.write(
|
|
268
|
+
` Wrapper script: ${wrapperPath}\n Log file: ${logPath}\n To view or edit crontab: crontab -l / crontab -e\n`,
|
|
269
|
+
);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
const message =
|
|
272
|
+
err instanceof Error ? err.message : String(err);
|
|
273
|
+
log.error(err instanceof Error ? err : new Error(message));
|
|
274
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
223
278
|
}
|
|
224
279
|
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
282
|
+
log.error(err instanceof Error ? err : new Error(message));
|
|
283
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
284
|
+
process.exitCode = 1;
|
|
225
285
|
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
-
log.error(err instanceof Error ? err : new Error(message));
|
|
230
|
-
process.stderr.write(`Error: ${message}\n`);
|
|
231
|
-
process.exitCode = 1;
|
|
232
|
-
}
|
|
233
|
-
});
|
|
286
|
+
},
|
|
287
|
+
);
|
|
234
288
|
}
|
package/src/lib/config.ts
CHANGED
|
@@ -68,11 +68,25 @@ function normalizeIgnoredPaths(parsed: unknown): string[] | undefined {
|
|
|
68
68
|
return list.length === 0 ? undefined : list;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function normalizeEditor(parsed: unknown): string | undefined {
|
|
72
|
+
if (typeof parsed !== "string") return undefined;
|
|
73
|
+
const trimmed = parsed.trim();
|
|
74
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
async function readAppConfig(): Promise<AppConfig> {
|
|
72
78
|
try {
|
|
73
79
|
const raw = await fs.readFile(CONFIG_PATH, "utf8");
|
|
74
|
-
const parsed = JSON.parse(raw) as {
|
|
80
|
+
const parsed = JSON.parse(raw) as {
|
|
81
|
+
version?: number;
|
|
82
|
+
roots?: unknown;
|
|
83
|
+
editor?: unknown;
|
|
84
|
+
ignores?: unknown;
|
|
85
|
+
ignoredPaths?: unknown;
|
|
86
|
+
slugParentFolders?: unknown;
|
|
87
|
+
};
|
|
75
88
|
const roots = normalizeRoots(parsed.roots ?? []);
|
|
89
|
+
const editor = normalizeEditor(parsed.editor);
|
|
76
90
|
const ignores = normalizeIgnores(parsed.ignores);
|
|
77
91
|
const ignoredPaths = normalizeIgnoredPaths(parsed.ignoredPaths);
|
|
78
92
|
const slugParentFolders = normalizeSlugParentFolders(parsed.slugParentFolders);
|
|
@@ -80,6 +94,7 @@ async function readAppConfig(): Promise<AppConfig> {
|
|
|
80
94
|
...DEFAULT_APP_CONFIG,
|
|
81
95
|
version: parsed.version ?? 1,
|
|
82
96
|
roots,
|
|
97
|
+
...(editor !== undefined && { editor }),
|
|
83
98
|
...(ignores !== undefined && { ignores }),
|
|
84
99
|
...(ignoredPaths !== undefined && { ignoredPaths }),
|
|
85
100
|
...(slugParentFolders !== undefined && { slugParentFolders }),
|
|
@@ -146,6 +161,7 @@ export async function writeConfig(config: Config): Promise<void> {
|
|
|
146
161
|
const appConfig: AppConfig = {
|
|
147
162
|
version: config.version,
|
|
148
163
|
roots: config.roots,
|
|
164
|
+
...(config.editor !== undefined && { editor: config.editor }),
|
|
149
165
|
...(config.ignores !== undefined && { ignores: config.ignores }),
|
|
150
166
|
...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
|
|
151
167
|
...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type LaunchCommand = {
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function tokenizeCommand(input: string): string[] {
|
|
9
|
+
const tokens: string[] = [];
|
|
10
|
+
let current = "";
|
|
11
|
+
let inSingle = false;
|
|
12
|
+
let inDouble = false;
|
|
13
|
+
let escaped = false;
|
|
14
|
+
|
|
15
|
+
for (const char of input) {
|
|
16
|
+
if (escaped) {
|
|
17
|
+
current += char;
|
|
18
|
+
escaped = false;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (char === "\\") {
|
|
23
|
+
escaped = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (char === "'" && !inDouble) {
|
|
28
|
+
inSingle = !inSingle;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (char === '"' && !inSingle) {
|
|
33
|
+
inDouble = !inDouble;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!inSingle && !inDouble && /\s/.test(char)) {
|
|
38
|
+
if (current.length > 0) {
|
|
39
|
+
tokens.push(current);
|
|
40
|
+
current = "";
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
current += char;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (escaped || inSingle || inDouble) {
|
|
49
|
+
throw new Error("Invalid editor command in config.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (current.length > 0) {
|
|
53
|
+
tokens.push(current);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return tokens;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseEditorCommand(editor: string): LaunchCommand {
|
|
60
|
+
const trimmed = editor.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
throw new Error("Config editor must not be empty.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tokens = tokenizeCommand(trimmed);
|
|
66
|
+
if (tokens.length === 0) {
|
|
67
|
+
throw new Error("Config editor must not be empty.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [command, ...args] = tokens;
|
|
71
|
+
return { command, args };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getSystemOpenCommand(
|
|
75
|
+
targetPath: string,
|
|
76
|
+
platform: NodeJS.Platform = process.platform,
|
|
77
|
+
): LaunchCommand {
|
|
78
|
+
if (platform === "darwin") {
|
|
79
|
+
return { command: "open", args: [targetPath] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (platform === "win32") {
|
|
83
|
+
return { command: "cmd", args: ["/c", "start", "", targetPath] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { command: "xdg-open", args: [targetPath] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getEnvEditor(env: NodeJS.ProcessEnv): string | undefined {
|
|
90
|
+
const visual = env.VISUAL?.trim();
|
|
91
|
+
if (visual) return visual;
|
|
92
|
+
|
|
93
|
+
const editor = env.EDITOR?.trim();
|
|
94
|
+
if (editor) return editor;
|
|
95
|
+
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function spawnDetached(command: string, args: string[]): Promise<void> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const child = spawn(command, args, {
|
|
102
|
+
detached: true,
|
|
103
|
+
stdio: "ignore",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.once("error", (error) => {
|
|
107
|
+
reject(error);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
child.once("spawn", () => {
|
|
111
|
+
child.unref();
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function openProjectInEditor(
|
|
118
|
+
projectPath: string,
|
|
119
|
+
configuredEditor?: string,
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const preferredEditor = configuredEditor?.trim() || getEnvEditor(env);
|
|
123
|
+
if (preferredEditor) {
|
|
124
|
+
const parsed = parseEditorCommand(preferredEditor);
|
|
125
|
+
await spawnDetached(parsed.command, [...parsed.args, projectPath]);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fallback = getSystemOpenCommand(projectPath);
|
|
130
|
+
await spawnDetached(fallback.command, fallback.args);
|
|
131
|
+
}
|
package/src/lib/git.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { execFile } from
|
|
2
|
-
import { promisify } from
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
3
|
|
|
4
4
|
const execFileAsync = promisify(execFile);
|
|
5
5
|
|
|
6
6
|
async function runGit(cwd: string, args: string[]): Promise<string | null> {
|
|
7
7
|
try {
|
|
8
|
-
const { stdout } = await execFileAsync(
|
|
9
|
-
encoding:
|
|
8
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
9
|
+
encoding: "utf8",
|
|
10
10
|
});
|
|
11
11
|
return stdout.trim();
|
|
12
12
|
} catch {
|
|
@@ -14,18 +14,28 @@ async function runGit(cwd: string, args: string[]): Promise<string | null> {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export async function getFirstCommitDate(
|
|
18
|
-
|
|
17
|
+
export async function getFirstCommitDate(
|
|
18
|
+
cwd: string,
|
|
19
|
+
): Promise<string | undefined> {
|
|
20
|
+
const output = await runGit(cwd, [
|
|
21
|
+
"log",
|
|
22
|
+
"--max-parents=0",
|
|
23
|
+
"--format=%cd",
|
|
24
|
+
"--date=iso-strict",
|
|
25
|
+
"HEAD",
|
|
26
|
+
]);
|
|
19
27
|
return output || undefined;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
export async function getDirtyStatus(
|
|
23
|
-
|
|
30
|
+
export async function getDirtyStatus(
|
|
31
|
+
cwd: string,
|
|
32
|
+
): Promise<boolean | undefined> {
|
|
33
|
+
const output = await runGit(cwd, ["status", "--porcelain"]);
|
|
24
34
|
if (output === null) return undefined;
|
|
25
35
|
return output.length > 0;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export async function isInsideGitRepo(cwd: string): Promise<boolean> {
|
|
29
|
-
const output = await runGit(cwd, [
|
|
30
|
-
return output ===
|
|
39
|
+
const output = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
40
|
+
return output === "true";
|
|
31
41
|
}
|