alignfirst 0.4.0 → 0.5.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 +54 -2
- package/dist/cli.js +2 -1
- package/dist/commands/config.js +15 -4
- package/dist/commands/context.js +23 -3
- package/dist/commands/docmap.js +4 -1
- package/dist/commands/doctor.js +27 -4
- package/dist/commands/guide.js +6 -6
- package/dist/commands/plans.js +18 -15
- package/dist/commands/sync.js +5 -5
- package/dist/commands/ticket.js +22 -19
- package/dist/context.d.ts +2 -0
- package/dist/conventions.js +11 -9
- package/dist/format.d.ts +2 -0
- package/dist/format.js +8 -0
- package/dist/plans/archive.d.ts +8 -2
- package/dist/plans/archive.js +41 -17
- package/dist/plans/catchup.js +12 -10
- package/dist/plans/layout.d.ts +4 -7
- package/dist/plans/layout.js +10 -14
- package/dist/plans/link.d.ts +1 -1
- package/dist/plans/link.js +8 -6
- package/dist/plans/mode.d.ts +3 -1
- package/dist/plans/mode.js +11 -7
- package/dist/plans/ticket.d.ts +7 -4
- package/dist/plans/ticket.js +30 -20
- package/dist/project-config.d.ts +4 -3
- package/dist/project-config.js +7 -9
- package/dist/project-layout.d.ts +30 -0
- package/dist/project-layout.js +174 -0
- package/package.json +3 -3
- package/templates/guide/core.md +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import { CliError } from "./cli-error.js";
|
|
5
|
+
import { errorMessage } from "./errors.js";
|
|
6
|
+
import { gitOutputOrUndefined } from "./git.js";
|
|
7
|
+
export const ITEM_NAMES = [
|
|
8
|
+
".alignfirst.json",
|
|
9
|
+
".alignfirst.md",
|
|
10
|
+
"DEVELOPERS.md",
|
|
11
|
+
"docs",
|
|
12
|
+
".plans",
|
|
13
|
+
"_aligndev",
|
|
14
|
+
];
|
|
15
|
+
const FLAG = "boolean | 'auto'";
|
|
16
|
+
const flagsSchema = type({
|
|
17
|
+
"+": "reject",
|
|
18
|
+
".alignfirst.json?": FLAG,
|
|
19
|
+
".alignfirst.md?": FLAG,
|
|
20
|
+
"DEVELOPERS.md?": FLAG,
|
|
21
|
+
"docs?": FLAG,
|
|
22
|
+
".plans?": FLAG,
|
|
23
|
+
"_aligndev?": FLAG,
|
|
24
|
+
});
|
|
25
|
+
const companionsSchema = type({
|
|
26
|
+
"+": "reject",
|
|
27
|
+
root: "string > 0",
|
|
28
|
+
paths: type.Record("string", flagsSchema),
|
|
29
|
+
});
|
|
30
|
+
export function layoutOf(ctx) {
|
|
31
|
+
ctx.layout ??= resolveProjectLayout(ctx.cwd, ctx.home);
|
|
32
|
+
return ctx.layout;
|
|
33
|
+
}
|
|
34
|
+
export function resolveProjectLayout(cwd, home) {
|
|
35
|
+
const companion = resolveCompanion(cwd, home);
|
|
36
|
+
return { companion, locations: resolveLocations(cwd, companion) };
|
|
37
|
+
}
|
|
38
|
+
function resolveCompanion(cwd, home) {
|
|
39
|
+
const file = readCompanionsFile(home);
|
|
40
|
+
if (file === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
const mainWorktree = findMainWorktree(cwd);
|
|
43
|
+
if (mainWorktree === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
const realHome = realOrResolved(home);
|
|
46
|
+
const matches = matchingEntries(file, mainWorktree, realHome);
|
|
47
|
+
if (matches.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
const flags = mergeFlags(matches);
|
|
50
|
+
assertValidFlags(file, flags, matches);
|
|
51
|
+
const dir = join(normalizePath(file.root, realHome), companionName(mainWorktree, realHome));
|
|
52
|
+
return { dir, exists: pathExists(dir), entries: matches.map((match) => match.key), flags };
|
|
53
|
+
}
|
|
54
|
+
function readCompanionsFile(home) {
|
|
55
|
+
const path = companionsPath(home);
|
|
56
|
+
if (!pathExists(path))
|
|
57
|
+
return;
|
|
58
|
+
let value;
|
|
59
|
+
try {
|
|
60
|
+
value = JSON.parse(readFileSync(path, "utf-8"));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw invalidCompanions(path, errorMessage(error));
|
|
64
|
+
}
|
|
65
|
+
const file = companionsSchema(value);
|
|
66
|
+
if (file instanceof type.errors)
|
|
67
|
+
throw invalidCompanions(path, file.summary.split("\n", 1)[0]);
|
|
68
|
+
if (!isUserPath(file.root))
|
|
69
|
+
throw invalidCompanions(path, `root must be an absolute path or start with ~/: ${file.root}`);
|
|
70
|
+
const badKey = Object.keys(file.paths).find((key) => !isUserPath(key));
|
|
71
|
+
if (badKey !== undefined)
|
|
72
|
+
throw invalidCompanions(path, `paths key must be an absolute path or start with ~/: ${badKey}`);
|
|
73
|
+
return { path, root: file.root, paths: file.paths };
|
|
74
|
+
}
|
|
75
|
+
export function companionsPath(home) {
|
|
76
|
+
return join(home, ".config", "alignfirst", "companions.json");
|
|
77
|
+
}
|
|
78
|
+
function invalidCompanions(path, detail) {
|
|
79
|
+
return new CliError(`Invalid ${path}: ${detail}`);
|
|
80
|
+
}
|
|
81
|
+
function isUserPath(value) {
|
|
82
|
+
return value === "~" || value.startsWith("~/") || isAbsolute(value);
|
|
83
|
+
}
|
|
84
|
+
/** The main worktree is the parent of the common `.git` directory; a bare repository has none. */
|
|
85
|
+
function findMainWorktree(cwd) {
|
|
86
|
+
const commonDir = gitOutputOrUndefined(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
87
|
+
if (commonDir === undefined || basename(commonDir) !== ".git")
|
|
88
|
+
return;
|
|
89
|
+
return realpathSync(dirname(commonDir));
|
|
90
|
+
}
|
|
91
|
+
function normalizePath(value, realHome) {
|
|
92
|
+
if (value === "~")
|
|
93
|
+
return realHome;
|
|
94
|
+
return realOrResolved(value.startsWith("~/") ? join(realHome, value.slice(2)) : value);
|
|
95
|
+
}
|
|
96
|
+
function realOrResolved(path) {
|
|
97
|
+
return existsSync(path) ? realpathSync(path) : resolve(path);
|
|
98
|
+
}
|
|
99
|
+
function matchingEntries(file, mainWorktree, realHome) {
|
|
100
|
+
return Object.entries(file.paths)
|
|
101
|
+
.map(([key, flags]) => ({ key, path: normalizePath(key, realHome), flags }))
|
|
102
|
+
.filter((entry) => isSameOrInside(mainWorktree, entry.path))
|
|
103
|
+
.toSorted((left, right) => right.path.length - left.path.length);
|
|
104
|
+
}
|
|
105
|
+
function isSameOrInside(path, ancestor) {
|
|
106
|
+
return path === ancestor || path.startsWith(ancestor.endsWith(sep) ? ancestor : ancestor + sep);
|
|
107
|
+
}
|
|
108
|
+
function mergeFlags(matches) {
|
|
109
|
+
const flagOf = (item) => matches.find((match) => match.flags[item] !== undefined)?.flags[item] ?? "auto";
|
|
110
|
+
return {
|
|
111
|
+
".alignfirst.json": flagOf(".alignfirst.json"),
|
|
112
|
+
".alignfirst.md": flagOf(".alignfirst.md"),
|
|
113
|
+
"DEVELOPERS.md": flagOf("DEVELOPERS.md"),
|
|
114
|
+
docs: flagOf("docs"),
|
|
115
|
+
".plans": flagOf(".plans"),
|
|
116
|
+
_aligndev: flagOf("_aligndev"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function assertValidFlags(file, flags, matches) {
|
|
120
|
+
if (flags._aligndev !== true || flags[".plans"] !== "auto")
|
|
121
|
+
return;
|
|
122
|
+
const keys = matches.map((match) => match.key).join(", ");
|
|
123
|
+
throw invalidCompanions(file.path, `"_aligndev": true requires ".plans" set to true or false (matching keys: ${keys})`);
|
|
124
|
+
}
|
|
125
|
+
function companionName(mainWorktree, realHome) {
|
|
126
|
+
const name = mainWorktree !== realHome && isSameOrInside(mainWorktree, realHome)
|
|
127
|
+
? relative(realHome, mainWorktree)
|
|
128
|
+
: mainWorktree.slice(1);
|
|
129
|
+
return name.replaceAll("/", "_");
|
|
130
|
+
}
|
|
131
|
+
function resolveLocations(cwd, companion) {
|
|
132
|
+
const locate = (name) => locateItem(cwd, companion, name);
|
|
133
|
+
const plans = locate(".plans");
|
|
134
|
+
return {
|
|
135
|
+
".alignfirst.json": locate(".alignfirst.json"),
|
|
136
|
+
".alignfirst.md": locate(".alignfirst.md"),
|
|
137
|
+
"DEVELOPERS.md": locate("DEVELOPERS.md"),
|
|
138
|
+
docs: locate("docs"),
|
|
139
|
+
".plans": plans,
|
|
140
|
+
_aligndev: companion?.flags._aligndev === true ? companionCopy(companion, ".plans") : plans,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function locateItem(cwd, companion, name) {
|
|
144
|
+
const project = projectCopy(cwd, name);
|
|
145
|
+
if (companion === null || companion.flags[name] === false)
|
|
146
|
+
return project;
|
|
147
|
+
const copy = companionCopy(companion, name);
|
|
148
|
+
if (companion.flags[name] === true || copy.exists || !project.exists)
|
|
149
|
+
return copy;
|
|
150
|
+
return project;
|
|
151
|
+
}
|
|
152
|
+
function projectCopy(cwd, name) {
|
|
153
|
+
const path = join(cwd, name);
|
|
154
|
+
return { path, in: "project", exists: pathExists(path) };
|
|
155
|
+
}
|
|
156
|
+
function companionCopy(companion, name) {
|
|
157
|
+
const path = join(companion.dir, name);
|
|
158
|
+
return { path, in: "companion", exists: pathExists(path) };
|
|
159
|
+
}
|
|
160
|
+
/** An lstat check, so a broken `.plans` symlink still resolves in place. */
|
|
161
|
+
function pathExists(path) {
|
|
162
|
+
return lstatSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
163
|
+
}
|
|
164
|
+
/** One line: `<name>: <path> (<in>)`, with `, missing` when absent. */
|
|
165
|
+
export function renderItemLocation(name, location) {
|
|
166
|
+
return `${name}: ${location.path} (${location.in}${location.exists ? "" : ", missing"})`;
|
|
167
|
+
}
|
|
168
|
+
/** The `_aligndev` tree when it is not the resolved `.plans` and exists. */
|
|
169
|
+
export function separateSessionTree(layout) {
|
|
170
|
+
const sessions = layout.locations._aligndev;
|
|
171
|
+
if (!sessions.exists || sessions.path === layout.locations[".plans"].path)
|
|
172
|
+
return;
|
|
173
|
+
return sessions.path;
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alignfirst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "CC0-1.0",
|
|
5
5
|
"author": "Thomas MUR",
|
|
6
6
|
"description": "The AlignFirst CLI: protocols, work files and docs in one command.",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@alignfirst/docmap": "~0.11.
|
|
38
|
+
"@alignfirst/docmap": "~0.11.1",
|
|
39
39
|
"arktype": "^2.2.3",
|
|
40
40
|
"semver": "^7.8.5"
|
|
41
41
|
},
|
|
@@ -44,6 +44,6 @@
|
|
|
44
44
|
"@types/semver": "~7.8.0",
|
|
45
45
|
"rimraf": "~6.1.3",
|
|
46
46
|
"typescript": "~7.0.2",
|
|
47
|
-
"vitest": "~
|
|
47
|
+
"vitest": "~5.0.1"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/templates/guide/core.md
CHANGED
|
@@ -16,7 +16,7 @@ When the user says there is no ticket or asks for a side ticket, run `{{CMD}} ti
|
|
|
16
16
|
|
|
17
17
|
Files use `{CYCLE_LETTER}{FILE_NUMBER}-{FILE_TYPE}.md`. FILE_PREFIX combines the cycle letter and the file number within that cycle. FILE_NAME includes the prefix and extension.
|
|
18
18
|
|
|
19
|
-
Immediately before creating each file, run `{{TICKET_CMD}} --next <filename>` with the extension included. It returns TICKET_DIR, CYCLE_LETTER, FILE_NUMBER, and FILE_NAME. Append FILE_NAME to TICKET_DIR to get the file path
|
|
19
|
+
Immediately before creating each file, run `{{TICKET_CMD}} --next <filename>` with the extension included. It returns TICKET_DIR, CYCLE_LETTER, FILE_NUMBER, and FILE_NAME. Append FILE_NAME to TICKET_DIR exactly as printed to get the file path.
|
|
20
20
|
|
|
21
21
|
With no filename, `{{TICKET_CMD}} --next` returns FILE_PREFIX instead of FILE_NAME. To name several files at once, repeat `--next <filename>` once per file: the command returns FILE_NAMES, numbered in that order. Add `--new-cycle` to any form when the protocol or user calls for a new cycle.
|
|
22
22
|
|