@longshot/cli 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 +8 -0
- package/dist/agent.js +214 -0
- package/dist/cli.js +172 -0
- package/dist/git.js +291 -0
- package/dist/index.js +1250 -0
- package/dist/profile.js +79 -0
- package/dist/projects.js +337 -0
- package/dist/queue.js +868 -0
- package/dist/services.js +194 -0
- package/dist/store.js +612 -0
- package/dist/views/agent-progress.js +242 -0
- package/dist/views/branches.js +191 -0
- package/dist/views/chat.js +386 -0
- package/dist/views/diff.js +321 -0
- package/dist/views/history.js +124 -0
- package/dist/views/layout.js +121 -0
- package/dist/views/run.js +92 -0
- package/dist/views/services.js +230 -0
- package/dist/views/spec.js +18 -0
- package/dist/views/tasks.js +898 -0
- package/dist/views/verify.js +209 -0
- package/package.json +36 -0
- package/public/style.css +2088 -0
package/dist/profile.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { userInfo, hostname } from "node:os";
|
|
5
|
+
let cachedPrefix = null;
|
|
6
|
+
let dataDir = null;
|
|
7
|
+
/** Set the data directory (for testing) */
|
|
8
|
+
export function setProfileDataDir(dir) {
|
|
9
|
+
dataDir = dir;
|
|
10
|
+
cachedPrefix = null;
|
|
11
|
+
}
|
|
12
|
+
/** Generate a 4-char hex prefix from user@hostname */
|
|
13
|
+
export function generatePrefix(identity) {
|
|
14
|
+
const input = identity || `${userInfo().username}@${hostname()}`;
|
|
15
|
+
const hash = createHash("sha256").update(input).digest("hex");
|
|
16
|
+
return hash.slice(0, 4);
|
|
17
|
+
}
|
|
18
|
+
function getProfilePath() {
|
|
19
|
+
if (dataDir)
|
|
20
|
+
return join(dataDir, "profile.json");
|
|
21
|
+
return join(import.meta.dirname, "..", ".longshot", "profile.json");
|
|
22
|
+
}
|
|
23
|
+
/** Get or create the user prefix. Cached after first call. */
|
|
24
|
+
export function getPrefix() {
|
|
25
|
+
if (cachedPrefix)
|
|
26
|
+
return cachedPrefix;
|
|
27
|
+
const path = getProfilePath();
|
|
28
|
+
if (existsSync(path)) {
|
|
29
|
+
try {
|
|
30
|
+
const profile = JSON.parse(readFileSync(path, "utf-8"));
|
|
31
|
+
cachedPrefix = profile.prefix;
|
|
32
|
+
return cachedPrefix;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Corrupt file — regenerate
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const prefix = generatePrefix();
|
|
39
|
+
writeFileSync(path, JSON.stringify({ prefix }, null, 2), "utf-8");
|
|
40
|
+
cachedPrefix = prefix;
|
|
41
|
+
return prefix;
|
|
42
|
+
}
|
|
43
|
+
/** Extract the sequence number from a prefixed task ID (e.g. "a3f2-5" → 5) */
|
|
44
|
+
export function getSeqNumber(id) {
|
|
45
|
+
const parts = id.split("-");
|
|
46
|
+
if (parts.length >= 2) {
|
|
47
|
+
const seq = parseInt(parts[parts.length - 1], 10);
|
|
48
|
+
if (!isNaN(seq))
|
|
49
|
+
return seq;
|
|
50
|
+
}
|
|
51
|
+
// Legacy numeric ID
|
|
52
|
+
const num = parseInt(id, 10);
|
|
53
|
+
return isNaN(num) ? 0 : num;
|
|
54
|
+
}
|
|
55
|
+
/** Check if a task ID is a legacy numeric-only ID */
|
|
56
|
+
export function isLegacyId(id) {
|
|
57
|
+
return /^\d+$/.test(id);
|
|
58
|
+
}
|
|
59
|
+
/** Get the prefix part from a prefixed task ID (e.g. "a3f2-5" → "a3f2") */
|
|
60
|
+
export function getIdPrefix(id) {
|
|
61
|
+
if (isLegacyId(id))
|
|
62
|
+
return null;
|
|
63
|
+
const lastDash = id.lastIndexOf("-");
|
|
64
|
+
if (lastDash <= 0)
|
|
65
|
+
return null;
|
|
66
|
+
return id.slice(0, lastDash);
|
|
67
|
+
}
|
|
68
|
+
/** Format a task ID for file naming (e.g. "a3f2-5" → "a3f2-005") */
|
|
69
|
+
export function formatIdForFile(id) {
|
|
70
|
+
if (isLegacyId(id)) {
|
|
71
|
+
return String(parseInt(id, 10)).padStart(3, "0");
|
|
72
|
+
}
|
|
73
|
+
const prefix = getIdPrefix(id);
|
|
74
|
+
const seq = getSeqNumber(id);
|
|
75
|
+
if (prefix) {
|
|
76
|
+
return `${prefix}-${String(seq).padStart(3, "0")}`;
|
|
77
|
+
}
|
|
78
|
+
return String(seq).padStart(3, "0");
|
|
79
|
+
}
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { existsSync, readdirSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import * as store from "./store.js";
|
|
5
|
+
// --- Root directory (where longshot was launched) ---
|
|
6
|
+
const ROOT_DIR = process.env.PROJECT_ROOT || process.cwd();
|
|
7
|
+
// --- Multi-project state ---
|
|
8
|
+
let multiProject = false;
|
|
9
|
+
let currentProjectName = null;
|
|
10
|
+
let currentProjectRoot = ROOT_DIR;
|
|
11
|
+
// --- Exports ---
|
|
12
|
+
export function isMultiProject() {
|
|
13
|
+
return multiProject;
|
|
14
|
+
}
|
|
15
|
+
export function getProjectRoot() {
|
|
16
|
+
return currentProjectRoot;
|
|
17
|
+
}
|
|
18
|
+
export function getRootDir() {
|
|
19
|
+
return ROOT_DIR;
|
|
20
|
+
}
|
|
21
|
+
export function getCurrentProjectName() {
|
|
22
|
+
return currentProjectName;
|
|
23
|
+
}
|
|
24
|
+
// --- Project registry (parent data/projects.json) ---
|
|
25
|
+
function parentDataDir() {
|
|
26
|
+
return join(ROOT_DIR, ".longshot");
|
|
27
|
+
}
|
|
28
|
+
function projectsJsonPath() {
|
|
29
|
+
return join(parentDataDir(), "projects.json");
|
|
30
|
+
}
|
|
31
|
+
function ensureParentDataDir() {
|
|
32
|
+
const dir = parentDataDir();
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function readProjects() {
|
|
38
|
+
const path = projectsJsonPath();
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return [];
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function saveProjects(projects) {
|
|
49
|
+
ensureParentDataDir();
|
|
50
|
+
writeFileSync(projectsJsonPath(), JSON.stringify(projects, null, 2), "utf-8");
|
|
51
|
+
}
|
|
52
|
+
// --- Detection on startup ---
|
|
53
|
+
export function detectMode() {
|
|
54
|
+
// Auto-migrate legacy data/ → .longshot/
|
|
55
|
+
const legacyDir = join(ROOT_DIR, "data");
|
|
56
|
+
const newDir = join(ROOT_DIR, ".longshot");
|
|
57
|
+
if (!existsSync(newDir) && existsSync(legacyDir) && (existsSync(join(legacyDir, "tasks.json")) || existsSync(join(legacyDir, "spec.md")))) {
|
|
58
|
+
renameSync(legacyDir, newDir);
|
|
59
|
+
console.log("Migrated data/ → .longshot/");
|
|
60
|
+
}
|
|
61
|
+
// Auto-migrate .rem-c/ → .longshot/
|
|
62
|
+
const remcDir = join(ROOT_DIR, ".rem-c");
|
|
63
|
+
if (!existsSync(newDir) && existsSync(remcDir)) {
|
|
64
|
+
renameSync(remcDir, newDir);
|
|
65
|
+
console.log("Migrated .rem-c/ → .longshot/");
|
|
66
|
+
}
|
|
67
|
+
const hasLocalData = existsSync(join(ROOT_DIR, ".longshot"));
|
|
68
|
+
if (hasLocalData) {
|
|
69
|
+
// Project mode — single project, current behavior
|
|
70
|
+
multiProject = false;
|
|
71
|
+
currentProjectRoot = ROOT_DIR;
|
|
72
|
+
currentProjectName = null;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Parent mode — look for child projects
|
|
76
|
+
multiProject = true;
|
|
77
|
+
autoDiscover();
|
|
78
|
+
// Switch to the first project if any exist
|
|
79
|
+
const projects = readProjects();
|
|
80
|
+
if (projects.length > 0) {
|
|
81
|
+
switchProject(projects[0].name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// --- Auto-discovery ---
|
|
85
|
+
function autoDiscover() {
|
|
86
|
+
const projects = readProjects();
|
|
87
|
+
const knownNames = new Set(projects.map((p) => p.name));
|
|
88
|
+
try {
|
|
89
|
+
const entries = readdirSync(ROOT_DIR, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory() || entry.name === ".longshot" || entry.name.startsWith("."))
|
|
92
|
+
continue;
|
|
93
|
+
if (knownNames.has(entry.name))
|
|
94
|
+
continue;
|
|
95
|
+
const childDataDir = join(ROOT_DIR, entry.name, ".longshot");
|
|
96
|
+
if (existsSync(childDataDir)) {
|
|
97
|
+
projects.push({ name: entry.name, path: `./${entry.name}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
saveProjects(projects);
|
|
103
|
+
}
|
|
104
|
+
// --- Switch project ---
|
|
105
|
+
export function switchProject(name) {
|
|
106
|
+
if (!multiProject)
|
|
107
|
+
return false;
|
|
108
|
+
const projects = readProjects();
|
|
109
|
+
const project = projects.find((p) => p.name === name);
|
|
110
|
+
if (!project)
|
|
111
|
+
return false;
|
|
112
|
+
const projectPath = resolve(ROOT_DIR, project.path);
|
|
113
|
+
if (!existsSync(join(projectPath, ".longshot")))
|
|
114
|
+
return false;
|
|
115
|
+
currentProjectName = name;
|
|
116
|
+
currentProjectRoot = projectPath;
|
|
117
|
+
store.setDataDir(join(projectPath, ".longshot"));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
// --- List available (unregistered) subdirectories ---
|
|
121
|
+
export function listAvailableSubdirs() {
|
|
122
|
+
const projects = readProjects();
|
|
123
|
+
const knownNames = new Set(projects.map((p) => p.name));
|
|
124
|
+
const available = [];
|
|
125
|
+
try {
|
|
126
|
+
const entries = readdirSync(ROOT_DIR, { withFileTypes: true });
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules")
|
|
129
|
+
continue;
|
|
130
|
+
if (knownNames.has(entry.name))
|
|
131
|
+
continue;
|
|
132
|
+
available.push(entry.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
return available;
|
|
137
|
+
}
|
|
138
|
+
// --- Gitignore entries for transient state ---
|
|
139
|
+
const GITIGNORE_ENTRIES = [
|
|
140
|
+
"# longshot transient state",
|
|
141
|
+
".longshot/status.json",
|
|
142
|
+
".longshot/pending-diffs.json",
|
|
143
|
+
".longshot/queue.json",
|
|
144
|
+
"CLAUDE.md",
|
|
145
|
+
];
|
|
146
|
+
function setupGitignore(projectPath, isNew) {
|
|
147
|
+
const gitignorePath = join(projectPath, ".gitignore");
|
|
148
|
+
if (isNew) {
|
|
149
|
+
const lines = [
|
|
150
|
+
"node_modules/",
|
|
151
|
+
"dist/",
|
|
152
|
+
"",
|
|
153
|
+
...GITIGNORE_ENTRIES,
|
|
154
|
+
"",
|
|
155
|
+
];
|
|
156
|
+
writeFileSync(gitignorePath, lines.join("\n"), "utf-8");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Adopt flow — append to existing .gitignore
|
|
160
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
161
|
+
const existingLines = existing.split("\n");
|
|
162
|
+
const toAdd = [];
|
|
163
|
+
for (const entry of GITIGNORE_ENTRIES) {
|
|
164
|
+
if (entry.startsWith("#")) {
|
|
165
|
+
// Always add the header if we're adding anything
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!existingLines.includes(entry)) {
|
|
169
|
+
toAdd.push(entry);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (toAdd.length > 0) {
|
|
173
|
+
const block = "\n\n# longshot transient state\n" + toAdd.join("\n") + "\n";
|
|
174
|
+
appendFileSync(gitignorePath, block, "utf-8");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// --- Scaffold data dir ---
|
|
178
|
+
function scaffoldDataDir(projectPath, projectName) {
|
|
179
|
+
const dataDir = join(projectPath, ".longshot");
|
|
180
|
+
mkdirSync(dataDir, { recursive: true });
|
|
181
|
+
writeFileSync(join(dataDir, "spec.md"), `# ${projectName}\n\n`, "utf-8");
|
|
182
|
+
writeFileSync(join(dataDir, "tasks.json"), "[]", "utf-8");
|
|
183
|
+
writeFileSync(join(dataDir, "conversation.json"), "[]", "utf-8");
|
|
184
|
+
writeFileSync(join(dataDir, "history.jsonl"), "", "utf-8");
|
|
185
|
+
writeFileSync(join(dataDir, "queue.json"), "[]", "utf-8");
|
|
186
|
+
}
|
|
187
|
+
// --- Create new project ---
|
|
188
|
+
export function createProject(name) {
|
|
189
|
+
if (!multiProject)
|
|
190
|
+
return { ok: false, error: "Not in multi-project mode" };
|
|
191
|
+
if (!name || /[^a-zA-Z0-9_-]/.test(name)) {
|
|
192
|
+
return { ok: false, error: "Invalid project name (use letters, numbers, hyphens, underscores)" };
|
|
193
|
+
}
|
|
194
|
+
const projects = readProjects();
|
|
195
|
+
if (projects.find((p) => p.name === name)) {
|
|
196
|
+
return { ok: false, error: "Project already exists" };
|
|
197
|
+
}
|
|
198
|
+
const projectPath = join(ROOT_DIR, name);
|
|
199
|
+
if (existsSync(projectPath)) {
|
|
200
|
+
return { ok: false, error: "Directory already exists" };
|
|
201
|
+
}
|
|
202
|
+
// Create directory and git init
|
|
203
|
+
mkdirSync(projectPath, { recursive: true });
|
|
204
|
+
execFileSync("git", ["init"], { cwd: projectPath, stdio: "ignore" });
|
|
205
|
+
// Scaffold data dir
|
|
206
|
+
scaffoldDataDir(projectPath, name);
|
|
207
|
+
// Set up .gitignore
|
|
208
|
+
setupGitignore(projectPath, true);
|
|
209
|
+
// Initial commit
|
|
210
|
+
execFileSync("git", ["add", "."], { cwd: projectPath, stdio: "ignore" });
|
|
211
|
+
execFileSync("git", ["commit", "-m", "Initial longshot scaffold"], { cwd: projectPath, stdio: "ignore" });
|
|
212
|
+
// Register
|
|
213
|
+
projects.push({ name, path: `./${name}` });
|
|
214
|
+
saveProjects(projects);
|
|
215
|
+
return { ok: true };
|
|
216
|
+
}
|
|
217
|
+
// --- Adopt existing subfolder ---
|
|
218
|
+
export function adoptProject(name) {
|
|
219
|
+
if (!multiProject)
|
|
220
|
+
return { ok: false, error: "Not in multi-project mode" };
|
|
221
|
+
const projectPath = join(ROOT_DIR, name);
|
|
222
|
+
if (!existsSync(projectPath)) {
|
|
223
|
+
return { ok: false, error: "Directory not found" };
|
|
224
|
+
}
|
|
225
|
+
// Check it's a git repo
|
|
226
|
+
if (!existsSync(join(projectPath, ".git"))) {
|
|
227
|
+
return { ok: false, error: "Not a git repo — run `git init` first" };
|
|
228
|
+
}
|
|
229
|
+
const projects = readProjects();
|
|
230
|
+
// Already has .longshot/ — just register, don't re-scaffold
|
|
231
|
+
if (existsSync(join(projectPath, ".longshot"))) {
|
|
232
|
+
if (projects.find((p) => p.name === name)) {
|
|
233
|
+
return { ok: false, error: "Already registered as a project" };
|
|
234
|
+
}
|
|
235
|
+
projects.push({ name, path: `./${name}` });
|
|
236
|
+
saveProjects(projects);
|
|
237
|
+
switchProject(name);
|
|
238
|
+
return { ok: true };
|
|
239
|
+
}
|
|
240
|
+
if (projects.find((p) => p.name === name)) {
|
|
241
|
+
return { ok: false, error: "Already registered as a project" };
|
|
242
|
+
}
|
|
243
|
+
// Scaffold data dir
|
|
244
|
+
scaffoldDataDir(projectPath, name);
|
|
245
|
+
// Update .gitignore
|
|
246
|
+
setupGitignore(projectPath, false);
|
|
247
|
+
// Commit the scaffold
|
|
248
|
+
try {
|
|
249
|
+
execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: projectPath, stdio: "ignore" });
|
|
250
|
+
execFileSync("git", ["commit", "-m", "Add longshot data scaffold"], { cwd: projectPath, stdio: "ignore" });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// May fail if nothing to commit
|
|
254
|
+
}
|
|
255
|
+
// Register
|
|
256
|
+
projects.push({ name, path: `./${name}` });
|
|
257
|
+
saveProjects(projects);
|
|
258
|
+
// Switch to it
|
|
259
|
+
switchProject(name);
|
|
260
|
+
return { ok: true };
|
|
261
|
+
}
|
|
262
|
+
// --- CLI onboarding helpers ---
|
|
263
|
+
/**
|
|
264
|
+
* Initialize a directory as a single longshot project.
|
|
265
|
+
* Scaffolds .longshot/ and sets up .gitignore.
|
|
266
|
+
* Commits if the directory is a git repo.
|
|
267
|
+
*/
|
|
268
|
+
export function initSingleProject(dir) {
|
|
269
|
+
const name = dir.split("/").pop() || "project";
|
|
270
|
+
scaffoldDataDir(dir, name);
|
|
271
|
+
setupGitignore(dir, !existsSync(join(dir, ".gitignore")));
|
|
272
|
+
// Commit if it's a git repo
|
|
273
|
+
if (existsSync(join(dir, ".git"))) {
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: dir, stdio: "ignore" });
|
|
276
|
+
execFileSync("git", ["commit", "-m", "Initialize longshot project"], { cwd: dir, stdio: "ignore" });
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// May fail if nothing to commit
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Initialize multi-project mode in a parent directory.
|
|
285
|
+
* Adopts selected subdirectories as projects.
|
|
286
|
+
* Self-contained — reads/writes projects.json directly in parentDir/.longshot/
|
|
287
|
+
* so it works regardless of the module-level ROOT_DIR.
|
|
288
|
+
*/
|
|
289
|
+
export function initMultiProject(parentDir, selectedDirs) {
|
|
290
|
+
const parentDataPath = join(parentDir, ".longshot");
|
|
291
|
+
const projectsPath = join(parentDataPath, "projects.json");
|
|
292
|
+
// Ensure parent .longshot dir exists
|
|
293
|
+
if (!existsSync(parentDataPath)) {
|
|
294
|
+
mkdirSync(parentDataPath, { recursive: true });
|
|
295
|
+
}
|
|
296
|
+
// Read existing projects.json or start fresh
|
|
297
|
+
let projects = [];
|
|
298
|
+
if (existsSync(projectsPath)) {
|
|
299
|
+
try {
|
|
300
|
+
projects = JSON.parse(readFileSync(projectsPath, "utf-8"));
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
projects = [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const dirName of selectedDirs) {
|
|
307
|
+
const projectPath = join(parentDir, dirName);
|
|
308
|
+
if (!existsSync(projectPath))
|
|
309
|
+
continue;
|
|
310
|
+
// Already has .longshot/ — just register
|
|
311
|
+
if (existsSync(join(projectPath, ".longshot"))) {
|
|
312
|
+
if (!projects.find((p) => p.name === dirName)) {
|
|
313
|
+
projects.push({ name: dirName, path: `./${dirName}` });
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// Scaffold the subdirectory
|
|
318
|
+
scaffoldDataDir(projectPath, dirName);
|
|
319
|
+
setupGitignore(projectPath, !existsSync(join(projectPath, ".gitignore")));
|
|
320
|
+
// Commit if it's a git repo
|
|
321
|
+
if (existsSync(join(projectPath, ".git"))) {
|
|
322
|
+
try {
|
|
323
|
+
execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: projectPath, stdio: "ignore" });
|
|
324
|
+
execFileSync("git", ["commit", "-m", "Add longshot data scaffold"], { cwd: projectPath, stdio: "ignore" });
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// May fail if nothing to commit
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Register
|
|
331
|
+
if (!projects.find((p) => p.name === dirName)) {
|
|
332
|
+
projects.push({ name: dirName, path: `./${dirName}` });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Save projects.json
|
|
336
|
+
writeFileSync(projectsPath, JSON.stringify(projects, null, 2), "utf-8");
|
|
337
|
+
}
|