alignfirst 0.8.0 → 0.9.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 +11 -23
- 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 +15 -4
- package/dist/project-layout.d.ts +35 -7
- package/dist/project-layout.js +113 -37
- package/package.json +1 -1
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,7 +134,7 @@ 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
|
|
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
|
@@ -8,7 +8,7 @@ import { parseBareCommandArgs } from "../parse-args.js";
|
|
|
8
8
|
import { resolvePlansMode } from "../plans/mode.js";
|
|
9
9
|
import { findStoppedRebase } from "../plans/rebase.js";
|
|
10
10
|
import { PROJECT_CONFIG_FILENAME, resolveProjectConfig, } from "../project-config.js";
|
|
11
|
-
import { registryPath, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
|
|
11
|
+
import { registryPath, sharedCompanionDirs, strayRegistryKeys, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
|
|
12
12
|
import { COMMAND_SKILLS, findInstalledSkill } from "../skills.js";
|
|
13
13
|
import { cliRangeResult } from "../version-guard.js";
|
|
14
14
|
export function runDoctor(ctx, args) {
|
|
@@ -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;
|
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,4 +1,4 @@
|
|
|
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
3
|
import { type } from "arktype";
|
|
4
4
|
import { CliError } from "./cli-error.js";
|
|
@@ -6,7 +6,7 @@ import { errorMessage } from "./errors.js";
|
|
|
6
6
|
import { gitOutputOrUndefined } from "./git.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",
|
|
@@ -19,7 +19,7 @@ const FLAG = "boolean | 'auto'";
|
|
|
19
19
|
const flagsSchema = type({
|
|
20
20
|
"+": "reject",
|
|
21
21
|
".alignfirst.json?": FLAG,
|
|
22
|
-
".alignfirst
|
|
22
|
+
".alignfirst-instructions?": FLAG,
|
|
23
23
|
"DEVELOPERS.md?": FLAG,
|
|
24
24
|
"docs?": FLAG,
|
|
25
25
|
".plans?": FLAG,
|
|
@@ -45,13 +45,13 @@ function resolveCompanion(cwd, home) {
|
|
|
45
45
|
if (mainWorktree === undefined)
|
|
46
46
|
return null;
|
|
47
47
|
const realHome = realOrResolved(home);
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
48
|
+
const entry = findEntry(registry, mainWorktree, realHome);
|
|
49
|
+
if (entry === undefined)
|
|
50
50
|
return null;
|
|
51
|
-
const flags =
|
|
52
|
-
assertValidFlags(registry, flags,
|
|
51
|
+
const flags = effectiveFlags(entry);
|
|
52
|
+
assertValidFlags(registry, flags, entry);
|
|
53
53
|
const dir = companionDir(mainWorktree, realHome);
|
|
54
|
-
return { dir, exists: pathExists(dir),
|
|
54
|
+
return { dir, exists: pathExists(dir), key: entry.key, flags };
|
|
55
55
|
}
|
|
56
56
|
function readRegistry(home) {
|
|
57
57
|
const path = registryPath(home);
|
|
@@ -96,31 +96,28 @@ function normalizePath(value, realHome) {
|
|
|
96
96
|
function realOrResolved(path) {
|
|
97
97
|
return existsSync(path) ? realpathSync(path) : resolve(path);
|
|
98
98
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.filter((entry) => isSameOrInside(mainWorktree, entry.path))
|
|
103
|
-
.toSorted((left, right) => right.path.length - left.path.length);
|
|
99
|
+
function findEntry(registry, mainWorktree, realHome) {
|
|
100
|
+
const key = Object.keys(registry.paths).find((candidate) => normalizePath(candidate, realHome) === mainWorktree);
|
|
101
|
+
return key === undefined ? undefined : { key, flags: registry.paths[key] };
|
|
104
102
|
}
|
|
105
103
|
function isSameOrInside(path, ancestor) {
|
|
106
104
|
return path === ancestor || path.startsWith(ancestor.endsWith(sep) ? ancestor : ancestor + sep);
|
|
107
105
|
}
|
|
108
|
-
function
|
|
109
|
-
const flagOf = (item) =>
|
|
106
|
+
function effectiveFlags(entry) {
|
|
107
|
+
const flagOf = (item) => entry.flags[item] ?? "auto";
|
|
110
108
|
return {
|
|
111
109
|
".alignfirst.json": flagOf(".alignfirst.json"),
|
|
112
|
-
".alignfirst
|
|
110
|
+
".alignfirst-instructions": flagOf(".alignfirst-instructions"),
|
|
113
111
|
"DEVELOPERS.md": flagOf("DEVELOPERS.md"),
|
|
114
112
|
docs: flagOf("docs"),
|
|
115
113
|
".plans": flagOf(".plans"),
|
|
116
114
|
_aligndev: flagOf("_aligndev"),
|
|
117
115
|
};
|
|
118
116
|
}
|
|
119
|
-
function assertValidFlags(registry, flags,
|
|
117
|
+
function assertValidFlags(registry, flags, entry) {
|
|
120
118
|
if (flags._aligndev !== true || flags[".plans"] !== "auto")
|
|
121
119
|
return;
|
|
122
|
-
|
|
123
|
-
throw invalidRegistry(registry.path, `"_aligndev": true requires ".plans" set to true or false (matching keys: ${keys})`);
|
|
120
|
+
throw invalidRegistry(registry.path, `"_aligndev": true requires ".plans" set to true or false (key: ${entry.key})`);
|
|
124
121
|
}
|
|
125
122
|
function companionDir(mainWorktree, realHome) {
|
|
126
123
|
return join(normalizePath(COMPANIONS_ROOT, realHome), companionName(mainWorktree, realHome));
|
|
@@ -136,7 +133,7 @@ function resolveLocations(cwd, companion) {
|
|
|
136
133
|
const plans = locate(".plans");
|
|
137
134
|
return {
|
|
138
135
|
".alignfirst.json": locate(".alignfirst.json"),
|
|
139
|
-
".alignfirst
|
|
136
|
+
".alignfirst-instructions": locate(".alignfirst-instructions"),
|
|
140
137
|
"DEVELOPERS.md": locate("DEVELOPERS.md"),
|
|
141
138
|
docs: locate("docs"),
|
|
142
139
|
".plans": plans,
|
|
@@ -176,32 +173,111 @@ export function separateSessionTree(layout) {
|
|
|
176
173
|
return sessions.path;
|
|
177
174
|
}
|
|
178
175
|
/**
|
|
179
|
-
* Registers the main worktree of `cwd` with every item on `"auto"`, unless
|
|
180
|
-
*
|
|
176
|
+
* Registers the main worktree of `cwd` with every item on `"auto"`, unless it is registered.
|
|
177
|
+
* Creates the registry when it is missing, and the companion directory with `createDir`.
|
|
181
178
|
*/
|
|
182
|
-
export function
|
|
183
|
-
const mainWorktree =
|
|
184
|
-
if (mainWorktree === undefined)
|
|
185
|
-
throw new CliError("A companion needs a git repository with a main worktree.");
|
|
179
|
+
export function registerCompanion(cwd, home, { createDir }) {
|
|
180
|
+
const mainWorktree = requireMainWorktree(cwd);
|
|
186
181
|
const realHome = realOrResolved(home);
|
|
187
182
|
const registry = readRegistry(home) ?? { path: registryPath(home), paths: {} };
|
|
188
|
-
const
|
|
189
|
-
const key =
|
|
190
|
-
if (match === undefined)
|
|
191
|
-
writeRegistry(registry, key);
|
|
183
|
+
const entry = findEntry(registry, mainWorktree, realHome);
|
|
184
|
+
const key = entry?.key ?? userPathOf(mainWorktree, realHome);
|
|
192
185
|
const dir = companionDir(mainWorktree, realHome);
|
|
193
|
-
|
|
194
|
-
|
|
186
|
+
if (entry === undefined) {
|
|
187
|
+
assertOwnCompanionDir(registry, key, dir, realHome);
|
|
188
|
+
writeRegistry(registry.path, { ...registry.paths, [key]: {} });
|
|
189
|
+
}
|
|
190
|
+
const created = createDir && !pathExists(dir);
|
|
191
|
+
if (created)
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
return {
|
|
194
|
+
registry: registry.path,
|
|
195
|
+
key,
|
|
196
|
+
added: entry === undefined,
|
|
197
|
+
dir,
|
|
198
|
+
exists: pathExists(dir),
|
|
199
|
+
created,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function requireMainWorktree(cwd) {
|
|
203
|
+
const mainWorktree = findMainWorktree(cwd);
|
|
204
|
+
if (mainWorktree === undefined)
|
|
205
|
+
throw new CliError("A companion needs a git repository with a main worktree.");
|
|
206
|
+
return mainWorktree;
|
|
195
207
|
}
|
|
196
208
|
function userPathOf(path, realHome) {
|
|
197
209
|
if (path === realHome)
|
|
198
210
|
return "~";
|
|
199
211
|
return isSameOrInside(path, realHome) ? `~/${relative(realHome, path)}` : path;
|
|
200
212
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
213
|
+
/** Two keys collide when their names differ only by a `_` in place of a `/`. */
|
|
214
|
+
function assertOwnCompanionDir(registry, key, dir, realHome) {
|
|
215
|
+
const others = (keysByCompanionDir(registry, realHome).get(dir) ?? []).filter((k) => k !== key);
|
|
216
|
+
if (others.length === 0)
|
|
217
|
+
return;
|
|
218
|
+
throw new CliError(`The companion directory ${dir} is already used by ${others.join(", ")}.`);
|
|
219
|
+
}
|
|
220
|
+
function keysByCompanionDir(registry, realHome) {
|
|
221
|
+
const keysByDir = new Map();
|
|
222
|
+
for (const key of Object.keys(registry.paths)) {
|
|
223
|
+
const dir = companionDir(normalizePath(key, realHome), realHome);
|
|
224
|
+
keysByDir.set(dir, [...(keysByDir.get(dir) ?? []), key]);
|
|
225
|
+
}
|
|
226
|
+
return keysByDir;
|
|
227
|
+
}
|
|
228
|
+
function writeRegistry(path, paths) {
|
|
229
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
230
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
205
231
|
writeFileSync(tmpPath, `${JSON.stringify({ paths }, undefined, 2)}\n`);
|
|
206
|
-
renameSync(tmpPath,
|
|
232
|
+
renameSync(tmpPath, path);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Removes the registry key of the main worktree of `cwd`. With `removeDir`, also removes its
|
|
236
|
+
* companion directory: a non-empty one requires `force`. Fails before any change.
|
|
237
|
+
*/
|
|
238
|
+
export function unregisterCompanion(cwd, home, { removeDir, force }) {
|
|
239
|
+
const mainWorktree = requireMainWorktree(cwd);
|
|
240
|
+
const realHome = realOrResolved(home);
|
|
241
|
+
const path = registryPath(home);
|
|
242
|
+
const registry = readRegistry(home);
|
|
243
|
+
const entry = registry && findEntry(registry, mainWorktree, realHome);
|
|
244
|
+
if (!registry || entry === undefined)
|
|
245
|
+
throw new CliError(`${mainWorktree} is not registered in ${path}.`);
|
|
246
|
+
const dir = companionDir(mainWorktree, realHome);
|
|
247
|
+
const entries = pathExists(dir) ? readdirSync(dir) : undefined;
|
|
248
|
+
if (removeDir)
|
|
249
|
+
assertRemovableDir(registry, entry.key, dir, entries, force, realHome);
|
|
250
|
+
const { [entry.key]: _removed, ...paths } = registry.paths;
|
|
251
|
+
writeRegistry(path, paths);
|
|
252
|
+
const removed = removeDir && entries !== undefined;
|
|
253
|
+
if (removed)
|
|
254
|
+
rmSync(dir, { recursive: true, force: true });
|
|
255
|
+
return { registry: path, key: entry.key, dir, empty: entries && entries.length === 0, removed };
|
|
256
|
+
}
|
|
257
|
+
function assertRemovableDir(registry, key, dir, entries, force, realHome) {
|
|
258
|
+
assertOwnCompanionDir(registry, key, dir, realHome);
|
|
259
|
+
if (force || entries === undefined || entries.length === 0)
|
|
260
|
+
return;
|
|
261
|
+
throw new CliError(`The companion directory ${dir} is not empty: ${entries.sort().join(", ")}. ` +
|
|
262
|
+
"Add --force to delete it.");
|
|
263
|
+
}
|
|
264
|
+
/** The registry keys that name no git main worktree: they match nothing. */
|
|
265
|
+
export function strayRegistryKeys(home) {
|
|
266
|
+
const registry = readRegistry(home);
|
|
267
|
+
if (registry === undefined)
|
|
268
|
+
return [];
|
|
269
|
+
const realHome = realOrResolved(home);
|
|
270
|
+
return Object.keys(registry.paths).filter((key) => {
|
|
271
|
+
const path = normalizePath(key, realHome);
|
|
272
|
+
return !existsSync(path) || findMainWorktree(path) !== path;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/** The companion directories shared by several registry keys, with those keys. */
|
|
276
|
+
export function sharedCompanionDirs(home) {
|
|
277
|
+
const registry = readRegistry(home);
|
|
278
|
+
if (registry === undefined)
|
|
279
|
+
return [];
|
|
280
|
+
return [...keysByCompanionDir(registry, realOrResolved(home))]
|
|
281
|
+
.filter(([, keys]) => keys.length > 1)
|
|
282
|
+
.map(([dir, keys]) => ({ dir, keys }));
|
|
207
283
|
}
|