alignfirst 0.8.0 → 0.10.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 +12 -24
- package/dist/cli.js +1 -1
- package/dist/commands/companion.js +66 -11
- package/dist/commands/context.js +5 -4
- package/dist/commands/doctor.js +25 -8
- package/dist/json-shape.d.ts +19 -0
- package/dist/json-shape.js +93 -0
- package/dist/project-config.js +26 -33
- package/dist/project-layout.d.ts +35 -7
- package/dist/project-layout.js +127 -53
- package/dist/version-guard.js +2 -2
- package/dist/version-range.d.ts +5 -0
- package/dist/version-range.js +103 -0
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -51,20 +51,6 @@ To implement a plan, start a fresh agent context and ask it to execute the plan
|
|
|
51
51
|
|
|
52
52
|
AlignFirst stores the work files of a ticket, such as specifications, plans and summaries, in `.plans/<ticket-id>/`. It normally derives the ticket ID from the request or branch and asks when none is available. Files use a cycle letter and sequence number, such as `A1-spec.md` and `A2-plan.md`.
|
|
53
53
|
|
|
54
|
-
## Global CLI
|
|
55
|
-
|
|
56
|
-
To type `alignfirst` instead of `npx alignfirst`, install the CLI globally:
|
|
57
|
-
|
|
58
|
-
```sh
|
|
59
|
-
npm install -g alignfirst
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Update the global CLI
|
|
63
|
-
|
|
64
|
-
```sh
|
|
65
|
-
npm update -g alignfirst
|
|
66
|
-
```
|
|
67
|
-
|
|
68
54
|
## Set up a project (with your agent)
|
|
69
55
|
|
|
70
56
|
Temporarily install the setup-guide skill:
|
|
@@ -95,8 +81,9 @@ Use your alignfirst-setup-guide skill. Set up AlignFirst in this project without
|
|
|
95
81
|
- `plans` — Link `.plans` to the work-files repository, check the link, archive tickets.
|
|
96
82
|
- `docmap` — Browse project documentation.
|
|
97
83
|
- `conventions` — Print the effective project conventions.
|
|
98
|
-
- `context` — Print the conventions, the project instructions from `.alignfirst.md`, the documentation map when `docs/` exists, and the protocol aliases.
|
|
99
|
-
- `companion
|
|
84
|
+
- `context` — Print the conventions, the project instructions from `.alignfirst-instructions/context.md`, the documentation map when `docs/` exists, and the protocol aliases.
|
|
85
|
+
- `companion register` — Register the current project in the companion registry. `--create-dir` also creates its companion directory.
|
|
86
|
+
- `companion unregister` — Remove the current project from the companion registry. `--remove-dir` also removes its companion directory.
|
|
100
87
|
- `config` — Report the effective project configuration, the companion directory and the location of each AlignFirst file.
|
|
101
88
|
- `doctor` — Diagnose an AlignFirst setup.
|
|
102
89
|
|
|
@@ -114,23 +101,24 @@ A companion directory holds a project's AlignFirst files outside its repository,
|
|
|
114
101
|
{
|
|
115
102
|
"paths": {
|
|
116
103
|
"~/projects/team-app": { ".plans": false, "_aligndev": true },
|
|
117
|
-
"~/projects/client-api": {}
|
|
118
|
-
"~/projects": {}
|
|
104
|
+
"~/projects/client-api": {}
|
|
119
105
|
}
|
|
120
106
|
}
|
|
121
107
|
```
|
|
122
108
|
|
|
123
|
-
- `paths` — the projects, by absolute or `~/` path. Each value sets flags for the items a companion can hold: `.alignfirst.json`, `.alignfirst
|
|
109
|
+
- `paths` — the projects, by absolute or `~/` path. Each value sets flags for the items a companion can hold: `.alignfirst.json`, `.alignfirst-instructions`, `DEVELOPERS.md`, `docs`, `.plans` and `_aligndev`. A flag is `true`, `false` or `"auto"`.
|
|
124
110
|
|
|
125
111
|
An absent registry means no project has a companion. An invalid registry makes every command fail; `doctor` reports it and continues.
|
|
126
112
|
|
|
127
|
-
Run `alignfirst companion
|
|
113
|
+
Run `alignfirst companion register` in a project to register it. It adds the project's main worktree path with every item on `"auto"`, unless it is registered. The companion directory is created on the first write, or at once with `--create-dir`. Set the flags by editing the registry.
|
|
114
|
+
|
|
115
|
+
`alignfirst companion unregister` removes the entry and keeps the directory. With `--remove-dir`, it also removes an empty directory; a non-empty one requires `--force`.
|
|
128
116
|
|
|
129
117
|
### Matching
|
|
130
118
|
|
|
131
|
-
A key matches a project when it names the project's main worktree
|
|
119
|
+
A key matches a project when it names the project's main worktree, so every worktree of a project shares one companion. A key that names another directory matches nothing, and `alignfirst doctor` warns about it. An unset flag is `"auto"`. A bare repository or a directory outside git has no companion.
|
|
132
120
|
|
|
133
|
-
The companion directory is `~/.alignfirst/companions/<name>`. The name is the main worktree path relative to the home directory, or the absolute path without its leading `/` outside it, with every `/` replaced by `_`. For example, `~/projects/client-api` gets `~/.alignfirst/companions/projects_client-api/`. To keep the companions and their registry elsewhere, make `~/.alignfirst/companions` a symlink.
|
|
121
|
+
The companion directory is `~/.alignfirst/companions/<name>`. The name is the main worktree path relative to the home directory, or the absolute path without its leading `/` outside it, with every `/` replaced by `_`. For example, `~/projects/client-api` gets `~/.alignfirst/companions/projects_client-api/`. Two keys can only share a directory through a `_` in a directory name: `companion register` refuses such a project, and `doctor` warns about it. To keep the companions and their registry elsewhere, make `~/.alignfirst/companions` a symlink.
|
|
134
122
|
|
|
135
123
|
### Resolution
|
|
136
124
|
|
|
@@ -146,14 +134,14 @@ The companion directory is `~/.alignfirst/companions/<name>`. The name is the ma
|
|
|
146
134
|
|
|
147
135
|
### Project instructions
|
|
148
136
|
|
|
149
|
-
`.alignfirst
|
|
137
|
+
`.alignfirst-instructions/` holds Markdown instructions for the coding agent, one file per moment they apply. `context.md` holds the project instructions a prepared project keeps in its `AGENTS.md`, and `alignfirst context` prints it under `# Project Instructions`. The directory resolves like the other items, so a project copy works too.
|
|
150
138
|
|
|
151
139
|
### Agent bootstrap
|
|
152
140
|
|
|
153
141
|
An agent reads a repository's `AGENTS.md` on its own, but never a companion. When your repositories carry no AlignFirst instructions, add this line to your global agent instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, or the equivalent):
|
|
154
142
|
|
|
155
143
|
```text
|
|
156
|
-
In a git repository, run `alignfirst context` as your first command, whatever the task, unless the project's instructions already say so.
|
|
144
|
+
In a git repository, run `npx -y alignfirst context` as your first command, whatever the task, unless the project's instructions already say so.
|
|
157
145
|
```
|
|
158
146
|
|
|
159
147
|
## Upgrade from v1, v2, or v3
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
1
2
|
import { CliError } from "../cli-error.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { parseCommandArgs } from "../parse-args.js";
|
|
4
|
+
import { registerCompanion, unregisterCompanion } from "../project-layout.js";
|
|
5
|
+
const HELP_OPTION = { help: { type: "boolean", short: "h", default: false } };
|
|
4
6
|
export function runCompanion(ctx, args) {
|
|
5
7
|
const [command, ...rest] = args;
|
|
6
8
|
switch (command) {
|
|
7
|
-
case "
|
|
8
|
-
return
|
|
9
|
+
case "register":
|
|
10
|
+
return runRegister(ctx, rest);
|
|
11
|
+
case "unregister":
|
|
12
|
+
return runUnregister(ctx, rest);
|
|
9
13
|
case "--help":
|
|
10
14
|
case "-h":
|
|
11
15
|
ctx.stdout.write(companionUsage(ctx));
|
|
@@ -16,21 +20,72 @@ export function runCompanion(ctx, args) {
|
|
|
16
20
|
}
|
|
17
21
|
function companionUsage(ctx) {
|
|
18
22
|
return `Usage:
|
|
19
|
-
${ctx.form} companion
|
|
23
|
+
${ctx.form} companion register [--create-dir]
|
|
24
|
+
${ctx.form} companion unregister [--remove-dir [--force]]
|
|
20
25
|
`;
|
|
21
26
|
}
|
|
22
|
-
function
|
|
23
|
-
const usage = `Usage: ${ctx.form} companion
|
|
27
|
+
function runRegister(ctx, args) {
|
|
28
|
+
const usage = `Usage: ${ctx.form} companion register [--create-dir]
|
|
24
29
|
|
|
25
30
|
Registers the current project in ~/.alignfirst/companions/registry.json, with every item on
|
|
26
|
-
"auto"
|
|
31
|
+
"auto". A registered project keeps its registration. Fails when another key uses the same
|
|
32
|
+
companion directory.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--create-dir Create the companion directory when it is missing. Without it, the commands
|
|
36
|
+
that write an item there create it.
|
|
27
37
|
`;
|
|
28
|
-
|
|
38
|
+
const { values } = parseCommandArgs(usage, () => parseArgs({
|
|
39
|
+
args,
|
|
40
|
+
options: { "create-dir": { type: "boolean", default: false }, ...HELP_OPTION },
|
|
41
|
+
strict: true,
|
|
42
|
+
}));
|
|
43
|
+
if (printedHelp(ctx, values.help, usage))
|
|
29
44
|
return 0;
|
|
30
|
-
const registration =
|
|
45
|
+
const registration = registerCompanion(ctx.cwd, ctx.home, { createDir: values["create-dir"] });
|
|
31
46
|
ctx.stdout.write(registration.added
|
|
32
47
|
? `Registered ${registration.key} in ${registration.registry}.\n`
|
|
33
48
|
: `Already registered by ${registration.key} in ${registration.registry}.\n`);
|
|
34
|
-
|
|
49
|
+
const state = registration.created ? " (created)" : registration.exists ? "" : " (missing)";
|
|
50
|
+
ctx.stdout.write(`Companion: ${registration.dir}${state}\n`);
|
|
51
|
+
ctx.stdout.write(`Next: write the items at the paths \`${ctx.form} config\` reports.\n`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
function runUnregister(ctx, args) {
|
|
55
|
+
const usage = `Usage: ${ctx.form} companion unregister [--remove-dir [--force]]
|
|
56
|
+
|
|
57
|
+
Removes the current project from ~/.alignfirst/companions/registry.json. Keeps its companion
|
|
58
|
+
directory, unless --remove-dir.
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--remove-dir Also remove the companion directory. Fails, with no change, when it is not
|
|
62
|
+
empty or another key uses it.
|
|
63
|
+
--force With --remove-dir, delete a non-empty companion directory.
|
|
64
|
+
`;
|
|
65
|
+
const { values } = parseCommandArgs(usage, () => parseArgs({
|
|
66
|
+
args,
|
|
67
|
+
options: {
|
|
68
|
+
"remove-dir": { type: "boolean", default: false },
|
|
69
|
+
force: { type: "boolean", default: false },
|
|
70
|
+
...HELP_OPTION,
|
|
71
|
+
},
|
|
72
|
+
strict: true,
|
|
73
|
+
}));
|
|
74
|
+
if (printedHelp(ctx, values.help, usage))
|
|
75
|
+
return 0;
|
|
76
|
+
const { "remove-dir": removeDir, force } = values;
|
|
77
|
+
if (force && !removeDir)
|
|
78
|
+
throw new CliError(`--force requires --remove-dir.\n\n${usage}`);
|
|
79
|
+
const removal = unregisterCompanion(ctx.cwd, ctx.home, { removeDir, force });
|
|
80
|
+
ctx.stdout.write(`Unregistered ${removal.key} from ${removal.registry}.\n`);
|
|
81
|
+
if (removal.removed)
|
|
82
|
+
ctx.stdout.write(`Removed companion directory: ${removal.dir}\n`);
|
|
83
|
+
else if (removal.empty !== undefined)
|
|
84
|
+
ctx.stdout.write(`Orphaned companion directory: ${removal.dir}${removal.empty ? " (empty)" : ""}\n`);
|
|
35
85
|
return 0;
|
|
36
86
|
}
|
|
87
|
+
function printedHelp(ctx, help, usage) {
|
|
88
|
+
if (help)
|
|
89
|
+
ctx.stdout.write(usage);
|
|
90
|
+
return help;
|
|
91
|
+
}
|
package/dist/commands/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { CliError } from "../cli-error.js";
|
|
3
4
|
import { renderCommandForm } from "../command-form.js";
|
|
4
5
|
import { renderConventions } from "../conventions.js";
|
|
@@ -17,10 +18,10 @@ export function runContext(ctx, args) {
|
|
|
17
18
|
return code;
|
|
18
19
|
}
|
|
19
20
|
function writeProjectInstructions(ctx) {
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
21
|
+
const path = join(layoutOf(ctx).locations[".alignfirst-instructions"].path, "context.md");
|
|
22
|
+
if (!existsSync(path))
|
|
22
23
|
return;
|
|
23
|
-
const content = readInstructions(
|
|
24
|
+
const content = readInstructions(path).trim();
|
|
24
25
|
if (content === "")
|
|
25
26
|
return;
|
|
26
27
|
ctx.stdout.write(`\n# Project Instructions\n\n${content}\n`);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import semver from "semver";
|
|
5
4
|
import { resolveDefaultBranch } from "../default-branch.js";
|
|
6
5
|
import { errorMessage } from "../errors.js";
|
|
7
6
|
import { parseBareCommandArgs } from "../parse-args.js";
|
|
8
7
|
import { resolvePlansMode } from "../plans/mode.js";
|
|
9
8
|
import { findStoppedRebase } from "../plans/rebase.js";
|
|
10
9
|
import { PROJECT_CONFIG_FILENAME, resolveProjectConfig, } from "../project-config.js";
|
|
11
|
-
import { registryPath, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
|
|
10
|
+
import { registryPath, sharedCompanionDirs, strayRegistryKeys, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
|
|
12
11
|
import { COMMAND_SKILLS, findInstalledSkill } from "../skills.js";
|
|
13
12
|
import { cliRangeResult } from "../version-guard.js";
|
|
13
|
+
import { isAheadOfRange } from "../version-range.js";
|
|
14
14
|
export function runDoctor(ctx, args) {
|
|
15
15
|
const usage = `Usage: ${ctx.form} doctor\n`;
|
|
16
16
|
if (parseBareCommandArgs(ctx, args, usage))
|
|
@@ -62,7 +62,7 @@ function inspectConfig(ctx, resolved) {
|
|
|
62
62
|
level: result.satisfied ? "ok" : "error",
|
|
63
63
|
text: `${result.satisfied ? "satisfies" : "does not satisfy"} ${result.range}`,
|
|
64
64
|
});
|
|
65
|
-
if (
|
|
65
|
+
if (isAheadOfRange(ctx.version, result.range))
|
|
66
66
|
lines.push({ level: "warn", text: `${ctx.version} is ahead of ${result.range}` });
|
|
67
67
|
return lines;
|
|
68
68
|
}
|
|
@@ -73,16 +73,27 @@ function inspectCompanion(ctx) {
|
|
|
73
73
|
level: "ok",
|
|
74
74
|
text: `registry ${existsSync(path) ? "valid" : "absent"} (${path})`,
|
|
75
75
|
};
|
|
76
|
+
const registryLines = [
|
|
77
|
+
file,
|
|
78
|
+
...strayRegistryKeys(ctx.home).map(describeStrayKey),
|
|
79
|
+
...sharedCompanionDirs(ctx.home).map(describeSharedDir),
|
|
80
|
+
];
|
|
76
81
|
if (layout.companion === null)
|
|
77
|
-
return [
|
|
82
|
+
return [...registryLines, { level: "ok", text: "none" }];
|
|
78
83
|
const { companion } = layout;
|
|
79
84
|
return [
|
|
80
|
-
|
|
81
|
-
{ level: "ok", text: `
|
|
85
|
+
...registryLines,
|
|
86
|
+
{ level: "ok", text: `key ${companion.key}` },
|
|
82
87
|
{ level: "ok", text: `directory ${companion.dir}${companion.exists ? "" : " (missing)"}` },
|
|
83
88
|
...ITEM_NAMES.map((name) => describeItem(name, layout, companion)),
|
|
84
89
|
];
|
|
85
90
|
}
|
|
91
|
+
function describeStrayKey(key) {
|
|
92
|
+
return { level: "warn", text: `key ${key} names no git main worktree (ignored)` };
|
|
93
|
+
}
|
|
94
|
+
function describeSharedDir({ dir, keys }) {
|
|
95
|
+
return { level: "warn", text: `keys ${keys.join(", ")} share the companion directory ${dir}` };
|
|
96
|
+
}
|
|
86
97
|
function describeItem(name, layout, companion) {
|
|
87
98
|
const location = layout.locations[name];
|
|
88
99
|
const missingCopy = companion.flags[name] === true && !location.exists;
|
|
@@ -143,11 +154,17 @@ function describeCommandSkill({ name, installed }) {
|
|
|
143
154
|
}
|
|
144
155
|
function describeInstalledSkill(name, installed) {
|
|
145
156
|
const version = installed.version ?? "unknown";
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
157
|
+
const major = majorOf(installed.version);
|
|
158
|
+
if (major === undefined || major < 4)
|
|
148
159
|
return {
|
|
149
160
|
level: "warn",
|
|
150
161
|
text: `${name} ${version} predates v4; update: npx -y skills update --global --yes`,
|
|
151
162
|
};
|
|
152
163
|
return { level: "ok", text: `${name} ${version} (${installed.root})` };
|
|
153
164
|
}
|
|
165
|
+
function majorOf(version) {
|
|
166
|
+
if (version === undefined)
|
|
167
|
+
return;
|
|
168
|
+
const match = /^(\d+)\./.exec(version);
|
|
169
|
+
return match === null ? undefined : Number(match[1]);
|
|
170
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Check<T> = (value: unknown, path: string) => T;
|
|
2
|
+
export interface OptionalField<T> {
|
|
3
|
+
optional: Check<T>;
|
|
4
|
+
}
|
|
5
|
+
type Fields<T> = {
|
|
6
|
+
[K in keyof T]-?: undefined extends T[K] ? OptionalField<Exclude<T[K], undefined>> : Check<T[K]>;
|
|
7
|
+
};
|
|
8
|
+
/** Runs `check` at the root; a shape failure becomes `toError(<message>)`. */
|
|
9
|
+
export declare function parseShape<T>(check: Check<T>, value: unknown, toError: (detail: string) => Error): T;
|
|
10
|
+
/** Rejects the first unknown key, then checks each field in declaration order. */
|
|
11
|
+
export declare function object<T extends object>(fields: Fields<T>): Check<T>;
|
|
12
|
+
export declare function shapeError(path: string, expectation: string): Error;
|
|
13
|
+
export declare function optional<T>(check: Check<T>): OptionalField<T>;
|
|
14
|
+
export declare function record<T>(check: Check<T>): Check<Record<string, T>>;
|
|
15
|
+
export declare function literal<const T extends readonly (string | number)[]>(...values: T): Check<T[number]>;
|
|
16
|
+
export declare function integerBetween(min: number, max: number): Check<number>;
|
|
17
|
+
export declare function nonEmptyString(value: unknown, path: string): string;
|
|
18
|
+
export declare function boolean(value: unknown, path: string): boolean;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
2
|
+
class ShapeError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** Runs `check` at the root; a shape failure becomes `toError(<message>)`. */
|
|
5
|
+
export function parseShape(check, value, toError) {
|
|
6
|
+
try {
|
|
7
|
+
return check(value, "");
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error instanceof ShapeError)
|
|
11
|
+
throw toError(error.message);
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Rejects the first unknown key, then checks each field in declaration order. */
|
|
16
|
+
export function object(fields) {
|
|
17
|
+
const entries = Object.entries(fields);
|
|
18
|
+
return (value, path) => {
|
|
19
|
+
if (!isPlainObject(value))
|
|
20
|
+
throw shapeError(path, "be an object");
|
|
21
|
+
const unknownKey = Object.keys(value).find((key) => !Object.hasOwn(fields, key));
|
|
22
|
+
if (unknownKey !== undefined)
|
|
23
|
+
throw shapeError(childPath(path, unknownKey), "be removed");
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [key, field] of entries) {
|
|
26
|
+
const fieldPath = childPath(path, key);
|
|
27
|
+
if (typeof field === "function")
|
|
28
|
+
result[key] = field(value[key], fieldPath);
|
|
29
|
+
else if (value[key] !== undefined)
|
|
30
|
+
result[key] = field.optional(value[key], fieldPath);
|
|
31
|
+
}
|
|
32
|
+
// The fields mirror T key by key.
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
if (typeof value !== "object" || value === null)
|
|
38
|
+
return false;
|
|
39
|
+
const prototype = Object.getPrototypeOf(value);
|
|
40
|
+
return prototype === Object.prototype || prototype === null;
|
|
41
|
+
}
|
|
42
|
+
function childPath(path, key) {
|
|
43
|
+
if (!IDENTIFIER.test(key))
|
|
44
|
+
return bracketPath(path, JSON.stringify(key));
|
|
45
|
+
return path === "" ? key : `${path}.${key}`;
|
|
46
|
+
}
|
|
47
|
+
function bracketPath(path, segment) {
|
|
48
|
+
return `${path === "" ? "value" : path}[${segment}]`;
|
|
49
|
+
}
|
|
50
|
+
export function shapeError(path, expectation) {
|
|
51
|
+
return new ShapeError(`${path === "" ? "value" : path} must ${expectation}`);
|
|
52
|
+
}
|
|
53
|
+
export function optional(check) {
|
|
54
|
+
return { optional: check };
|
|
55
|
+
}
|
|
56
|
+
export function record(check) {
|
|
57
|
+
return (value, path) => {
|
|
58
|
+
if (!isPlainObject(value))
|
|
59
|
+
throw shapeError(path, "be an object");
|
|
60
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, check(item, childPath(path, key))]));
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function literal(...values) {
|
|
64
|
+
const expectation = `be ${listOf(values.map((item) => JSON.stringify(item)))}`;
|
|
65
|
+
return (value, path) => {
|
|
66
|
+
const match = values.find((item) => item === value);
|
|
67
|
+
if (match === undefined)
|
|
68
|
+
throw shapeError(path, expectation);
|
|
69
|
+
return match;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function listOf(items) {
|
|
73
|
+
if (items.length === 1)
|
|
74
|
+
return items[0];
|
|
75
|
+
return `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
|
|
76
|
+
}
|
|
77
|
+
export function integerBetween(min, max) {
|
|
78
|
+
return (value, path) => {
|
|
79
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max)
|
|
80
|
+
throw shapeError(path, `be an integer from ${min} to ${max}`);
|
|
81
|
+
return value;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function nonEmptyString(value, path) {
|
|
85
|
+
if (typeof value !== "string" || value.length === 0)
|
|
86
|
+
throw shapeError(path, "be a non-empty string");
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
export function boolean(value, path) {
|
|
90
|
+
if (typeof value !== "boolean")
|
|
91
|
+
throw shapeError(path, "be a boolean");
|
|
92
|
+
return value;
|
|
93
|
+
}
|
package/dist/project-config.js
CHANGED
|
@@ -1,39 +1,34 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { type } from "arktype";
|
|
3
|
-
import semver from "semver";
|
|
4
2
|
import { CliError } from "./cli-error.js";
|
|
5
3
|
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { boolean, integerBetween, literal, nonEmptyString, object, optional, parseShape, } from "./json-shape.js";
|
|
5
|
+
import { isValidVersionRange } from "./version-range.js";
|
|
6
6
|
export const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
last: "1 <= number.integer <= 65535",
|
|
7
|
+
const portRangeShape = object({
|
|
8
|
+
first: integerBetween(1, 65_535),
|
|
9
|
+
last: integerBetween(1, 65_535),
|
|
11
10
|
});
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"autoArchive?": "boolean",
|
|
11
|
+
const plansShape = object({
|
|
12
|
+
folder: optional(nonEmptyString),
|
|
13
|
+
autoArchive: optional(boolean),
|
|
16
14
|
});
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"ticketReference?": "'bracketed' | 'bracketedHash'",
|
|
15
|
+
const commitShape = object({
|
|
16
|
+
style: literal("conventionalCommit"),
|
|
17
|
+
ticketReference: optional(literal("bracketed", "bracketedHash")),
|
|
21
18
|
});
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"agentCoauthoring?": "boolean",
|
|
19
|
+
const gitShape = object({
|
|
20
|
+
defaultBranch: optional(nonEmptyString),
|
|
21
|
+
branchNameTemplate: optional(nonEmptyString),
|
|
22
|
+
commit: optional(commitShape),
|
|
23
|
+
agentCoauthoring: optional(boolean),
|
|
28
24
|
});
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"git?": gitSchema,
|
|
25
|
+
const projectConfigShape = object({
|
|
26
|
+
schemaVersion: literal(1),
|
|
27
|
+
cli: optional(nonEmptyString),
|
|
28
|
+
ticketIdPattern: optional(nonEmptyString),
|
|
29
|
+
plans: optional(plansShape),
|
|
30
|
+
portRange: optional(portRangeShape),
|
|
31
|
+
git: optional(gitShape),
|
|
37
32
|
});
|
|
38
33
|
export function resolveProjectConfig(layout) {
|
|
39
34
|
const location = layout.locations[PROJECT_CONFIG_FILENAME];
|
|
@@ -52,11 +47,9 @@ export function readProjectConfig(path) {
|
|
|
52
47
|
return validateProjectConfig(value, path);
|
|
53
48
|
}
|
|
54
49
|
export function validateProjectConfig(value, label) {
|
|
55
|
-
const config =
|
|
56
|
-
if (config
|
|
57
|
-
throw invalidConfig(label, config.
|
|
58
|
-
if (config.cli !== undefined && semver.validRange(config.cli) === null)
|
|
59
|
-
throw invalidConfig(label, `cli is not a valid semver range: ${config.cli}`);
|
|
50
|
+
const config = parseShape(projectConfigShape, value, (detail) => invalidConfig(label, detail));
|
|
51
|
+
if (config.cli !== undefined && !isValidVersionRange(config.cli))
|
|
52
|
+
throw invalidConfig(label, `cli is not a supported version range: ${config.cli}`);
|
|
60
53
|
if (config.ticketIdPattern !== undefined)
|
|
61
54
|
assertValidPattern(config.ticketIdPattern, label);
|
|
62
55
|
if (config.portRange !== undefined)
|
package/dist/project-layout.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommandContext } from "./context.js";
|
|
2
|
-
export declare const ITEM_NAMES: readonly [".alignfirst.json", ".alignfirst
|
|
2
|
+
export declare const ITEM_NAMES: readonly [".alignfirst.json", ".alignfirst-instructions", "DEVELOPERS.md", "docs", ".plans", "_aligndev"];
|
|
3
3
|
export type ItemName = (typeof ITEM_NAMES)[number];
|
|
4
4
|
export type Flag = boolean | "auto";
|
|
5
5
|
export interface ProjectLayout {
|
|
@@ -10,8 +10,8 @@ export interface CompanionLayout {
|
|
|
10
10
|
/** Absolute. */
|
|
11
11
|
dir: string;
|
|
12
12
|
exists: boolean;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/** The registry key, as written. */
|
|
14
|
+
key: string;
|
|
15
15
|
/** Effective flags. */
|
|
16
16
|
flags: Record<ItemName, Flag>;
|
|
17
17
|
}
|
|
@@ -30,14 +30,42 @@ export declare function renderItemLocation(name: ItemName, location: ItemLocatio
|
|
|
30
30
|
export declare function separateSessionTree(layout: ProjectLayout): string | undefined;
|
|
31
31
|
export interface CompanionRegistration {
|
|
32
32
|
registry: string;
|
|
33
|
-
/** The key added, or the
|
|
33
|
+
/** The key added, or the key already present. */
|
|
34
34
|
key: string;
|
|
35
35
|
added: boolean;
|
|
36
36
|
/** Absolute. */
|
|
37
37
|
dir: string;
|
|
38
|
+
exists: boolean;
|
|
39
|
+
created: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Registers the main worktree of `cwd` with every item on `"auto"`, unless it is registered.
|
|
43
|
+
* Creates the registry when it is missing, and the companion directory with `createDir`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function registerCompanion(cwd: string, home: string, { createDir }: {
|
|
46
|
+
createDir: boolean;
|
|
47
|
+
}): CompanionRegistration;
|
|
48
|
+
export interface CompanionUnregistration {
|
|
49
|
+
registry: string;
|
|
50
|
+
key: string;
|
|
51
|
+
/** Absolute. */
|
|
52
|
+
dir: string;
|
|
53
|
+
/** `undefined` when the directory is missing. */
|
|
54
|
+
empty?: boolean;
|
|
55
|
+
removed: boolean;
|
|
38
56
|
}
|
|
39
57
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
58
|
+
* Removes the registry key of the main worktree of `cwd`. With `removeDir`, also removes its
|
|
59
|
+
* companion directory: a non-empty one requires `force`. Fails before any change.
|
|
42
60
|
*/
|
|
43
|
-
export declare function
|
|
61
|
+
export declare function unregisterCompanion(cwd: string, home: string, { removeDir, force }: {
|
|
62
|
+
removeDir: boolean;
|
|
63
|
+
force: boolean;
|
|
64
|
+
}): CompanionUnregistration;
|
|
65
|
+
/** The registry keys that name no git main worktree: they match nothing. */
|
|
66
|
+
export declare function strayRegistryKeys(home: string): string[];
|
|
67
|
+
/** The companion directories shared by several registry keys, with those keys. */
|
|
68
|
+
export declare function sharedCompanionDirs(home: string): {
|
|
69
|
+
dir: string;
|
|
70
|
+
keys: string[];
|
|
71
|
+
}[];
|
package/dist/project-layout.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { type } from "arktype";
|
|
4
3
|
import { CliError } from "./cli-error.js";
|
|
5
4
|
import { errorMessage } from "./errors.js";
|
|
6
5
|
import { gitOutputOrUndefined } from "./git.js";
|
|
6
|
+
import { object, optional, parseShape, record, shapeError } from "./json-shape.js";
|
|
7
7
|
export const ITEM_NAMES = [
|
|
8
8
|
".alignfirst.json",
|
|
9
|
-
".alignfirst
|
|
9
|
+
".alignfirst-instructions",
|
|
10
10
|
"DEVELOPERS.md",
|
|
11
11
|
"docs",
|
|
12
12
|
".plans",
|
|
@@ -15,20 +15,15 @@ export const ITEM_NAMES = [
|
|
|
15
15
|
// The directory holding the companions and their registry. A symlink there moves them elsewhere.
|
|
16
16
|
const COMPANIONS_ROOT = "~/.alignfirst/companions";
|
|
17
17
|
const REGISTRY_FILE = "registry.json";
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
".
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
".plans?": FLAG,
|
|
26
|
-
"_aligndev?": FLAG,
|
|
27
|
-
});
|
|
28
|
-
const registrySchema = type({
|
|
29
|
-
"+": "reject",
|
|
30
|
-
paths: type.Record("string", flagsSchema),
|
|
18
|
+
const flagsShape = object({
|
|
19
|
+
".alignfirst.json": optional(flag),
|
|
20
|
+
".alignfirst-instructions": optional(flag),
|
|
21
|
+
"DEVELOPERS.md": optional(flag),
|
|
22
|
+
docs: optional(flag),
|
|
23
|
+
".plans": optional(flag),
|
|
24
|
+
_aligndev: optional(flag),
|
|
31
25
|
});
|
|
26
|
+
const registryFileShape = object({ paths: record(flagsShape) });
|
|
32
27
|
export function layoutOf(ctx) {
|
|
33
28
|
ctx.layout ??= resolveProjectLayout(ctx.cwd, ctx.home);
|
|
34
29
|
return ctx.layout;
|
|
@@ -45,13 +40,13 @@ function resolveCompanion(cwd, home) {
|
|
|
45
40
|
if (mainWorktree === undefined)
|
|
46
41
|
return null;
|
|
47
42
|
const realHome = realOrResolved(home);
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
43
|
+
const entry = findEntry(registry, mainWorktree, realHome);
|
|
44
|
+
if (entry === undefined)
|
|
50
45
|
return null;
|
|
51
|
-
const flags =
|
|
52
|
-
assertValidFlags(registry, flags,
|
|
46
|
+
const flags = effectiveFlags(entry);
|
|
47
|
+
assertValidFlags(registry, flags, entry);
|
|
53
48
|
const dir = companionDir(mainWorktree, realHome);
|
|
54
|
-
return { dir, exists: pathExists(dir),
|
|
49
|
+
return { dir, exists: pathExists(dir), key: entry.key, flags };
|
|
55
50
|
}
|
|
56
51
|
function readRegistry(home) {
|
|
57
52
|
const path = registryPath(home);
|
|
@@ -64,9 +59,7 @@ function readRegistry(home) {
|
|
|
64
59
|
catch (error) {
|
|
65
60
|
throw invalidRegistry(path, errorMessage(error));
|
|
66
61
|
}
|
|
67
|
-
const file =
|
|
68
|
-
if (file instanceof type.errors)
|
|
69
|
-
throw invalidRegistry(path, file.summary.split("\n", 1)[0]);
|
|
62
|
+
const file = parseShape(registryFileShape, value, (detail) => invalidRegistry(path, detail));
|
|
70
63
|
const badKey = Object.keys(file.paths).find((key) => !isUserPath(key));
|
|
71
64
|
if (badKey !== undefined)
|
|
72
65
|
throw invalidRegistry(path, `paths key must be an absolute path or start with ~/: ${badKey}`);
|
|
@@ -78,6 +71,11 @@ export function registryPath(home) {
|
|
|
78
71
|
function invalidRegistry(path, detail) {
|
|
79
72
|
return new CliError(`Invalid ${path}: ${detail}`);
|
|
80
73
|
}
|
|
74
|
+
function flag(value, path) {
|
|
75
|
+
if (typeof value !== "boolean" && value !== "auto")
|
|
76
|
+
throw shapeError(path, 'be a boolean or "auto"');
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
81
79
|
function isUserPath(value) {
|
|
82
80
|
return value === "~" || value.startsWith("~/") || isAbsolute(value);
|
|
83
81
|
}
|
|
@@ -96,31 +94,28 @@ function normalizePath(value, realHome) {
|
|
|
96
94
|
function realOrResolved(path) {
|
|
97
95
|
return existsSync(path) ? realpathSync(path) : resolve(path);
|
|
98
96
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.filter((entry) => isSameOrInside(mainWorktree, entry.path))
|
|
103
|
-
.toSorted((left, right) => right.path.length - left.path.length);
|
|
97
|
+
function findEntry(registry, mainWorktree, realHome) {
|
|
98
|
+
const key = Object.keys(registry.paths).find((candidate) => normalizePath(candidate, realHome) === mainWorktree);
|
|
99
|
+
return key === undefined ? undefined : { key, flags: registry.paths[key] };
|
|
104
100
|
}
|
|
105
101
|
function isSameOrInside(path, ancestor) {
|
|
106
102
|
return path === ancestor || path.startsWith(ancestor.endsWith(sep) ? ancestor : ancestor + sep);
|
|
107
103
|
}
|
|
108
|
-
function
|
|
109
|
-
const flagOf = (item) =>
|
|
104
|
+
function effectiveFlags(entry) {
|
|
105
|
+
const flagOf = (item) => entry.flags[item] ?? "auto";
|
|
110
106
|
return {
|
|
111
107
|
".alignfirst.json": flagOf(".alignfirst.json"),
|
|
112
|
-
".alignfirst
|
|
108
|
+
".alignfirst-instructions": flagOf(".alignfirst-instructions"),
|
|
113
109
|
"DEVELOPERS.md": flagOf("DEVELOPERS.md"),
|
|
114
110
|
docs: flagOf("docs"),
|
|
115
111
|
".plans": flagOf(".plans"),
|
|
116
112
|
_aligndev: flagOf("_aligndev"),
|
|
117
113
|
};
|
|
118
114
|
}
|
|
119
|
-
function assertValidFlags(registry, flags,
|
|
115
|
+
function assertValidFlags(registry, flags, entry) {
|
|
120
116
|
if (flags._aligndev !== true || flags[".plans"] !== "auto")
|
|
121
117
|
return;
|
|
122
|
-
|
|
123
|
-
throw invalidRegistry(registry.path, `"_aligndev": true requires ".plans" set to true or false (matching keys: ${keys})`);
|
|
118
|
+
throw invalidRegistry(registry.path, `"_aligndev": true requires ".plans" set to true or false (key: ${entry.key})`);
|
|
124
119
|
}
|
|
125
120
|
function companionDir(mainWorktree, realHome) {
|
|
126
121
|
return join(normalizePath(COMPANIONS_ROOT, realHome), companionName(mainWorktree, realHome));
|
|
@@ -136,7 +131,7 @@ function resolveLocations(cwd, companion) {
|
|
|
136
131
|
const plans = locate(".plans");
|
|
137
132
|
return {
|
|
138
133
|
".alignfirst.json": locate(".alignfirst.json"),
|
|
139
|
-
".alignfirst
|
|
134
|
+
".alignfirst-instructions": locate(".alignfirst-instructions"),
|
|
140
135
|
"DEVELOPERS.md": locate("DEVELOPERS.md"),
|
|
141
136
|
docs: locate("docs"),
|
|
142
137
|
".plans": plans,
|
|
@@ -176,32 +171,111 @@ export function separateSessionTree(layout) {
|
|
|
176
171
|
return sessions.path;
|
|
177
172
|
}
|
|
178
173
|
/**
|
|
179
|
-
* Registers the main worktree of `cwd` with every item on `"auto"`, unless
|
|
180
|
-
*
|
|
174
|
+
* Registers the main worktree of `cwd` with every item on `"auto"`, unless it is registered.
|
|
175
|
+
* Creates the registry when it is missing, and the companion directory with `createDir`.
|
|
181
176
|
*/
|
|
182
|
-
export function
|
|
183
|
-
const mainWorktree =
|
|
184
|
-
if (mainWorktree === undefined)
|
|
185
|
-
throw new CliError("A companion needs a git repository with a main worktree.");
|
|
177
|
+
export function registerCompanion(cwd, home, { createDir }) {
|
|
178
|
+
const mainWorktree = requireMainWorktree(cwd);
|
|
186
179
|
const realHome = realOrResolved(home);
|
|
187
180
|
const registry = readRegistry(home) ?? { path: registryPath(home), paths: {} };
|
|
188
|
-
const
|
|
189
|
-
const key =
|
|
190
|
-
if (match === undefined)
|
|
191
|
-
writeRegistry(registry, key);
|
|
181
|
+
const entry = findEntry(registry, mainWorktree, realHome);
|
|
182
|
+
const key = entry?.key ?? userPathOf(mainWorktree, realHome);
|
|
192
183
|
const dir = companionDir(mainWorktree, realHome);
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
if (entry === undefined) {
|
|
185
|
+
assertOwnCompanionDir(registry, key, dir, realHome);
|
|
186
|
+
writeRegistry(registry.path, { ...registry.paths, [key]: {} });
|
|
187
|
+
}
|
|
188
|
+
const created = createDir && !pathExists(dir);
|
|
189
|
+
if (created)
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
return {
|
|
192
|
+
registry: registry.path,
|
|
193
|
+
key,
|
|
194
|
+
added: entry === undefined,
|
|
195
|
+
dir,
|
|
196
|
+
exists: pathExists(dir),
|
|
197
|
+
created,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function requireMainWorktree(cwd) {
|
|
201
|
+
const mainWorktree = findMainWorktree(cwd);
|
|
202
|
+
if (mainWorktree === undefined)
|
|
203
|
+
throw new CliError("A companion needs a git repository with a main worktree.");
|
|
204
|
+
return mainWorktree;
|
|
195
205
|
}
|
|
196
206
|
function userPathOf(path, realHome) {
|
|
197
207
|
if (path === realHome)
|
|
198
208
|
return "~";
|
|
199
209
|
return isSameOrInside(path, realHome) ? `~/${relative(realHome, path)}` : path;
|
|
200
210
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
211
|
+
/** Two keys collide when their names differ only by a `_` in place of a `/`. */
|
|
212
|
+
function assertOwnCompanionDir(registry, key, dir, realHome) {
|
|
213
|
+
const others = (keysByCompanionDir(registry, realHome).get(dir) ?? []).filter((k) => k !== key);
|
|
214
|
+
if (others.length === 0)
|
|
215
|
+
return;
|
|
216
|
+
throw new CliError(`The companion directory ${dir} is already used by ${others.join(", ")}.`);
|
|
217
|
+
}
|
|
218
|
+
function keysByCompanionDir(registry, realHome) {
|
|
219
|
+
const keysByDir = new Map();
|
|
220
|
+
for (const key of Object.keys(registry.paths)) {
|
|
221
|
+
const dir = companionDir(normalizePath(key, realHome), realHome);
|
|
222
|
+
keysByDir.set(dir, [...(keysByDir.get(dir) ?? []), key]);
|
|
223
|
+
}
|
|
224
|
+
return keysByDir;
|
|
225
|
+
}
|
|
226
|
+
function writeRegistry(path, paths) {
|
|
227
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
228
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
205
229
|
writeFileSync(tmpPath, `${JSON.stringify({ paths }, undefined, 2)}\n`);
|
|
206
|
-
renameSync(tmpPath,
|
|
230
|
+
renameSync(tmpPath, path);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Removes the registry key of the main worktree of `cwd`. With `removeDir`, also removes its
|
|
234
|
+
* companion directory: a non-empty one requires `force`. Fails before any change.
|
|
235
|
+
*/
|
|
236
|
+
export function unregisterCompanion(cwd, home, { removeDir, force }) {
|
|
237
|
+
const mainWorktree = requireMainWorktree(cwd);
|
|
238
|
+
const realHome = realOrResolved(home);
|
|
239
|
+
const path = registryPath(home);
|
|
240
|
+
const registry = readRegistry(home);
|
|
241
|
+
const entry = registry && findEntry(registry, mainWorktree, realHome);
|
|
242
|
+
if (!registry || entry === undefined)
|
|
243
|
+
throw new CliError(`${mainWorktree} is not registered in ${path}.`);
|
|
244
|
+
const dir = companionDir(mainWorktree, realHome);
|
|
245
|
+
const entries = pathExists(dir) ? readdirSync(dir) : undefined;
|
|
246
|
+
if (removeDir)
|
|
247
|
+
assertRemovableDir(registry, entry.key, dir, entries, force, realHome);
|
|
248
|
+
const { [entry.key]: _removed, ...paths } = registry.paths;
|
|
249
|
+
writeRegistry(path, paths);
|
|
250
|
+
const removed = removeDir && entries !== undefined;
|
|
251
|
+
if (removed)
|
|
252
|
+
rmSync(dir, { recursive: true, force: true });
|
|
253
|
+
return { registry: path, key: entry.key, dir, empty: entries && entries.length === 0, removed };
|
|
254
|
+
}
|
|
255
|
+
function assertRemovableDir(registry, key, dir, entries, force, realHome) {
|
|
256
|
+
assertOwnCompanionDir(registry, key, dir, realHome);
|
|
257
|
+
if (force || entries === undefined || entries.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
throw new CliError(`The companion directory ${dir} is not empty: ${entries.sort().join(", ")}. ` +
|
|
260
|
+
"Add --force to delete it.");
|
|
261
|
+
}
|
|
262
|
+
/** The registry keys that name no git main worktree: they match nothing. */
|
|
263
|
+
export function strayRegistryKeys(home) {
|
|
264
|
+
const registry = readRegistry(home);
|
|
265
|
+
if (registry === undefined)
|
|
266
|
+
return [];
|
|
267
|
+
const realHome = realOrResolved(home);
|
|
268
|
+
return Object.keys(registry.paths).filter((key) => {
|
|
269
|
+
const path = normalizePath(key, realHome);
|
|
270
|
+
return !existsSync(path) || findMainWorktree(path) !== path;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/** The companion directories shared by several registry keys, with those keys. */
|
|
274
|
+
export function sharedCompanionDirs(home) {
|
|
275
|
+
const registry = readRegistry(home);
|
|
276
|
+
if (registry === undefined)
|
|
277
|
+
return [];
|
|
278
|
+
return [...keysByCompanionDir(registry, realOrResolved(home))]
|
|
279
|
+
.filter(([, keys]) => keys.length > 1)
|
|
280
|
+
.map(([dir, keys]) => ({ dir, keys }));
|
|
207
281
|
}
|
package/dist/version-guard.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import semver from "semver";
|
|
2
1
|
import { CliError } from "./cli-error.js";
|
|
2
|
+
import { satisfiesRange } from "./version-range.js";
|
|
3
3
|
export function checkCliRange(config, installedVersion, commandArgs) {
|
|
4
4
|
const result = cliRangeResult(config, installedVersion);
|
|
5
5
|
if (result === undefined || result.satisfied)
|
|
@@ -13,5 +13,5 @@ export function cliRangeResult(config, installedVersion) {
|
|
|
13
13
|
const range = config?.cli;
|
|
14
14
|
if (range === undefined)
|
|
15
15
|
return;
|
|
16
|
-
return { range, satisfied:
|
|
16
|
+
return { range, satisfied: satisfiesRange(installedVersion, range) };
|
|
17
17
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function isValidVersionRange(range: string): boolean;
|
|
2
|
+
/** False for a version with a prerelease tag or an invalid range. */
|
|
3
|
+
export declare function satisfiesRange(version: string, range: string): boolean;
|
|
4
|
+
/** True when `version` is above every version the range accepts. */
|
|
5
|
+
export declare function isAheadOfRange(version: string, range: string): boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
2
|
+
// Two-character operators first, so `<=` is not read as `<`.
|
|
3
|
+
const OPERATORS = ["<=", ">=", "<", ">", "=", "~", "^"];
|
|
4
|
+
export function isValidVersionRange(range) {
|
|
5
|
+
return parseRange(range) !== undefined;
|
|
6
|
+
}
|
|
7
|
+
/** False for a version with a prerelease tag or an invalid range. */
|
|
8
|
+
export function satisfiesRange(version, range) {
|
|
9
|
+
const parsed = parseVersion(version);
|
|
10
|
+
const sets = parseRange(range);
|
|
11
|
+
if (parsed === undefined || sets === undefined)
|
|
12
|
+
return false;
|
|
13
|
+
return sets.some((set) => set.every((comparator) => holds(comparator, parsed)));
|
|
14
|
+
}
|
|
15
|
+
/** True when `version` is above every version the range accepts. */
|
|
16
|
+
export function isAheadOfRange(version, range) {
|
|
17
|
+
const parsed = parseVersion(version);
|
|
18
|
+
const sets = parseRange(range);
|
|
19
|
+
if (parsed === undefined || sets === undefined)
|
|
20
|
+
return false;
|
|
21
|
+
return sets.every((set) => set.some((comparator) => exceeds(comparator, parsed)));
|
|
22
|
+
}
|
|
23
|
+
function parseVersion(text) {
|
|
24
|
+
const match = VERSION.exec(text);
|
|
25
|
+
if (match === null)
|
|
26
|
+
return;
|
|
27
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
28
|
+
}
|
|
29
|
+
function parseRange(range) {
|
|
30
|
+
const sets = [];
|
|
31
|
+
for (const part of range.split("||")) {
|
|
32
|
+
const set = parseComparatorSet(part.trim());
|
|
33
|
+
if (set === undefined)
|
|
34
|
+
return;
|
|
35
|
+
sets.push(set);
|
|
36
|
+
}
|
|
37
|
+
return sets;
|
|
38
|
+
}
|
|
39
|
+
function parseComparatorSet(text) {
|
|
40
|
+
if (text === "")
|
|
41
|
+
return;
|
|
42
|
+
const comparators = [];
|
|
43
|
+
for (const token of text.split(/\s+/)) {
|
|
44
|
+
const desugared = parseComparator(token);
|
|
45
|
+
if (desugared === undefined)
|
|
46
|
+
return;
|
|
47
|
+
comparators.push(...desugared);
|
|
48
|
+
}
|
|
49
|
+
return comparators;
|
|
50
|
+
}
|
|
51
|
+
function parseComparator(token) {
|
|
52
|
+
const operator = OPERATORS.find((candidate) => token.startsWith(candidate));
|
|
53
|
+
const version = parseVersion(operator === undefined ? token : token.slice(operator.length));
|
|
54
|
+
if (version === undefined)
|
|
55
|
+
return;
|
|
56
|
+
return desugar(operator ?? "=", version);
|
|
57
|
+
}
|
|
58
|
+
function desugar(operator, version) {
|
|
59
|
+
if (operator === "~") {
|
|
60
|
+
const upper = { major: version.major, minor: version.minor + 1, patch: 0 };
|
|
61
|
+
return [lowerBound(version), { operator: "<", version: upper }];
|
|
62
|
+
}
|
|
63
|
+
if (operator === "^")
|
|
64
|
+
return [lowerBound(version), { operator: "<", version: caretUpper(version) }];
|
|
65
|
+
return [{ operator, version }];
|
|
66
|
+
}
|
|
67
|
+
function lowerBound(version) {
|
|
68
|
+
return { operator: ">=", version };
|
|
69
|
+
}
|
|
70
|
+
function caretUpper({ major, minor, patch }) {
|
|
71
|
+
if (major > 0)
|
|
72
|
+
return { major: major + 1, minor: 0, patch: 0 };
|
|
73
|
+
if (minor > 0)
|
|
74
|
+
return { major: 0, minor: minor + 1, patch: 0 };
|
|
75
|
+
return { major: 0, minor: 0, patch: patch + 1 };
|
|
76
|
+
}
|
|
77
|
+
function holds({ operator, version }, candidate) {
|
|
78
|
+
const order = compareVersions(candidate, version);
|
|
79
|
+
switch (operator) {
|
|
80
|
+
case "<":
|
|
81
|
+
return order < 0;
|
|
82
|
+
case "<=":
|
|
83
|
+
return order <= 0;
|
|
84
|
+
case ">":
|
|
85
|
+
return order > 0;
|
|
86
|
+
case ">=":
|
|
87
|
+
return order >= 0;
|
|
88
|
+
case "=":
|
|
89
|
+
return order === 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function compareVersions(left, right) {
|
|
93
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
94
|
+
}
|
|
95
|
+
/** A set is exceeded when the candidate fails one of its upper bounds on the high side. */
|
|
96
|
+
function exceeds({ operator, version }, candidate) {
|
|
97
|
+
const order = compareVersions(candidate, version);
|
|
98
|
+
if (operator === "<")
|
|
99
|
+
return order >= 0;
|
|
100
|
+
if (operator === "<=" || operator === "=")
|
|
101
|
+
return order > 0;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alignfirst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,13 +35,10 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@alignfirst/docmap": "~0.11.1"
|
|
39
|
-
"arktype": "^2.2.3",
|
|
40
|
-
"semver": "^7.8.5"
|
|
38
|
+
"@alignfirst/docmap": "~0.11.1"
|
|
41
39
|
},
|
|
42
40
|
"devDependencies": {
|
|
43
41
|
"@types/node": "~24.13.3",
|
|
44
|
-
"@types/semver": "~7.8.0",
|
|
45
42
|
"rimraf": "~6.1.3",
|
|
46
43
|
"typescript": "~7.0.2",
|
|
47
44
|
"vitest": "~5.0.1"
|