@appfleet-cli/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/appfleet.d.ts +4 -0
- package/dist/appfleet.js +12253 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +85 -0
- package/dist/billing-cost.d.ts +8 -0
- package/dist/billing-cost.js +186 -0
- package/dist/cloud-session.d.ts +124 -0
- package/dist/cloud-session.js +1819 -0
- package/dist/command-registry.d.ts +18 -0
- package/dist/command-registry.js +1067 -0
- package/dist/demo-fixture.d.ts +11 -0
- package/dist/demo-fixture.js +39 -0
- package/dist/generate-cli-docs.d.ts +1 -0
- package/dist/generate-cli-docs.js +94 -0
- package/dist/health.d.ts +8 -0
- package/dist/health.js +60 -0
- package/dist/local-vault.d.ts +75 -0
- package/dist/local-vault.js +1169 -0
- package/dist/operations.d.ts +8 -0
- package/dist/operations.js +220 -0
- package/dist/project-memory.d.ts +138 -0
- package/dist/project-memory.js +1529 -0
- package/dist/prototype-inject.d.ts +21 -0
- package/dist/prototype-inject.js +170 -0
- package/dist/provider-integrations.d.ts +8 -0
- package/dist/provider-integrations.js +197 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
8
|
+
import { createAppProjectMemory, createDemoFleetProjectMemories, createProjectDoctorReport, createProjectMemorySyncRequest, createProjectMemorySyncResult, createProjectStatusSummary, } from "@appfleet/domain";
|
|
9
|
+
const defaultStorePath = defaultProjectMemoryStorePath(process.cwd());
|
|
10
|
+
const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export function parseProjectMemoryCommand(argv) {
|
|
13
|
+
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
|
|
14
|
+
if (normalizedArgv[0] !== "projects") {
|
|
15
|
+
throw new Error("usage: projects <create|edit|env|discover|remember|brief|status|doctor|dashboard|check|sync|seed-demo-fleet> [arguments]");
|
|
16
|
+
}
|
|
17
|
+
const action = normalizedArgv[1];
|
|
18
|
+
if (action === "seed-demo-fleet") {
|
|
19
|
+
return { action };
|
|
20
|
+
}
|
|
21
|
+
if (action === "discover") {
|
|
22
|
+
return {
|
|
23
|
+
action,
|
|
24
|
+
rootPath: readOptionalPositional(normalizedArgv, 2),
|
|
25
|
+
appId: readFlag(normalizedArgv, "--project"),
|
|
26
|
+
name: readFlag(normalizedArgv, "--name"),
|
|
27
|
+
canonicalUrl: readFlag(normalizedArgv, "--url"),
|
|
28
|
+
remember: hasFlag(normalizedArgv, "--remember"),
|
|
29
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (action === "brief") {
|
|
33
|
+
return {
|
|
34
|
+
action,
|
|
35
|
+
appId: readOptionalPositional(normalizedArgv, 2),
|
|
36
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (action === "status" || action === "doctor" || action === "dashboard") {
|
|
40
|
+
return {
|
|
41
|
+
action,
|
|
42
|
+
appId: readOptionalPositional(normalizedArgv, 2),
|
|
43
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (action === "check") {
|
|
47
|
+
const appId = normalizedArgv[2];
|
|
48
|
+
if (!appId) {
|
|
49
|
+
throw new Error("usage: projects check <project>");
|
|
50
|
+
}
|
|
51
|
+
return { action, appId };
|
|
52
|
+
}
|
|
53
|
+
if (action === "sync") {
|
|
54
|
+
return {
|
|
55
|
+
action,
|
|
56
|
+
workspaceId: readFlag(normalizedArgv, "--workspace") ?? "workspace_local",
|
|
57
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (action === "create" || action === "remember") {
|
|
61
|
+
const appId = normalizedArgv[2];
|
|
62
|
+
const canonicalUrl = readFlag(normalizedArgv, "--url");
|
|
63
|
+
if (!appId || !canonicalUrl) {
|
|
64
|
+
throw new Error(`usage: projects ${action} <project> --url <url>`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
action,
|
|
68
|
+
appId,
|
|
69
|
+
name: readFlag(normalizedArgv, "--name") ?? appId,
|
|
70
|
+
canonicalUrl,
|
|
71
|
+
repoUrl: readFlag(normalizedArgv, "--repo"),
|
|
72
|
+
gitRemoteFingerprint: readFlag(normalizedArgv, "--git-remote-fingerprint"),
|
|
73
|
+
localPaths: readRepeatedFlag(normalizedArgv, "--path"),
|
|
74
|
+
rememberedUrls: readRepeatedFlag(normalizedArgv, "--remembered-url"),
|
|
75
|
+
providers: readRepeatedFlag(normalizedArgv, "--provider").map(providerMemoryFromInput),
|
|
76
|
+
environmentAliases: readRepeatedFlag(normalizedArgv, "--env-alias").map(envAliasFromInput),
|
|
77
|
+
notes: readRepeatedFlag(normalizedArgv, "--note"),
|
|
78
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (action === "edit") {
|
|
82
|
+
const appId = normalizedArgv[2];
|
|
83
|
+
if (!appId) {
|
|
84
|
+
throw new Error("usage: projects edit <project> [options]");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
action,
|
|
88
|
+
appId,
|
|
89
|
+
name: readFlag(normalizedArgv, "--name"),
|
|
90
|
+
canonicalUrl: readFlag(normalizedArgv, "--url"),
|
|
91
|
+
repoUrl: readFlag(normalizedArgv, "--repo"),
|
|
92
|
+
gitRemoteFingerprint: readFlag(normalizedArgv, "--git-remote-fingerprint"),
|
|
93
|
+
rememberedUrls: readRepeatedFlag(normalizedArgv, "--remembered-url"),
|
|
94
|
+
providers: readRepeatedFlag(normalizedArgv, "--provider").map(providerMemoryFromInput),
|
|
95
|
+
environmentAliases: readRepeatedFlag(normalizedArgv, "--env-alias").map(envAliasFromInput),
|
|
96
|
+
notes: readRepeatedFlag(normalizedArgv, "--note"),
|
|
97
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (action === "env") {
|
|
101
|
+
const envAction = normalizedArgv[2];
|
|
102
|
+
const appId = normalizedArgv[3];
|
|
103
|
+
if (envAction === "list") {
|
|
104
|
+
if (!appId) {
|
|
105
|
+
throw new Error("usage: projects env list <project> [--json]");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
action: "env-list",
|
|
109
|
+
appId,
|
|
110
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (envAction === "add") {
|
|
114
|
+
const environment = normalizedArgv[4];
|
|
115
|
+
if (!appId || !environment) {
|
|
116
|
+
throw new Error("usage: projects env add <project> <environment> [options]");
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
action: "env-add",
|
|
120
|
+
appId,
|
|
121
|
+
environment,
|
|
122
|
+
environmentAliases: readRepeatedFlag(normalizedArgv, "--env-alias").map(envAliasFromInput),
|
|
123
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (envAction === "remove") {
|
|
127
|
+
const environment = normalizedArgv[4];
|
|
128
|
+
if (!appId || !environment) {
|
|
129
|
+
throw new Error("usage: projects env remove <project> <environment> [options]");
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
action: "env-remove",
|
|
133
|
+
appId,
|
|
134
|
+
environment,
|
|
135
|
+
environmentAliasNames: readRepeatedFlag(normalizedArgv, "--env-alias").map(envAliasNameFromInput),
|
|
136
|
+
outputFormat: hasFlag(normalizedArgv, "--json") ? "json" : "human",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw new Error("usage: projects env <list|add|remove> [arguments]");
|
|
140
|
+
}
|
|
141
|
+
throw new Error("usage: projects <create|edit|env|discover|remember|brief|status|doctor|dashboard|check|sync|seed-demo-fleet> [arguments]");
|
|
142
|
+
}
|
|
143
|
+
export async function runProjectMemoryCommand(argv, options = {}) {
|
|
144
|
+
const checkUrl = options.checkUrl ?? checkHttpStatus;
|
|
145
|
+
const now = options.now ?? (() => new Date());
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = parseProjectMemoryCommand(argv);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return {
|
|
152
|
+
exitCode: 1,
|
|
153
|
+
stdout: "",
|
|
154
|
+
stderr: `AppFleet project memory failed: ${errorMessage(error)}\n`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const storePath = options.storePath ?? defaultStorePathForCommand(parsed, options);
|
|
158
|
+
try {
|
|
159
|
+
if (parsed.action === "seed-demo-fleet") {
|
|
160
|
+
const memories = createDemoFleetProjectMemories({
|
|
161
|
+
checkedAt: now().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
await upsertProjectMemories(storePath, memories);
|
|
164
|
+
return {
|
|
165
|
+
exitCode: 0,
|
|
166
|
+
stdout: formatDemoFleetSaved(memories),
|
|
167
|
+
stderr: "",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (parsed.action === "discover") {
|
|
171
|
+
const rootPath = resolve(parsed.rootPath ?? options.projectRoot ?? process.cwd());
|
|
172
|
+
const gitRemoteFingerprint = options.currentGitRemoteFingerprint ??
|
|
173
|
+
(await (options.detectGitRemoteFingerprint ??
|
|
174
|
+
(() => detectCurrentGitRemoteFingerprint(rootPath)))());
|
|
175
|
+
const discovery = await discoverLocalProject({
|
|
176
|
+
rootPath,
|
|
177
|
+
appId: parsed.appId,
|
|
178
|
+
name: parsed.name,
|
|
179
|
+
canonicalUrl: parsed.canonicalUrl,
|
|
180
|
+
gitRemoteFingerprint,
|
|
181
|
+
remember: parsed.remember,
|
|
182
|
+
});
|
|
183
|
+
if (parsed.remember) {
|
|
184
|
+
if (!discovery.project.canonicalUrl) {
|
|
185
|
+
return {
|
|
186
|
+
exitCode: 1,
|
|
187
|
+
stdout: "",
|
|
188
|
+
stderr: "AppFleet project discovery failed: --remember requires --url or package.json homepage\n",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
await upsertProjectMemory(storePath, memoryFromDiscovery(discovery));
|
|
192
|
+
}
|
|
193
|
+
const report = parsed.remember
|
|
194
|
+
? { ...discovery, remembered: true }
|
|
195
|
+
: discovery;
|
|
196
|
+
return {
|
|
197
|
+
exitCode: 0,
|
|
198
|
+
stdout: parsed.outputFormat === "json"
|
|
199
|
+
? formatJson(report)
|
|
200
|
+
: formatProjectDiscovery(report),
|
|
201
|
+
stderr: "",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (parsed.action === "create" || parsed.action === "remember") {
|
|
205
|
+
const gitRemoteFingerprint = parsed.gitRemoteFingerprint ??
|
|
206
|
+
options.currentGitRemoteFingerprint ??
|
|
207
|
+
(await (options.detectGitRemoteFingerprint ??
|
|
208
|
+
detectCurrentGitRemoteFingerprint)());
|
|
209
|
+
const memory = createMemoryFromSafeInput(parsed, gitRemoteFingerprint);
|
|
210
|
+
await upsertProjectMemory(storePath, memory);
|
|
211
|
+
return {
|
|
212
|
+
exitCode: 0,
|
|
213
|
+
stdout: parsed.outputFormat === "json"
|
|
214
|
+
? formatJson(createRememberResult(memory))
|
|
215
|
+
: formatMemorySaved(memory),
|
|
216
|
+
stderr: "",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (parsed.action === "edit" ||
|
|
220
|
+
parsed.action === "env-list" ||
|
|
221
|
+
parsed.action === "env-add" ||
|
|
222
|
+
parsed.action === "env-remove") {
|
|
223
|
+
const memories = await readProjectMemories(storePath);
|
|
224
|
+
const memory = memories.find((candidate) => candidate.id === parsed.appId);
|
|
225
|
+
if (!memory) {
|
|
226
|
+
return {
|
|
227
|
+
exitCode: 1,
|
|
228
|
+
stdout: "",
|
|
229
|
+
stderr: `AppFleet project memory failed: no project memory for ${parsed.appId}\n`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (parsed.action === "env-list") {
|
|
233
|
+
const result = createProjectEnvResult(memory);
|
|
234
|
+
return {
|
|
235
|
+
exitCode: 0,
|
|
236
|
+
stdout: parsed.outputFormat === "json"
|
|
237
|
+
? formatJson(result)
|
|
238
|
+
: formatProjectEnvResult(result),
|
|
239
|
+
stderr: "",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const updatedMemory = parsed.action === "edit"
|
|
243
|
+
? editProjectMemory(memory, parsed)
|
|
244
|
+
: parsed.action === "env-add"
|
|
245
|
+
? addProjectEnvironment(memory, parsed)
|
|
246
|
+
: removeProjectEnvironment(memory, parsed);
|
|
247
|
+
await upsertProjectMemory(storePath, updatedMemory);
|
|
248
|
+
const result = createProjectEnvResult(updatedMemory);
|
|
249
|
+
return {
|
|
250
|
+
exitCode: 0,
|
|
251
|
+
stdout: parsed.outputFormat === "json"
|
|
252
|
+
? formatJson(result)
|
|
253
|
+
: parsed.action === "edit"
|
|
254
|
+
? formatMemorySaved(updatedMemory)
|
|
255
|
+
: formatProjectEnvResult(result),
|
|
256
|
+
stderr: "",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const memories = await readProjectMemories(storePath);
|
|
260
|
+
if (parsed.action === "brief" ||
|
|
261
|
+
parsed.action === "status" ||
|
|
262
|
+
parsed.action === "doctor" ||
|
|
263
|
+
parsed.action === "dashboard") {
|
|
264
|
+
const currentGitRemoteFingerprint = options.currentGitRemoteFingerprint ??
|
|
265
|
+
(await (options.detectGitRemoteFingerprint ??
|
|
266
|
+
detectCurrentGitRemoteFingerprint)());
|
|
267
|
+
if (parsed.action === "dashboard") {
|
|
268
|
+
const resolvedForDashboard = resolveProjectMemory(memories, {
|
|
269
|
+
appId: parsed.appId,
|
|
270
|
+
currentGitRemoteFingerprint,
|
|
271
|
+
});
|
|
272
|
+
if (parsed.appId && !resolvedForDashboard.ok) {
|
|
273
|
+
return {
|
|
274
|
+
exitCode: 1,
|
|
275
|
+
stdout: "",
|
|
276
|
+
stderr: `${resolvedForDashboard.message}\n`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const guide = createDashboardGuide({
|
|
280
|
+
memories,
|
|
281
|
+
selectedMemory: resolvedForDashboard.ok
|
|
282
|
+
? resolvedForDashboard.memory
|
|
283
|
+
: undefined,
|
|
284
|
+
storePath,
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
stdout: parsed.outputFormat === "json"
|
|
289
|
+
? formatJson(guide)
|
|
290
|
+
: formatDashboardGuide(guide),
|
|
291
|
+
stderr: "",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const resolved = resolveProjectMemory(memories, {
|
|
295
|
+
appId: parsed.appId,
|
|
296
|
+
currentGitRemoteFingerprint,
|
|
297
|
+
});
|
|
298
|
+
if (!resolved.ok) {
|
|
299
|
+
return { exitCode: 1, stdout: "", stderr: `${resolved.message}\n` };
|
|
300
|
+
}
|
|
301
|
+
if (parsed.action === "status") {
|
|
302
|
+
const summary = createProjectStatusSummary({
|
|
303
|
+
memory: resolved.memory,
|
|
304
|
+
nextActions: createStatusNextActions(resolved.memory),
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
exitCode: 0,
|
|
308
|
+
stdout: parsed.outputFormat === "json"
|
|
309
|
+
? formatJson(summary)
|
|
310
|
+
: formatProjectStatus(summary),
|
|
311
|
+
stderr: "",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (parsed.action === "doctor") {
|
|
315
|
+
const doctorResult = await createDoctorReport({
|
|
316
|
+
memory: resolved.memory,
|
|
317
|
+
currentGitRemoteFingerprint,
|
|
318
|
+
checkUrl,
|
|
319
|
+
checkedAt: now().toISOString(),
|
|
320
|
+
});
|
|
321
|
+
const report = doctorResult.report;
|
|
322
|
+
await upsertProjectMemory(storePath, createAppProjectMemory({
|
|
323
|
+
...resolved.memory,
|
|
324
|
+
latestCheck: doctorResult.latestCheck,
|
|
325
|
+
latestDoctorReport: report,
|
|
326
|
+
}));
|
|
327
|
+
return {
|
|
328
|
+
exitCode: 0,
|
|
329
|
+
stdout: parsed.outputFormat === "json"
|
|
330
|
+
? formatJson(report)
|
|
331
|
+
: formatProjectDoctor(report),
|
|
332
|
+
stderr: "",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
exitCode: 0,
|
|
337
|
+
stdout: parsed.outputFormat === "json"
|
|
338
|
+
? formatJson(createProjectBriefDocument(resolved.memory))
|
|
339
|
+
: formatProjectBrief(resolved.memory),
|
|
340
|
+
stderr: "",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (parsed.action === "sync") {
|
|
344
|
+
const createdAt = now().toISOString();
|
|
345
|
+
const syncRequest = createProjectMemorySyncRequest({
|
|
346
|
+
workspaceId: parsed.workspaceId,
|
|
347
|
+
createdAt,
|
|
348
|
+
memories,
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
exitCode: 0,
|
|
352
|
+
stdout: parsed.outputFormat === "json"
|
|
353
|
+
? formatJson({
|
|
354
|
+
request: syncRequest,
|
|
355
|
+
result: createProjectMemorySyncResult({
|
|
356
|
+
workspaceId: parsed.workspaceId,
|
|
357
|
+
createdAt,
|
|
358
|
+
acceptedProjectIds: syncRequest.envelopes.map((envelope) => envelope.projectId),
|
|
359
|
+
}),
|
|
360
|
+
})
|
|
361
|
+
: formatSyncScaffold(syncRequest),
|
|
362
|
+
stderr: "",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const memory = memories.find((candidate) => candidate.id === parsed.appId);
|
|
366
|
+
if (!memory) {
|
|
367
|
+
return {
|
|
368
|
+
exitCode: 1,
|
|
369
|
+
stdout: "",
|
|
370
|
+
stderr: `AppFleet project memory failed: no project memory for ${parsed.appId}\n`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const latestCheck = normalizeStatusCheck(await checkUrl(memory.canonicalUrl));
|
|
374
|
+
const rememberedChecks = await Promise.all(memory.rememberedUrls.map(async (url) => normalizeStatusCheck(await checkUrl(url))));
|
|
375
|
+
const updatedMemory = createAppProjectMemory({ ...memory, latestCheck });
|
|
376
|
+
await upsertProjectMemory(storePath, updatedMemory);
|
|
377
|
+
return {
|
|
378
|
+
exitCode: 0,
|
|
379
|
+
stdout: formatCheckResult(updatedMemory, latestCheck, rememberedChecks),
|
|
380
|
+
stderr: "",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
return {
|
|
385
|
+
exitCode: 1,
|
|
386
|
+
stdout: "",
|
|
387
|
+
stderr: `AppFleet project memory failed: ${errorMessage(error)}\n`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function defaultStorePathForCommand(parsed, options) {
|
|
392
|
+
const rootPath = parsed.action === "discover" && parsed.rootPath
|
|
393
|
+
? resolve(parsed.rootPath)
|
|
394
|
+
: options.projectRoot ?? process.cwd();
|
|
395
|
+
return defaultProjectMemoryStorePath(rootPath);
|
|
396
|
+
}
|
|
397
|
+
export async function readProjectMemories(storePath = defaultStorePath) {
|
|
398
|
+
try {
|
|
399
|
+
const raw = await readFile(storePath, "utf8");
|
|
400
|
+
const parsed = JSON.parse(raw);
|
|
401
|
+
return parsed.map(createAppProjectMemory);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (error.code === "ENOENT") {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
export async function writeProjectMemories(storePath, memories) {
|
|
411
|
+
await mkdir(dirname(storePath), { recursive: true });
|
|
412
|
+
await writeFile(storePath, `${JSON.stringify(memories, null, 2)}\n`, {
|
|
413
|
+
mode: 0o600,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
export async function upsertProjectMemory(storePath, memory) {
|
|
417
|
+
await upsertProjectMemories(storePath, [memory]);
|
|
418
|
+
}
|
|
419
|
+
export async function upsertProjectMemories(storePath, memoriesToUpsert) {
|
|
420
|
+
const memories = await readProjectMemories(storePath);
|
|
421
|
+
const upsertIds = new Set(memoriesToUpsert.map((memory) => memory.id));
|
|
422
|
+
const withoutExisting = memories.filter((candidate) => !upsertIds.has(candidate.id));
|
|
423
|
+
await writeProjectMemories(storePath, [...withoutExisting, ...memoriesToUpsert]);
|
|
424
|
+
}
|
|
425
|
+
export async function discoverLocalProject(input) {
|
|
426
|
+
const rootPath = resolve(input.rootPath);
|
|
427
|
+
const packageJson = await readPackageJson(rootPath);
|
|
428
|
+
const appId = input.appId ?? slugify(packageJson?.name ?? basename(rootPath));
|
|
429
|
+
const name = input.name ?? packageJson?.name ?? titleFromSlug(appId);
|
|
430
|
+
const canonicalUrl = input.canonicalUrl ?? safeUrl(packageJson?.homepage);
|
|
431
|
+
const repoUrl = repoUrlFromPackage(packageJson);
|
|
432
|
+
const fileInventory = await collectSafeSourceFiles(rootPath);
|
|
433
|
+
const sourceText = await readSafeSourceText(fileInventory.files);
|
|
434
|
+
const providers = inferProviders({
|
|
435
|
+
packageJson,
|
|
436
|
+
rootPath,
|
|
437
|
+
sourceText,
|
|
438
|
+
filePaths: fileInventory.files,
|
|
439
|
+
});
|
|
440
|
+
const environmentAliases = inferEnvironmentAliases(sourceText);
|
|
441
|
+
return {
|
|
442
|
+
type: "project_discovery_report",
|
|
443
|
+
version: 1,
|
|
444
|
+
rootPath,
|
|
445
|
+
project: {
|
|
446
|
+
id: appId,
|
|
447
|
+
name,
|
|
448
|
+
canonicalUrl,
|
|
449
|
+
repoUrl,
|
|
450
|
+
gitRemoteFingerprint: input.gitRemoteFingerprint,
|
|
451
|
+
},
|
|
452
|
+
packageManager: detectPackageManager(fileInventory.files),
|
|
453
|
+
providers,
|
|
454
|
+
environmentAliases,
|
|
455
|
+
filesScanned: fileInventory.files.length,
|
|
456
|
+
skippedPaths: fileInventory.skippedPaths,
|
|
457
|
+
remembered: Boolean(input.remember),
|
|
458
|
+
productionCloudPersistenceImplemented: false,
|
|
459
|
+
trustBoundary: [
|
|
460
|
+
"Discovery reads package metadata and source/config filenames only.",
|
|
461
|
+
"Discovery skips .env files, .appfleet, node_modules, .git, test files, build output, and encrypted vault files.",
|
|
462
|
+
"Discovery records env alias names only; it never stores values.",
|
|
463
|
+
"Discovery does not call provider APIs or infer provider health.",
|
|
464
|
+
],
|
|
465
|
+
nextActions: input.remember
|
|
466
|
+
? [
|
|
467
|
+
"Run projects status for the imported project.",
|
|
468
|
+
"Run projects doctor to perform safe URL and metadata checks.",
|
|
469
|
+
"Use secrets set to store local encrypted values for discovered aliases.",
|
|
470
|
+
]
|
|
471
|
+
: [
|
|
472
|
+
"Run projects discover --remember --url <url> to import these safe facts.",
|
|
473
|
+
"Review discovered aliases before storing any secret values in the local vault.",
|
|
474
|
+
],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function memoryFromDiscovery(discovery) {
|
|
478
|
+
if (!discovery.project.canonicalUrl) {
|
|
479
|
+
throw new Error("discovery import requires a canonical URL");
|
|
480
|
+
}
|
|
481
|
+
return createAppProjectMemory({
|
|
482
|
+
id: discovery.project.id,
|
|
483
|
+
name: discovery.project.name,
|
|
484
|
+
identity: {
|
|
485
|
+
id: discovery.project.id,
|
|
486
|
+
name: discovery.project.name,
|
|
487
|
+
repoUrl: discovery.project.repoUrl,
|
|
488
|
+
gitRemoteFingerprint: discovery.project.gitRemoteFingerprint,
|
|
489
|
+
rememberedLocalPaths: [discovery.rootPath],
|
|
490
|
+
},
|
|
491
|
+
canonicalUrl: discovery.project.canonicalUrl,
|
|
492
|
+
rememberedUrls: [],
|
|
493
|
+
domain: {
|
|
494
|
+
canonicalUrl: discovery.project.canonicalUrl,
|
|
495
|
+
rememberedUrls: [],
|
|
496
|
+
customDomainBought: false,
|
|
497
|
+
notes: "Imported from safe local project discovery.",
|
|
498
|
+
},
|
|
499
|
+
providers: discovery.providers,
|
|
500
|
+
environments: ["unknown"],
|
|
501
|
+
environmentAliases: discovery.environmentAliases,
|
|
502
|
+
credentialReferences: [],
|
|
503
|
+
troubleshootingNotes: [
|
|
504
|
+
"Project was imported from local safe metadata discovery.",
|
|
505
|
+
"Provider health remains unknown until checked explicitly.",
|
|
506
|
+
],
|
|
507
|
+
nextPlaceToLook: [
|
|
508
|
+
"Run projects doctor to check public URLs.",
|
|
509
|
+
"Use secrets set for local encrypted values matching discovered aliases.",
|
|
510
|
+
],
|
|
511
|
+
cloudSync: {
|
|
512
|
+
intendedSourceOfTruth: "appfleet_cloud",
|
|
513
|
+
productionPersistenceImplemented: false,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function createMemoryFromSafeInput(parsed, gitRemoteFingerprint) {
|
|
518
|
+
return createAppProjectMemory({
|
|
519
|
+
id: parsed.appId,
|
|
520
|
+
name: parsed.name,
|
|
521
|
+
identity: {
|
|
522
|
+
id: parsed.appId,
|
|
523
|
+
name: parsed.name,
|
|
524
|
+
repoUrl: parsed.repoUrl,
|
|
525
|
+
gitRemoteFingerprint,
|
|
526
|
+
rememberedLocalPaths: parsed.localPaths,
|
|
527
|
+
},
|
|
528
|
+
canonicalUrl: parsed.canonicalUrl,
|
|
529
|
+
rememberedUrls: parsed.rememberedUrls,
|
|
530
|
+
domain: {
|
|
531
|
+
canonicalUrl: parsed.canonicalUrl,
|
|
532
|
+
rememberedUrls: parsed.rememberedUrls,
|
|
533
|
+
customDomainBought: false,
|
|
534
|
+
notes: parsed.rememberedUrls.length > 0
|
|
535
|
+
? "Remembered URLs recorded as safe public recovery facts."
|
|
536
|
+
: "No custom domain recorded in AppFleet yet.",
|
|
537
|
+
},
|
|
538
|
+
providers: parsed.providers,
|
|
539
|
+
environments: ["unknown"],
|
|
540
|
+
environmentAliases: parsed.environmentAliases,
|
|
541
|
+
credentialReferences: [],
|
|
542
|
+
troubleshootingNotes: parsed.notes,
|
|
543
|
+
nextPlaceToLook: [
|
|
544
|
+
"Check the canonical URL first.",
|
|
545
|
+
"If the app loads but behavior is broken, check backend provider status next.",
|
|
546
|
+
],
|
|
547
|
+
cloudSync: {
|
|
548
|
+
intendedSourceOfTruth: "appfleet_cloud",
|
|
549
|
+
productionPersistenceImplemented: false,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
function editProjectMemory(memory, parsed) {
|
|
554
|
+
const name = parsed.name ?? memory.name;
|
|
555
|
+
const canonicalUrl = parsed.canonicalUrl ?? memory.canonicalUrl;
|
|
556
|
+
const rememberedUrls = dedupeStrings([
|
|
557
|
+
...memory.rememberedUrls,
|
|
558
|
+
...parsed.rememberedUrls,
|
|
559
|
+
]);
|
|
560
|
+
return createAppProjectMemory({
|
|
561
|
+
...memory,
|
|
562
|
+
name,
|
|
563
|
+
identity: {
|
|
564
|
+
...memory.identity,
|
|
565
|
+
name,
|
|
566
|
+
repoUrl: parsed.repoUrl ?? memory.identity.repoUrl,
|
|
567
|
+
gitRemoteFingerprint: parsed.gitRemoteFingerprint ?? memory.identity.gitRemoteFingerprint,
|
|
568
|
+
},
|
|
569
|
+
canonicalUrl,
|
|
570
|
+
rememberedUrls,
|
|
571
|
+
domain: {
|
|
572
|
+
...memory.domain,
|
|
573
|
+
canonicalUrl,
|
|
574
|
+
rememberedUrls,
|
|
575
|
+
},
|
|
576
|
+
providers: mergeProviders(memory.providers, parsed.providers),
|
|
577
|
+
environmentAliases: mergeEnvironmentAliases(memory.environmentAliases, parsed.environmentAliases),
|
|
578
|
+
troubleshootingNotes: dedupeStrings([
|
|
579
|
+
...memory.troubleshootingNotes,
|
|
580
|
+
...parsed.notes,
|
|
581
|
+
]),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function addProjectEnvironment(memory, parsed) {
|
|
585
|
+
const existing = memory.environments.length === 1 && memory.environments[0] === "unknown"
|
|
586
|
+
? []
|
|
587
|
+
: memory.environments;
|
|
588
|
+
return createAppProjectMemory({
|
|
589
|
+
...memory,
|
|
590
|
+
environments: dedupeStrings([...existing, parsed.environment]),
|
|
591
|
+
environmentAliases: mergeEnvironmentAliases(memory.environmentAliases, parsed.environmentAliases),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
function removeProjectEnvironment(memory, parsed) {
|
|
595
|
+
const environments = memory.environments.filter((environment) => environment !== parsed.environment);
|
|
596
|
+
const aliasNames = new Set(parsed.environmentAliasNames);
|
|
597
|
+
return createAppProjectMemory({
|
|
598
|
+
...memory,
|
|
599
|
+
environments: environments.length > 0 ? environments : ["unknown"],
|
|
600
|
+
environmentAliases: aliasNames.size === 0
|
|
601
|
+
? memory.environmentAliases
|
|
602
|
+
: memory.environmentAliases.filter((alias) => !aliasNames.has(alias.name)),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function mergeProviders(existing, incoming) {
|
|
606
|
+
const byId = new Map(existing.map((provider) => [provider.id, provider]));
|
|
607
|
+
for (const provider of incoming) {
|
|
608
|
+
byId.set(provider.id, provider);
|
|
609
|
+
}
|
|
610
|
+
return Array.from(byId.values());
|
|
611
|
+
}
|
|
612
|
+
function mergeEnvironmentAliases(existing, incoming) {
|
|
613
|
+
const byName = new Map(existing.map((alias) => [alias.name, alias]));
|
|
614
|
+
for (const alias of incoming) {
|
|
615
|
+
byName.set(alias.name, { ...alias, valueStored: false });
|
|
616
|
+
}
|
|
617
|
+
return Array.from(byName.values());
|
|
618
|
+
}
|
|
619
|
+
function dedupeStrings(values) {
|
|
620
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
621
|
+
}
|
|
622
|
+
export function resolveProjectMemory(memories, input) {
|
|
623
|
+
if (input.appId) {
|
|
624
|
+
const memory = memories.find((candidate) => candidate.id === input.appId);
|
|
625
|
+
return memory
|
|
626
|
+
? { ok: true, memory }
|
|
627
|
+
: {
|
|
628
|
+
ok: false,
|
|
629
|
+
message: `AppFleet project memory failed: no project memory for ${input.appId}`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (input.currentGitRemoteFingerprint) {
|
|
633
|
+
const matches = memories.filter((candidate) => candidate.identity.gitRemoteFingerprint ===
|
|
634
|
+
input.currentGitRemoteFingerprint);
|
|
635
|
+
if (matches.length === 1) {
|
|
636
|
+
return { ok: true, memory: matches[0] };
|
|
637
|
+
}
|
|
638
|
+
if (matches.length > 1) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
message: "AppFleet project memory found multiple matching projects; choose one explicitly.",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
message: "AppFleet project memory found no matching project; run projects remember first.",
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
export async function detectCurrentGitRemoteFingerprint(cwd) {
|
|
651
|
+
try {
|
|
652
|
+
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], cwd ? { cwd } : undefined);
|
|
653
|
+
return normalizeGitRemoteFingerprint(stdout.toString());
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
export function normalizeGitRemoteFingerprint(remoteUrl) {
|
|
660
|
+
const trimmed = remoteUrl.trim();
|
|
661
|
+
if (!trimmed) {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(trimmed);
|
|
665
|
+
if (sshMatch) {
|
|
666
|
+
return normalizeRepositoryFingerprint(sshMatch[1], sshMatch[2]);
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const parsed = new URL(trimmed);
|
|
670
|
+
return normalizeRepositoryFingerprint(parsed.hostname, parsed.pathname);
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
export async function checkHttpStatus(url) {
|
|
677
|
+
return await checkHttpStatusWithRedirects(url, "HEAD", 0);
|
|
678
|
+
}
|
|
679
|
+
async function checkHttpStatusWithRedirects(url, method, redirectCount) {
|
|
680
|
+
const checkedAt = new Date().toISOString();
|
|
681
|
+
const startedAt = Date.now();
|
|
682
|
+
try {
|
|
683
|
+
const response = await requestUrl(url, method);
|
|
684
|
+
const latencyMs = Date.now() - startedAt;
|
|
685
|
+
if (response.statusCode &&
|
|
686
|
+
redirectStatusCodes.has(response.statusCode) &&
|
|
687
|
+
response.location &&
|
|
688
|
+
redirectCount < 5) {
|
|
689
|
+
return await checkHttpStatusWithRedirects(new URL(response.location, url).toString(), method, redirectCount + 1);
|
|
690
|
+
}
|
|
691
|
+
if (response.statusCode === 405 && method === "HEAD") {
|
|
692
|
+
return await checkHttpStatusWithRedirects(url, "GET", redirectCount);
|
|
693
|
+
}
|
|
694
|
+
const status = statusFromHttpCode(response.statusCode);
|
|
695
|
+
return {
|
|
696
|
+
checkedAt,
|
|
697
|
+
url,
|
|
698
|
+
status,
|
|
699
|
+
latencyMs,
|
|
700
|
+
httpStatusCode: response.statusCode,
|
|
701
|
+
tlsCertificateExpiresAt: response.tlsCertificateExpiresAt,
|
|
702
|
+
tlsCertificateDaysRemaining: response.tlsCertificateDaysRemaining,
|
|
703
|
+
failureCategory: status === "up" ? undefined : "http_error",
|
|
704
|
+
message: status === "up"
|
|
705
|
+
? "HTTP check succeeded"
|
|
706
|
+
: `HTTP check returned ${response.statusCode ?? "unknown status"}`,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
const failureCategory = failureCategoryFromError(error);
|
|
711
|
+
return {
|
|
712
|
+
checkedAt,
|
|
713
|
+
url,
|
|
714
|
+
status: failureCategory === "tls_error" ? "misconfigured" : "down",
|
|
715
|
+
latencyMs: Date.now() - startedAt,
|
|
716
|
+
failureCategory,
|
|
717
|
+
message: errorMessage(error),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function requestUrl(url, method) {
|
|
722
|
+
return new Promise((resolvePromise, reject) => {
|
|
723
|
+
const parsed = new URL(url);
|
|
724
|
+
const request = (parsed.protocol === "http:" ? httpRequest : httpsRequest)(parsed, { method, timeout: 10_000 }, (response) => {
|
|
725
|
+
const tlsCertificate = tlsCertificateMetadata(response);
|
|
726
|
+
response.resume();
|
|
727
|
+
response.on("end", () => resolvePromise({
|
|
728
|
+
statusCode: response.statusCode,
|
|
729
|
+
location: response.headers.location,
|
|
730
|
+
...tlsCertificate,
|
|
731
|
+
}));
|
|
732
|
+
});
|
|
733
|
+
request.on("timeout", () => {
|
|
734
|
+
request.destroy(new Error("HTTP check timed out"));
|
|
735
|
+
});
|
|
736
|
+
request.on("error", reject);
|
|
737
|
+
request.end();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function tlsCertificateMetadata(response) {
|
|
741
|
+
const socket = response.socket;
|
|
742
|
+
const certificate = socket?.getPeerCertificate?.();
|
|
743
|
+
if (!certificate?.valid_to) {
|
|
744
|
+
return {};
|
|
745
|
+
}
|
|
746
|
+
const expiresAtMs = Date.parse(certificate.valid_to);
|
|
747
|
+
if (Number.isNaN(expiresAtMs)) {
|
|
748
|
+
return {};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
tlsCertificateExpiresAt: new Date(expiresAtMs).toISOString(),
|
|
752
|
+
tlsCertificateDaysRemaining: Math.floor((expiresAtMs - Date.now()) / 86_400_000),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function statusFromHttpCode(statusCode) {
|
|
756
|
+
if (!statusCode) {
|
|
757
|
+
return "unknown";
|
|
758
|
+
}
|
|
759
|
+
return statusCode >= 200 && statusCode < 400 ? "up" : "down";
|
|
760
|
+
}
|
|
761
|
+
function failureCategoryFromError(error) {
|
|
762
|
+
const code = error.code;
|
|
763
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
764
|
+
return "dns_error";
|
|
765
|
+
}
|
|
766
|
+
if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || code === "CERT_HAS_EXPIRED") {
|
|
767
|
+
return "tls_error";
|
|
768
|
+
}
|
|
769
|
+
if (error.message?.toLowerCase().includes("timed out")) {
|
|
770
|
+
return "timeout";
|
|
771
|
+
}
|
|
772
|
+
return "unknown_error";
|
|
773
|
+
}
|
|
774
|
+
const ignoredDiscoveryDirectories = new Set([
|
|
775
|
+
".appfleet",
|
|
776
|
+
".git",
|
|
777
|
+
".next",
|
|
778
|
+
"__tests__",
|
|
779
|
+
"coverage",
|
|
780
|
+
"dist",
|
|
781
|
+
"build",
|
|
782
|
+
"node_modules",
|
|
783
|
+
"test",
|
|
784
|
+
"tests",
|
|
785
|
+
]);
|
|
786
|
+
const ignoredDiscoveryFilenames = new Set([
|
|
787
|
+
".env",
|
|
788
|
+
".env.local",
|
|
789
|
+
".env.development",
|
|
790
|
+
".env.production",
|
|
791
|
+
".env.test",
|
|
792
|
+
"local-vault.json",
|
|
793
|
+
"vault-audit.jsonl",
|
|
794
|
+
"prototype-audit.jsonl",
|
|
795
|
+
]);
|
|
796
|
+
const readableDiscoveryExtensions = new Set([
|
|
797
|
+
".cjs",
|
|
798
|
+
".cts",
|
|
799
|
+
".js",
|
|
800
|
+
".jsx",
|
|
801
|
+
".mjs",
|
|
802
|
+
".mts",
|
|
803
|
+
".ts",
|
|
804
|
+
".tsx",
|
|
805
|
+
".json",
|
|
806
|
+
".toml",
|
|
807
|
+
".yaml",
|
|
808
|
+
".yml",
|
|
809
|
+
]);
|
|
810
|
+
const ignoredEnvironmentAliases = new Set([
|
|
811
|
+
"CI",
|
|
812
|
+
"HOME",
|
|
813
|
+
"INIT_CWD",
|
|
814
|
+
"NODE_ENV",
|
|
815
|
+
"PATH",
|
|
816
|
+
"PORT",
|
|
817
|
+
"PWD",
|
|
818
|
+
"SHELL",
|
|
819
|
+
"TERM",
|
|
820
|
+
"TMPDIR",
|
|
821
|
+
"USER",
|
|
822
|
+
]);
|
|
823
|
+
async function readPackageJson(rootPath) {
|
|
824
|
+
try {
|
|
825
|
+
const raw = await readFile(join(rootPath, "package.json"), "utf8");
|
|
826
|
+
return JSON.parse(raw);
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
if (error.code === "ENOENT") {
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
async function collectSafeSourceFiles(rootPath) {
|
|
836
|
+
const files = [];
|
|
837
|
+
const skippedPaths = [];
|
|
838
|
+
async function visit(directory) {
|
|
839
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
840
|
+
for (const entry of entries) {
|
|
841
|
+
const absolutePath = join(directory, entry.name);
|
|
842
|
+
const relativePath = absolutePath.replace(`${rootPath}/`, "");
|
|
843
|
+
if (entry.isDirectory()) {
|
|
844
|
+
if (ignoredDiscoveryDirectories.has(entry.name)) {
|
|
845
|
+
skippedPaths.push(relativePath);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
await visit(absolutePath);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (!entry.isFile()) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (ignoredDiscoveryFilenames.has(entry.name) ||
|
|
855
|
+
entry.name.startsWith(".env.") ||
|
|
856
|
+
isTestSourceFile(entry.name) ||
|
|
857
|
+
!readableDiscoveryExtensions.has(extname(entry.name))) {
|
|
858
|
+
if (entry.name.startsWith(".env") || ignoredDiscoveryFilenames.has(entry.name)) {
|
|
859
|
+
skippedPaths.push(relativePath);
|
|
860
|
+
}
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const fileStat = await stat(absolutePath);
|
|
864
|
+
if (fileStat.size > 256 * 1024) {
|
|
865
|
+
skippedPaths.push(relativePath);
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
files.push(absolutePath);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
await visit(rootPath);
|
|
872
|
+
return { files, skippedPaths };
|
|
873
|
+
}
|
|
874
|
+
async function readSafeSourceText(files) {
|
|
875
|
+
const chunks = [];
|
|
876
|
+
for (const file of files) {
|
|
877
|
+
chunks.push(await readFile(file, "utf8"));
|
|
878
|
+
}
|
|
879
|
+
return chunks.join("\n");
|
|
880
|
+
}
|
|
881
|
+
function inferProviders(input) {
|
|
882
|
+
const packageNames = new Set([
|
|
883
|
+
...Object.keys(input.packageJson?.dependencies ?? {}),
|
|
884
|
+
...Object.keys(input.packageJson?.devDependencies ?? {}),
|
|
885
|
+
]);
|
|
886
|
+
const filenames = new Set(input.filePaths.map((file) => basename(file)));
|
|
887
|
+
const providers = [];
|
|
888
|
+
const add = (name, kind, role) => {
|
|
889
|
+
if (!providers.some((provider) => provider.name === name)) {
|
|
890
|
+
providers.push({ id: slugify(name), name, kind, role, status: "known" });
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
if (packageNames.has("@supabase/supabase-js") || input.sourceText.includes("supabase")) {
|
|
894
|
+
add("Supabase", "backend", "database and auth");
|
|
895
|
+
}
|
|
896
|
+
if (packageNames.has("@netlify/functions") || filenames.has("netlify.toml")) {
|
|
897
|
+
add("Netlify", "deployment", "frontend hosting");
|
|
898
|
+
}
|
|
899
|
+
if (packageNames.has("vercel") || filenames.has("vercel.json")) {
|
|
900
|
+
add("Vercel", "deployment", "frontend hosting");
|
|
901
|
+
}
|
|
902
|
+
if (packageNames.has("stripe")) {
|
|
903
|
+
add("Stripe", "other", "payments");
|
|
904
|
+
}
|
|
905
|
+
if (packageNames.has("openai")) {
|
|
906
|
+
add("OpenAI", "other", "AI API");
|
|
907
|
+
}
|
|
908
|
+
if (packageNames.has("@anthropic-ai/sdk")) {
|
|
909
|
+
add("Anthropic", "other", "AI API");
|
|
910
|
+
}
|
|
911
|
+
if (packageNames.has("resend")) {
|
|
912
|
+
add("Resend", "other", "email");
|
|
913
|
+
}
|
|
914
|
+
if (Array.from(packageNames).some((name) => name.startsWith("@clerk/"))) {
|
|
915
|
+
add("Clerk", "other", "auth");
|
|
916
|
+
}
|
|
917
|
+
if (filenames.has("wrangler.toml")) {
|
|
918
|
+
add("Cloudflare", "deployment", "edge hosting");
|
|
919
|
+
}
|
|
920
|
+
return providers;
|
|
921
|
+
}
|
|
922
|
+
function inferEnvironmentAliases(sourceText) {
|
|
923
|
+
const aliases = new Set();
|
|
924
|
+
const patterns = [
|
|
925
|
+
/process\.env\.([A-Z][A-Z0-9_]*)/g,
|
|
926
|
+
/process\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/g,
|
|
927
|
+
/import\.meta\.env\.([A-Z][A-Z0-9_]*)/g,
|
|
928
|
+
];
|
|
929
|
+
for (const pattern of patterns) {
|
|
930
|
+
for (const match of sourceText.matchAll(pattern)) {
|
|
931
|
+
const alias = match[1];
|
|
932
|
+
if (!ignoredEnvironmentAliases.has(alias) && !alias.startsWith("APPFLEET_")) {
|
|
933
|
+
aliases.add(alias);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return Array.from(aliases)
|
|
938
|
+
.sort()
|
|
939
|
+
.map((alias) => ({
|
|
940
|
+
name: alias,
|
|
941
|
+
purpose: purposeFromAlias(alias),
|
|
942
|
+
required: true,
|
|
943
|
+
valueStored: false,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
function purposeFromAlias(alias) {
|
|
947
|
+
if (alias.includes("SUPABASE")) {
|
|
948
|
+
return "Discovered Supabase environment alias";
|
|
949
|
+
}
|
|
950
|
+
if (alias.includes("STRIPE")) {
|
|
951
|
+
return "Discovered Stripe environment alias";
|
|
952
|
+
}
|
|
953
|
+
if (alias.includes("OPENAI")) {
|
|
954
|
+
return "Discovered OpenAI environment alias";
|
|
955
|
+
}
|
|
956
|
+
if (alias.includes("ANTHROPIC")) {
|
|
957
|
+
return "Discovered Anthropic environment alias";
|
|
958
|
+
}
|
|
959
|
+
if (alias.includes("NETLIFY")) {
|
|
960
|
+
return "Discovered Netlify environment alias";
|
|
961
|
+
}
|
|
962
|
+
if (alias.includes("VERCEL")) {
|
|
963
|
+
return "Discovered Vercel environment alias";
|
|
964
|
+
}
|
|
965
|
+
return "Discovered environment variable alias";
|
|
966
|
+
}
|
|
967
|
+
function detectPackageManager(files) {
|
|
968
|
+
const filenames = new Set(files.map((file) => basename(file)));
|
|
969
|
+
if (filenames.has("pnpm-lock.yaml")) {
|
|
970
|
+
return "pnpm";
|
|
971
|
+
}
|
|
972
|
+
if (filenames.has("yarn.lock")) {
|
|
973
|
+
return "yarn";
|
|
974
|
+
}
|
|
975
|
+
if (filenames.has("package-lock.json")) {
|
|
976
|
+
return "npm";
|
|
977
|
+
}
|
|
978
|
+
if (filenames.has("bun.lockb")) {
|
|
979
|
+
return "bun";
|
|
980
|
+
}
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
function isTestSourceFile(filename) {
|
|
984
|
+
return (filename.includes(".test.") ||
|
|
985
|
+
filename.includes(".spec.") ||
|
|
986
|
+
filename.endsWith("test.ts") ||
|
|
987
|
+
filename.endsWith("test.js"));
|
|
988
|
+
}
|
|
989
|
+
function repoUrlFromPackage(packageJson) {
|
|
990
|
+
const repository = packageJson?.repository;
|
|
991
|
+
if (!repository) {
|
|
992
|
+
return undefined;
|
|
993
|
+
}
|
|
994
|
+
if (typeof repository === "string") {
|
|
995
|
+
return repository;
|
|
996
|
+
}
|
|
997
|
+
return repository.url;
|
|
998
|
+
}
|
|
999
|
+
function safeUrl(value) {
|
|
1000
|
+
if (typeof value !== "string" || !value) {
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = new URL(value);
|
|
1005
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
1006
|
+
? parsed.toString()
|
|
1007
|
+
: undefined;
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
return undefined;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function titleFromSlug(value) {
|
|
1014
|
+
return value
|
|
1015
|
+
.split(/[-_]/)
|
|
1016
|
+
.filter(Boolean)
|
|
1017
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
1018
|
+
.join(" ");
|
|
1019
|
+
}
|
|
1020
|
+
function providerMemoryFromInput(input) {
|
|
1021
|
+
const [name, kindInput, ...roleParts] = input.split(":");
|
|
1022
|
+
const kind = parseProviderKind(kindInput);
|
|
1023
|
+
const role = roleParts.join(":").trim() || "manually recorded provider";
|
|
1024
|
+
return {
|
|
1025
|
+
id: slugify(name),
|
|
1026
|
+
name: name.trim(),
|
|
1027
|
+
kind,
|
|
1028
|
+
role,
|
|
1029
|
+
status: "known",
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function parseProviderKind(value) {
|
|
1033
|
+
if (value === "deployment" ||
|
|
1034
|
+
value === "backend" ||
|
|
1035
|
+
value === "domain" ||
|
|
1036
|
+
value === "other") {
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
return "other";
|
|
1040
|
+
}
|
|
1041
|
+
function envAliasFromInput(input) {
|
|
1042
|
+
const [name, ...purposeParts] = input.split(":");
|
|
1043
|
+
const purpose = purposeParts.join(":").trim() ||
|
|
1044
|
+
"Manually recorded environment variable alias";
|
|
1045
|
+
return {
|
|
1046
|
+
name: name.trim(),
|
|
1047
|
+
purpose,
|
|
1048
|
+
required: true,
|
|
1049
|
+
valueStored: false,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function envAliasNameFromInput(input) {
|
|
1053
|
+
return input.split(":")[0].trim();
|
|
1054
|
+
}
|
|
1055
|
+
function formatMemorySaved(memory) {
|
|
1056
|
+
return ([
|
|
1057
|
+
`AppFleet project memory saved: ${memory.name}`,
|
|
1058
|
+
`canonicalUrl=${memory.canonicalUrl}`,
|
|
1059
|
+
`rememberedUrls=${memory.rememberedUrls.join(", ") || "none"}`,
|
|
1060
|
+
`repo=${memory.identity.repoUrl ?? "unknown"}`,
|
|
1061
|
+
`gitRemoteFingerprint=${memory.identity.gitRemoteFingerprint ?? "unknown"}`,
|
|
1062
|
+
`providers=${memory.providers.map((provider) => provider.name).join(", ")}`,
|
|
1063
|
+
`envAliases=${memory.environmentAliases.map((alias) => alias.name).join(", ")}`,
|
|
1064
|
+
"cloudSync=contract_scaffold",
|
|
1065
|
+
].join("\n") + "\n");
|
|
1066
|
+
}
|
|
1067
|
+
function createRememberResult(memory) {
|
|
1068
|
+
return {
|
|
1069
|
+
type: "project_remember_result",
|
|
1070
|
+
version: 1,
|
|
1071
|
+
project: {
|
|
1072
|
+
id: memory.id,
|
|
1073
|
+
name: memory.name,
|
|
1074
|
+
canonicalUrl: memory.canonicalUrl,
|
|
1075
|
+
rememberedUrls: memory.rememberedUrls,
|
|
1076
|
+
repoUrl: memory.identity.repoUrl,
|
|
1077
|
+
gitRemoteFingerprint: memory.identity.gitRemoteFingerprint,
|
|
1078
|
+
},
|
|
1079
|
+
providers: memory.providers.map((provider) => ({
|
|
1080
|
+
id: provider.id,
|
|
1081
|
+
name: provider.name,
|
|
1082
|
+
kind: provider.kind,
|
|
1083
|
+
role: provider.role,
|
|
1084
|
+
status: provider.status,
|
|
1085
|
+
})),
|
|
1086
|
+
environmentAliases: memory.environmentAliases.map((alias) => ({
|
|
1087
|
+
name: alias.name,
|
|
1088
|
+
purpose: alias.purpose,
|
|
1089
|
+
required: alias.required,
|
|
1090
|
+
})),
|
|
1091
|
+
productionCloudPersistenceImplemented: false,
|
|
1092
|
+
nextActions: createStatusNextActions(memory),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function createProjectEnvResult(memory) {
|
|
1096
|
+
return {
|
|
1097
|
+
type: "project_environment_result",
|
|
1098
|
+
version: 1,
|
|
1099
|
+
project: {
|
|
1100
|
+
id: memory.id,
|
|
1101
|
+
name: memory.name,
|
|
1102
|
+
},
|
|
1103
|
+
environments: memory.environments,
|
|
1104
|
+
environmentAliases: memory.environmentAliases.map((alias) => ({
|
|
1105
|
+
name: alias.name,
|
|
1106
|
+
purpose: alias.purpose,
|
|
1107
|
+
required: alias.required,
|
|
1108
|
+
})),
|
|
1109
|
+
productionCloudPersistenceImplemented: false,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
function formatProjectEnvResult(result) {
|
|
1113
|
+
return ([
|
|
1114
|
+
`AppFleet project environments: ${result.project.name}`,
|
|
1115
|
+
`environments=${result.environments.join(", ") || "none"}`,
|
|
1116
|
+
`envAliases=${result.environmentAliases.map((alias) => alias.name).join(", ") || "none"}`,
|
|
1117
|
+
`productionCloudPersistence=${result.productionCloudPersistenceImplemented}`,
|
|
1118
|
+
].join("\n") + "\n");
|
|
1119
|
+
}
|
|
1120
|
+
function formatDemoFleetSaved(memories) {
|
|
1121
|
+
return ([
|
|
1122
|
+
"AppFleet demo fleet saved.",
|
|
1123
|
+
`projectCount=${memories.length}`,
|
|
1124
|
+
`projectIds=${memories.map((memory) => memory.id).join(", ")}`,
|
|
1125
|
+
"doctorReports=persisted_safe_mock_findings",
|
|
1126
|
+
"cloudSync=contract_scaffold",
|
|
1127
|
+
].join("\n") + "\n");
|
|
1128
|
+
}
|
|
1129
|
+
function formatProjectBrief(memory) {
|
|
1130
|
+
const latest = memory.latestCheck;
|
|
1131
|
+
const lines = [
|
|
1132
|
+
`AppFleet project brief: ${memory.name}`,
|
|
1133
|
+
`repo=${memory.identity.repoUrl ?? "unknown"}`,
|
|
1134
|
+
`gitRemoteFingerprint=${memory.identity.gitRemoteFingerprint ?? "unknown"}`,
|
|
1135
|
+
`canonicalUrl=${memory.canonicalUrl}`,
|
|
1136
|
+
`rememberedUrls=${memory.rememberedUrls.join(", ") || "none"}`,
|
|
1137
|
+
`providers=${memory.providers.map((provider) => provider.name).join(", ") || "none"}`,
|
|
1138
|
+
`envAliases=${memory.environmentAliases.map((alias) => alias.name).join(", ") || "none"}`,
|
|
1139
|
+
`latestStatus=${latest?.status ?? "unknown"}`,
|
|
1140
|
+
`supabaseHealth=unknown`,
|
|
1141
|
+
"troubleshootingNotes:",
|
|
1142
|
+
...memory.troubleshootingNotes.map((note) => `- ${note}`),
|
|
1143
|
+
"nextPlaceToLook:",
|
|
1144
|
+
...memory.nextPlaceToLook.map((next) => `- ${next}`),
|
|
1145
|
+
];
|
|
1146
|
+
return `${lines.join("\n")}\n`;
|
|
1147
|
+
}
|
|
1148
|
+
function createProjectBriefDocument(memory) {
|
|
1149
|
+
return {
|
|
1150
|
+
type: "project_memory_brief",
|
|
1151
|
+
version: 1,
|
|
1152
|
+
project: {
|
|
1153
|
+
id: memory.id,
|
|
1154
|
+
name: memory.name,
|
|
1155
|
+
repoUrl: memory.identity.repoUrl,
|
|
1156
|
+
gitRemoteFingerprint: memory.identity.gitRemoteFingerprint,
|
|
1157
|
+
canonicalUrl: memory.canonicalUrl,
|
|
1158
|
+
rememberedUrls: memory.rememberedUrls,
|
|
1159
|
+
},
|
|
1160
|
+
providers: memory.providers.map((provider) => ({
|
|
1161
|
+
id: provider.id,
|
|
1162
|
+
name: provider.name,
|
|
1163
|
+
kind: provider.kind,
|
|
1164
|
+
role: provider.role,
|
|
1165
|
+
status: provider.status,
|
|
1166
|
+
})),
|
|
1167
|
+
environmentAliases: memory.environmentAliases,
|
|
1168
|
+
latestStatus: memory.latestCheck?.status ?? "unknown",
|
|
1169
|
+
supabaseHealth: "unknown",
|
|
1170
|
+
cloudSyncStatus: memory.cloudSync.lastSyncedAt
|
|
1171
|
+
? "synced"
|
|
1172
|
+
: "contract_scaffold",
|
|
1173
|
+
troubleshootingNotes: memory.troubleshootingNotes,
|
|
1174
|
+
nextPlaceToLook: memory.nextPlaceToLook,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function createStatusNextActions(memory) {
|
|
1178
|
+
const nextActions = [
|
|
1179
|
+
memory.latestCheck
|
|
1180
|
+
? "Run projects doctor when the stored status needs investigation."
|
|
1181
|
+
: "Run projects doctor to perform safe URL and metadata checks.",
|
|
1182
|
+
"Use secrets inject for commands that need environment values.",
|
|
1183
|
+
];
|
|
1184
|
+
return [...nextActions, ...memory.nextPlaceToLook];
|
|
1185
|
+
}
|
|
1186
|
+
function createDashboardGuide(input) {
|
|
1187
|
+
const nextActions = input.memories.length === 0
|
|
1188
|
+
? [
|
|
1189
|
+
"Run projects seed-demo-fleet to load safe mock projects.",
|
|
1190
|
+
"Run projects remember <project> --url <url> to record public recovery facts.",
|
|
1191
|
+
"Run pnpm --filter @appfleet/web dev, then open http://localhost:3000.",
|
|
1192
|
+
]
|
|
1193
|
+
: [
|
|
1194
|
+
"Run pnpm --filter @appfleet/web dev, then open http://localhost:3000.",
|
|
1195
|
+
"Run projects status for the daily recovery summary.",
|
|
1196
|
+
"Run projects doctor to refresh safe stored dashboard findings.",
|
|
1197
|
+
];
|
|
1198
|
+
return {
|
|
1199
|
+
type: "project_dashboard_guide",
|
|
1200
|
+
version: 1,
|
|
1201
|
+
localDashboardUrl: "http://localhost:3000",
|
|
1202
|
+
devCommand: "pnpm --filter @appfleet/web dev",
|
|
1203
|
+
storePath: input.storePath,
|
|
1204
|
+
memoryStoreExists: input.memories.length > 0,
|
|
1205
|
+
projectCount: input.memories.length,
|
|
1206
|
+
selectedProject: input.selectedMemory
|
|
1207
|
+
? {
|
|
1208
|
+
id: input.selectedMemory.id,
|
|
1209
|
+
name: input.selectedMemory.name,
|
|
1210
|
+
gitRemoteFingerprint: input.selectedMemory.identity.gitRemoteFingerprint,
|
|
1211
|
+
latestDoctorCheckedAt: input.selectedMemory.latestDoctorReport?.checkedAt,
|
|
1212
|
+
}
|
|
1213
|
+
: undefined,
|
|
1214
|
+
productionCloudPersistenceImplemented: false,
|
|
1215
|
+
trustBoundary: [
|
|
1216
|
+
"Dashboard reads local project memory only.",
|
|
1217
|
+
"Dashboard does not read .env files or print environment values.",
|
|
1218
|
+
"Dashboard does not call provider APIs or claim hosted AppFleet Cloud persistence.",
|
|
1219
|
+
],
|
|
1220
|
+
nextActions,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
async function createDoctorReport(input) {
|
|
1224
|
+
const findings = [];
|
|
1225
|
+
findings.push(createFinding("project_memory_present", "ok", "project_memory", "Project memory found", `AppFleet resolved project memory for ${input.memory.name}.`));
|
|
1226
|
+
if (!input.memory.identity.gitRemoteFingerprint) {
|
|
1227
|
+
findings.push(createFinding("git_remote_not_recorded", "warning", "git_remote", "Git remote fingerprint not recorded", "Record the repo fingerprint with projects remember so future recovery can auto-resolve this project.", undefined, "Run projects remember with the current repository remote."));
|
|
1228
|
+
}
|
|
1229
|
+
else if (input.currentGitRemoteFingerprint &&
|
|
1230
|
+
input.currentGitRemoteFingerprint === input.memory.identity.gitRemoteFingerprint) {
|
|
1231
|
+
findings.push(createFinding("git_remote_matches", "ok", "git_remote", "Git remote matches project memory", "The current repository remote matches this project memory.", input.memory.identity.gitRemoteFingerprint));
|
|
1232
|
+
}
|
|
1233
|
+
else if (input.currentGitRemoteFingerprint) {
|
|
1234
|
+
findings.push(createFinding("git_remote_mismatch", "warning", "git_remote", "Git remote differs from project memory", "The current repository remote does not match the stored project fingerprint.", input.memory.identity.gitRemoteFingerprint, "Choose the project explicitly or update project memory if this repo moved."));
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
findings.push(createFinding("git_remote_unknown", "unknown", "git_remote", "Current git remote unknown", "AppFleet could not detect a current origin remote.", undefined, "Run from inside a git repo or pass a project id explicitly."));
|
|
1238
|
+
}
|
|
1239
|
+
const canonicalCheck = normalizeStatusCheck(await input.checkUrl(input.memory.canonicalUrl));
|
|
1240
|
+
findings.push(findingFromStatusCheck("canonical_url", canonicalCheck));
|
|
1241
|
+
const canonicalTlsFinding = findingFromTlsExpiry("canonical_url", canonicalCheck);
|
|
1242
|
+
if (canonicalTlsFinding) {
|
|
1243
|
+
findings.push(canonicalTlsFinding);
|
|
1244
|
+
}
|
|
1245
|
+
const rememberedChecks = await Promise.all(input.memory.rememberedUrls.map(async (url) => normalizeStatusCheck(await input.checkUrl(url))));
|
|
1246
|
+
for (const rememberedCheck of rememberedChecks) {
|
|
1247
|
+
findings.push(findingFromStatusCheck("remembered_url", rememberedCheck));
|
|
1248
|
+
const rememberedTlsFinding = findingFromTlsExpiry("remembered_url", rememberedCheck);
|
|
1249
|
+
if (rememberedTlsFinding) {
|
|
1250
|
+
findings.push(rememberedTlsFinding);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (input.memory.environmentAliases.length === 0) {
|
|
1254
|
+
findings.push(createFinding("env_aliases_missing", "warning", "environment_alias", "No environment aliases recorded", "AppFleet has no env alias names for this project.", undefined, "Record alias names with projects remember; do not store values in project memory."));
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
findings.push(createFinding("env_aliases_recorded", "ok", "environment_alias", "Environment aliases recorded", `${input.memory.environmentAliases.length} alias name(s) are recorded without values.`));
|
|
1258
|
+
}
|
|
1259
|
+
for (const provider of input.memory.providers) {
|
|
1260
|
+
if (provider.kind === "backend" || provider.status === "unknown") {
|
|
1261
|
+
findings.push(createFinding(`provider_${provider.id}_unknown`, "unknown", "provider_health", `${provider.name} health unknown`, "Provider health is unknown because AppFleet does not call provider APIs, read .env files, or infer provider failures in this safe check.", provider.kind, "Check provider status outside AppFleet without exposing credentials."));
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
findings.push(createFinding("cloud_sync_scaffold", input.memory.cloudSync.lastSyncedAt ? "ok" : "unknown", "cloud_sync", input.memory.cloudSync.lastSyncedAt
|
|
1265
|
+
? "Cloud sync timestamp recorded"
|
|
1266
|
+
: "Cloud sync is scaffold-only", input.memory.cloudSync.lastSyncedAt
|
|
1267
|
+
? `Last synced at ${input.memory.cloudSync.lastSyncedAt}.`
|
|
1268
|
+
: "Production AppFleet Cloud persistence is not implemented in this slice.", undefined, "Use projects sync --json to inspect the cloud-safe contract."));
|
|
1269
|
+
const report = createProjectDoctorReport({
|
|
1270
|
+
memory: { ...input.memory, latestCheck: canonicalCheck },
|
|
1271
|
+
checkedAt: input.checkedAt,
|
|
1272
|
+
latestCheck: canonicalCheck,
|
|
1273
|
+
findings,
|
|
1274
|
+
nextActions: [
|
|
1275
|
+
"Fix misconfigured URL findings first.",
|
|
1276
|
+
"Use projects check to refresh the stored canonical status.",
|
|
1277
|
+
"Use secrets inject for commands that need environment values.",
|
|
1278
|
+
],
|
|
1279
|
+
});
|
|
1280
|
+
return { report, latestCheck: canonicalCheck };
|
|
1281
|
+
}
|
|
1282
|
+
function findingFromStatusCheck(prefix, check) {
|
|
1283
|
+
const severity = severityFromStatus(check.status);
|
|
1284
|
+
return createFinding(`${prefix}_${slugify(check.url)}`, severity, "http_status", `${prefix === "canonical_url" ? "Canonical URL" : "Remembered URL"} is ${check.status}`, check.message ?? `HTTP status is ${check.status}.`, [
|
|
1285
|
+
`${check.url} status=${check.status}`,
|
|
1286
|
+
`failureCategory=${check.failureCategory ?? "none"}`,
|
|
1287
|
+
`checkedAt=${check.checkedAt}`,
|
|
1288
|
+
`responseTimeMs=${check.responseTimeMs ?? "unknown"}`,
|
|
1289
|
+
`tlsCertificateExpiresAt=${check.tlsCertificateExpiresAt ?? "unknown"}`,
|
|
1290
|
+
`tlsCertificateDaysRemaining=${check.tlsCertificateDaysRemaining ?? "unknown"}`,
|
|
1291
|
+
].join(" "), severity === "ok"
|
|
1292
|
+
? undefined
|
|
1293
|
+
: prefix === "canonical_url"
|
|
1294
|
+
? "Fix the canonical project URL before debugging provider state."
|
|
1295
|
+
: "Prefer the canonical URL when remembered URLs are stale or misconfigured.");
|
|
1296
|
+
}
|
|
1297
|
+
function findingFromTlsExpiry(prefix, check) {
|
|
1298
|
+
if (check.tlsCertificateExpiresAt === undefined ||
|
|
1299
|
+
check.tlsCertificateDaysRemaining === undefined) {
|
|
1300
|
+
return undefined;
|
|
1301
|
+
}
|
|
1302
|
+
if (check.tlsCertificateDaysRemaining > 30) {
|
|
1303
|
+
return createFinding(`${prefix}_tls_certificate_valid_${slugify(check.url)}`, "ok", "http_status", `${prefix === "canonical_url" ? "Canonical URL" : "Remembered URL"} TLS certificate is current`, `TLS certificate expires in ${check.tlsCertificateDaysRemaining} day(s).`, `${check.url} tlsCertificateExpiresAt=${check.tlsCertificateExpiresAt} tlsCertificateDaysRemaining=${check.tlsCertificateDaysRemaining}`);
|
|
1304
|
+
}
|
|
1305
|
+
const expired = check.tlsCertificateDaysRemaining < 0;
|
|
1306
|
+
return createFinding(`${prefix}_tls_certificate_${expired ? "expired" : "expiring"}_${slugify(check.url)}`, expired ? "misconfigured" : "warning", "http_status", `${prefix === "canonical_url" ? "Canonical URL" : "Remembered URL"} TLS certificate ${expired ? "expired" : "expires soon"}`, expired
|
|
1307
|
+
? "TLS certificate has passed its public expiry date."
|
|
1308
|
+
: `TLS certificate expires in ${check.tlsCertificateDaysRemaining} day(s).`, `${check.url} tlsCertificateExpiresAt=${check.tlsCertificateExpiresAt} tlsCertificateDaysRemaining=${check.tlsCertificateDaysRemaining}`, "Renew the public TLS certificate before debugging provider state.");
|
|
1309
|
+
}
|
|
1310
|
+
function severityFromStatus(status) {
|
|
1311
|
+
switch (status) {
|
|
1312
|
+
case "up":
|
|
1313
|
+
return "ok";
|
|
1314
|
+
case "misconfigured":
|
|
1315
|
+
return "misconfigured";
|
|
1316
|
+
case "down":
|
|
1317
|
+
return "warning";
|
|
1318
|
+
case "unknown":
|
|
1319
|
+
return "unknown";
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
function createFinding(id, severity, category, title, message, evidence, nextAction) {
|
|
1323
|
+
return {
|
|
1324
|
+
id,
|
|
1325
|
+
severity,
|
|
1326
|
+
category,
|
|
1327
|
+
title,
|
|
1328
|
+
message,
|
|
1329
|
+
evidence,
|
|
1330
|
+
nextAction,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function normalizeStatusCheck(check) {
|
|
1334
|
+
const responseTimeMs = check.responseTimeMs ?? check.latencyMs;
|
|
1335
|
+
return {
|
|
1336
|
+
...check,
|
|
1337
|
+
latencyMs: check.latencyMs ?? responseTimeMs,
|
|
1338
|
+
responseTimeMs,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
function formatCheckResult(memory, latestCheck, rememberedChecks) {
|
|
1342
|
+
const lines = [
|
|
1343
|
+
`AppFleet project check: ${memory.name}`,
|
|
1344
|
+
`canonicalUrl=${latestCheck.url}`,
|
|
1345
|
+
`status=${latestCheck.status}`,
|
|
1346
|
+
`checkedAt=${latestCheck.checkedAt}`,
|
|
1347
|
+
`responseTimeMs=${latestCheck.responseTimeMs ?? "unknown"}`,
|
|
1348
|
+
`httpStatusCode=${latestCheck.httpStatusCode ?? "unknown"}`,
|
|
1349
|
+
`failureCategory=${latestCheck.failureCategory ?? "none"}`,
|
|
1350
|
+
`tlsCertificateExpiresAt=${latestCheck.tlsCertificateExpiresAt ?? "unknown"}`,
|
|
1351
|
+
`tlsCertificateDaysRemaining=${latestCheck.tlsCertificateDaysRemaining ?? "unknown"}`,
|
|
1352
|
+
`message=${latestCheck.message ?? ""}`,
|
|
1353
|
+
];
|
|
1354
|
+
for (const remembered of rememberedChecks) {
|
|
1355
|
+
lines.push(`rememberedUrl=${remembered.url} status=${remembered.status} checkedAt=${remembered.checkedAt} responseTimeMs=${remembered.responseTimeMs ?? "unknown"} failureCategory=${remembered.failureCategory ?? "none"} tlsCertificateExpiresAt=${remembered.tlsCertificateExpiresAt ?? "unknown"} tlsCertificateDaysRemaining=${remembered.tlsCertificateDaysRemaining ?? "unknown"}`);
|
|
1356
|
+
}
|
|
1357
|
+
lines.push("nextPlaceToLook:");
|
|
1358
|
+
for (const next of memory.nextPlaceToLook) {
|
|
1359
|
+
lines.push(`- ${next}`);
|
|
1360
|
+
}
|
|
1361
|
+
return `${lines.join("\n")}\n`;
|
|
1362
|
+
}
|
|
1363
|
+
function formatProjectStatus(summary) {
|
|
1364
|
+
const lines = [
|
|
1365
|
+
`AppFleet project status: ${summary.name}`,
|
|
1366
|
+
`repo=${summary.repoUrl ?? "unknown"}`,
|
|
1367
|
+
`gitRemoteFingerprint=${summary.gitRemoteFingerprint ?? "unknown"}`,
|
|
1368
|
+
`canonicalUrl=${summary.canonicalUrl}`,
|
|
1369
|
+
`rememberedUrls=${summary.rememberedUrls.join(", ") || "none"}`,
|
|
1370
|
+
`latestStatus=${summary.latestStatus}`,
|
|
1371
|
+
`lastCheckedAt=${summary.lastCheckedAt ?? "unknown"}`,
|
|
1372
|
+
`responseTimeMs=${summary.responseTimeMs ?? "unknown"}`,
|
|
1373
|
+
`cloudSync=${summary.cloudSyncStatus}`,
|
|
1374
|
+
`productionCloudPersistence=${summary.productionCloudPersistenceImplemented}`,
|
|
1375
|
+
`providers=${summary.providers.map((provider) => provider.name).join(", ") || "none"}`,
|
|
1376
|
+
`envAliases=${summary.environmentAliases.map((alias) => alias.name).join(", ") || "none"}`,
|
|
1377
|
+
"unknownHealthExplanations:",
|
|
1378
|
+
...summary.unknownHealthExplanations.map((explanation) => `- ${explanation}`),
|
|
1379
|
+
"nextActions:",
|
|
1380
|
+
...summary.nextActions.map((action) => `- ${action}`),
|
|
1381
|
+
];
|
|
1382
|
+
return `${lines.join("\n")}\n`;
|
|
1383
|
+
}
|
|
1384
|
+
function formatProjectDoctor(report) {
|
|
1385
|
+
const lines = [
|
|
1386
|
+
`AppFleet project doctor: ${report.name}`,
|
|
1387
|
+
`checkedAt=${report.checkedAt}`,
|
|
1388
|
+
`gitRemoteFingerprint=${report.gitRemoteFingerprint ?? "unknown"}`,
|
|
1389
|
+
`latestStatus=${report.latestCheck?.status ?? "unknown"}`,
|
|
1390
|
+
`lastCheckedAt=${report.latestCheck?.checkedAt ?? "unknown"}`,
|
|
1391
|
+
`responseTimeMs=${report.latestCheck?.responseTimeMs ?? "unknown"}`,
|
|
1392
|
+
`productionCloudPersistence=${report.productionCloudPersistenceImplemented}`,
|
|
1393
|
+
"unknownHealthExplanations:",
|
|
1394
|
+
...report.unknownHealthExplanations.map((explanation) => `- ${explanation}`),
|
|
1395
|
+
"findings:",
|
|
1396
|
+
...report.findings.map((finding) => `- ${finding.severity} ${finding.category}: ${finding.title} (${finding.message})`),
|
|
1397
|
+
"nextActions:",
|
|
1398
|
+
...report.nextActions.map((action) => `- ${action}`),
|
|
1399
|
+
];
|
|
1400
|
+
return `${lines.join("\n")}\n`;
|
|
1401
|
+
}
|
|
1402
|
+
function formatProjectDiscovery(report) {
|
|
1403
|
+
const lines = [
|
|
1404
|
+
"AppFleet project discovery",
|
|
1405
|
+
`rootPath=${report.rootPath}`,
|
|
1406
|
+
`projectId=${report.project.id}`,
|
|
1407
|
+
`name=${report.project.name}`,
|
|
1408
|
+
`canonicalUrl=${report.project.canonicalUrl ?? "unknown"}`,
|
|
1409
|
+
`repo=${report.project.repoUrl ?? "unknown"}`,
|
|
1410
|
+
`gitRemoteFingerprint=${report.project.gitRemoteFingerprint ?? "unknown"}`,
|
|
1411
|
+
`packageManager=${report.packageManager ?? "unknown"}`,
|
|
1412
|
+
`providers=${report.providers.map((provider) => provider.name).join(", ") || "none"}`,
|
|
1413
|
+
`envAliases=${report.environmentAliases.map((alias) => alias.name).join(", ") || "none"}`,
|
|
1414
|
+
`filesScanned=${report.filesScanned}`,
|
|
1415
|
+
`skippedPaths=${report.skippedPaths.join(", ") || "none"}`,
|
|
1416
|
+
`remembered=${report.remembered}`,
|
|
1417
|
+
`productionCloudPersistence=${report.productionCloudPersistenceImplemented}`,
|
|
1418
|
+
"trustBoundary:",
|
|
1419
|
+
...report.trustBoundary.map((item) => `- ${item}`),
|
|
1420
|
+
"nextActions:",
|
|
1421
|
+
...report.nextActions.map((action) => `- ${action}`),
|
|
1422
|
+
];
|
|
1423
|
+
return `${lines.join("\n")}\n`;
|
|
1424
|
+
}
|
|
1425
|
+
function formatDashboardGuide(guide) {
|
|
1426
|
+
const lines = [
|
|
1427
|
+
"AppFleet local dashboard",
|
|
1428
|
+
`url=${guide.localDashboardUrl}`,
|
|
1429
|
+
`devCommand=${guide.devCommand}`,
|
|
1430
|
+
`storePath=${guide.storePath}`,
|
|
1431
|
+
`memoryStoreExists=${guide.memoryStoreExists}`,
|
|
1432
|
+
`projectCount=${guide.projectCount}`,
|
|
1433
|
+
`productionCloudPersistence=${guide.productionCloudPersistenceImplemented}`,
|
|
1434
|
+
];
|
|
1435
|
+
if (guide.selectedProject) {
|
|
1436
|
+
lines.push(`selectedProject=${guide.selectedProject.name}`, `gitRemoteFingerprint=${guide.selectedProject.gitRemoteFingerprint ?? "unknown"}`, `latestDoctorCheckedAt=${guide.selectedProject.latestDoctorCheckedAt ?? "unknown"}`);
|
|
1437
|
+
}
|
|
1438
|
+
lines.push("trustBoundary:", ...guide.trustBoundary.map((item) => `- ${item}`), "nextActions:", ...guide.nextActions.map((action) => `- ${action}`));
|
|
1439
|
+
return `${lines.join("\n")}\n`;
|
|
1440
|
+
}
|
|
1441
|
+
function formatSyncScaffold(syncRequest) {
|
|
1442
|
+
return ([
|
|
1443
|
+
"AppFleet sync scaffold: project memory envelopes prepared.",
|
|
1444
|
+
"productionCloudPersistence=false",
|
|
1445
|
+
`envelopeCount=${syncRequest.envelopes.length}`,
|
|
1446
|
+
`projectIds=${syncRequest.envelopes.map((envelope) => envelope.projectId).join(", ")}`,
|
|
1447
|
+
].join("\n") + "\n");
|
|
1448
|
+
}
|
|
1449
|
+
function formatJson(value) {
|
|
1450
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1451
|
+
}
|
|
1452
|
+
function readFlag(argv, flag) {
|
|
1453
|
+
const index = argv.indexOf(flag);
|
|
1454
|
+
if (index === -1) {
|
|
1455
|
+
return undefined;
|
|
1456
|
+
}
|
|
1457
|
+
return argv[index + 1];
|
|
1458
|
+
}
|
|
1459
|
+
function readRepeatedFlag(argv, flag) {
|
|
1460
|
+
const values = [];
|
|
1461
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1462
|
+
if (argv[index] === flag && argv[index + 1]) {
|
|
1463
|
+
values.push(argv[index + 1]);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return values;
|
|
1467
|
+
}
|
|
1468
|
+
function readOptionalPositional(argv, startIndex) {
|
|
1469
|
+
const flagsWithValues = new Set([
|
|
1470
|
+
"--env-alias",
|
|
1471
|
+
"--git-remote-fingerprint",
|
|
1472
|
+
"--name",
|
|
1473
|
+
"--note",
|
|
1474
|
+
"--path",
|
|
1475
|
+
"--project",
|
|
1476
|
+
"--provider",
|
|
1477
|
+
"--remembered-url",
|
|
1478
|
+
"--repo",
|
|
1479
|
+
"--url",
|
|
1480
|
+
"--workspace",
|
|
1481
|
+
]);
|
|
1482
|
+
for (let index = startIndex; index < argv.length; index += 1) {
|
|
1483
|
+
const argument = argv[index];
|
|
1484
|
+
if (flagsWithValues.has(argument)) {
|
|
1485
|
+
index += 1;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (!argument.startsWith("--")) {
|
|
1489
|
+
return argument;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return undefined;
|
|
1493
|
+
}
|
|
1494
|
+
function hasFlag(argv, flag) {
|
|
1495
|
+
return argv.includes(flag);
|
|
1496
|
+
}
|
|
1497
|
+
function slugify(value) {
|
|
1498
|
+
return (value
|
|
1499
|
+
.toLowerCase()
|
|
1500
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1501
|
+
.replace(/^-|-$/g, "") || randomUUID());
|
|
1502
|
+
}
|
|
1503
|
+
function normalizeRepositoryFingerprint(hostname, pathname) {
|
|
1504
|
+
const normalizedPath = pathname
|
|
1505
|
+
.replace(/^\/+/, "")
|
|
1506
|
+
.replace(/\.git$/i, "")
|
|
1507
|
+
.replace(/\/+$/, "");
|
|
1508
|
+
return `${hostname.toLowerCase()}/${normalizedPath}`;
|
|
1509
|
+
}
|
|
1510
|
+
function defaultProjectMemoryStorePath(cwd) {
|
|
1511
|
+
const packageParent = basename(dirname(cwd));
|
|
1512
|
+
const packageName = basename(cwd);
|
|
1513
|
+
if (packageParent === "apps" && packageName === "cli") {
|
|
1514
|
+
return resolve(cwd, "..", "..", ".appfleet", "project-memory.json");
|
|
1515
|
+
}
|
|
1516
|
+
return resolve(cwd, ".appfleet", "project-memory.json");
|
|
1517
|
+
}
|
|
1518
|
+
function errorMessage(error) {
|
|
1519
|
+
return error instanceof Error ? error.message : String(error);
|
|
1520
|
+
}
|
|
1521
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1522
|
+
const result = await runProjectMemoryCommand(process.argv.slice(2), {
|
|
1523
|
+
storePath: process.env.APPFLEET_PROJECT_MEMORY_STORE_PATH,
|
|
1524
|
+
currentGitRemoteFingerprint: process.env.APPFLEET_GIT_REMOTE_FINGERPRINT,
|
|
1525
|
+
});
|
|
1526
|
+
process.stdout.write(result.stdout);
|
|
1527
|
+
process.stderr.write(result.stderr);
|
|
1528
|
+
process.exitCode = result.exitCode;
|
|
1529
|
+
}
|