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 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 add` — Register the current project in the companion registry and create its companion directory.
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.md`, `DEVELOPERS.md`, `docs`, `.plans` and `_aligndev`. A flag is `true`, `false` or `"auto"`.
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 add` in a project to register it. It adds the project's main worktree path with every item on `"auto"`, unless a key already matches it, and creates the companion directory. Set the flags by editing the registry.
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 or one of its ancestors, so every worktree of a project shares one companion. `"~": {}` matches every project under the home directory. For each item, the longest matching key that sets the flag wins, and an unset flag is `"auto"`. A bare repository or a directory outside git has no companion.
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.md` holds free prose for the coding agent: the project instructions a prepared project keeps in its `AGENTS.md`. `alignfirst context` prints it under `# Project Instructions`. It resolves like the other items, so a project copy works too.
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
@@ -68,7 +68,7 @@ Usage:
68
68
  ${ctx.form} docmap [<arguments>]
69
69
  ${ctx.form} conventions
70
70
  ${ctx.form} context
71
- ${ctx.form} companion add
71
+ ${ctx.form} companion register | unregister
72
72
  ${ctx.form} config [--json]
73
73
  ${ctx.form} doctor
74
74
  ${ctx.form} --help
@@ -1,11 +1,15 @@
1
+ import { parseArgs } from "node:util";
1
2
  import { CliError } from "../cli-error.js";
2
- import { parseBareCommandArgs } from "../parse-args.js";
3
- import { addCompanion } from "../project-layout.js";
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 "add":
8
- return runAdd(ctx, rest);
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 add
23
+ ${ctx.form} companion register [--create-dir]
24
+ ${ctx.form} companion unregister [--remove-dir [--force]]
20
25
  `;
21
26
  }
22
- function runAdd(ctx, args) {
23
- const usage = `Usage: ${ctx.form} companion add
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", and creates its companion directory. A project that a key already matches stays as is.
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
- if (parseBareCommandArgs(ctx, args, usage))
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 = addCompanion(ctx.cwd, ctx.home);
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
- ctx.stdout.write(`Companion: ${registration.dir}\n`);
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
+ }
@@ -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 location = layoutOf(ctx).locations[".alignfirst.md"];
21
- if (!location.exists)
21
+ const path = join(layoutOf(ctx).locations[".alignfirst-instructions"].path, "context.md");
22
+ if (!existsSync(path))
22
23
  return;
23
- const content = readInstructions(location.path).trim();
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`);
@@ -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 [file, { level: "ok", text: "none" }];
82
+ return [...registryLines, { level: "ok", text: "none" }];
78
83
  const { companion } = layout;
79
84
  return [
80
- file,
81
- { level: "ok", text: `matched by ${companion.entries.join(", ")}` },
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;
@@ -1,5 +1,5 @@
1
1
  import type { CommandContext } from "./context.js";
2
- export declare const ITEM_NAMES: readonly [".alignfirst.json", ".alignfirst.md", "DEVELOPERS.md", "docs", ".plans", "_aligndev"];
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
- /** Matching keys as written, most specific first. */
14
- entries: string[];
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 most specific key that already matched. */
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
- * Registers the main worktree of `cwd` with every item on `"auto"`, unless a key already matches
41
- * it, and creates its companion directory. Creates the registry when it is missing.
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 addCompanion(cwd: string, home: string): CompanionRegistration;
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
+ }[];
@@ -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.md",
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.md?": FLAG,
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 matches = matchingEntries(registry, mainWorktree, realHome);
49
- if (matches.length === 0)
48
+ const entry = findEntry(registry, mainWorktree, realHome);
49
+ if (entry === undefined)
50
50
  return null;
51
- const flags = mergeFlags(matches);
52
- assertValidFlags(registry, flags, matches);
51
+ const flags = effectiveFlags(entry);
52
+ assertValidFlags(registry, flags, entry);
53
53
  const dir = companionDir(mainWorktree, realHome);
54
- return { dir, exists: pathExists(dir), entries: matches.map((match) => match.key), flags };
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 matchingEntries(registry, mainWorktree, realHome) {
100
- return Object.entries(registry.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);
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 mergeFlags(matches) {
109
- const flagOf = (item) => matches.find((match) => match.flags[item] !== undefined)?.flags[item] ?? "auto";
106
+ function effectiveFlags(entry) {
107
+ const flagOf = (item) => entry.flags[item] ?? "auto";
110
108
  return {
111
109
  ".alignfirst.json": flagOf(".alignfirst.json"),
112
- ".alignfirst.md": flagOf(".alignfirst.md"),
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, matches) {
117
+ function assertValidFlags(registry, flags, entry) {
120
118
  if (flags._aligndev !== true || flags[".plans"] !== "auto")
121
119
  return;
122
- const keys = matches.map((match) => match.key).join(", ");
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.md": locate(".alignfirst.md"),
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 a key already matches
180
- * it, and creates its companion directory. Creates the registry when it is missing.
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 addCompanion(cwd, home) {
183
- const mainWorktree = findMainWorktree(cwd);
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 match = matchingEntries(registry, mainWorktree, realHome)[0];
189
- const key = match?.key ?? userPathOf(mainWorktree, realHome);
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
- mkdirSync(dir, { recursive: true });
194
- return { registry: registry.path, key, added: match === undefined, dir };
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
- function writeRegistry(registry, key) {
202
- mkdirSync(dirname(registry.path), { recursive: true });
203
- const paths = { ...registry.paths, [key]: {} };
204
- const tmpPath = `${registry.path}.${process.pid}.tmp`;
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, registry.path);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.",