@macroscope/cli 0.1.0 → 0.2.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/dist/cli.js +2918 -397
- package/dist/cli.js.map +1 -1
- package/dist/core/index.d.ts +91 -4
- package/dist/core/index.js +505 -6
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +458 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/assets/index-DNLqogGA.css +1 -0
- package/dist/ui/assets/index-EeHIpual.js +45 -0
- package/dist/ui/assets/index-EeHIpual.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +5 -2
- package/dist/ui/assets/index-ByDfVdzb.css +0 -1
- package/dist/ui/assets/index-D3mfLpRq.js +0 -45
- package/dist/ui/assets/index-D3mfLpRq.js.map +0 -1
- /package/dist/{index-BTDioymD.d.ts → version-BTDioymD.d.ts} +0 -0
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,271 @@ var __export = (target, all) => {
|
|
|
9
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
// ../contracts/dist/index.js
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { z as z2 } from "zod";
|
|
15
|
+
import { z as z3 } from "zod";
|
|
16
|
+
import { z as z4 } from "zod";
|
|
17
|
+
import { z as z5 } from "zod";
|
|
18
|
+
import { z as z6 } from "zod";
|
|
19
|
+
var SCHEMA_VERSION, FILESYSTEM_LAYOUT, BlockManifestSchema, SymbolSignatureSchema, UploadPayloadSchema, MeilisearchDocSchema, BlockHitSchema, CodeMatchSchema, IndexBatchRequestSchema, IndexBatchResponseSchema, SearchRequestSchema, SearchResponseSchema, DeleteIndexResponseSchema, ScopeTouchRequestSchema, ScopeTouchResponseSchema, HealthResponseSchema, API_ENDPOINTS, ErrorEnvelopeSchema, ProviderSchema, OAuthCredentialsSchema, UserContextSchema;
|
|
20
|
+
var init_dist = __esm({
|
|
21
|
+
"../contracts/dist/index.js"() {
|
|
22
|
+
"use strict";
|
|
23
|
+
SCHEMA_VERSION = 1;
|
|
24
|
+
FILESYSTEM_LAYOUT = {
|
|
25
|
+
user: {
|
|
26
|
+
/** `~/.macroscope/` */
|
|
27
|
+
rootDir: ".macroscope",
|
|
28
|
+
/** `~/.macroscope/credentials` — OAuth token store, mode 0600. */
|
|
29
|
+
credentialsFile: ".macroscope/credentials",
|
|
30
|
+
/** `~/.macroscope/machine-id` — stable per-install identifier. */
|
|
31
|
+
machineIdFile: ".macroscope/machine-id"
|
|
32
|
+
},
|
|
33
|
+
project: {
|
|
34
|
+
/** `<project>/.macroscope/` */
|
|
35
|
+
rootDir: ".macroscope",
|
|
36
|
+
/** `<project>/.macroscope/blueprints/` — one folder per kind. */
|
|
37
|
+
blueprintsDir: ".macroscope/blueprints",
|
|
38
|
+
/** `<project>/.macroscope/cache/` */
|
|
39
|
+
cacheDir: ".macroscope/cache",
|
|
40
|
+
/** `<project>/.macroscope/cache/hashes.json` — content-hash cache used by catch-up scan. */
|
|
41
|
+
hashesFile: ".macroscope/cache/hashes.json",
|
|
42
|
+
/** `<project>/.macroscope/cache/queue.json` — watcher push queue, survives restart. */
|
|
43
|
+
queueFile: ".macroscope/cache/queue.json"
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
BlockManifestSchema = z.object({
|
|
47
|
+
/** Must reference a registered blueprint's `kind`. */
|
|
48
|
+
kind: z.string().min(1),
|
|
49
|
+
/** Optional override for the auto-derived block id (POSIX relative path). */
|
|
50
|
+
id: z.string().optional(),
|
|
51
|
+
name: z.string().optional(),
|
|
52
|
+
description: z.string().optional(),
|
|
53
|
+
tags: z.array(z.string()).optional(),
|
|
54
|
+
source: z.string().optional(),
|
|
55
|
+
preview: z.string().optional(),
|
|
56
|
+
docs: z.string().optional(),
|
|
57
|
+
tests: z.union([z.string(), z.array(z.string())]).optional()
|
|
58
|
+
}).passthrough();
|
|
59
|
+
SymbolSignatureSchema = z2.object({
|
|
60
|
+
name: z2.string().min(1),
|
|
61
|
+
kind: z2.string().min(1),
|
|
62
|
+
signature: z2.string()
|
|
63
|
+
});
|
|
64
|
+
UploadPayloadSchema = z2.object({
|
|
65
|
+
/** Stable block id (POSIX relative path or manifest `id` override). */
|
|
66
|
+
blockId: z2.string().min(1),
|
|
67
|
+
kind: z2.string().min(1),
|
|
68
|
+
name: z2.string().optional(),
|
|
69
|
+
description: z2.string().optional(),
|
|
70
|
+
tags: z2.array(z2.string()).optional(),
|
|
71
|
+
/** Exported symbols extracted from the block's source files. */
|
|
72
|
+
symbols: z2.array(SymbolSignatureSchema),
|
|
73
|
+
/** First ~500 chars of the README, if any. */
|
|
74
|
+
readmeExcerpt: z2.string().optional()
|
|
75
|
+
});
|
|
76
|
+
MeilisearchDocSchema = UploadPayloadSchema.extend({
|
|
77
|
+
/** SHA-256 over the canonical JSON of the UploadPayload (see T6). */
|
|
78
|
+
contentHash: z2.string().min(1),
|
|
79
|
+
/** Git remote URL (normalised) or local repo name when no remote exists. */
|
|
80
|
+
repo: z2.string().min(1),
|
|
81
|
+
/** hash(machineUuid + absolutePath) — see ARCHITECTURE.md. */
|
|
82
|
+
worktreeId: z2.string().min(1),
|
|
83
|
+
/** POSIX path from the worktree root to the block folder. */
|
|
84
|
+
path: z2.string().min(1),
|
|
85
|
+
/** Unix epoch milliseconds. Refreshed on every push and by `/scope/touch`. */
|
|
86
|
+
lastActiveAt: z2.number().int().nonnegative()
|
|
87
|
+
});
|
|
88
|
+
BlockHitSchema = z3.object({
|
|
89
|
+
blockId: z3.string().min(1),
|
|
90
|
+
kind: z3.string().min(1),
|
|
91
|
+
name: z3.string().optional(),
|
|
92
|
+
description: z3.string().optional(),
|
|
93
|
+
repo: z3.string().min(1),
|
|
94
|
+
worktreeId: z3.string().min(1),
|
|
95
|
+
path: z3.string().min(1),
|
|
96
|
+
/** Relevance score from Meilisearch hybrid ranking. Larger = more relevant. */
|
|
97
|
+
score: z3.number()
|
|
98
|
+
});
|
|
99
|
+
CodeMatchSchema = z3.object({
|
|
100
|
+
/** POSIX path from the worktree root. */
|
|
101
|
+
file: z3.string().min(1),
|
|
102
|
+
/** 1-based line number. */
|
|
103
|
+
line: z3.number().int().positive(),
|
|
104
|
+
/** 1-based column number, when available. */
|
|
105
|
+
column: z3.number().int().positive().optional(),
|
|
106
|
+
/** A single line of source text containing the match. */
|
|
107
|
+
preview: z3.string()
|
|
108
|
+
});
|
|
109
|
+
IndexBatchRequestSchema = z4.object({
|
|
110
|
+
/** Per-block docs to upsert. The cloud routes these to the user's Meilisearch index. */
|
|
111
|
+
upserts: z4.array(MeilisearchDocSchema),
|
|
112
|
+
/** Block ids to delete. Empty array is valid (upsert-only batch). */
|
|
113
|
+
deletes: z4.array(z4.string().min(1))
|
|
114
|
+
});
|
|
115
|
+
IndexBatchResponseSchema = z4.object({
|
|
116
|
+
/** Unix epoch ms of server-side acknowledgement. */
|
|
117
|
+
acceptedAt: z4.number().int().nonnegative(),
|
|
118
|
+
/** Number of upserts accepted (deletes excluded). */
|
|
119
|
+
accepted: z4.number().int().nonnegative()
|
|
120
|
+
});
|
|
121
|
+
SearchRequestSchema = z4.object({
|
|
122
|
+
q: z4.string().min(1),
|
|
123
|
+
/** Restrict to one worktree. Omit to search across all of the user's worktrees. */
|
|
124
|
+
worktreeId: z4.string().optional(),
|
|
125
|
+
/** Restrict to one repo. */
|
|
126
|
+
repo: z4.string().optional(),
|
|
127
|
+
/** Restrict to one block kind. */
|
|
128
|
+
kind: z4.string().optional(),
|
|
129
|
+
/** Cap on returned hits. Server enforces an upper bound. */
|
|
130
|
+
limit: z4.number().int().positive().optional()
|
|
131
|
+
});
|
|
132
|
+
SearchResponseSchema = z4.object({
|
|
133
|
+
hits: z4.array(BlockHitSchema),
|
|
134
|
+
/** Approximate total match count from Meilisearch (estimated, not exact). */
|
|
135
|
+
totalEstimated: z4.number().int().nonnegative()
|
|
136
|
+
});
|
|
137
|
+
DeleteIndexResponseSchema = z4.object({
|
|
138
|
+
deletedAt: z4.number().int().nonnegative()
|
|
139
|
+
});
|
|
140
|
+
ScopeTouchRequestSchema = z4.object({
|
|
141
|
+
worktreeId: z4.string().min(1),
|
|
142
|
+
repo: z4.string().min(1)
|
|
143
|
+
});
|
|
144
|
+
ScopeTouchResponseSchema = z4.object({
|
|
145
|
+
touchedAt: z4.number().int().nonnegative()
|
|
146
|
+
});
|
|
147
|
+
HealthResponseSchema = z4.object({
|
|
148
|
+
status: z4.literal("ok"),
|
|
149
|
+
schemaVersion: z4.number().int().positive()
|
|
150
|
+
});
|
|
151
|
+
API_ENDPOINTS = {
|
|
152
|
+
indexBatch: {
|
|
153
|
+
method: "POST",
|
|
154
|
+
path: "/index/batch",
|
|
155
|
+
request: IndexBatchRequestSchema,
|
|
156
|
+
response: IndexBatchResponseSchema
|
|
157
|
+
},
|
|
158
|
+
search: {
|
|
159
|
+
method: "GET",
|
|
160
|
+
path: "/search",
|
|
161
|
+
request: SearchRequestSchema,
|
|
162
|
+
response: SearchResponseSchema
|
|
163
|
+
},
|
|
164
|
+
deleteIndex: {
|
|
165
|
+
method: "DELETE",
|
|
166
|
+
path: "/index",
|
|
167
|
+
request: null,
|
|
168
|
+
response: DeleteIndexResponseSchema
|
|
169
|
+
},
|
|
170
|
+
scopeTouch: {
|
|
171
|
+
method: "POST",
|
|
172
|
+
path: "/scope/touch",
|
|
173
|
+
request: ScopeTouchRequestSchema,
|
|
174
|
+
response: ScopeTouchResponseSchema
|
|
175
|
+
},
|
|
176
|
+
health: {
|
|
177
|
+
method: "GET",
|
|
178
|
+
path: "/health",
|
|
179
|
+
request: null,
|
|
180
|
+
response: HealthResponseSchema
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
ErrorEnvelopeSchema = z5.object({
|
|
184
|
+
error: z5.object({
|
|
185
|
+
/** Stable machine-readable code (e.g. `unauthorized`, `validation_failed`). */
|
|
186
|
+
code: z5.string().min(1),
|
|
187
|
+
/** Human-readable message. */
|
|
188
|
+
message: z5.string().min(1),
|
|
189
|
+
/** Source file path when the error originates from parsing a manifest or other on-disk artifact. Set by the CLI side (see `ScanError`) and by the cloud when validating uploaded manifests. */
|
|
190
|
+
file: z5.string().optional(),
|
|
191
|
+
/** Path of the offending field when the error is validation-related. */
|
|
192
|
+
field: z5.string().optional(),
|
|
193
|
+
/** Server-assigned request id for log correlation. */
|
|
194
|
+
requestId: z5.string().optional()
|
|
195
|
+
})
|
|
196
|
+
});
|
|
197
|
+
ProviderSchema = z6.enum(["github", "gitlab"]);
|
|
198
|
+
OAuthCredentialsSchema = z6.object({
|
|
199
|
+
provider: ProviderSchema,
|
|
200
|
+
accessToken: z6.string().min(1),
|
|
201
|
+
refreshToken: z6.string().min(1).optional(),
|
|
202
|
+
/** Unix epoch milliseconds at which `accessToken` expires. */
|
|
203
|
+
expiresAt: z6.number().int().nonnegative().optional(),
|
|
204
|
+
/** Provider-side user id (e.g. GitHub `id`, GitLab `id`). Optional — useful
|
|
205
|
+
* for UI display ("logged in as alexspdlr on github") and for the CLI to
|
|
206
|
+
* short-circuit redundant `/user` lookups. */
|
|
207
|
+
providerUserId: z6.string().min(1).optional()
|
|
208
|
+
});
|
|
209
|
+
UserContextSchema = z6.object({
|
|
210
|
+
userId: z6.string().min(1),
|
|
211
|
+
indexName: z6.string().min(1),
|
|
212
|
+
provider: ProviderSchema
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// src/core/project.ts
|
|
218
|
+
import { existsSync } from "fs";
|
|
219
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
220
|
+
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
221
|
+
import { parse as parseYaml } from "yaml";
|
|
222
|
+
import { z as z7 } from "zod";
|
|
223
|
+
async function findProject(cwd = process.cwd()) {
|
|
224
|
+
const startedFrom = resolve(cwd);
|
|
225
|
+
let current = startedFrom;
|
|
226
|
+
while (true) {
|
|
227
|
+
if (existsSync(join2(current, ".macroscope"))) {
|
|
228
|
+
return { root: current, config: await loadConfig(current) };
|
|
229
|
+
}
|
|
230
|
+
const parent = dirname2(current);
|
|
231
|
+
if (parent === current) {
|
|
232
|
+
throw new ProjectNotFoundError(startedFrom);
|
|
233
|
+
}
|
|
234
|
+
current = parent;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function loadConfig(root) {
|
|
238
|
+
const configPath = join2(root, ".macroscope", "config.yaml");
|
|
239
|
+
if (!existsSync(configPath)) {
|
|
240
|
+
return projectConfigSchema.parse({});
|
|
241
|
+
}
|
|
242
|
+
const raw = await readFile2(configPath, "utf8");
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
parsed = parseYaml(raw) ?? {};
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
248
|
+
throw new Error(`Failed to parse ${configPath}: ${msg}`);
|
|
249
|
+
}
|
|
250
|
+
const result = projectConfigSchema.safeParse(parsed);
|
|
251
|
+
if (!result.success) {
|
|
252
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
253
|
+
throw new Error(`Invalid project config at ${configPath}:
|
|
254
|
+
${issues}`);
|
|
255
|
+
}
|
|
256
|
+
return result.data;
|
|
257
|
+
}
|
|
258
|
+
var projectConfigSchema, ProjectNotFoundError;
|
|
259
|
+
var init_project = __esm({
|
|
260
|
+
"src/core/project.ts"() {
|
|
261
|
+
"use strict";
|
|
262
|
+
projectConfigSchema = z7.object({
|
|
263
|
+
scanRoots: z7.array(z7.string()).default(["."]),
|
|
264
|
+
ignore: z7.array(z7.string()).default(["node_modules", ".git", "dist", ".macroscope"])
|
|
265
|
+
});
|
|
266
|
+
ProjectNotFoundError = class extends Error {
|
|
267
|
+
constructor(startedFrom) {
|
|
268
|
+
super(
|
|
269
|
+
`No macroscope project found. Walked up from ${startedFrom} looking for a .macroscope/ directory but reached the filesystem root. Run \`npm create macroscope\` to scaffold one, or cd into an existing project.`
|
|
270
|
+
);
|
|
271
|
+
this.name = "ProjectNotFoundError";
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
12
277
|
// src/core/handler.ts
|
|
13
278
|
function isHandler(value) {
|
|
14
279
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -70,13 +335,13 @@ var init_handler = __esm({
|
|
|
70
335
|
});
|
|
71
336
|
|
|
72
337
|
// src/core/blueprints.ts
|
|
73
|
-
import { existsSync } from "fs";
|
|
338
|
+
import { existsSync as existsSync2 } from "fs";
|
|
74
339
|
import { readdir } from "fs/promises";
|
|
75
|
-
import { join } from "path";
|
|
340
|
+
import { join as join3 } from "path";
|
|
76
341
|
import { createJiti } from "jiti";
|
|
77
342
|
async function loadBlueprints(project) {
|
|
78
|
-
const blueprintsDir =
|
|
79
|
-
if (!
|
|
343
|
+
const blueprintsDir = join3(project.root, ".macroscope", "blueprints");
|
|
344
|
+
if (!existsSync2(blueprintsDir)) {
|
|
80
345
|
return { blueprints: [], errors: [] };
|
|
81
346
|
}
|
|
82
347
|
let entries;
|
|
@@ -90,7 +355,7 @@ async function loadBlueprints(project) {
|
|
|
90
355
|
const jiti = createJiti(import.meta.url);
|
|
91
356
|
for (const entry of entries) {
|
|
92
357
|
if (!entry.isDirectory()) continue;
|
|
93
|
-
const folder =
|
|
358
|
+
const folder = join3(blueprintsDir, entry.name);
|
|
94
359
|
const kind = entry.name.normalize("NFC");
|
|
95
360
|
const result = await loadOne(folder, kind, jiti);
|
|
96
361
|
if (result.ok) {
|
|
@@ -106,8 +371,8 @@ async function loadBlueprints(project) {
|
|
|
106
371
|
async function loadOne(folder, kind, jiti) {
|
|
107
372
|
let file = null;
|
|
108
373
|
for (const filename of HANDLER_FILENAMES) {
|
|
109
|
-
const candidate =
|
|
110
|
-
if (
|
|
374
|
+
const candidate = join3(folder, filename);
|
|
375
|
+
if (existsSync2(candidate)) {
|
|
111
376
|
file = candidate;
|
|
112
377
|
break;
|
|
113
378
|
}
|
|
@@ -194,266 +459,76 @@ var init_blueprints = __esm({
|
|
|
194
459
|
}
|
|
195
460
|
});
|
|
196
461
|
|
|
197
|
-
// src/core/
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
462
|
+
// src/core/manifest.ts
|
|
463
|
+
var manifestSchema;
|
|
464
|
+
var init_manifest = __esm({
|
|
465
|
+
"src/core/manifest.ts"() {
|
|
466
|
+
"use strict";
|
|
467
|
+
init_dist();
|
|
468
|
+
manifestSchema = BlockManifestSchema;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// src/core/scanner.ts
|
|
473
|
+
import { createHash as createHash3 } from "crypto";
|
|
474
|
+
import { existsSync as existsSync3 } from "fs";
|
|
475
|
+
import { readFile as readFile6, readdir as readdir2 } from "fs/promises";
|
|
476
|
+
import { dirname as dirname4, join as join5, relative, resolve as resolve2, sep } from "path";
|
|
477
|
+
import { parse as parseYaml2 } from "yaml";
|
|
478
|
+
async function scan(project) {
|
|
479
|
+
const blocks = [];
|
|
480
|
+
const errors = [];
|
|
481
|
+
const ignore = new Set(project.config.ignore);
|
|
482
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
483
|
+
for (const root of project.config.scanRoots) {
|
|
484
|
+
const start = join5(project.root, root);
|
|
485
|
+
if (!existsSync3(start)) continue;
|
|
486
|
+
for await (const manifestPath of findManifests(start, ignore)) {
|
|
487
|
+
const result = await parseBlock(manifestPath, project.root);
|
|
488
|
+
if (result.kind === "ok") {
|
|
489
|
+
errors.push(...result.errors);
|
|
490
|
+
if (seenIds.has(result.block.id)) {
|
|
491
|
+
errors.push({
|
|
492
|
+
file: manifestPath,
|
|
493
|
+
kind: "validation",
|
|
494
|
+
message: `Duplicate block id "${result.block.id}" at ${manifestPath}. Set an explicit \`id:\` field to disambiguate.`,
|
|
495
|
+
field: "id"
|
|
496
|
+
});
|
|
497
|
+
} else {
|
|
498
|
+
seenIds.add(result.block.id);
|
|
499
|
+
blocks.push(result.block);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
errors.push(result.error);
|
|
503
|
+
}
|
|
213
504
|
}
|
|
214
|
-
current = parent;
|
|
215
505
|
}
|
|
506
|
+
blocks.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
507
|
+
return { blocks, errors };
|
|
216
508
|
}
|
|
217
|
-
async function
|
|
218
|
-
|
|
219
|
-
if (!existsSync2(configPath)) {
|
|
220
|
-
return projectConfigSchema.parse({});
|
|
221
|
-
}
|
|
222
|
-
const raw = await readFile(configPath, "utf8");
|
|
223
|
-
let parsed;
|
|
509
|
+
async function* findManifests(dir, ignore) {
|
|
510
|
+
let entries;
|
|
224
511
|
try {
|
|
225
|
-
|
|
226
|
-
} catch
|
|
227
|
-
|
|
228
|
-
throw new Error(`Failed to parse ${configPath}: ${msg}`);
|
|
512
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
513
|
+
} catch {
|
|
514
|
+
return;
|
|
229
515
|
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"src/core/project.ts"() {
|
|
241
|
-
"use strict";
|
|
242
|
-
projectConfigSchema = z.object({
|
|
243
|
-
scanRoots: z.array(z.string()).default(["."]),
|
|
244
|
-
ignore: z.array(z.string()).default(["node_modules", ".git", "dist", ".macroscope"])
|
|
245
|
-
});
|
|
246
|
-
ProjectNotFoundError = class extends Error {
|
|
247
|
-
constructor(startedFrom) {
|
|
248
|
-
super(
|
|
249
|
-
`No macroscope project found. Walked up from ${startedFrom} looking for a .macroscope/ directory but reached the filesystem root. Run \`npm create macroscope\` to scaffold one, or cd into an existing project.`
|
|
250
|
-
);
|
|
251
|
-
this.name = "ProjectNotFoundError";
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// ../contracts/dist/index.js
|
|
258
|
-
import { z as z2 } from "zod";
|
|
259
|
-
import { z as z22 } from "zod";
|
|
260
|
-
import { z as z3 } from "zod";
|
|
261
|
-
import { z as z4 } from "zod";
|
|
262
|
-
import { z as z5 } from "zod";
|
|
263
|
-
var BlockManifestSchema, SymbolSignatureSchema, UploadPayloadSchema, MeilisearchDocSchema, BlockHitSchema, CodeMatchSchema, IndexBatchRequestSchema, IndexBatchResponseSchema, SearchRequestSchema, SearchResponseSchema, DeleteIndexResponseSchema, ScopeTouchRequestSchema, ScopeTouchResponseSchema, HealthResponseSchema, ErrorEnvelopeSchema;
|
|
264
|
-
var init_dist = __esm({
|
|
265
|
-
"../contracts/dist/index.js"() {
|
|
266
|
-
"use strict";
|
|
267
|
-
BlockManifestSchema = z2.object({
|
|
268
|
-
/** Must reference a registered blueprint's `kind`. */
|
|
269
|
-
kind: z2.string().min(1),
|
|
270
|
-
/** Optional override for the auto-derived block id (POSIX relative path). */
|
|
271
|
-
id: z2.string().optional(),
|
|
272
|
-
name: z2.string().optional(),
|
|
273
|
-
description: z2.string().optional(),
|
|
274
|
-
tags: z2.array(z2.string()).optional(),
|
|
275
|
-
source: z2.string().optional(),
|
|
276
|
-
preview: z2.string().optional(),
|
|
277
|
-
docs: z2.string().optional(),
|
|
278
|
-
tests: z2.union([z2.string(), z2.array(z2.string())]).optional()
|
|
279
|
-
}).passthrough();
|
|
280
|
-
SymbolSignatureSchema = z22.object({
|
|
281
|
-
name: z22.string().min(1),
|
|
282
|
-
kind: z22.string().min(1),
|
|
283
|
-
signature: z22.string()
|
|
284
|
-
});
|
|
285
|
-
UploadPayloadSchema = z22.object({
|
|
286
|
-
/** Stable block id (POSIX relative path or manifest `id` override). */
|
|
287
|
-
blockId: z22.string().min(1),
|
|
288
|
-
kind: z22.string().min(1),
|
|
289
|
-
name: z22.string().optional(),
|
|
290
|
-
description: z22.string().optional(),
|
|
291
|
-
tags: z22.array(z22.string()).optional(),
|
|
292
|
-
/** Exported symbols extracted from the block's source files. */
|
|
293
|
-
symbols: z22.array(SymbolSignatureSchema),
|
|
294
|
-
/** First ~500 chars of the README, if any. */
|
|
295
|
-
readmeExcerpt: z22.string().optional()
|
|
296
|
-
});
|
|
297
|
-
MeilisearchDocSchema = UploadPayloadSchema.extend({
|
|
298
|
-
/** SHA-256 over the canonical JSON of the UploadPayload (see T6). */
|
|
299
|
-
contentHash: z22.string().min(1),
|
|
300
|
-
/** Git remote URL (normalised) or local repo name when no remote exists. */
|
|
301
|
-
repo: z22.string().min(1),
|
|
302
|
-
/** hash(machineUuid + absolutePath) — see ARCHITECTURE.md. */
|
|
303
|
-
worktreeId: z22.string().min(1),
|
|
304
|
-
/** POSIX path from the worktree root to the block folder. */
|
|
305
|
-
path: z22.string().min(1),
|
|
306
|
-
/** Unix epoch milliseconds. Refreshed on every push and by `/scope/touch`. */
|
|
307
|
-
lastActiveAt: z22.number().int().nonnegative()
|
|
308
|
-
});
|
|
309
|
-
BlockHitSchema = z3.object({
|
|
310
|
-
blockId: z3.string().min(1),
|
|
311
|
-
kind: z3.string().min(1),
|
|
312
|
-
name: z3.string().optional(),
|
|
313
|
-
description: z3.string().optional(),
|
|
314
|
-
repo: z3.string().min(1),
|
|
315
|
-
worktreeId: z3.string().min(1),
|
|
316
|
-
path: z3.string().min(1),
|
|
317
|
-
/** Relevance score from Meilisearch hybrid ranking. Larger = more relevant. */
|
|
318
|
-
score: z3.number()
|
|
319
|
-
});
|
|
320
|
-
CodeMatchSchema = z3.object({
|
|
321
|
-
/** POSIX path from the worktree root. */
|
|
322
|
-
file: z3.string().min(1),
|
|
323
|
-
/** 1-based line number. */
|
|
324
|
-
line: z3.number().int().positive(),
|
|
325
|
-
/** 1-based column number, when available. */
|
|
326
|
-
column: z3.number().int().positive().optional(),
|
|
327
|
-
/** A single line of source text containing the match. */
|
|
328
|
-
preview: z3.string()
|
|
329
|
-
});
|
|
330
|
-
IndexBatchRequestSchema = z4.object({
|
|
331
|
-
/** Per-block docs to upsert. The cloud routes these to the user's Meilisearch index. */
|
|
332
|
-
upserts: z4.array(MeilisearchDocSchema),
|
|
333
|
-
/** Block ids to delete. Empty array is valid (upsert-only batch). */
|
|
334
|
-
deletes: z4.array(z4.string().min(1))
|
|
335
|
-
});
|
|
336
|
-
IndexBatchResponseSchema = z4.object({
|
|
337
|
-
/** Unix epoch ms of server-side acknowledgement. */
|
|
338
|
-
acceptedAt: z4.number().int().nonnegative(),
|
|
339
|
-
/** Number of upserts accepted (deletes excluded). */
|
|
340
|
-
accepted: z4.number().int().nonnegative()
|
|
341
|
-
});
|
|
342
|
-
SearchRequestSchema = z4.object({
|
|
343
|
-
q: z4.string().min(1),
|
|
344
|
-
/** Restrict to one worktree. Omit to search across all of the user's worktrees. */
|
|
345
|
-
worktreeId: z4.string().optional(),
|
|
346
|
-
/** Restrict to one repo. */
|
|
347
|
-
repo: z4.string().optional(),
|
|
348
|
-
/** Restrict to one block kind. */
|
|
349
|
-
kind: z4.string().optional(),
|
|
350
|
-
/** Cap on returned hits. Server enforces an upper bound. */
|
|
351
|
-
limit: z4.number().int().positive().optional()
|
|
352
|
-
});
|
|
353
|
-
SearchResponseSchema = z4.object({
|
|
354
|
-
hits: z4.array(BlockHitSchema),
|
|
355
|
-
/** Approximate total match count from Meilisearch (estimated, not exact). */
|
|
356
|
-
totalEstimated: z4.number().int().nonnegative()
|
|
357
|
-
});
|
|
358
|
-
DeleteIndexResponseSchema = z4.object({
|
|
359
|
-
deletedAt: z4.number().int().nonnegative()
|
|
360
|
-
});
|
|
361
|
-
ScopeTouchRequestSchema = z4.object({
|
|
362
|
-
worktreeId: z4.string().min(1),
|
|
363
|
-
repo: z4.string().min(1)
|
|
364
|
-
});
|
|
365
|
-
ScopeTouchResponseSchema = z4.object({
|
|
366
|
-
touchedAt: z4.number().int().nonnegative()
|
|
367
|
-
});
|
|
368
|
-
HealthResponseSchema = z4.object({
|
|
369
|
-
status: z4.literal("ok"),
|
|
370
|
-
schemaVersion: z4.number().int().positive()
|
|
371
|
-
});
|
|
372
|
-
ErrorEnvelopeSchema = z5.object({
|
|
373
|
-
error: z5.object({
|
|
374
|
-
/** Stable machine-readable code (e.g. `unauthorized`, `validation_failed`). */
|
|
375
|
-
code: z5.string().min(1),
|
|
376
|
-
/** Human-readable message. */
|
|
377
|
-
message: z5.string().min(1),
|
|
378
|
-
/** Source file path when the error originates from parsing a manifest or other on-disk artifact. Set by the CLI side (see `ScanError`) and by the cloud when validating uploaded manifests. */
|
|
379
|
-
file: z5.string().optional(),
|
|
380
|
-
/** Path of the offending field when the error is validation-related. */
|
|
381
|
-
field: z5.string().optional(),
|
|
382
|
-
/** Server-assigned request id for log correlation. */
|
|
383
|
-
requestId: z5.string().optional()
|
|
384
|
-
})
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// src/core/manifest.ts
|
|
390
|
-
var manifestSchema;
|
|
391
|
-
var init_manifest = __esm({
|
|
392
|
-
"src/core/manifest.ts"() {
|
|
393
|
-
"use strict";
|
|
394
|
-
init_dist();
|
|
395
|
-
manifestSchema = BlockManifestSchema;
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// src/core/scanner.ts
|
|
400
|
-
import { createHash } from "crypto";
|
|
401
|
-
import { existsSync as existsSync3 } from "fs";
|
|
402
|
-
import { readFile as readFile2, readdir as readdir2 } from "fs/promises";
|
|
403
|
-
import { dirname as dirname2, join as join3, relative, sep } from "path";
|
|
404
|
-
import { parse as parseYaml2 } from "yaml";
|
|
405
|
-
async function scan(project) {
|
|
406
|
-
const blocks = [];
|
|
407
|
-
const errors = [];
|
|
408
|
-
const ignore = new Set(project.config.ignore);
|
|
409
|
-
const seenIds = /* @__PURE__ */ new Set();
|
|
410
|
-
for (const root of project.config.scanRoots) {
|
|
411
|
-
const start = join3(project.root, root);
|
|
412
|
-
if (!existsSync3(start)) continue;
|
|
413
|
-
for await (const manifestPath of findManifests(start, ignore)) {
|
|
414
|
-
const result = await parseBlock(manifestPath, project.root);
|
|
415
|
-
if (result.kind === "ok") {
|
|
416
|
-
if (seenIds.has(result.block.id)) {
|
|
417
|
-
errors.push({
|
|
418
|
-
file: manifestPath,
|
|
419
|
-
kind: "validation",
|
|
420
|
-
message: `Duplicate block id "${result.block.id}" at ${manifestPath}. Set an explicit \`id:\` field to disambiguate.`,
|
|
421
|
-
field: "id"
|
|
422
|
-
});
|
|
423
|
-
} else {
|
|
424
|
-
seenIds.add(result.block.id);
|
|
425
|
-
blocks.push(result.block);
|
|
426
|
-
}
|
|
427
|
-
} else {
|
|
428
|
-
errors.push(result.error);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
blocks.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
433
|
-
return { blocks, errors };
|
|
434
|
-
}
|
|
435
|
-
async function* findManifests(dir, ignore) {
|
|
436
|
-
let entries;
|
|
437
|
-
try {
|
|
438
|
-
entries = await readdir2(dir, { withFileTypes: true });
|
|
439
|
-
} catch {
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
for (const entry of entries) {
|
|
443
|
-
if (entry.isFile() && entry.name === "macroscope.yaml") {
|
|
444
|
-
yield join3(dir, entry.name);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
for (const entry of entries) {
|
|
448
|
-
if (!entry.isDirectory()) continue;
|
|
449
|
-
if (ignore.has(entry.name)) continue;
|
|
450
|
-
yield* findManifests(join3(dir, entry.name), ignore);
|
|
516
|
+
for (const entry of entries) {
|
|
517
|
+
if (entry.isFile() && entry.name === "macroscope.yaml") {
|
|
518
|
+
yield join5(dir, entry.name);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
if (!entry.isDirectory()) continue;
|
|
523
|
+
if (entry.isSymbolicLink()) continue;
|
|
524
|
+
if (ignore.has(entry.name)) continue;
|
|
525
|
+
yield* findManifests(join5(dir, entry.name), ignore);
|
|
451
526
|
}
|
|
452
527
|
}
|
|
453
528
|
async function parseBlock(manifestPath, projectRoot) {
|
|
454
529
|
let raw;
|
|
455
530
|
try {
|
|
456
|
-
raw = await
|
|
531
|
+
raw = await readFile6(manifestPath, "utf8");
|
|
457
532
|
} catch (err) {
|
|
458
533
|
return {
|
|
459
534
|
kind: "err",
|
|
@@ -491,15 +566,88 @@ async function parseBlock(manifestPath, projectRoot) {
|
|
|
491
566
|
};
|
|
492
567
|
}
|
|
493
568
|
const manifest = parsed.data;
|
|
494
|
-
const blockDir =
|
|
569
|
+
const blockDir = dirname4(manifestPath);
|
|
495
570
|
const defaultId = toPosix(relative(projectRoot, blockDir));
|
|
496
571
|
const id = manifest.id ?? defaultId;
|
|
497
|
-
const contentHash =
|
|
572
|
+
const contentHash = createHash3("sha256").update(raw).digest("hex");
|
|
573
|
+
const fileErrors = [];
|
|
574
|
+
const resolvedPaths = resolveManifestPaths(
|
|
575
|
+
manifest,
|
|
576
|
+
blockDir,
|
|
577
|
+
projectRoot,
|
|
578
|
+
manifestPath,
|
|
579
|
+
fileErrors
|
|
580
|
+
);
|
|
498
581
|
return {
|
|
499
582
|
kind: "ok",
|
|
500
|
-
block: { id, path: blockDir, manifest, contentHash }
|
|
583
|
+
block: { id, path: blockDir, manifest, contentHash, resolvedPaths },
|
|
584
|
+
errors: fileErrors
|
|
501
585
|
};
|
|
502
586
|
}
|
|
587
|
+
function resolveManifestPaths(manifest, blockDir, projectRoot, manifestPath, errors) {
|
|
588
|
+
const testsRaw = manifest.tests;
|
|
589
|
+
const testsArray = testsRaw == null ? void 0 : typeof testsRaw === "string" ? [testsRaw] : testsRaw;
|
|
590
|
+
const hasAnyField = FILE_FIELDS.some((f) => manifest[f] != null) || testsArray != null;
|
|
591
|
+
if (!hasAnyField) return void 0;
|
|
592
|
+
const resolved = {};
|
|
593
|
+
for (const field of FILE_FIELDS) {
|
|
594
|
+
const value = manifest[field];
|
|
595
|
+
if (value == null) continue;
|
|
596
|
+
const abs = resolve2(blockDir, value);
|
|
597
|
+
if (!isInsideProject(abs, projectRoot)) {
|
|
598
|
+
errors.push({
|
|
599
|
+
file: manifestPath,
|
|
600
|
+
kind: "validation",
|
|
601
|
+
field,
|
|
602
|
+
message: `Field \`${field}\` in ${manifestPath} resolves to ${abs}, which is outside the project root ${projectRoot}.`
|
|
603
|
+
});
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (!existsSync3(abs)) {
|
|
607
|
+
errors.push({
|
|
608
|
+
file: manifestPath,
|
|
609
|
+
kind: "io",
|
|
610
|
+
field,
|
|
611
|
+
message: `Field \`${field}\` in ${manifestPath} references ${abs}, which does not exist.`
|
|
612
|
+
});
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
resolved[field] = abs;
|
|
616
|
+
}
|
|
617
|
+
if (testsArray) {
|
|
618
|
+
const resolvedTests = [];
|
|
619
|
+
for (const t of testsArray) {
|
|
620
|
+
const abs = resolve2(blockDir, t);
|
|
621
|
+
if (!isInsideProject(abs, projectRoot)) {
|
|
622
|
+
errors.push({
|
|
623
|
+
file: manifestPath,
|
|
624
|
+
kind: "validation",
|
|
625
|
+
field: "tests",
|
|
626
|
+
message: `Field \`tests\` in ${manifestPath} resolves to ${abs}, which is outside the project root ${projectRoot}.`
|
|
627
|
+
});
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (!existsSync3(abs)) {
|
|
631
|
+
errors.push({
|
|
632
|
+
file: manifestPath,
|
|
633
|
+
kind: "io",
|
|
634
|
+
field: "tests",
|
|
635
|
+
message: `Field \`tests\` in ${manifestPath} references ${abs}, which does not exist.`
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
resolvedTests.push(abs);
|
|
640
|
+
}
|
|
641
|
+
if (resolvedTests.length > 0) {
|
|
642
|
+
resolved.tests = resolvedTests;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return Object.keys(resolved).length > 0 ? resolved : void 0;
|
|
646
|
+
}
|
|
647
|
+
function isInsideProject(absPath, projectRoot) {
|
|
648
|
+
const normalized = resolve2(absPath);
|
|
649
|
+
return normalized === projectRoot || normalized.startsWith(projectRoot + sep);
|
|
650
|
+
}
|
|
503
651
|
function firstZodIssue(err) {
|
|
504
652
|
const issue = err.issues[0];
|
|
505
653
|
if (!issue) return { fieldPath: "(root)", message: "unknown validation error" };
|
|
@@ -509,10 +657,46 @@ function firstZodIssue(err) {
|
|
|
509
657
|
function toPosix(p) {
|
|
510
658
|
return p.split(sep).join("/");
|
|
511
659
|
}
|
|
660
|
+
var FILE_FIELDS;
|
|
512
661
|
var init_scanner = __esm({
|
|
513
662
|
"src/core/scanner.ts"() {
|
|
514
663
|
"use strict";
|
|
515
664
|
init_manifest();
|
|
665
|
+
FILE_FIELDS = ["source", "docs", "preview"];
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// src/util/process-group.ts
|
|
670
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
671
|
+
function spawnDetached(cmd, args, opts = {}, deps = {}) {
|
|
672
|
+
const spawnImpl = deps.spawn ?? nodeSpawn;
|
|
673
|
+
return spawnImpl(cmd, args, { ...opts, detached: true });
|
|
674
|
+
}
|
|
675
|
+
async function killTree(child, deps = {}) {
|
|
676
|
+
const kill = deps.kill ?? ((pid2, sig) => process.kill(pid2, sig));
|
|
677
|
+
const grace = deps.gracePeriodMs ?? DEFAULT_GRACE_MS;
|
|
678
|
+
const pid = child.pid;
|
|
679
|
+
if (!pid) return;
|
|
680
|
+
const safeKill = (sig) => {
|
|
681
|
+
try {
|
|
682
|
+
kill(-pid, sig);
|
|
683
|
+
return true;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
const code = err.code;
|
|
686
|
+
if (code === "ESRCH") return false;
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
if (!safeKill("SIGTERM")) return;
|
|
691
|
+
await new Promise((r) => setTimeout(r, grace));
|
|
692
|
+
if (child.killed) return;
|
|
693
|
+
safeKill("SIGKILL");
|
|
694
|
+
}
|
|
695
|
+
var DEFAULT_GRACE_MS;
|
|
696
|
+
var init_process_group = __esm({
|
|
697
|
+
"src/util/process-group.ts"() {
|
|
698
|
+
"use strict";
|
|
699
|
+
DEFAULT_GRACE_MS = 5e3;
|
|
516
700
|
}
|
|
517
701
|
});
|
|
518
702
|
|
|
@@ -587,7 +771,6 @@ __export(terminal_ws_exports, {
|
|
|
587
771
|
attachTerminalWs: () => attachTerminalWs,
|
|
588
772
|
setSpawnOverride: () => setSpawnOverride
|
|
589
773
|
});
|
|
590
|
-
import { spawn } from "child_process";
|
|
591
774
|
import { WebSocketServer } from "ws";
|
|
592
775
|
function setSpawnOverride(override) {
|
|
593
776
|
spawnOverride = override;
|
|
@@ -600,103 +783,2051 @@ function attachTerminalWs(httpServer, defaultCwd) {
|
|
|
600
783
|
socket.destroy();
|
|
601
784
|
return;
|
|
602
785
|
}
|
|
603
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
604
|
-
handleConnection(ws, req, defaultCwd);
|
|
786
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
787
|
+
handleConnection(ws, req, defaultCwd);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
async function handleConnection(ws, req, defaultCwd) {
|
|
792
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
793
|
+
const blockId = url.searchParams.get("blockId") ?? "";
|
|
794
|
+
const viewKey = url.searchParams.get("viewKey") ?? "";
|
|
795
|
+
let command;
|
|
796
|
+
let cwd;
|
|
797
|
+
if (spawnOverride) {
|
|
798
|
+
command = spawnOverride.command;
|
|
799
|
+
cwd = spawnOverride.cwd ?? defaultCwd;
|
|
800
|
+
} else {
|
|
801
|
+
const resolved = await resolveTerminalCommand(defaultCwd, blockId, viewKey);
|
|
802
|
+
if (!resolved.ok) {
|
|
803
|
+
send(ws, { type: "error", message: resolved.error });
|
|
804
|
+
ws.close();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
command = resolved.command;
|
|
808
|
+
cwd = resolved.cwd;
|
|
809
|
+
}
|
|
810
|
+
if (command.length === 0) {
|
|
811
|
+
send(ws, { type: "error", message: "empty command" });
|
|
812
|
+
ws.close();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
send(ws, { type: "open", command });
|
|
816
|
+
const [bin, ...args] = command;
|
|
817
|
+
let child;
|
|
818
|
+
try {
|
|
819
|
+
child = spawnDetached(bin, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
820
|
+
} catch (err) {
|
|
821
|
+
send(ws, { type: "error", message: `failed to spawn: ${err.message}` });
|
|
822
|
+
ws.close();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
child.stdout?.on("data", (chunk) => send(ws, { type: "out", data: chunk.toString() }));
|
|
826
|
+
child.stderr?.on("data", (chunk) => send(ws, { type: "err", data: chunk.toString() }));
|
|
827
|
+
child.on("exit", (code, signal) => {
|
|
828
|
+
send(ws, { type: "close", code, signal });
|
|
829
|
+
ws.close();
|
|
830
|
+
});
|
|
831
|
+
child.on("error", (err) => {
|
|
832
|
+
send(ws, { type: "error", message: err.message });
|
|
833
|
+
ws.close();
|
|
834
|
+
});
|
|
835
|
+
ws.on("message", (raw) => {
|
|
836
|
+
let frame;
|
|
837
|
+
try {
|
|
838
|
+
frame = JSON.parse(raw.toString());
|
|
839
|
+
} catch {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (frame.type === "in" && typeof frame.data === "string") {
|
|
843
|
+
child.stdin?.write(frame.data);
|
|
844
|
+
} else if (frame.type === "eof") {
|
|
845
|
+
child.stdin?.end();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
ws.on("close", () => {
|
|
849
|
+
if (!child.killed) void killTree(child);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function send(ws, frame) {
|
|
853
|
+
try {
|
|
854
|
+
ws.send(JSON.stringify(frame));
|
|
855
|
+
} catch {
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
var spawnOverride;
|
|
859
|
+
var init_terminal_ws = __esm({
|
|
860
|
+
"src/ui/api/terminal-ws.ts"() {
|
|
861
|
+
"use strict";
|
|
862
|
+
init_process_group();
|
|
863
|
+
init_terminal_resolver();
|
|
864
|
+
spawnOverride = null;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// src/cli.ts
|
|
869
|
+
import { dirname as dirname8, resolve as resolve4 } from "path";
|
|
870
|
+
import { fileURLToPath } from "url";
|
|
871
|
+
import { Command, Option } from "commander";
|
|
872
|
+
import importLocal from "import-local";
|
|
873
|
+
|
|
874
|
+
// src/auth/errors.ts
|
|
875
|
+
var LoginError = class extends Error {
|
|
876
|
+
code;
|
|
877
|
+
constructor(code, message) {
|
|
878
|
+
super(message);
|
|
879
|
+
this.code = code;
|
|
880
|
+
this.name = "LoginError";
|
|
881
|
+
}
|
|
882
|
+
toEnvelope() {
|
|
883
|
+
return { error: { code: this.code, message: this.message } };
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// src/auth/callback-server.ts
|
|
888
|
+
import { createServer } from "http";
|
|
889
|
+
var CALLBACK_PORT = 51337;
|
|
890
|
+
var CALLBACK_PATH = "/callback";
|
|
891
|
+
var REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
892
|
+
function awaitCallback(opts) {
|
|
893
|
+
let settled = false;
|
|
894
|
+
let resolveResult;
|
|
895
|
+
let rejectResult;
|
|
896
|
+
let resolveBound;
|
|
897
|
+
let rejectBound;
|
|
898
|
+
const result = new Promise((res, rej) => {
|
|
899
|
+
resolveResult = res;
|
|
900
|
+
rejectResult = rej;
|
|
901
|
+
});
|
|
902
|
+
const bound = new Promise((res, rej) => {
|
|
903
|
+
resolveBound = res;
|
|
904
|
+
rejectBound = rej;
|
|
905
|
+
});
|
|
906
|
+
const server = createServer((req, res) => {
|
|
907
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${CALLBACK_PORT}`);
|
|
908
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
909
|
+
res.statusCode = 404;
|
|
910
|
+
res.end("not found");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const state = url.searchParams.get("state") ?? "";
|
|
914
|
+
const code = url.searchParams.get("code") ?? "";
|
|
915
|
+
if (state !== opts.expectedState) {
|
|
916
|
+
res.statusCode = 400;
|
|
917
|
+
res.end("state mismatch \u2014 close this tab and rerun `macroscope login`");
|
|
918
|
+
settle(
|
|
919
|
+
() => rejectResult(new LoginError("state_mismatch", "OAuth state parameter did not match"))
|
|
920
|
+
);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (!code) {
|
|
924
|
+
res.statusCode = 400;
|
|
925
|
+
res.end("missing code");
|
|
926
|
+
settle(
|
|
927
|
+
() => rejectResult(new LoginError("token_exchange_failed", "callback missing `code` parameter"))
|
|
928
|
+
);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
res.statusCode = 200;
|
|
932
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
933
|
+
res.end(
|
|
934
|
+
"<!doctype html><meta charset=utf-8><title>macroscope</title><p>Login complete. You can close this tab.</p>"
|
|
935
|
+
);
|
|
936
|
+
settle(() => resolveResult({ code }));
|
|
937
|
+
});
|
|
938
|
+
const closeServer = () => new Promise((r) => {
|
|
939
|
+
const s = server;
|
|
940
|
+
if (typeof s.closeAllConnections === "function") s.closeAllConnections();
|
|
941
|
+
server.close(() => r());
|
|
942
|
+
});
|
|
943
|
+
function settle(fn) {
|
|
944
|
+
if (settled) return;
|
|
945
|
+
settled = true;
|
|
946
|
+
void closeServer().finally(fn);
|
|
947
|
+
}
|
|
948
|
+
server.on("error", (err) => {
|
|
949
|
+
if (err.code === "EADDRINUSE") {
|
|
950
|
+
const e = new LoginError(
|
|
951
|
+
"port_in_use",
|
|
952
|
+
`Port ${CALLBACK_PORT} is in use; close the process holding it, or rerun later`
|
|
953
|
+
);
|
|
954
|
+
rejectBound(e);
|
|
955
|
+
settle(() => rejectResult(e));
|
|
956
|
+
} else {
|
|
957
|
+
rejectBound(err);
|
|
958
|
+
settle(() => rejectResult(err));
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
let timer;
|
|
962
|
+
server.on("listening", () => {
|
|
963
|
+
resolveBound();
|
|
964
|
+
timer = setTimeout(() => {
|
|
965
|
+
settle(
|
|
966
|
+
() => rejectResult(
|
|
967
|
+
new LoginError(
|
|
968
|
+
"callback_timeout",
|
|
969
|
+
`no OAuth callback received within ${opts.timeoutMs}ms`
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
);
|
|
973
|
+
}, opts.timeoutMs);
|
|
974
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
975
|
+
});
|
|
976
|
+
server.listen(CALLBACK_PORT, "127.0.0.1");
|
|
977
|
+
if (opts.signal) {
|
|
978
|
+
const onAbort = () => {
|
|
979
|
+
if (timer) clearTimeout(timer);
|
|
980
|
+
settle(() => rejectResult(new LoginError("login_cancelled", "login aborted")));
|
|
981
|
+
};
|
|
982
|
+
if (opts.signal.aborted) onAbort();
|
|
983
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
984
|
+
}
|
|
985
|
+
bound.catch(() => {
|
|
986
|
+
});
|
|
987
|
+
result.catch(() => {
|
|
988
|
+
});
|
|
989
|
+
return { bound, result };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/auth/credentials.ts
|
|
993
|
+
init_dist();
|
|
994
|
+
import { chmod, stat as fsStat, mkdir, writeFile } from "fs/promises";
|
|
995
|
+
import { homedir } from "os";
|
|
996
|
+
import { dirname, join } from "path";
|
|
997
|
+
function credentialsPath(homeDir = homedir()) {
|
|
998
|
+
return join(homeDir, FILESYSTEM_LAYOUT.user.credentialsFile);
|
|
999
|
+
}
|
|
1000
|
+
async function writeCredentials(creds, opts = {}) {
|
|
1001
|
+
const path2 = credentialsPath(opts.homeDir);
|
|
1002
|
+
await mkdir(dirname(path2), { recursive: true });
|
|
1003
|
+
const statFn = opts._statForTest ?? fsStat;
|
|
1004
|
+
try {
|
|
1005
|
+
const s = await statFn(path2);
|
|
1006
|
+
if (process.platform !== "win32" && typeof process.getuid === "function") {
|
|
1007
|
+
const uid = process.getuid();
|
|
1008
|
+
if (s.uid !== uid) {
|
|
1009
|
+
throw new LoginError(
|
|
1010
|
+
"credentials_write_failed",
|
|
1011
|
+
`${path2} is owned by another user (uid ${s.uid}); refusing to overwrite`
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (err instanceof LoginError) throw err;
|
|
1017
|
+
if (err.code !== "ENOENT") throw err;
|
|
1018
|
+
}
|
|
1019
|
+
const json = `${JSON.stringify(creds, null, 2)}
|
|
1020
|
+
`;
|
|
1021
|
+
await writeFile(path2, json, { mode: 384 });
|
|
1022
|
+
await chmod(path2, 384);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/auth/pkce.ts
|
|
1026
|
+
import { createHash, randomBytes } from "crypto";
|
|
1027
|
+
function generateVerifier() {
|
|
1028
|
+
return base64url(randomBytes(32));
|
|
1029
|
+
}
|
|
1030
|
+
function challengeFromVerifier(verifier) {
|
|
1031
|
+
return base64url(createHash("sha256").update(verifier, "ascii").digest());
|
|
1032
|
+
}
|
|
1033
|
+
function base64url(buf) {
|
|
1034
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/auth/state.ts
|
|
1038
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1039
|
+
function generateState() {
|
|
1040
|
+
return randomBytes2(24).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/auth/token-exchange.ts
|
|
1044
|
+
async function exchangeCodeForToken(provider, opts) {
|
|
1045
|
+
const body = new URLSearchParams({
|
|
1046
|
+
grant_type: "authorization_code",
|
|
1047
|
+
code: opts.code,
|
|
1048
|
+
code_verifier: opts.codeVerifier,
|
|
1049
|
+
redirect_uri: opts.redirectUri,
|
|
1050
|
+
client_id: opts.clientId
|
|
1051
|
+
});
|
|
1052
|
+
const secret = opts.clientSecret ?? provider.clientSecret;
|
|
1053
|
+
if (secret) body.set("client_secret", secret);
|
|
1054
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
1055
|
+
let res;
|
|
1056
|
+
try {
|
|
1057
|
+
res = await fetchFn(provider.tokenUrl, {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: {
|
|
1060
|
+
accept: "application/json",
|
|
1061
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1062
|
+
"user-agent": "macroscope"
|
|
1063
|
+
},
|
|
1064
|
+
body: body.toString()
|
|
1065
|
+
});
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
throw new LoginError(
|
|
1068
|
+
"token_exchange_failed",
|
|
1069
|
+
`network error talking to ${provider.tokenUrl}: ${err.message}`
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
if (!res.ok) {
|
|
1073
|
+
const detail = await res.text().catch(() => "");
|
|
1074
|
+
throw new LoginError(
|
|
1075
|
+
"token_exchange_failed",
|
|
1076
|
+
`${provider.humanName} token endpoint returned ${res.status}: ${detail.slice(0, 200)}`
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const json = await res.json();
|
|
1080
|
+
if (!json.access_token) {
|
|
1081
|
+
throw new LoginError(
|
|
1082
|
+
"token_exchange_failed",
|
|
1083
|
+
`${provider.humanName} token endpoint returned 200 but no access_token`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
accessToken: json.access_token,
|
|
1088
|
+
refreshToken: json.refresh_token,
|
|
1089
|
+
expiresAt: typeof json.expires_in === "number" ? Date.now() + json.expires_in * 1e3 : void 0
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/auth/user-info.ts
|
|
1094
|
+
async function fetchUserInfo(provider, accessToken, fetchFn = fetch) {
|
|
1095
|
+
let res;
|
|
1096
|
+
try {
|
|
1097
|
+
res = await fetchFn(provider.userInfoUrl, {
|
|
1098
|
+
headers: {
|
|
1099
|
+
authorization: `Bearer ${accessToken}`,
|
|
1100
|
+
accept: "application/json",
|
|
1101
|
+
"user-agent": "macroscope"
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
throw new LoginError(
|
|
1106
|
+
"user_info_failed",
|
|
1107
|
+
`network error talking to ${provider.userInfoUrl}: ${err.message}`
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
if (!res.ok) {
|
|
1111
|
+
throw new LoginError(
|
|
1112
|
+
"user_info_failed",
|
|
1113
|
+
`${provider.humanName} user-info endpoint returned ${res.status}`
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return provider.parseProviderUserId(await res.json());
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// src/auth/login.ts
|
|
1120
|
+
async function login(opts) {
|
|
1121
|
+
const { provider, clientId } = opts;
|
|
1122
|
+
const verifier = generateVerifier();
|
|
1123
|
+
const challenge = challengeFromVerifier(verifier);
|
|
1124
|
+
const state = generateState();
|
|
1125
|
+
const authorizeUrl = new URL(provider.authorizeUrl);
|
|
1126
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
1127
|
+
authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
1128
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
1129
|
+
authorizeUrl.searchParams.set("state", state);
|
|
1130
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
1131
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
1132
|
+
if (provider.defaultScopes.length > 0) {
|
|
1133
|
+
authorizeUrl.searchParams.set("scope", provider.defaultScopes.join(" "));
|
|
1134
|
+
}
|
|
1135
|
+
const { bound, result } = awaitCallback({
|
|
1136
|
+
expectedState: state,
|
|
1137
|
+
timeoutMs: opts.timeoutMs ?? 5 * 6e4,
|
|
1138
|
+
signal: opts.signal
|
|
1139
|
+
});
|
|
1140
|
+
try {
|
|
1141
|
+
await bound;
|
|
1142
|
+
} catch {
|
|
1143
|
+
return await result;
|
|
1144
|
+
}
|
|
1145
|
+
if (opts.openBrowser) {
|
|
1146
|
+
await opts.openBrowser(authorizeUrl.toString());
|
|
1147
|
+
} else {
|
|
1148
|
+
const open = (await import("open")).default;
|
|
1149
|
+
await open(authorizeUrl.toString());
|
|
1150
|
+
}
|
|
1151
|
+
const { code } = await result;
|
|
1152
|
+
const token = await exchangeCodeForToken(provider, {
|
|
1153
|
+
code,
|
|
1154
|
+
codeVerifier: verifier,
|
|
1155
|
+
redirectUri: REDIRECT_URI,
|
|
1156
|
+
clientId,
|
|
1157
|
+
clientSecret: opts.clientSecret
|
|
1158
|
+
});
|
|
1159
|
+
const providerUserId = await fetchUserInfo(provider, token.accessToken);
|
|
1160
|
+
const creds = {
|
|
1161
|
+
provider: provider.id,
|
|
1162
|
+
accessToken: token.accessToken,
|
|
1163
|
+
refreshToken: token.refreshToken,
|
|
1164
|
+
expiresAt: token.expiresAt,
|
|
1165
|
+
providerUserId
|
|
1166
|
+
};
|
|
1167
|
+
await writeCredentials(creds, { homeDir: opts.homeDir });
|
|
1168
|
+
return creds;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/auth/providers/github.ts
|
|
1172
|
+
var github = {
|
|
1173
|
+
id: "github",
|
|
1174
|
+
humanName: "GitHub",
|
|
1175
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
1176
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
1177
|
+
userInfoUrl: "https://api.github.com/user",
|
|
1178
|
+
defaultScopes: [],
|
|
1179
|
+
// Hardcoded fallback is intentionally a placeholder — the release build
|
|
1180
|
+
// substitutes the public OAuth-App secret. Env var wins at runtime.
|
|
1181
|
+
clientSecret: process.env.MACROSCOPE_GITHUB_CLIENT_SECRET ?? "REPLACE_AT_BUILD",
|
|
1182
|
+
parseProviderUserId(userInfo) {
|
|
1183
|
+
if (typeof userInfo === "object" && userInfo !== null && "id" in userInfo && (typeof userInfo.id === "number" || typeof userInfo.id === "string")) {
|
|
1184
|
+
return String(userInfo.id);
|
|
1185
|
+
}
|
|
1186
|
+
throw new Error("GitHub /user response missing numeric `id` field");
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
// src/auth/providers/gitlab.ts
|
|
1191
|
+
var gitlab = {
|
|
1192
|
+
id: "gitlab",
|
|
1193
|
+
humanName: "GitLab",
|
|
1194
|
+
authorizeUrl: "https://gitlab.com/oauth/authorize",
|
|
1195
|
+
tokenUrl: "https://gitlab.com/oauth/token",
|
|
1196
|
+
userInfoUrl: "https://gitlab.com/api/v4/user",
|
|
1197
|
+
defaultScopes: ["read_user"],
|
|
1198
|
+
parseProviderUserId(userInfo) {
|
|
1199
|
+
if (typeof userInfo === "object" && userInfo !== null && "id" in userInfo && (typeof userInfo.id === "number" || typeof userInfo.id === "string")) {
|
|
1200
|
+
return String(userInfo.id);
|
|
1201
|
+
}
|
|
1202
|
+
throw new Error("GitLab /api/v4/user response missing `id` field");
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// src/auth/providers/index.ts
|
|
1207
|
+
var REGISTRY = {
|
|
1208
|
+
github,
|
|
1209
|
+
gitlab
|
|
1210
|
+
};
|
|
1211
|
+
function getProvider(id) {
|
|
1212
|
+
const p = REGISTRY[id];
|
|
1213
|
+
if (!p) throw new Error(`unknown provider: ${id}`);
|
|
1214
|
+
return p;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// src/auth/cli.ts
|
|
1218
|
+
var CLIENT_ID_ENV = {
|
|
1219
|
+
github: "MACROSCOPE_GITHUB_CLIENT_ID",
|
|
1220
|
+
gitlab: "MACROSCOPE_GITLAB_CLIENT_ID"
|
|
1221
|
+
};
|
|
1222
|
+
var CLIENT_ID_FALLBACK = {
|
|
1223
|
+
github: "REPLACE_AT_BUILD",
|
|
1224
|
+
gitlab: "REPLACE_AT_BUILD"
|
|
1225
|
+
};
|
|
1226
|
+
async function runLoginCommand(opts) {
|
|
1227
|
+
const provider = getProvider(opts.providerId);
|
|
1228
|
+
const t = opts._testOverrides ?? {};
|
|
1229
|
+
const write = t.write ?? ((s) => void process.stdout.write(s));
|
|
1230
|
+
const clientId = t.clientId ?? process.env[CLIENT_ID_ENV[opts.providerId]] ?? CLIENT_ID_FALLBACK[opts.providerId];
|
|
1231
|
+
const clientSecret = opts.providerId === "github" ? t.clientSecret ?? process.env.MACROSCOPE_GITHUB_CLIENT_SECRET ?? provider.clientSecret : void 0;
|
|
1232
|
+
try {
|
|
1233
|
+
const creds = await login({
|
|
1234
|
+
provider,
|
|
1235
|
+
clientId,
|
|
1236
|
+
clientSecret,
|
|
1237
|
+
homeDir: t.homeDir,
|
|
1238
|
+
openBrowser: t.openBrowser,
|
|
1239
|
+
signal: opts.signal
|
|
1240
|
+
});
|
|
1241
|
+
if (opts.json) {
|
|
1242
|
+
write(
|
|
1243
|
+
`${JSON.stringify({
|
|
1244
|
+
ok: true,
|
|
1245
|
+
provider: creds.provider,
|
|
1246
|
+
providerUserId: creds.providerUserId
|
|
1247
|
+
})}
|
|
1248
|
+
`
|
|
1249
|
+
);
|
|
1250
|
+
} else {
|
|
1251
|
+
write(`Logged in to ${provider.humanName} as ${creds.providerUserId}
|
|
1252
|
+
`);
|
|
1253
|
+
}
|
|
1254
|
+
return 0;
|
|
1255
|
+
} catch (err) {
|
|
1256
|
+
const loginErr = err instanceof LoginError ? err : new LoginError("credentials_write_failed", err.message);
|
|
1257
|
+
if (opts.json) {
|
|
1258
|
+
write(`${JSON.stringify(loginErr.toEnvelope())}
|
|
1259
|
+
`);
|
|
1260
|
+
} else {
|
|
1261
|
+
write(`Error: ${loginErr.message} (${loginErr.code})
|
|
1262
|
+
`);
|
|
1263
|
+
}
|
|
1264
|
+
return 1;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/util/lifecycle.ts
|
|
1269
|
+
var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
1270
|
+
function installLifecycle(opts) {
|
|
1271
|
+
const proc = opts.proc ?? process;
|
|
1272
|
+
const stdin = opts.stdin ?? proc.stdin;
|
|
1273
|
+
const hardTimeoutMs = opts.hardTimeoutMs ?? 5e3;
|
|
1274
|
+
let fired = false;
|
|
1275
|
+
const fire = (reason) => {
|
|
1276
|
+
if (fired) return;
|
|
1277
|
+
fired = true;
|
|
1278
|
+
const hardKill = setTimeout(() => {
|
|
1279
|
+
proc.exit(1);
|
|
1280
|
+
}, hardTimeoutMs);
|
|
1281
|
+
if (typeof hardKill.unref === "function") hardKill.unref();
|
|
1282
|
+
Promise.resolve(opts.onShutdown(reason)).finally(() => {
|
|
1283
|
+
clearTimeout(hardKill);
|
|
1284
|
+
});
|
|
1285
|
+
};
|
|
1286
|
+
const signalHandlers = /* @__PURE__ */ new Map();
|
|
1287
|
+
for (const sig of SIGNALS) {
|
|
1288
|
+
const h = () => fire(sig);
|
|
1289
|
+
signalHandlers.set(sig, h);
|
|
1290
|
+
proc.on(sig, h);
|
|
1291
|
+
}
|
|
1292
|
+
const onUncaught = (_err) => fire("uncaughtException");
|
|
1293
|
+
const onUnhandled = (_err) => fire("unhandledRejection");
|
|
1294
|
+
proc.on("uncaughtException", onUncaught);
|
|
1295
|
+
proc.on("unhandledRejection", onUnhandled);
|
|
1296
|
+
const stdinEnd = () => fire("stdin-eof");
|
|
1297
|
+
if (opts.watchStdin) {
|
|
1298
|
+
stdin.resume();
|
|
1299
|
+
stdin.on("end", stdinEnd);
|
|
1300
|
+
stdin.on("close", stdinEnd);
|
|
1301
|
+
}
|
|
1302
|
+
return {
|
|
1303
|
+
uninstall: () => {
|
|
1304
|
+
for (const [sig, h] of signalHandlers) proc.off(sig, h);
|
|
1305
|
+
proc.off("uncaughtException", onUncaught);
|
|
1306
|
+
proc.off("unhandledRejection", onUnhandled);
|
|
1307
|
+
if (opts.watchStdin) {
|
|
1308
|
+
stdin.off("end", stdinEnd);
|
|
1309
|
+
stdin.off("close", stdinEnd);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/commands/login.ts
|
|
1316
|
+
async function runLogin(opts) {
|
|
1317
|
+
const runLoginImpl = opts.runLoginImpl ?? runLoginCommand;
|
|
1318
|
+
const installLifecycleImpl = opts.installLifecycleImpl ?? installLifecycle;
|
|
1319
|
+
const ac = new AbortController();
|
|
1320
|
+
const lifecycle = installLifecycleImpl({
|
|
1321
|
+
watchStdin: true,
|
|
1322
|
+
onShutdown: () => {
|
|
1323
|
+
ac.abort();
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
try {
|
|
1327
|
+
return await runLoginImpl({
|
|
1328
|
+
providerId: opts.providerId,
|
|
1329
|
+
json: opts.json,
|
|
1330
|
+
signal: ac.signal
|
|
1331
|
+
});
|
|
1332
|
+
} finally {
|
|
1333
|
+
lifecycle.uninstall();
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/commands/logout.ts
|
|
1338
|
+
import { stat as fsStat2, unlink } from "fs/promises";
|
|
1339
|
+
async function runLogout(opts) {
|
|
1340
|
+
const write = opts.write ?? ((s) => void process.stdout.write(s));
|
|
1341
|
+
const statFn = opts._statForTest ?? fsStat2;
|
|
1342
|
+
const path2 = credentialsPath(opts.homeDir);
|
|
1343
|
+
try {
|
|
1344
|
+
if (process.platform !== "win32" && typeof process.getuid === "function") {
|
|
1345
|
+
const s = await statFn(path2);
|
|
1346
|
+
const uid = process.getuid();
|
|
1347
|
+
if (s.uid !== uid) {
|
|
1348
|
+
return fail(
|
|
1349
|
+
write,
|
|
1350
|
+
opts.json,
|
|
1351
|
+
"credentials_foreign_owner",
|
|
1352
|
+
`${path2} is owned by another user (uid ${s.uid}); refusing to delete`
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
await unlink(path2);
|
|
1357
|
+
if (opts.json) {
|
|
1358
|
+
write(`${JSON.stringify({ ok: true, deleted: path2 })}
|
|
1359
|
+
`);
|
|
1360
|
+
} else {
|
|
1361
|
+
write("Logged out\n");
|
|
1362
|
+
}
|
|
1363
|
+
return 0;
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
const e = err;
|
|
1366
|
+
if (e.code === "ENOENT") {
|
|
1367
|
+
if (opts.json) {
|
|
1368
|
+
write(`${JSON.stringify({ ok: true, alreadyLoggedOut: true })}
|
|
1369
|
+
`);
|
|
1370
|
+
} else {
|
|
1371
|
+
write("Already logged out\n");
|
|
1372
|
+
}
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
return fail(write, opts.json, "logout_failed", e.message);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
function fail(write, json, code, message) {
|
|
1379
|
+
if (json) {
|
|
1380
|
+
const envelope = { error: { code, message } };
|
|
1381
|
+
write(`${JSON.stringify(envelope)}
|
|
1382
|
+
`);
|
|
1383
|
+
} else {
|
|
1384
|
+
write(`Error: ${message}
|
|
1385
|
+
`);
|
|
1386
|
+
}
|
|
1387
|
+
return 1;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/cloud/client.ts
|
|
1391
|
+
init_dist();
|
|
1392
|
+
import { readFile, stat } from "fs/promises";
|
|
1393
|
+
import { homedir as homedir2 } from "os";
|
|
1394
|
+
import path from "path";
|
|
1395
|
+
var DEFAULT_BASE_URL = "http://localhost:3001";
|
|
1396
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
1397
|
+
var MAX_RETRIES = 3;
|
|
1398
|
+
var BASE_BACKOFF_MS = 500;
|
|
1399
|
+
var JITTER_FRACTION = 0.25;
|
|
1400
|
+
var isRetryableStatus = (status) => status === 429 || status >= 500 && status <= 599;
|
|
1401
|
+
var computeBackoffMs = (attempt) => {
|
|
1402
|
+
const exp = BASE_BACKOFF_MS * 2 ** attempt;
|
|
1403
|
+
const jitter = exp * JITTER_FRACTION * (Math.random() * 2 - 1);
|
|
1404
|
+
return Math.max(0, Math.round(exp + jitter));
|
|
1405
|
+
};
|
|
1406
|
+
var sleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
1407
|
+
function parseRetryAfterMs(header) {
|
|
1408
|
+
if (!header) return null;
|
|
1409
|
+
const trimmed = header.trim();
|
|
1410
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1411
|
+
return Number(trimmed) * 1e3;
|
|
1412
|
+
}
|
|
1413
|
+
const ts2 = Date.parse(trimmed);
|
|
1414
|
+
if (Number.isNaN(ts2)) return null;
|
|
1415
|
+
return Math.max(0, ts2 - Date.now());
|
|
1416
|
+
}
|
|
1417
|
+
var notLoggedIn = (message) => ({
|
|
1418
|
+
kind: "error",
|
|
1419
|
+
status: 0,
|
|
1420
|
+
envelope: { error: { code: "not_logged_in", message } }
|
|
1421
|
+
});
|
|
1422
|
+
var unexpectedResponse = (status, statusText) => ({
|
|
1423
|
+
kind: "error",
|
|
1424
|
+
status,
|
|
1425
|
+
envelope: {
|
|
1426
|
+
error: {
|
|
1427
|
+
code: "unexpected_response",
|
|
1428
|
+
message: statusText || `HTTP ${status}`
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
var invalidResponse = (status) => ({
|
|
1433
|
+
kind: "error",
|
|
1434
|
+
status,
|
|
1435
|
+
envelope: {
|
|
1436
|
+
error: {
|
|
1437
|
+
code: "invalid_response",
|
|
1438
|
+
message: "Server returned a body that did not match the expected schema"
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
var resolveCredentialsPath = (override) => override ?? path.join(homedir2(), FILESYSTEM_LAYOUT.user.credentialsFile);
|
|
1443
|
+
var resolveBaseUrl = (override) => override ?? process.env.MACROSCOPE_CLOUD_URL ?? DEFAULT_BASE_URL;
|
|
1444
|
+
async function readAccessToken(credPath) {
|
|
1445
|
+
let raw;
|
|
1446
|
+
try {
|
|
1447
|
+
raw = await readFile(credPath, "utf8");
|
|
1448
|
+
} catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
let parsed;
|
|
1452
|
+
try {
|
|
1453
|
+
parsed = JSON.parse(raw);
|
|
1454
|
+
} catch {
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
const result = OAuthCredentialsSchema.safeParse(parsed);
|
|
1458
|
+
if (!result.success) return null;
|
|
1459
|
+
return result.data.accessToken;
|
|
1460
|
+
}
|
|
1461
|
+
async function parseErrorBody(res) {
|
|
1462
|
+
let body;
|
|
1463
|
+
try {
|
|
1464
|
+
body = await res.json();
|
|
1465
|
+
} catch {
|
|
1466
|
+
return unexpectedResponse(res.status, res.statusText);
|
|
1467
|
+
}
|
|
1468
|
+
const envelope = ErrorEnvelopeSchema.safeParse(body);
|
|
1469
|
+
if (!envelope.success) {
|
|
1470
|
+
return unexpectedResponse(res.status, res.statusText);
|
|
1471
|
+
}
|
|
1472
|
+
return { kind: "error", status: res.status, envelope: envelope.data };
|
|
1473
|
+
}
|
|
1474
|
+
function clientValidationError(issue, fallbackMessage) {
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "error",
|
|
1477
|
+
status: 0,
|
|
1478
|
+
envelope: {
|
|
1479
|
+
error: {
|
|
1480
|
+
code: "validation_failed",
|
|
1481
|
+
message: issue?.message ?? fallbackMessage,
|
|
1482
|
+
field: issue?.path.join(".") || void 0
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
function serializeRequest(method, data) {
|
|
1488
|
+
if (data === null) return {};
|
|
1489
|
+
if (method === "GET") {
|
|
1490
|
+
const params = new URLSearchParams();
|
|
1491
|
+
for (const [k, v] of Object.entries(data)) {
|
|
1492
|
+
if (v === void 0) continue;
|
|
1493
|
+
params.append(k, String(v));
|
|
1494
|
+
}
|
|
1495
|
+
const qs = params.toString();
|
|
1496
|
+
return qs ? { query: `?${qs}` } : {};
|
|
1497
|
+
}
|
|
1498
|
+
return { body: JSON.stringify(data) };
|
|
1499
|
+
}
|
|
1500
|
+
async function parseOkBody(res, schema) {
|
|
1501
|
+
let body;
|
|
1502
|
+
try {
|
|
1503
|
+
body = await res.json();
|
|
1504
|
+
} catch {
|
|
1505
|
+
return invalidResponse(res.status);
|
|
1506
|
+
}
|
|
1507
|
+
const parsed = schema.safeParse(body);
|
|
1508
|
+
if (!parsed.success) {
|
|
1509
|
+
return invalidResponse(res.status);
|
|
1510
|
+
}
|
|
1511
|
+
return { kind: "ok", data: parsed.data };
|
|
1512
|
+
}
|
|
1513
|
+
function createCloudClient(opts = {}) {
|
|
1514
|
+
const credPath = resolveCredentialsPath(opts.credentialsPath);
|
|
1515
|
+
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
1516
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
1517
|
+
let cached = null;
|
|
1518
|
+
async function getAccessToken() {
|
|
1519
|
+
let mtimeMs;
|
|
1520
|
+
try {
|
|
1521
|
+
mtimeMs = (await stat(credPath)).mtimeMs;
|
|
1522
|
+
} catch {
|
|
1523
|
+
cached = null;
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
1527
|
+
return cached.token;
|
|
1528
|
+
}
|
|
1529
|
+
const token = await readAccessToken(credPath);
|
|
1530
|
+
cached = token ? { mtimeMs, token } : null;
|
|
1531
|
+
return token;
|
|
1532
|
+
}
|
|
1533
|
+
async function executeRequest(endpoint, schema, init) {
|
|
1534
|
+
const token = await getAccessToken();
|
|
1535
|
+
if (!token) {
|
|
1536
|
+
return notLoggedIn(`No valid credentials at ${credPath}. Run \`macroscope login\`.`);
|
|
1537
|
+
}
|
|
1538
|
+
const url = `${baseUrl}${endpoint.path}${init.query ?? ""}`;
|
|
1539
|
+
const headers = {
|
|
1540
|
+
Authorization: `Bearer ${token}`
|
|
1541
|
+
};
|
|
1542
|
+
if (init.body !== void 0) {
|
|
1543
|
+
headers["Content-Type"] = "application/json";
|
|
1544
|
+
}
|
|
1545
|
+
let lastResponse = null;
|
|
1546
|
+
let lastNetworkError = null;
|
|
1547
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
|
1548
|
+
let res;
|
|
1549
|
+
try {
|
|
1550
|
+
res = await fetchImpl(url, {
|
|
1551
|
+
method: endpoint.method,
|
|
1552
|
+
headers,
|
|
1553
|
+
body: init.body,
|
|
1554
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
1555
|
+
});
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
lastNetworkError = err;
|
|
1558
|
+
if (attempt === MAX_RETRIES) break;
|
|
1559
|
+
await sleep(computeBackoffMs(attempt));
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
if (!isRetryableStatus(res.status)) {
|
|
1563
|
+
if (res.status >= 200 && res.status < 300) {
|
|
1564
|
+
return parseOkBody(res, schema);
|
|
1565
|
+
}
|
|
1566
|
+
return parseErrorBody(res);
|
|
1567
|
+
}
|
|
1568
|
+
lastResponse = res;
|
|
1569
|
+
lastNetworkError = null;
|
|
1570
|
+
if (attempt === MAX_RETRIES) break;
|
|
1571
|
+
const retryAfterMs = res.status === 429 ? parseRetryAfterMs(res.headers.get("retry-after")) : null;
|
|
1572
|
+
const waitMs = retryAfterMs ?? computeBackoffMs(attempt);
|
|
1573
|
+
await sleep(waitMs);
|
|
1574
|
+
}
|
|
1575
|
+
if (lastResponse) {
|
|
1576
|
+
return parseErrorBody(lastResponse);
|
|
1577
|
+
}
|
|
1578
|
+
return {
|
|
1579
|
+
kind: "error",
|
|
1580
|
+
status: 0,
|
|
1581
|
+
envelope: {
|
|
1582
|
+
error: {
|
|
1583
|
+
code: "upstream_unreachable",
|
|
1584
|
+
message: lastNetworkError instanceof Error ? lastNetworkError.message : "Network request failed"
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
health: () => executeRequest(API_ENDPOINTS.health, HealthResponseSchema, serializeRequest("GET", null)),
|
|
1591
|
+
search: (req) => {
|
|
1592
|
+
const parsed = SearchRequestSchema.safeParse(req);
|
|
1593
|
+
if (!parsed.success) {
|
|
1594
|
+
return Promise.resolve(
|
|
1595
|
+
clientValidationError(
|
|
1596
|
+
parsed.error.issues[0],
|
|
1597
|
+
"Invalid search request"
|
|
1598
|
+
)
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
return executeRequest(
|
|
1602
|
+
API_ENDPOINTS.search,
|
|
1603
|
+
SearchResponseSchema,
|
|
1604
|
+
serializeRequest("GET", parsed.data)
|
|
1605
|
+
);
|
|
1606
|
+
},
|
|
1607
|
+
indexBatch: (req) => {
|
|
1608
|
+
const parsed = IndexBatchRequestSchema.safeParse(req);
|
|
1609
|
+
if (!parsed.success) {
|
|
1610
|
+
return Promise.resolve(
|
|
1611
|
+
clientValidationError(
|
|
1612
|
+
parsed.error.issues[0],
|
|
1613
|
+
"Invalid index batch request"
|
|
1614
|
+
)
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
return executeRequest(
|
|
1618
|
+
API_ENDPOINTS.indexBatch,
|
|
1619
|
+
IndexBatchResponseSchema,
|
|
1620
|
+
serializeRequest("POST", parsed.data)
|
|
1621
|
+
);
|
|
1622
|
+
},
|
|
1623
|
+
scopeTouch: (req) => {
|
|
1624
|
+
const parsed = ScopeTouchRequestSchema.safeParse(req);
|
|
1625
|
+
if (!parsed.success) {
|
|
1626
|
+
return Promise.resolve(
|
|
1627
|
+
clientValidationError(
|
|
1628
|
+
parsed.error.issues[0],
|
|
1629
|
+
"Invalid scope touch request"
|
|
1630
|
+
)
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
return executeRequest(
|
|
1634
|
+
API_ENDPOINTS.scopeTouch,
|
|
1635
|
+
ScopeTouchResponseSchema,
|
|
1636
|
+
serializeRequest("POST", parsed.data)
|
|
1637
|
+
);
|
|
1638
|
+
},
|
|
1639
|
+
deleteIndex: () => executeRequest(
|
|
1640
|
+
API_ENDPOINTS.deleteIndex,
|
|
1641
|
+
DeleteIndexResponseSchema,
|
|
1642
|
+
serializeRequest("DELETE", null)
|
|
1643
|
+
)
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/commands/search.ts
|
|
1648
|
+
init_project();
|
|
1649
|
+
|
|
1650
|
+
// src/local/code-search.ts
|
|
1651
|
+
init_dist();
|
|
1652
|
+
import { execFile as execFileCb } from "child_process";
|
|
1653
|
+
import { promisify } from "util";
|
|
1654
|
+
var execFile = promisify(execFileCb);
|
|
1655
|
+
var DEFAULT_LIMIT = 100;
|
|
1656
|
+
var MAX_STDOUT_BYTES = 32 * 1024 * 1024;
|
|
1657
|
+
async function codeSearch(input) {
|
|
1658
|
+
const limit = input.limit ?? DEFAULT_LIMIT;
|
|
1659
|
+
const args = [
|
|
1660
|
+
"grep",
|
|
1661
|
+
"--no-color",
|
|
1662
|
+
"--line-number",
|
|
1663
|
+
"--column",
|
|
1664
|
+
"--null",
|
|
1665
|
+
"--ignore-case",
|
|
1666
|
+
"--",
|
|
1667
|
+
input.query
|
|
1668
|
+
];
|
|
1669
|
+
let stdout;
|
|
1670
|
+
try {
|
|
1671
|
+
const result = await execFile("git", args, {
|
|
1672
|
+
cwd: input.cwd,
|
|
1673
|
+
maxBuffer: MAX_STDOUT_BYTES,
|
|
1674
|
+
encoding: "utf8"
|
|
1675
|
+
});
|
|
1676
|
+
stdout = result.stdout;
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
const e = err;
|
|
1679
|
+
if (e.code === "ENOENT") {
|
|
1680
|
+
return errorEnvelope("git_unavailable", "git executable not found on PATH");
|
|
1681
|
+
}
|
|
1682
|
+
const stderr = e.stderr ?? "";
|
|
1683
|
+
const exitCode = typeof e.code === "number" ? e.code : Number.NaN;
|
|
1684
|
+
if (/not a git repository/i.test(stderr)) {
|
|
1685
|
+
return errorEnvelope("not_a_git_repo", `${input.cwd} is not a git repository`);
|
|
1686
|
+
}
|
|
1687
|
+
if (exitCode === 1) {
|
|
1688
|
+
return { kind: "ok", matches: [] };
|
|
1689
|
+
}
|
|
1690
|
+
const firstLine = stderr.split("\n", 1)[0]?.trim() || "git grep failed";
|
|
1691
|
+
return errorEnvelope("git_grep_failed", firstLine);
|
|
1692
|
+
}
|
|
1693
|
+
return { kind: "ok", matches: parseGrepOutput(stdout, limit) };
|
|
1694
|
+
}
|
|
1695
|
+
function parseGrepOutput(stdout, limit) {
|
|
1696
|
+
const matches = [];
|
|
1697
|
+
if (!stdout) return matches;
|
|
1698
|
+
const records = stdout.split("\n");
|
|
1699
|
+
for (const record of records) {
|
|
1700
|
+
if (matches.length >= limit) break;
|
|
1701
|
+
if (!record) continue;
|
|
1702
|
+
const fields = record.split("\0");
|
|
1703
|
+
if (fields.length < 4) continue;
|
|
1704
|
+
const [file, lineStr, colStr, ...rest] = fields;
|
|
1705
|
+
const preview = rest.join("\0");
|
|
1706
|
+
const line = Number.parseInt(lineStr, 10);
|
|
1707
|
+
const column = Number.parseInt(colStr, 10);
|
|
1708
|
+
if (!Number.isFinite(line) || line <= 0) continue;
|
|
1709
|
+
const candidate = {
|
|
1710
|
+
file,
|
|
1711
|
+
line,
|
|
1712
|
+
column: Number.isFinite(column) && column > 0 ? column : void 0,
|
|
1713
|
+
preview
|
|
1714
|
+
};
|
|
1715
|
+
const parsed = CodeMatchSchema.safeParse(candidate);
|
|
1716
|
+
if (!parsed.success) continue;
|
|
1717
|
+
matches.push(parsed.data);
|
|
1718
|
+
}
|
|
1719
|
+
return matches;
|
|
1720
|
+
}
|
|
1721
|
+
function errorEnvelope(code, message) {
|
|
1722
|
+
return { kind: "error", envelope: { error: { code, message } } };
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/commands/search.ts
|
|
1726
|
+
var DEFAULT_LIMIT_PER_SIDE = 10;
|
|
1727
|
+
async function runSearch(opts) {
|
|
1728
|
+
const write = opts.write ?? ((s) => void process.stdout.write(s));
|
|
1729
|
+
const codeSearchFn = opts.codeSearchImpl ?? codeSearch;
|
|
1730
|
+
const limit = opts.scope.limit ?? DEFAULT_LIMIT_PER_SIDE;
|
|
1731
|
+
const credPath = credentialsPath(opts.homeDir);
|
|
1732
|
+
const client = opts.client ?? createCloudClient({ credentialsPath: credPath });
|
|
1733
|
+
const notes = [];
|
|
1734
|
+
let projectRoot = null;
|
|
1735
|
+
try {
|
|
1736
|
+
const p = await findProject(opts.cwd ?? process.cwd());
|
|
1737
|
+
projectRoot = p.root;
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
if (err instanceof ProjectNotFoundError) {
|
|
1740
|
+
notes.push("local search unavailable: not in a macroscope project");
|
|
1741
|
+
} else {
|
|
1742
|
+
notes.push(`local search unavailable: ${err.message}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const searchReq = { q: opts.query, limit };
|
|
1746
|
+
if (opts.scope.worktree) searchReq.worktreeId = opts.scope.worktree;
|
|
1747
|
+
if (opts.scope.repo) searchReq.repo = opts.scope.repo;
|
|
1748
|
+
if (opts.scope.kind) searchReq.kind = opts.scope.kind;
|
|
1749
|
+
const [cloudRes, codeRes] = await Promise.allSettled([
|
|
1750
|
+
client.search(searchReq),
|
|
1751
|
+
projectRoot === null ? Promise.resolve({
|
|
1752
|
+
kind: "error",
|
|
1753
|
+
envelope: { error: { code: "not_a_git_repo", message: "no project" } }
|
|
1754
|
+
}) : codeSearchFn({ query: opts.query, cwd: projectRoot, limit })
|
|
1755
|
+
]);
|
|
1756
|
+
let blocks = [];
|
|
1757
|
+
if (cloudRes.status === "fulfilled") {
|
|
1758
|
+
const r = cloudRes.value;
|
|
1759
|
+
if (r.kind === "ok") {
|
|
1760
|
+
blocks = r.data.hits;
|
|
1761
|
+
} else if (r.envelope.error.code === "not_logged_in") {
|
|
1762
|
+
notes.push("cloud search unavailable: run `macroscope login` to enable catalogue search");
|
|
1763
|
+
} else {
|
|
1764
|
+
notes.push(`cloud unreachable: ${r.envelope.error.message}`);
|
|
1765
|
+
}
|
|
1766
|
+
} else {
|
|
1767
|
+
notes.push(`cloud unreachable: ${cloudRes.reason.message}`);
|
|
1768
|
+
}
|
|
1769
|
+
let codeMatches = [];
|
|
1770
|
+
if (codeRes.status === "fulfilled" && codeRes.value.kind === "ok") {
|
|
1771
|
+
codeMatches = codeRes.value.matches;
|
|
1772
|
+
} else if (codeRes.status === "fulfilled" && codeRes.value.kind === "error") {
|
|
1773
|
+
if (projectRoot !== null) {
|
|
1774
|
+
notes.push(`local search failed: ${codeRes.value.envelope.error.message}`);
|
|
1775
|
+
}
|
|
1776
|
+
} else if (codeRes.status === "rejected") {
|
|
1777
|
+
notes.push(`local search failed: ${codeRes.reason.message}`);
|
|
1778
|
+
}
|
|
1779
|
+
const cloudFailed = cloudRes.status !== "fulfilled" || cloudRes.value.kind !== "ok";
|
|
1780
|
+
if (projectRoot === null && cloudFailed) {
|
|
1781
|
+
return fail2(write, opts.json, "search_unavailable", notes.join("; ") || "no project, no creds");
|
|
1782
|
+
}
|
|
1783
|
+
const result = { blocks, code: codeMatches, notes };
|
|
1784
|
+
if (opts.json) {
|
|
1785
|
+
write(`${JSON.stringify(result)}
|
|
1786
|
+
`);
|
|
1787
|
+
} else {
|
|
1788
|
+
writeHuman(write, result);
|
|
1789
|
+
}
|
|
1790
|
+
return 0;
|
|
1791
|
+
}
|
|
1792
|
+
function fail2(write, json, code, message) {
|
|
1793
|
+
if (json) {
|
|
1794
|
+
const env = { error: { code, message } };
|
|
1795
|
+
write(`${JSON.stringify(env)}
|
|
1796
|
+
`);
|
|
1797
|
+
} else {
|
|
1798
|
+
write(`Error: ${message}
|
|
1799
|
+
`);
|
|
1800
|
+
}
|
|
1801
|
+
return 2;
|
|
1802
|
+
}
|
|
1803
|
+
function writeHuman(write, r) {
|
|
1804
|
+
write(`Cloud catalogue (${r.blocks.length} hits):
|
|
1805
|
+
`);
|
|
1806
|
+
if (r.blocks.length === 0) {
|
|
1807
|
+
write(" (no results)\n");
|
|
1808
|
+
} else {
|
|
1809
|
+
for (const b of r.blocks) {
|
|
1810
|
+
const name = b.name ?? b.blockId;
|
|
1811
|
+
write(` ${name.padEnd(28)} ${b.kind.padEnd(20)} ${b.path}
|
|
1812
|
+
`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
write(`
|
|
1816
|
+
Code matches (${r.code.length}):
|
|
1817
|
+
`);
|
|
1818
|
+
if (r.code.length === 0) {
|
|
1819
|
+
write(" (no results)\n");
|
|
1820
|
+
} else {
|
|
1821
|
+
for (const m of r.code) {
|
|
1822
|
+
const loc = m.column ? `${m.file}:${m.line}:${m.column}` : `${m.file}:${m.line}`;
|
|
1823
|
+
write(` ${loc} ${m.preview.trim()}
|
|
1824
|
+
`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
for (const note of r.notes) {
|
|
1828
|
+
write(`
|
|
1829
|
+
(${note})
|
|
1830
|
+
`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/commands/status.ts
|
|
1835
|
+
init_dist();
|
|
1836
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1837
|
+
init_project();
|
|
1838
|
+
var DEFAULT_HEALTH_TIMEOUT_MS = 3e3;
|
|
1839
|
+
async function runStatus(opts) {
|
|
1840
|
+
const write = opts.write ?? ((s) => void process.stdout.write(s));
|
|
1841
|
+
const healthTimeoutMs = opts.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
|
|
1842
|
+
const worktree = await resolveWorktree(opts.cwd);
|
|
1843
|
+
const credPath = credentialsPath(opts.homeDir);
|
|
1844
|
+
const client = opts.client ?? createCloudClient({ credentialsPath: credPath });
|
|
1845
|
+
const cloud = await probeCloud(client, healthTimeoutMs);
|
|
1846
|
+
const login2 = await readLogin(credPath);
|
|
1847
|
+
const report = {
|
|
1848
|
+
worktree,
|
|
1849
|
+
cloud,
|
|
1850
|
+
login: login2,
|
|
1851
|
+
lastIndexTime: null,
|
|
1852
|
+
watcher: "not_running"
|
|
1853
|
+
};
|
|
1854
|
+
if (opts.json) {
|
|
1855
|
+
write(`${JSON.stringify(report)}
|
|
1856
|
+
`);
|
|
1857
|
+
} else {
|
|
1858
|
+
writeHuman2(write, report);
|
|
1859
|
+
}
|
|
1860
|
+
return 0;
|
|
1861
|
+
}
|
|
1862
|
+
async function resolveWorktree(cwd) {
|
|
1863
|
+
try {
|
|
1864
|
+
const project = await findProject(cwd ?? process.cwd());
|
|
1865
|
+
return project.root;
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
if (err instanceof ProjectNotFoundError) return null;
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
async function probeCloud(client, timeoutMs) {
|
|
1872
|
+
const result = await Promise.race([
|
|
1873
|
+
client.health().catch(() => null),
|
|
1874
|
+
new Promise((resolve5) => setTimeout(() => resolve5(null), timeoutMs))
|
|
1875
|
+
]);
|
|
1876
|
+
if (result === null) return "unreachable";
|
|
1877
|
+
if (result.kind === "ok") return "reachable";
|
|
1878
|
+
if (result.envelope.error.code === "not_logged_in") return "not_logged_in";
|
|
1879
|
+
return "unreachable";
|
|
1880
|
+
}
|
|
1881
|
+
async function readLogin(credPath) {
|
|
1882
|
+
let raw;
|
|
1883
|
+
try {
|
|
1884
|
+
raw = await readFile3(credPath, "utf8");
|
|
1885
|
+
} catch {
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
let parsed;
|
|
1889
|
+
try {
|
|
1890
|
+
parsed = JSON.parse(raw);
|
|
1891
|
+
} catch {
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
const result = OAuthCredentialsSchema.safeParse(parsed);
|
|
1895
|
+
if (!result.success) return null;
|
|
1896
|
+
return { provider: result.data.provider, providerUserId: result.data.providerUserId };
|
|
1897
|
+
}
|
|
1898
|
+
function writeHuman2(write, r) {
|
|
1899
|
+
const worktree = r.worktree ?? "(not in a macroscope project)";
|
|
1900
|
+
const login2 = r.login ? `${r.login.provider}${r.login.providerUserId ? ` (${r.login.providerUserId})` : ""}` : "not logged in";
|
|
1901
|
+
const lastIndexed = r.lastIndexTime === null ? "never" : new Date(r.lastIndexTime).toISOString();
|
|
1902
|
+
write(`worktree: ${worktree}
|
|
1903
|
+
`);
|
|
1904
|
+
write(`cloud: ${r.cloud}
|
|
1905
|
+
`);
|
|
1906
|
+
write(`login: ${login2}
|
|
1907
|
+
`);
|
|
1908
|
+
write(`last indexed: ${lastIndexed}
|
|
1909
|
+
`);
|
|
1910
|
+
write(`watcher: ${r.watcher}
|
|
1911
|
+
`);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// src/cli.ts
|
|
1915
|
+
init_blueprints();
|
|
1916
|
+
init_project();
|
|
1917
|
+
|
|
1918
|
+
// src/core/version.ts
|
|
1919
|
+
var VERSION = "0.0.0";
|
|
1920
|
+
|
|
1921
|
+
// src/ui/ui-server.ts
|
|
1922
|
+
init_dist();
|
|
1923
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1924
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
1925
|
+
import { createServer as createServer2 } from "http";
|
|
1926
|
+
import { homedir as homedir4 } from "os";
|
|
1927
|
+
import { extname as extname2, join as join9, normalize, resolve as resolve3 } from "path";
|
|
1928
|
+
|
|
1929
|
+
// src/local/catchup.ts
|
|
1930
|
+
init_dist();
|
|
1931
|
+
import { mkdir as mkdir5, readFile as readFile9, rename as rename3, writeFile as writeFile5 } from "fs/promises";
|
|
1932
|
+
import { dirname as dirname7, join as join8, relative as relative3, sep as sep3 } from "path";
|
|
1933
|
+
|
|
1934
|
+
// src/core/extractor.ts
|
|
1935
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1936
|
+
import ts from "typescript";
|
|
1937
|
+
var DEFAULT_README_EXCERPT_MAX = 500;
|
|
1938
|
+
var TS_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts"]);
|
|
1939
|
+
var DEFAULT_COMPILER_OPTIONS = {
|
|
1940
|
+
target: ts.ScriptTarget.ES2022,
|
|
1941
|
+
module: ts.ModuleKind.ESNext,
|
|
1942
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
1943
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
1944
|
+
allowJs: false,
|
|
1945
|
+
noEmit: true,
|
|
1946
|
+
skipLibCheck: true,
|
|
1947
|
+
esModuleInterop: true,
|
|
1948
|
+
allowSyntheticDefaultImports: true,
|
|
1949
|
+
strict: false,
|
|
1950
|
+
isolatedModules: true
|
|
1951
|
+
};
|
|
1952
|
+
function defaultProgramFactory(rootFiles) {
|
|
1953
|
+
return ts.createProgram(rootFiles, DEFAULT_COMPILER_OPTIONS);
|
|
1954
|
+
}
|
|
1955
|
+
async function extractAll(blocks, opts = {}) {
|
|
1956
|
+
const readmeMax = opts.readmeExcerptMax ?? DEFAULT_README_EXCERPT_MAX;
|
|
1957
|
+
const payloads = [];
|
|
1958
|
+
const errors = [];
|
|
1959
|
+
const sourcesToBlocks = /* @__PURE__ */ new Map();
|
|
1960
|
+
for (const block of blocks) {
|
|
1961
|
+
if (!block.manifest?.kind) continue;
|
|
1962
|
+
const src = block.resolvedPaths?.source;
|
|
1963
|
+
if (src && isTsSource(src)) {
|
|
1964
|
+
sourcesToBlocks.set(src, block);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
let program;
|
|
1968
|
+
let checker;
|
|
1969
|
+
if (sourcesToBlocks.size > 0) {
|
|
1970
|
+
const rootFiles = Array.from(sourcesToBlocks.keys());
|
|
1971
|
+
const factory = opts.programFactory ?? defaultProgramFactory;
|
|
1972
|
+
program = factory(rootFiles);
|
|
1973
|
+
checker = program.getTypeChecker();
|
|
1974
|
+
}
|
|
1975
|
+
for (const block of blocks) {
|
|
1976
|
+
if (!block.manifest?.kind) continue;
|
|
1977
|
+
const payload = {
|
|
1978
|
+
blockId: block.id,
|
|
1979
|
+
kind: block.manifest.kind,
|
|
1980
|
+
symbols: []
|
|
1981
|
+
};
|
|
1982
|
+
const { name, description, tags } = block.manifest;
|
|
1983
|
+
if (typeof name === "string") payload.name = name;
|
|
1984
|
+
if (typeof description === "string") payload.description = description;
|
|
1985
|
+
if (Array.isArray(tags)) payload.tags = tags;
|
|
1986
|
+
const sourcePath = block.resolvedPaths?.source;
|
|
1987
|
+
if (sourcePath && isTsSource(sourcePath) && program && checker) {
|
|
1988
|
+
const sourceFile = program.getSourceFile(sourcePath);
|
|
1989
|
+
if (!sourceFile) {
|
|
1990
|
+
errors.push({
|
|
1991
|
+
blockId: block.id,
|
|
1992
|
+
kind: "parse",
|
|
1993
|
+
message: `TS Program did not load ${sourcePath} (parse error or missing file)`
|
|
1994
|
+
});
|
|
1995
|
+
} else if (hasFatalSyntaxErrors(program, sourceFile)) {
|
|
1996
|
+
errors.push({
|
|
1997
|
+
blockId: block.id,
|
|
1998
|
+
kind: "parse",
|
|
1999
|
+
message: `Source file ${sourcePath} has syntax errors; skipping symbol extraction`
|
|
2000
|
+
});
|
|
2001
|
+
} else {
|
|
2002
|
+
payload.symbols = extractSymbols(sourceFile, checker);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
const docsPath = block.resolvedPaths?.docs;
|
|
2006
|
+
if (docsPath) {
|
|
2007
|
+
try {
|
|
2008
|
+
const raw = await readFile4(docsPath, "utf8");
|
|
2009
|
+
payload.readmeExcerpt = formatReadmeExcerpt(raw, readmeMax);
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
errors.push({
|
|
2012
|
+
blockId: block.id,
|
|
2013
|
+
kind: "io",
|
|
2014
|
+
message: `Failed to read docs at ${docsPath}: ${err.message}`
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
payloads.push(payload);
|
|
2019
|
+
}
|
|
2020
|
+
return { payloads, errors };
|
|
2021
|
+
}
|
|
2022
|
+
function isTsSource(path2) {
|
|
2023
|
+
const dot = path2.lastIndexOf(".");
|
|
2024
|
+
if (dot < 0) return false;
|
|
2025
|
+
return TS_EXTENSIONS.has(path2.slice(dot).toLowerCase());
|
|
2026
|
+
}
|
|
2027
|
+
function hasFatalSyntaxErrors(program, sourceFile) {
|
|
2028
|
+
const diagnostics = program.getSyntacticDiagnostics(sourceFile);
|
|
2029
|
+
return diagnostics.length > 0;
|
|
2030
|
+
}
|
|
2031
|
+
function extractSymbols(sourceFile, checker) {
|
|
2032
|
+
const symbols = [];
|
|
2033
|
+
for (const statement of sourceFile.statements) {
|
|
2034
|
+
visitTopLevel(statement, sourceFile, checker, symbols);
|
|
2035
|
+
}
|
|
2036
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2037
|
+
return symbols.filter((s) => {
|
|
2038
|
+
const key = `${s.kind}\0${s.name}`;
|
|
2039
|
+
if (seen.has(key)) return false;
|
|
2040
|
+
seen.add(key);
|
|
2041
|
+
return true;
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
function visitTopLevel(node, sourceFile, checker, out) {
|
|
2045
|
+
if (ts.isExportAssignment(node)) {
|
|
2046
|
+
out.push(extractDefaultExport(node, sourceFile, checker));
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
if (ts.isExportDeclaration(node)) {
|
|
2050
|
+
extractExportDeclaration(node, sourceFile, checker, out);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (!hasExportModifier(node)) return;
|
|
2054
|
+
const isDefault = hasDefaultModifier(node);
|
|
2055
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
2056
|
+
const name = node.name?.text ?? (isDefault ? "default" : void 0);
|
|
2057
|
+
if (!name) return;
|
|
2058
|
+
out.push({
|
|
2059
|
+
name,
|
|
2060
|
+
kind: "function",
|
|
2061
|
+
signature: renderFunctionSignature(node, name, sourceFile, checker)
|
|
2062
|
+
});
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (ts.isClassDeclaration(node)) {
|
|
2066
|
+
const name = node.name?.text ?? (isDefault ? "default" : void 0);
|
|
2067
|
+
if (!name) return;
|
|
2068
|
+
out.push({ name, kind: "class", signature: `class ${name}` });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (ts.isInterfaceDeclaration(node)) {
|
|
2072
|
+
out.push({
|
|
2073
|
+
name: node.name.text,
|
|
2074
|
+
kind: "interface",
|
|
2075
|
+
signature: oneLineDeclaration(node, sourceFile, `interface ${node.name.text}`)
|
|
2076
|
+
});
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
if (ts.isTypeAliasDeclaration(node)) {
|
|
2080
|
+
out.push({
|
|
2081
|
+
name: node.name.text,
|
|
2082
|
+
kind: "type",
|
|
2083
|
+
signature: oneLineDeclaration(node, sourceFile, `type ${node.name.text}`)
|
|
2084
|
+
});
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (ts.isEnumDeclaration(node)) {
|
|
2088
|
+
out.push({ name: node.name.text, kind: "enum", signature: `enum ${node.name.text}` });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (ts.isVariableStatement(node)) {
|
|
2092
|
+
for (const decl of node.declarationList.declarations) {
|
|
2093
|
+
if (!ts.isIdentifier(decl.name)) continue;
|
|
2094
|
+
const name = decl.name.text;
|
|
2095
|
+
out.push({
|
|
2096
|
+
name,
|
|
2097
|
+
kind: "const",
|
|
2098
|
+
signature: renderVariableSignature(decl, name, checker)
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
function extractDefaultExport(node, sourceFile, checker) {
|
|
2105
|
+
const expr = node.expression;
|
|
2106
|
+
if (ts.isIdentifier(expr)) {
|
|
2107
|
+
const sym = checker.getSymbolAtLocation(expr);
|
|
2108
|
+
const type = sym ? checker.getTypeOfSymbolAtLocation(sym, expr) : void 0;
|
|
2109
|
+
const signature = type ? checker.typeToString(type) : "default";
|
|
2110
|
+
return { name: "default", kind: "const", signature };
|
|
2111
|
+
}
|
|
2112
|
+
return { name: "default", kind: "const", signature: expr.getText(sourceFile) };
|
|
2113
|
+
}
|
|
2114
|
+
function extractExportDeclaration(node, sourceFile, checker, out) {
|
|
2115
|
+
if (!node.exportClause && node.moduleSpecifier) {
|
|
2116
|
+
const moduleSymbol = checker.getSymbolAtLocation(node.moduleSpecifier);
|
|
2117
|
+
if (moduleSymbol) {
|
|
2118
|
+
for (const exp of checker.getExportsOfModule(moduleSymbol)) {
|
|
2119
|
+
out.push(renderExportedSymbol(exp.name, exp, checker, sourceFile));
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
2125
|
+
for (const spec of node.exportClause.elements) {
|
|
2126
|
+
const name = spec.name.text;
|
|
2127
|
+
const sym = checker.getSymbolAtLocation(spec.name);
|
|
2128
|
+
out.push(renderExportedSymbol(name, sym, checker, sourceFile));
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
function renderExportedSymbol(name, symbol, checker, sourceFile) {
|
|
2133
|
+
if (!symbol) return { name, kind: "const", signature: name };
|
|
2134
|
+
const aliased = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
|
|
2135
|
+
const decl = aliased.declarations?.[0];
|
|
2136
|
+
if (decl) {
|
|
2137
|
+
if (ts.isFunctionDeclaration(decl) || ts.isMethodDeclaration(decl)) {
|
|
2138
|
+
return {
|
|
2139
|
+
name,
|
|
2140
|
+
kind: "function",
|
|
2141
|
+
signature: renderFunctionSignature(decl, name, sourceFile, checker)
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
if (ts.isClassDeclaration(decl)) return { name, kind: "class", signature: `class ${name}` };
|
|
2145
|
+
if (ts.isInterfaceDeclaration(decl)) {
|
|
2146
|
+
return {
|
|
2147
|
+
name,
|
|
2148
|
+
kind: "interface",
|
|
2149
|
+
signature: oneLineDeclaration(decl, decl.getSourceFile(), `interface ${name}`)
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
if (ts.isTypeAliasDeclaration(decl)) {
|
|
2153
|
+
return {
|
|
2154
|
+
name,
|
|
2155
|
+
kind: "type",
|
|
2156
|
+
signature: oneLineDeclaration(decl, decl.getSourceFile(), `type ${name}`)
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
if (ts.isEnumDeclaration(decl)) return { name, kind: "enum", signature: `enum ${name}` };
|
|
2160
|
+
}
|
|
2161
|
+
const type = checker.getTypeOfSymbolAtLocation(aliased, sourceFile);
|
|
2162
|
+
return { name, kind: "const", signature: checker.typeToString(type) };
|
|
2163
|
+
}
|
|
2164
|
+
function renderFunctionSignature(node, name, _sourceFile, checker) {
|
|
2165
|
+
const signature = checker.getSignatureFromDeclaration(node);
|
|
2166
|
+
if (!signature) return `function ${name}()`;
|
|
2167
|
+
return `function ${name}${checker.signatureToString(signature)}`;
|
|
2168
|
+
}
|
|
2169
|
+
function renderVariableSignature(decl, name, checker) {
|
|
2170
|
+
const type = checker.getTypeAtLocation(decl);
|
|
2171
|
+
return `const ${name}: ${checker.typeToString(type)}`;
|
|
2172
|
+
}
|
|
2173
|
+
function oneLineDeclaration(node, sourceFile, fallback) {
|
|
2174
|
+
try {
|
|
2175
|
+
const text = node.getText(sourceFile);
|
|
2176
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
2177
|
+
return collapsed.length > 0 ? collapsed : fallback;
|
|
2178
|
+
} catch {
|
|
2179
|
+
return fallback;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function hasExportModifier(node) {
|
|
2183
|
+
return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0;
|
|
2184
|
+
}
|
|
2185
|
+
function hasDefaultModifier(node) {
|
|
2186
|
+
return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Default) !== 0;
|
|
2187
|
+
}
|
|
2188
|
+
function formatReadmeExcerpt(raw, max) {
|
|
2189
|
+
const stripped = raw.replace(/[#*`_~>]/g, "");
|
|
2190
|
+
const collapsed = stripped.replace(/\s+/g, " ").trim();
|
|
2191
|
+
if (collapsed.length <= max) return collapsed;
|
|
2192
|
+
const slice = collapsed.slice(0, max);
|
|
2193
|
+
const nextChar = collapsed.charAt(max);
|
|
2194
|
+
if (nextChar === "" || /\s/.test(nextChar)) return slice;
|
|
2195
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
2196
|
+
if (lastSpace > 0) return slice.slice(0, lastSpace);
|
|
2197
|
+
return slice;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/core/hasher.ts
|
|
2201
|
+
init_dist();
|
|
2202
|
+
import { createHash as createHash2 } from "crypto";
|
|
2203
|
+
import { mkdir as mkdir2, readFile as readFile5, rename, stat as stat2, writeFile as writeFile2 } from "fs/promises";
|
|
2204
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
2205
|
+
function canonicalJSON(value) {
|
|
2206
|
+
return stringify(value);
|
|
2207
|
+
}
|
|
2208
|
+
function stringify(value) {
|
|
2209
|
+
if (value === null) return "null";
|
|
2210
|
+
if (typeof value === "string") return JSON.stringify(value.normalize("NFC"));
|
|
2211
|
+
if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
|
|
2212
|
+
if (Array.isArray(value)) {
|
|
2213
|
+
const parts = value.map((v) => v === void 0 ? "null" : stringify(v));
|
|
2214
|
+
return `[${parts.join(",")}]`;
|
|
2215
|
+
}
|
|
2216
|
+
if (typeof value === "object") {
|
|
2217
|
+
const obj = value;
|
|
2218
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
|
|
2219
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${stringify(obj[k])}`);
|
|
2220
|
+
return `{${parts.join(",")}}`;
|
|
2221
|
+
}
|
|
2222
|
+
throw new TypeError(`canonicalJSON: unsupported value type ${typeof value}`);
|
|
2223
|
+
}
|
|
2224
|
+
function hashPayload(payload) {
|
|
2225
|
+
const canonical = canonicalJSON(payload);
|
|
2226
|
+
return createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
2227
|
+
}
|
|
2228
|
+
function resolveHashesPath(opts) {
|
|
2229
|
+
const root = opts?.workspaceRoot ?? process.cwd();
|
|
2230
|
+
return join4(root, FILESYSTEM_LAYOUT.project.hashesFile);
|
|
2231
|
+
}
|
|
2232
|
+
async function readCacheFile(opts) {
|
|
2233
|
+
const path2 = resolveHashesPath(opts);
|
|
2234
|
+
let raw;
|
|
2235
|
+
try {
|
|
2236
|
+
raw = await readFile5(path2, "utf8");
|
|
2237
|
+
} catch (err) {
|
|
2238
|
+
const code = err.code;
|
|
2239
|
+
if (code !== "ENOENT") {
|
|
2240
|
+
console.warn(`[macroscope] failed to read hash cache at ${path2}: ${err.message}`);
|
|
2241
|
+
}
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
let parsed;
|
|
2245
|
+
try {
|
|
2246
|
+
parsed = JSON.parse(raw);
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
console.warn(`[macroscope] hash cache at ${path2} is not valid JSON: ${err.message}`);
|
|
2249
|
+
return null;
|
|
2250
|
+
}
|
|
2251
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2252
|
+
console.warn(`[macroscope] hash cache at ${path2} has unexpected shape; ignoring`);
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
const obj = parsed;
|
|
2256
|
+
if (typeof obj.schemaVersion === "number" && obj.entries && typeof obj.entries === "object" && !Array.isArray(obj.entries)) {
|
|
2257
|
+
return { schemaVersion: obj.schemaVersion, entries: obj.entries };
|
|
2258
|
+
}
|
|
2259
|
+
return { schemaVersion: void 0, entries: obj };
|
|
2260
|
+
}
|
|
2261
|
+
async function loadHashCache(opts) {
|
|
2262
|
+
const file = await readCacheFile(opts);
|
|
2263
|
+
return file?.entries ?? {};
|
|
2264
|
+
}
|
|
2265
|
+
async function readSchemaVersion(opts) {
|
|
2266
|
+
const file = await readCacheFile(opts);
|
|
2267
|
+
return file?.schemaVersion;
|
|
2268
|
+
}
|
|
2269
|
+
async function saveHashCache(cache, opts) {
|
|
2270
|
+
const path2 = resolveHashesPath(opts);
|
|
2271
|
+
const dir = dirname3(path2);
|
|
2272
|
+
await mkdir2(dir, { recursive: true });
|
|
2273
|
+
const tmp = `${path2}.tmp`;
|
|
2274
|
+
const envelope = {
|
|
2275
|
+
schemaVersion: opts?.schemaVersion ?? SCHEMA_VERSION,
|
|
2276
|
+
entries: cache
|
|
2277
|
+
};
|
|
2278
|
+
const body = `${JSON.stringify(envelope, null, 2)}
|
|
2279
|
+
`;
|
|
2280
|
+
await writeFile2(tmp, body, "utf8");
|
|
2281
|
+
await rename(tmp, path2);
|
|
2282
|
+
}
|
|
2283
|
+
async function filterChangedBlocks(blocks, cache) {
|
|
2284
|
+
const changed = [];
|
|
2285
|
+
for (const block of blocks) {
|
|
2286
|
+
const cached = cache[block.id];
|
|
2287
|
+
if (!cached) {
|
|
2288
|
+
changed.push(block);
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
const maxMtime = await maxFileMtime(block);
|
|
2292
|
+
if (maxMtime === void 0 || maxMtime > cached.mtime) {
|
|
2293
|
+
changed.push(block);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
return changed;
|
|
2297
|
+
}
|
|
2298
|
+
async function maxFileMtime(block) {
|
|
2299
|
+
const paths = [join4(block.path, "macroscope.yaml")];
|
|
2300
|
+
if (block.resolvedPaths?.source) paths.push(block.resolvedPaths.source);
|
|
2301
|
+
if (block.resolvedPaths?.docs) paths.push(block.resolvedPaths.docs);
|
|
2302
|
+
let max;
|
|
2303
|
+
for (const p of paths) {
|
|
2304
|
+
try {
|
|
2305
|
+
const s = await stat2(p);
|
|
2306
|
+
const m = s.mtimeMs;
|
|
2307
|
+
if (max === void 0 || m > max) max = m;
|
|
2308
|
+
} catch {
|
|
2309
|
+
return void 0;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
return max;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// src/local/catchup.ts
|
|
2316
|
+
init_project();
|
|
2317
|
+
init_scanner();
|
|
2318
|
+
|
|
2319
|
+
// src/local/repo-id.ts
|
|
2320
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
2321
|
+
import { basename } from "path";
|
|
2322
|
+
import { promisify as promisify2 } from "util";
|
|
2323
|
+
var execFile2 = promisify2(execFileCb2);
|
|
2324
|
+
var NotAGitRepoError = class extends Error {
|
|
2325
|
+
constructor(path2) {
|
|
2326
|
+
super(
|
|
2327
|
+
`Not a git repository: ${path2}. The watcher needs a git working tree to derive a repo identity.`
|
|
2328
|
+
);
|
|
2329
|
+
this.name = "NotAGitRepoError";
|
|
2330
|
+
}
|
|
2331
|
+
};
|
|
2332
|
+
function normalizeRemoteUrl(url) {
|
|
2333
|
+
let s = url.trim();
|
|
2334
|
+
s = s.toLowerCase();
|
|
2335
|
+
s = s.replace(/^ssh:\/\//, "");
|
|
2336
|
+
s = s.replace(/^https?:\/\//, "");
|
|
2337
|
+
s = s.replace(/^git:\/\//, "");
|
|
2338
|
+
s = s.replace(/^[a-z0-9._-]+@/, "");
|
|
2339
|
+
s = s.replace(":", "/");
|
|
2340
|
+
s = s.replace(/\.git$/, "");
|
|
2341
|
+
s = s.replace(/\/+$/, "");
|
|
2342
|
+
return s;
|
|
2343
|
+
}
|
|
2344
|
+
async function deriveRepoId(absolutePath) {
|
|
2345
|
+
try {
|
|
2346
|
+
await execFile2("git", ["-C", absolutePath, "rev-parse", "--git-dir"]);
|
|
2347
|
+
} catch {
|
|
2348
|
+
throw new NotAGitRepoError(absolutePath);
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
const { stdout } = await execFile2("git", [
|
|
2352
|
+
"-C",
|
|
2353
|
+
absolutePath,
|
|
2354
|
+
"config",
|
|
2355
|
+
"--get",
|
|
2356
|
+
"remote.origin.url"
|
|
2357
|
+
]);
|
|
2358
|
+
const url = stdout.trim();
|
|
2359
|
+
if (url) return normalizeRemoteUrl(url);
|
|
2360
|
+
} catch {
|
|
2361
|
+
}
|
|
2362
|
+
return basename(absolutePath);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// src/local/watcher.ts
|
|
2366
|
+
init_dist();
|
|
2367
|
+
import { createHash as createHash5 } from "crypto";
|
|
2368
|
+
import { EventEmitter } from "events";
|
|
2369
|
+
import { mkdir as mkdir4, readFile as readFile8, rename as rename2, writeFile as writeFile4 } from "fs/promises";
|
|
2370
|
+
import { dirname as dirname6, join as join7, relative as relative2, sep as sep2 } from "path";
|
|
2371
|
+
import chokidar from "chokidar";
|
|
2372
|
+
init_project();
|
|
2373
|
+
init_scanner();
|
|
2374
|
+
|
|
2375
|
+
// src/local/worktree-id.ts
|
|
2376
|
+
init_dist();
|
|
2377
|
+
import { createHash as createHash4, randomUUID } from "crypto";
|
|
2378
|
+
import { mkdir as mkdir3, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
|
|
2379
|
+
import { homedir as homedir3 } from "os";
|
|
2380
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
2381
|
+
function deriveWorktreeId(machineUuid, absolutePath) {
|
|
2382
|
+
return createHash4("sha256").update(`${machineUuid}:${absolutePath}`).digest("hex").slice(0, 32);
|
|
2383
|
+
}
|
|
2384
|
+
async function getOrCreateMachineUuid(opts) {
|
|
2385
|
+
const home = opts?.homeDir ?? homedir3();
|
|
2386
|
+
const path2 = join6(home, FILESYSTEM_LAYOUT.user.machineIdFile);
|
|
2387
|
+
try {
|
|
2388
|
+
const raw = await readFile7(path2, "utf8");
|
|
2389
|
+
const trimmed = raw.trim();
|
|
2390
|
+
if (trimmed) return trimmed;
|
|
2391
|
+
} catch (err) {
|
|
2392
|
+
const code = err.code;
|
|
2393
|
+
if (code !== "ENOENT") throw err;
|
|
2394
|
+
}
|
|
2395
|
+
const uuid = randomUUID();
|
|
2396
|
+
await mkdir3(dirname5(path2), { recursive: true });
|
|
2397
|
+
await writeFile3(path2, `${uuid}
|
|
2398
|
+
`, { encoding: "utf8", mode: 384 });
|
|
2399
|
+
return uuid;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// src/local/watcher.ts
|
|
2403
|
+
var composeDocId = (repo, worktreeId, blockId) => createHash5("sha256").update(`${repo}:${worktreeId}:${blockId}`).digest("hex");
|
|
2404
|
+
var QUEUE_MAX_BYTES = 1048576;
|
|
2405
|
+
var STOP_DRAIN_TIMEOUT_MS = 3e4;
|
|
2406
|
+
var READY_TIMEOUT_MS = 1e4;
|
|
2407
|
+
var isRetryable = (r) => {
|
|
2408
|
+
if (r.kind !== "error") return false;
|
|
2409
|
+
if (r.status >= 500 && r.status <= 599) return true;
|
|
2410
|
+
if (r.status === 0 && r.envelope.error.code === "not_logged_in") return false;
|
|
2411
|
+
if (r.envelope.error.code === "upstream_unreachable") return true;
|
|
2412
|
+
return false;
|
|
2413
|
+
};
|
|
2414
|
+
var toPosix2 = (p) => p.split(sep2).join("/");
|
|
2415
|
+
async function startWatcher(opts) {
|
|
2416
|
+
const debounceMs = opts.debounceMs ?? 500;
|
|
2417
|
+
const queueRetryMs = opts.queueRetryMs ?? 5e3;
|
|
2418
|
+
const project = await findProject(opts.projectRoot);
|
|
2419
|
+
const machineUuid = await getOrCreateMachineUuid({ homeDir: opts.homeDir });
|
|
2420
|
+
const worktreeId = deriveWorktreeId(machineUuid, project.root);
|
|
2421
|
+
const repo = await deriveRepoId(project.root);
|
|
2422
|
+
const queuePath = join7(project.root, FILESYSTEM_LAYOUT.project.queueFile);
|
|
2423
|
+
const emitter = new EventEmitter();
|
|
2424
|
+
let stopping = false;
|
|
2425
|
+
let stopped = false;
|
|
2426
|
+
let flushTimer = null;
|
|
2427
|
+
let inFlight = null;
|
|
2428
|
+
let retryTimer = null;
|
|
2429
|
+
async function readQueue2() {
|
|
2430
|
+
try {
|
|
2431
|
+
const raw = await readFile8(queuePath, "utf8");
|
|
2432
|
+
const parsed = JSON.parse(raw);
|
|
2433
|
+
if (Array.isArray(parsed)) return parsed;
|
|
2434
|
+
return [];
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
if (err.code === "ENOENT") return [];
|
|
2437
|
+
return [];
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
async function writeQueue(q) {
|
|
2441
|
+
await mkdir4(dirname6(queuePath), { recursive: true });
|
|
2442
|
+
const tmp = `${queuePath}.tmp`;
|
|
2443
|
+
let body = JSON.stringify(q);
|
|
2444
|
+
while (Buffer.byteLength(body, "utf8") > QUEUE_MAX_BYTES && q.length > 0) {
|
|
2445
|
+
q.shift();
|
|
2446
|
+
body = JSON.stringify(q);
|
|
2447
|
+
console.warn(
|
|
2448
|
+
`[macroscope] watcher queue exceeded ${QUEUE_MAX_BYTES} bytes; dropping oldest entry`
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
await writeFile4(tmp, body, "utf8");
|
|
2452
|
+
await rename2(tmp, queuePath);
|
|
2453
|
+
}
|
|
2454
|
+
async function enqueueBatch(batch) {
|
|
2455
|
+
const q = await readQueue2();
|
|
2456
|
+
q.push({ ...batch, queuedAt: Date.now() });
|
|
2457
|
+
await writeQueue(q);
|
|
2458
|
+
}
|
|
2459
|
+
async function drainQueue() {
|
|
2460
|
+
const q = await readQueue2();
|
|
2461
|
+
if (q.length === 0) return { drained: 0 };
|
|
2462
|
+
let drained = 0;
|
|
2463
|
+
while (q.length > 0) {
|
|
2464
|
+
const next = q[0];
|
|
2465
|
+
const res = await opts.cloudClient.indexBatch({
|
|
2466
|
+
upserts: next.upserts,
|
|
2467
|
+
deletes: next.deletes
|
|
2468
|
+
});
|
|
2469
|
+
if (res.kind === "ok") {
|
|
2470
|
+
q.shift();
|
|
2471
|
+
drained += 1;
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
if (isRetryable(res)) break;
|
|
2475
|
+
q.shift();
|
|
2476
|
+
drained += 1;
|
|
2477
|
+
emitter.emit("error", {
|
|
2478
|
+
err: new Error(res.envelope.error.message),
|
|
2479
|
+
retryable: false
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
await writeQueue(q);
|
|
2483
|
+
if (drained > 0) emitter.emit("queueDrained", { drained });
|
|
2484
|
+
return { drained };
|
|
2485
|
+
}
|
|
2486
|
+
async function flush() {
|
|
2487
|
+
flushTimer = null;
|
|
2488
|
+
if (stopping) return;
|
|
2489
|
+
const start = Date.now();
|
|
2490
|
+
const scanResult = await scan(project);
|
|
2491
|
+
const cache = await loadHashCache({ workspaceRoot: project.root });
|
|
2492
|
+
const changedBlocks = await filterChangedBlocks(scanResult.blocks, cache);
|
|
2493
|
+
const { payloads } = await extractAll(changedBlocks);
|
|
2494
|
+
const payloadById = new Map(payloads.map((p) => [p.blockId, p]));
|
|
2495
|
+
const blockById = new Map(scanResult.blocks.map((b) => [b.id, b]));
|
|
2496
|
+
const upserts = [];
|
|
2497
|
+
const newCache = { ...cache };
|
|
2498
|
+
const now = Date.now();
|
|
2499
|
+
for (const block of changedBlocks) {
|
|
2500
|
+
const payload = payloadById.get(block.id);
|
|
2501
|
+
if (!payload) continue;
|
|
2502
|
+
const contentHash = hashPayload(payload);
|
|
2503
|
+
const doc = {
|
|
2504
|
+
...payload,
|
|
2505
|
+
contentHash,
|
|
2506
|
+
repo,
|
|
2507
|
+
worktreeId,
|
|
2508
|
+
path: toPosix2(relative2(project.root, block.path)),
|
|
2509
|
+
lastActiveAt: now
|
|
2510
|
+
};
|
|
2511
|
+
upserts.push(doc);
|
|
2512
|
+
newCache[block.id] = { hash: contentHash, mtime: now };
|
|
2513
|
+
}
|
|
2514
|
+
const currentIds = new Set(scanResult.blocks.map((b) => b.id));
|
|
2515
|
+
const deletes = [];
|
|
2516
|
+
for (const id of Object.keys(cache)) {
|
|
2517
|
+
if (currentIds.has(id)) continue;
|
|
2518
|
+
deletes.push(composeDocId(repo, worktreeId, id));
|
|
2519
|
+
delete newCache[id];
|
|
2520
|
+
}
|
|
2521
|
+
if (upserts.length === 0 && deletes.length === 0) {
|
|
2522
|
+
await drainQueue();
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
emitter.emit("batchStart", {
|
|
2526
|
+
pendingUpserts: upserts.length,
|
|
2527
|
+
pendingDeletes: deletes.length
|
|
605
2528
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (!resolved.ok) {
|
|
620
|
-
send(ws, { type: "error", message: resolved.error });
|
|
621
|
-
ws.close();
|
|
2529
|
+
const drainBefore = await readQueue2();
|
|
2530
|
+
if (drainBefore.length > 0) {
|
|
2531
|
+
await drainQueue();
|
|
2532
|
+
}
|
|
2533
|
+
const res = await opts.cloudClient.indexBatch({ upserts, deletes });
|
|
2534
|
+
if (res.kind === "ok") {
|
|
2535
|
+
await saveHashCache(newCache, { workspaceRoot: project.root });
|
|
2536
|
+
emitter.emit("batchSent", {
|
|
2537
|
+
accepted: res.data.accepted,
|
|
2538
|
+
deleted: deletes.length,
|
|
2539
|
+
durationMs: Date.now() - start
|
|
2540
|
+
});
|
|
2541
|
+
await drainQueue();
|
|
622
2542
|
return;
|
|
623
2543
|
}
|
|
624
|
-
|
|
625
|
-
|
|
2544
|
+
if (isRetryable(res)) {
|
|
2545
|
+
await enqueueBatch({ upserts, deletes });
|
|
2546
|
+
ensureQueueRetry();
|
|
2547
|
+
emitter.emit("error", {
|
|
2548
|
+
err: new Error(res.envelope.error.message),
|
|
2549
|
+
retryable: true
|
|
2550
|
+
});
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
emitter.emit("error", {
|
|
2554
|
+
err: new Error(res.envelope.error.message),
|
|
2555
|
+
retryable: false
|
|
2556
|
+
});
|
|
626
2557
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
2558
|
+
function scheduleFlush() {
|
|
2559
|
+
if (stopping) return;
|
|
2560
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
2561
|
+
flushTimer = setTimeout(() => {
|
|
2562
|
+
inFlight = flush().catch((err) => {
|
|
2563
|
+
emitter.emit("error", {
|
|
2564
|
+
err: err instanceof Error ? err : new Error(String(err)),
|
|
2565
|
+
retryable: false
|
|
2566
|
+
});
|
|
2567
|
+
}).finally(() => {
|
|
2568
|
+
inFlight = null;
|
|
2569
|
+
});
|
|
2570
|
+
}, debounceMs);
|
|
631
2571
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
} catch (err) {
|
|
638
|
-
send(ws, { type: "error", message: `failed to spawn: ${err.message}` });
|
|
639
|
-
ws.close();
|
|
640
|
-
return;
|
|
2572
|
+
function clearQueueRetry() {
|
|
2573
|
+
if (retryTimer) {
|
|
2574
|
+
clearInterval(retryTimer);
|
|
2575
|
+
retryTimer = null;
|
|
2576
|
+
}
|
|
641
2577
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
2578
|
+
function ensureQueueRetry() {
|
|
2579
|
+
if (retryTimer || stopping) return;
|
|
2580
|
+
retryTimer = setInterval(() => {
|
|
2581
|
+
void (async () => {
|
|
2582
|
+
if (stopping) {
|
|
2583
|
+
clearQueueRetry();
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
if ((await readQueue2()).length === 0) {
|
|
2587
|
+
clearQueueRetry();
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
scheduleFlush();
|
|
2591
|
+
})();
|
|
2592
|
+
}, queueRetryMs);
|
|
2593
|
+
if (typeof retryTimer.unref === "function") retryTimer.unref();
|
|
2594
|
+
}
|
|
2595
|
+
const factory = opts.watcherFactory ?? ((paths) => chokidar.watch(paths, {
|
|
2596
|
+
ignoreInitial: true,
|
|
2597
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
2598
|
+
ignored: (p) => /(^|[/\\])(node_modules|\.git|dist)([/\\]|$)/.test(p) || /([/\\])\.macroscope[/\\]cache([/\\]|$)/.test(p)
|
|
2599
|
+
}));
|
|
2600
|
+
const fsWatcher = factory([project.root]);
|
|
2601
|
+
for (const event of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
|
|
2602
|
+
fsWatcher.on(event, () => scheduleFlush());
|
|
2603
|
+
}
|
|
2604
|
+
fsWatcher.on("error", (err) => {
|
|
2605
|
+
emitter.emit("error", {
|
|
2606
|
+
err: err instanceof Error ? err : new Error(String(err)),
|
|
2607
|
+
retryable: false
|
|
2608
|
+
});
|
|
647
2609
|
});
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
2610
|
+
await new Promise((resolve5) => {
|
|
2611
|
+
let settled = false;
|
|
2612
|
+
const settle = () => {
|
|
2613
|
+
if (settled) return;
|
|
2614
|
+
settled = true;
|
|
2615
|
+
resolve5();
|
|
2616
|
+
};
|
|
2617
|
+
fsWatcher.on("ready", settle);
|
|
2618
|
+
const t = setTimeout(settle, READY_TIMEOUT_MS);
|
|
2619
|
+
if (typeof t.unref === "function") t.unref();
|
|
651
2620
|
});
|
|
652
|
-
|
|
653
|
-
|
|
2621
|
+
await drainQueue().catch((err) => {
|
|
2622
|
+
emitter.emit("error", {
|
|
2623
|
+
err: err instanceof Error ? err : new Error(String(err)),
|
|
2624
|
+
retryable: false
|
|
2625
|
+
});
|
|
2626
|
+
});
|
|
2627
|
+
if ((await readQueue2()).length > 0) ensureQueueRetry();
|
|
2628
|
+
async function stop() {
|
|
2629
|
+
if (stopped) return;
|
|
2630
|
+
stopping = true;
|
|
2631
|
+
if (flushTimer) {
|
|
2632
|
+
clearTimeout(flushTimer);
|
|
2633
|
+
flushTimer = null;
|
|
2634
|
+
}
|
|
2635
|
+
clearQueueRetry();
|
|
654
2636
|
try {
|
|
655
|
-
|
|
2637
|
+
await fsWatcher.close();
|
|
656
2638
|
} catch {
|
|
657
|
-
return;
|
|
658
2639
|
}
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
2640
|
+
if (inFlight) {
|
|
2641
|
+
const timeout = new Promise((resolve5) => {
|
|
2642
|
+
const t = setTimeout(() => {
|
|
2643
|
+
console.warn(
|
|
2644
|
+
`[macroscope] watcher stop: in-flight batch did not settle within ${STOP_DRAIN_TIMEOUT_MS}ms \u2014 giving up`
|
|
2645
|
+
);
|
|
2646
|
+
resolve5();
|
|
2647
|
+
}, STOP_DRAIN_TIMEOUT_MS);
|
|
2648
|
+
if (typeof t.unref === "function") t.unref();
|
|
2649
|
+
});
|
|
2650
|
+
await Promise.race([inFlight, timeout]);
|
|
663
2651
|
}
|
|
2652
|
+
stopped = true;
|
|
2653
|
+
}
|
|
2654
|
+
const handle = {
|
|
2655
|
+
stop,
|
|
2656
|
+
on(event, cb) {
|
|
2657
|
+
const wrapped = (payload) => cb(payload);
|
|
2658
|
+
emitter.on(event, wrapped);
|
|
2659
|
+
return () => emitter.off(event, wrapped);
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
if (opts.lifecycle !== false) {
|
|
2663
|
+
installLifecycle({
|
|
2664
|
+
...opts.lifecycle ?? {},
|
|
2665
|
+
onShutdown: () => handle.stop()
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
return handle;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// src/local/catchup.ts
|
|
2672
|
+
var CHUNK = 500;
|
|
2673
|
+
var EXTRACT_PROGRESS_INTERVAL_MS = 100;
|
|
2674
|
+
var toPosix3 = (p) => p.split(sep3).join("/");
|
|
2675
|
+
async function runCatchup(opts) {
|
|
2676
|
+
const startTime = Date.now();
|
|
2677
|
+
const onProgress = opts.onProgress ?? (() => {
|
|
664
2678
|
});
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
2679
|
+
const project = await findProject(opts.projectRoot);
|
|
2680
|
+
const machineUuid = await getOrCreateMachineUuid({ homeDir: opts.homeDir });
|
|
2681
|
+
const worktreeId = deriveWorktreeId(machineUuid, project.root);
|
|
2682
|
+
const repo = await deriveRepoId(project.root);
|
|
2683
|
+
const scanResult = await scan(project);
|
|
2684
|
+
const blocks = scanResult.blocks;
|
|
2685
|
+
onProgress({ kind: "start", total: blocks.length });
|
|
2686
|
+
const cachedSchemaVersion = await readSchemaVersion({ workspaceRoot: project.root });
|
|
2687
|
+
const schemaMismatch = cachedSchemaVersion !== SCHEMA_VERSION;
|
|
2688
|
+
const cache = schemaMismatch ? {} : await loadHashCache({ workspaceRoot: project.root });
|
|
2689
|
+
const changedBlocks = schemaMismatch ? blocks : await filterChangedBlocks(blocks, cache);
|
|
2690
|
+
const { payloads } = await extractAll(changedBlocks);
|
|
2691
|
+
const payloadById = new Map(payloads.map((p) => [p.blockId, p]));
|
|
2692
|
+
const mtimeRefreshes = {};
|
|
2693
|
+
const upserts = [];
|
|
2694
|
+
const upsertCacheEntries = /* @__PURE__ */ new Map();
|
|
2695
|
+
const now = Date.now();
|
|
2696
|
+
let lastExtractEmit = 0;
|
|
2697
|
+
let done = 0;
|
|
2698
|
+
for (const block of changedBlocks) {
|
|
2699
|
+
done += 1;
|
|
2700
|
+
const payload = payloadById.get(block.id);
|
|
2701
|
+
if (payload) {
|
|
2702
|
+
const contentHash = hashPayload(payload);
|
|
2703
|
+
const cached = cache[block.id];
|
|
2704
|
+
if (cached && cached.hash === contentHash) {
|
|
2705
|
+
mtimeRefreshes[block.id] = { hash: contentHash, mtime: now };
|
|
2706
|
+
} else {
|
|
2707
|
+
const doc = {
|
|
2708
|
+
...payload,
|
|
2709
|
+
contentHash,
|
|
2710
|
+
repo,
|
|
2711
|
+
worktreeId,
|
|
2712
|
+
path: toPosix3(relative3(project.root, block.path)),
|
|
2713
|
+
lastActiveAt: now
|
|
2714
|
+
};
|
|
2715
|
+
upserts.push(doc);
|
|
2716
|
+
upsertCacheEntries.set(block.id, { hash: contentHash, mtime: now });
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
const t = Date.now();
|
|
2720
|
+
if (t - lastExtractEmit >= EXTRACT_PROGRESS_INTERVAL_MS || done === changedBlocks.length) {
|
|
2721
|
+
onProgress({ kind: "extract", done, total: changedBlocks.length });
|
|
2722
|
+
lastExtractEmit = t;
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
const currentIds = new Set(blocks.map((b) => b.id));
|
|
2726
|
+
const deletes = [];
|
|
2727
|
+
for (const id of Object.keys(cache)) {
|
|
2728
|
+
if (currentIds.has(id)) continue;
|
|
2729
|
+
deletes.push(composeDocId(repo, worktreeId, id));
|
|
2730
|
+
}
|
|
2731
|
+
const batches = [];
|
|
2732
|
+
for (let i = 0; i < upserts.length; i += CHUNK) {
|
|
2733
|
+
batches.push({ upserts: upserts.slice(i, i + CHUNK), deletes: [] });
|
|
2734
|
+
}
|
|
2735
|
+
if (deletes.length > 0) {
|
|
2736
|
+
if (batches.length > 0) batches[batches.length - 1].deletes = deletes;
|
|
2737
|
+
else batches.push({ upserts: [], deletes });
|
|
2738
|
+
}
|
|
2739
|
+
const persistedCache = { ...cache, ...mtimeRefreshes };
|
|
2740
|
+
let pushedUpserts = 0;
|
|
2741
|
+
let pushedDeletes = 0;
|
|
2742
|
+
const skipped = blocks.length - upserts.length;
|
|
2743
|
+
if (batches.length === 0) {
|
|
2744
|
+
if (Object.keys(mtimeRefreshes).length > 0 || schemaMismatch) {
|
|
2745
|
+
await saveHashCache(persistedCache, { workspaceRoot: project.root });
|
|
2746
|
+
}
|
|
2747
|
+
const durationMs2 = Date.now() - startTime;
|
|
2748
|
+
onProgress({ kind: "complete", durationMs: durationMs2, upserts: 0, deletes: 0 });
|
|
2749
|
+
return { upserts: 0, deletes: 0, skipped, durationMs: durationMs2 };
|
|
2750
|
+
}
|
|
2751
|
+
for (let i = 0; i < batches.length; i += 1) {
|
|
2752
|
+
const batch = batches[i];
|
|
2753
|
+
const res = await opts.cloudClient.indexBatch(batch);
|
|
2754
|
+
if (res.kind === "ok") {
|
|
2755
|
+
for (const doc of batch.upserts) {
|
|
2756
|
+
const entry = upsertCacheEntries.get(doc.blockId);
|
|
2757
|
+
if (entry) persistedCache[doc.blockId] = entry;
|
|
2758
|
+
}
|
|
2759
|
+
if (batch.deletes.length > 0) {
|
|
2760
|
+
for (const id of Object.keys(cache)) {
|
|
2761
|
+
if (!currentIds.has(id)) delete persistedCache[id];
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
await saveHashCache(persistedCache, { workspaceRoot: project.root });
|
|
2765
|
+
pushedUpserts += batch.upserts.length;
|
|
2766
|
+
pushedDeletes += batch.deletes.length;
|
|
2767
|
+
onProgress({ kind: "pushed", batch: i + 1, remaining: batches.length - i - 1 });
|
|
2768
|
+
if (batch.deletes.length > 0) {
|
|
2769
|
+
onProgress({ kind: "deleted", count: batch.deletes.length });
|
|
2770
|
+
}
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
const retryable = isRetryable2(res);
|
|
2774
|
+
if (!retryable) {
|
|
2775
|
+
onProgress({
|
|
2776
|
+
kind: "error",
|
|
2777
|
+
err: new Error(res.envelope.error.message),
|
|
2778
|
+
retryable: false
|
|
2779
|
+
});
|
|
2780
|
+
const durationMs3 = Date.now() - startTime;
|
|
2781
|
+
return { upserts: pushedUpserts, deletes: pushedDeletes, skipped, durationMs: durationMs3 };
|
|
2782
|
+
}
|
|
2783
|
+
const remainingBatches = batches.slice(i);
|
|
2784
|
+
await enqueueBatches(project.root, remainingBatches);
|
|
2785
|
+
onProgress({
|
|
2786
|
+
kind: "error",
|
|
2787
|
+
err: new Error(res.envelope.error.message),
|
|
2788
|
+
retryable: true
|
|
2789
|
+
});
|
|
2790
|
+
const durationMs2 = Date.now() - startTime;
|
|
2791
|
+
return { upserts: pushedUpserts, deletes: pushedDeletes, skipped, durationMs: durationMs2 };
|
|
2792
|
+
}
|
|
2793
|
+
const durationMs = Date.now() - startTime;
|
|
2794
|
+
onProgress({ kind: "complete", durationMs, upserts: pushedUpserts, deletes: pushedDeletes });
|
|
2795
|
+
return { upserts: pushedUpserts, deletes: pushedDeletes, skipped, durationMs };
|
|
668
2796
|
}
|
|
669
|
-
function
|
|
2797
|
+
function isRetryable2(r) {
|
|
2798
|
+
if (r.kind !== "error") return false;
|
|
2799
|
+
const code = r.envelope.error.code;
|
|
2800
|
+
if (code === "not_logged_in" || code === "unauthorized") return false;
|
|
2801
|
+
if (code === "upstream_unreachable") return true;
|
|
2802
|
+
if (r.status >= 500 && r.status <= 599) return true;
|
|
2803
|
+
return false;
|
|
2804
|
+
}
|
|
2805
|
+
async function readQueue(queuePath) {
|
|
670
2806
|
try {
|
|
671
|
-
|
|
672
|
-
|
|
2807
|
+
const raw = await readFile9(queuePath, "utf8");
|
|
2808
|
+
const parsed = JSON.parse(raw);
|
|
2809
|
+
if (Array.isArray(parsed)) return parsed;
|
|
2810
|
+
return [];
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
if (err.code === "ENOENT") return [];
|
|
2813
|
+
return [];
|
|
673
2814
|
}
|
|
674
2815
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
import importLocal from "import-local";
|
|
691
|
-
|
|
692
|
-
// src/core/version.ts
|
|
693
|
-
var VERSION = "0.0.0";
|
|
694
|
-
|
|
695
|
-
// src/ui/ui-server.ts
|
|
696
|
-
import { existsSync as existsSync4 } from "fs";
|
|
697
|
-
import { readFile as readFile5 } from "fs/promises";
|
|
698
|
-
import { createServer } from "http";
|
|
699
|
-
import { extname as extname2, join as join4, normalize, resolve as resolve2 } from "path";
|
|
2816
|
+
async function writeQueueAtomic(queuePath, q) {
|
|
2817
|
+
await mkdir5(dirname7(queuePath), { recursive: true });
|
|
2818
|
+
const tmp = `${queuePath}.tmp`;
|
|
2819
|
+
await writeFile5(tmp, JSON.stringify(q), "utf8");
|
|
2820
|
+
await rename3(tmp, queuePath);
|
|
2821
|
+
}
|
|
2822
|
+
async function enqueueBatches(projectRoot, batches) {
|
|
2823
|
+
const queuePath = join8(projectRoot, FILESYSTEM_LAYOUT.project.queueFile);
|
|
2824
|
+
const existing = await readQueue(queuePath);
|
|
2825
|
+
const queuedAt = Date.now();
|
|
2826
|
+
for (const b of batches) {
|
|
2827
|
+
existing.push({ upserts: b.upserts, deletes: b.deletes, queuedAt });
|
|
2828
|
+
}
|
|
2829
|
+
await writeQueueAtomic(queuePath, existing);
|
|
2830
|
+
}
|
|
700
2831
|
|
|
701
2832
|
// src/ui/api/blocks-handler.ts
|
|
702
2833
|
init_blueprints();
|
|
@@ -838,7 +2969,7 @@ async function handleBlueprintsRequest(cwd) {
|
|
|
838
2969
|
}
|
|
839
2970
|
|
|
840
2971
|
// src/ui/api/code-handler.ts
|
|
841
|
-
import { readFile as
|
|
2972
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
842
2973
|
import { extname } from "path";
|
|
843
2974
|
import { codeToHtml } from "shiki";
|
|
844
2975
|
var EXT_TO_LANG = {
|
|
@@ -862,7 +2993,7 @@ var EXT_TO_LANG = {
|
|
|
862
2993
|
async function handleCodeRequest(absolutePath, language) {
|
|
863
2994
|
let raw;
|
|
864
2995
|
try {
|
|
865
|
-
raw = await
|
|
2996
|
+
raw = await readFile10(absolutePath, "utf8");
|
|
866
2997
|
} catch {
|
|
867
2998
|
return errorResponse(404, `File not found: ${absolutePath}`);
|
|
868
2999
|
}
|
|
@@ -908,13 +3039,54 @@ function escapeHtml(s) {
|
|
|
908
3039
|
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c] ?? c);
|
|
909
3040
|
}
|
|
910
3041
|
|
|
3042
|
+
// src/ui/api/code-search-handler.ts
|
|
3043
|
+
var MAX_QUERY_LENGTH = 1024;
|
|
3044
|
+
async function handleCodeSearchRequest(cwd, query) {
|
|
3045
|
+
if (!query || query.length === 0) {
|
|
3046
|
+
return {
|
|
3047
|
+
status: 400,
|
|
3048
|
+
body: { error: { code: "invalid_request", message: "Missing or empty ?q" } }
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
3052
|
+
return {
|
|
3053
|
+
status: 400,
|
|
3054
|
+
body: { error: { code: "invalid_request", message: "Query too long" } }
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
const result = await codeSearch({ query, cwd });
|
|
3058
|
+
return { status: 200, body: result };
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/ui/api/context-handler.ts
|
|
3062
|
+
init_project();
|
|
3063
|
+
async function handleContextRequest(cwd, opts = {}) {
|
|
3064
|
+
try {
|
|
3065
|
+
const project = await findProject(cwd);
|
|
3066
|
+
const machineUuid = await getOrCreateMachineUuid({ homeDir: opts.homeDir });
|
|
3067
|
+
const worktreeId = deriveWorktreeId(machineUuid, project.root);
|
|
3068
|
+
const repo = await deriveRepoId(project.root);
|
|
3069
|
+
return { status: 200, body: { worktreeId, repo } };
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
return {
|
|
3072
|
+
status: 500,
|
|
3073
|
+
body: {
|
|
3074
|
+
error: {
|
|
3075
|
+
code: "context_unavailable",
|
|
3076
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
911
3083
|
// src/ui/api/markdown-handler.ts
|
|
912
|
-
import { readFile as
|
|
3084
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
913
3085
|
import { marked } from "marked";
|
|
914
3086
|
async function handleMarkdownRequest(absolutePath) {
|
|
915
3087
|
let raw;
|
|
916
3088
|
try {
|
|
917
|
-
raw = await
|
|
3089
|
+
raw = await readFile11(absolutePath, "utf8");
|
|
918
3090
|
} catch {
|
|
919
3091
|
return errorResponse2(404, `File not found: ${absolutePath}`);
|
|
920
3092
|
}
|
|
@@ -958,8 +3130,182 @@ function escapeHtml2(s) {
|
|
|
958
3130
|
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c] ?? c);
|
|
959
3131
|
}
|
|
960
3132
|
|
|
3133
|
+
// src/ui/api/progress-stream.ts
|
|
3134
|
+
var DEFAULT_HEARTBEAT_MS = 3e4;
|
|
3135
|
+
function createProgressBus(opts = {}) {
|
|
3136
|
+
const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
|
|
3137
|
+
const clients = /* @__PURE__ */ new Set();
|
|
3138
|
+
let closed = false;
|
|
3139
|
+
return {
|
|
3140
|
+
emit(event) {
|
|
3141
|
+
if (closed) return;
|
|
3142
|
+
const frame = `data: ${JSON.stringify(event)}
|
|
3143
|
+
|
|
3144
|
+
`;
|
|
3145
|
+
for (const c of clients) {
|
|
3146
|
+
try {
|
|
3147
|
+
c.res.write(frame);
|
|
3148
|
+
} catch {
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
},
|
|
3152
|
+
handleStream(req, res) {
|
|
3153
|
+
if (closed) return;
|
|
3154
|
+
res.statusCode = 200;
|
|
3155
|
+
res.setHeader("content-type", "text/event-stream");
|
|
3156
|
+
res.setHeader("cache-control", "no-cache");
|
|
3157
|
+
res.setHeader("connection", "keep-alive");
|
|
3158
|
+
if (typeof res.flushHeaders === "function") {
|
|
3159
|
+
res.flushHeaders();
|
|
3160
|
+
}
|
|
3161
|
+
const timer = setInterval(() => {
|
|
3162
|
+
try {
|
|
3163
|
+
res.write(": heartbeat\n\n");
|
|
3164
|
+
} catch {
|
|
3165
|
+
}
|
|
3166
|
+
}, heartbeatIntervalMs);
|
|
3167
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
3168
|
+
const entry = { res, timer };
|
|
3169
|
+
clients.add(entry);
|
|
3170
|
+
const cleanup = () => {
|
|
3171
|
+
clearInterval(timer);
|
|
3172
|
+
clients.delete(entry);
|
|
3173
|
+
};
|
|
3174
|
+
req.on("close", cleanup);
|
|
3175
|
+
res.on("close", cleanup);
|
|
3176
|
+
},
|
|
3177
|
+
close() {
|
|
3178
|
+
if (closed) return;
|
|
3179
|
+
closed = true;
|
|
3180
|
+
for (const c of clients) {
|
|
3181
|
+
clearInterval(c.timer);
|
|
3182
|
+
try {
|
|
3183
|
+
c.res.end();
|
|
3184
|
+
} catch {
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
clients.clear();
|
|
3188
|
+
}
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
// src/ui/api/proxy.ts
|
|
3193
|
+
init_dist();
|
|
3194
|
+
import { readFile as readFile12, stat as stat3 } from "fs/promises";
|
|
3195
|
+
import { request as httpRequest } from "http";
|
|
3196
|
+
import { request as httpsRequest } from "https";
|
|
3197
|
+
var CLOUD_PREFIX = "/api/cloud";
|
|
3198
|
+
var DEFAULT_CLOUD_URL = "http://localhost:3001";
|
|
3199
|
+
var MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
3200
|
+
var cachedToken = null;
|
|
3201
|
+
var cachedMtime = 0;
|
|
3202
|
+
function isCloudProxyRoute(pathname) {
|
|
3203
|
+
return pathname.startsWith(`${CLOUD_PREFIX}/`) || pathname === CLOUD_PREFIX;
|
|
3204
|
+
}
|
|
3205
|
+
function selectRequestFn(url) {
|
|
3206
|
+
return url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
3207
|
+
}
|
|
3208
|
+
async function handleCloudProxy(req, res, opts) {
|
|
3209
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3210
|
+
const cloudPath = url.pathname.slice(CLOUD_PREFIX.length);
|
|
3211
|
+
const downstream = url.search ? `${cloudPath}${url.search}` : cloudPath;
|
|
3212
|
+
const cloudBase = opts.cloudUrl ?? process.env.MACROSCOPE_CLOUD_URL ?? DEFAULT_CLOUD_URL;
|
|
3213
|
+
const credPath = opts.credentialsPath;
|
|
3214
|
+
if (!credPath) {
|
|
3215
|
+
sendError(res, 401, "not_logged_in", "Run `macroscope login` first");
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
let token;
|
|
3219
|
+
try {
|
|
3220
|
+
token = await readToken(credPath);
|
|
3221
|
+
} catch {
|
|
3222
|
+
sendError(res, 401, "not_logged_in", "Run `macroscope login` first");
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
const { body, tooLarge } = await readBody(req);
|
|
3226
|
+
if (tooLarge) {
|
|
3227
|
+
sendError(res, 413, "payload_too_large", `request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
const targetUrl = new URL(downstream, cloudBase);
|
|
3231
|
+
const requestFn = selectRequestFn(targetUrl);
|
|
3232
|
+
const headers = {
|
|
3233
|
+
authorization: `Bearer ${token}`
|
|
3234
|
+
};
|
|
3235
|
+
if (req.headers["content-type"]) {
|
|
3236
|
+
headers["content-type"] = req.headers["content-type"];
|
|
3237
|
+
}
|
|
3238
|
+
try {
|
|
3239
|
+
await new Promise((resolve5, reject) => {
|
|
3240
|
+
const proxyReq = requestFn(
|
|
3241
|
+
targetUrl,
|
|
3242
|
+
{ method: req.method ?? "GET", headers },
|
|
3243
|
+
(upstream) => {
|
|
3244
|
+
res.statusCode = upstream.statusCode ?? 502;
|
|
3245
|
+
const ct = upstream.headers["content-type"];
|
|
3246
|
+
if (ct) res.setHeader("content-type", ct);
|
|
3247
|
+
upstream.pipe(res);
|
|
3248
|
+
upstream.on("end", resolve5);
|
|
3249
|
+
upstream.on("error", reject);
|
|
3250
|
+
}
|
|
3251
|
+
);
|
|
3252
|
+
proxyReq.on("error", reject);
|
|
3253
|
+
if (body.length > 0) proxyReq.write(body);
|
|
3254
|
+
proxyReq.end();
|
|
3255
|
+
});
|
|
3256
|
+
} catch (err) {
|
|
3257
|
+
if (!res.headersSent) {
|
|
3258
|
+
sendError(res, 502, "cloud_unreachable", err instanceof Error ? err.message : String(err));
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
async function readToken(credPath) {
|
|
3263
|
+
const s = await stat3(credPath);
|
|
3264
|
+
if (process.platform !== "win32") {
|
|
3265
|
+
const groupWorldBits = s.mode & 63;
|
|
3266
|
+
if (groupWorldBits !== 0) {
|
|
3267
|
+
throw new Error(
|
|
3268
|
+
`credentials file ${credPath} has mode ${(s.mode & 511).toString(8)}; require 0600`
|
|
3269
|
+
);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const mtime = s.mtimeMs;
|
|
3273
|
+
if (cachedToken && mtime === cachedMtime) {
|
|
3274
|
+
return cachedToken;
|
|
3275
|
+
}
|
|
3276
|
+
const raw = await readFile12(credPath, "utf-8");
|
|
3277
|
+
const parsed = OAuthCredentialsSchema.parse(JSON.parse(raw));
|
|
3278
|
+
cachedToken = parsed.accessToken;
|
|
3279
|
+
cachedMtime = mtime;
|
|
3280
|
+
return cachedToken;
|
|
3281
|
+
}
|
|
3282
|
+
function readBody(req) {
|
|
3283
|
+
return new Promise((resolve5, reject) => {
|
|
3284
|
+
const chunks = [];
|
|
3285
|
+
let total = 0;
|
|
3286
|
+
let tooLarge = false;
|
|
3287
|
+
req.on("data", (c) => {
|
|
3288
|
+
total += c.length;
|
|
3289
|
+
if (total > MAX_BODY_BYTES) {
|
|
3290
|
+
tooLarge = true;
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
chunks.push(c);
|
|
3294
|
+
});
|
|
3295
|
+
req.on("end", () => resolve5({ body: Buffer.concat(chunks), tooLarge }));
|
|
3296
|
+
req.on("error", reject);
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
function sendError(res, status, code, message) {
|
|
3300
|
+
const envelope = { error: { code, message } };
|
|
3301
|
+
res.statusCode = status;
|
|
3302
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3303
|
+
res.end(JSON.stringify(envelope));
|
|
3304
|
+
}
|
|
3305
|
+
|
|
961
3306
|
// src/ui/api/service-manager.ts
|
|
962
|
-
|
|
3307
|
+
init_process_group();
|
|
3308
|
+
import { spawn as nodeSpawn2 } from "child_process";
|
|
963
3309
|
|
|
964
3310
|
// src/ui/api/service-readiness.ts
|
|
965
3311
|
async function pollReadiness(url, opts = {}) {
|
|
@@ -992,7 +3338,7 @@ async function pollReadiness(url, opts = {}) {
|
|
|
992
3338
|
if (opts.signal?.aborted || err instanceof AbortError) return { ready: false, aborted: true };
|
|
993
3339
|
lastError = err instanceof Error ? err.message : String(err);
|
|
994
3340
|
}
|
|
995
|
-
const result = await
|
|
3341
|
+
const result = await sleep2(intervalMs, opts.signal);
|
|
996
3342
|
if (result.aborted) return { ready: false, aborted: true };
|
|
997
3343
|
}
|
|
998
3344
|
return { ready: false, lastError };
|
|
@@ -1002,22 +3348,22 @@ var AbortError = class extends Error {
|
|
|
1002
3348
|
super("aborted");
|
|
1003
3349
|
}
|
|
1004
3350
|
};
|
|
1005
|
-
function
|
|
1006
|
-
return new Promise((
|
|
3351
|
+
function sleep2(ms, signal) {
|
|
3352
|
+
return new Promise((resolve5) => {
|
|
1007
3353
|
let onAbort = null;
|
|
1008
3354
|
const t = setTimeout(() => {
|
|
1009
3355
|
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
1010
|
-
|
|
3356
|
+
resolve5({ aborted: false });
|
|
1011
3357
|
}, ms);
|
|
1012
3358
|
if (signal?.aborted) {
|
|
1013
3359
|
clearTimeout(t);
|
|
1014
|
-
|
|
3360
|
+
resolve5({ aborted: true });
|
|
1015
3361
|
return;
|
|
1016
3362
|
}
|
|
1017
3363
|
onAbort = () => {
|
|
1018
3364
|
clearTimeout(t);
|
|
1019
3365
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
1020
|
-
|
|
3366
|
+
resolve5({ aborted: true });
|
|
1021
3367
|
};
|
|
1022
3368
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1023
3369
|
});
|
|
@@ -1028,10 +3374,14 @@ var LOG_BUFFER_BYTES = 4096;
|
|
|
1028
3374
|
var ServiceManager = class {
|
|
1029
3375
|
spawn;
|
|
1030
3376
|
poll;
|
|
3377
|
+
kill;
|
|
3378
|
+
killGracePeriodMs;
|
|
1031
3379
|
services = /* @__PURE__ */ new Map();
|
|
1032
3380
|
constructor(deps = {}) {
|
|
1033
|
-
this.spawn = deps.spawn ??
|
|
3381
|
+
this.spawn = deps.spawn ?? nodeSpawn2;
|
|
1034
3382
|
this.poll = deps.pollReadiness ?? pollReadiness;
|
|
3383
|
+
this.kill = deps.kill;
|
|
3384
|
+
this.killGracePeriodMs = deps.killGracePeriodMs ?? 5e3;
|
|
1035
3385
|
}
|
|
1036
3386
|
keyFor(input) {
|
|
1037
3387
|
return JSON.stringify({ command: input.command, cwd: input.cwd });
|
|
@@ -1057,7 +3407,12 @@ var ServiceManager = class {
|
|
|
1057
3407
|
const state = { status: "starting", logs: "" };
|
|
1058
3408
|
let child;
|
|
1059
3409
|
try {
|
|
1060
|
-
child =
|
|
3410
|
+
child = spawnDetached(
|
|
3411
|
+
bin,
|
|
3412
|
+
args,
|
|
3413
|
+
{ cwd: input.cwd, stdio: ["ignore", "pipe", "pipe"] },
|
|
3414
|
+
{ spawn: this.spawn }
|
|
3415
|
+
);
|
|
1061
3416
|
} catch (err) {
|
|
1062
3417
|
return { status: "failed", logs: "", message: `spawn failed: ${err.message}` };
|
|
1063
3418
|
}
|
|
@@ -1084,24 +3439,34 @@ var ServiceManager = class {
|
|
|
1084
3439
|
void this.poll(input.readyUrl, {
|
|
1085
3440
|
timeoutMs: input.startupTimeoutMs ?? 3e4,
|
|
1086
3441
|
signal: abort.signal
|
|
1087
|
-
}).then((result) => {
|
|
3442
|
+
}).then(async (result) => {
|
|
1088
3443
|
if (state.status !== "starting") return;
|
|
1089
3444
|
if (result.ready) {
|
|
1090
3445
|
state.status = "ready";
|
|
1091
3446
|
} else if (!result.aborted) {
|
|
1092
3447
|
state.status = "failed";
|
|
1093
3448
|
state.message = `readiness check failed: ${result.lastError ?? "timeout"}`;
|
|
1094
|
-
if (!child.killed)
|
|
3449
|
+
if (!child.killed) {
|
|
3450
|
+
await killTree(child, { kill: this.kill, gracePeriodMs: this.killGracePeriodMs });
|
|
3451
|
+
}
|
|
1095
3452
|
}
|
|
1096
3453
|
});
|
|
1097
3454
|
return state;
|
|
1098
3455
|
}
|
|
1099
|
-
shutdown() {
|
|
1100
|
-
|
|
1101
|
-
entry.abort.abort();
|
|
1102
|
-
if (entry.child && !entry.child.killed) entry.child.kill();
|
|
1103
|
-
}
|
|
3456
|
+
async shutdown() {
|
|
3457
|
+
const entries = [...this.services.values()];
|
|
1104
3458
|
this.services.clear();
|
|
3459
|
+
await Promise.all(
|
|
3460
|
+
entries.map(async (entry) => {
|
|
3461
|
+
entry.abort.abort();
|
|
3462
|
+
if (entry.child && !entry.child.killed) {
|
|
3463
|
+
await killTree(entry.child, {
|
|
3464
|
+
kill: this.kill,
|
|
3465
|
+
gracePeriodMs: this.killGracePeriodMs
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
})
|
|
3469
|
+
);
|
|
1105
3470
|
}
|
|
1106
3471
|
};
|
|
1107
3472
|
|
|
@@ -1170,19 +3535,26 @@ var MIME_TYPES = {
|
|
|
1170
3535
|
".map": "application/json; charset=utf-8"
|
|
1171
3536
|
};
|
|
1172
3537
|
async function startUiServer(opts) {
|
|
1173
|
-
const staticDir =
|
|
3538
|
+
const staticDir = resolve3(opts.staticDir);
|
|
1174
3539
|
if (!existsSync4(staticDir)) {
|
|
1175
3540
|
throw new Error(
|
|
1176
3541
|
`Macroscope UI build is missing. Expected files at ${staticDir}. Run \`pnpm build\` first.`
|
|
1177
3542
|
);
|
|
1178
3543
|
}
|
|
1179
|
-
const indexFile =
|
|
3544
|
+
const indexFile = join9(staticDir, "index.html");
|
|
1180
3545
|
if (!existsSync4(indexFile)) {
|
|
1181
3546
|
throw new Error(`Macroscope UI build is incomplete: ${indexFile} not found.`);
|
|
1182
3547
|
}
|
|
1183
3548
|
const serviceManager = new ServiceManager();
|
|
1184
|
-
const
|
|
1185
|
-
|
|
3549
|
+
const progressBus = createProgressBus();
|
|
3550
|
+
const credentialsPath2 = opts.credentialsPath ?? join9(homedir4(), FILESYSTEM_LAYOUT.user.credentialsFile);
|
|
3551
|
+
const proxyOpts = { cloudUrl: opts.cloudUrl, credentialsPath: credentialsPath2 };
|
|
3552
|
+
const server = createServer2((req, res) => {
|
|
3553
|
+
if (req.url && new URL(req.url, "http://localhost").pathname === "/api/local/progress") {
|
|
3554
|
+
progressBus.handleStream(req, res);
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
void handleRequest(req, res, staticDir, opts.cwd, serviceManager, proxyOpts).catch((err) => {
|
|
1186
3558
|
respondError(res, 500, err instanceof Error ? err.message : String(err));
|
|
1187
3559
|
});
|
|
1188
3560
|
});
|
|
@@ -1208,22 +3580,106 @@ async function startUiServer(opts) {
|
|
|
1208
3580
|
} catch {
|
|
1209
3581
|
}
|
|
1210
3582
|
}
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
process.once("SIGTERM", onSignal);
|
|
3583
|
+
const watcher = await maybeStartCloudSync(opts, credentialsPath2, progressBus);
|
|
3584
|
+
let closed = false;
|
|
1214
3585
|
return {
|
|
1215
3586
|
url,
|
|
1216
3587
|
port,
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
3588
|
+
serviceManager,
|
|
3589
|
+
watcher,
|
|
3590
|
+
close: async () => {
|
|
3591
|
+
if (closed) return;
|
|
3592
|
+
closed = true;
|
|
3593
|
+
if (watcher) {
|
|
3594
|
+
await watcher.stop().catch((err) => {
|
|
3595
|
+
console.warn(
|
|
3596
|
+
`[macroscope] watcher.stop() failed: ${err instanceof Error ? err.message : String(err)}`
|
|
3597
|
+
);
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
progressBus.close();
|
|
3601
|
+
await serviceManager.shutdown();
|
|
3602
|
+
await new Promise(
|
|
3603
|
+
(resolveClose, reject) => server.close((err) => err ? reject(err) : resolveClose())
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
1223
3606
|
};
|
|
1224
3607
|
}
|
|
1225
|
-
async function
|
|
3608
|
+
async function maybeStartCloudSync(opts, credentialsPath2, progressBus) {
|
|
3609
|
+
if (opts.cloudSync === false) return null;
|
|
3610
|
+
const sync = opts.cloudSync ?? {};
|
|
3611
|
+
const cloudClient = sync.cloudClient ?? createCloudClient({ baseUrl: opts.cloudUrl, credentialsPath: credentialsPath2 });
|
|
3612
|
+
let cloudFatalError = null;
|
|
3613
|
+
try {
|
|
3614
|
+
const result = await runCatchup({
|
|
3615
|
+
projectRoot: opts.cwd,
|
|
3616
|
+
cloudClient,
|
|
3617
|
+
onProgress: (e) => {
|
|
3618
|
+
sync.onCatchupProgress?.(e);
|
|
3619
|
+
progressBus.emit({ source: "catchup", payload: e });
|
|
3620
|
+
if (e.kind === "error" && !e.retryable) {
|
|
3621
|
+
cloudFatalError = e.err.message;
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
});
|
|
3625
|
+
if (result.upserts > 0 || result.deletes > 0) {
|
|
3626
|
+
console.log(
|
|
3627
|
+
`[macroscope] catch-up scan complete: ${result.upserts} upserts, ${result.deletes} deletes (${result.durationMs}ms)`
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
} catch (err) {
|
|
3631
|
+
cloudFatalError = err instanceof Error ? err.message : String(err);
|
|
3632
|
+
}
|
|
3633
|
+
if (cloudFatalError) {
|
|
3634
|
+
console.warn(
|
|
3635
|
+
`[macroscope] cloud catalogue disabled: ${cloudFatalError} \u2014 local code search still works. Run \`macroscope login\` to enable cloud search.`
|
|
3636
|
+
);
|
|
3637
|
+
return null;
|
|
3638
|
+
}
|
|
3639
|
+
try {
|
|
3640
|
+
const watcher = await startWatcher({
|
|
3641
|
+
projectRoot: opts.cwd,
|
|
3642
|
+
cloudClient,
|
|
3643
|
+
lifecycle: false
|
|
3644
|
+
});
|
|
3645
|
+
const userFwd = sync.onWatcherEvent;
|
|
3646
|
+
for (const name of ["batchStart", "batchSent", "error", "queueDrained"]) {
|
|
3647
|
+
watcher.on(name, (payload) => {
|
|
3648
|
+
userFwd?.(name, payload);
|
|
3649
|
+
progressBus.emit({
|
|
3650
|
+
source: "watcher",
|
|
3651
|
+
payload: { event: name, data: payload }
|
|
3652
|
+
});
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
return watcher;
|
|
3656
|
+
} catch (err) {
|
|
3657
|
+
console.warn(
|
|
3658
|
+
`[macroscope] watcher failed to start: ${err instanceof Error ? err.message : String(err)}`
|
|
3659
|
+
);
|
|
3660
|
+
return null;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
async function handleRequest(req, res, staticDir, cwd, serviceManager, proxyOpts) {
|
|
1226
3664
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3665
|
+
if (isCloudProxyRoute(url.pathname)) {
|
|
3666
|
+
await handleCloudProxy(req, res, proxyOpts);
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
if (url.pathname === "/api/local/code-search") {
|
|
3670
|
+
const result = await handleCodeSearchRequest(cwd, url.searchParams.get("q"));
|
|
3671
|
+
res.statusCode = result.status;
|
|
3672
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3673
|
+
res.end(JSON.stringify(result.body));
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
if (url.pathname === "/api/local/context") {
|
|
3677
|
+
const result = await handleContextRequest(cwd);
|
|
3678
|
+
res.statusCode = result.status;
|
|
3679
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3680
|
+
res.end(JSON.stringify(result.body));
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
1227
3683
|
if (url.pathname === "/api/blueprints") {
|
|
1228
3684
|
const result = await handleBlueprintsRequest(cwd);
|
|
1229
3685
|
res.statusCode = result.status;
|
|
@@ -1246,8 +3702,8 @@ async function handleRequest(req, res, staticDir, cwd, serviceManager) {
|
|
|
1246
3702
|
res.end("Missing ?path");
|
|
1247
3703
|
return;
|
|
1248
3704
|
}
|
|
1249
|
-
const absolute =
|
|
1250
|
-
if (!absolute.startsWith(
|
|
3705
|
+
const absolute = resolve3(cwd, requestedPath);
|
|
3706
|
+
if (!absolute.startsWith(resolve3(cwd))) {
|
|
1251
3707
|
res.statusCode = 403;
|
|
1252
3708
|
res.setHeader("content-type", "text/plain");
|
|
1253
3709
|
res.end("Forbidden");
|
|
@@ -1268,8 +3724,8 @@ async function handleRequest(req, res, staticDir, cwd, serviceManager) {
|
|
|
1268
3724
|
res.end("Missing ?path");
|
|
1269
3725
|
return;
|
|
1270
3726
|
}
|
|
1271
|
-
const absolute =
|
|
1272
|
-
if (!absolute.startsWith(
|
|
3727
|
+
const absolute = resolve3(cwd, requestedPath);
|
|
3728
|
+
if (!absolute.startsWith(resolve3(cwd))) {
|
|
1273
3729
|
res.statusCode = 403;
|
|
1274
3730
|
res.setHeader("content-type", "text/plain");
|
|
1275
3731
|
res.end("Forbidden");
|
|
@@ -1282,7 +3738,7 @@ async function handleRequest(req, res, staticDir, cwd, serviceManager) {
|
|
|
1282
3738
|
return;
|
|
1283
3739
|
}
|
|
1284
3740
|
if (url.pathname === "/api/services/ensure" && req.method === "POST") {
|
|
1285
|
-
const raw = await
|
|
3741
|
+
const raw = await readBody2(req);
|
|
1286
3742
|
let parsed;
|
|
1287
3743
|
try {
|
|
1288
3744
|
parsed = JSON.parse(raw || "{}");
|
|
@@ -1302,19 +3758,19 @@ async function handleRequest(req, res, staticDir, cwd, serviceManager) {
|
|
|
1302
3758
|
return;
|
|
1303
3759
|
}
|
|
1304
3760
|
const requested = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
1305
|
-
const absolutePath = normalize(
|
|
3761
|
+
const absolutePath = normalize(join9(staticDir, requested));
|
|
1306
3762
|
if (!absolutePath.startsWith(staticDir)) {
|
|
1307
3763
|
respondError(res, 403, "Forbidden");
|
|
1308
3764
|
return;
|
|
1309
3765
|
}
|
|
1310
3766
|
try {
|
|
1311
|
-
const content = await
|
|
3767
|
+
const content = await readFile13(absolutePath);
|
|
1312
3768
|
res.setHeader("content-type", MIME_TYPES[extname2(absolutePath)] ?? "application/octet-stream");
|
|
1313
3769
|
res.end(content);
|
|
1314
3770
|
return;
|
|
1315
3771
|
} catch {
|
|
1316
3772
|
try {
|
|
1317
|
-
const indexContent = await
|
|
3773
|
+
const indexContent = await readFile13(join9(staticDir, "index.html"));
|
|
1318
3774
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1319
3775
|
res.end(indexContent);
|
|
1320
3776
|
} catch {
|
|
@@ -1327,7 +3783,7 @@ function respondError(res, status, message) {
|
|
|
1327
3783
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1328
3784
|
res.end(message);
|
|
1329
3785
|
}
|
|
1330
|
-
function
|
|
3786
|
+
function readBody2(req) {
|
|
1331
3787
|
return new Promise((resolveBody, reject) => {
|
|
1332
3788
|
const chunks = [];
|
|
1333
3789
|
req.on("data", (c) => chunks.push(c));
|
|
@@ -1361,12 +3817,62 @@ function main() {
|
|
|
1361
3817
|
"--port <n>",
|
|
1362
3818
|
"port to listen on (default: auto-pick a free port)",
|
|
1363
3819
|
(v) => Number.parseInt(v, 10)
|
|
1364
|
-
).option("--no-open", "don't automatically open a browser").
|
|
3820
|
+
).option("--no-open", "don't automatically open a browser").option(
|
|
3821
|
+
"--cloud-url <url>",
|
|
3822
|
+
"override the cloud API URL (also: MACROSCOPE_CLOUD_URL env var; defaults to http://localhost:3001)"
|
|
3823
|
+
).option(
|
|
3824
|
+
"--credentials <path>",
|
|
3825
|
+
"override the credentials file path (defaults to ~/.macroscope/credentials)"
|
|
3826
|
+
).action(async (cmdOpts) => {
|
|
1365
3827
|
await runUi({
|
|
1366
3828
|
cwd: cmdOpts.cwd,
|
|
1367
3829
|
port: cmdOpts.port,
|
|
1368
|
-
open: cmdOpts.open !== false
|
|
3830
|
+
open: cmdOpts.open !== false,
|
|
3831
|
+
cloudUrl: cmdOpts.cloudUrl,
|
|
3832
|
+
credentialsPath: cmdOpts.credentials
|
|
3833
|
+
});
|
|
3834
|
+
});
|
|
3835
|
+
program.command("login").description(
|
|
3836
|
+
"Authenticate with a Git host (GitHub or GitLab) and store credentials at ~/.macroscope/credentials"
|
|
3837
|
+
).addOption(
|
|
3838
|
+
new Option("--provider <id>", "identity provider").choices(["github", "gitlab"]).default("github")
|
|
3839
|
+
).action(async (cmdOpts, cmd) => {
|
|
3840
|
+
const globalOpts = cmd.parent?.opts() ?? {};
|
|
3841
|
+
const code = await runLogin({
|
|
3842
|
+
providerId: cmdOpts.provider,
|
|
3843
|
+
json: !!globalOpts.json
|
|
3844
|
+
});
|
|
3845
|
+
process.exitCode = code;
|
|
3846
|
+
});
|
|
3847
|
+
program.command("logout").description("Delete the stored credentials file at ~/.macroscope/credentials").action(async (_cmdOpts, cmd) => {
|
|
3848
|
+
const globalOpts = cmd.parent?.opts() ?? {};
|
|
3849
|
+
const code = await runLogout({ json: !!globalOpts.json });
|
|
3850
|
+
process.exitCode = code;
|
|
3851
|
+
});
|
|
3852
|
+
program.command("search <query>").description("Search the cloud catalogue and local code in one shot").option("--cwd <path>", "project search starts here (defaults to current dir)").option("--worktree <id>", "restrict to one worktree").option("--all-worktrees", "search across all worktrees (default until T14)").option("--all-repos", "no repo filter (default)").option("--repo <name>", "restrict to one repo").option("--kind <k>", "restrict to one block kind").option("--limit <n>", "cap results per side", (v) => Number.parseInt(v, 10)).action(async (query, cmdOpts, cmd) => {
|
|
3853
|
+
const globalOpts = cmd.parent?.opts() ?? {};
|
|
3854
|
+
const code = await runSearch({
|
|
3855
|
+
query,
|
|
3856
|
+
json: !!globalOpts.json,
|
|
3857
|
+
cwd: cmdOpts.cwd,
|
|
3858
|
+
scope: {
|
|
3859
|
+
worktree: cmdOpts.worktree,
|
|
3860
|
+
allWorktrees: !!cmdOpts.allWorktrees,
|
|
3861
|
+
allRepos: !!cmdOpts.allRepos,
|
|
3862
|
+
repo: cmdOpts.repo,
|
|
3863
|
+
kind: cmdOpts.kind,
|
|
3864
|
+
limit: cmdOpts.limit
|
|
3865
|
+
}
|
|
3866
|
+
});
|
|
3867
|
+
process.exitCode = code;
|
|
3868
|
+
});
|
|
3869
|
+
program.command("status").description("Show worktree + cloud + login state").option("--cwd <path>", "project search starts here (defaults to current dir)").action(async (cmdOpts, cmd) => {
|
|
3870
|
+
const globalOpts = cmd.parent?.opts() ?? {};
|
|
3871
|
+
const code = await runStatus({
|
|
3872
|
+
json: !!globalOpts.json,
|
|
3873
|
+
cwd: cmdOpts.cwd
|
|
1369
3874
|
});
|
|
3875
|
+
process.exitCode = code;
|
|
1370
3876
|
});
|
|
1371
3877
|
program.parse(process.argv);
|
|
1372
3878
|
}
|
|
@@ -1386,13 +3892,13 @@ async function runBlueprints(opts) {
|
|
|
1386
3892
|
if (opts.json) {
|
|
1387
3893
|
writeJson(result);
|
|
1388
3894
|
} else {
|
|
1389
|
-
|
|
3895
|
+
writeHuman3(result);
|
|
1390
3896
|
}
|
|
1391
3897
|
if (result.errors.length > 0) {
|
|
1392
3898
|
process.exitCode = 1;
|
|
1393
3899
|
}
|
|
1394
3900
|
}
|
|
1395
|
-
function
|
|
3901
|
+
function writeHuman3(result) {
|
|
1396
3902
|
if (result.blueprints.length === 0 && result.errors.length === 0) {
|
|
1397
3903
|
process.stdout.write(
|
|
1398
3904
|
"No blueprints found. Create one at .macroscope/blueprints/<kind>/handler.ts.\n"
|
|
@@ -1441,25 +3947,40 @@ async function runUi(opts) {
|
|
|
1441
3947
|
}
|
|
1442
3948
|
throw err;
|
|
1443
3949
|
}
|
|
1444
|
-
const here =
|
|
1445
|
-
const staticDir =
|
|
3950
|
+
const here = dirname8(fileURLToPath(import.meta.url));
|
|
3951
|
+
const staticDir = resolve4(here, "ui");
|
|
1446
3952
|
let server;
|
|
1447
3953
|
try {
|
|
1448
|
-
server = await startUiServer({
|
|
3954
|
+
server = await startUiServer({
|
|
3955
|
+
cwd,
|
|
3956
|
+
staticDir,
|
|
3957
|
+
port: opts.port,
|
|
3958
|
+
open: opts.open,
|
|
3959
|
+
cloudUrl: opts.cloudUrl,
|
|
3960
|
+
credentialsPath: opts.credentialsPath
|
|
3961
|
+
});
|
|
1449
3962
|
} catch (err) {
|
|
1450
3963
|
process.stderr.write(`macroscope ui: ${err.message}
|
|
1451
3964
|
`);
|
|
1452
3965
|
process.exit(1);
|
|
1453
3966
|
}
|
|
3967
|
+
installLifecycle({
|
|
3968
|
+
watchStdin: true,
|
|
3969
|
+
onShutdown: async (reason) => {
|
|
3970
|
+
process.stderr.write(`macroscope shutting down (${reason})
|
|
3971
|
+
`);
|
|
3972
|
+
try {
|
|
3973
|
+
await server.close();
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
process.stderr.write(`shutdown error: ${err.message}
|
|
3976
|
+
`);
|
|
3977
|
+
}
|
|
3978
|
+
process.exit(0);
|
|
3979
|
+
}
|
|
3980
|
+
});
|
|
1454
3981
|
process.stdout.write(`macroscope ui running at ${server.url}
|
|
1455
3982
|
`);
|
|
1456
3983
|
process.stdout.write("Press Ctrl+C to stop.\n");
|
|
1457
|
-
const shutdown = async () => {
|
|
1458
|
-
await server.close();
|
|
1459
|
-
process.exit(0);
|
|
1460
|
-
};
|
|
1461
|
-
process.on("SIGINT", () => void shutdown());
|
|
1462
|
-
process.on("SIGTERM", () => void shutdown());
|
|
1463
3984
|
}
|
|
1464
3985
|
function writeError(message, json) {
|
|
1465
3986
|
if (json) {
|