aligndev 0.0.0 → 0.18.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 +166 -1
- package/bin/aligndev.mjs +3 -0
- package/dist/alignfirst-cli.d.ts +10 -0
- package/dist/alignfirst-cli.js +56 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +95 -0
- package/dist/code/claude-agent.d.ts +14 -0
- package/dist/code/claude-agent.js +168 -0
- package/dist/code/code-cli.d.ts +72 -0
- package/dist/code/code-cli.js +630 -0
- package/dist/code/codex-agent.d.ts +15 -0
- package/dist/code/codex-agent.js +168 -0
- package/dist/code/codex-rollout.d.ts +18 -0
- package/dist/code/codex-rollout.js +114 -0
- package/dist/code/coding-agent.d.ts +4 -0
- package/dist/code/coding-agent.js +6 -0
- package/dist/code/models.d.ts +14 -0
- package/dist/code/models.js +89 -0
- package/dist/code/prompt.d.ts +11 -0
- package/dist/code/prompt.js +26 -0
- package/dist/code/quota.d.ts +25 -0
- package/dist/code/quota.js +248 -0
- package/dist/code/run-agent.d.ts +60 -0
- package/dist/code/run-agent.js +212 -0
- package/dist/code/session-file.d.ts +50 -0
- package/dist/code/session-file.js +263 -0
- package/dist/command-form.d.ts +6 -0
- package/dist/command-form.js +7 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +72 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +6 -0
- package/dist/guide/code-guide.d.ts +4 -0
- package/dist/guide/code-guide.js +17 -0
- package/dist/guide/guide-cli.d.ts +3 -0
- package/dist/guide/guide-cli.js +81 -0
- package/dist/guide/render-template.d.ts +4 -0
- package/dist/guide/render-template.js +62 -0
- package/dist/guide/topics.d.ts +3 -0
- package/dist/guide/topics.js +16 -0
- package/dist/output.d.ts +3 -0
- package/dist/output.js +1 -0
- package/dist/project/discovery.d.ts +42 -0
- package/dist/project/discovery.js +287 -0
- package/dist/project/format.d.ts +6 -0
- package/dist/project/format.js +31 -0
- package/dist/project/guide.d.ts +3 -0
- package/dist/project/guide.js +69 -0
- package/dist/project/layout.d.ts +36 -0
- package/dist/project/layout.js +128 -0
- package/dist/project/markers.d.ts +18 -0
- package/dist/project/markers.js +90 -0
- package/dist/project/ports.d.ts +3 -0
- package/dist/project/ports.js +55 -0
- package/dist/project/project-cli.d.ts +17 -0
- package/dist/project/project-cli.js +227 -0
- package/dist/project/render.d.ts +10 -0
- package/dist/project/render.js +144 -0
- package/dist/project/status.d.ts +24 -0
- package/dist/project/status.js +110 -0
- package/dist/templates.d.ts +1 -0
- package/dist/templates.js +5 -0
- package/package.json +38 -3
- package/templates/guide/code.md +252 -0
- package/templates/guide/playbook/channel-handling.md +110 -0
- package/templates/guide/playbook/consultation.md +71 -0
- package/templates/guide/playbook/discord-message-tool.md +37 -0
- package/templates/guide/playbook/playbook.md +139 -0
- package/templates/guide/playbook/project-lifecycle.md +100 -0
- package/templates/guide/playbook/project-workspace-setup.md +186 -0
- package/templates/guide/playbook/slack-message-tool.md +23 -0
- package/templates/guide/playbook/working-session.md +588 -0
- package/templates/guide/project.md +33 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { isNodeError } from "../errors.js";
|
|
4
|
+
import { formatRange } from "./format.js";
|
|
5
|
+
import { readProjectReport } from "./layout.js";
|
|
6
|
+
import { containsRange, rangesOverlap, readMarker, } from "./markers.js";
|
|
7
|
+
const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
|
|
8
|
+
export function buildInventory(root, marker, ctx) {
|
|
9
|
+
const state = { directories: [], candidates: [], directoryClaims: [], issues: [] };
|
|
10
|
+
walkProjectsDirectory(root, marker, undefined, state);
|
|
11
|
+
const projects = classifyCandidates(state, ctx);
|
|
12
|
+
reportSharedCompanions(projects, state.issues);
|
|
13
|
+
reportOverlappingClaims(projects, state.directoryClaims, state.issues);
|
|
14
|
+
sortInventory(state.directories, projects, state.issues);
|
|
15
|
+
return { root, directories: state.directories, projects, issues: state.issues };
|
|
16
|
+
}
|
|
17
|
+
function walkProjectsDirectory(path, marker, enclosingRanges, state) {
|
|
18
|
+
const effectiveRanges = marker.portRanges ?? enclosingRanges;
|
|
19
|
+
state.directories.push({
|
|
20
|
+
path,
|
|
21
|
+
...(marker.description === undefined ? {} : { description: marker.description }),
|
|
22
|
+
...(marker.portRanges === undefined ? {} : { portRanges: marker.portRanges }),
|
|
23
|
+
others: [],
|
|
24
|
+
});
|
|
25
|
+
for (const candidate of readDirectoryCandidates(path)) {
|
|
26
|
+
const childMarker = readMarker(candidate.path);
|
|
27
|
+
if (childMarker === undefined) {
|
|
28
|
+
state.candidates.push({ ...candidate, enclosingRanges: effectiveRanges });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const portRange of childMarker.portRanges ?? []) {
|
|
32
|
+
reportOutsideRange(candidate.path, portRange, effectiveRanges, state.issues);
|
|
33
|
+
state.directoryClaims.push({
|
|
34
|
+
scope: path,
|
|
35
|
+
path: candidate.path,
|
|
36
|
+
portRange,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
walkProjectsDirectory(candidate.path, childMarker, effectiveRanges, state);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function readDirectoryCandidates(directory) {
|
|
43
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
44
|
+
.filter((entry) => entry.isDirectory())
|
|
45
|
+
.flatMap((entry) => readDirectoryCandidate(directory, entry.name))
|
|
46
|
+
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
47
|
+
}
|
|
48
|
+
function readDirectoryCandidate(directory, name) {
|
|
49
|
+
try {
|
|
50
|
+
return [{ name, directory, path: realpathSync(join(directory, name)) }];
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
|
|
54
|
+
return [];
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function classifyCandidates(state, ctx) {
|
|
59
|
+
const linkedCandidates = [];
|
|
60
|
+
const ordinaryCandidates = [];
|
|
61
|
+
for (const candidate of state.candidates) {
|
|
62
|
+
(isLinkedWorktree(candidate.path) ? linkedCandidates : ordinaryCandidates).push(candidate);
|
|
63
|
+
}
|
|
64
|
+
const classified = ordinaryCandidates.flatMap((candidate) => classifyCandidate(candidate, state, ctx));
|
|
65
|
+
const projects = excludeWorkFilesRepositories(classified, state.directories);
|
|
66
|
+
attachLinkedWorktrees(linkedCandidates, projects, state.directories);
|
|
67
|
+
return projects;
|
|
68
|
+
}
|
|
69
|
+
function classifyCandidate(candidate, state, ctx) {
|
|
70
|
+
if (mainWorktreeGitDirectory(candidate.path) === undefined) {
|
|
71
|
+
if (existsSync(join(candidate.path, PROJECT_CONFIG_FILENAME))) {
|
|
72
|
+
state.issues.push({ path: candidate.path, message: "not a git main worktree" });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
addOther(state.directories, candidate.directory, candidate.name);
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const description = readProjectReport(ctx.alignfirstCommand, candidate.path, ctx.env);
|
|
80
|
+
if ("error" in description) {
|
|
81
|
+
state.issues.push({ path: candidate.path, message: description.error });
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const project = {
|
|
85
|
+
name: candidate.name,
|
|
86
|
+
path: candidate.path,
|
|
87
|
+
directory: candidate.directory,
|
|
88
|
+
description,
|
|
89
|
+
...(description.config?.portRange === undefined
|
|
90
|
+
? {}
|
|
91
|
+
: { portRange: description.config.portRange }),
|
|
92
|
+
workspaces: [],
|
|
93
|
+
};
|
|
94
|
+
const enclosingRange = findEnclosingRange(candidate.enclosingRanges, project.portRange);
|
|
95
|
+
if (enclosingRange?.code !== undefined)
|
|
96
|
+
project.portRangeCode = enclosingRange.code;
|
|
97
|
+
if (description.cli !== null && !description.cli.satisfied) {
|
|
98
|
+
state.issues.push({
|
|
99
|
+
path: candidate.path,
|
|
100
|
+
message: `AlignFirst CLI ${description.cli.installed} does not satisfy required range ` +
|
|
101
|
+
description.cli.range,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
reportOutsideRange(project.path, project.portRange, candidate.enclosingRanges, state.issues);
|
|
105
|
+
return [project];
|
|
106
|
+
}
|
|
107
|
+
// A repository holding another project's `.plans` target, such as a work-files clone, is listed
|
|
108
|
+
// with the others.
|
|
109
|
+
function excludeWorkFilesRepositories(projects, directories) {
|
|
110
|
+
const plansTargets = projects.flatMap((project) => {
|
|
111
|
+
const target = realPlansPath(project);
|
|
112
|
+
return target === undefined ? [] : [{ owner: project.path, target }];
|
|
113
|
+
});
|
|
114
|
+
return projects.filter((project) => {
|
|
115
|
+
const holdsPlans = plansTargets.some(({ owner, target }) => owner !== project.path && isSameOrInside(target, project.path));
|
|
116
|
+
if (holdsPlans)
|
|
117
|
+
addOther(directories, project.directory, project.name);
|
|
118
|
+
return !holdsPlans;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function realPlansPath(project) {
|
|
122
|
+
const plans = project.description.locations[".plans"];
|
|
123
|
+
if (!plans.exists)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
return realpathSync(plans.path);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function isSameOrInside(path, ancestor) {
|
|
133
|
+
return path === ancestor || path.startsWith(`${ancestor}${sep}`);
|
|
134
|
+
}
|
|
135
|
+
function isLinkedWorktree(path) {
|
|
136
|
+
try {
|
|
137
|
+
return lstatSync(join(path, ".git")).isFile();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function attachLinkedWorktrees(candidates, projects, directories) {
|
|
144
|
+
const mainsByGitDirectory = new Map();
|
|
145
|
+
for (const project of projects) {
|
|
146
|
+
const gitDirectory = mainWorktreeGitDirectory(project.path);
|
|
147
|
+
if (gitDirectory === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
mainsByGitDirectory.set(gitDirectory, {
|
|
150
|
+
name: project.name,
|
|
151
|
+
directory: project.directory,
|
|
152
|
+
path: project.path,
|
|
153
|
+
gitDirectory,
|
|
154
|
+
project,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
const main = linkedWorktreeMain(candidate.path, mainsByGitDirectory);
|
|
159
|
+
if (main === undefined)
|
|
160
|
+
addOther(directories, candidate.directory, candidate.name);
|
|
161
|
+
else
|
|
162
|
+
main.project.workspaces.push(candidate.name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function mainWorktreeGitDirectory(projectPath) {
|
|
166
|
+
const gitPath = join(projectPath, ".git");
|
|
167
|
+
try {
|
|
168
|
+
if (!lstatSync(gitPath).isDirectory())
|
|
169
|
+
return;
|
|
170
|
+
return realpathSync(gitPath);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function linkedWorktreeMain(worktreePath, mainsByGitDirectory) {
|
|
177
|
+
const worktreeGitFile = join(worktreePath, ".git");
|
|
178
|
+
try {
|
|
179
|
+
if (!lstatSync(worktreeGitFile).isFile())
|
|
180
|
+
return;
|
|
181
|
+
const metadataDirectory = resolveGitdirFile(worktreeGitFile);
|
|
182
|
+
const mainGitDirectory = resolveMetadataPath(metadataDirectory, "commondir");
|
|
183
|
+
const main = mainsByGitDirectory.get(mainGitDirectory);
|
|
184
|
+
if (main === undefined)
|
|
185
|
+
return;
|
|
186
|
+
if (dirname(metadataDirectory) !== join(mainGitDirectory, "worktrees"))
|
|
187
|
+
return;
|
|
188
|
+
const backlink = resolveMetadataPath(metadataDirectory, "gitdir");
|
|
189
|
+
if (backlink !== realpathSync(worktreeGitFile))
|
|
190
|
+
return;
|
|
191
|
+
return main;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function resolveGitdirFile(gitFile) {
|
|
198
|
+
const match = /^gitdir:\s*(.+)\s*$/u.exec(readFileSync(gitFile, "utf8"));
|
|
199
|
+
if (match === null)
|
|
200
|
+
throw new Error(`Invalid Git file: ${gitFile}`);
|
|
201
|
+
return realpathSync(resolve(dirname(gitFile), match[1]));
|
|
202
|
+
}
|
|
203
|
+
function resolveMetadataPath(metadataDirectory, filename) {
|
|
204
|
+
const target = readFileSync(join(metadataDirectory, filename), "utf8").trim();
|
|
205
|
+
if (target.length === 0)
|
|
206
|
+
throw new Error(`Empty Git metadata file: ${filename}`);
|
|
207
|
+
return realpathSync(resolve(metadataDirectory, target));
|
|
208
|
+
}
|
|
209
|
+
function addOther(directories, directoryPath, name) {
|
|
210
|
+
const directory = directories.find(({ path }) => path === directoryPath);
|
|
211
|
+
if (directory === undefined)
|
|
212
|
+
throw new Error(`Unknown projects directory: ${directoryPath}`);
|
|
213
|
+
directory.others.push(name);
|
|
214
|
+
}
|
|
215
|
+
function reportOutsideRange(path, range, enclosingRanges, issues) {
|
|
216
|
+
if (range === undefined || enclosingRanges === undefined)
|
|
217
|
+
return;
|
|
218
|
+
if (findEnclosingRange(enclosingRanges, range) !== undefined)
|
|
219
|
+
return;
|
|
220
|
+
issues.push({
|
|
221
|
+
path,
|
|
222
|
+
message: `port range ${formatRange(range)} fits no single enclosing range: ` +
|
|
223
|
+
enclosingRanges.map(formatRange).join(", "),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function findEnclosingRange(ranges, claim) {
|
|
227
|
+
if (claim === undefined)
|
|
228
|
+
return;
|
|
229
|
+
return ranges?.find((range) => containsRange(range, claim));
|
|
230
|
+
}
|
|
231
|
+
// Two projects can share a companion only through a `_` in a directory name.
|
|
232
|
+
function reportSharedCompanions(projects, issues) {
|
|
233
|
+
const projectsByCompanion = new Map();
|
|
234
|
+
for (const project of projects) {
|
|
235
|
+
const dir = project.description.companion?.dir;
|
|
236
|
+
if (dir === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
projectsByCompanion.set(dir, [...(projectsByCompanion.get(dir) ?? []), project]);
|
|
239
|
+
}
|
|
240
|
+
for (const [dir, group] of projectsByCompanion) {
|
|
241
|
+
if (group.length < 2)
|
|
242
|
+
continue;
|
|
243
|
+
for (const project of group) {
|
|
244
|
+
const others = group
|
|
245
|
+
.filter((other) => other !== project)
|
|
246
|
+
.map((other) => other.path)
|
|
247
|
+
.toSorted();
|
|
248
|
+
issues.push({
|
|
249
|
+
path: project.path,
|
|
250
|
+
message: `shares companion directory ${dir} with ${others.join(", ")}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function reportOverlappingClaims(projects, directoryClaims, issues) {
|
|
256
|
+
const claims = [
|
|
257
|
+
...directoryClaims,
|
|
258
|
+
...projects.flatMap((project) => project.portRange === undefined
|
|
259
|
+
? []
|
|
260
|
+
: [{ scope: project.directory, path: project.path, portRange: project.portRange }]),
|
|
261
|
+
].toSorted((left, right) => left.scope.localeCompare(right.scope) || left.path.localeCompare(right.path));
|
|
262
|
+
for (let index = 0; index < claims.length; ++index) {
|
|
263
|
+
const claim = claims[index];
|
|
264
|
+
for (let previous = 0; previous < index; ++previous) {
|
|
265
|
+
const other = claims[previous];
|
|
266
|
+
if (claim.scope !== other.scope || !rangesOverlap(claim.portRange, other.portRange))
|
|
267
|
+
continue;
|
|
268
|
+
issues.push({
|
|
269
|
+
path: claim.path,
|
|
270
|
+
message: `port range ${formatRange(claim.portRange)} overlaps ${basename(other.path)}`,
|
|
271
|
+
conflict: {
|
|
272
|
+
left: { path: other.path, portRange: other.portRange },
|
|
273
|
+
right: { path: claim.path, portRange: claim.portRange },
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function sortInventory(directories, projects, issues) {
|
|
280
|
+
directories.sort((left, right) => left.path.localeCompare(right.path));
|
|
281
|
+
projects.sort((left, right) => left.path.localeCompare(right.path));
|
|
282
|
+
issues.sort((left, right) => left.path.localeCompare(right.path));
|
|
283
|
+
for (const directory of directories)
|
|
284
|
+
directory.others.sort((left, right) => left.localeCompare(right));
|
|
285
|
+
for (const project of projects)
|
|
286
|
+
project.workspaces.sort((left, right) => left.localeCompare(right));
|
|
287
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PortRange } from "./markers.js";
|
|
2
|
+
export declare function formatRange(range: PortRange): string;
|
|
3
|
+
/** Escapes C0, C1 and line-separator characters for a terminal-safe line. */
|
|
4
|
+
export declare function escapeControlCharacters(value: string): string;
|
|
5
|
+
/** Escapes the characters JSON.stringify leaves as-is: C1 and the line separators. */
|
|
6
|
+
export declare function escapeAdditionalJsonCharacters(value: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function formatRange(range) {
|
|
2
|
+
return `${range.first}..${range.last}`;
|
|
3
|
+
}
|
|
4
|
+
/** Escapes C0, C1 and line-separator characters for a terminal-safe line. */
|
|
5
|
+
export function escapeControlCharacters(value) {
|
|
6
|
+
return Array.from(value, (character) => {
|
|
7
|
+
if (!isControlCharacter(character, true))
|
|
8
|
+
return character;
|
|
9
|
+
const jsonEscape = JSON.stringify(character).slice(1, -1);
|
|
10
|
+
return jsonEscape === character ? unicodeEscape(character) : jsonEscape;
|
|
11
|
+
}).join("");
|
|
12
|
+
}
|
|
13
|
+
/** Escapes the characters JSON.stringify leaves as-is: C1 and the line separators. */
|
|
14
|
+
export function escapeAdditionalJsonCharacters(value) {
|
|
15
|
+
return Array.from(value, (character) => isControlCharacter(character, false) ? unicodeEscape(character) : character).join("");
|
|
16
|
+
}
|
|
17
|
+
function isControlCharacter(character, includeC0) {
|
|
18
|
+
const codePoint = character.codePointAt(0);
|
|
19
|
+
if (codePoint === undefined)
|
|
20
|
+
return false;
|
|
21
|
+
return ((includeC0 && codePoint <= 0x1f) ||
|
|
22
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
23
|
+
codePoint === 0x2028 ||
|
|
24
|
+
codePoint === 0x2029);
|
|
25
|
+
}
|
|
26
|
+
function unicodeEscape(character) {
|
|
27
|
+
const codePoint = character.codePointAt(0);
|
|
28
|
+
if (codePoint === undefined)
|
|
29
|
+
throw new Error("Cannot escape an empty character");
|
|
30
|
+
return `\\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { renderGuideTemplate } from "../guide/render-template.js";
|
|
3
|
+
import { escapeAdditionalJsonCharacters, formatRange } from "./format.js";
|
|
4
|
+
// The projects guide serves the OpenClaw playbook only.
|
|
5
|
+
export function renderProjectsGuide(forms, inventory) {
|
|
6
|
+
const guide = renderGuideTemplate("project.md", "openclaw", forms);
|
|
7
|
+
if (inventory === undefined)
|
|
8
|
+
return guide;
|
|
9
|
+
const sections = inventory.directories.map((directory) => renderDirectory(inventory, directory));
|
|
10
|
+
return `${guide}\n\n${sections.join("\n\n")}`;
|
|
11
|
+
}
|
|
12
|
+
function renderDirectory(inventory, directory) {
|
|
13
|
+
const lines = [`## Directory ${renderData(directory.path)}`];
|
|
14
|
+
if (directory.description !== undefined) {
|
|
15
|
+
lines.push("", `Description: ${renderData(directory.description)}`);
|
|
16
|
+
}
|
|
17
|
+
lines.push("", "Port ranges:");
|
|
18
|
+
if (directory.portRanges === undefined)
|
|
19
|
+
lines.push("- (none)");
|
|
20
|
+
else {
|
|
21
|
+
for (const range of directory.portRanges)
|
|
22
|
+
lines.push(`- ${renderMarkerRange(range)}`);
|
|
23
|
+
}
|
|
24
|
+
lines.push("", "Projects:");
|
|
25
|
+
const projects = inventory.projects.filter((project) => project.directory === directory.path);
|
|
26
|
+
if (projects.length === 0)
|
|
27
|
+
lines.push("- (none)");
|
|
28
|
+
else {
|
|
29
|
+
for (const project of projects) {
|
|
30
|
+
lines.push(`- ${renderData(project.name)} — ${renderProjectRange(project.portRange, project.portRangeCode)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lines.push("", "Nested directories:");
|
|
34
|
+
const nested = inventory.directories.filter((candidate) => candidate.path !== directory.path && dirname(candidate.path) === directory.path);
|
|
35
|
+
if (nested.length === 0)
|
|
36
|
+
lines.push("- (none)");
|
|
37
|
+
else {
|
|
38
|
+
for (const child of nested) {
|
|
39
|
+
lines.push(`- ${renderData(child.path)} — ${renderRanges(child.portRanges)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
function renderData(value) {
|
|
45
|
+
const escaped = escapeAdditionalJsonCharacters(JSON.stringify(value));
|
|
46
|
+
const delimiter = "`".repeat(longestBacktickRun(escaped) + 1);
|
|
47
|
+
return `${delimiter}${escaped}${delimiter}`;
|
|
48
|
+
}
|
|
49
|
+
function longestBacktickRun(value) {
|
|
50
|
+
let longest = 0;
|
|
51
|
+
for (const [run] of value.matchAll(/`+/gu))
|
|
52
|
+
longest = Math.max(longest, run.length);
|
|
53
|
+
return longest;
|
|
54
|
+
}
|
|
55
|
+
function renderRange(range, absent = "(none)") {
|
|
56
|
+
return range === undefined ? absent : formatRange(range);
|
|
57
|
+
}
|
|
58
|
+
function renderProjectRange(range, code) {
|
|
59
|
+
const rendered = renderRange(range, "(portless)");
|
|
60
|
+
return `${rendered}${code === undefined ? "" : ` (${code})`}`;
|
|
61
|
+
}
|
|
62
|
+
function renderRanges(ranges) {
|
|
63
|
+
return ranges === undefined ? "(none)" : ranges.map(renderMarkerRange).join(", ");
|
|
64
|
+
}
|
|
65
|
+
function renderMarkerRange(range) {
|
|
66
|
+
const code = range.code ?? "default";
|
|
67
|
+
const description = range.description === undefined ? "" : ` — ${renderData(range.description)}`;
|
|
68
|
+
return `${formatRange(range)} (${code})${description}`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PortRange } from "./markers.js";
|
|
2
|
+
export type ItemName = ".alignfirst.json" | ".alignfirst.md" | "DEVELOPERS.md" | "docs" | ".plans" | "_aligndev";
|
|
3
|
+
export interface ProjectReport {
|
|
4
|
+
source: ConfigSource | null;
|
|
5
|
+
cli: ProjectCliReport | null;
|
|
6
|
+
config: ProjectConfigView | null;
|
|
7
|
+
companion: CompanionReport | null;
|
|
8
|
+
locations: Record<ItemName, ItemLocation>;
|
|
9
|
+
}
|
|
10
|
+
export type ConfigSource = "project" | "companion";
|
|
11
|
+
export interface ProjectCliReport {
|
|
12
|
+
installed: string;
|
|
13
|
+
range: string;
|
|
14
|
+
satisfied: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ProjectConfigView {
|
|
17
|
+
ticketIdPattern?: string;
|
|
18
|
+
plans?: {
|
|
19
|
+
folder?: string;
|
|
20
|
+
};
|
|
21
|
+
portRange?: PortRange;
|
|
22
|
+
}
|
|
23
|
+
export interface CompanionReport {
|
|
24
|
+
dir: string;
|
|
25
|
+
exists: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ItemLocation {
|
|
28
|
+
path: string;
|
|
29
|
+
in: "project" | "companion";
|
|
30
|
+
exists: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface ProjectError {
|
|
33
|
+
error: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function readProjectReport(command: string[], cwd: string, env: NodeJS.ProcessEnv): ProjectReport | ProjectError;
|
|
36
|
+
export declare function companionInUse(report: ProjectReport, items: readonly ItemName[]): boolean;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { runAlignfirst } from "../alignfirst-cli.js";
|
|
2
|
+
import { errorMessage } from "../errors.js";
|
|
3
|
+
const MIN_ALIGNFIRST_VERSION = "0.5.0";
|
|
4
|
+
export function readProjectReport(command, cwd, env) {
|
|
5
|
+
const result = runAlignfirst(command, ["config", "--json"], cwd, env);
|
|
6
|
+
if (result.status !== 0) {
|
|
7
|
+
const error = firstLine(result.stderr);
|
|
8
|
+
return { error: error === "" ? "alignfirst config failed" : error };
|
|
9
|
+
}
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = JSON.parse(result.stdout);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error(`Invalid alignfirst config report for ${cwd}: ${errorMessage(error)}`);
|
|
16
|
+
}
|
|
17
|
+
return parseProjectReport(value, cwd);
|
|
18
|
+
}
|
|
19
|
+
function firstLine(value) {
|
|
20
|
+
return value.trim().split("\n", 1)[0] ?? "";
|
|
21
|
+
}
|
|
22
|
+
function parseProjectReport(value, path) {
|
|
23
|
+
if (!isRecord(value))
|
|
24
|
+
throw invalidReport(path);
|
|
25
|
+
if (value.source === "root" || value.locations === undefined)
|
|
26
|
+
throw outdatedAlignfirst(path);
|
|
27
|
+
return {
|
|
28
|
+
source: parseSource(value.source, path),
|
|
29
|
+
cli: parseCli(value.cli, path),
|
|
30
|
+
config: parseConfig(value.config, path),
|
|
31
|
+
companion: parseCompanion(value.companion, path),
|
|
32
|
+
locations: parseLocations(value.locations, path),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function parseSource(value, path) {
|
|
36
|
+
if (value === "project" || value === "companion" || value === null)
|
|
37
|
+
return value;
|
|
38
|
+
throw invalidReport(path);
|
|
39
|
+
}
|
|
40
|
+
function parseCli(value, path) {
|
|
41
|
+
if (value === null)
|
|
42
|
+
return null;
|
|
43
|
+
if (!isRecord(value) ||
|
|
44
|
+
typeof value.installed !== "string" ||
|
|
45
|
+
typeof value.range !== "string" ||
|
|
46
|
+
typeof value.satisfied !== "boolean") {
|
|
47
|
+
throw invalidReport(path);
|
|
48
|
+
}
|
|
49
|
+
return { installed: value.installed, range: value.range, satisfied: value.satisfied };
|
|
50
|
+
}
|
|
51
|
+
function parseConfig(value, path) {
|
|
52
|
+
if (value === null)
|
|
53
|
+
return null;
|
|
54
|
+
if (!isRecord(value))
|
|
55
|
+
throw invalidReport(path);
|
|
56
|
+
const ticketIdPattern = value.ticketIdPattern;
|
|
57
|
+
const plans = parsePlans(value.plans, path);
|
|
58
|
+
const portRange = value.portRange;
|
|
59
|
+
if (ticketIdPattern !== undefined && typeof ticketIdPattern !== "string")
|
|
60
|
+
throw invalidReport(path);
|
|
61
|
+
if (portRange !== undefined && !isPortRange(portRange))
|
|
62
|
+
throw invalidReport(path);
|
|
63
|
+
return {
|
|
64
|
+
...(ticketIdPattern === undefined ? {} : { ticketIdPattern }),
|
|
65
|
+
...(plans === undefined ? {} : { plans }),
|
|
66
|
+
...(portRange === undefined ? {} : { portRange }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function parsePlans(value, path) {
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return;
|
|
72
|
+
if (!isRecord(value))
|
|
73
|
+
throw invalidReport(path);
|
|
74
|
+
if (value.folder !== undefined && typeof value.folder !== "string")
|
|
75
|
+
throw invalidReport(path);
|
|
76
|
+
return value.folder === undefined ? {} : { folder: value.folder };
|
|
77
|
+
}
|
|
78
|
+
// The report's other companion fields (`entries`, `flags`) are alignfirst's concern.
|
|
79
|
+
function parseCompanion(value, path) {
|
|
80
|
+
if (value === null)
|
|
81
|
+
return null;
|
|
82
|
+
if (!isRecord(value) || typeof value.dir !== "string" || typeof value.exists !== "boolean") {
|
|
83
|
+
throw invalidReport(path);
|
|
84
|
+
}
|
|
85
|
+
return { dir: value.dir, exists: value.exists };
|
|
86
|
+
}
|
|
87
|
+
function parseLocations(value, path) {
|
|
88
|
+
if (!isRecord(value))
|
|
89
|
+
throw invalidReport(path);
|
|
90
|
+
const location = (name) => parseLocation(value[name], path);
|
|
91
|
+
return {
|
|
92
|
+
".alignfirst.json": location(".alignfirst.json"),
|
|
93
|
+
".alignfirst.md": location(".alignfirst.md"),
|
|
94
|
+
"DEVELOPERS.md": location("DEVELOPERS.md"),
|
|
95
|
+
docs: location("docs"),
|
|
96
|
+
".plans": location(".plans"),
|
|
97
|
+
_aligndev: location("_aligndev"),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseLocation(value, path) {
|
|
101
|
+
if (!isRecord(value) ||
|
|
102
|
+
typeof value.path !== "string" ||
|
|
103
|
+
(value.in !== "project" && value.in !== "companion") ||
|
|
104
|
+
typeof value.exists !== "boolean") {
|
|
105
|
+
throw invalidReport(path);
|
|
106
|
+
}
|
|
107
|
+
return { path: value.path, in: value.in, exists: value.exists };
|
|
108
|
+
}
|
|
109
|
+
function invalidReport(path) {
|
|
110
|
+
return new Error(`Invalid alignfirst config report for ${path}`);
|
|
111
|
+
}
|
|
112
|
+
function outdatedAlignfirst(path) {
|
|
113
|
+
return new Error(`The alignfirst CLI used in ${path} is too old: aligndev requires alignfirst ` +
|
|
114
|
+
`${MIN_ALIGNFIRST_VERSION} or later.`);
|
|
115
|
+
}
|
|
116
|
+
function isPortRange(value) {
|
|
117
|
+
return isRecord(value) && typeof value.first === "number" && typeof value.last === "number";
|
|
118
|
+
}
|
|
119
|
+
function isRecord(value) {
|
|
120
|
+
return typeof value === "object" && value !== null;
|
|
121
|
+
}
|
|
122
|
+
// Some of the given items exists in the companion directory.
|
|
123
|
+
export function companionInUse(report, items) {
|
|
124
|
+
return items.some((item) => {
|
|
125
|
+
const location = report.locations[item];
|
|
126
|
+
return location.in === "companion" && location.exists;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const MARKER_FILENAME = ".alignfirst-projects.json";
|
|
2
|
+
export interface PortRange {
|
|
3
|
+
first: number;
|
|
4
|
+
last: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ProjectsMarker {
|
|
7
|
+
description?: string;
|
|
8
|
+
portRanges?: MarkerPortRange[];
|
|
9
|
+
}
|
|
10
|
+
export interface MarkerPortRange extends PortRange {
|
|
11
|
+
code?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function readMarker(dir: string): ProjectsMarker | undefined;
|
|
15
|
+
export declare function writeMarker(dir: string, marker: ProjectsMarker): void;
|
|
16
|
+
export declare function assertValidPortRanges(ranges: MarkerPortRange[], label: string): void;
|
|
17
|
+
export declare function containsRange(available: PortRange, allocation: PortRange): boolean;
|
|
18
|
+
export declare function rangesOverlap(left: PortRange, right: PortRange): boolean;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import { errorMessage } from "../errors.js";
|
|
5
|
+
import { formatRange } from "./format.js";
|
|
6
|
+
export const MARKER_FILENAME = ".alignfirst-projects.json";
|
|
7
|
+
const markerPortRangeSchema = type({
|
|
8
|
+
"+": "reject",
|
|
9
|
+
first: "1 <= number.integer <= 65535",
|
|
10
|
+
last: "1 <= number.integer <= 65535",
|
|
11
|
+
"code?": /^[a-z][a-z0-9-]*$/,
|
|
12
|
+
"description?": "string",
|
|
13
|
+
});
|
|
14
|
+
const markerSchema = type({
|
|
15
|
+
"+": "reject",
|
|
16
|
+
"description?": "string",
|
|
17
|
+
"portRanges?": markerPortRangeSchema.array().atLeastLength(1),
|
|
18
|
+
});
|
|
19
|
+
export function readMarker(dir) {
|
|
20
|
+
const path = join(dir, MARKER_FILENAME);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return;
|
|
23
|
+
let value;
|
|
24
|
+
try {
|
|
25
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw invalidMarker(path, errorMessage(error));
|
|
29
|
+
}
|
|
30
|
+
const marker = markerSchema(value);
|
|
31
|
+
if (marker instanceof type.errors) {
|
|
32
|
+
throw invalidMarker(path, marker.summary.split("\n", 1)[0]);
|
|
33
|
+
}
|
|
34
|
+
if (marker.portRanges !== undefined)
|
|
35
|
+
assertValidPortRanges(marker.portRanges, path);
|
|
36
|
+
return marker;
|
|
37
|
+
}
|
|
38
|
+
export function writeMarker(dir, marker) {
|
|
39
|
+
writeFileSync(join(dir, MARKER_FILENAME), `${JSON.stringify(marker, undefined, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
export function assertValidPortRanges(ranges, label) {
|
|
42
|
+
for (const range of ranges)
|
|
43
|
+
assertValidPortRange(range, label);
|
|
44
|
+
assertUniquePortRangeCodes(ranges, label);
|
|
45
|
+
if (ranges.filter(({ code }) => code === undefined).length > 1) {
|
|
46
|
+
throw new Error(`Invalid ${label}: at most one port range may have no code`);
|
|
47
|
+
}
|
|
48
|
+
for (let index = 0; index < ranges.length; ++index) {
|
|
49
|
+
const range = ranges[index];
|
|
50
|
+
for (let previous = 0; previous < index; ++previous) {
|
|
51
|
+
const other = ranges[previous];
|
|
52
|
+
if (!rangesOverlap(range, other))
|
|
53
|
+
continue;
|
|
54
|
+
throw new Error(`Invalid ${label}: port ranges ${formatRange(other)} and ${formatRange(range)} overlap`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function assertValidPortRange(range, label) {
|
|
59
|
+
if (!Number.isInteger(range.first) ||
|
|
60
|
+
!Number.isInteger(range.last) ||
|
|
61
|
+
range.first < 1 ||
|
|
62
|
+
range.first > 65_535 ||
|
|
63
|
+
range.last < 1 ||
|
|
64
|
+
range.last > 65_535) {
|
|
65
|
+
throw new Error(`Invalid ${label}: port range endpoints must be integers from 1 to 65535`);
|
|
66
|
+
}
|
|
67
|
+
if (range.first > range.last) {
|
|
68
|
+
throw new Error(`Invalid ${label}: portRange.first must not exceed portRange.last`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function assertUniquePortRangeCodes(ranges, label) {
|
|
72
|
+
const codes = new Set();
|
|
73
|
+
for (const { code } of ranges) {
|
|
74
|
+
if (code === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
if (codes.has(code)) {
|
|
77
|
+
throw new Error(`Invalid ${label}: duplicate port range code ${JSON.stringify(code)}`);
|
|
78
|
+
}
|
|
79
|
+
codes.add(code);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function containsRange(available, allocation) {
|
|
83
|
+
return allocation.first >= available.first && allocation.last <= available.last;
|
|
84
|
+
}
|
|
85
|
+
export function rangesOverlap(left, right) {
|
|
86
|
+
return left.first <= right.last && right.first <= left.last;
|
|
87
|
+
}
|
|
88
|
+
function invalidMarker(path, detail) {
|
|
89
|
+
return new Error(`Invalid projects marker ${path}: ${detail}`);
|
|
90
|
+
}
|